Skip to content
248 linesCodeBlameRaw
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 */
13import type { AgentTemplate, ExtensionInstall, ExtensionManifest, InstallRequest, InstallRequestStatus, ListingTier, WorkspaceAgent } from "@g1t/contracts";
14import { type ConnectorView, connectorPath } from "@g1t/contracts/connectors";
15import { listingRef, parseListing } from "@g1t/contracts/marketplace";
16
17import type { ConnectedState } from "./connectors";
18
19/** The Marketplace's pages, under `/<workspace>/-/marketplace`. */
20export 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. */
25export 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. */
30export 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. */
35export 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. */
43function 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`. */
48function 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. */
53export 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 */
69export 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. */
90export 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 */
106export 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 */
135export 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. */
143export 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. */
152export 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. */
167export function extensionPath(slug: string, id: string): string {
168 return `/${slug}/-/marketplace/extensions/${id}`;
169}
170
171/** Where an extension runs, in words. */
172export 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. */
177export 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. */
185export 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. */
190export 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. */
200export 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. */
208export 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. */
237export 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". */
242export 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}