Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public enum SettingsLinkPlace: Equatable, Sendable {
case voice
/// Happy Cloud's servers.
case servers
/// Read-only Happy Cloud compute pools and machines.
case computes
/// The page in the web app, in the system browser.
case web(URL)
/// A Happy Cloud server's AI providers: the list, the chooser or one
Expand All @@ -34,6 +36,7 @@ public enum SettingsLinkPlace: Equatable, Sendable {
// Reading this phone's files is the desktop's: here, the chooser.
case .providersImport:
return cloud ? .providers(.providersAdd) : HappyCloud.webSettings(server: server, page: page).map(SettingsLinkPlace.web)
case .computes: return cloud ? .computes : nil
default: return HappyCloud.webSettings(server: server, page: page).map(SettingsLinkPlace.web)
}
}
Expand Down
104 changes: 104 additions & 0 deletions packages/happy-ios/Sources/HappyCore/Agents/CloudCompute.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Foundation

/// Monitoring snapshots from `/v0/compute`. These types expose no node
/// management operations. Slots are configured capacity, not free resources.
public struct ComputeCapacity: Decodable, Equatable, Sendable {
public let used: Int
public let total: Int?

enum CodingKeys: String, CodingKey { case used, total }
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
used = try c.decode(Int.self, forKey: .used)
total = try c.decode(Int?.self, forKey: .total)
guard used >= 0, total.map({ $0 >= 0 }) ?? true else {
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Negative sandbox capacity"))
}
}
}

public struct ComputePool: Decodable, Equatable, Sendable {
public struct Counts: Decodable, Equatable, Sendable {
public let total: Int
public let online: Int
public let offline: Int
public let draining: Int
public let enrolling: Int
public let revoked: Int
}

public let id: String
public let name: String
public let kind: String
public let isDefault: Bool
public let nodeCounts: Counts
public let sandboxCapacity: ComputeCapacity

enum CodingKeys: String, CodingKey {
case id, name, kind, nodeCounts, sandboxCapacity
case isDefault = "default"
}
}

public struct ComputeNode: Decodable, Equatable, Sendable {
/// Startup host totals. Missing facts remain unknown, including on B
/// servers, which omit `host` altogether.
public struct Host: Decodable, Equatable, Sendable {
public let cpuCores: Double?
public let totalMemoryBytes: Double?
public let totalDiskBytes: Double?
}

/// Durable placements, including idle/reserved sandboxes; not processes.
public struct Placement: Decodable, Equatable, Sendable {
public let sandboxId: String
public let agentId: String
public let agentTitle: String?
}

public let id: String
public let pool: String
public let name: String?
public let hostname: String?
public let os: String?
public let arch: String?
public let version: String?
public let osVersion: String?
public let shell: String?
public let sandboxError: String?
public let status: String
public let since: Double?
public let enrolledAt: Double?
public let lastSeenAt: Double?
public let backend: String?
public let capabilities: [String: String]?
public let features: [String: Bool]?
public let sandboxCapacity: ComputeCapacity
public let host: Host?
public let activeSandboxes: [Placement]

public var displayName: String {
[name, hostname].compactMap { $0 }.first { !$0.isEmpty } ?? id
}
}

public struct ComputePoolDetail: Decodable, Equatable, Sendable {
public let pool: ComputePool
public let nodes: [ComputeNode]
}

extension AgentClient {
public func computePools() async throws -> [ComputePool] {
struct Response: Decodable { let pools: [ComputePool] }
return try await decode(Response.self, "GET", "/v0/compute/pools").pools
}

public func computePool(_ id: String) async throws -> ComputePoolDetail {
try await decode(ComputePoolDetail.self, "GET", "/v0/compute/pools/\(Self.escape(id))")
}

public func computeNode(_ id: String) async throws -> ComputeNode {
struct Response: Decodable { let node: ComputeNode }
return try await decode(Response.self, "GET", "/v0/compute/nodes/\(Self.escape(id))").node
}
}
113 changes: 113 additions & 0 deletions packages/happy-ios/Sources/HappyCore/Agents/ComputeMonitor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import Foundation

