| 1 | /** |
| 2 | * The Marketplace (routes/workspace/marketplace/): what a workspace can |
| 3 | * add today, read against what it has and what its people asked for. |
| 4 | * |
| 5 | * Two kinds of listing can be added now: an agent from the catalog (the |
| 6 | * agents service's role templates) and an integration the connector |
| 7 | * catalog marks available for a workspace. Owners add them; anyone else |
| 8 | * asks, and the agents service keeps the request (`install_requests`). |
| 9 | * Extensions have no listings yet: their tiers are described, not filled. |
| 10 | * |
| 11 | * No Workers or React imports, so it can be tested under Node. |
| 12 | */ |
| 13 | import type { AgentTemplate, ExtensionInstall, ExtensionManifest, InstallRequest, InstallRequestStatus, ListingTier, WorkspaceAgent } from "@g1t/contracts"; |
| 14 | import { type ConnectorView, connectorPath } from "@g1t/contracts/connectors"; |
| 15 | import { listingRef, parseListing } from "@g1t/contracts/marketplace"; |
| 16 | |
| 17 | import type { ConnectedState } from "./connectors"; |
| 18 | |
| 19 | /** The Marketplace's pages, under `/<workspace>/-/marketplace`. */ |
| 20 | export function marketplacePath(slug: string, page: "" | "agents" | "integrations" | "extensions" | "requests" = ""): string { |
| 21 | return `/${slug}/-/marketplace${page ? `/${page}` : ""}`; |
| 22 | } |
| 23 | |
| 24 | /** One role's page in the agent catalog. */ |
| 25 | export function catalogAgentPath(slug: string, template: string): string { |
| 26 | return `/${slug}/-/marketplace/agents/${template}`; |
| 27 | } |
| 28 | |
| 29 | /** Where an owner hires an agent into a role: the new-agent form, with the role chosen. */ |
| 30 | export function hirePath(slug: string, template: string): string { |
| 31 | return `/${slug}/-/agents/new?template=${encodeURIComponent(template)}`; |
| 32 | } |
| 33 | |
| 34 | /** Who stands behind a listing, in a word and a sentence. */ |
| 35 | export const TIERS: Record<ListingTier, { label: string; about: string }> = { |
| 36 | official: { label: "Official", about: "Built and supported by g1t." }, |
| 37 | verified: { label: "Verified", about: "From a reviewed publisher, whose code and permissions are checked before it is listed." }, |
| 38 | community: { label: "Community", about: "From anyone, shared from a public repository. Pages run sandboxed; server code waits for an isolated sandbox." }, |
| 39 | internal: { label: "Internal", about: "Built by your own people and agents, and seen only by your workspace." }, |
| 40 | }; |
| 41 | |
| 42 | /** Whether `request` is `username`'s and still waiting. */ |
| 43 | function openFor(request: InstallRequest, ref: string, username: string): boolean { |
| 44 | return request.status === "open" && request.listing === ref && request.requested_by.toLowerCase() === username.toLowerCase(); |
| 45 | } |
| 46 | |
| 47 | /** Open requests for `ref`. */ |
| 48 | function openCount(requests: InstallRequest[], ref: string): number { |
| 49 | return requests.filter((request) => request.status === "open" && request.listing === ref).length; |
| 50 | } |
| 51 | |
| 52 | /** A role in the agent catalog, with what the workspace has of it. */ |
| 53 | export type AgentListing = { |
| 54 | ref: string; |
| 55 | template: AgentTemplate; |
| 56 | /** The workspace's agents hired into this role, not archived. */ |
| 57 | hired: Pick<WorkspaceAgent, "id" | "handle" | "display_name">[]; |
| 58 | /** The viewer asked for it, and an owner hasn't answered. */ |
| 59 | requested: boolean; |
| 60 | /** Open requests for it: everyone's for an owner, the viewer's own otherwise. */ |
| 61 | waiting: number; |
| 62 | }; |
| 63 | |
| 64 | /** |
| 65 | * Every role in the catalog, in the order the agents service gives them, |
| 66 | * with who has been hired into each. `agents` null: the agents service |
| 67 | * could not say, so nobody counts as hired. |
| 68 | */ |
| 69 | export function agentListings( |
| 70 | templates: AgentTemplate[], |
| 71 | agents: Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "template" | "archived_at">[] | null, |
| 72 | requests: InstallRequest[], |
| 73 | username: string, |
| 74 | ): AgentListing[] { |
| 75 | return templates.map((template) => { |
| 76 | const ref = listingRef("agent", template.id); |
| 77 | return { |
| 78 | ref, |
| 79 | template, |
| 80 | hired: (agents ?? []) |
| 81 | .filter((agent) => agent.template === template.id && !agent.archived_at) |
| 82 | .map(({ id, handle, display_name }) => ({ id, handle, display_name })), |
| 83 | requested: requests.some((request) => openFor(request, ref, username)), |
| 84 | waiting: openCount(requests, ref), |
| 85 | }; |
| 86 | }); |
| 87 | } |
| 88 | |
| 89 | /** An integration a workspace can connect, with whether it has. */ |
| 90 | export type IntegrationListing = { |
| 91 | ref: string; |
| 92 | view: ConnectorView; |
| 93 | /** How it is doing, when it is connected. */ |
| 94 | connected: ConnectedState | null; |
| 95 | /** Where an owner connects it, or manages it once connected. */ |
| 96 | href: string | null; |
| 97 | requested: boolean; |
| 98 | waiting: number; |
| 99 | }; |
| 100 | |
| 101 | /** |
| 102 | * The integrations a workspace can connect today, connected ones first, |
| 103 | * each in catalog order. `connected` comes from the integrations service |
| 104 | * and the GitHub App (lib/connected.server.ts). |
| 105 | */ |
| 106 | export function integrationListings( |
| 107 | views: ConnectorView[], |
| 108 | connected: Record<string, ConnectedState>, |
| 109 | requests: InstallRequest[], |
| 110 | username: string, |
| 111 | slug: string, |
| 112 | ): IntegrationListing[] { |
| 113 | const listings = views |
| 114 | .filter((view) => view.status === "available") |
| 115 | .map((view) => { |
| 116 | const ref = listingRef("integration", view.id); |
| 117 | const state = connected[view.id] ?? null; |
| 118 | const setup = view.href ? connectorPath(view.href, slug) : null; |
| 119 | return { |
| 120 | ref, |
| 121 | view, |
| 122 | connected: state, |
| 123 | href: state?.manage ?? setup, |
| 124 | requested: requests.some((request) => openFor(request, ref, username)), |
| 125 | waiting: openCount(requests, ref), |
| 126 | }; |
| 127 | }); |
| 128 | return [...listings.filter((l) => l.connected), ...listings.filter((l) => !l.connected)]; |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * The integrations the catalog lists as coming: a workspace's, then those |
| 133 | * each person connects for themselves (a calendar, a mailbox), once each. |
| 134 | */ |
| 135 | export function comingIntegrations(views: ConnectorView[], personal: ConnectorView[] = []): ConnectorView[] { |
| 136 | const workspace = views.filter((view) => view.status === "soon"); |
| 137 | const seen = new Set(workspace.map((view) => view.id)); |
| 138 | const own = personal.filter((view) => view.status === "soon" && !seen.has(view.id) && !views.some((v) => v.id === view.id)); |
| 139 | return [...workspace, ...own]; |
| 140 | } |
| 141 | |
| 142 | /** An extension in the Marketplace, with whether the workspace has it. */ |
| 143 | export type ExtensionListing = { |
| 144 | ref: string; |
| 145 | manifest: ExtensionManifest; |
| 146 | install: ExtensionInstall | null; |
| 147 | requested: boolean; |
| 148 | waiting: number; |
| 149 | }; |
| 150 | |
| 151 | /** Every extension listed, published ones first, each with its install. */ |
| 152 | export function extensionListings(manifests: ExtensionManifest[], installs: ExtensionInstall[], requests: InstallRequest[], username: string): ExtensionListing[] { |
| 153 | const listings = manifests.map((manifest) => { |
| 154 | const ref = listingRef("extension", manifest.id); |
| 155 | return { |
| 156 | ref, |
| 157 | manifest, |
| 158 | install: installs.find((install) => install.listing === ref) ?? null, |
| 159 | requested: requests.some((request) => openFor(request, ref, username)), |
| 160 | waiting: openCount(requests, ref), |
| 161 | }; |
| 162 | }); |
| 163 | return [...listings.filter((l) => l.manifest.status === "available"), ...listings.filter((l) => l.manifest.status !== "available")]; |
| 164 | } |
| 165 | |
| 166 | /** One extension's page in the Marketplace. */ |
| 167 | export function extensionPath(slug: string, id: string): string { |
| 168 | return `/${slug}/-/marketplace/extensions/${id}`; |
| 169 | } |
| 170 | |
| 171 | /** Where an extension runs, in words. */ |
| 172 | export function runtimeWords(manifest: Pick<ExtensionManifest, "runtime" | "publisher">): string { |
| 173 | return manifest.runtime === "hosted" ? `Runs on g1t` : `Runs on ${manifest.publisher.name}'s servers`; |
| 174 | } |
| 175 | |
| 176 | /** Whether an integration listing matches what someone typed: its name, category words, capabilities or keywords. */ |
| 177 | export function integrationMatches(listing: IntegrationListing, query: string): boolean { |
| 178 | const wanted = query.trim().toLowerCase(); |
| 179 | if (!wanted) return true; |
| 180 | const { view } = listing; |
| 181 | return [view.name, view.description, view.category, ...view.capabilities, ...view.keywords].some((word) => word.toLowerCase().includes(wanted)); |
| 182 | } |
| 183 | |
| 184 | /** The requests still waiting on an owner. */ |
| 185 | export function openRequests(requests: InstallRequest[]): InstallRequest[] { |
| 186 | return requests.filter((request) => request.status === "open"); |
| 187 | } |
| 188 | |
| 189 | /** Where an owner goes to add what a request asks for: the hire form, or the integration's setup page. */ |
| 190 | export function addPath(request: Pick<InstallRequest, "listing">, slug: string, views: ConnectorView[]): string | null { |
| 191 | const parsed = parseListing(request.listing); |
| 192 | if (!parsed) return null; |
| 193 | if (parsed.kind === "agent") return hirePath(slug, parsed.id); |
| 194 | if (parsed.kind === "extension") return extensionPath(slug, parsed.id); |
| 195 | const view = views.find((v) => v.id === parsed.id); |
| 196 | return view?.href ? connectorPath(view.href, slug) : null; |
| 197 | } |
| 198 | |
| 199 | /** What the Marketplace's forms ask for, read from one. */ |
| 200 | export type MarketplaceForm = |
| 201 | | { intent: "request"; listing: string; note: string | null } |
| 202 | | { intent: "resolve"; id: string; status: Exclude<InstallRequestStatus, "open"> } |
| 203 | | { intent: "install"; extension: string } |
| 204 | | { intent: "switch"; listing: string; enabled: boolean } |
| 205 | | { intent: "uninstall"; listing: string }; |
| 206 | |
| 207 | /** A Marketplace form's request; null when it asks for nothing that makes sense. */ |
| 208 | export function marketplaceForm(form: FormData): MarketplaceForm | null { |
| 209 | const intent = form.get("intent"); |
| 210 | if (intent === "request") { |
| 211 | const listing = String(form.get("listing") ?? ""); |
| 212 | if (!parseListing(listing)) return null; |
| 213 | const note = String(form.get("note") ?? "").trim(); |
| 214 | return { intent, listing, note: note || null }; |
| 215 | } |
| 216 | if (intent === "resolve") { |
| 217 | const id = String(form.get("id") ?? ""); |
| 218 | const status = form.get("status"); |
| 219 | if (!id || (status !== "done" && status !== "declined")) return null; |
| 220 | return { intent, id, status }; |
| 221 | } |
| 222 | const listing = String(form.get("listing") ?? ""); |
| 223 | const extension = parseListing(listing)?.kind === "extension" ? listing : null; |
| 224 | if (intent === "install") { |
| 225 | const id = String(form.get("extension") ?? ""); |
| 226 | return /^[a-z][a-z0-9-]{1,31}$/.test(id) ? { intent, extension: id } : null; |
| 227 | } |
| 228 | if (intent === "switch" && extension) { |
| 229 | const enabled = form.get("enabled"); |
| 230 | return enabled === "on" || enabled === "off" ? { intent, listing: extension, enabled: enabled === "on" } : null; |
| 231 | } |
| 232 | if (intent === "uninstall" && extension) return { intent, listing: extension }; |
| 233 | return null; |
| 234 | } |
| 235 | |
| 236 | /** "Software Engineer, Engineering": a role as one line. */ |
| 237 | export function roleLine(template: Pick<AgentTemplate, "title" | "department">): string { |
| 238 | return template.department ? `${template.title} · ${template.department}` : template.title; |
| 239 | } |
| 240 | |
| 241 | /** How a routing limit reads: "Any model", "At least large", "Up to large", "Large to frontier". */ |
| 242 | export function routingWords(routing: Pick<AgentTemplate["routing"], "floor" | "ceiling">): string { |
| 243 | const name = (tier: string) => tier.charAt(0).toUpperCase() + tier.slice(1); |
| 244 | if (routing.floor && routing.ceiling) return `${name(routing.floor)} to ${routing.ceiling} models`; |
| 245 | if (routing.floor) return `${name(routing.floor)} models or better`; |
| 246 | if (routing.ceiling) return `Up to ${routing.ceiling} models`; |
| 247 | return "Any model the work needs"; |
| 248 | } |