mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-13 14:12:57 -06:00
aba94bbe0b
* fix(skills): keep ClawHub publisher identity from search through install ClawHub search returns one entry per publisher, so several results can share a slug. Every client collapsed the selection to that bare slug before calling skills.detail and skills.install, and ClawHub answered 409 AMBIGUOUS_SKILL_SLUG with no in-product way forward. searchClawHubSkills now records the publisher-qualified reference once, on the result that carries it, and the Gateway protocol documents it. skills.detail parses the same reference grammar skills.install already accepted, so review and install cannot resolve to different publishers. Control UI carries that one reference through row actions, detail, busy state, and acknowledgement retries, and shows it so otherwise identical rows are distinguishable. Fixes #117633 * fix(apps): send the ClawHub publisher reference from native skill browsers macOS, iOS, and Android read the qualified reference from search results and use it for skills.detail, install, busy state, installed matching, and list identity, so two publishers sharing a slug stay distinct instead of collapsing into one ambiguous request. * fix(skills): refuse external-source skill detail instead of reading a same-slug skill ClawHub has no source-qualified read endpoint, so a skills-sh reference parsed down to its bare slug would have returned a registry skill's card while install resolved the external artifact. Review and install could name different skills. skills.detail now fails closed on any reference that carries a source, and the macOS and AgentPro rows show the publisher reference next to the summary instead of only when a summary is missing, so same-slug rows stay distinguishable. * chore(apps): refresh native i18n source baseline for the skill row references * refactor(skills): drop the unread search-result ownerHandle field installRef is the one reference clients send back, and no client reads the publisher handle separately, so the protocol and Control UI carry one field instead of two. * fix(skills): name the next step when external skill detail is refused Clients that gate install behind a successful review would otherwise see only a refusal, so the error names the direct install path and the CLI equivalent. * fix(macos): use a doc comment on the ClawHub row subtitle swift-format's docComments rule requires doc comments on declarations; the subtitle property carried a regular comment and failed macos-swift. * fix(skills): carry ClawHub trust state to clients that can install Forwarding installRef let clients install the exact publisher the operator picked, including external skills-sh sources. It did not forward the trust state that says ClawHub never scanned that source, so iOS AgentPro — the one surface that installs in a single tap with no review step — could install an unscanned artifact with nothing on screen saying so. The CLI already labels these (docs/clawhub/cli.md, docs/cli/skills.md); native clients could not, because trustState was never on the wire. trustState becomes an optional field on SkillsSearchResultSchema. It is purely additive: older clients ignore an unknown key and the field is absent for registry results, so downgraded readers are unaffected and no protocol version moves. Every client that renders a search row now shows "Not scanned by ClawHub", matching the CLI wording exactly: iOS AgentPro in the row above the install button, macOS and Android beside the review action, and Control UI on the row that explains why review is refused for these sources. Covered by a wire assertion that the state reaches clients for an external source and stays absent for registry rows, plus decode-and-label tests on the shared Swift kit and the Android parser, and a Control UI render assertion. * fix(ui): size the ClawHub detail dialog to a refusal message Refusing detail for an external source made an error-only dialog reachable. The shared preview panel reserves a tall reader height for skill documents, so a two-line refusal rendered in a mostly empty dialog and read as broken rather than deliberate. Found by inspecting the review captures. * revert(ui,apps): drop the ClawHub trust label layer Maintainer product decision: skills.sh runs its own scanners, so OpenClaw does not add a second alert layer in the apps. Removes the label from Control UI, iOS, macOS and Android, and drops the trustState wire field that nothing would render. The CLI keeps its existing label; changing that is a separate call. Publisher identity, the fail-closed detail refusal, and the message-only dialog are unchanged. Splits the oversized skills view test file to satisfy max-lines without a suppression. * test(ui): fix ClawHub skill fixture checks * chore(plugin-sdk): refresh API baseline --------- Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
524 lines
14 KiB
Swift
524 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 installRef: String?
|
|
let displayName: String
|
|
let summary: String?
|
|
let version: String?
|
|
|
|
/// Several publishers can share one slug, so install must send the Gateway-supplied reference.
|
|
var reference: String {
|
|
self.installRef ?? self.slug
|
|
}
|
|
}
|
|
|
|
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?
|
|
}
|