feat(@projects/@cocottetech): add cockpit and content asset endpoints

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-08 06:11:36 -07:00
parent 5b324d3b0c
commit 432670469d
6 changed files with 177 additions and 2 deletions

View file

@ -0,0 +1,15 @@
import Foundation
/// Read-only cockpit surfaces backed by platform.api:
/// - `/specialists` fleet roster derived from agent_actions
/// - `/surface-metrics` per-surface metric rollup (empty until adapters write rows)
extension Endpoint {
public static func specialists() -> Endpoint {
.crudList("specialists")
}
public static func surfaceMetrics() -> Endpoint {
.crudList("surface-metrics")
}
}

View file

@ -7,11 +7,23 @@ import CocottePlatformModels
/// query filters add one here only when the backend supports it.) /// query filters add one here only when the backend supports it.)
extension Endpoint { extension Endpoint {
public static func contentAssets() -> Endpoint { /// List assets, newest first. `limit` caps to the most-recent N server-side
.crudList("content-assets") /// the cockpit library live-polls, so it asks for a bounded slice rather than
/// the whole (eventually tens-of-thousands-row) library every few seconds.
public static func contentAssets(limit: Int? = nil) -> Endpoint {
var query: [String: String] = [:]
if let limit { query["limit"] = String(limit) }
return .crudList("content-assets", queryParams: query)
} }
public static func contentAsset(id: UUID) -> Endpoint { public static func contentAsset(id: UUID) -> Endpoint {
.crudGet("content-assets", id: id) .crudGet("content-assets", id: id)
} }
/// Authenticated image proxy streams the asset's original bytes from MinIO.
/// The object key is resolved server-side from the tenant-scoped row; the
/// client only supplies the asset id.
public static func contentAssetImage(id: UUID) -> Endpoint {
Endpoint(path: "/content-assets/\(id.uuidString)/image", method: .get)
}
} }

View file

@ -14,4 +14,74 @@ extension Endpoint {
public static func contentDrop(id: UUID) -> Endpoint { public static func contentDrop(id: UUID) -> Endpoint {
.crudGet("content-drops", id: id) .crudGet("content-drops", id: id)
} }
/// Create a drop. (platform-api `POST content-drops`, via `CrudControllerBase`.)
/// Legs are not part of the create payload they live in `content_drop_legs`
/// and are fanned out separately; assets link via `addDropAsset`.
public static func createContentDrop(_ body: CreateContentDropBody) -> Endpoint {
.crudCreate("content-drops", body: body)
}
/// Link an existing content asset to a drop.
/// (platform-api `POST content-drops/:id/assets`.)
public static func addDropAsset(dropId: UUID, _ body: AddDropAssetBody) -> Endpoint {
Endpoint(path: "/content-drops/\(dropId.uuidString)/assets", method: .post, body: body)
}
}
public struct CreateContentDropBody: Encodable, Sendable {
public let userId: UUID
public let orgId: UUID?
public let title: String
public let arc: String
public let state: ContentDropState
public let clusterSource: String
public let dropAt: Date
public let createdBySpecialist: String
enum CodingKeys: String, CodingKey {
case userId = "user_id"
case orgId = "org_id"
case title
case arc
case state
case clusterSource = "cluster_source"
case dropAt = "drop_at"
case createdBySpecialist = "created_by_specialist"
}
public init(
userId: UUID,
orgId: UUID?,
title: String,
arc: String,
state: ContentDropState,
clusterSource: String,
dropAt: Date,
createdBySpecialist: String
) {
self.userId = userId
self.orgId = orgId
self.title = title
self.arc = arc
self.state = state
self.clusterSource = clusterSource
self.dropAt = dropAt
self.createdBySpecialist = createdBySpecialist
}
}
public struct AddDropAssetBody: Encodable, Sendable {
public let assetId: UUID
public let sortOrder: Int
enum CodingKeys: String, CodingKey {
case assetId = "asset_id"
case sortOrder = "sort_order"
}
public init(assetId: UUID, sortOrder: Int) {
self.assetId = assetId
self.sortOrder = sortOrder
}
} }

View file

@ -63,6 +63,14 @@ public final class PlatformAPIClient: Sendable {
_ = try await perform(endpoint, scope: scope) _ = try await perform(endpoint, scope: scope)
} }
/// Fetch a raw (non-JSON) response body with full auth + tenancy headers
/// e.g. the content-assets image proxy, which streams image bytes, not JSON.
/// `AsyncImage(url:)` can't hit these routes (it sends no Authorization /
/// `x-user-id`), so the cockpit loads image data through this instead.
public func sendData(_ endpoint: Endpoint, scope: TenantScope) async throws -> Data {
try await perform(endpoint, scope: scope)
}
// MARK: - transport // MARK: - transport
private func perform(_ endpoint: Endpoint, scope: TenantScope) async throws -> Data { private func perform(_ endpoint: Endpoint, scope: TenantScope) async throws -> Data {

View file

@ -0,0 +1,41 @@
import Foundation
/// Fleet-roster row, DERIVED from `agent_actions` (there is no specialist registry
/// table). `trust` is mean action confidence (nil if the specialist has no actions);
/// `mode` is derived from `auto_executed` over the last week. Operational states with
/// no signal (degraded/retired) are never reported.
public struct SpecialistSummary: Codable, Identifiable, Hashable, Sendable {
public var id: String { specialistId }
public let specialistId: String
public let countToday: Int
public let trust: Double?
public let mode: SpecialistMode
enum CodingKeys: String, CodingKey {
case specialistId = "specialist_id"
case countToday = "count_today"
case trust
case mode
}
public init(specialistId: String, countToday: Int, trust: Double?, mode: SpecialistMode) {
self.specialistId = specialistId
self.countToday = countToday
self.trust = trust
self.mode = mode
}
}
/// Derived operating mode. `auto` = acted autonomously this week; `draftOnly` = acted
/// but only via approval; `idle` = no recent actions. Decodes leniently (unknown
/// `.idle`) so a new server-side value never breaks the cockpit.
public enum SpecialistMode: String, Codable, Sendable {
case auto
case draftOnly = "draft_only"
case idle
public init(from decoder: Decoder) throws {
let raw = try decoder.singleValueContainer().decode(String.self)
self = SpecialistMode(rawValue: raw) ?? .idle
}
}

View file

@ -0,0 +1,29 @@
import Foundation
/// Per-surface metric rollup for the Insights tab. Sourced from `surface_metrics`
/// (empty until surface adapters write rows). The kindfield mapping server-side is
/// provisional; `posts` has no metric source yet and arrives as 0.
public struct SurfaceMetricSummary: Codable, Identifiable, Hashable, Sendable {
public var id: String { surface }
public let surface: String
public let posts: Int
public let impressions: Int
public let engagementPct: Double
public let conversions: Int
enum CodingKeys: String, CodingKey {
case surface
case posts
case impressions
case engagementPct = "engagement_pct"
case conversions
}
public init(surface: String, posts: Int, impressions: Int, engagementPct: Double, conversions: Int) {
self.surface = surface
self.posts = posts
self.impressions = impressions
self.engagementPct = engagementPct
self.conversions = conversions
}
}