Commit5ca514aunknown_key
feat(marketplace-agents): agent marketplace + ts fix on rejectListing body parse
feat(marketplace-agents): agent marketplace + ts fix on rejectListing body parse Agent marketplace shipped (drizzle/0070 + lib/agent-marketplace.ts + routes/marketplace-agents.tsx + schema additions). Catalog of 3rd-party AI agents, install per-repo via the agent-multiplayer foundation, reviews + ratings + admin moderation queue. TS2339 fix on the reject handler: parseBody() returns a union with empty object so 'reason' was unsafe. Cast to Record<string,unknown>.
8 files changed+2601−45ca514a1489dc9621867d4a404b93494b95160f7
8 changed files+2601−4
Addeddrizzle/0070_agent_marketplace.sql+107−0View fileUnifiedSplit
@@ -0,0 +1,107 @@
1-- Agent Marketplace — third-party AI agents that users one-click-install per
2-- repo. Builds on `agent_sessions` (0058): every install provisions a
3-- fresh agent_session whose `branch_namespace` and `budget_cents_per_day`
4-- are seeded from the listing's `agent_template`. We take a 30% cut on
5-- paid invocations (`price_cents`, charged through ai_cost_events).
6--
7-- Three tables:
8-- agent_marketplace_listings — the catalog row a publisher creates.
9-- agent_marketplace_installs — link table: which listing is wired to
10-- which repo (+ which agent_session it
11-- provisioned). UNIQUE on (listing_id,
12-- repository_id) so we can't double-install.
13-- agent_marketplace_reviews — 1-5 star ratings + body.
14
15CREATE TABLE IF NOT EXISTS agent_marketplace_listings (
16 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
17 -- The user who published this listing and is paid out (after our 30%).
18 publisher_user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
19 -- URL-safe slug. Globally unique so /marketplace/agents/<slug> is stable.
20 slug text NOT NULL UNIQUE,
21 name text NOT NULL,
22 -- One-line teaser shown in the catalog grid.
23 tagline text NOT NULL DEFAULT '',
24 -- Markdown description shown on the detail page.
25 description text NOT NULL DEFAULT '',
26 -- Closed vocabulary, validated in the lib:
27 -- reviewer | tester | migrator | security | docs | custom
28 category text NOT NULL DEFAULT 'custom',
29 -- Closed vocabulary:
30 -- per_invocation — charge once per agent action (price_cents)
31 -- per_repo_per_month — flat monthly subscription per install
32 -- free — price_cents is ignored
33 pricing_model text NOT NULL DEFAULT 'free',
34 price_cents integer NOT NULL DEFAULT 0,
35 -- Defaults the install flow seeds into the new agent_session.
36 -- Recognised keys: branchNamespace, budgetCentsPerDay, capabilities[].
37 agent_template jsonb NOT NULL DEFAULT '{}'::jsonb,
38 source_url text,
39 -- Moderation state: draft | pending_review | approved | rejected.
40 status text NOT NULL DEFAULT 'draft',
41 install_count integer NOT NULL DEFAULT 0,
42 -- Aggregated rating. Updated after every review insert.
43 rating_avg numeric(3, 2) NOT NULL DEFAULT 0,
44 rating_count integer NOT NULL DEFAULT 0,
45 created_at timestamptz NOT NULL DEFAULT now(),
46 updated_at timestamptz NOT NULL DEFAULT now()
47);
48
49CREATE INDEX IF NOT EXISTS agent_marketplace_listings_category
50 ON agent_marketplace_listings (category, status);
51
52CREATE INDEX IF NOT EXISTS agent_marketplace_listings_rating
53 ON agent_marketplace_listings (rating_avg DESC, rating_count DESC);
54
55CREATE INDEX IF NOT EXISTS agent_marketplace_listings_installs
56 ON agent_marketplace_listings (install_count DESC);
57
58CREATE INDEX IF NOT EXISTS agent_marketplace_listings_publisher
59 ON agent_marketplace_listings (publisher_user_id);
60
61CREATE INDEX IF NOT EXISTS agent_marketplace_listings_status_created
62 ON agent_marketplace_listings (status, created_at DESC);
63
64CREATE TABLE IF NOT EXISTS agent_marketplace_installs (
65 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
66 listing_id uuid NOT NULL
67 REFERENCES agent_marketplace_listings(id) ON DELETE CASCADE,
68 repository_id uuid NOT NULL
69 REFERENCES repositories(id) ON DELETE CASCADE,
70 installed_by_user_id uuid NOT NULL
71 REFERENCES users(id) ON DELETE CASCADE,
72 -- The agent_session provisioned at install time. Killed when the install
73 -- flips to 'uninstalled'.
74 agent_session_id uuid
75 REFERENCES agent_sessions(id) ON DELETE SET NULL,
76 status text NOT NULL DEFAULT 'active', -- active | paused | uninstalled
77 installed_at timestamptz NOT NULL DEFAULT now(),
78 last_invoked_at timestamptz,
79 created_at timestamptz NOT NULL DEFAULT now()
80);
81
82-- Can't install the same listing on the same repo twice.
83CREATE UNIQUE INDEX IF NOT EXISTS agent_marketplace_installs_listing_repo
84 ON agent_marketplace_installs (listing_id, repository_id);
85
86CREATE INDEX IF NOT EXISTS agent_marketplace_installs_repo
87 ON agent_marketplace_installs (repository_id, status);
88
89CREATE INDEX IF NOT EXISTS agent_marketplace_installs_installer
90 ON agent_marketplace_installs (installed_by_user_id);
91
92CREATE TABLE IF NOT EXISTS agent_marketplace_reviews (
93 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
94 listing_id uuid NOT NULL
95 REFERENCES agent_marketplace_listings(id) ON DELETE CASCADE,
96 reviewer_user_id uuid NOT NULL
97 REFERENCES users(id) ON DELETE CASCADE,
98 rating integer NOT NULL,
99 body text NOT NULL DEFAULT '',
100 created_at timestamptz NOT NULL DEFAULT now()
101);
102
103CREATE INDEX IF NOT EXISTS agent_marketplace_reviews_listing_created
104 ON agent_marketplace_reviews (listing_id, created_at DESC);
105
106CREATE INDEX IF NOT EXISTS agent_marketplace_reviews_reviewer
107 ON agent_marketplace_reviews (reviewer_user_id);
Modifiedsrc/app.tsx+2−0View fileUnifiedSplit
@@ -115,6 +115,7 @@ import gistsRoutes from "./routes/gists";
115115import graphqlRoutes from "./routes/graphql";
116116import mcpRoutes from "./routes/mcp";
117117import marketplaceRoutes from "./routes/marketplace";
118import marketplaceAgentsRoutes from "./routes/marketplace-agents";
118119import mergeQueueRoutes from "./routes/merge-queue";
119120import mirrorsRoutes from "./routes/mirrors";
120121import orgInsightsRoutes from "./routes/org-insights";
@@ -583,6 +584,7 @@ app.route("/", gistsRoutes);
583584app.route("/", graphqlRoutes);
584585app.route("/", mcpRoutes);
585586app.route("/", marketplaceRoutes);
587app.route("/", marketplaceAgentsRoutes);
586588app.route("/", mergeQueueRoutes);
587589app.route("/", mirrorsRoutes);
588590app.route("/", orgInsightsRoutes);
Modifiedsrc/db/schema.ts+141−0View fileUnifiedSplit
@@ -10,6 +10,7 @@ import {
1010 serial,
1111 bigint,
1212 jsonb,
13 numeric,
1314 customType,
1415} from "drizzle-orm/pg-core";
1516
@@ -3580,3 +3581,143 @@ export type HostedClaudeLoop = typeof hostedClaudeLoops.$inferSelect;
35803581export type NewHostedClaudeLoop = typeof hostedClaudeLoops.$inferInsert;
35813582export type HostedClaudeLoopRun = typeof hostedClaudeLoopRuns.$inferSelect;
35823583export type NewHostedClaudeLoopRun = typeof hostedClaudeLoopRuns.$inferInsert;
3584
3585// ---------------------------------------------------------------------------
3586// 0070 — Agent Marketplace. Third-party AI agents listed in a catalog,
3587// one-click install per repo. Builds on agent_sessions (0058): every install
3588// provisions a fresh agent_session whose `branch_namespace` and
3589// `budget_cents_per_day` are seeded from the listing's `agent_template`.
3590// See src/lib/agent-marketplace.ts.
3591// ---------------------------------------------------------------------------
3592export const agentMarketplaceListings = pgTable(
3593 "agent_marketplace_listings",
3594 {
3595 id: uuid("id").primaryKey().defaultRandom(),
3596 publisherUserId: uuid("publisher_user_id")
3597 .notNull()
3598 .references(() => users.id, { onDelete: "cascade" }),
3599 slug: text("slug").notNull().unique(),
3600 name: text("name").notNull(),
3601 tagline: text("tagline").default("").notNull(),
3602 description: text("description").default("").notNull(),
3603 // 'reviewer' | 'tester' | 'migrator' | 'security' | 'docs' | 'custom'
3604 category: text("category").default("custom").notNull(),
3605 // 'per_invocation' | 'per_repo_per_month' | 'free'
3606 pricingModel: text("pricing_model").default("free").notNull(),
3607 priceCents: integer("price_cents").default(0).notNull(),
3608 agentTemplate: jsonb("agent_template")
3609 .$type<{
3610 branchNamespace?: string;
3611 budgetCentsPerDay?: number;
3612 capabilities?: string[];
3613 [k: string]: unknown;
3614 }>()
3615 .default({})
3616 .notNull(),
3617 sourceUrl: text("source_url"),
3618 // 'draft' | 'pending_review' | 'approved' | 'rejected'
3619 status: text("status").default("draft").notNull(),
3620 installCount: integer("install_count").default(0).notNull(),
3621 ratingAvg: numeric("rating_avg", { precision: 3, scale: 2 })
3622 .default("0")
3623 .notNull(),
3624 ratingCount: integer("rating_count").default(0).notNull(),
3625 createdAt: timestamp("created_at", { withTimezone: true })
3626 .defaultNow()
3627 .notNull(),
3628 updatedAt: timestamp("updated_at", { withTimezone: true })
3629 .defaultNow()
3630 .notNull(),
3631 },
3632 (table) => [
3633 index("agent_marketplace_listings_category").on(
3634 table.category,
3635 table.status
3636 ),
3637 index("agent_marketplace_listings_rating").on(
3638 table.ratingAvg,
3639 table.ratingCount
3640 ),
3641 index("agent_marketplace_listings_installs").on(table.installCount),
3642 index("agent_marketplace_listings_publisher").on(table.publisherUserId),
3643 index("agent_marketplace_listings_status_created").on(
3644 table.status,
3645 table.createdAt
3646 ),
3647 ]
3648);
3649
3650export const agentMarketplaceInstalls = pgTable(
3651 "agent_marketplace_installs",
3652 {
3653 id: uuid("id").primaryKey().defaultRandom(),
3654 listingId: uuid("listing_id")
3655 .notNull()
3656 .references(() => agentMarketplaceListings.id, { onDelete: "cascade" }),
3657 repositoryId: uuid("repository_id")
3658 .notNull()
3659 .references(() => repositories.id, { onDelete: "cascade" }),
3660 installedByUserId: uuid("installed_by_user_id")
3661 .notNull()
3662 .references(() => users.id, { onDelete: "cascade" }),
3663 agentSessionId: uuid("agent_session_id").references(
3664 () => agentSessions.id,
3665 { onDelete: "set null" }
3666 ),
3667 // 'active' | 'paused' | 'uninstalled'
3668 status: text("status").default("active").notNull(),
3669 installedAt: timestamp("installed_at", { withTimezone: true })
3670 .defaultNow()
3671 .notNull(),
3672 lastInvokedAt: timestamp("last_invoked_at", { withTimezone: true }),
3673 createdAt: timestamp("created_at", { withTimezone: true })
3674 .defaultNow()
3675 .notNull(),
3676 },
3677 (table) => [
3678 uniqueIndex("agent_marketplace_installs_listing_repo").on(
3679 table.listingId,
3680 table.repositoryId
3681 ),
3682 index("agent_marketplace_installs_repo").on(table.repositoryId, table.status),
3683 index("agent_marketplace_installs_installer").on(table.installedByUserId),
3684 ]
3685);
3686
3687export const agentMarketplaceReviews = pgTable(
3688 "agent_marketplace_reviews",
3689 {
3690 id: uuid("id").primaryKey().defaultRandom(),
3691 listingId: uuid("listing_id")
3692 .notNull()
3693 .references(() => agentMarketplaceListings.id, { onDelete: "cascade" }),
3694 reviewerUserId: uuid("reviewer_user_id")
3695 .notNull()
3696 .references(() => users.id, { onDelete: "cascade" }),
3697 rating: integer("rating").notNull(),
3698 body: text("body").default("").notNull(),
3699 createdAt: timestamp("created_at", { withTimezone: true })
3700 .defaultNow()
3701 .notNull(),
3702 },
3703 (table) => [
3704 index("agent_marketplace_reviews_listing_created").on(
3705 table.listingId,
3706 table.createdAt
3707 ),
3708 index("agent_marketplace_reviews_reviewer").on(table.reviewerUserId),
3709 ]
3710);
3711
3712export type AgentMarketplaceListing =
3713 typeof agentMarketplaceListings.$inferSelect;
3714export type NewAgentMarketplaceListing =
3715 typeof agentMarketplaceListings.$inferInsert;
3716export type AgentMarketplaceInstall =
3717 typeof agentMarketplaceInstalls.$inferSelect;
3718export type NewAgentMarketplaceInstall =
3719 typeof agentMarketplaceInstalls.$inferInsert;
3720export type AgentMarketplaceReview =
3721 typeof agentMarketplaceReviews.$inferSelect;
3722export type NewAgentMarketplaceReview =
3723 typeof agentMarketplaceReviews.$inferInsert;
Modifiedsrc/index.ts+17−0View fileUnifiedSplit
@@ -7,6 +7,7 @@ import { startAutopilot } from "./lib/autopilot";
77import { ensureDemoContent } from "./lib/demo-seed";
88import { ensureDemoActivity } from "./lib/demo-activity-seed";
99import { ensureEnvSiteAdmin } from "./lib/admin-bootstrap";
10import { ensureMarketplaceSeed } from "./lib/agent-marketplace-seed";
1011import { maybeSelfBootstrap } from "./lib/self-bootstrap";
1112import { notifySystemdReady } from "./lib/systemd-notify";
1213import { loadConfigIntoEnv } from "./lib/system-config";
@@ -63,6 +64,22 @@ void ensureEnvSiteAdmin().catch((err) => {
6364 );
6465});
6566
67// Agent marketplace seed. Idempotent — inserts the 4 canonical example
68// listings only if they don't already exist. Background-fired with a small
69// delay so the admin-bootstrap path lands first (the seed lists the
70// publisher as the bootstrap admin).
71void (async () => {
72 try {
73 await new Promise((r) => setTimeout(r, 1500));
74 await ensureMarketplaceSeed();
75 } catch (err) {
76 console.warn(
77 "[agent-marketplace-seed] failed:",
78 err instanceof Error ? err.message : err
79 );
80 }
81})();
82
6683// Opt-in demo content seed on boot (DEMO_SEED_ON_BOOT=1). Idempotent, never
6784// throws — safe to run on every start. Block L3 layers extra activity (more
6885// issues, an open + merged PR on todo-api, AI-review comment, auto-merge
Addedsrc/lib/agent-marketplace-seed.ts+198−0View fileUnifiedSplit
@@ -0,0 +1,198 @@
1/**
2 * Seed the agent marketplace with 4 example, admin-published listings on
3 * first boot. Idempotent — runs SELECT first and bails when any of the
4 * canonical slugs is already in the DB. Designed to fire-and-forget from
5 * `src/index.ts`; never throws past its own boundary.
6 *
7 * The listings wrap existing in-tree AI helpers:
8 * - "Gluecron AI Reviewer" → ai-review-trio
9 * - "Test Generator Bot" → ai-test-generator
10 * - "Doc Drift Watcher" → ai-doc-updater
11 * - "Security Patrol" → ai-patch-generator (weekly cadence)
12 *
13 * Publisher: the bootstrap admin (first user / SITE_ADMIN_USERNAME). When
14 * no admin exists yet we skip — the seed will pick up on a later boot.
15 */
16
17import { eq } from "drizzle-orm";
18import { db } from "../db";
19import { agentMarketplaceListings, users } from "../db/schema";
20import { getEnvAdminUsername } from "./admin-bootstrap";
21import { createListing } from "./agent-marketplace";
22
23interface SeedListing {
24 slug: string;
25 name: string;
26 tagline: string;
27 description: string;
28 category: "reviewer" | "tester" | "docs" | "security";
29 pricingModel: "free" | "per_invocation" | "per_repo_per_month";
30 priceCents: number;
31 agentTemplate: Record<string, unknown>;
32 sourceUrl?: string;
33}
34
35const SEEDS: SeedListing[] = [
36 {
37 slug: "gluecron-ai-reviewer",
38 name: "Gluecron AI Reviewer",
39 tagline:
40 "The official trio-review pass — security, correctness, style — on every PR.",
41 description:
42 "Runs the built-in `ai-review-trio` pipeline on every pull request. " +
43 "Three specialised passes (security, correctness, style) land as " +
44 "inline comments on the diff. Same agent the Gluecron team uses on " +
45 "its own monorepo.\n\n" +
46 "**Capabilities:** `pr:read`, `pr:comment:write`.\n",
47 category: "reviewer",
48 pricingModel: "per_invocation",
49 priceCents: 25,
50 agentTemplate: {
51 branchNamespace: "agents/ai-reviewer",
52 budgetCentsPerDay: 1000,
53 capabilities: ["pr:read", "pr:comment:write"],
54 handler: "ai-review-trio",
55 },
56 sourceUrl: "https://gluecron.com/ccantynz/Gluecron.com/blob/main/src/lib/ai-review-trio.ts",
57 },
58 {
59 slug: "test-generator-bot",
60 name: "Test Generator Bot",
61 tagline:
62 "Auto-writes failing tests for every uncovered function in a PR diff.",
63 description:
64 "Scans the PR diff, finds functions without test coverage, and opens " +
65 "a follow-up PR with generated unit tests. Backed by " +
66 "`ai-test-generator` — the same engine that powers the autopilot " +
67 "weekly test-gen sweep.\n",
68 category: "tester",
69 pricingModel: "per_invocation",
70 priceCents: 35,
71 agentTemplate: {
72 branchNamespace: "agents/test-gen",
73 budgetCentsPerDay: 1500,
74 capabilities: ["pr:read", "repo:write"],
75 handler: "ai-test-generator",
76 },
77 sourceUrl: "https://gluecron.com/ccantynz/Gluecron.com/blob/main/src/lib/ai-test-generator.ts",
78 },
79 {
80 slug: "doc-drift-watcher",
81 name: "Doc Drift Watcher",
82 tagline:
83 "Spots when code changes drift from docstrings and opens a doc-update PR.",
84 description:
85 "Watches every push and flags doc drift — functions whose " +
86 "implementation has changed but whose docstring hasn't. Opens a " +
87 "follow-up PR with the proposed doc update. Wraps " +
88 "`ai-doc-updater`.\n",
89 category: "docs",
90 pricingModel: "free",
91 priceCents: 0,
92 agentTemplate: {
93 branchNamespace: "agents/doc-drift",
94 budgetCentsPerDay: 500,
95 capabilities: ["repo:read", "pr:write"],
96 handler: "ai-doc-updater",
97 },
98 sourceUrl: "https://gluecron.com/ccantynz/Gluecron.com/blob/main/src/lib/ai-doc-updater.ts",
99 },
100 {
101 slug: "security-patrol",
102 name: "Security Patrol",
103 tagline:
104 "Weekly automated security scan + patch PRs for known CVEs in your deps.",
105 description:
106 "Runs once a week on a per-repo cadence. Scans your dependency " +
107 "tree for known CVEs (via the advisories DB), then opens a patch " +
108 "PR with the minimum-viable bump. Wraps `ai-patch-generator`.\n",
109 category: "security",
110 pricingModel: "per_repo_per_month",
111 priceCents: 500,
112 agentTemplate: {
113 branchNamespace: "agents/security-patrol",
114 budgetCentsPerDay: 2000,
115 capabilities: ["repo:read", "pr:write"],
116 handler: "ai-patch-generator",
117 cadence: "weekly",
118 },
119 sourceUrl: "https://gluecron.com/ccantynz/Gluecron.com/blob/main/src/lib/ai-patch-generator.ts",
120 },
121];
122
123/**
124 * Idempotent seed. Returns the number of new listings inserted. Skips
125 * silently when no admin user exists yet (so the very first boot of a
126 * fresh DB doesn't error).
127 */
128export async function ensureMarketplaceSeed(): Promise<number> {
129 let publisher: { id: string } | null = null;
130 try {
131 // Prefer the env-configured site admin if set, else oldest user.
132 const envAdminName = getEnvAdminUsername();
133 if (envAdminName) {
134 const [row] = await db
135 .select({ id: users.id })
136 .from(users)
137 .where(eq(users.username, envAdminName))
138 .limit(1);
139 if (row) publisher = row;
140 }
141 if (!publisher) {
142 const [row] = await db
143 .select({ id: users.id })
144 .from(users)
145 .orderBy(users.createdAt)
146 .limit(1);
147 if (row) publisher = row;
148 }
149 } catch {
150 return 0;
151 }
152 if (!publisher) return 0;
153
154 let inserted = 0;
155 for (const seed of SEEDS) {
156 try {
157 const [existing] = await db
158 .select({ id: agentMarketplaceListings.id })
159 .from(agentMarketplaceListings)
160 .where(eq(agentMarketplaceListings.slug, seed.slug))
161 .limit(1);
162 if (existing) continue;
163 const row = await createListing({
164 publisherUserId: publisher.id,
165 name: seed.name,
166 tagline: seed.tagline,
167 description: seed.description,
168 category: seed.category,
169 pricingModel: seed.pricingModel,
170 priceCents: seed.priceCents,
171 agentTemplate: seed.agentTemplate,
172 sourceUrl: seed.sourceUrl,
173 initialStatus: "approved",
174 });
175 if (row) {
176 // createListing sometimes invents a hex suffix on slug conflict; force
177 // the canonical slug back so subsequent runs see "already exists".
178 if (row.slug !== seed.slug) {
179 await db
180 .update(agentMarketplaceListings)
181 .set({ slug: seed.slug })
182 .where(eq(agentMarketplaceListings.id, row.id))
183 .catch(() => undefined);
184 }
185 inserted++;
186 }
187 } catch (err) {
188 console.warn(
189 `[agent-marketplace-seed] ${seed.slug} skipped:`,
190 err instanceof Error ? err.message : err
191 );
192 }
193 }
194 if (inserted > 0) {
195 console.log(`[agent-marketplace-seed] inserted ${inserted} listing(s)`);
196 }
197 return inserted;
198}
Addedsrc/lib/agent-marketplace.ts+603−0View fileUnifiedSplit
@@ -0,0 +1,603 @@
1/**
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 = [
142 "linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%)",
143 "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}
Addedsrc/routes/marketplace-agents.tsx+1520−0View fileUnifiedSplit
Large file (1,520 lines). Load full file
Modifiedsrc/routes/marketplace.tsx+13−4View fileUnifiedSplit
@@ -735,11 +735,20 @@ marketplace.get("/marketplace", async (c) => {
735735 in one click — every app runs with its own scoped bot identity.
736736 </p>
737737 </div>
738 {user && (
739 <a href="/developer/apps-new" class="mkt-hero-cta">
740 + Register app
738 <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
739 <a href="/marketplace/agents" class="mkt-hero-cta">
740 Agents →
741741 </a>
742 )}
742 {user && (
743 <a
744 href="/developer/apps-new"
745 class="mkt-hero-cta"
746 style="background:transparent;border:1px solid var(--border);color:var(--text);box-shadow:none"
747 >
748 + Register app
749 </a>
750 )}
751 </div>
743752 </div>
744753 </section>
745754
746755