| 1 | /** |
| 2 | * The Marketplace's install requests (docs.g1t.sh/guides/marketplace/): a |
| 3 | * member asks the workspace's owners to add an extension or an |
| 4 | * integration, and an owner adds it or turns it down. Kept in |
| 5 | * `install_requests`, one open request per person and listing. |
| 6 | * |
| 7 | * Only what can be added today can be asked for: a published extension, |
| 8 | * or a connector the catalog marks available for a workspace |
| 9 | * (@g1t/contracts/connectors). Agents aren't asked for here: they start |
| 10 | * from templates in Agents mode, and owners make them. Who is told, and |
| 11 | * how, is index.ts's; this file is the rules and the table, tested on |
| 12 | * SQLite. |
| 13 | */ |
| 14 | import type { FailureCode, InstallRequest, InstallRequestStatus, ListingKind, Result } from "@g1t/contracts"; |
| 15 | import { CONNECTORS } from "@g1t/contracts/connectors"; |
| 16 | import { ANSWERED_REQUESTS_DAYS, MAX_OPEN_REQUESTS, extensionById, listingRef, parseListing } from "@g1t/contracts/marketplace"; |
| 17 | import { parsePluginRef } from "@g1t/contracts/claude-plugin"; |
| 18 | |
| 19 | // Plain values rather than @g1t/contracts' helpers, so this file runs under Node's tests. |
| 20 | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); |
| 21 | const fail = (code: FailureCode, message: string): Result<never> => ({ ok: false, error: { code, message } }); |
| 22 | |
| 23 | /** Something the workspace can add, as a request names it. */ |
| 24 | export type Listing = { ref: string; kind: ListingKind; id: string; name: string }; |
| 25 | |
| 26 | /** The listing `value` names, when it is one that can be added today; null otherwise. */ |
| 27 | export function findListing(value: unknown): Listing | null { |
| 28 | const parsed = parseListing(value); |
| 29 | if (!parsed) return null; |
| 30 | if (parsed.kind === "plugin") { |
| 31 | const ref = parsePluginRef(parsed.id); |
| 32 | return ref ? { ref: listingRef("plugin", parsed.id), kind: "plugin", id: parsed.id, name: ref.name } : null; |
| 33 | } |
| 34 | if (parsed.kind === "skill") { |
| 35 | const [plugin, skill] = parsed.id.split("/"); |
| 36 | const ref = parsePluginRef(plugin); |
| 37 | return ref && skill ? { ref: listingRef("skill", parsed.id), kind: "skill", id: parsed.id, name: `${ref.name}:${skill}` } : null; |
| 38 | } |
| 39 | if (parsed.kind === "connector") { |
| 40 | const connector = CONNECTORS.find((c) => c.id === parsed.id && c.mcp); |
| 41 | return connector ? { ref: listingRef("connector", connector.id), kind: "connector", id: connector.id, name: connector.name } : null; |
| 42 | } |
| 43 | if (parsed.kind === "extension") { |
| 44 | const extension = extensionById(parsed.id); |
| 45 | return extension?.status === "available" ? { ref: listingRef("extension", extension.id), kind: "extension", id: extension.id, name: extension.name } : null; |
| 46 | } |
| 47 | const connector = CONNECTORS.find((c) => c.id === parsed.id); |
| 48 | if (!connector || connector.status !== "available" || !connector.scopes.includes("workspace")) return null; |
| 49 | return { ref: listingRef("integration", connector.id), kind: "integration", id: connector.id, name: connector.name }; |
| 50 | } |
| 51 | |
| 52 | type Row = { |
| 53 | id: string; |
| 54 | listing: string; |
| 55 | name: string; |
| 56 | note: string | null; |
| 57 | requested_by: string; |
| 58 | requested_by_id: string; |
| 59 | requested_at: string; |
| 60 | status: string; |
| 61 | resolved_by: string | null; |
| 62 | resolved_at: string | null; |
| 63 | }; |
| 64 | |
| 65 | const COLUMNS = "id, listing, name, note, requested_by, requested_by_id, requested_at, status, resolved_by, resolved_at"; |
| 66 | |
| 67 | /** A row as a request; null for one whose listing is no longer a kind the Marketplace has. */ |
| 68 | export function toRequest(row: Row): InstallRequest | null { |
| 69 | const parsed = parseListing(row.listing); |
| 70 | if (!parsed) return null; |
| 71 | return { |
| 72 | id: row.id, |
| 73 | listing: row.listing, |
| 74 | kind: parsed.kind, |
| 75 | name: row.name, |
| 76 | note: row.note, |
| 77 | requested_by: row.requested_by, |
| 78 | requested_at: row.requested_at, |
| 79 | status: (["open", "done", "declined"].includes(row.status) ? row.status : "open") as InstallRequestStatus, |
| 80 | resolved_by: row.resolved_by, |
| 81 | resolved_at: row.resolved_at, |
| 82 | }; |
| 83 | } |
| 84 | |
| 85 | /** Who is asking or answering. */ |
| 86 | export type Person = { id: string; username: string }; |
| 87 | |
| 88 | /** |
| 89 | * Requests as `viewer` sees them: every one in the workspace for an owner, |
| 90 | * their own for anyone else. Open ones first, then the latest; answered |
| 91 | * ones for `ANSWERED_REQUESTS_DAYS`. At most 200. |
| 92 | */ |
| 93 | export async function listRequests(db: D1Database, workspaceId: string, viewer: Person, owner: boolean, now = new Date()): Promise<InstallRequest[]> { |
| 94 | const since = new Date(now.getTime() - ANSWERED_REQUESTS_DAYS * 86_400_000).toISOString(); |
| 95 | const mine = owner ? "" : " AND requested_by_id = ?3"; |
| 96 | const statement = db.prepare( |
| 97 | `SELECT ${COLUMNS} FROM install_requests |
| 98 | WHERE workspace_id = ?1 AND (status = 'open' OR resolved_at >= ?2)${mine} |
| 99 | ORDER BY status = 'open' DESC, requested_at DESC LIMIT 200`, |
| 100 | ); |
| 101 | const bound = owner ? statement.bind(workspaceId, since) : statement.bind(workspaceId, since, viewer.id); |
| 102 | const { results } = await bound.all<Row>(); |
| 103 | return results.map(toRequest).filter((request) => request !== null); |
| 104 | } |
| 105 | |
| 106 | /** |
| 107 | * Opens a request for `listing`, as `id`. A conflict when the person has |
| 108 | * one open for it already; a limit when they have `MAX_OPEN_REQUESTS` open. |
| 109 | */ |
| 110 | export async function openRequest( |
| 111 | db: D1Database, |
| 112 | workspaceId: string, |
| 113 | id: string, |
| 114 | listing: Listing, |
| 115 | by: Person, |
| 116 | note: string | null, |
| 117 | now = new Date(), |
| 118 | ): Promise<Result<InstallRequest>> { |
| 119 | const open = await db |
| 120 | .prepare("SELECT COUNT(*) AS n FROM install_requests WHERE workspace_id = ? AND requested_by_id = ? AND status = 'open'") |
| 121 | .bind(workspaceId, by.id) |
| 122 | .first<{ n: number }>(); |
| 123 | if ((open?.n ?? 0) >= MAX_OPEN_REQUESTS) { |
| 124 | return fail("limit", `You have ${MAX_OPEN_REQUESTS} requests waiting on an owner. Ask again once some are answered.`); |
| 125 | } |
| 126 | const row: Row = { |
| 127 | id, |
| 128 | listing: listing.ref, |
| 129 | name: listing.name, |
| 130 | note, |
| 131 | requested_by: by.username, |
| 132 | requested_by_id: by.id, |
| 133 | requested_at: now.toISOString(), |
| 134 | status: "open", |
| 135 | resolved_by: null, |
| 136 | resolved_at: null, |
| 137 | }; |
| 138 | // The unique index on open requests turns a second press into nothing. |
| 139 | const inserted = await db |
| 140 | .prepare( |
| 141 | `INSERT INTO install_requests (id, workspace_id, listing, name, note, requested_by, requested_by_id, requested_at, status) |
| 142 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open') ON CONFLICT DO NOTHING RETURNING id`, |
| 143 | ) |
| 144 | .bind(row.id, workspaceId, row.listing, row.name, row.note, row.requested_by, row.requested_by_id, row.requested_at) |
| 145 | .first<{ id: string }>(); |
| 146 | if (!inserted) return fail("conflict", `You've already asked for ${listing.name}. The workspace's owners have your request.`); |
| 147 | return ok(toRequest(row)!); |
| 148 | } |
| 149 | |
| 150 | /** A request answered, with who asked, for telling them. */ |
| 151 | export type Answered = { request: InstallRequest; requested_by_id: string }; |
| 152 | |
| 153 | /** An owner adds (`done`) or turns down (`declined`) an open request. */ |
| 154 | export async function resolveRequest( |
| 155 | db: D1Database, |
| 156 | workspaceId: string, |
| 157 | id: string, |
| 158 | status: Exclude<InstallRequestStatus, "open">, |
| 159 | by: Person, |
| 160 | now = new Date(), |
| 161 | ): Promise<Result<Answered>> { |
| 162 | if (status !== "done" && status !== "declined") return fail("invalid", "Answer a request with done or declined."); |
| 163 | const row = await db |
| 164 | .prepare( |
| 165 | `UPDATE install_requests SET status = ?, resolved_by = ?, resolved_at = ? |
| 166 | WHERE id = ? AND workspace_id = ? AND status = 'open' RETURNING ${COLUMNS}`, |
| 167 | ) |
| 168 | .bind(status, by.username, now.toISOString(), id, workspaceId) |
| 169 | .first<Row>(); |
| 170 | const request = row ? toRequest(row) : null; |
| 171 | if (row && request) return ok({ request, requested_by_id: row.requested_by_id }); |
| 172 | const exists = await db.prepare("SELECT status FROM install_requests WHERE id = ? AND workspace_id = ?").bind(id, workspaceId).first<{ status: string }>(); |
| 173 | return exists ? fail("conflict", "That request was already answered.") : fail("not_found", "There is no such request."); |
| 174 | } |
| 175 | |
| 176 | /** |
| 177 | * Every open request for `listing`, marked added: an owner added it, so |
| 178 | * whoever asked for it has what they asked for. |
| 179 | */ |
| 180 | export async function resolveListing(db: D1Database, workspaceId: string, listing: string, by: Person, now = new Date()): Promise<Answered[]> { |
| 181 | const { results } = await db |
| 182 | .prepare( |
| 183 | `UPDATE install_requests SET status = 'done', resolved_by = ?, resolved_at = ? |
| 184 | WHERE workspace_id = ? AND listing = ? AND status = 'open' RETURNING ${COLUMNS}`, |
| 185 | ) |
| 186 | .bind(by.username, now.toISOString(), workspaceId, listing) |
| 187 | .all<Row>(); |
| 188 | return results.flatMap((row) => { |
| 189 | const request = toRequest(row); |
| 190 | return request ? [{ request, requested_by_id: row.requested_by_id }] : []; |
| 191 | }); |
| 192 | } |
| 193 | |
| 194 | /** Where a request is looked at in the workspace `slug`. */ |
| 195 | export function requestsPath(slug: string): string { |
| 196 | return `/${slug}/-/customize/requests`; |
| 197 | } |
| 198 | |
| 199 | /** Where the thing a request asked for is, once added. */ |
| 200 | export function listingPath(slug: string, listing: string): string { |
| 201 | const parsed = parseListing(listing); |
| 202 | if (parsed?.kind === "plugin") return `/${slug}/-/customize/plugins/${encodeURIComponent(parsed.id)}`; |
| 203 | if (parsed?.kind === "extension") return `/${slug}/-/customize/plugins/${encodeURIComponent(`${parsed.id}@g1t`)}`; |
| 204 | if (parsed?.kind === "skill") return `/${slug}/-/customize/skills`; |
| 205 | return `/${slug}/-/customize/connectors`; |
| 206 | } |
| 207 | |
| 208 | /** What the person who asked is told when their request is answered. */ |
| 209 | export function answerLine(request: InstallRequest, by: string): { title: string; body: string } { |
| 210 | if (request.status === "declined") { |
| 211 | return { title: `${by} turned down your request for ${request.name}`, body: "Ask them in chat if you want to know why." }; |
| 212 | } |
| 213 | return { |
| 214 | title: `${request.name} was added to the workspace`, |
| 215 | body: request.kind === "extension" || request.kind === "plugin" ? `${by} installed it.` : request.kind === "skill" ? `${by} added it.` : `${by} connected it.`, |
| 216 | }; |
| 217 | } |
| 218 | |
| 219 | /** |
| 220 | * After something was added from Customize: every open request for its |
| 221 | * listing is marked done. Telling the people who asked is index.ts's; |
| 222 | * here the rows change, so a listing added twice answers nothing new. |
| 223 | */ |
| 224 | export async function answerListing(ctx: { db: D1Database; workspaceId: string; viewer: { id: string; username: string } }, listing: string): Promise<Answered[]> { |
| 225 | try { |
| 226 | return await resolveListing(ctx.db, ctx.workspaceId, listing, { id: ctx.viewer.id, username: ctx.viewer.username }); |
| 227 | } catch (error) { |
| 228 | console.error("agents: requests for a listing were not answered", listing, String(error)); |
| 229 | return []; |
| 230 | } |
| 231 | } |