Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

agent-marketplace.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

agent-marketplace.tsBlame603 lines · 1 contributor
5ca514aClaude1/**
2 * Agent Marketplace — catalog + install + reviews.
3 *
4 * Third-party AI agents listed in a public catalog, one-click installable
5 * per repo. Builds on `agent_sessions` (src/lib/agent-multiplayer.ts):
6 * every install provisions a fresh agent session whose `branch_namespace`
7 * and `budget_cents_per_day` are seeded from the listing's
8 * `agent_template`. The one-time agent token is returned to the caller
9 * exactly once, mirroring the PAT-issuance pattern.
10 *
11 * Revenue split: Gluecron takes 30%, publisher keeps 70%. The cut is a
12 * pure helper (`splitRevenueCents`) so accounting tests can exercise it
13 * without a DB. Actual payout is handled by the billing pipeline that
14 * already reads `ai_cost_events`.
15 *
16 * All DB-touching helpers swallow errors and return `null`/`false`/`[]`
17 * — same graceful-degradation pattern as `agent-multiplayer.ts`. Pure
18 * format helpers (slug, price, gradient, category validation) run
19 * without a DB so the test suite can drive them in isolation.
20 */
21
22import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
23import { db } from "../db";
24import {
25 agentMarketplaceListings,
26 agentMarketplaceInstalls,
27 agentMarketplaceReviews,
28 users,
29} from "../db/schema";
30import type {
31 AgentMarketplaceListing,
32 AgentMarketplaceInstall,
33 AgentMarketplaceReview,
34} from "../db/schema";
35import { createAgentSession, revokeAgentSession } from "./agent-multiplayer";
36import type { CreateAgentSessionResult } from "./agent-multiplayer";
37
38// ---------------------------------------------------------------------------
39// Constants
40// ---------------------------------------------------------------------------
41
42export const MARKETPLACE_CATEGORIES = [
43 "reviewer",
44 "tester",
45 "migrator",
46 "security",
47 "docs",
48 "custom",
49] as const;
50export type MarketplaceCategory = (typeof MARKETPLACE_CATEGORIES)[number];
51
52export const PRICING_MODELS = [
53 "per_invocation",
54 "per_repo_per_month",
55 "free",
56] as const;
57export type PricingModel = (typeof PRICING_MODELS)[number];
58
59export const LISTING_STATUSES = [
60 "draft",
61 "pending_review",
62 "approved",
63 "rejected",
64] as const;
65export type ListingStatus = (typeof LISTING_STATUSES)[number];
66
67/** Gluecron's cut of paid invocations, in basis points (3000 = 30%). */
68export const MARKETPLACE_REVENUE_SPLIT_BPS = 3000;
69
70// ---------------------------------------------------------------------------
71// Pure helpers (no DB)
72// ---------------------------------------------------------------------------
73
74/**
75 * Normalise a free-form name to a URL-safe slug. Lower-case, alphanumeric +
76 * dashes, cap at 60 chars. Mirrors `lib/marketplace.ts.slugify` but with a
77 * longer cap because agent listings tend to have longer names.
78 */
79export function slugifyListing(name: string): string {
80 return name
81 .toLowerCase()
82 .trim()
83 .replace(/[^a-z0-9]+/g, "-")
84 .replace(/^-+|-+$/g, "")
85 .slice(0, 60);
86}
87
88/**
89 * Format a `price_cents` integer as a display string per pricing model.
90 * Free listings always render "Free" — we never show "$0" because that
91 * looks broken in the catalog grid.
92 */
93export function formatPrice(
94 priceCents: number,
95 pricingModel: PricingModel | string
96): string {
97 if (pricingModel === "free" || priceCents <= 0) return "Free";
98 const dollars = (priceCents / 100).toFixed(2);
99 if (pricingModel === "per_repo_per_month") return `$${dollars}/repo/mo`;
100 if (pricingModel === "per_invocation") return `$${dollars}/run`;
101 return `$${dollars}`;
102}
103
104/** Whether `value` is a recognised category. Used to validate publisher input. */
105export function isValidCategory(value: unknown): value is MarketplaceCategory {
106 return (
107 typeof value === "string" &&
108 (MARKETPLACE_CATEGORIES as readonly string[]).includes(value)
109 );
110}
111
112/** Whether `value` is a recognised pricing model. */
113export function isValidPricingModel(value: unknown): value is PricingModel {
114 return (
115 typeof value === "string" &&
116 (PRICING_MODELS as readonly string[]).includes(value)
117 );
118}
119
120/**
121 * Compute the platform/publisher split on a `price_cents` amount, in cents.
122 * Rounds the platform cut down (favoring publishers when the cents don't
123 * divide evenly). Pure — exercise from tests without a DB.
124 */
125export function splitRevenueCents(priceCents: number): {
126 platformCents: number;
127 publisherCents: number;
128} {
129 const amount = Math.max(0, Math.floor(priceCents));
130 const platformCents = Math.floor(
131 (amount * MARKETPLACE_REVENUE_SPLIT_BPS) / 10_000
132 );
133 return { platformCents, publisherCents: amount - platformCents };
134}
135
136/**
137 * Deterministic gradient picker for the listing logo. Same input always
138 * returns the same gradient so the catalog stays visually stable across
139 * rebuilds. Mirrors the pattern in `routes/marketplace.tsx`.
140 */
141const LOGO_GRADIENTS = [
6fd5915Claude142 "linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%)",
5ca514aClaude143 "linear-gradient(135deg, #ec4899 0%, #f43f5e 100%)",
144 "linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)",
145 "linear-gradient(135deg, #10b981 0%, #14b8a6 100%)",
146 "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
147 "linear-gradient(135deg, #06b6d4 0%, #3b82f6 100%)",
148 "linear-gradient(135deg, #84cc16 0%, #22c55e 100%)",
149 "linear-gradient(135deg, #f97316 0%, #fb7185 100%)",
150];
151
152export function gradientForSlug(slug: string): string {
153 let h = 0;
154 for (let i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) | 0;
155 const idx =
156 ((h % LOGO_GRADIENTS.length) + LOGO_GRADIENTS.length) %
157 LOGO_GRADIENTS.length;
158 return LOGO_GRADIENTS[idx]!;
159}
160
161export function listingInitials(name: string): string {
162 const parts = name
163 .trim()
164 .split(/[\s\-_]+/)
165 .filter(Boolean);
166 if (parts.length >= 2) return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
167 return name.slice(0, 2).toUpperCase();
168}
169
170// ---------------------------------------------------------------------------
171// Listing reads
172// ---------------------------------------------------------------------------
173
174export type ListListingsSort = "top" | "new" | "rated";
175
176export interface ListListingsArgs {
177 category?: string;
178 search?: string;
179 sort?: ListListingsSort;
180 /** Default: only approved listings show in the public catalog. */
181 status?: ListingStatus | "any";
182 limit?: number;
183}
184
185/**
186 * Public catalog query. Defaults to approved-only + sorted by install_count
187 * desc. The admin moderation queue calls this with `status: "pending_review"`.
188 */
189export async function listListings(
190 args: ListListingsArgs = {}
191): Promise<AgentMarketplaceListing[]> {
192 const limit = Math.min(200, Math.max(1, args.limit ?? 100));
193 const sort: ListListingsSort = args.sort ?? "top";
194
195 const where = [] as ReturnType<typeof eq>[];
196 if (!args.status || args.status === "approved") {
197 where.push(eq(agentMarketplaceListings.status, "approved"));
198 } else if (args.status !== "any") {
199 where.push(eq(agentMarketplaceListings.status, args.status));
200 }
201 if (args.category && isValidCategory(args.category)) {
202 where.push(eq(agentMarketplaceListings.category, args.category));
203 }
204 if (args.search) {
205 const term = `%${args.search}%`;
206 const matchOr = or(
207 ilike(agentMarketplaceListings.name, term),
208 ilike(agentMarketplaceListings.tagline, term),
209 ilike(agentMarketplaceListings.description, term)
210 );
211 if (matchOr) where.push(matchOr as ReturnType<typeof eq>);
212 }
213
214 const orderBy =
215 sort === "new"
216 ? desc(agentMarketplaceListings.createdAt)
217 : sort === "rated"
218 ? desc(agentMarketplaceListings.ratingAvg)
219 : desc(agentMarketplaceListings.installCount);
220
221 try {
222 const rows = await db
223 .select()
224 .from(agentMarketplaceListings)
225 .where(where.length ? and(...where) : undefined)
226 .orderBy(orderBy)
227 .limit(limit);
228 return rows;
229 } catch {
230 return [];
231 }
232}
233
234export interface ListingWithPublisher extends AgentMarketplaceListing {
235 publisherUsername: string | null;
236}
237
238export interface ListingDetail {
239 listing: ListingWithPublisher;
240 reviews: Array<AgentMarketplaceReview & { reviewerUsername: string | null }>;
241}
242
243/**
244 * Detail view — listing + publisher handle + 20 most-recent reviews with
245 * the reviewer's username inlined. Returns null when the slug is unknown
246 * or the DB call throws.
247 */
248export async function getListing(slug: string): Promise<ListingDetail | null> {
249 try {
250 const [row] = await db
251 .select({
252 listing: agentMarketplaceListings,
253 publisherUsername: users.username,
254 })
255 .from(agentMarketplaceListings)
256 .leftJoin(users, eq(users.id, agentMarketplaceListings.publisherUserId))
257 .where(eq(agentMarketplaceListings.slug, slug))
258 .limit(1);
259 if (!row) return null;
260
261 const reviewRows = await db
262 .select({
263 review: agentMarketplaceReviews,
264 reviewerUsername: users.username,
265 })
266 .from(agentMarketplaceReviews)
267 .leftJoin(users, eq(users.id, agentMarketplaceReviews.reviewerUserId))
268 .where(eq(agentMarketplaceReviews.listingId, row.listing.id))
269 .orderBy(desc(agentMarketplaceReviews.createdAt))
270 .limit(20);
271
272 return {
273 listing: {
274 ...row.listing,
275 publisherUsername: row.publisherUsername,
276 },
277 reviews: reviewRows.map((r) => ({
278 ...r.review,
279 reviewerUsername: r.reviewerUsername,
280 })),
281 };
282 } catch {
283 return null;
284 }
285}
286
287// ---------------------------------------------------------------------------
288// Listing writes
289// ---------------------------------------------------------------------------
290
291export interface CreateListingArgs {
292 publisherUserId: string;
293 name: string;
294 tagline?: string;
295 description?: string;
296 category?: string;
297 pricingModel?: string;
298 priceCents?: number;
299 agentTemplate?: Record<string, unknown>;
300 sourceUrl?: string;
301 /** Skip the pending_review state — only used by the seed script. */
302 initialStatus?: ListingStatus;
303}
304
305/**
306 * Create a draft listing. Slug is derived from `name`; retries on collision
307 * with a short hex suffix. Publisher submissions land in `pending_review`
308 * so a moderator can vet them; the seed path passes `initialStatus:
309 * "approved"` for the four example listings.
310 */
311export async function createListing(
312 args: CreateListingArgs
313): Promise<AgentMarketplaceListing | null> {
314 const name = args.name.trim();
315 if (!name) return null;
316 const category = isValidCategory(args.category)
317 ? args.category
318 : "custom";
319 const pricingModel = isValidPricingModel(args.pricingModel)
320 ? args.pricingModel
321 : "free";
322 const status: ListingStatus = args.initialStatus ?? "pending_review";
323 const baseSlug = slugifyListing(name) || "agent";
324
325 for (let attempt = 0; attempt < 6; attempt++) {
326 const slug =
327 attempt === 0
328 ? baseSlug
329 : `${baseSlug}-${Math.floor(Math.random() * 0xffff)
330 .toString(16)
331 .padStart(4, "0")}`;
332 try {
333 const [row] = await db
334 .insert(agentMarketplaceListings)
335 .values({
336 publisherUserId: args.publisherUserId,
337 slug,
338 name,
339 tagline: (args.tagline ?? "").slice(0, 280),
340 description: args.description ?? "",
341 category,
342 pricingModel,
343 priceCents: Math.max(0, Math.floor(args.priceCents ?? 0)),
344 agentTemplate: (args.agentTemplate ?? {}) as never,
345 sourceUrl: args.sourceUrl ?? null,
346 status,
347 })
348 .returning();
349 return row ?? null;
350 } catch (err) {
351 // 23505 unique violation on slug — retry with a fresh suffix.
352 const code = (err as { code?: string } | undefined)?.code;
353 if (code === "23505") continue;
354 console.error("[agent-marketplace] createListing:", err);
355 return null;
356 }
357 }
358 return null;
359}
360
361/** Flip a listing to approved. Idempotent. */
362export async function approveListing(
363 slug: string,
364 _moderatorUserId: string
365): Promise<AgentMarketplaceListing | null> {
366 try {
367 const [row] = await db
368 .update(agentMarketplaceListings)
369 .set({ status: "approved", updatedAt: new Date() })
370 .where(eq(agentMarketplaceListings.slug, slug))
371 .returning();
372 return row ?? null;
373 } catch {
374 return null;
375 }
376}
377
378/** Flip a listing to rejected. Reason isn't persisted yet — surfaced via audit. */
379export async function rejectListing(
380 slug: string,
381 _moderatorUserId: string,
382 _reason: string
383): Promise<AgentMarketplaceListing | null> {
384 try {
385 const [row] = await db
386 .update(agentMarketplaceListings)
387 .set({ status: "rejected", updatedAt: new Date() })
388 .where(eq(agentMarketplaceListings.slug, slug))
389 .returning();
390 return row ?? null;
391 } catch {
392 return null;
393 }
394}
395
396// ---------------------------------------------------------------------------
397// Installs
398// ---------------------------------------------------------------------------
399
400export interface InstallListingArgs {
401 listingId: string;
402 repositoryId: string;
403 installedByUserId: string;
404}
405
406export interface InstallListingResult {
407 install: AgentMarketplaceInstall;
408 /** One-time agent token — store immediately, never retrievable again. */
409 agentToken: string;
410}
411
412/**
413 * Wire a listing onto a repo. Side-effects:
414 * 1. Provisions a fresh `agent_session` seeded by the listing's
415 * `agent_template` (branchNamespace, budgetCentsPerDay).
416 * 2. Inserts the link row. The UNIQUE (listing_id, repository_id) index
417 * ensures we can't double-install.
418 * 3. Bumps `install_count`.
419 * 4. Returns the plaintext agent token exactly once.
420 *
421 * On any failure after the session is created we attempt to revoke it so
422 * we don't leak orphan agents.
423 */
424export async function installListing(
425 args: InstallListingArgs
426): Promise<InstallListingResult | null> {
427 const listing = await fetchListingById(args.listingId);
428 if (!listing || listing.status !== "approved") return null;
429
430 const tpl = listing.agentTemplate ?? {};
431 const sessionName =
432 `mkt-${listing.slug}-${args.repositoryId.slice(0, 8)}`.slice(0, 60);
433 const sess: CreateAgentSessionResult | null = await createAgentSession({
434 ownerUserId: args.installedByUserId,
435 name: sessionName,
436 repositoryId: args.repositoryId,
437 branchNamespace:
438 typeof tpl.branchNamespace === "string"
439 ? tpl.branchNamespace
440 : `agents/${listing.slug}`,
441 budgetCentsPerDay:
442 typeof tpl.budgetCentsPerDay === "number" ? tpl.budgetCentsPerDay : 500,
443 });
444 if (!sess) return null;
445
446 try {
447 const [install] = await db
448 .insert(agentMarketplaceInstalls)
449 .values({
450 listingId: args.listingId,
451 repositoryId: args.repositoryId,
452 installedByUserId: args.installedByUserId,
453 agentSessionId: sess.session.id,
454 status: "active",
455 })
456 .returning();
457 if (!install) {
458 await revokeAgentSession(sess.session.id, args.installedByUserId);
459 return null;
460 }
461 // Bump the listing's install_count (best-effort).
462 db.update(agentMarketplaceListings)
463 .set({
464 installCount: sql`${agentMarketplaceListings.installCount} + 1`,
465 updatedAt: new Date(),
466 })
467 .where(eq(agentMarketplaceListings.id, args.listingId))
468 .catch(() => undefined);
469 return { install, agentToken: sess.token };
470 } catch (err) {
471 // 23505 unique violation → already installed. Roll back the session.
472 await revokeAgentSession(sess.session.id, args.installedByUserId);
473 const code = (err as { code?: string } | undefined)?.code;
474 if (code !== "23505") {
475 console.error("[agent-marketplace] installListing:", err);
476 }
477 return null;
478 }
479}
480
481/**
482 * Flip an install to 'uninstalled' and revoke the underlying agent_session
483 * so the agent's token immediately stops authenticating.
484 */
485export async function uninstallListing(args: {
486 installId: string;
487}): Promise<boolean> {
488 try {
489 const [row] = await db
490 .update(agentMarketplaceInstalls)
491 .set({ status: "uninstalled" })
492 .where(eq(agentMarketplaceInstalls.id, args.installId))
493 .returning();
494 if (!row) return false;
495 if (row.agentSessionId) {
496 await revokeAgentSession(row.agentSessionId, row.installedByUserId);
497 }
498 return true;
499 } catch {
500 return false;
501 }
502}
503
504export async function listInstallsForRepo(
505 repositoryId: string
506): Promise<AgentMarketplaceInstall[]> {
507 try {
508 return await db
509 .select()
510 .from(agentMarketplaceInstalls)
511 .where(eq(agentMarketplaceInstalls.repositoryId, repositoryId))
512 .orderBy(desc(agentMarketplaceInstalls.installedAt));
513 } catch {
514 return [];
515 }
516}
517
518async function fetchListingById(
519 id: string
520): Promise<AgentMarketplaceListing | null> {
521 try {
522 const [row] = await db
523 .select()
524 .from(agentMarketplaceListings)
525 .where(eq(agentMarketplaceListings.id, id))
526 .limit(1);
527 return row ?? null;
528 } catch {
529 return null;
530 }
531}
532
533/** Slug-form sibling of `fetchListingById`. Public — used by route handlers. */
534export async function fetchListingBySlug(
535 slug: string
536): Promise<AgentMarketplaceListing | null> {
537 try {
538 const [row] = await db
539 .select()
540 .from(agentMarketplaceListings)
541 .where(eq(agentMarketplaceListings.slug, slug))
542 .limit(1);
543 return row ?? null;
544 } catch {
545 return null;
546 }
547}
548
549// ---------------------------------------------------------------------------
550// Reviews
551// ---------------------------------------------------------------------------
552
553export interface RecordReviewArgs {
554 listingId: string;
555 reviewerUserId: string;
556 rating: number;
557 body?: string;
558}
559
560/**
561 * Insert a 1-5 rating + body, then recompute `rating_avg`/`rating_count`
562 * for the listing. The aggregate update is a single SQL `AVG` so we
563 * don't race against concurrent inserts.
564 */
565export async function recordReview(
566 args: RecordReviewArgs
567): Promise<AgentMarketplaceReview | null> {
568 const rating = Math.max(1, Math.min(5, Math.floor(args.rating)));
569 try {
570 const [row] = await db
571 .insert(agentMarketplaceReviews)
572 .values({
573 listingId: args.listingId,
574 reviewerUserId: args.reviewerUserId,
575 rating,
576 body: (args.body ?? "").slice(0, 4000),
577 })
578 .returning();
579 if (!row) return null;
580
581 // Recompute aggregate from the source of truth — single round-trip.
582 await db
583 .update(agentMarketplaceListings)
584 .set({
585 ratingAvg: sql`(
586 SELECT COALESCE(ROUND(AVG(rating)::numeric, 2), 0)
587 FROM ${agentMarketplaceReviews}
588 WHERE ${agentMarketplaceReviews.listingId} = ${args.listingId}
589 )`,
590 ratingCount: sql`(
591 SELECT COUNT(*)::int
592 FROM ${agentMarketplaceReviews}
593 WHERE ${agentMarketplaceReviews.listingId} = ${args.listingId}
594 )`,
595 updatedAt: new Date(),
596 })
597 .where(eq(agentMarketplaceListings.id, args.listingId));
598
599 return row;
600 } catch {
601 return null;
602 }
603}