Files
openclaw/apps/ios/Sources/Design/AgentProModels.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

518 lines
14 KiB
Swift

import Foundation
import OpenClawKit
import OpenClawProtocol
struct AgentOverviewRefreshGate {
private var generation: UInt64 = 0
mutating func begin() -> UInt64 {
self.generation &+= 1
return self.generation
}
func isCurrent(_ generation: UInt64) -> Bool {
self.generation == generation
}
}
enum AgentProValueReader {
static func intValue(_ value: AnyCodable?) -> Int? {
switch value?.value {
case let int as Int: int
case let double as Double where double.isFinite: Int(double)
case let string as String: Int(string)
default: nil
}
}
static func doubleValue(_ value: AnyCodable?) -> Double? {
switch value?.value {
case let double as Double where double.isFinite: double
case let int as Int: Double(int)
case let string as String: Double(string)
default: nil
}
}
}
struct AgentOverviewSnapshot {
let gatewayID: String
let skills: SkillStatusReportLite?
let presence: [PresenceEntry]
let cronStatus: CronStatusLite?
let cronJobs: [CronJob]
let dreaming: DreamingStatusLite?
let dreamDiary: DreamDiaryLite?
let usage: CostUsageSummaryLite?
let agentSkillFilter: [String]?
var hasAnyLiveData: Bool {
self.skills != nil
|| !self.presence.isEmpty
|| self.cronStatus != nil
|| !self.cronJobs.isEmpty
|| self.dreaming != nil
|| self.dreamDiary != nil
|| self.usage != nil
}
}
extension AgentOverviewSnapshot {
static var screenshotFixture: AgentOverviewSnapshot {
let now = Int(Date().timeIntervalSince1970 * 1000)
let daily = CronJob(
id: "release-briefing",
name: "Release briefing",
description: "Summarize mobile release readiness and open risks.",
enabled: true,
deleteafterrun: false,
createdatms: now - 86_400_000 * 12,
updatedatms: now - 3_600_000,
configrevision: "sha256:screenshot-release-briefing",
schedule: AnyCodable([
"kind": AnyCodable("cron"),
"expr": AnyCodable("0 9 * * 1-5"),
"tz": AnyCodable("America/Los_Angeles"),
]),
sessiontarget: AnyCodable("isolated"),
wakemode: AnyCodable("now"),
payload: AnyCodable([
"kind": AnyCodable("agentTurn"),
"message": AnyCodable("Summarize mobile release readiness and open risks."),
"model": AnyCodable("openai/gpt-5.6-sol"),
]),
state: [
"nextRunAtMs": AnyCodable(now + 3_600_000),
"lastRunAtMs": AnyCodable(now - 82_800_000),
"lastStatus": AnyCodable("ok"),
],
nextrunatms: now + 3_600_000,
lastrunatms: now - 82_800_000,
lastrunstatus: AnyCodable("ok"))
let weekly = CronJob(
id: "weekly-project-review",
name: "Weekly project review",
description: "Prepare a concise progress report every Friday.",
enabled: false,
deleteafterrun: false,
createdatms: now - 86_400_000 * 30,
updatedatms: now - 86_400_000,
configrevision: "sha256:screenshot-weekly-review",
schedule: AnyCodable([
"kind": AnyCodable("cron"),
"expr": AnyCodable("30 16 * * 5"),
"tz": AnyCodable("America/Los_Angeles"),
]),
sessiontarget: AnyCodable("isolated"),
wakemode: AnyCodable("now"),
payload: AnyCodable([
"kind": AnyCodable("agentTurn"),
"message": AnyCodable("Prepare the weekly project review."),
]),
state: ["lastStatus": AnyCodable("ok")],
lastrunatms: now - 86_400_000 * 7,
lastrunstatus: AnyCodable("ok"))
return AgentOverviewSnapshot(
gatewayID: ScreenshotFixtureMode.gatewayID,
skills: nil,
presence: [],
cronStatus: CronStatusLite(enabled: true, jobs: 2, nextwakeatms: now + 3_600_000),
cronJobs: [daily, weekly],
dreaming: nil,
dreamDiary: nil,
usage: nil,
agentSkillFilter: nil)
}
}
struct SkillStatusReportLite: Decodable {
let workspaceDir: String?
let managedSkillsDir: String?
let agentId: String?
let agentSkillFilter: [String]?
let skills: [SkillStatusEntryLite]
var totalCount: Int {
self.skills.count
}
var enabledCount: Int {
self.skills.count {
$0.isEnabled
}
}
var blockedCount: Int {
self.skills.count {
$0.blockedByAllowlist == true || $0.blockedByAgentFilter == true
}
}
var missingRequirementCount: Int {
self.skills.count {
$0.hasMissingRequirements
}
}
}
struct SkillStatusEntryLite: Decodable {
let name: String
let description: String?
let source: String?
let filePath: String?
let skillKey: String?
let primaryEnv: String?
let emoji: String?
let homepage: String?
let disabled: Bool?
let blockedByAllowlist: Bool?
let blockedByAgentFilter: Bool?
let missing: SkillStatusMissingLite?
let install: [SkillInstallOptionLite]?
var displayName: String {
if let emoji, !emoji.isEmpty {
return "\(emoji) \(self.name)"
}
return self.name
}
var effectiveSkillKey: String {
let trimmed = (skillKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? self.name : trimmed
}
var isGloballyEnabled: Bool {
self.disabled != true
}
var isEnabled: Bool {
self.disabled != true
&& self.blockedByAllowlist != true
&& self.blockedByAgentFilter != true
}
var hasMissingRequirements: Bool {
guard let missing else { return false }
return !missing.bins.isEmpty
|| !missing.anyBins.isEmpty
|| !missing.env.isEmpty
|| !missing.config.isEmpty
|| !missing.os.isEmpty
}
var missingSummary: String? {
guard let missing else { return nil }
let values = [
missing.bins,
missing.anyBins,
missing.env,
missing.config,
missing.os,
].flatMap(\.self)
return values.isEmpty ? nil : values.prefix(3).joined(separator: ", ")
}
var installSummary: String? {
guard let option = install?.first else { return nil }
return option.label
}
var missingBins: [String] {
guard let missing else { return [] }
return missing.bins + missing.anyBins
}
var homepageURL: URL? {
guard let homepage else { return nil }
return URL(string: homepage)
}
}
struct SkillInstallOptionLite: Decodable {
let id: String?
let kind: String?
let label: String
let bins: [String]?
}
struct SkillUpdateParams: Encodable {
let skillKey: String
var enabled: Bool?
var apiKey: String?
}
struct SkillInstallParams: Encodable {
let name: String
let installId: String
let timeoutMs: Int
}
struct SkillInstallResultLite: Decodable {
let message: String?
}
struct ClawHubSearchParams: Encodable {
let query: String?
let limit: Int
}
struct ClawHubSearchResponseLite: Decodable {
let results: [ClawHubSearchResultLite]
}
struct ClawHubSearchResultLite: Decodable {
let slug: String
let displayName: String
let summary: String?
let version: String?
}
struct ClawHubInstallParams: Encodable {
let source = "clawhub"
let slug: String
}
struct SkillStatusMissingLite: Decodable {
let bins: [String]
let anyBins: [String]
let env: [String]
let config: [String]
let os: [String]
private enum CodingKeys: String, CodingKey {
case bins
case anyBins
case env
case config
case os
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.bins = try container.decode([String].self, forKey: .bins)
self.anyBins = try container.decodeIfPresent([String].self, forKey: .anyBins) ?? []
self.env = try container.decode([String].self, forKey: .env)
self.config = try container.decode([String].self, forKey: .config)
self.os = try container.decodeIfPresent([String].self, forKey: .os) ?? []
}
}
struct CronStatusLite: Decodable {
let enabled: Bool
let jobs: Int
let nextwakeatms: Int?
enum CodingKeys: String, CodingKey {
case enabled
case jobs
case nextwakeatms = "nextWakeAtMs"
}
}
struct CronJobsListLite: Decodable {
let jobs: [CronJob]
let snapshotRevision: String?
let total: Int?
let hasMore: Bool
let nextOffset: Int?
private enum CodingKeys: String, CodingKey {
case jobs
case snapshotRevision
case total
case hasMore
case nextOffset
}
init(
jobs: [CronJob],
snapshotRevision: String? = nil,
total: Int?,
hasMore: Bool,
nextOffset: Int?)
{
self.jobs = jobs
self.snapshotRevision = snapshotRevision
self.total = total
self.hasMore = hasMore
self.nextOffset = nextOffset
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.jobs = try container.decode([CronJob].self, forKey: .jobs)
self.snapshotRevision = try container.decodeIfPresent(String.self, forKey: .snapshotRevision)
self.total = try container.decodeIfPresent(Int.self, forKey: .total)
self.hasMore = try container.decodeIfPresent(Bool.self, forKey: .hasMore) ?? false
self.nextOffset = try container.decodeIfPresent(Int.self, forKey: .nextOffset)
}
}
struct CronJobsSnapshotIdentity: Equatable {
let total: Int?
let revision: String?
}
func cronJobsSnapshotIdentity(page: CronJobsListLite, maximumCount: Int) -> CronJobsSnapshotIdentity? {
guard page.total.map({ (0...maximumCount).contains($0) }) ?? true else { return nil }
let revision = page.snapshotRevision?.trimmingCharacters(in: .whitespacesAndNewlines)
return CronJobsSnapshotIdentity(
total: page.total,
revision: revision?.isEmpty == false ? revision : nil)
}
func nextCronJobsListOffset(page: CronJobsListLite, currentOffset: Int) -> Int? {
guard page.hasMore, let nextOffset = page.nextOffset, nextOffset > currentOffset else { return nil }
return nextOffset
}
struct DreamingStatusEnvelope: Decodable {
let dreaming: DreamingStatusLite?
}
struct DreamingStatusLite: Decodable {
let enabled: Bool
let shortTermCount: Int?
let totalSignalCount: Int?
let promotedToday: Int?
let storeError: String?
let shortTermEntries: [DreamingEntryLite]?
let signalEntries: [DreamingEntryLite]?
let promotedEntries: [DreamingEntryLite]?
let phases: [String: DreamingPhaseStatusLite]?
var nextRunAtMs: Int? {
self.phases?.values
.compactMap(\.nextRunAtMs)
.min()
}
}
struct DreamingEntryLite: Decodable, Identifiable {
let key: String
let path: String
let startLine: Int
let endLine: Int
let snippet: String
let recallCount: Int
let dailyCount: Int
let groundedCount: Int
let totalSignalCount: Int
let lightHits: Int
let remHits: Int
let phaseHitCount: Int
let promotedAt: String?
let lastRecalledAt: String?
var id: String {
"\(self.key):\(self.path):\(self.startLine):\(self.endLine)"
}
}
struct DreamDiaryLite: Decodable {
let agentId: String
let found: Bool
let path: String
let content: String?
let updatedAtMs: Int?
}
struct DreamingPhaseStatusLite: Decodable {
let enabled: Bool?
let cron: String?
let managedCronPresent: Bool?
let nextRunAtMs: Int?
}
struct DreamingPhaseRow: Identifiable {
let id: String
let title: String
let status: DreamingPhaseStatusLite
}
struct ConfigSnapshotLite: Decodable {
let hash: String?
let config: ConfigRootLite?
func agentConfig(id: String) -> AgentConfigLite? {
self.config?.agents?.list?.first { $0.id == id }
}
func effectiveSkillFilter(agentId: String) -> [String]? {
if let agentSkills = agentConfig(id: agentId)?.skills {
return agentSkills
}
return self.config?.agents?.defaults?.skills
}
}
struct ConfigRootLite: Decodable {
let agents: AgentsConfigLite?
}
struct AgentsConfigLite: Decodable {
let defaults: AgentDefaultsConfigLite?
let list: [AgentConfigLite]?
}
struct AgentDefaultsConfigLite: Decodable {
let skills: [String]?
}
struct AgentConfigLite: Decodable {
let id: String
let skills: [String]?
}
struct ConfigPatchParams: Encodable {
let raw: String
let baseHash: String
let replacePaths: [String]?
init(raw: String, baseHash: String, replacePaths: [String]? = nil) {
self.raw = raw
self.baseHash = baseHash
self.replacePaths = replacePaths
}
}
enum SkillMutationError: LocalizedError {
case liveGatewayUnavailable
case missingConfigHash
case invalidPatchPayload
var errorDescription: String? {
switch self {
case .liveGatewayUnavailable:
"Connect a live gateway to edit agent skills."
case .missingConfigHash:
"Config hash missing; refresh and retry."
case .invalidPatchPayload:
"Could not encode the skill config update."
}
}
}
struct CostUsageSummaryLite: Decodable {
let updatedAt: Int?
let days: Int?
let daily: [CostUsageDailyEntryLite]?
let totals: [String: AnyCodable]?
let cacheStatus: [String: AnyCodable]?
var totalCost: Double? {
AgentProValueReader.doubleValue(self.totals?["totalCost"])
}
var totalTokens: Int? {
AgentProValueReader.intValue(self.totals?["totalTokens"])
}
}
struct CostUsageDailyEntryLite: Decodable {
let date: String
let totalTokens: Int?
let totalCost: Double?
}