mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 19:01:14 +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
518 lines
14 KiB
Swift
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?
|
|
}
|