/// One visible read-only monitoring page. Cancellation and authorization
/// are checked again after every response, even if a transport ignores cancellation.
@MainActor
public final class ComputeMonitor {
public enum Page: Equatable, Sendable {
case pools
case pool(String)
case node(String)
}
public enum Snapshot: Equatable, Sendable {
case pools([ComputePool])
case pool(ComputePoolDetail)
case node(ComputeNode)
}
public enum Failure: Equatable, Sendable { case unavailable, forbidden, missing, signedOut }

public let page: Page
public private(set) var snapshot: Snapshot?
public private(set) var failure: Failure?
public private(set) var reading: Task<Void, Never>?
public var changed: (() -> Void)?
private let client: AgentClient
private var visible = false
private var allowed = false
private var again = false
private var poll: Task<Void, Never>?

public static let refreshEvents = ["compute.nodes.updated", "compute.pools.updated"]

public init(client: AgentClient, page: Page) {
self.client = client
self.page = page
}

deinit { reading?.cancel(); poll?.cancel() }

public func appear(allowed: Bool, interval: Duration = .seconds(30)) {
visible = true
authorize(allowed)
guard allowed, failure != .forbidden, failure != .signedOut, poll == nil else { return }
poll = Task { [weak self] in
while !Task.isCancelled {
do { try await Task.sleep(for: interval) } catch { return }
self?.refresh()
}
}
}

public func disappear(clear: Bool = false) {
visible = false
cancel()
if clear { snapshot = nil; changed?() }
}

public func authorize(_ allowed: Bool) {
let wasAllowed = self.allowed
self.allowed = allowed
guard allowed else {
cancel()
snapshot = nil
failure = .forbidden
changed?()
return
}
if !wasAllowed { failure = nil }
if visible { refresh() }
}

private func cancel() {
reading?.cancel(); reading = nil
poll?.cancel(); poll = nil
again = false
}

/// Overlapping polling/event reads become at most one follow-up read.
public func refresh() {
guard visible, allowed, failure != .forbidden, failure != .signedOut else { return }
guard reading == nil else { again = true; return }
let client = client, page = page
reading = Task { [weak self] in
do {
let next: Snapshot
switch page {
case .pools: next = .pools(try await client.computePools())
case .pool(let id): next = .pool(try await client.computePool(id))
case .node(let id): next = .node(try await client.computeNode(id))
}
guard !Task.isCancelled, let self, visible, allowed else { return }
snapshot = next
failure = nil
} catch {
guard !Task.isCancelled, let self, visible, allowed else { return }
switch error {
case AgentClientError.revoked:
snapshot = nil; failure = .signedOut; poll?.cancel(); poll = nil
case AgentClientError.status(403, _, _):
snapshot = nil; failure = .forbidden; poll?.cancel(); poll = nil
case AgentClientError.status(404, _, _):
snapshot = nil; failure = .missing
default:
// A transient failure retains the last reading, marked stale.
failure = .unavailable
}
}
guard !Task.isCancelled, let self, visible, allowed else { return }
reading = nil
changed?()
if again { again = false; refresh() }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ public struct Permissions: Decodable, Equatable, Sendable {
/// The team's inference providers; the person's own take `providers.own`.
case providersManage = "providers.manage"
case providersOwn = "providers.own"
/// Read-only Compute pools and node monitoring on this phone.
case poolsManage = "pools.manage"
}

public static let refreshEvents = ["role."]
public static let refreshEvents = ["role.", "member.updated", "member.removed"]
}

/// What a connection lets the person do. A computer, or a server that is
Expand Down
5 changes: 5 additions & 0 deletions packages/happy-ios/Sources/HappyCore/Agents/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,11 @@ public actor Store {
return stream
}

/// Subscription ownership diagnostics: one listener may name several prefixes.
func eventListenerCount(matching prefixes: [String]) -> Int {
Set(prefixes.flatMap { prefixed[$0] ?? [] }).count
}

// MARK: Reads

/// Everything the sidebar is drawn from, read whole. Events that arrive
Expand Down
Loading
Loading