The Marketplace is where a workspace finds what to add, reached from Apps: the agent catalog of roles with who already does each job and Add to workspace; every integration with whether it's connected; g1t's own extensions, listed as Soon; and requests, where members ask and owners add or turn down, each told by a notification. Underneath, an extension is a manifest (publisher and tier, source and version from a tag, scopes, the domains its data goes to, its page on g1tusercontent.com, no price) and an install pins its version with an off switch, a budget and the audit log. Apps lists only what was added from the Marketplace, with pins; g1t's own pages stay in their sidebars, and AI Gateway joins Workspace's. The marketplace guide says how.
45 files+3568−3370/45 viewed
| 55 | 55 | | Spend: where the money went by agent, person, channel, model and product, every budget, a receipt for each task, and your spend in the top bar | Preview | | |
| 56 | 56 | | Spend by extension | Coming | | |
| 57 | 57 | | Memory, sessions with live steps and cost, routines on a schedule | Live | | |
| 58 | − | | An agent catalog of specialists, and foundational skills (documents, research, data, code, communication, files) | Coming | | |
| 58 | + | | An agent catalog in the Marketplace: roles to add an agent into, which members can ask owners for | Live | | |
| 59 | + | | More catalog specialists, and foundational skills (documents, research, data, code, communication, files) | Coming | | |
| 59 | 60 | | Agents on runners anywhere (g1t's, yours, your desktop), with sessions that persist between tasks | Coming | | |
| 60 | 61 | | Agents answering in Slack and Teams | Coming | | |
| 61 | 62 | ||
| ⋯ | |||
| 83 | 84 | | Integrations: the GitHub App, Sentry, Datadog, Jira, Linear, alerts | Live | | |
| 84 | 85 | | REST API, OpenAPI, MCP server, webhooks, an event bus | Live | | |
| 85 | 86 | | Usage billing with no seats, a public price book, spend limits, itemised invoices | Live | | |
| 86 | − | | Marketplace of extensions, shared from public repositories; Drive, Gmail, Calendar and Slack integrations; Mail | Coming | | |
| 87 | + | | The Marketplace: agents from the catalog and integrations, added by owners, with install requests from members | Live | | |
| 88 | + | | Extensions shared from public repositories (Support, Recruiting, Mail and On-call are listed); Drive, Gmail, Calendar and Slack integrations | Coming | | |
| 87 | 89 | | Single sign-on (SAML, OIDC) and SCIM per workspace | Coming | | |
| 88 | 90 | | Storage: databases and buckets per team, with copies of production for anything unreviewed | Coming | | |
| 89 | 91 | | Self-hosting: the core forge in Docker Compose (agents, deployments and context search off) | Preview | | |
| 144 | 144 | { | |
| 145 | 145 | label: 'Connect your tools', | |
| 146 | 146 | items: [ | |
| 147 | + | { label: 'Marketplace', slug: 'guides/marketplace' }, | |
| 147 | 148 | { label: 'Integrations', slug: 'guides/integrations' }, | |
| 148 | 149 | { label: 'Model providers', slug: 'guides/models' }, | |
| 149 | 150 | { label: 'AI Gateway', slug: 'guides/ai-gateway' }, |
| 65 | 65 | <Steps> | |
| 66 | 66 | ||
| 67 | 67 | 1. Open **Agents** in the dock, then choose **New agent**, or go to | |
| 68 | − | `g1t.sh/<workspace>/-/agents/new`. | |
| 68 | + | `g1t.sh/<workspace>/-/agents/new`. Or start from the | |
| 69 | + | [agent catalog](/guides/marketplace/#the-agent-catalog) in the | |
| 70 | + | Marketplace, where members who aren't owners can ask for a role. | |
| 69 | 71 | 2. **Pick a role** from the templates, grouped by department, or choose | |
| 70 | 72 | **Start from nothing**. A role fills in every field below, and you can | |
| 71 | 73 | change any of them. See [role templates](#role-templates). |
| 1 | + | --- | |
| 2 | + | title: Marketplace | |
| 3 | + | description: Add agents, integrations and extensions to a workspace. Anyone browses the Marketplace; owners add things, and everyone else asks an owner. What each listing may do, where its data goes, and how installs, requests and the kill switch work. | |
| 4 | + | --- | |
| 5 | + | ||
| 6 | + | The Marketplace is where a workspace adds what isn't built in. Code, | |
| 7 | + | Deployments, Chat, Agents and Artifacts come with every workspace; the | |
| 8 | + | Marketplace lists what you can add to them: | |
| 9 | + | ||
| 10 | + | | Kind | What it adds | Status | | |
| 11 | + | | --- | --- | --- | | |
| 12 | + | | **Agents** | An agent hired into a role from the agent catalog. | Live | | |
| 13 | + | | **Integrations** | A connection to a tool you already use, which gives agents new abilities without adding pages. | Live for the integrations marked available; the rest are coming | | |
| 14 | + | | **Extensions** | Pages, data, cards and agent roles for what your team does besides code. | Coming: g1t's own are listed, and install from their first release | | |
| 15 | + | ||
| 16 | + | Open it from **Apps** at the foot of the dock (the launcher's | |
| 17 | + | **Marketplace** link, or **Browse the Marketplace** on the Apps page), from | |
| 18 | + | **More** on a phone, or with <kbd>Ctrl</kbd> <kbd>K</kbd> and | |
| 19 | + | *Marketplace*. Its address is `g1t.sh/<workspace>/-/marketplace`. The | |
| 20 | + | Marketplace isn't in the dock itself. | |
| 21 | + | ||
| 22 | + | ## Who can add things | |
| 23 | + | ||
| 24 | + | Every member of a workspace can browse the Marketplace. Only its | |
| 25 | + | **owners** add agents, connect integrations and install extensions, | |
| 26 | + | because each of them can spend the workspace's money or reach its data. | |
| 27 | + | Everyone else sees **Request** where an owner sees **Add**, and asks. | |
| 28 | + | ||
| 29 | + | ## The Marketplace's pages | |
| 30 | + | ||
| 31 | + | | Tab | What it shows | | |
| 32 | + | | --- | --- | | |
| 33 | + | | **Discover** | What every workspace has, a few roles from the agent catalog with `@g1t` first, integrations with the connected ones first, and extensions. | | |
| 34 | + | | **Agents** | The agent catalog: every role, by department, with who in the workspace has the job. | | |
| 35 | + | | **Integrations** | Connected integrations, the ones you can connect now, and the ones coming. Search by name, by what it does, or by a word such as *tracker*. | | |
| 36 | + | | **Extensions** | Every extension listed, published ones first, and how extensions are shared. | | |
| 37 | + | | **Requests** | For owners, every request members made, waiting first; for anyone else, **Your requests**. The tab shows how many are waiting. | | |
| 38 | + | ||
| 39 | + | ## The agent catalog | |
| 40 | + | ||
| 41 | + | Each role in the catalog is a starting point for an agent: a name it | |
| 42 | + | suggests, a title, responsibilities, a voice, model limits and the helpers | |
| 43 | + | it works with. [Role templates](/guides/agents/#role-templates) lists them. | |
| 44 | + | Choose a role to see all of it, including the instructions it starts from | |
| 45 | + | and who in the workspace was already hired into it. | |
| 46 | + | ||
| 47 | + | To add an agent from the catalog as an owner: | |
| 48 | + | ||
| 49 | + | 1. Open **Marketplace → Agents**, or `g1t.sh/<workspace>/-/marketplace/agents`. | |
| 50 | + | 2. Choose **Add to workspace** on the role (**Add another** when someone | |
| 51 | + | already has the job). | |
| 52 | + | 3. Finish the new-agent form, which opens with the role chosen, and choose | |
| 53 | + | **Create agent**. See [Hire an agent](/guides/agents/#hire-an-agent). | |
| 54 | + | ||
| 55 | + | Adding an agent from a role answers every open request for that role: each | |
| 56 | + | person who asked hears that it was added. | |
| 57 | + | ||
| 58 | + | ## Integrations | |
| 59 | + | ||
| 60 | + | The Marketplace lists every integration in the | |
| 61 | + | [integrations directory](/guides/integrations/) that a workspace can | |
| 62 | + | connect, with whether yours has. **Connect** opens its setup page; | |
| 63 | + | **Manage** opens it once it is connected. **Needs attention** means a | |
| 64 | + | connection's last check failed: hover it to see why. | |
| 65 | + | ||
| 66 | + | Integrations each person connects for themselves, such as a calendar or a | |
| 67 | + | mailbox, are listed among the coming ones; they are set up in your own | |
| 68 | + | [settings](/guides/integrations/#workspace-and-personal) once available. | |
| 69 | + | ||
| 70 | + | ## Extensions | |
| 71 | + | ||
| 72 | + | An extension adds pages to its own sidebar, data, cards in chat, tools for | |
| 73 | + | agents and agent roles. g1t's own are listed now, marked **Soon**, so you | |
| 74 | + | can see what each will add and what it will be able to do: **Support**, | |
| 75 | + | **Recruiting**, **Mail** and **On-call**. None can be installed before its | |
| 76 | + | first release. | |
| 77 | + | ||
| 78 | + | ### Who stands behind a listing | |
| 79 | + | ||
| 80 | + | | Tier | Who | | | |
| 81 | + | | --- | --- | --- | | |
| 82 | + | | **Official** | g1t | Built and supported by g1t. | | |
| 83 | + | | **Verified** | A reviewed publisher | Code and permissions are checked before it is listed. | | |
| 84 | + | | **Community** | Anyone | Shared from a public repository. Pages run sandboxed; server code runs on the publisher's own servers until hosted code has an isolated sandbox. | | |
| 85 | + | | **Internal** | Your workspace | Built by your own people and agents, seen only by your workspace. | | |
| 86 | + | ||
| 87 | + | ### What an extension's page tells you | |
| 88 | + | ||
| 89 | + | Before anything is installed, its page lists: | |
| 90 | + | ||
| 91 | + | - **What it adds**: its pages, the agent roles it brings, the tools agents | |
| 92 | + | get, and the notifications it sends. | |
| 93 | + | - **It will be able to**: what it may do, in plain words, and the scopes | |
| 94 | + | its token asks for, each described. | |
| 95 | + | - **Where it runs, and where data goes**: on g1t, or on its publisher's | |
| 96 | + | servers, and every domain outside g1t its data goes to, as *Data leaves | |
| 97 | + | g1t to api.example.com*. *Its data stays in g1t* means it sends nothing | |
| 98 | + | elsewhere. | |
| 99 | + | - **Version** and **Source**: the release it installs, and the repository | |
| 100 | + | and tag it was published from. | |
| 101 | + | - **Price**: every listing is free. What its agents do is billed at what | |
| 102 | + | it costs, like any agent's work. | |
| 103 | + | ||
| 104 | + | ### How extensions are shared | |
| 105 | + | ||
| 106 | + | An extension is a public repository on g1t with a manifest, | |
| 107 | + | `.g1t/extension.json`. Tagging a release (`v1.2.0`) publishes that version. | |
| 108 | + | An install keeps the version it was installed at; updates are offered to | |
| 109 | + | owners, never forced. | |
| 110 | + | ||
| 111 | + | | Manifest field | What it says | | |
| 112 | + | | --- | --- | | |
| 113 | + | | `id`, `name`, `tagline`, `description`, `category` | What it is called and what it does. | | |
| 114 | + | | `publisher` | Who publishes it, and its tier. | | |
| 115 | + | | `runtime` | `hosted` (runs on g1t) or `connected` (runs on the publisher's servers). | | |
| 116 | + | | `scopes` | The [scopes](/guides/authentication/#scopes) its token asks for. | | |
| 117 | + | | `permissions` | What it may do, in plain words, for the install screen. | | |
| 118 | + | | `domains` | Every host outside g1t its data goes to. A connected extension must name the ones it runs on. | | |
| 119 | + | | `ui.entry` | Its page, loaded in a sandboxed frame from the user-content domain, which reaches g1t only through what it was allowed. | | |
| 120 | + | | `adds` | Its `pages`, `agent_roles`, `tools` and `notifications`. | | |
| 121 | + | | `pricing` | `null`: listings are free. | | |
| 122 | + | ||
| 123 | + | ### Installed extensions | |
| 124 | + | ||
| 125 | + | Once installed, an owner can, from the extension's page: | |
| 126 | + | ||
| 127 | + | - **Switch off**: the kill switch. Its token stops working and its page | |
| 128 | + | stops loading at once, until it is switched on again. | |
| 129 | + | - Cap what it spends in a month. Without a cap, the workspace's | |
| 130 | + | [spend limit](/guides/usage-and-billing/) applies. | |
| 131 | + | - **Uninstall** it. The record of who installed and removed it stays. | |
| 132 | + | ||
| 133 | + | Installing, switching on or off, setting a budget and uninstalling are in | |
| 134 | + | the [audit log](/guides/audit-log/). | |
| 135 | + | ||
| 136 | + | ## Ask an owner to add something | |
| 137 | + | ||
| 138 | + | 1. Choose **Request** on an agent, an integration or an extension. | |
| 139 | + | 2. Say why you want it, if you like, and choose **Send request**. | |
| 140 | + | ||
| 141 | + | Every owner is notified. The button then reads **Requested**, and **Your | |
| 142 | + | requests** lists it until an owner answers; you are notified when one does. | |
| 143 | + | You can't ask twice for the same thing while a request is waiting, and you | |
| 144 | + | can have 20 waiting at once. | |
| 145 | + | ||
| 146 | + | To answer requests as an owner, open **Marketplace → Requests**: | |
| 147 | + | ||
| 148 | + | | | | | |
| 149 | + | | --- | --- | | |
| 150 | + | | **Add**, **Connect** or **Install** | Opens where it is added. Adding an agent from its role answers every request for that role by itself. | | |
| 151 | + | | **Mark added** | After you connected the integration, or added it another way. | | |
| 152 | + | | **Turn down** | The person who asked is told. They can ask again. | | |
| 153 | + | ||
| 154 | + | Answered requests stay listed for 30 days. | |
| 155 | + | ||
| 156 | + | ## Apps | |
| 157 | + | ||
| 158 | + | What the workspace added from the Marketplace that you can use is under | |
| 159 | + | **Apps**: today, the integrations it connected. Pin the ones you want to | |
| 160 | + | your dock. See [Apps](/guides/workspaces/#apps). |
| 596 | 596 | ||
| 597 | 597 | ### Apps | |
| 598 | 598 | ||
| 599 | − | Every other app in the workspace is a page you can pin to your dock: | |
| 600 | − | **Projects**, **Packages**, **Security**, **Context** and **Memory** (for | |
| 601 | − | members with Code access), **Teams**, **Usage**, **AI Gateway**, | |
| 602 | − | **Integrations** and **Audit log**. A pinned app shows as its icon under | |
| 603 | − | the built-in ones, in the order you pinned them. | |
| 599 | + | Apps are what the workspace added from the [Marketplace](/guides/marketplace/) | |
| 600 | + | that you can use: today, the integrations it connected, such as Sentry or | |
| 601 | + | GitHub, each opening its own page; installed extensions join them once | |
| 602 | + | extensions are published. An app you can't use shows **Request access**. | |
| 603 | + | The built-in apps' own pages are in their sidebars, not in Apps: Code's has | |
| 604 | + | **Projects**, **Security** and **Packages**; Agents' has **Context** and | |
| 605 | + | **Memory**; People's has **Teams**; Workspace's has **Usage**, | |
| 606 | + | **AI Gateway**, **Integrations** and the **Audit log**. | |
| 604 | 607 | ||
| 605 | − | To pin an app: | |
| 608 | + | A pinned app shows as its mark under the built-in ones, in the order you | |
| 609 | + | pinned them. To pin one: | |
| 606 | 610 | ||
| 607 | 611 | 1. Select **Apps** near the foot of the dock, or open the Apps page at | |
| 608 | 612 | `g1t.sh/<workspace>/-/apps`. | |
| 609 | 613 | 2. Find the app, by name if you like. | |
| 610 | 614 | 3. Select the pin on its tile. Select it again to unpin it. | |
| 611 | 615 | ||
| 616 | + | When the workspace hasn't added anything yet, the launcher and the Apps | |
| 617 | + | page say so and lead to the Marketplace. The launcher's **Marketplace** | |
| 618 | + | link always does. | |
| 619 | + | ||
| 612 | 620 | Your pins are yours alone and each workspace has its own. They are saved | |
| 613 | 621 | to your account, so your dock is the same on every browser and device you | |
| 614 | − | sign in on, in the order you pinned. There is no marketplace yet: it | |
| 615 | − | shows as coming. | |
| 622 | + | sign in on, in the order you pinned. | |
| 616 | 623 | ||
| 617 | 624 | ### The sidebar | |
| 618 | 625 | ||
| ⋯ | |||
| 634 | 641 | the sidebar already there. | |
| 635 | 642 | ||
| 636 | 643 | Workspace's sidebar lists the workspace's **Overview**; under **Money**, | |
| 637 | − | [**Spend**](/guides/spend/), **Usage**, | |
| 638 | − | **Billing and plans**, **Integrations**, its security policies, the | |
| 644 | + | [**Spend**](/guides/spend/), **Usage**, **AI Gateway**, | |
| 645 | + | **Billing and plans**; for owners, **Runners** under **Compute**; | |
| 646 | + | **Integrations**, its security policies, the | |
| 639 | 647 | **Audit log** and **Settings**, which slides over to how the workspace is | |
| 640 | 648 | set up: | |
| 641 | 649 | ||
| ⋯ | |||
| 689 | 697 | | | What it does | | |
| 690 | 698 | | --- | --- | | |
| 691 | 699 | | **The bar along the bottom** | **Today**, **Chat**, **Notifications**, **Agents** and **Code** (for members with Code access), each with what is unread, and **More**. It steps aside while the keyboard is up and inside a conversation. | | |
| 692 | − | | **More** | A panel above the bar with **All apps**, the Marketplace (coming), your pinned apps, **Artifacts**, **People**, **Workspace**, and **You and help**: your status, profile and settings, [appearance](#appearance), the documentation, support, status, the keyboard's shortcuts and signing out. | | |
| 700 | + | | **More** | A panel above the bar with **All apps**, the [Marketplace](/guides/marketplace/), your pinned apps, **Artifacts**, **People**, **Workspace**, and **You and help**: your status, profile and settings, [appearance](#appearance), the documentation, support, status, the keyboard's shortcuts and signing out. | | |
| 693 | 701 | | **The sidebar button**, at the left of the page's header | Opens the sidebar of the app you are in from the left: the same lists and links as on a computer. Tap outside it, or open a page, and it closes. | | |
| 694 | 702 | | **The tab you are already on** | Tap it again to open that app's sidebar too. | | |
| 695 | 703 | | **The workspace's name** in the page's header | Switches workspace, or opens your profile or settings. | | |
| 83 | 83 | | [Spend](/guides/spend/) | Where the money went, by agent, person, channel, model and product; every budget with what happens at 100%; a receipt for each task; your own spend in the top bar. | <Status is="preview" /> | | |
| 84 | 84 | | [Acting for the asker](/guides/agent-access/) | Agents act with the access of the person who asked, and answer with only what their audience may see. | <Status is="live" /> | | |
| 85 | 85 | | [Memory](/guides/agent-memory/), [sessions](/guides/agent-sessions/) and [routines](/guides/agent-routines/) | What an agent keeps, every run with its steps and cost, and work on a schedule. | <Status is="live" /> | | |
| 86 | − | | Agent catalog | Specialists to hire from a catalog, each with a role, skills, tools and the runner it prefers. | <Status is="coming" /> | | |
| 86 | + | | [Agent catalog](/guides/marketplace/#the-agent-catalog) | Roles to add an agent into, from the Marketplace, each with responsibilities, a voice and the helpers it works with. | <Status is="live" /> | | |
| 87 | + | | More specialists | Catalog roles that bring their own skills, tools and the runner they prefer. | <Status is="coming" /> | | |
| 87 | 88 | | Foundational skills | Documents, research on the web, data and charts, code, communication, and files, in every agent. | <Status is="coming" /> | | |
| 88 | 89 | | Agents on runners | Every agent's machine is a runner: g1t's, one of yours, or your desktop, with sessions that last between tasks. | <Status is="coming" /> | | |
| 89 | 90 | | [Agents in your chat app](/guides/chat-app/) | The same agents, answering in the chat app your team already uses. | <Status is="coming" /> | | |
| ⋯ | |||
| 112 | 113 | | --- | --- | --- | | |
| 113 | 114 | | [Integrations](/guides/integrations/) | The GitHub App, Sentry, Datadog, Jira, Linear and alerts. | <Status is="live" /> | | |
| 114 | 115 | | [API](/reference/api/), [MCP](/reference/mcp/) and [webhooks](/guides/webhooks/) | Everything as a REST endpoint and an MCP tool, and a webhook for every event. | <Status is="live" /> | | |
| 115 | − | | Marketplace | Extensions that add pages, cards, tools and agent roles, installed by an owner after reading their permissions. | <Status is="coming" /> | | |
| 116 | + | | [Marketplace](/guides/marketplace/) | Agents from the catalog and integrations, added by owners; members ask for what they need. | <Status is="live" /> | | |
| 117 | + | | Extensions | Pages, cards, tools and agent roles shared from public repositories, installed by an owner after reading their permissions and where their data goes. Support, Recruiting, Mail and On-call are listed. | <Status is="coming" /> | | |
| 116 | 118 | | More integrations | Drive, Gmail, Calendar, Slack and Microsoft 365, each listing what it lets agents do. | <Status is="coming" /> | | |
| 117 | 119 | | Mail | Email on your own domain, with shared inboxes that agents work in. | <Status is="coming" /> | | |
| 118 | 120 | ||
| 1 | − | import { | |
| 2 | − | BarChart3, | |
| 3 | − | Bell, | |
| 4 | − | Boxes, | |
| 5 | − | Brain, | |
| 6 | − | Building2, | |
| 7 | − | Code2, | |
| 8 | − | History, | |
| 9 | − | LayoutGrid, | |
| 10 | − | MessagesSquare, | |
| 11 | − | Network, | |
| 12 | − | Package, | |
| 13 | − | Pin, | |
| 14 | − | Plug, | |
| 15 | − | Search, | |
| 16 | − | Shapes, | |
| 17 | − | ShieldCheck, | |
| 18 | − | Sparkles, | |
| 19 | − | Store, | |
| 20 | − | Sun, | |
| 21 | − | Users, | |
| 22 | − | UsersRound, | |
| 23 | − | Waypoints, | |
| 24 | − | } from "lucide-react"; | |
| 25 | − | import { type ReactNode, useMemo, useState } from "react"; | |
| 1 | + | import { Bell, Building2, Code2, LayoutGrid, Lock, MessagesSquare, Pin, Search, Shapes, Sparkles, Store, Sun, Users } from "lucide-react"; | |
| 2 | + | import { type ReactNode, useEffect, useMemo, useState } from "react"; | |
| 26 | 3 | import { Link, useFetcher, useFetchers } from "react-router"; | |
| 27 | 4 | ||
| 28 | − | import { type AppInfo, type AppKey, type PinnableApp, appPinFromForm, appsFor, withPin } from "../lib/apps"; | |
| 5 | + | import { ConnectorMark } from "./connectors"; | |
| 6 | + | import { Skeleton } from "./ui/skeleton"; | |
| 7 | + | import { type BuiltinApp, type InstalledAppData, type PinnableApp, appPinFromForm, installedAppOf, withPin } from "../lib/apps"; | |
| 29 | 8 | import { cn } from "../lib/cn"; | |
| 30 | 9 | import { Hint } from "./ui/hint"; | |
| 31 | 10 | ||
| 32 | − | /** Each app's icon, at the size asked for. */ | |
| 33 | − | export function appIcon(key: AppKey, size = 18): ReactNode { | |
| 34 | − | const icons: Record<AppKey, ReactNode> = { | |
| 11 | + | /** Each built-in app's icon, at the size asked for. */ | |
| 12 | + | export function appIcon(key: BuiltinApp, size = 18): ReactNode { | |
| 13 | + | const icons: Record<BuiltinApp, ReactNode> = { | |
| 35 | 14 | today: <Sun size={size} />, | |
| 36 | 15 | chat: <MessagesSquare size={size} />, | |
| 37 | 16 | notifications: <Bell size={size} />, | |
| ⋯ | |||
| 40 | 19 | artifacts: <Shapes size={size} />, | |
| 41 | 20 | people: <Users size={size} />, | |
| 42 | 21 | workspace: <Building2 size={size} />, | |
| 43 | − | projects: <Boxes size={size} />, | |
| 44 | − | packages: <Package size={size} />, | |
| 45 | − | security: <ShieldCheck size={size} />, | |
| 46 | − | context: <Network size={size} />, | |
| 47 | − | memory: <Brain size={size} />, | |
| 48 | − | teams: <UsersRound size={size} />, | |
| 49 | − | usage: <BarChart3 size={size} />, | |
| 50 | − | gateway: <Waypoints size={size} />, | |
| 51 | − | integrations: <Plug size={size} />, | |
| 52 | − | audit: <History size={size} />, | |
| 53 | 22 | }; | |
| 54 | 23 | return icons[key]; | |
| 55 | 24 | } | |
| 56 | 25 | ||
| 26 | + | /** An installed app's mark: its connector's, until extensions bring their own. */ | |
| 27 | + | export function InstalledMark({ app, size = 22 }: { app: { connector: InstalledAppData["connector"] }; size?: number }) { | |
| 28 | + | return <ConnectorMark view={{ id: app.connector.id, name: app.connector.name, provider: app.connector.provider ?? null }} size={size} />; | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /** A pinned app as the dock draws it, from its key alone; null when the key names nothing installable. */ | |
| 32 | + | export function pinnedApp(key: PinnableApp, slug: string): { key: string; name: string; to: string; icon: (size: number) => ReactNode } | null { | |
| 33 | + | const app = installedAppOf(key); | |
| 34 | + | if (!app) return null; | |
| 35 | + | return { key, name: app.name, to: app.path(slug), icon: (size) => <InstalledMark app={app} size={size} /> }; | |
| 36 | + | } | |
| 37 | + | ||
| 57 | 38 | /** Every pin form shares this key, so the dock, the launcher and the Apps page see one change. */ | |
| 58 | 39 | const PIN_FETCHER = "app-pin"; | |
| 59 | 40 | ||
| ⋯ | |||
| 84 | 65 | } | |
| 85 | 66 | ||
| 86 | 67 | /** | |
| 87 | − | * One app: its icon and name, opening it; and, for an app people pin, a | |
| 88 | − | * pin that shows on hover or focus and stays lit while it is pinned. | |
| 68 | + | * One installed app: its mark and name, opening it; and a pin that shows | |
| 69 | + | * on hover or focus and stays lit while it is pinned. An app the person | |
| 70 | + | * can't use says Request access instead, and has no pin. | |
| 89 | 71 | */ | |
| 90 | 72 | export function AppTile({ | |
| 91 | 73 | app, | |
| 92 | − | slug, | |
| 93 | 74 | pinned, | |
| 94 | 75 | onToggle, | |
| 95 | 76 | onOpen, | |
| 96 | 77 | card = false, | |
| 97 | 78 | }: { | |
| 98 | − | app: AppInfo; | |
| 99 | − | slug: string; | |
| 79 | + | app: InstalledAppData; | |
| 100 | 80 | pinned: boolean; | |
| 101 | 81 | onToggle: (app: PinnableApp) => void; | |
| 102 | 82 | onOpen?: () => void; | |
| 103 | 83 | /** On the Apps page: a card with what the app is. */ | |
| 104 | 84 | card?: boolean; | |
| 105 | 85 | }) { | |
| 86 | + | const body = ( | |
| 87 | + | <> | |
| 88 | + | <span className="relative flex shrink-0"> | |
| 89 | + | <InstalledMark app={app} size={40} /> | |
| 90 | + | {!app.usable && ( | |
| 91 | + | <span className="absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-surface text-faint ring-1 ring-line"> | |
| 92 | + | <Lock size={9} /> | |
| 93 | + | </span> | |
| 94 | + | )} | |
| 95 | + | </span> | |
| 96 | + | {card ? ( | |
| 97 | + | <span className="min-w-0"> | |
| 98 | + | <span className="block truncate text-sm font-medium text-fg">{app.name}</span> | |
| 99 | + | <span className="block truncate text-xs text-muted">{app.usable ? app.about : "Request access"}</span> | |
| 100 | + | </span> | |
| 101 | + | ) : ( | |
| 102 | + | <span className="w-full min-w-0"> | |
| 103 | + | <span className="block truncate text-xs font-medium text-fg-soft">{app.name}</span> | |
| 104 | + | {!app.usable && <span className="block truncate text-[0.625rem] text-accent">Request access</span>} | |
| 105 | + | </span> | |
| 106 | + | )} | |
| 107 | + | </> | |
| 108 | + | ); | |
| 109 | + | const linkClass = cn( | |
| 110 | + | "flex w-full rounded-xl text-center outline-none focus-visible:ring-2 focus-visible:ring-accent", | |
| 111 | + | card ? "items-center gap-3 p-3.5 pr-11 text-left" : "flex-col items-center gap-1.5 px-1 pt-3 pb-2.5", | |
| 112 | + | ); | |
| 106 | 113 | return ( | |
| 107 | 114 | <div | |
| 108 | 115 | className={cn( | |
| ⋯ | |||
| 110 | 117 | card ? "border border-line bg-surface hover:border-line-strong" : "hover:bg-raised focus-within:bg-raised", | |
| 111 | 118 | )} | |
| 112 | 119 | > | |
| 113 | − | <Link | |
| 114 | − | to={app.path(slug)} | |
| 115 | − | prefetch="intent" | |
| 116 | − | onClick={onOpen} | |
| 117 | − | className={cn( | |
| 118 | − | "flex w-full rounded-xl text-center outline-none focus-visible:ring-2 focus-visible:ring-accent", | |
| 119 | − | card ? "items-center gap-3 p-3.5 pr-11 text-left" : "flex-col items-center gap-1.5 px-1 pt-3 pb-2.5", | |
| 120 | − | )} | |
| 121 | − | > | |
| 122 | − | <span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-raised text-fg ring-1 ring-line group-hover/tile:ring-line-strong"> | |
| 123 | − | {appIcon(app.key, 19)} | |
| 124 | − | </span> | |
| 125 | − | {card ? ( | |
| 126 | − | <span className="min-w-0"> | |
| 127 | − | <span className="block truncate text-sm font-medium text-fg">{app.name}</span> | |
| 128 | − | <span className="block truncate text-xs text-muted">{app.about}</span> | |
| 129 | − | </span> | |
| 130 | − | ) : ( | |
| 131 | − | <span className="w-full truncate text-xs font-medium text-fg-soft">{app.name}</span> | |
| 132 | − | )} | |
| 120 | + | {/* An app the person can't use leads (`href`) to where they ask for it. */} | |
| 121 | + | <Link to={app.href} prefetch="intent" onClick={onOpen} className={linkClass}> | |
| 122 | + | {body} | |
| 133 | 123 | </Link> | |
| 134 | − | {!app.builtin && ( | |
| 124 | + | {app.usable && ( | |
| 135 | 125 | <Hint label={pinned ? "Unpin from your dock" : "Pin to your dock"}> | |
| 136 | 126 | <button | |
| 137 | 127 | type="button" | |
| 138 | 128 | aria-label={pinned ? `Unpin ${app.name} from your dock` : `Pin ${app.name} to your dock`} | |
| 139 | 129 | aria-pressed={pinned} | |
| 140 | − | onClick={() => onToggle(app.key as PinnableApp)} | |
| 130 | + | onClick={() => onToggle(app.key)} | |
| 141 | 131 | className={cn( | |
| 142 | 132 | "absolute flex size-6 items-center justify-center rounded-md outline-none transition-opacity hover:bg-line focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-accent [@media(hover:none)]:opacity-100", | |
| 143 | 133 | card ? "top-1/2 right-2.5 -translate-y-1/2" : "top-1 right-1", | |
| ⋯ | |||
| 152 | 142 | ); | |
| 153 | 143 | } | |
| 154 | 144 | ||
| 145 | + | /** When nothing is installed: what Apps are, and the way to the Marketplace. */ | |
| 146 | + | export function NoApps({ slug, onOpen, compact = false }: { slug: string; onOpen?: () => void; compact?: boolean }) { | |
| 147 | + | return ( | |
| 148 | + | <div className={cn("flex flex-col items-center text-center", compact ? "px-4 py-6" : "rounded-xl border border-dashed border-line px-6 py-12")}> | |
| 149 | + | <span className="flex size-10 items-center justify-center rounded-xl bg-raised text-muted ring-1 ring-line"> | |
| 150 | + | <Store size={18} /> | |
| 151 | + | </span> | |
| 152 | + | <p className="mt-3 text-sm font-medium">No apps installed yet</p> | |
| 153 | + | <p className="mt-1 max-w-xs text-[0.8125rem] text-muted">Apps come from the Marketplace: integrations your workspace connects, and extensions later. Each one you can use shows here, to pin to your dock.</p> | |
| 154 | + | <Link to={`/${slug}/-/marketplace`} onClick={onOpen} className="mt-4 inline-flex h-8 items-center gap-1.5 rounded-md bg-fg px-3 text-[0.8125rem] font-medium text-bg hover:bg-fg-hover"> | |
| 155 | + | <Store size={14} /> | |
| 156 | + | Browse the Marketplace | |
| 157 | + | </Link> | |
| 158 | + | </div> | |
| 159 | + | ); | |
| 160 | + | } | |
| 161 | + | ||
| 155 | 162 | /** | |
| 156 | − | * The Apps launcher's body: a search, every app in the workspace the | |
| 157 | − | * person can use, and the way to all of them and to the Marketplace. | |
| 163 | + | * The Apps launcher's body: a search, every app the workspace installed | |
| 164 | + | * that the person can use (read from the Apps page as it opens), and the | |
| 165 | + | * way to all of them and to the Marketplace. | |
| 158 | 166 | */ | |
| 159 | − | export function AppsLauncher({ | |
| 160 | − | slug, | |
| 161 | − | code, | |
| 162 | − | pins, | |
| 163 | − | onToggle, | |
| 164 | − | onClose, | |
| 165 | − | }: { | |
| 166 | − | slug: string; | |
| 167 | − | code: boolean; | |
| 168 | − | pins: PinnableApp[]; | |
| 169 | − | onToggle: (app: PinnableApp) => void; | |
| 170 | − | onClose: () => void; | |
| 171 | − | }) { | |
| 167 | + | export function AppsLauncher({ slug, pins, onToggle, onClose }: { slug: string; pins: PinnableApp[]; onToggle: (app: PinnableApp) => void; onClose: () => void }) { | |
| 172 | 168 | const [query, setQuery] = useState(""); | |
| 169 | + | const loader = useFetcher<{ apps: InstalledAppData[] | null }>(); | |
| 170 | + | useEffect(() => { | |
| 171 | + | if (loader.state === "idle" && !loader.data) loader.load(`/${slug}/-/apps`); | |
| 172 | + | // Once per opening: the launcher's body mounts when it opens. | |
| 173 | + | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 174 | + | }, [slug]); | |
| 175 | + | const installed = loader.data?.apps; | |
| 173 | 176 | const wanted = query.trim().toLowerCase(); | |
| 174 | − | const apps = appsFor(code).filter((app) => !wanted || app.name.toLowerCase().includes(wanted) || app.about.toLowerCase().includes(wanted)); | |
| 177 | + | const apps = (installed ?? []).filter((app) => !wanted || app.name.toLowerCase().includes(wanted) || app.about.toLowerCase().includes(wanted)); | |
| 175 | 178 | return ( | |
| 176 | 179 | <div className="flex flex-col gap-2"> | |
| 177 | − | <label className="flex h-9 items-center gap-2 rounded-lg border border-line bg-bg px-2.5 text-sm focus-within:border-line-strong"> | |
| 178 | − | <Search size={15} className="shrink-0 text-faint" /> | |
| 179 | − | <input | |
| 180 | − | value={query} | |
| 181 | − | onChange={(event) => setQuery(event.target.value)} | |
| 182 | − | placeholder="Find an app" | |
| 183 | − | aria-label="Find an app" | |
| 184 | − | autoComplete="off" | |
| 185 | − | className="min-w-0 grow bg-transparent outline-none placeholder:text-faint" | |
| 186 | − | /> | |
| 187 | − | </label> | |
| 188 | − | {apps.length === 0 ? ( | |
| 189 | − | <p className="px-2 py-6 text-center text-sm text-muted">No app matches.</p> | |
| 190 | − | ) : ( | |
| 191 | − | <div className="grid max-h-[min(23.5rem,60dvh)] grid-cols-4 gap-1 overflow-y-auto max-sm:grid-cols-3"> | |
| 192 | − | {apps.map((app) => ( | |
| 193 | − | <AppTile key={app.key} app={app} slug={slug} pinned={pins.includes(app.key as PinnableApp)} onToggle={onToggle} onOpen={onClose} /> | |
| 180 | + | {installed == null && loader.data ? ( | |
| 181 | + | <p className="px-2 py-6 text-center text-sm text-muted">Apps can't be listed right now. Try again in a moment.</p> | |
| 182 | + | ) : installed == null ? ( | |
| 183 | + | <div className="grid grid-cols-4 gap-1 p-1 max-sm:grid-cols-3" aria-busy="true" aria-label="Loading apps"> | |
| 184 | + | {Array.from({ length: 4 }, (_, n) => ( | |
| 185 | + | <div key={n} className="flex flex-col items-center gap-2 px-1 pt-3 pb-2.5"> | |
| 186 | + | <Skeleton className="size-10 rounded-xl" /> | |
| 187 | + | <Skeleton className="h-2.5 w-12" /> | |
| 188 | + | </div> | |
| 194 | 189 | ))} | |
| 195 | 190 | </div> | |
| 191 | + | ) : installed.length === 0 ? ( | |
| 192 | + | <NoApps slug={slug} onOpen={onClose} compact /> | |
| 193 | + | ) : ( | |
| 194 | + | <> | |
| 195 | + | <label className="flex h-9 items-center gap-2 rounded-lg border border-line bg-bg px-2.5 text-sm focus-within:border-line-strong"> | |
| 196 | + | <Search size={15} className="shrink-0 text-faint" /> | |
| 197 | + | <input | |
| 198 | + | value={query} | |
| 199 | + | onChange={(event) => setQuery(event.target.value)} | |
| 200 | + | placeholder="Find an app" | |
| 201 | + | aria-label="Find an app" | |
| 202 | + | autoComplete="off" | |
| 203 | + | className="min-w-0 grow bg-transparent outline-none placeholder:text-faint" | |
| 204 | + | /> | |
| 205 | + | </label> | |
| 206 | + | {apps.length === 0 ? ( | |
| 207 | + | <p className="px-2 py-6 text-center text-sm text-muted">No installed app matches.</p> | |
| 208 | + | ) : ( | |
| 209 | + | <div className="grid max-h-[min(23.5rem,60dvh)] grid-cols-4 gap-1 overflow-y-auto max-sm:grid-cols-3"> | |
| 210 | + | {apps.map((app) => ( | |
| 211 | + | <AppTile key={app.key} app={app} pinned={pins.includes(app.key)} onToggle={onToggle} onOpen={onClose} /> | |
| 212 | + | ))} | |
| 213 | + | </div> | |
| 214 | + | )} | |
| 215 | + | </> | |
| 196 | 216 | )} | |
| 197 | 217 | <div className="flex items-center justify-between border-t border-line pt-2"> | |
| 198 | 218 | <Link | |
| ⋯ | |||
| 203 | 223 | <LayoutGrid size={14} /> | |
| 204 | 224 | All apps | |
| 205 | 225 | </Link> | |
| 206 | − | <Hint label="There is no marketplace yet." disabled> | |
| 207 | − | <button type="button" disabled className="flex h-8 items-center gap-1.5 rounded-md px-2 text-[0.8125rem] text-faint"> | |
| 208 | − | <Store size={14} /> | |
| 209 | − | Marketplace | |
| 210 | − | <span className="rounded-full bg-line px-1.5 text-[0.625rem] font-medium text-muted">Coming</span> | |
| 211 | − | </button> | |
| 212 | − | </Hint> | |
| 226 | + | <Link | |
| 227 | + | to={`/${slug}/-/marketplace`} | |
| 228 | + | onClick={onClose} | |
| 229 | + | className="flex h-8 items-center gap-1.5 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-line hover:text-fg" | |
| 230 | + | > | |
| 231 | + | <Store size={14} /> | |
| 232 | + | Marketplace | |
| 233 | + | </Link> | |
| 213 | 234 | </div> | |
| 214 | 235 | </div> | |
| 215 | 236 | ); | |
| 74 | 74 | borderRadius: size * 0.28, | |
| 75 | 75 | fontSize: size * (letters.length > 1 ? 0.34 : 0.42), | |
| 76 | 76 | background: `color-mix(in srgb, ${color} 16%, transparent)`, | |
| 77 | − | color: `color-mix(in srgb, ${color} 72%, white)`, | |
| 77 | + | // Toward the text colour, so the letters read in both themes. | |
| 78 | + | color: `color-mix(in srgb, ${color} 60%, var(--color-fg))`, | |
| 78 | 79 | boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${color} 30%, transparent)`, | |
| 79 | 80 | }} | |
| 80 | 81 | > |
| 4 | 4 | ||
| 5 | 5 | import { type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | − | import { AppsLauncher, appIcon, useAppPins } from "./apps"; | |
| 7 | + | import { AppsLauncher, appIcon, pinnedApp, useAppPins } from "./apps"; | |
| 8 | 8 | import { Mark } from "./logo"; | |
| 9 | 9 | import { Avatar } from "./ui"; | |
| 10 | 10 | import { Hint } from "./ui/hint"; | |
| ⋯ | |||
| 18 | 18 | DropdownMenuTrigger, | |
| 19 | 19 | } from "./ui/dropdown-menu"; | |
| 20 | 20 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; | |
| 21 | − | import { type AppKey, type PinnableApp, appOf } from "../lib/apps"; | |
| 21 | + | import { type BuiltinApp, type PinnableApp } from "../lib/apps"; | |
| 22 | 22 | import { cn } from "../lib/cn"; | |
| 23 | 23 | import { paletteKeyLabel } from "../lib/shortcut"; | |
| 24 | 24 | import { type ModeKey, modeHome, modeOf, todayPath } from "../lib/workspace-nav"; | |
| ⋯ | |||
| 224 | 224 | } | |
| 225 | 225 | ||
| 226 | 226 | /** The Apps button at the foot of the dock's apps, and the launcher it opens. */ | |
| 227 | − | function AppsButton({ slug, code, pins, onToggle, current }: { slug: string; code: boolean; pins: PinnableApp[]; onToggle: (app: PinnableApp) => void; current: boolean }) { | |
| 227 | + | function AppsButton({ slug, pins, onToggle, current }: { slug: string; pins: PinnableApp[]; onToggle: (app: PinnableApp) => void; current: boolean }) { | |
| 228 | 228 | const [open, setOpen] = useState(false); | |
| 229 | 229 | return ( | |
| 230 | 230 | <Popover open={open} onOpenChange={setOpen}> | |
| ⋯ | |||
| 236 | 236 | <DockLabel current={current || open}>Apps</DockLabel> | |
| 237 | 237 | </PopoverTrigger> | |
| 238 | 238 | <PopoverContent side="right" align="end" sideOffset={14} aria-label="Apps" className="w-[23rem] bg-dock p-2.5"> | |
| 239 | − | <AppsLauncher slug={slug} code={code} pins={pins} onToggle={onToggle} onClose={() => setOpen(false)} /> | |
| 239 | + | <AppsLauncher slug={slug} pins={pins} onToggle={onToggle} onClose={() => setOpen(false)} /> | |
| 240 | 240 | </PopoverContent> | |
| 241 | 241 | </Popover> | |
| 242 | 242 | ); | |
| ⋯ | |||
| 281 | 281 | ...(code ? [{ key: "code" as const, label: "Code" }] : []), | |
| 282 | 282 | { key: "artifacts", label: "Artifacts" }, | |
| 283 | 283 | ]; | |
| 284 | − | const shown = pins.filter((key) => code || !appOf(key).code); | |
| 284 | + | const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null); | |
| 285 | 285 | // A pinned app is current on its own page and the pages under it. | |
| 286 | 286 | const at = (to: string) => path === to || path.startsWith(`${to}/`); | |
| 287 | 287 | return ( | |
| ⋯ | |||
| 302 | 302 | key={item.key} | |
| 303 | 303 | to={modeHome(item.key, slug)} | |
| 304 | 304 | label={item.label} | |
| 305 | − | icon={appIcon(item.key as AppKey, 19)} | |
| 306 | − | current={here === item.key && !shown.some((key) => at(appOf(key).path(slug)))} | |
| 305 | + | icon={appIcon(item.key as BuiltinApp, 19)} | |
| 306 | + | current={here === item.key && !shown.some((app) => at(app.to))} | |
| 307 | 307 | badge={item.badge} | |
| 308 | 308 | /> | |
| 309 | 309 | ))} | |
| 310 | 310 | </div> | |
| 311 | 311 | {/* Your pinned apps: as many as you like, scrolling under a fade at either end. */} | |
| 312 | 312 | <div className="flex min-h-0 w-full grow flex-col items-center gap-1 overflow-y-auto border-t border-line px-1.5 py-2 [mask-image:linear-gradient(to_bottom,transparent,#000_10px,#000_calc(100%-14px),transparent)] [scrollbar-width:none]"> | |
| 313 | − | {shown.map((key) => { | |
| 314 | − | const app = appOf(key); | |
| 315 | − | return <DockLink key={key} to={app.path(slug)} label={app.name} icon={appIcon(key, 18)} current={at(app.path(slug))} compact />; | |
| 316 | − | })} | |
| 313 | + | {shown.map((app) => ( | |
| 314 | + | <DockLink key={app.key} to={app.to} label={app.name} icon={app.icon(22)} current={at(app.to)} compact /> | |
| 315 | + | ))} | |
| 317 | 316 | </div> | |
| 318 | 317 | <div className="flex w-full shrink-0 flex-col items-center px-1.5 pb-1.5"> | |
| 319 | − | <AppsButton slug={slug} code={code} pins={pins} onToggle={toggle} current={here === "apps"} /> | |
| 318 | + | <AppsButton slug={slug} pins={pins} onToggle={toggle} current={here === "apps"} /> | |
| 320 | 319 | </div> | |
| 321 | 320 | <div className="flex w-full shrink-0 flex-col items-center gap-1 border-t border-line px-1.5 pt-2 pb-2.5"> | |
| 322 | 321 | <DockLink to={modeHome("people", slug)} label="People" icon={appIcon("people", 19)} current={here === "people"} /> | |
| 1 | + | /** | |
| 2 | + | * The Marketplace's pieces (routes/workspace/marketplace/): listings for | |
| 3 | + | * agents and integrations with what the workspace has of each, the button | |
| 4 | + | * that adds one (owners) or asks an owner to (everyone else), and a | |
| 5 | + | * request as owners and askers see it. | |
| 6 | + | */ | |
| 7 | + | import { ArrowRight, Check, CircleAlert, Clock, Plus, Send, X } from "lucide-react"; | |
| 8 | + | import { type ReactNode, useEffect, useState } from "react"; | |
| 9 | + | import { Link, useFetcher } from "react-router"; | |
| 10 | + | ||
| 11 | + | import type { ExtensionManifest, InstallRequest, ListingTier } from "@g1t/contracts"; | |
| 12 | + | ||
| 13 | + | import { PixelCreature } from "./agent-avatar"; | |
| 14 | + | import { ConnectorMark } from "./connectors"; | |
| 15 | + | import { Badge } from "./ui/badge"; | |
| 16 | + | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 17 | + | import { Hint } from "./ui/hint"; | |
| 18 | + | import { ErrorText, SubmitButton, Textarea, TimeAgo } from "./ui"; | |
| 19 | + | import { type AgentListing, type ExtensionListing, type IntegrationListing, TIERS, catalogAgentPath, extensionPath, hirePath, marketplacePath } from "../lib/marketplace"; | |
| 20 | + | import { cn } from "../lib/cn"; | |
| 21 | + | ||
| 22 | + | const SMALL = "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-[0.8125rem] font-medium transition-colors"; | |
| 23 | + | export const ACTION = { | |
| 24 | + | primary: `${SMALL} bg-fg text-bg hover:bg-fg-hover`, | |
| 25 | + | quiet: `${SMALL} border border-line text-fg/85 hover:border-line-strong hover:bg-raised hover:text-fg`, | |
| 26 | + | }; | |
| 27 | + | ||
| 28 | + | /** Who stands behind a listing: a small badge, with the sentence on hover. */ | |
| 29 | + | export function TierBadge({ tier }: { tier: ListingTier }) { | |
| 30 | + | return ( | |
| 31 | + | <Hint label={TIERS[tier].about}> | |
| 32 | + | <span tabIndex={0} className="inline-flex rounded outline-none focus-visible:ring-2 focus-visible:ring-accent"> | |
| 33 | + | <Badge tone={tier === "official" ? "accent" : tier === "verified" ? "success" : tier === "community" ? "warn" : "info"}>{TIERS[tier].label}</Badge> | |
| 34 | + | </span> | |
| 35 | + | </Hint> | |
| 36 | + | ); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /** "Coming", on what is planned and not built. */ | |
| 40 | + | export function ComingBadge() { | |
| 41 | + | return <Badge tone="neutral">Coming</Badge>; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /** A section's heading, with a count or a link at its end. */ | |
| 45 | + | export function SectionHead({ id, title, aside, children }: { id: string; title: string; aside?: ReactNode; children?: ReactNode }) { | |
| 46 | + | return ( | |
| 47 | + | <div className="mb-3"> | |
| 48 | + | <div className="flex items-baseline justify-between gap-3"> | |
| 49 | + | <h2 id={id} className="text-base font-semibold tracking-tight"> | |
| 50 | + | {title} | |
| 51 | + | </h2> | |
| 52 | + | {aside && <div className="shrink-0 text-xs text-muted">{aside}</div>} | |
| 53 | + | </div> | |
| 54 | + | {children && <p className="mt-1 max-w-2xl text-sm text-muted">{children}</p>} | |
| 55 | + | </div> | |
| 56 | + | ); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /** A link at the end of a section's heading. */ | |
| 60 | + | export function SeeAll({ to, children }: { to: string; children: ReactNode }) { | |
| 61 | + | return ( | |
| 62 | + | <Link to={to} prefetch="intent" className="inline-flex items-center gap-1 text-[0.8125rem] text-muted hover:text-fg"> | |
| 63 | + | {children} | |
| 64 | + | <ArrowRight size={13} /> | |
| 65 | + | </Link> | |
| 66 | + | ); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /** | |
| 70 | + | * Asking the workspace's owners to add something: a dialog with an | |
| 71 | + | * optional note, sent to the requests route. Once sent, it says so. | |
| 72 | + | */ | |
| 73 | + | export function RequestButton({ slug, listing, name, requested, className }: { slug: string; listing: string; name: string; requested: boolean; className?: string }) { | |
| 74 | + | const fetcher = useFetcher<{ error: string | null }>({ key: `request:${listing}` }); | |
| 75 | + | const [open, setOpen] = useState(false); | |
| 76 | + | const sent = fetcher.state === "idle" && fetcher.data?.error === null; | |
| 77 | + | useEffect(() => { | |
| 78 | + | if (sent) setOpen(false); | |
| 79 | + | }, [sent]); | |
| 80 | + | if (requested || sent) { | |
| 81 | + | return ( | |
| 82 | + | <Hint label="The workspace's owners have your request. You'll hear when they answer."> | |
| 83 | + | <span tabIndex={0} className={cn(SMALL, "border border-line text-muted outline-none focus-visible:ring-2 focus-visible:ring-accent", className)}> | |
| 84 | + | <Clock size={13} /> | |
| 85 | + | Requested | |
| 86 | + | </span> | |
| 87 | + | </Hint> | |
| 88 | + | ); | |
| 89 | + | } | |
| 90 | + | return ( | |
| 91 | + | <Dialog open={open} onOpenChange={setOpen}> | |
| 92 | + | <DialogTrigger asChild> | |
| 93 | + | <button type="button" className={cn(ACTION.quiet, className)}> | |
| 94 | + | <Send size={13} /> | |
| 95 | + | Request | |
| 96 | + | </button> | |
| 97 | + | </DialogTrigger> | |
| 98 | + | <DialogContent> | |
| 99 | + | <fetcher.Form method="post" action={marketplacePath(slug, "requests")} className="grid gap-4"> | |
| 100 | + | <DialogHeader> | |
| 101 | + | <DialogTitle>Ask to add {name}</DialogTitle> | |
| 102 | + | <DialogDescription>Only the workspace's owners add agents and integrations. Each of them is notified, and you hear back when one answers.</DialogDescription> | |
| 103 | + | </DialogHeader> | |
| 104 | + | <input type="hidden" name="intent" value="request" /> | |
| 105 | + | <input type="hidden" name="listing" value={listing} /> | |
| 106 | + | <label className="grid gap-1.5 text-sm"> | |
| 107 | + | <span className="font-medium"> | |
| 108 | + | Why you want it <span className="font-normal text-faint">(optional)</span> | |
| 109 | + | </span> | |
| 110 | + | <Textarea name="note" rows={3} maxLength={280} placeholder="What it would help with" /> | |
| 111 | + | </label> | |
| 112 | + | {fetcher.data?.error && <ErrorText>{fetcher.data.error}</ErrorText>} | |
| 113 | + | <DialogFooter> | |
| 114 | + | <SubmitButton fetcher={fetcher} pending="Sending…"> | |
| 115 | + | <Send size={14} /> | |
| 116 | + | Send request | |
| 117 | + | </SubmitButton> | |
| 118 | + | </DialogFooter> | |
| 119 | + | </fetcher.Form> | |
| 120 | + | </DialogContent> | |
| 121 | + | </Dialog> | |
| 122 | + | ); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /** "3 waiting": open requests for a listing, for owners. */ | |
| 126 | + | function Waiting({ slug, count }: { slug: string; count: number }) { | |
| 127 | + | if (count === 0) return null; | |
| 128 | + | return ( | |
| 129 | + | <Link to={marketplacePath(slug, "requests")} className="text-xs text-warn hover:underline"> | |
| 130 | + | {count} {count === 1 ? "request" : "requests"} | |
| 131 | + | </Link> | |
| 132 | + | ); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /** What an owner or a member can do with a role in the catalog. */ | |
| 136 | + | export function AgentAction({ listing, slug, owner, className }: { listing: AgentListing; slug: string; owner: boolean; className?: string }) { | |
| 137 | + | if (owner) { | |
| 138 | + | return ( | |
| 139 | + | <Link to={hirePath(slug, listing.template.id)} className={cn(listing.hired.length > 0 ? ACTION.quiet : ACTION.primary, className)}> | |
| 140 | + | <Plus size={14} /> | |
| 141 | + | {listing.hired.length > 0 ? "Add another" : "Add to workspace"} | |
| 142 | + | </Link> | |
| 143 | + | ); | |
| 144 | + | } | |
| 145 | + | return <RequestButton slug={slug} listing={listing.ref} name={`${listing.template.display_name}, ${listing.template.title}`} requested={listing.requested} className={className} />; | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /** A role in the agent catalog, as a card: its face, the name it suggests, what it does, and what the workspace has of it. */ | |
| 149 | + | export function AgentCard({ listing, slug, owner }: { listing: AgentListing; slug: string; owner: boolean }) { | |
| 150 | + | const { template, hired } = listing; | |
| 151 | + | return ( | |
| 152 | + | <article className="group relative flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"> | |
| 153 | + | <div className="flex items-start gap-3"> | |
| 154 | + | <PixelCreature seed={template.handle} size={44} /> | |
| 155 | + | <div className="min-w-0 grow"> | |
| 156 | + | <h3 className="truncate text-sm font-semibold"> | |
| 157 | + | <Link to={catalogAgentPath(slug, template.id)} prefetch="intent" className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:ring-2 focus-visible:after:ring-accent"> | |
| 158 | + | {template.display_name} | |
| 159 | + | </Link> | |
| 160 | + | </h3> | |
| 161 | + | <p className="truncate text-xs text-muted"> | |
| 162 | + | {template.title} | |
| 163 | + | </p> | |
| 164 | + | </div> | |
| 165 | + | <TierBadge tier="official" /> | |
| 166 | + | </div> | |
| 167 | + | <ul className="mt-3 grow space-y-1"> | |
| 168 | + | {template.responsibilities.slice(0, 3).map((duty) => ( | |
| 169 | + | <li key={duty} className="flex gap-2 text-[0.8125rem] leading-snug text-muted"> | |
| 170 | + | <span className="mt-[0.45rem] size-1 shrink-0 rounded-full bg-line-strong" aria-hidden="true" /> | |
| 171 | + | <span className="line-clamp-1">{duty}</span> | |
| 172 | + | </li> | |
| 173 | + | ))} | |
| 174 | + | </ul> | |
| 175 | + | <div className="relative mt-4 flex items-center justify-between gap-3 border-t border-line pt-3"> | |
| 176 | + | <span className="min-w-0 truncate text-xs text-faint"> | |
| 177 | + | {/* What waits on an owner says more than what isn't there. */} | |
| 178 | + | {owner && listing.waiting > 0 && hired.length === 0 ? ( | |
| 179 | + | <Waiting slug={slug} count={listing.waiting} /> | |
| 180 | + | ) : hired.length === 0 ? ( | |
| 181 | + | "Not added" | |
| 182 | + | ) : hired.length === 1 ? ( | |
| 183 | + | `@${hired[0]!.handle} works here` | |
| 184 | + | ) : ( | |
| 185 | + | `${hired.length} work here` | |
| 186 | + | )} | |
| 187 | + | </span> | |
| 188 | + | <AgentAction listing={listing} slug={slug} owner={owner} /> | |
| 189 | + | </div> | |
| 190 | + | </article> | |
| 191 | + | ); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | /** What an owner or a member can do with an integration. */ | |
| 195 | + | export function IntegrationAction({ listing, slug, owner }: { listing: IntegrationListing; slug: string; owner: boolean }) { | |
| 196 | + | if (listing.connected) { | |
| 197 | + | return listing.href ? ( | |
| 198 | + | <Link to={listing.href} className={ACTION.quiet}> | |
| 199 | + | Manage | |
| 200 | + | </Link> | |
| 201 | + | ) : null; | |
| 202 | + | } | |
| 203 | + | if (owner) { | |
| 204 | + | return listing.href ? ( | |
| 205 | + | <Link to={listing.href} className={ACTION.primary}> | |
| 206 | + | <Plus size={14} /> | |
| 207 | + | Connect | |
| 208 | + | </Link> | |
| 209 | + | ) : null; | |
| 210 | + | } | |
| 211 | + | return <RequestButton slug={slug} listing={listing.ref} name={listing.view.name} requested={listing.requested} />; | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | /** An integration as a card: its mark, what it does for agents, and whether the workspace has it. */ | |
| 215 | + | export function IntegrationCard({ listing, slug, owner }: { listing: IntegrationListing; slug: string; owner: boolean }) { | |
| 216 | + | const { view, connected } = listing; | |
| 217 | + | return ( | |
| 218 | + | <article className="flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"> | |
| 219 | + | <div className="flex items-start gap-3"> | |
| 220 | + | <ConnectorMark view={view} size={40} /> | |
| 221 | + | <div className="min-w-0 grow"> | |
| 222 | + | <h3 className="truncate text-sm font-semibold">{view.name}</h3> | |
| 223 | + | <p className="mt-0.5 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{view.description}</p> | |
| 224 | + | </div> | |
| 225 | + | </div> | |
| 226 | + | {view.capabilities.length > 0 && ( | |
| 227 | + | <ul className="mt-3 flex grow flex-wrap content-start gap-1.5" aria-label="What it does"> | |
| 228 | + | {view.capabilities.map((capability) => ( | |
| 229 | + | <li key={capability} className="rounded-full bg-raised px-2 py-px text-[0.6875rem] text-muted"> | |
| 230 | + | {capability} | |
| 231 | + | </li> | |
| 232 | + | ))} | |
| 233 | + | </ul> | |
| 234 | + | )} | |
| 235 | + | <div className="mt-4 flex items-center justify-between gap-3 border-t border-line pt-3"> | |
| 236 | + | <span className="flex min-w-0 items-center gap-1.5 text-xs"> | |
| 237 | + | {connected ? ( | |
| 238 | + | connected.problem ? ( | |
| 239 | + | <Hint label={connected.problem}> | |
| 240 | + | <span tabIndex={0} className="inline-flex min-w-0 items-center gap-1 text-warn outline-none"> | |
| 241 | + | <CircleAlert size={13} className="shrink-0" /> | |
| 242 | + | <span className="truncate">Needs attention</span> | |
| 243 | + | </span> | |
| 244 | + | </Hint> | |
| 245 | + | ) : ( | |
| 246 | + | <span className="inline-flex min-w-0 items-center gap-1 text-success"> | |
| 247 | + | <Check size={13} className="shrink-0" /> | |
| 248 | + | <span className="truncate">Connected</span> | |
| 249 | + | </span> | |
| 250 | + | ) | |
| 251 | + | ) : ( | |
| 252 | + | <span className="truncate text-faint">Not connected</span> | |
| 253 | + | )} | |
| 254 | + | {owner && listing.waiting > 0 && ( | |
| 255 | + | <> | |
| 256 | + | <span className="text-faint">·</span> | |
| 257 | + | <Waiting slug={slug} count={listing.waiting} /> | |
| 258 | + | </> | |
| 259 | + | )} | |
| 260 | + | </span> | |
| 261 | + | <IntegrationAction listing={listing} slug={slug} owner={owner} /> | |
| 262 | + | </div> | |
| 263 | + | </article> | |
| 264 | + | ); | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | /** An extension's mark: its initials on a steady colour, until listings bring their own icon. */ | |
| 268 | + | export function ExtensionMark({ manifest, size = 40 }: { manifest: Pick<ExtensionManifest, "id" | "name">; size?: number }) { | |
| 269 | + | return <ConnectorMark view={{ id: manifest.id, name: manifest.name, provider: null }} size={size} />; | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | /** What an owner or a member can do with an extension: install or ask, once it is published; nothing before. */ | |
| 273 | + | export function ExtensionAction({ listing, slug, owner, className }: { listing: ExtensionListing; slug: string; owner: boolean; className?: string }) { | |
| 274 | + | const fetcher = useFetcher<{ error: string | null }>(); | |
| 275 | + | const { manifest, install } = listing; | |
| 276 | + | if (manifest.status !== "available") { | |
| 277 | + | return ( | |
| 278 | + | <Hint label="Not published yet. It can be installed from its first release."> | |
| 279 | + | <span tabIndex={0} className={cn(SMALL, "border border-dashed border-line-strong text-muted outline-none focus-visible:ring-2 focus-visible:ring-accent", className)}> | |
| 280 | + | Soon | |
| 281 | + | </span> | |
| 282 | + | </Hint> | |
| 283 | + | ); | |
| 284 | + | } | |
| 285 | + | if (install) return <Badge tone={install.enabled ? "success" : "warn"}>{install.enabled ? `Installed · ${install.version}` : "Switched off"}</Badge>; | |
| 286 | + | if (!owner) return <RequestButton slug={slug} listing={listing.ref} name={manifest.name} requested={listing.requested} className={className} />; | |
| 287 | + | return ( | |
| 288 | + | <fetcher.Form method="post" action={marketplacePath(slug, "requests")}> | |
| 289 | + | <input type="hidden" name="intent" value="install" /> | |
| 290 | + | <input type="hidden" name="extension" value={manifest.id} /> | |
| 291 | + | <SubmitButton fetcher={fetcher} className={cn(ACTION.primary, className)} pending="Installing…"> | |
| 292 | + | <Plus size={14} /> | |
| 293 | + | Install | |
| 294 | + | </SubmitButton> | |
| 295 | + | </fetcher.Form> | |
| 296 | + | ); | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | /** An extension as a card: its mark, publisher and tier, what it does, and where its data goes. */ | |
| 300 | + | export function ExtensionCard({ listing, slug, owner }: { listing: ExtensionListing; slug: string; owner: boolean }) { | |
| 301 | + | const { manifest } = listing; | |
| 302 | + | return ( | |
| 303 | + | <article className="relative flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"> | |
| 304 | + | <div className="flex items-start gap-3"> | |
| 305 | + | <ExtensionMark manifest={manifest} /> | |
| 306 | + | <div className="min-w-0 grow"> | |
| 307 | + | <h3 className="truncate text-sm font-semibold"> | |
| 308 | + | <Link to={extensionPath(slug, manifest.id)} prefetch="intent" className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:ring-2 focus-visible:after:ring-accent"> | |
| 309 | + | {manifest.name} | |
| 310 | + | </Link> | |
| 311 | + | </h3> | |
| 312 | + | <p className="truncate text-xs text-muted"> | |
| 313 | + | {manifest.publisher.name} · {manifest.category} | |
| 314 | + | </p> | |
| 315 | + | </div> | |
| 316 | + | <span className="relative"> | |
| 317 | + | <TierBadge tier={manifest.publisher.tier} /> | |
| 318 | + | </span> | |
| 319 | + | </div> | |
| 320 | + | <p className="mt-3 line-clamp-2 grow text-[0.8125rem] leading-snug text-muted">{manifest.tagline}</p> | |
| 321 | + | <div className="relative mt-4 flex items-center justify-between gap-3 border-t border-line pt-3"> | |
| 322 | + | <span className="min-w-0 truncate text-xs text-faint">{manifest.domains.length === 0 ? "Data stays in g1t" : `Data goes to ${manifest.domains[0]}`}</span> | |
| 323 | + | <ExtensionAction listing={listing} slug={slug} owner={owner} /> | |
| 324 | + | </div> | |
| 325 | + | </article> | |
| 326 | + | ); | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | /** A request's status, in a word. */ | |
| 330 | + | export function RequestStatus({ request }: { request: InstallRequest }) { | |
| 331 | + | if (request.status === "open") return <Badge tone="warn">Waiting</Badge>; | |
| 332 | + | if (request.status === "done") return <Badge tone="success">Added</Badge>; | |
| 333 | + | return <Badge tone="neutral">Turned down</Badge>; | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | /** | |
| 337 | + | * One request as a row: what was asked for, by whom, why and when; for an | |
| 338 | + | * owner, while it waits, the way to add it, mark it added, or turn it down. | |
| 339 | + | */ | |
| 340 | + | export function RequestRow({ request, slug, owner, addTo }: { request: InstallRequest; slug: string; owner: boolean; addTo: string | null }) { | |
| 341 | + | const fetcher = useFetcher<{ error: string | null }>(); | |
| 342 | + | const action = marketplacePath(slug, "requests"); | |
| 343 | + | return ( | |
| 344 | + | <li className="flex flex-wrap items-start gap-x-4 gap-y-3 px-4 py-3.5 sm:flex-nowrap"> | |
| 345 | + | <div className="min-w-0 grow"> | |
| 346 | + | <p className="flex flex-wrap items-center gap-2 text-sm"> | |
| 347 | + | <span className="font-medium">{request.name}</span> | |
| 348 | + | <span className="text-xs text-faint">{request.kind === "agent" ? "Agent" : request.kind === "extension" ? "Extension" : "Integration"}</span> | |
| 349 | + | <RequestStatus request={request} /> | |
| 350 | + | </p> | |
| 351 | + | <p className="mt-0.5 text-xs text-muted"> | |
| 352 | + | {owner ? `@${request.requested_by} asked ` : "You asked "} | |
| 353 | + | <TimeAgo at={request.requested_at} /> | |
| 354 | + | {request.resolved_by && request.status !== "open" && ( | |
| 355 | + | <> | |
| 356 | + | {" · "} | |
| 357 | + | {request.status === "done" ? "added" : "turned down"} by @{request.resolved_by} | |
| 358 | + | </> | |
| 359 | + | )} | |
| 360 | + | </p> | |
| 361 | + | {request.note && <p className="mt-1.5 max-w-xl rounded-md border-l-2 border-line-strong pl-2.5 text-[0.8125rem] text-fg-soft">{request.note}</p>} | |
| 362 | + | {fetcher.data?.error && ( | |
| 363 | + | <div className="mt-2"> | |
| 364 | + | <ErrorText>{fetcher.data.error}</ErrorText> | |
| 365 | + | </div> | |
| 366 | + | )} | |
| 367 | + | </div> | |
| 368 | + | {owner && request.status === "open" && ( | |
| 369 | + | <fetcher.Form method="post" action={action} className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:shrink-0"> | |
| 370 | + | <input type="hidden" name="intent" value="resolve" /> | |
| 371 | + | <input type="hidden" name="id" value={request.id} /> | |
| 372 | + | {addTo && ( | |
| 373 | + | <Link to={addTo} className={ACTION.primary}> | |
| 374 | + | <Plus size={14} /> | |
| 375 | + | {request.kind === "agent" ? "Add" : request.kind === "extension" ? "Install" : "Connect"} | |
| 376 | + | </Link> | |
| 377 | + | )} | |
| 378 | + | <SubmitButton fetcher={fetcher} name="status" value="done" className={ACTION.quiet} pending="Saving…"> | |
| 379 | + | <Check size={14} /> | |
| 380 | + | Mark added | |
| 381 | + | </SubmitButton> | |
| 382 | + | <SubmitButton fetcher={fetcher} name="status" value="declined" className={ACTION.quiet} pending="Saving…"> | |
| 383 | + | <X size={14} /> | |
| 384 | + | Turn down | |
| 385 | + | </SubmitButton> | |
| 386 | + | </fetcher.Form> | |
| 387 | + | )} | |
| 388 | + | </li> | |
| 389 | + | ); | |
| 390 | + | } |
| 21 | 21 | ||
| 22 | 22 | import { type Abilities, type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts"; | |
| 23 | 23 | ||
| 24 | − | import { appIcon } from "./apps"; | |
| 24 | + | import { appIcon, pinnedApp } from "./apps"; | |
| 25 | 25 | import { CountBadge, ShortcutsDialog } from "./dock"; | |
| 26 | 26 | import { Mark } from "./logo"; | |
| 27 | 27 | import { Avatar } from "./ui"; | |
| 28 | 28 | import { TabStrip } from "./ui/tab-strip"; | |
| 29 | 29 | import { StatusDialog } from "./presence"; | |
| 30 | 30 | import { ThemeSwitch } from "./theme-switch"; | |
| 31 | − | import { type AppKey, type PinnableApp, appOf } from "../lib/apps"; | |
| 31 | + | import { type BuiltinApp, type PinnableApp, appOf } from "../lib/apps"; | |
| 32 | 32 | import { setPresence, useOwnPresence } from "../lib/notify-client"; | |
| 33 | 33 | import { dndOn, liveStatus, pauseUntil, untilLabel } from "../lib/presence"; | |
| 34 | 34 | import { STATUS_URL } from "../lib/status"; | |
| ⋯ | |||
| 184 | 184 | > | |
| 185 | 185 | {current && <TopBar />} | |
| 186 | 186 | <BarIcon current={current}> | |
| 187 | − | {appIcon(tab.key as AppKey, 20)} | |
| 187 | + | {appIcon(tab.key as BuiltinApp, 20)} | |
| 188 | 188 | {tab.badge && <CountBadge count={count} loud={tab.badge.loud} />} | |
| 189 | 189 | </BarIcon> | |
| 190 | 190 | <span className={`w-full truncate text-[0.625rem] leading-tight font-medium max-[380px]:text-[0.5625rem] max-[380px]:tracking-[-0.02em] ${current ? "text-fg" : "text-faint"}`}>{tab.label}</span> | |
| ⋯ | |||
| 249 | 249 | ||
| 250 | 250 | /** | |
| 251 | 251 | * More, from the bottom bar: a rounded panel just above it, with g1t's | |
| 252 | − | * mark at its top and a grid of big tiles: all apps, the Marketplace | |
| 253 | − | * (coming), your pinned apps, Artifacts, People, Workspace, and "You and | |
| 252 | + | * mark at its top and a grid of big tiles: all apps, the Marketplace, | |
| 253 | + | * your pinned apps, Artifacts, People, Workspace, and "You and | |
| 254 | 254 | * help", which opens your account's sheet. | |
| 255 | 255 | */ | |
| 256 | 256 | function MoreSheet({ open, onOpenChange, user, workspace, pins }: { open: boolean; onOpenChange: (open: boolean) => void; user: User; workspace: Membership; pins: PinnableApp[] }) { | |
| 257 | 257 | const slug = workspace.slug; | |
| 258 | − | const code = hasCodeAccess(workspace); | |
| 259 | 258 | const [account, setAccount] = useState(false); | |
| 260 | − | const shown = pins.filter((key) => code || !appOf(key).code); | |
| 259 | + | const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null); | |
| 261 | 260 | return ( | |
| 262 | 261 | <> | |
| 263 | 262 | <Primitive.Root open={open} onOpenChange={onOpenChange}> | |
| ⋯ | |||
| 276 | 275 | </div> | |
| 277 | 276 | <div className="grid grid-cols-4 gap-1.5"> | |
| 278 | 277 | <MoreTile to={`/${slug}/-/apps`} icon={<LayoutGrid size={20} />} label="All apps" /> | |
| 279 | − | <MoreTile icon={<Store size={20} />} label="Marketplace" disabled note="Coming" /> | |
| 280 | − | {shown.map((key) => ( | |
| 281 | − | <MoreTile key={key} to={appOf(key).path(slug)} icon={appIcon(key, 20)} label={appOf(key).name} /> | |
| 278 | + | <MoreTile to={`/${slug}/-/marketplace`} icon={<Store size={20} />} label="Marketplace" /> | |
| 279 | + | {shown.map((app) => ( | |
| 280 | + | <MoreTile key={app.key} to={app.to} icon={app.icon(26)} label={app.name} /> | |
| 282 | 281 | ))} | |
| 283 | 282 | <MoreTile to={appOf("artifacts").path(slug)} icon={appIcon("artifacts", 20)} label="Artifacts" /> | |
| 284 | 283 | <MoreTile to={appOf("people").path(slug)} icon={appIcon("people", 20)} label="People" /> | |
| 176 | 176 | borderRadius: size * 0.28, | |
| 177 | 177 | fontSize: size * (String(content).length > 1 ? 0.34 : 0.42), | |
| 178 | 178 | background: `color-mix(in srgb, ${color} 16%, transparent)`, | |
| 179 | − | // Lifted toward white, so the small letters read on the dark tile. | |
| 180 | − | color: `color-mix(in srgb, ${color} 72%, white)`, | |
| 179 | + | // Toward the text colour, so the letters read on the tile in both themes. | |
| 180 | + | color: `color-mix(in srgb, ${color} 60%, var(--color-fg))`, | |
| 181 | 181 | boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${color} 30%, transparent)`, | |
| 182 | 182 | }} | |
| 183 | 183 | > |
| 1 | − | import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PanelLeft, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Sun, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react"; | |
| 1 | + | import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Bot, Box, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Coins, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, KanbanSquare, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogOut, Mail, Network, Package, PanelLeft, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, ShieldCheck, Scale, Smile, Sparkles, Store, Sun, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X, ArrowLeftRight } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 1171 | 1171 | <SidebarLink to={`/${slug}/-/spend`} icon={<Coins size={15} />} current={at("spend")}> | |
| 1172 | 1172 | Spend | |
| 1173 | 1173 | </SidebarLink> | |
| 1174 | − | <SidebarLink to={`/${slug}/-/usage`} icon={<BarChart3 size={15} />} current={at("usage", "gateway")}> | |
| 1174 | + | <SidebarLink to={`/${slug}/-/usage`} icon={<BarChart3 size={15} />} current={at("usage")}> | |
| 1175 | 1175 | Usage | |
| 1176 | 1176 | </SidebarLink> | |
| 1177 | + | <SidebarLink to={`/${slug}/-/gateway`} icon={<Network size={15} />} current={at("gateway")}> | |
| 1178 | + | AI Gateway | |
| 1179 | + | </SidebarLink> | |
| 1177 | 1180 | <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />} current={at("billing")}> | |
| 1178 | 1181 | Billing and plans | |
| 1179 | 1182 | </SidebarLink> | |
| ⋯ | |||
| 1409 | 1412 | artifacts: "Artifacts", | |
| 1410 | 1413 | today: "Today", | |
| 1411 | 1414 | apps: "Apps", | |
| 1415 | + | marketplace: "Marketplace", | |
| 1412 | 1416 | "code-access": "Code access", | |
| 1413 | 1417 | overview: "Overview", | |
| 1414 | 1418 | workspace: "Workspace", | |
| ⋯ | |||
| 1470 | 1474 | if (third && SETTINGS_PAGE.test(page)) { | |
| 1471 | 1475 | trail.push({ label: "Settings", to: `/${owner}/-/settings` }); | |
| 1472 | 1476 | trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page }); | |
| 1473 | − | } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page }); | |
| 1477 | + | } else if (third) { | |
| 1478 | + | trail.push({ label: SECTIONS[third] ?? third, to: page }); | |
| 1479 | + | // The Marketplace names its tab: Marketplace / Agents. | |
| 1480 | + | const tabs: Record<string, string> = { agents: "Agents", integrations: "Integrations", extensions: "Extensions", requests: "Requests" }; | |
| 1481 | + | const tab = third === "marketplace" && fourth ? tabs[fourth] : undefined; | |
| 1482 | + | if (tab) trail.push({ label: tab, to: `${page}/${fourth}` }); | |
| 1483 | + | } | |
| 1474 | 1484 | } else if (second) { | |
| 1475 | 1485 | const repo = `/${owner}/${second}`; | |
| 1476 | 1486 | trail.push({ label: second, to: repo, mono: true }); | |
| ⋯ | |||
| 1566 | 1576 | ...(hasCodeAccess(ws) ? [{ label: "Code overview", to: `/${ws.slug}/-/overview`, icon: <Code2 size={15} /> }] : []), | |
| 1567 | 1577 | { label: "Artifacts", to: `/${ws.slug}/-/artifacts`, icon: <Shapes size={15} /> }, | |
| 1568 | 1578 | { label: "Apps", hint: "Everything installed that you can use", to: `/${ws.slug}/-/apps`, icon: <LayoutGrid size={15} /> }, | |
| 1579 | + | { label: "Marketplace", hint: "Agents and integrations to add", to: `/${ws.slug}/-/marketplace`, icon: <Store size={15} /> }, | |
| 1580 | + | { label: "Agent catalog", hint: "Roles to add an agent into", to: `/${ws.slug}/-/marketplace/agents`, icon: <Bot size={15} /> }, | |
| 1569 | 1581 | { label: "Workspace", hint: "Billing, policies, settings", to: `/${ws.slug}/-/workspace`, icon: <Building2 size={15} /> }, | |
| 1570 | 1582 | ] | |
| 1571 | 1583 | : []), | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { APPS, MAX_APP_PINS, appPinFromForm, appsFor, dockCookie, isPinnable, pinsFrom, pinsIn, pinsToShow, readDock, sidebarClosed, sidebarCookie, withPin, writeDock } from "./apps.ts"; | |
| 4 | + | import { | |
| 5 | + | BUILTINS, | |
| 6 | + | MAX_APP_PINS, | |
| 7 | + | appData, | |
| 8 | + | appPinFromForm, | |
| 9 | + | dockCookie, | |
| 10 | + | installedAppOf, | |
| 11 | + | installedApps, | |
| 12 | + | integrationAppKey, | |
| 13 | + | isPinnable, | |
| 14 | + | pinsFrom, | |
| 15 | + | pinsIn, | |
| 16 | + | pinsToShow, | |
| 17 | + | readDock, | |
| 18 | + | sidebarClosed, | |
| 19 | + | sidebarCookie, | |
| 20 | + | withPin, | |
| 21 | + | writeDock, | |
| 22 | + | } from "./apps.ts"; | |
| 5 | 23 | ||
| 6 | − | test("built-in apps are never pinned; the rest are", () => { | |
| 7 | − | for (const app of APPS) assert.equal(isPinnable(app.key), !app.builtin, app.key); | |
| 8 | − | assert.equal(isPinnable("nonsense"), false); | |
| 24 | + | test("only apps installed from the Marketplace are pinned; built-in apps and their pages are not", () => { | |
| 25 | + | for (const app of BUILTINS) assert.equal(isPinnable(app.key), false, app.key); | |
| 26 | + | for (const page of ["projects", "packages", "security", "context", "memory", "teams", "usage", "gateway", "integrations", "audit"]) { | |
| 27 | + | assert.equal(isPinnable(page), false, page); | |
| 28 | + | } | |
| 29 | + | assert.equal(isPinnable("int-sentry"), true); | |
| 30 | + | assert.equal(isPinnable("int-slack"), false, "Slack can't be connected yet"); | |
| 31 | + | assert.equal(isPinnable("int-mcp"), false, "MCP clients are each person's, not the workspace's"); | |
| 32 | + | assert.equal(isPinnable("ext-support"), false, "no extensions yet"); | |
| 33 | + | assert.equal(isPinnable("int-Sentry"), false); | |
| 9 | 34 | }); | |
| 10 | 35 | ||
| 11 | − | test("Code's apps are only for members with Code access", () => { | |
| 12 | − | const without = appsFor(false).map((app) => app.key); | |
| 13 | − | for (const key of ["code", "projects", "packages", "security", "context", "memory"]) assert.ok(!without.includes(key as never), key); | |
| 14 | − | for (const key of ["today", "chat", "notifications", "agents", "artifacts", "people", "workspace", "teams", "usage"]) assert.ok(without.includes(key as never), key); | |
| 15 | − | assert.equal(appsFor(true).length, APPS.length); | |
| 36 | + | test("an integration's app opens its page", () => { | |
| 37 | + | assert.equal(installedAppOf("int-sentry")?.path("acme"), "/acme/-/integrations/alerts"); | |
| 38 | + | assert.equal(installedAppOf("int-linear")?.path("acme"), "/acme/-/integrations/trackers"); | |
| 39 | + | assert.equal(installedAppOf("int-anthropic")?.path("acme"), "/acme/-/integrations/models"); | |
| 40 | + | assert.equal(installedAppOf("int-webhooks")?.path("acme"), "/acme/-/webhooks"); | |
| 41 | + | assert.equal(installedAppOf(integrationAppKey("github"))?.listing, "integration:github"); | |
| 42 | + | // Every key fits what identity keeps (MAX_DOCK_APP_KEY, 32). | |
| 43 | + | assert.ok(integrationAppKey("anthropic-endpoint").length <= 32); | |
| 16 | 44 | }); | |
| 17 | 45 | ||
| 46 | + | test("installed apps are what the workspace connected, opening where each is managed", () => { | |
| 47 | + | const apps = installedApps({ sentry: { manage: "/acme/-/integrations/alerts" }, github: { manage: null }, nonsense: { manage: null } }); | |
| 48 | + | assert.deepEqual(apps.map((app) => app.key), ["int-github", "int-sentry"]); | |
| 49 | + | assert.equal(apps[0]!.path("acme"), "/new/github?workspace=acme"); | |
| 50 | + | assert.deepEqual(appData(apps[1]!, "acme").href, "/acme/-/integrations/alerts"); | |
| 51 | + | assert.deepEqual(installedApps({}), []); | |
| 52 | + | }); | |
| 53 | + | ||
| 18 | 54 | test("pins are kept per workspace, in the order they were pinned", () => { | |
| 19 | − | const value = writeDock(writeDock(null, "acme", ["projects", "usage"]), "Beta", ["teams"]); | |
| 20 | − | assert.equal(value, "beta:teams,acme:projects.usage"); | |
| 21 | − | assert.deepEqual(pinsIn(value, "acme"), ["projects", "usage"]); | |
| 22 | − | assert.deepEqual(pinsIn(value, "BETA"), ["teams"]); | |
| 55 | + | const value = writeDock(writeDock(null, "acme", ["int-sentry", "int-linear"]), "Beta", ["int-jira"]); | |
| 56 | + | assert.equal(value, "beta:int-jira,acme:int-sentry.int-linear"); | |
| 57 | + | assert.deepEqual(pinsIn(value, "acme"), ["int-sentry", "int-linear"]); | |
| 58 | + | assert.deepEqual(pinsIn(value, "BETA"), ["int-jira"]); | |
| 23 | 59 | assert.deepEqual(pinsIn(value, "other"), []); | |
| 24 | 60 | // Unpinning everything keeps the workspace, with no pins. | |
| 25 | 61 | assert.deepEqual(pinsIn(writeDock(value, "acme", []), "acme"), []); | |
| 26 | 62 | }); | |
| 27 | 63 | ||
| 28 | 64 | test("a cookie someone wrote by hand reads as far as it makes sense", () => { | |
| 29 | − | assert.deepEqual(readDock("acme:projects.today.nonsense.projects,BAD SLUG:usage,beta"), { acme: ["projects"] }); | |
| 65 | + | assert.deepEqual(readDock("acme:int-sentry.today.nonsense.int-sentry,BAD SLUG:int-jira,beta"), { acme: ["int-sentry"] }); | |
| 30 | 66 | assert.deepEqual(readDock(""), {}); | |
| 31 | − | const many = Array.from({ length: 30 }, () => "usage").join("."); | |
| 67 | + | const many = Array.from({ length: 30 }, () => "int-sentry").join("."); | |
| 32 | 68 | assert.ok(pinsIn(`acme:${many}`, "acme").length <= MAX_APP_PINS); | |
| 33 | 69 | }); | |
| 34 | 70 | ||
| 35 | − | test("pins kept with the account keep their order and only real apps", () => { | |
| 36 | − | assert.deepEqual(pinsFrom(["usage", "projects", "today", "retired-app", "usage", "teams"]), ["usage", "projects", "teams"]); | |
| 71 | + | test("pins of built-in pages from before Apps were the Marketplace's are dropped", () => { | |
| 72 | + | assert.deepEqual(pinsFrom(["usage", "int-sentry", "projects", "today", "int-sentry", "teams", "int-linear"]), ["int-sentry", "int-linear"]); | |
| 73 | + | assert.deepEqual(pinsIn("acme:projects.usage.teams", "acme"), []); | |
| 37 | 74 | assert.deepEqual(pinsFrom([]), []); | |
| 38 | − | assert.ok(pinsFrom(Array.from({ length: 30 }, (_, n) => APPS[8 + (n % 10)]!.key)).length <= MAX_APP_PINS); | |
| 39 | 75 | }); | |
| 40 | 76 | ||
| 41 | 77 | test("the account's pins win over the cookie, which is the fallback", () => { | |
| 42 | − | const cookie = writeDock(null, "acme", ["projects", "usage"]); | |
| 78 | + | const cookie = writeDock(null, "acme", ["int-sentry", "int-linear"]); | |
| 43 | 79 | // Saved with the account, even with nothing pinned: the account's. | |
| 44 | − | assert.deepEqual(pinsToShow(["teams", "audit"], cookie, "acme"), ["teams", "audit"]); | |
| 80 | + | assert.deepEqual(pinsToShow(["int-jira"], cookie, "acme"), ["int-jira"]); | |
| 45 | 81 | assert.deepEqual(pinsToShow([], cookie, "acme"), []); | |
| 46 | 82 | // Never saved, or identity could not be asked: this device's cookie. | |
| 47 | − | assert.deepEqual(pinsToShow(null, cookie, "acme"), ["projects", "usage"]); | |
| 83 | + | assert.deepEqual(pinsToShow(null, cookie, "acme"), ["int-sentry", "int-linear"]); | |
| 48 | 84 | assert.deepEqual(pinsToShow(undefined, null, "acme"), []); | |
| 49 | 85 | }); | |
| 50 | 86 | ||
| ⋯ | |||
| 54 | 90 | for (const [name, value] of Object.entries(fields)) data.set(name, value); | |
| 55 | 91 | return data; | |
| 56 | 92 | }; | |
| 57 | − | assert.deepEqual(appPinFromForm(form({ intent: "pin", app: "teams" })), { app: "teams", pinned: true }); | |
| 58 | − | assert.deepEqual(appPinFromForm(form({ intent: "unpin", app: "teams" })), { app: "teams", pinned: false }); | |
| 93 | + | assert.deepEqual(appPinFromForm(form({ intent: "pin", app: "int-sentry" })), { app: "int-sentry", pinned: true }); | |
| 94 | + | assert.deepEqual(appPinFromForm(form({ intent: "unpin", app: "int-sentry" })), { app: "int-sentry", pinned: false }); | |
| 59 | 95 | assert.equal(appPinFromForm(form({ intent: "pin", app: "chat" })), null); | |
| 60 | − | assert.equal(appPinFromForm(form({ intent: "drop", app: "teams" })), null); | |
| 61 | − | assert.deepEqual(withPin(["projects", "teams"], "projects", true), ["teams", "projects"]); | |
| 62 | − | assert.deepEqual(withPin(["projects", "teams"], "projects", false), ["teams"]); | |
| 96 | + | assert.equal(appPinFromForm(form({ intent: "pin", app: "teams" })), null); | |
| 97 | + | assert.equal(appPinFromForm(form({ intent: "drop", app: "int-sentry" })), null); | |
| 98 | + | assert.deepEqual(withPin(["int-sentry", "int-jira"], "int-sentry", true), ["int-jira", "int-sentry"]); | |
| 99 | + | assert.deepEqual(withPin(["int-sentry", "int-jira"], "int-sentry", false), ["int-jira"]); | |
| 63 | 100 | }); | |
| 64 | 101 | ||
| 65 | 102 | test("the cookies last a year and are secure when the site is", () => { | |
| 66 | − | assert.match(dockCookie("acme:teams", true), /^g1t_dock=acme%3Ateams; Path=\/; Max-Age=31536000; SameSite=Lax; Secure$/); | |
| 103 | + | assert.match(dockCookie("acme:int-sentry", true), /^g1t_dock=acme%3Aint-sentry; Path=\/; Max-Age=31536000; SameSite=Lax; Secure$/); | |
| 67 | 104 | assert.equal(sidebarCookie(true, false), "g1t_sidebar=closed; Path=/; Max-Age=31536000; SameSite=Lax"); | |
| 68 | 105 | assert.equal(sidebarClosed("closed"), true); | |
| 69 | 106 | assert.equal(sidebarClosed("open"), false); | |
| 1 | 1 | /** | |
| 2 | − | * The apps a workspace has, and the ones each person pins to their dock. | |
| 2 | + | * The dock's built-in apps, the apps a workspace installed from the | |
| 3 | + | * Marketplace, and the ones each person pins to their dock. | |
| 3 | 4 | * | |
| 4 | 5 | * Built-in apps (Today, Chat, Notifications, Agents, Code, Artifacts, | |
| 5 | − | * People and Workspace) are always in the dock. Every other app is a page | |
| 6 | − | * of the workspace that people use on its own: Projects, Packages, | |
| 7 | − | * Security, Context, Memory, Teams, Usage, the AI Gateway, Integrations | |
| 8 | − | * and the audit log. Each person pins the ones they want, and nobody sees | |
| 9 | − | * an app they cannot use: Code's apps are for members with Code access. | |
| 6 | + | * People and Workspace) are always in the dock; their own pages (Projects, | |
| 7 | + | * Usage, Teams and the rest) are in their sidebars. Apps are what the | |
| 8 | + | * workspace added from the Marketplace: today, the integrations it | |
| 9 | + | * connected, each opening its page. Each person pins the ones they want. | |
| 10 | + | * | |
| 11 | + | * An installed app's key says what it is: `int-<connector>` for an | |
| 12 | + | * integration (@g1t/contracts/connectors), and later `ext-<extension>`. | |
| 13 | + | * A key that names nothing installable (a built-in page pinned before | |
| 14 | + | * Apps were the Marketplace's) is dropped wherever pins are read. | |
| 10 | 15 | * | |
| 11 | 16 | * Pins are each person's own, per workspace, in the order they set, and | |
| 12 | 17 | * kept with their account by the identity service (`dock_pins`), so the | |
| ⋯ | |||
| 16 | 21 | * with the account carry over from it until the first change. | |
| 17 | 22 | * No Workers or React imports, so it can be tested under Node. | |
| 18 | 23 | */ | |
| 24 | + | import type { ExtensionInstall } from "@g1t/contracts"; | |
| 25 | + | import { CONNECTORS, type Connector, connectorPath } from "@g1t/contracts/connectors"; | |
| 26 | + | import { extensionById } from "@g1t/contracts/marketplace"; | |
| 19 | 27 | ||
| 20 | 28 | /** The apps that are always in the dock. */ | |
| 21 | 29 | export type BuiltinApp = "today" | "chat" | "notifications" | "agents" | "code" | "artifacts" | "people" | "workspace"; | |
| 22 | 30 | ||
| 23 | − | /** The apps people pin. */ | |
| 24 | − | export type PinnableApp = | |
| 25 | − | | "projects" | |
| 26 | − | | "packages" | |
| 27 | − | | "security" | |
| 28 | − | | "context" | |
| 29 | − | | "memory" | |
| 30 | − | | "teams" | |
| 31 | − | | "usage" | |
| 32 | − | | "gateway" | |
| 33 | − | | "integrations" | |
| 34 | − | | "audit"; | |
| 35 | − | ||
| 36 | − | export type AppKey = BuiltinApp | PinnableApp; | |
| 37 | − | ||
| 38 | − | export type AppInfo = { | |
| 39 | − | key: AppKey; | |
| 31 | + | export type BuiltinInfo = { | |
| 32 | + | key: BuiltinApp; | |
| 40 | 33 | name: string; | |
| 41 | − | /** What it is, in a few words, for the Apps page. */ | |
| 42 | − | about: string; | |
| 43 | 34 | /** Its page in the workspace `slug`. */ | |
| 44 | 35 | path: (slug: string) => string; | |
| 45 | − | /** Always in the dock: it cannot be pinned or unpinned. */ | |
| 46 | − | builtin: boolean; | |
| 47 | 36 | /** Part of Code: only for members with Code access. */ | |
| 48 | 37 | code?: boolean; | |
| 49 | 38 | }; | |
| 50 | 39 | ||
| 51 | 40 | const under = (page: string) => (slug: string) => `/${slug}/-/${page}`; | |
| 52 | 41 | ||
| 53 | − | /** Every app, built-in first, in the order the launcher shows them. */ | |
| 54 | − | export const APPS: AppInfo[] = [ | |
| 55 | − | { key: "today", name: "Today", about: "What needs you, across the workspace", path: under("today"), builtin: true }, | |
| 56 | − | { key: "chat", name: "Chat", about: "Channels and messages with people and agents", path: under("chat"), builtin: true }, | |
| 57 | − | { key: "notifications", name: "Notifications", about: "Reviews, mentions and what agents wait on", path: () => "/notifications", builtin: true }, | |
| 58 | − | { key: "agents", name: "Agents", about: "Your workspace's agents and their sessions", path: under("agents"), builtin: true }, | |
| 59 | − | { key: "code", name: "Code", about: "Projects, pull requests and checks", path: under("overview"), builtin: true, code: true }, | |
| 60 | − | { key: "artifacts", name: "Artifacts", about: "Documents, decks and pages", path: under("artifacts"), builtin: true }, | |
| 61 | − | { key: "people", name: "People", about: "Who belongs to the workspace", path: under("people"), builtin: true }, | |
| 62 | − | { key: "workspace", name: "Workspace", about: "Billing, policies and settings", path: under("workspace"), builtin: true }, | |
| 63 | − | { key: "projects", name: "Projects", about: "Every project in the workspace", path: under("projects"), builtin: false, code: true }, | |
| 64 | − | { key: "packages", name: "Packages", about: "Packages published from its projects", path: under("packages"), builtin: false, code: true }, | |
| 65 | − | { key: "security", name: "Security", about: "Alerts across its projects", path: under("security"), builtin: false, code: true }, | |
| 66 | − | { key: "context", name: "Context", about: "What agents can look up", path: under("context"), builtin: false, code: true }, | |
| 67 | − | { key: "memory", name: "Memory", about: "What agents remember about the workspace", path: under("memory"), builtin: false, code: true }, | |
| 68 | − | { key: "teams", name: "Teams", about: "Groups of people to mention and grant access", path: under("teams"), builtin: false }, | |
| 69 | − | { key: "usage", name: "Usage", about: "What the workspace has used this month", path: under("usage"), builtin: false }, | |
| 70 | − | { key: "gateway", name: "AI Gateway", about: "Every model request, with its cost", path: under("gateway"), builtin: false }, | |
| 71 | − | { key: "integrations", name: "Integrations", about: "Model providers, alerts and trackers", path: under("integrations"), builtin: false }, | |
| 72 | − | { key: "audit", name: "Audit log", about: "Who did what, and when", path: under("audit"), builtin: false }, | |
| 42 | + | /** The dock's built-in apps, in its order. */ | |
| 43 | + | export const BUILTINS: BuiltinInfo[] = [ | |
| 44 | + | { key: "today", name: "Today", path: under("today") }, | |
| 45 | + | { key: "chat", name: "Chat", path: under("chat") }, | |
| 46 | + | { key: "notifications", name: "Notifications", path: () => "/notifications" }, | |
| 47 | + | { key: "agents", name: "Agents", path: under("agents") }, | |
| 48 | + | { key: "code", name: "Code", path: under("overview"), code: true }, | |
| 49 | + | { key: "artifacts", name: "Artifacts", path: under("artifacts") }, | |
| 50 | + | { key: "people", name: "People", path: under("people") }, | |
| 51 | + | { key: "workspace", name: "Workspace", path: under("workspace") }, | |
| 73 | 52 | ]; | |
| 74 | 53 | ||
| 75 | − | const PINNABLE = new Set<string>(APPS.filter((app) => !app.builtin).map((app) => app.key)); | |
| 54 | + | /** The built-in app `key`. */ | |
| 55 | + | export function appOf(key: BuiltinApp): BuiltinInfo { | |
| 56 | + | return BUILTINS.find((app) => app.key === key)!; | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /** An installed app's key, as pins and forms carry it: `int-sentry`. */ | |
| 60 | + | export type PinnableApp = string; | |
| 61 | + | ||
| 62 | + | /** An app installed from the Marketplace. */ | |
| 63 | + | export type InstalledApp = { | |
| 64 | + | key: PinnableApp; | |
| 65 | + | name: string; | |
| 66 | + | /** What it is, in a few words. */ | |
| 67 | + | about: string; | |
| 68 | + | /** The Marketplace listing it was installed from: `integration:sentry`. */ | |
| 69 | + | listing: string; | |
| 70 | + | /** The connector behind it, for its mark. */ | |
| 71 | + | connector: Pick<Connector, "id" | "name" | "provider">; | |
| 72 | + | /** Its page in the workspace `slug`. */ | |
| 73 | + | path: (slug: string) => string; | |
| 74 | + | /** | |
| 75 | + | * Whether the person can open it. Every member can open an integration's | |
| 76 | + | * page; an app limited to some people shows Request access to the rest. | |
| 77 | + | */ | |
| 78 | + | usable: boolean; | |
| 79 | + | }; | |
| 80 | + | ||
| 81 | + | /** Where an integration is looked after: its setup page, by kind, or its own page. */ | |
| 82 | + | const SECTION_BY_CATEGORY: Partial<Record<Connector["category"], string>> = { ai: "models", monitoring: "alerts", issues: "trackers" }; | |
| 83 | + | ||
| 84 | + | function integrationPath(connector: Connector): (slug: string) => string { | |
| 85 | + | const section = connector.provider ? SECTION_BY_CATEGORY[connector.category] : undefined; | |
| 86 | + | if (section) return under(`integrations/${section}`); | |
| 87 | + | const href = connector.href?.workspace; | |
| 88 | + | return href ? (slug) => connectorPath(href, slug) : under("integrations"); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /** Identity keeps keys of at most this many characters (MAX_DOCK_APP_KEY). */ | |
| 92 | + | const MAX_KEY = 32; | |
| 76 | 93 | ||
| 77 | − | /** Whether `key` is an app people pin. */ | |
| 94 | + | /** The installed-app key for an integration: `int-sentry`. */ | |
| 95 | + | export function integrationAppKey(connectorId: string): PinnableApp { | |
| 96 | + | return `int-${connectorId}`; | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /** | |
| 100 | + | * The app a key names, from the catalog alone (no service is asked), or | |
| 101 | + | * null when it names nothing that can be installed: a retired key, a | |
| 102 | + | * built-in page, or a connector that is not available for a workspace. | |
| 103 | + | */ | |
| 104 | + | export function installedAppOf(key: string): InstalledApp | null { | |
| 105 | + | if (key.length > MAX_KEY) return null; | |
| 106 | + | if (/^ext-[a-z][a-z0-9-]*$/.test(key)) { | |
| 107 | + | const extension = extensionById(key.slice(4)); | |
| 108 | + | if (!extension || extension.status !== "available") return null; | |
| 109 | + | return { | |
| 110 | + | key, | |
| 111 | + | name: extension.name, | |
| 112 | + | about: extension.tagline, | |
| 113 | + | listing: `extension:${extension.id}`, | |
| 114 | + | connector: { id: extension.id, name: extension.name, provider: undefined }, | |
| 115 | + | // Its own page, in a sandboxed frame, is the extension host's to serve; until then, its listing. | |
| 116 | + | path: (slug) => `/${slug}/-/marketplace/extensions/${extension.id}`, | |
| 117 | + | usable: true, | |
| 118 | + | }; | |
| 119 | + | } | |
| 120 | + | if (!/^int-[a-z0-9][a-z0-9-]*$/.test(key)) return null; | |
| 121 | + | const connector = CONNECTORS.find((c) => c.id === key.slice(4)); | |
| 122 | + | if (!connector || connector.status !== "available" || !connector.scopes.includes("workspace")) return null; | |
| 123 | + | return { | |
| 124 | + | key, | |
| 125 | + | name: connector.name, | |
| 126 | + | about: connector.description, | |
| 127 | + | listing: `integration:${connector.id}`, | |
| 128 | + | connector: { id: connector.id, name: connector.name, provider: connector.provider }, | |
| 129 | + | path: integrationPath(connector), | |
| 130 | + | usable: true, | |
| 131 | + | }; | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /** Whether `key` names an app that can be pinned. */ | |
| 78 | 135 | export function isPinnable(key: string): key is PinnableApp { | |
| 79 | − | return PINNABLE.has(key); | |
| 136 | + | return installedAppOf(key) != null; | |
| 80 | 137 | } | |
| 81 | 138 | ||
| 82 | − | /** The apps someone can use in a workspace: Code's only with Code access. */ | |
| 83 | − | export function appsFor(code: boolean): AppInfo[] { | |
| 84 | − | return APPS.filter((app) => code || !app.code); | |
| 139 | + | /** | |
| 140 | + | * The apps the workspace installed, from what it has connected (by | |
| 141 | + | * connector id, lib/connected.server.ts), in catalog order. `manage`, when | |
| 142 | + | * a connection has one, is where its app opens. | |
| 143 | + | */ | |
| 144 | + | export function installedApps( | |
| 145 | + | connected: Record<string, { manage: string | null }>, | |
| 146 | + | installs: readonly Pick<ExtensionInstall, "listing" | "enabled">[] = [], | |
| 147 | + | ): InstalledApp[] { | |
| 148 | + | const integrations = CONNECTORS.filter((connector) => connected[connector.id]).map((connector) => { | |
| 149 | + | const app = installedAppOf(integrationAppKey(connector.id)); | |
| 150 | + | const manage = connected[connector.id]?.manage; | |
| 151 | + | return app && manage ? { ...app, path: () => manage } : app; | |
| 152 | + | }); | |
| 153 | + | // Extensions switched off (the kill switch) aren't apps until they're on again. | |
| 154 | + | const extensions = installs.filter((install) => install.enabled).map((install) => installedAppOf(`ext-${install.listing.replace(/^extension:/, "")}`)); | |
| 155 | + | return [...extensions, ...integrations].filter((app): app is InstalledApp => app != null); | |
| 85 | 156 | } | |
| 86 | 157 | ||
| 87 | − | /** The app `key`. */ | |
| 88 | − | export function appOf(key: AppKey): AppInfo { | |
| 89 | − | return APPS.find((app) => app.key === key)!; | |
| 158 | + | /** An app as a loader sends it: everything but its path, which is worked out for the workspace. */ | |
| 159 | + | export type InstalledAppData = Omit<InstalledApp, "path"> & { href: string }; | |
| 160 | + | ||
| 161 | + | export function appData(app: InstalledApp, slug: string): InstalledAppData { | |
| 162 | + | const { path, ...rest } = app; | |
| 163 | + | return { ...rest, href: path(slug) }; | |
| 90 | 164 | } | |
| 91 | 165 | ||
| 92 | 166 | /** The cookie that keeps this device's copy of each person's pins, per workspace. */ | |
| 1 | + | /** | |
| 2 | + | * What a workspace has connected, by connector id (@g1t/contracts/connectors): | |
| 3 | + | * its provider connections from the integrations service, the GitHub App's | |
| 4 | + | * installations and its own webhooks, each with how it is doing. The | |
| 5 | + | * Integrations directory and the Marketplace both read it. | |
| 6 | + | */ | |
| 7 | + | import type { User } from "@g1t/contracts"; | |
| 8 | + | import { PROVIDERS } from "@g1t/contracts"; | |
| 9 | + | import { CONNECTORS } from "@g1t/contracts/connectors"; | |
| 10 | + | ||
| 11 | + | import type { ConnectedState } from "./connectors"; | |
| 12 | + | import { githubApp } from "./github.server"; | |
| 13 | + | import { integrationsSection } from "./integration-sections"; | |
| 14 | + | import { integrations, webhooks } from "./services.server"; | |
| 15 | + | ||
| 16 | + | /** What the workspace `slug` has connected, as `viewer` may see it. Each source that does not answer counts as nothing connected. */ | |
| 17 | + | export async function connectedStates(slug: string, viewer: User): Promise<Record<string, ConnectedState>> { | |
| 18 | + | const [connections, github, hooks] = await Promise.all([ | |
| 19 | + | integrations.list(slug, viewer).catch(() => null), | |
| 20 | + | githubApp.status(viewer, slug).catch(() => null), | |
| 21 | + | webhooks.list(viewer, { workspace: slug }).catch(() => null), | |
| 22 | + | ]); | |
| 23 | + | ||
| 24 | + | const connected: Record<string, ConnectedState> = {}; | |
| 25 | + | if (connections?.ok) { | |
| 26 | + | for (const connector of CONNECTORS) { | |
| 27 | + | if (!connector.provider) continue; | |
| 28 | + | const mine = connections.value.filter((connection) => connection.provider === connector.provider); | |
| 29 | + | if (mine.length === 0) continue; | |
| 30 | + | const broken = mine.find((connection) => connection.lastError); | |
| 31 | + | connected[connector.id] = { | |
| 32 | + | detail: mine.length === 1 ? mine[0]!.name : `${mine.length} connections: ${mine.map((c) => c.name).join(", ")}`, | |
| 33 | + | problem: broken ? `${broken.name}: ${broken.lastError}` : null, | |
| 34 | + | manage: `/${slug}/-/integrations/${integrationsSection(PROVIDERS[connector.provider].kind)}`, | |
| 35 | + | }; | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | if (github?.ok && github.value.installations.length > 0) { | |
| 39 | + | const suspended = github.value.installations.find((installation) => installation.suspended); | |
| 40 | + | connected.github = { | |
| 41 | + | detail: `On ${github.value.installations.map((installation) => installation.account).join(", ")}`, | |
| 42 | + | problem: suspended ? `The installation on ${suspended.account} is suspended on GitHub.` : null, | |
| 43 | + | manage: null, | |
| 44 | + | }; | |
| 45 | + | } | |
| 46 | + | const ours = hooks?.ok ? hooks.value.filter((hook) => hook.scope === "workspace") : []; | |
| 47 | + | if (ours.length > 0) { | |
| 48 | + | const failing = ours.filter((hook) => hook.lastStatus === "failed"); | |
| 49 | + | connected.webhooks = { | |
| 50 | + | detail: ours.length === 1 ? ours[0]!.url : `${ours.length} webhooks`, | |
| 51 | + | problem: failing.length > 0 ? `The last delivery to ${failing[0]!.url} failed.` : null, | |
| 52 | + | manage: null, | |
| 53 | + | }; | |
| 54 | + | } | |
| 55 | + | return connected; | |
| 56 | + | } |
| 1 | + | /** | |
| 2 | + | * What the Marketplace's pages read: the agent catalog's roles with the | |
| 3 | + | * workspace's agents, and what the workspace has connected. Each part that | |
| 4 | + | * does not answer comes back null, and the page says so. | |
| 5 | + | */ | |
| 6 | + | import type { AgentTemplate, User, WorkspaceAgent } from "@g1t/contracts"; | |
| 7 | + | ||
| 8 | + | import type { ConnectedState } from "./connectors"; | |
| 9 | + | import { connectedStates } from "./connected.server"; | |
| 10 | + | import { workspaceAgents } from "./services.server"; | |
| 11 | + | ||
| 12 | + | /** An agent as the Marketplace needs it: who it is and which role it was hired into. */ | |
| 13 | + | export type HiredAgent = Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "template" | "archived_at">; | |
| 14 | + | ||
| 15 | + | export async function loadCatalog(slug: string, viewer: User): Promise<{ templates: AgentTemplate[] | null; agents: HiredAgent[] | null }> { | |
| 16 | + | const [templates, agents] = await Promise.all([ | |
| 17 | + | workspaceAgents.templates().catch(() => null), | |
| 18 | + | workspaceAgents | |
| 19 | + | .list(slug, viewer) | |
| 20 | + | .then((result) => (result.ok ? result.value.map(({ id, handle, display_name, template, archived_at }) => ({ id, handle, display_name, template, archived_at })) : null)) | |
| 21 | + | .catch(() => null), | |
| 22 | + | ]); | |
| 23 | + | return { templates, agents }; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | export async function loadConnected(slug: string, viewer: User): Promise<Record<string, ConnectedState>> { | |
| 27 | + | return connectedStates(slug, viewer); | |
| 28 | + | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { AgentTemplate, InstallRequest } from "@g1t/contracts"; | |
| 5 | + | import { connectorsFor } from "@g1t/contracts/connectors"; | |
| 6 | + | ||
| 7 | + | import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace"; | |
| 8 | + | ||
| 9 | + | import { addPath, agentListings, comingIntegrations, extensionListings, integrationListings, integrationMatches, marketplaceForm, routingWords } from "./marketplace.ts"; | |
| 10 | + | import { modeOf } from "./workspace-nav.ts"; | |
| 11 | + | ||
| 12 | + | const template = (id: string, title: string): AgentTemplate => ({ | |
| 13 | + | id, | |
| 14 | + | display_name: title.split(" ")[0]!, | |
| 15 | + | handle: id, | |
| 16 | + | name_ideas: [], | |
| 17 | + | role: title, | |
| 18 | + | title, | |
| 19 | + | department: "Engineering", | |
| 20 | + | responsibilities: ["Do the work"], | |
| 21 | + | subagents: [], | |
| 22 | + | instructions: "", | |
| 23 | + | personality_preset: "crisp", | |
| 24 | + | routing: { floor: null, ceiling: null, providers: [], pinned: null }, | |
| 25 | + | }); | |
| 26 | + | ||
| 27 | + | const request = (listing: string, by: string, status: InstallRequest["status"] = "open"): InstallRequest => ({ | |
| 28 | + | id: `ins_${listing}_${by}`, | |
| 29 | + | listing, | |
| 30 | + | kind: listing.startsWith("agent:") ? "agent" : "integration", | |
| 31 | + | name: listing, | |
| 32 | + | note: null, | |
| 33 | + | requested_by: by, | |
| 34 | + | requested_at: "2026-10-10T12:00:00Z", | |
| 35 | + | status, | |
| 36 | + | resolved_by: null, | |
| 37 | + | resolved_at: null, | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | test("a role counts the agents hired into it, not archived ones", () => { | |
| 41 | + | const [eng, qa] = agentListings( | |
| 42 | + | [template("engineering", "Software Engineer"), template("qa", "QA Engineer")], | |
| 43 | + | [ | |
| 44 | + | { id: "a1", handle: "otto", display_name: "Otto", template: "engineering", archived_at: null }, | |
| 45 | + | { id: "a2", handle: "bolt", display_name: "Bolt", template: "engineering", archived_at: "2026-10-01T00:00:00Z" }, | |
| 46 | + | { id: "a3", handle: "g1t", display_name: "g1t", template: null, archived_at: null }, | |
| 47 | + | ], | |
| 48 | + | [request("agent:qa", "ana"), request("agent:qa", "bo"), request("agent:engineering", "ana", "declined")], | |
| 49 | + | "Ana", | |
| 50 | + | ); | |
| 51 | + | assert.deepEqual(eng!.hired.map((a) => a.handle), ["otto"]); | |
| 52 | + | assert.equal(eng!.requested, false, "a turned-down request isn't waiting"); | |
| 53 | + | assert.equal(qa!.hired.length, 0); | |
| 54 | + | assert.equal(qa!.requested, true, "usernames match whatever their case"); | |
| 55 | + | assert.equal(qa!.waiting, 2); | |
| 56 | + | }); | |
| 57 | + | ||
| 58 | + | test("without the agents service, nobody counts as hired", () => { | |
| 59 | + | const [eng] = agentListings([template("engineering", "Software Engineer")], null, [], "ana"); | |
| 60 | + | assert.deepEqual(eng!.hired, []); | |
| 61 | + | }); | |
| 62 | + | ||
| 63 | + | test("only integrations a workspace can connect today are listed, connected first", () => { | |
| 64 | + | const views = connectorsFor("workspace"); | |
| 65 | + | const listings = integrationListings(views, { sentry: { detail: "acme", problem: null, manage: "/acme/-/integrations/alerts" } }, [], "ana", "acme"); | |
| 66 | + | assert.ok(listings.every((l) => l.view.status === "available")); | |
| 67 | + | assert.equal(listings[0]!.view.id, "sentry"); | |
| 68 | + | assert.equal(listings[0]!.href, "/acme/-/integrations/alerts", "managed where it is managed"); | |
| 69 | + | const linear = listings.find((l) => l.view.id === "linear")!; | |
| 70 | + | assert.equal(linear.href, "/acme/-/integrations/trackers?add=linear#add", "connected on its setup page"); | |
| 71 | + | assert.ok(!listings.some((l) => l.view.id === "slack")); | |
| 72 | + | assert.ok(comingIntegrations(views).some((v) => v.id === "slack")); | |
| 73 | + | assert.ok(integrationMatches(linear, "tracker")); | |
| 74 | + | assert.ok(!integrationMatches(linear, "deploys")); | |
| 75 | + | }); | |
| 76 | + | ||
| 77 | + | test("an owner adds what a request asks for where it is added", () => { | |
| 78 | + | const views = connectorsFor("workspace"); | |
| 79 | + | assert.equal(addPath({ listing: "agent:qa" }, "acme", views), "/acme/-/agents/new?template=qa"); | |
| 80 | + | assert.equal(addPath({ listing: "integration:jira" }, "acme", views), "/acme/-/integrations/trackers?add=jira#add"); | |
| 81 | + | assert.equal(addPath({ listing: "nonsense" }, "acme", views), null); | |
| 82 | + | }); | |
| 83 | + | ||
| 84 | + | test("the Marketplace's forms are read strictly", () => { | |
| 85 | + | const form = (fields: Record<string, string>) => { | |
| 86 | + | const data = new FormData(); | |
| 87 | + | for (const [k, v] of Object.entries(fields)) data.set(k, v); | |
| 88 | + | return data; | |
| 89 | + | }; | |
| 90 | + | assert.deepEqual(marketplaceForm(form({ intent: "request", listing: "agent:qa", note: " flaky " })), { intent: "request", listing: "agent:qa", note: "flaky" }); | |
| 91 | + | assert.deepEqual(marketplaceForm(form({ intent: "request", listing: "agent:qa" })), { intent: "request", listing: "agent:qa", note: null }); | |
| 92 | + | assert.equal(marketplaceForm(form({ intent: "request", listing: "app:qa" })), null); | |
| 93 | + | assert.deepEqual(marketplaceForm(form({ intent: "resolve", id: "ins_1", status: "declined" })), { intent: "resolve", id: "ins_1", status: "declined" }); | |
| 94 | + | assert.equal(marketplaceForm(form({ intent: "resolve", id: "ins_1", status: "open" })), null); | |
| 95 | + | assert.equal(marketplaceForm(form({ intent: "install" })), null); | |
| 96 | + | }); | |
| 97 | + | ||
| 98 | + | test("extensions list published ones first, each with its install", () => { | |
| 99 | + | const published = { ...FIRST_PARTY_EXTENSIONS[1]!, id: "standup", status: "available" as const, source: { repo: "flagon-io/standup", tag: "v1.0.0" }, version: "1.0.0" }; | |
| 100 | + | const install = { id: "ins_1", listing: "extension:standup", version: "1.0.0", plan: "free", installed_by: "chase", installed_at: "2026-10-10T00:00:00Z", enabled: true, disabled_by: null, disabled_at: null, budget_monthly_micros: null }; | |
| 101 | + | const listings = extensionListings([...FIRST_PARTY_EXTENSIONS, published], [install], [request("extension:support", "ana")], "ana"); | |
| 102 | + | assert.equal(listings[0]!.manifest.id, "standup"); | |
| 103 | + | assert.equal(listings[0]!.install?.version, "1.0.0"); | |
| 104 | + | assert.equal(listings.find((l) => l.manifest.id === "support")!.requested, true); | |
| 105 | + | assert.equal(listings.find((l) => l.manifest.id === "mail")!.install, null); | |
| 106 | + | }); | |
| 107 | + | ||
| 108 | + | test("coming integrations include the ones each person connects, once", () => { | |
| 109 | + | const workspace = connectorsFor("workspace"); | |
| 110 | + | const personal = connectorsFor("personal"); | |
| 111 | + | const coming = comingIntegrations(workspace, personal).map((v) => v.id); | |
| 112 | + | assert.ok(coming.includes("slack")); | |
| 113 | + | assert.ok(coming.includes("gmail"), "Gmail is each person's"); | |
| 114 | + | assert.ok(coming.includes("google-calendar")); | |
| 115 | + | assert.equal(new Set(coming).size, coming.length); | |
| 116 | + | }); | |
| 117 | + | ||
| 118 | + | test("installing, switching and removing an extension are read from forms", () => { | |
| 119 | + | const form = (fields: Record<string, string>) => { | |
| 120 | + | const data = new FormData(); | |
| 121 | + | for (const [k, v] of Object.entries(fields)) data.set(k, v); | |
| 122 | + | return data; | |
| 123 | + | }; | |
| 124 | + | assert.deepEqual(marketplaceForm(form({ intent: "install", extension: "support" })), { intent: "install", extension: "support" }); | |
| 125 | + | assert.deepEqual(marketplaceForm(form({ intent: "switch", listing: "extension:support", enabled: "off" })), { intent: "switch", listing: "extension:support", enabled: false }); | |
| 126 | + | assert.equal(marketplaceForm(form({ intent: "switch", listing: "integration:sentry", enabled: "off" })), null); | |
| 127 | + | assert.deepEqual(marketplaceForm(form({ intent: "uninstall", listing: "extension:support" })), { intent: "uninstall", listing: "extension:support" }); | |
| 128 | + | assert.equal(addPath({ listing: "extension:mail" }, "acme", []), "/acme/-/marketplace/extensions/mail"); | |
| 129 | + | }); | |
| 130 | + | ||
| 131 | + | test("routing limits read as words", () => { | |
| 132 | + | assert.equal(routingWords({ floor: null, ceiling: null }), "Any model the work needs"); | |
| 133 | + | assert.equal(routingWords({ floor: "large", ceiling: null }), "Large models or better"); | |
| 134 | + | assert.equal(routingWords({ floor: null, ceiling: "large" }), "Up to large models"); | |
| 135 | + | }); | |
| 136 | + | ||
| 137 | + | test("the Marketplace is part of Apps, not a mode of its own", () => { | |
| 138 | + | assert.equal(modeOf("/acme/-/marketplace", "acme"), "apps"); | |
| 139 | + | assert.equal(modeOf("/acme/-/marketplace/agents/qa", "acme"), "apps"); | |
| 140 | + | }); |
| 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 | + | } |
| 118 | 118 | "settings statement statement_entries status status_by_id suggest tree usage usage_meters user_by_username " + | |
| 119 | 119 | "user_for_session user_for_access_token usernames waiting_workspaces workflows workspace workspace_invites github_enabled " + | |
| 120 | 120 | "stars about public_links branch_drift tags last_commits languages contributors license releases release " + | |
| 121 | − | "stargazers starred commit_checks shortcuts spend person_budgets usage_report" | |
| 121 | + | "stargazers starred commit_checks shortcuts spend person_budgets usage_report templates install_requests extension_installs" | |
| 122 | 122 | ).split(" "), | |
| 123 | 123 | ); | |
| 124 | 124 |
| 146 | 146 | context: "agents", | |
| 147 | 147 | memory: "agents", | |
| 148 | 148 | apps: "apps", | |
| 149 | + | marketplace: "apps", | |
| 149 | 150 | people: "people", | |
| 150 | 151 | members: "people", | |
| 151 | 152 | teams: "people", |
| 202 | 202 | route("-/today", "routes/workspace/home.tsx"), | |
| 203 | 203 | route("-/home", "routes/workspace/moved.ts", { id: "routes/workspace/moved-home" }), | |
| 204 | 204 | route("-/apps", "routes/workspace/apps.tsx"), | |
| 205 | + | // The Marketplace: anyone browses it; owners add agents and integrations, | |
| 206 | + | // and everyone else asks (`requests` takes every form it posts). | |
| 207 | + | route("-/marketplace", "routes/workspace/marketplace/layout.tsx", [ | |
| 208 | + | index("routes/workspace/marketplace/discover.tsx"), | |
| 209 | + | route("agents", "routes/workspace/marketplace/agents.tsx"), | |
| 210 | + | route("agents/:template", "routes/workspace/marketplace/agent.tsx"), | |
| 211 | + | route("integrations", "routes/workspace/marketplace/integrations.tsx"), | |
| 212 | + | route("extensions", "routes/workspace/marketplace/extensions.tsx"), | |
| 213 | + | route("extensions/:extension", "routes/workspace/marketplace/extension.tsx"), | |
| 214 | + | route("requests", "routes/workspace/marketplace/requests.tsx"), | |
| 215 | + | ]), | |
| 205 | 216 | // What Code's pages say to a member without Code. | |
| 206 | 217 | route("-/code-access", "routes/workspace/code-access.tsx"), | |
| 207 | 218 | route("-/memory", "routes/workspace/memory.tsx"), |
| 249 | 249 | <h2 id="roster" className="text-sm font-medium"> | |
| 250 | 250 | Agents <span className="text-faint">{agents.length}</span> | |
| 251 | 251 | </h2> | |
| 252 | − | <Link to={`/${slug}/-/agents/new`} className="text-xs text-muted hover:text-fg"> | |
| 253 | − | Hire from a template | |
| 252 | + | <Link to={`/${slug}/-/marketplace/agents`} className="text-xs text-muted hover:text-fg"> | |
| 253 | + | Add from the agent catalog | |
| 254 | 254 | </Link> | |
| 255 | 255 | </div> | |
| 256 | 256 | <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> |
| 1 | 1 | /** | |
| 2 | − | * Apps: every app in the workspace that you can use, the ones pinned to | |
| 3 | − | * your dock first. Built-in apps are always in the dock; the rest you pin | |
| 4 | − | * or unpin here, from the launcher, or on a phone from More. Pins are | |
| 5 | − | * yours alone, per workspace, kept with your account so every device | |
| 6 | − | * shows the same dock (lib/dock.server.ts), with a copy in a cookie for | |
| 7 | − | * when the account's cannot be read (lib/apps.ts). The pin buttons post | |
| 8 | − | * here. | |
| 2 | + | * Apps: what the workspace installed from the Marketplace that you can | |
| 3 | + | * use, the ones pinned to your dock first. Built-in apps are always in the | |
| 4 | + | * dock, and their own pages are in their sidebars; Apps are only what was | |
| 5 | + | * added. Pins are yours alone, per workspace, kept with your account so | |
| 6 | + | * every device shows the same dock (lib/dock.server.ts), with a copy in a | |
| 7 | + | * cookie for when the account's cannot be read (lib/apps.ts). The pin | |
| 8 | + | * buttons post here, and the launcher reads its list from here. | |
| 9 | 9 | */ | |
| 10 | 10 | import { Store } from "lucide-react"; | |
| 11 | − | import { data } from "react-router"; | |
| 11 | + | import { Link, data } from "react-router"; | |
| 12 | 12 | ||
| 13 | − | import { hasCodeAccess } from "@g1t/contracts"; | |
| 14 | − | ||
| 15 | 13 | import type { Route } from "./+types/apps"; | |
| 16 | − | import { AppTile, useAppPins } from "../../components/apps"; | |
| 17 | − | import { DOCK_COOKIE, type PinnableApp, appPinFromForm, appsFor, dockCookie, pinsToShow, withPin, writeDock } from "../../lib/apps"; | |
| 14 | + | import { AppTile, NoApps, useAppPins } from "../../components/apps"; | |
| 15 | + | import { DOCK_COOKIE, type InstalledAppData, appData, appPinFromForm, dockCookie, installedApps, pinsToShow, withPin, writeDock } from "../../lib/apps"; | |
| 16 | + | import { connectedStates } from "../../lib/connected.server"; | |
| 18 | 17 | import { savePins, savedPins } from "../../lib/dock.server"; | |
| 18 | + | import { workspaceAgents } from "../../lib/services.server"; | |
| 19 | 19 | import { page } from "../../lib/meta"; | |
| 20 | 20 | import { readCookie } from "../../lib/mission"; | |
| 21 | 21 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; | |
| ⋯ | |||
| 29 | 29 | const slug = params.owner.toLowerCase(); | |
| 30 | 30 | const membership = viewer?.workspaces?.find((m) => m.slug === slug); | |
| 31 | 31 | if (!viewer || !membership) throw data(null, { status: 404 }); | |
| 32 | + | const [connected, installs, saved] = await Promise.all([ | |
| 33 | + | connectedStates(slug, viewer).catch(() => null), | |
| 34 | + | workspaceAgents | |
| 35 | + | .extensionInstalls(slug, viewer) | |
| 36 | + | .then((result) => (result.ok ? result.value : [])) | |
| 37 | + | .catch(() => []), | |
| 38 | + | savedPins(viewer, slug), | |
| 39 | + | ]); | |
| 32 | 40 | return { | |
| 33 | 41 | slug, | |
| 34 | 42 | name: membership.name?.trim() || slug, | |
| 35 | − | code: hasCodeAccess(membership), | |
| 36 | − | pins: pinsToShow(await savedPins(viewer, slug), readCookie(request.headers.get("cookie"), DOCK_COOKIE), slug), | |
| 43 | + | /** Null when what is installed could not be read. */ | |
| 44 | + | apps: connected ? installedApps(connected, installs).map((app) => appData(app, slug)) : (null as InstalledAppData[] | null), | |
| 45 | + | pins: pinsToShow(saved, readCookie(request.headers.get("cookie"), DOCK_COOKIE), slug), | |
| 37 | 46 | }; | |
| 38 | 47 | } | |
| 39 | 48 | ||
| ⋯ | |||
| 56 | 65 | } | |
| 57 | 66 | ||
| 58 | 67 | export default function Apps({ loaderData }: Route.ComponentProps) { | |
| 59 | − | const { slug, name, code } = loaderData; | |
| 68 | + | const { slug, name, apps } = loaderData; | |
| 60 | 69 | const { pins, toggle } = useAppPins(slug, loaderData.pins); | |
| 61 | − | const apps = appsFor(code); | |
| 62 | − | const pinned = pins.map((key) => apps.find((app) => app.key === key)).filter((app) => app != null); | |
| 70 | + | const usable = (apps ?? []).filter((app) => app.usable); | |
| 71 | + | const pinned = pins.map((key) => usable.find((app) => app.key === key)).filter((app) => app != null); | |
| 63 | 72 | return ( | |
| 64 | 73 | <main className="mx-auto max-w-5xl px-4 py-8 sm:px-8 sm:py-10"> | |
| 65 | 74 | <div className="flex flex-wrap items-end justify-between gap-4"> | |
| 66 | 75 | <div> | |
| 67 | 76 | <h1 className="text-2xl font-semibold tracking-tight">Apps</h1> | |
| 68 | 77 | <p className="mt-1 max-w-xl text-sm text-muted"> | |
| 69 | − | Everything in {name} that you can use. Pin the ones you want in your dock: your dock is yours, and nobody else sees your pins. | |
| 78 | + | What {name} added from the Marketplace. Pin the ones you want in your dock: your dock is yours, and nobody else sees your pins. | |
| 70 | 79 | </p> | |
| 71 | 80 | </div> | |
| 72 | − | <span className="inline-flex h-9 items-center gap-2 rounded-md border border-line px-3 text-sm text-faint"> | |
| 81 | + | <Link | |
| 82 | + | to={`/${slug}/-/marketplace`} | |
| 83 | + | prefetch="intent" | |
| 84 | + | className="inline-flex h-9 items-center gap-2 rounded-md border border-line px-3 text-sm text-fg/85 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg" | |
| 85 | + | > | |
| 73 | 86 | <Store size={15} /> | |
| 74 | − | Marketplace | |
| 75 | − | <span className="rounded-full bg-line px-1.5 text-[0.6875rem] font-medium text-muted">Coming</span> | |
| 76 | − | </span> | |
| 87 | + | Browse the Marketplace | |
| 88 | + | </Link> | |
| 77 | 89 | </div> | |
| 78 | 90 | ||
| 79 | − | <section aria-labelledby="pinned" className="mt-8"> | |
| 80 | − | <div className="flex items-baseline justify-between gap-3"> | |
| 81 | − | <h2 id="pinned" className="text-sm font-semibold"> | |
| 82 | − | Pinned to your dock | |
| 83 | − | </h2> | |
| 84 | − | <span className="text-xs text-faint">{pinned.length === 0 ? "None yet" : `${pinned.length} pinned`}</span> | |
| 91 | + | {apps == null ? ( | |
| 92 | + | <p className="mt-8 rounded-xl border border-line bg-surface px-4 py-6 text-center text-sm text-muted">What is installed can't be read right now. Reload in a minute.</p> | |
| 93 | + | ) : apps.length === 0 ? ( | |
| 94 | + | <div className="mt-8"> | |
| 95 | + | <NoApps slug={slug} /> | |
| 85 | 96 | </div> | |
| 86 | − | {pinned.length === 0 ? ( | |
| 87 | − | <p className="mt-3 rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted"> | |
| 88 | − | Nothing pinned yet. Pin an app below and it shows in your dock, under the built-in ones. | |
| 89 | − | </p> | |
| 90 | − | ) : ( | |
| 91 | − | <div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2"> | |
| 92 | − | {pinned.map((app) => ( | |
| 93 | − | <AppTile key={app.key} app={app} slug={slug} pinned onToggle={toggle} card /> | |
| 94 | − | ))} | |
| 95 | − | </div> | |
| 96 | − | )} | |
| 97 | − | </section> | |
| 97 | + | ) : ( | |
| 98 | + | <> | |
| 99 | + | <section aria-labelledby="pinned" className="mt-8"> | |
| 100 | + | <div className="flex items-baseline justify-between gap-3"> | |
| 101 | + | <h2 id="pinned" className="text-sm font-semibold"> | |
| 102 | + | Pinned to your dock | |
| 103 | + | </h2> | |
| 104 | + | <span className="text-xs text-faint">{pinned.length === 0 ? "None yet" : `${pinned.length} pinned`}</span> | |
| 105 | + | </div> | |
| 106 | + | {pinned.length === 0 ? ( | |
| 107 | + | <p className="mt-3 rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted"> | |
| 108 | + | Nothing pinned yet. Pin an app below and it shows in your dock, under the built-in ones. | |
| 109 | + | </p> | |
| 110 | + | ) : ( | |
| 111 | + | <div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2"> | |
| 112 | + | {pinned.map((app) => ( | |
| 113 | + | <AppTile key={app.key} app={app} pinned onToggle={toggle} card /> | |
| 114 | + | ))} | |
| 115 | + | </div> | |
| 116 | + | )} | |
| 117 | + | </section> | |
| 98 | 118 | ||
| 99 | − | <section aria-labelledby="yours" className="mt-10"> | |
| 100 | − | <div className="flex items-baseline justify-between gap-3"> | |
| 101 | − | <h2 id="yours" className="text-sm font-semibold"> | |
| 102 | − | Your apps | |
| 103 | − | </h2> | |
| 104 | − | <span className="text-xs text-faint">Built-in apps are always in your dock</span> | |
| 105 | − | </div> | |
| 106 | − | <div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2"> | |
| 107 | − | {apps.map((app) => ( | |
| 108 | − | <AppTile key={app.key} app={app} slug={slug} pinned={pins.includes(app.key as PinnableApp)} onToggle={toggle} card /> | |
| 109 | − | ))} | |
| 110 | − | </div> | |
| 111 | − | </section> | |
| 119 | + | <section aria-labelledby="installed" className="mt-10"> | |
| 120 | + | <div className="flex items-baseline justify-between gap-3"> | |
| 121 | + | <h2 id="installed" className="text-sm font-semibold"> | |
| 122 | + | Installed | |
| 123 | + | </h2> | |
| 124 | + | <span className="text-xs text-faint">{apps.length}</span> | |
| 125 | + | </div> | |
| 126 | + | <div className="mt-3 grid grid-cols-[repeat(auto-fill,minmax(15rem,1fr))] gap-2"> | |
| 127 | + | {apps.map((app) => ( | |
| 128 | + | <AppTile key={app.key} app={app} pinned={pins.includes(app.key)} onToggle={toggle} card /> | |
| 129 | + | ))} | |
| 130 | + | </div> | |
| 131 | + | </section> | |
| 132 | + | </> | |
| 133 | + | )} | |
| 112 | 134 | </main> | |
| 113 | 135 | ); | |
| 114 | 136 | } | |
| 1 | 1 | import { redirect } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import { type Provider, PROVIDERS } from "@g1t/contracts"; | |
| 4 | − | import { CONNECTORS, connectorsFor } from "@g1t/contracts/connectors"; | |
| 4 | + | import { connectorsFor } from "@g1t/contracts/connectors"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/integrations-directory"; | |
| 7 | 7 | import { ConnectorDirectory, ScopeNote } from "../../components/connectors"; | |
| 8 | − | import type { ConnectedState } from "../../lib/connectors"; | |
| 9 | − | import { githubApp } from "../../lib/github.server"; | |
| 8 | + | import { connectedStates } from "../../lib/connected.server"; | |
| 10 | 9 | import { integrationsSection } from "../../lib/integration-sections"; | |
| 11 | 10 | import { page } from "../../lib/meta"; | |
| 12 | − | import { integrations, webhooks } from "../../lib/services.server"; | |
| 13 | 11 | import { requireUser, roleIn } from "../../lib/session.server"; | |
| 14 | 12 | ||
| 15 | 13 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| ⋯ | |||
| 34 | 32 | if (add && add in PROVIDERS) { | |
| 35 | 33 | throw redirect(`/${slug}/-/integrations/${integrationsSection(PROVIDERS[add as Provider].kind)}?add=${add}#add`); | |
| 36 | 34 | } | |
| 37 | − | ||
| 38 | − | const [connections, github, hooks] = await Promise.all([ | |
| 39 | − | integrations.list(slug, viewer), | |
| 40 | − | githubApp.status(viewer, slug).catch(() => null), | |
| 41 | − | webhooks.list(viewer, { workspace: slug }).catch(() => null), | |
| 42 | − | ]); | |
| 43 | 35 | ||
| 44 | − | const connected: Record<string, ConnectedState> = {}; | |
| 45 | − | if (connections.ok) { | |
| 46 | − | for (const connector of CONNECTORS) { | |
| 47 | − | if (!connector.provider) continue; | |
| 48 | − | const mine = connections.value.filter((connection) => connection.provider === connector.provider); | |
| 49 | − | if (mine.length === 0) continue; | |
| 50 | − | const broken = mine.find((connection) => connection.lastError); | |
| 51 | − | connected[connector.id] = { | |
| 52 | − | detail: mine.length === 1 ? mine[0]!.name : `${mine.length} connections: ${mine.map((c) => c.name).join(", ")}`, | |
| 53 | − | problem: broken ? `${broken.name}: ${broken.lastError}` : null, | |
| 54 | − | manage: `/${slug}/-/integrations/${integrationsSection(PROVIDERS[connector.provider].kind)}`, | |
| 55 | − | }; | |
| 56 | − | } | |
| 57 | − | } | |
| 58 | − | if (github?.ok && github.value.installations.length > 0) { | |
| 59 | − | const suspended = github.value.installations.find((installation) => installation.suspended); | |
| 60 | − | connected.github = { | |
| 61 | − | detail: `On ${github.value.installations.map((installation) => installation.account).join(", ")}`, | |
| 62 | − | problem: suspended ? `The installation on ${suspended.account} is suspended on GitHub.` : null, | |
| 63 | − | manage: null, | |
| 64 | − | }; | |
| 65 | − | } | |
| 66 | − | const ours = hooks?.ok ? hooks.value.filter((hook) => hook.scope === "workspace") : []; | |
| 67 | − | if (ours.length > 0) { | |
| 68 | − | const failing = ours.filter((hook) => hook.lastStatus === "failed"); | |
| 69 | − | connected.webhooks = { | |
| 70 | − | detail: ours.length === 1 ? ours[0]!.url : `${ours.length} webhooks`, | |
| 71 | − | problem: failing.length > 0 ? `The last delivery to ${failing[0]!.url} failed.` : null, | |
| 72 | − | manage: null, | |
| 73 | − | }; | |
| 74 | − | } | |
| 75 | − | ||
| 76 | − | return { slug, owner: role === "owner", connected }; | |
| 36 | + | return { slug, owner: role === "owner", connected: await connectedStates(slug, viewer) }; | |
| 77 | 37 | } | |
| 78 | 38 | ||
| 79 | 39 | export default function WorkspaceIntegrationsDirectory({ loaderData }: Route.ComponentProps) { | |
| 1 | + | /** | |
| 2 | + | * One role in the agent catalog: what an agent hired into it is | |
| 3 | + | * responsible for, the helpers it works with, how it talks, which models | |
| 4 | + | * it runs on, the instructions it starts from, and who in the workspace | |
| 5 | + | * already has the job. | |
| 6 | + | */ | |
| 7 | + | import { ArrowLeft } from "lucide-react"; | |
| 8 | + | import { Link, data } from "react-router"; | |
| 9 | + | ||
| 10 | + | import type { Route } from "./+types/agent"; | |
| 11 | + | import { PixelCreature } from "../../../components/agent-avatar"; | |
| 12 | + | import { AgentAction, TierBadge } from "../../../components/marketplace"; | |
| 13 | + | import { PRESETS } from "../../../lib/agent-form"; | |
| 14 | + | import { agentListings, marketplacePath, roleLine, routingWords } from "../../../lib/marketplace"; | |
| 15 | + | import { loadCatalog } from "../../../lib/marketplace.server"; | |
| 16 | + | import { page } from "../../../lib/meta"; | |
| 17 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 18 | + | import { useMarketplace } from "./layout"; | |
| 19 | + | ||
| 20 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 21 | + | const template = args.loaderData?.templates?.find((t) => t.id === params.template); | |
| 22 | + | return page(args, { title: `${template ? template.title : "Agent"} · Marketplace · ${params.owner} · g1t` }); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 26 | + | const viewer = requireUser(context, request); | |
| 27 | + | const slug = params.owner.toLowerCase(); | |
| 28 | + | if (!roleIn(viewer, slug)) throw data(null, { status: 404 }); | |
| 29 | + | const catalog = await loadCatalog(slug, viewer); | |
| 30 | + | if (catalog.templates && !catalog.templates.some((t) => t.id === params.template)) throw data(null, { status: 404 }); | |
| 31 | + | return catalog; | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | export default function CatalogAgent({ loaderData, params }: Route.ComponentProps) { | |
| 35 | + | const { slug, owner, username, requests } = useMarketplace(); | |
| 36 | + | const listing = agentListings(loaderData.templates ?? [], loaderData.agents, requests?.requests ?? [], username).find((l) => l.template.id === params.template); | |
| 37 | + | const back = ( | |
| 38 | + | <Link to={marketplacePath(slug, "agents")} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 39 | + | <ArrowLeft size={14} /> | |
| 40 | + | All roles | |
| 41 | + | </Link> | |
| 42 | + | ); | |
| 43 | + | if (!listing) { | |
| 44 | + | return ( | |
| 45 | + | <div> | |
| 46 | + | {back} | |
| 47 | + | <p className="mt-6 text-sm text-muted">The agent catalog can't be shown right now: the agents service didn't answer. Reload in a minute.</p> | |
| 48 | + | </div> | |
| 49 | + | ); | |
| 50 | + | } | |
| 51 | + | const { template, hired } = listing; | |
| 52 | + | const voice = PRESETS.find((preset) => preset.value === template.personality_preset); | |
| 53 | + | const others = template.name_ideas.filter((name) => name !== template.display_name); | |
| 54 | + | return ( | |
| 55 | + | <div> | |
| 56 | + | {back} | |
| 57 | + | <header className="mt-5 flex flex-wrap items-center gap-4"> | |
| 58 | + | <PixelCreature seed={template.handle} size={64} /> | |
| 59 | + | <div className="min-w-0 grow basis-60"> | |
| 60 | + | <h2 className="flex flex-wrap items-center gap-2 text-xl font-semibold tracking-tight"> | |
| 61 | + | {template.display_name} | |
| 62 | + | <TierBadge tier="official" /> | |
| 63 | + | </h2> | |
| 64 | + | <p className="mt-0.5 text-sm text-muted">{roleLine(template)}</p> | |
| 65 | + | </div> | |
| 66 | + | <AgentAction listing={listing} slug={slug} owner={owner} className="h-9 px-4 text-sm" /> | |
| 67 | + | </header> | |
| 68 | + | ||
| 69 | + | <div className="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_18rem]"> | |
| 70 | + | <div className="min-w-0 space-y-8"> | |
| 71 | + | <section aria-labelledby="duties"> | |
| 72 | + | <h3 id="duties" className="text-sm font-semibold"> | |
| 73 | + | What it's responsible for | |
| 74 | + | </h3> | |
| 75 | + | <ul className="mt-3 space-y-2"> | |
| 76 | + | {template.responsibilities.map((duty) => ( | |
| 77 | + | <li key={duty} className="flex gap-2.5 text-sm text-fg-soft"> | |
| 78 | + | <span className="mt-2 size-1.5 shrink-0 rounded-full bg-accent" aria-hidden="true" /> | |
| 79 | + | {duty} | |
| 80 | + | </li> | |
| 81 | + | ))} | |
| 82 | + | </ul> | |
| 83 | + | </section> | |
| 84 | + | ||
| 85 | + | {template.subagents.length > 0 && ( | |
| 86 | + | <section aria-labelledby="helpers"> | |
| 87 | + | <h3 id="helpers" className="text-sm font-semibold"> | |
| 88 | + | Helpers it works with | |
| 89 | + | </h3> | |
| 90 | + | <p className="mt-1 text-sm text-muted">Smaller agents it starts for one piece of its work, paid from its own budget.</p> | |
| 91 | + | <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 92 | + | {template.subagents.map((helper) => ( | |
| 93 | + | <li key={helper.name} className="px-4 py-3"> | |
| 94 | + | <p className="font-mono text-[0.8125rem]">{helper.name}</p> | |
| 95 | + | <p className="mt-0.5 text-sm text-muted">{helper.description}</p> | |
| 96 | + | </li> | |
| 97 | + | ))} | |
| 98 | + | </ul> | |
| 99 | + | </section> | |
| 100 | + | )} | |
| 101 | + | ||
| 102 | + | <section aria-labelledby="instructions"> | |
| 103 | + | <h3 id="instructions" className="text-sm font-semibold"> | |
| 104 | + | The instructions it starts from | |
| 105 | + | </h3> | |
| 106 | + | <p className="mt-1 text-sm text-muted">Yours to change once it is added, on its profile.</p> | |
| 107 | + | <pre className="mt-3 max-h-96 overflow-auto rounded-xl border border-line bg-surface p-4 font-sans text-[0.8125rem] leading-relaxed whitespace-pre-wrap text-fg-soft">{template.instructions}</pre> | |
| 108 | + | </section> | |
| 109 | + | </div> | |
| 110 | + | ||
| 111 | + | <aside className="space-y-6 text-sm"> | |
| 112 | + | <dl className="space-y-4 rounded-xl border border-line bg-surface p-4"> | |
| 113 | + | <div> | |
| 114 | + | <dt className="text-xs text-faint">Voice</dt> | |
| 115 | + | <dd className="mt-0.5">{voice ? voice.label : template.personality_preset}</dd> | |
| 116 | + | {voice && <dd className="text-xs text-muted">{voice.about}</dd>} | |
| 117 | + | </div> | |
| 118 | + | <div> | |
| 119 | + | <dt className="text-xs text-faint">Models</dt> | |
| 120 | + | <dd className="mt-0.5">{routingWords(template.routing)}</dd> | |
| 121 | + | </div> | |
| 122 | + | <div> | |
| 123 | + | <dt className="text-xs text-faint">Budget</dt> | |
| 124 | + | <dd className="mt-0.5">The workspace's default for a new agent, billed at what it costs</dd> | |
| 125 | + | </div> | |
| 126 | + | {others.length > 0 && ( | |
| 127 | + | <div> | |
| 128 | + | <dt className="text-xs text-faint">Other names it suits</dt> | |
| 129 | + | <dd className="mt-0.5 text-muted">{others.slice(0, 5).join(", ")}</dd> | |
| 130 | + | </div> | |
| 131 | + | )} | |
| 132 | + | </dl> | |
| 133 | + | <section aria-labelledby="here" className="rounded-xl border border-line bg-surface p-4"> | |
| 134 | + | <h3 id="here" className="text-xs text-faint"> | |
| 135 | + | In this workspace | |
| 136 | + | </h3> | |
| 137 | + | {hired.length === 0 ? ( | |
| 138 | + | <p className="mt-1 text-muted">Nobody has this job yet.</p> | |
| 139 | + | ) : ( | |
| 140 | + | <ul className="mt-2 space-y-1.5"> | |
| 141 | + | {hired.map((agent) => ( | |
| 142 | + | <li key={agent.id} className="flex items-center gap-2"> | |
| 143 | + | <PixelCreature seed={agent.handle} size={20} /> | |
| 144 | + | <Link to={`/${slug}/-/agents/${agent.handle}`} className="truncate hover:underline"> | |
| 145 | + | {agent.display_name} <span className="text-faint">@{agent.handle}</span> | |
| 146 | + | </Link> | |
| 147 | + | </li> | |
| 148 | + | ))} | |
| 149 | + | </ul> | |
| 150 | + | )} | |
| 151 | + | </section> | |
| 152 | + | </aside> | |
| 153 | + | </div> | |
| 154 | + | </div> | |
| 155 | + | ); | |
| 156 | + | } |
| 1 | + | /** | |
| 2 | + | * The agent catalog: every role an agent can be hired into, by | |
| 3 | + | * department, with who in the workspace was hired into each. Owners add | |
| 4 | + | * one through the new-agent form with the role chosen; anyone else asks. | |
| 5 | + | */ | |
| 6 | + | import type { Route } from "./+types/agents"; | |
| 7 | + | import { AgentCard } from "../../../components/marketplace"; | |
| 8 | + | import { G1tMark } from "../../../components/orchestrator"; | |
| 9 | + | import { EmptyState } from "../../../components/ui"; | |
| 10 | + | import { agentListings } from "../../../lib/marketplace"; | |
| 11 | + | import { loadCatalog } from "../../../lib/marketplace.server"; | |
| 12 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 13 | + | import { useMarketplace } from "./layout"; | |
| 14 | + | ||
| 15 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 16 | + | const viewer = requireUser(context, request); | |
| 17 | + | const slug = params.owner.toLowerCase(); | |
| 18 | + | if (!roleIn(viewer, slug)) throw new Response(null, { status: 404 }); | |
| 19 | + | return loadCatalog(slug, viewer); | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | export default function AgentCatalog({ loaderData }: Route.ComponentProps) { | |
| 23 | + | const { slug, owner, username, requests } = useMarketplace(); | |
| 24 | + | if (loaderData.templates == null) { | |
| 25 | + | return <EmptyState title="The agent catalog can't be shown right now">The agents service didn't answer. Reload in a minute.</EmptyState>; | |
| 26 | + | } | |
| 27 | + | const listings = agentListings(loaderData.templates, loaderData.agents, requests?.requests ?? [], username); | |
| 28 | + | // By department, in the order the catalog lists them. | |
| 29 | + | const departments = new Map<string, typeof listings>(); | |
| 30 | + | for (const listing of listings) { | |
| 31 | + | const key = listing.template.department || "Other"; | |
| 32 | + | departments.set(key, [...(departments.get(key) ?? []), listing]); | |
| 33 | + | } | |
| 34 | + | return ( | |
| 35 | + | <div className="space-y-10"> | |
| 36 | + | <div className="flex flex-wrap items-start gap-4 rounded-2xl border border-line bg-surface p-5"> | |
| 37 | + | <G1tMark size={40} /> | |
| 38 | + | <div className="min-w-0 grow basis-72"> | |
| 39 | + | <p className="text-sm font-semibold">Start with g1t, add specialists as the work grows</p> | |
| 40 | + | <p className="mt-1 max-w-3xl text-sm text-muted"> | |
| 41 | + | g1t, the orchestrator, is in every workspace and knows every agent in it. Each role below is a starting point: pick a name, a team and a budget when you add it, and change anything after. Every agent works with the access of the person asking, never more, and starts with the workspace's default budget. | |
| 42 | + | </p> | |
| 43 | + | </div> | |
| 44 | + | <p className="shrink-0 text-xs text-faint"> | |
| 45 | + | {listings.length} {listings.length === 1 ? "role" : "roles"}, built by g1t | |
| 46 | + | </p> | |
| 47 | + | </div> | |
| 48 | + | {[...departments].map(([department, list]) => ( | |
| 49 | + | <section key={department} aria-label={department}> | |
| 50 | + | <h2 className="mb-3 text-xs font-semibold tracking-wide text-faint uppercase">{department}</h2> | |
| 51 | + | <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> | |
| 52 | + | {list.map((listing) => ( | |
| 53 | + | <AgentCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> | |
| 54 | + | ))} | |
| 55 | + | </div> | |
| 56 | + | </section> | |
| 57 | + | ))} | |
| 58 | + | </div> | |
| 59 | + | ); | |
| 60 | + | } |
| 1 | + | /** | |
| 2 | + | * The Marketplace's front page: what comes with every workspace, roles | |
| 3 | + | * from the agent catalog, integrations (connected first) and extensions. | |
| 4 | + | * Every listing is real: what can be added today with its real state, or | |
| 5 | + | * g1t's own extensions marked Soon until their first release. | |
| 6 | + | */ | |
| 7 | + | import { Rocket } from "lucide-react"; | |
| 8 | + | import type { ReactNode } from "react"; | |
| 9 | + | import { Link } from "react-router"; | |
| 10 | + | ||
| 11 | + | import { connectorsFor } from "@g1t/contracts/connectors"; | |
| 12 | + | ||
| 13 | + | import type { Route } from "./+types/discover"; | |
| 14 | + | import { appIcon } from "../../../components/apps"; | |
| 15 | + | import { AgentCard, ExtensionCard, IntegrationCard, SectionHead, SeeAll } from "../../../components/marketplace"; | |
| 16 | + | import { G1tMark } from "../../../components/orchestrator"; | |
| 17 | + | import { EmptyState } from "../../../components/ui"; | |
| 18 | + | import { appOf } from "../../../lib/apps"; | |
| 19 | + | import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace"; | |
| 20 | + | import { agentListings, extensionListings, integrationListings, marketplacePath } from "../../../lib/marketplace"; | |
| 21 | + | import { loadCatalog, loadConnected } from "../../../lib/marketplace.server"; | |
| 22 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 23 | + | import { useMarketplace } from "./layout"; | |
| 24 | + | ||
| 25 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 26 | + | const viewer = requireUser(context, request); | |
| 27 | + | const slug = params.owner.toLowerCase(); | |
| 28 | + | if (!roleIn(viewer, slug)) throw new Response(null, { status: 404 }); | |
| 29 | + | const [catalog, connected] = await Promise.all([loadCatalog(slug, viewer), loadConnected(slug, viewer)]); | |
| 30 | + | return { ...catalog, connected }; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /** How many of each kind of listing the front page shows before "All". */ | |
| 34 | + | const AGENTS_SHOWN = 5; | |
| 35 | + | const INTEGRATIONS_SHOWN = 6; | |
| 36 | + | ||
| 37 | + | export default function MarketplaceDiscover({ loaderData }: Route.ComponentProps) { | |
| 38 | + | const { slug, owner, username, requests, installs } = useMarketplace(); | |
| 39 | + | const asked = requests?.requests ?? []; | |
| 40 | + | const agents = agentListings(loaderData.templates ?? [], loaderData.agents, asked, username); | |
| 41 | + | const integrations = integrationListings(connectorsFor("workspace"), loaderData.connected, asked, username, slug); | |
| 42 | + | const extensions = extensionListings(FIRST_PARTY_EXTENSIONS, installs, asked, username); | |
| 43 | + | const builtIn: { name: string; to: string; icon: ReactNode }[] = [ | |
| 44 | + | ...(["code", "chat", "agents", "artifacts"] as const).map((key) => ({ name: appOf(key).name, to: appOf(key).path(slug), icon: appIcon(key, 15) })), | |
| 45 | + | { name: "Deployments", to: `/${slug}/-/projects`, icon: <Rocket size={15} /> }, | |
| 46 | + | ]; | |
| 47 | + | return ( | |
| 48 | + | <div className="space-y-12"> | |
| 49 | + | <section aria-labelledby="included" className="rounded-2xl border border-line bg-surface p-5 sm:p-6"> | |
| 50 | + | <div className="flex flex-wrap items-baseline justify-between gap-2"> | |
| 51 | + | <h2 id="included" className="text-base font-semibold tracking-tight"> | |
| 52 | + | In every workspace | |
| 53 | + | </h2> | |
| 54 | + | <span className="text-xs text-faint">Nothing to add</span> | |
| 55 | + | </div> | |
| 56 | + | <p className="mt-1 max-w-2xl text-sm text-muted">Code and Deployments are built in, because what agents make has to be reviewed, tested and shipped. So are Chat, Agents and Artifacts.</p> | |
| 57 | + | <ul className="mt-4 flex flex-wrap gap-2"> | |
| 58 | + | {builtIn.map(({ name, to, icon }) => ( | |
| 59 | + | <li key={name}> | |
| 60 | + | <Link to={to} className="flex h-9 items-center gap-2 rounded-full border border-line bg-bg pr-3.5 pl-2.5 text-sm transition-colors hover:border-line-strong"> | |
| 61 | + | <span className="text-muted">{icon}</span> | |
| 62 | + | {name} | |
| 63 | + | </Link> | |
| 64 | + | </li> | |
| 65 | + | ))} | |
| 66 | + | </ul> | |
| 67 | + | </section> | |
| 68 | + | ||
| 69 | + | <section aria-labelledby="agents"> | |
| 70 | + | <SectionHead id="agents" title="Agents" aside={agents.length > 0 && <SeeAll to={marketplacePath(slug, "agents")}>All {agents.length} roles</SeeAll>}> | |
| 71 | + | Each role brings responsibilities, a voice and the helpers it works with. Add one and rename it, or keep to g1t, which brings in whoever fits. | |
| 72 | + | </SectionHead> | |
| 73 | + | {loaderData.templates == null ? ( | |
| 74 | + | <EmptyState title="The agent catalog can't be shown right now">The agents service didn't answer. Reload in a minute.</EmptyState> | |
| 75 | + | ) : ( | |
| 76 | + | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 77 | + | <Link | |
| 78 | + | to={`/${slug}/-/agents`} | |
| 79 | + | className="flex flex-col rounded-xl border border-accent/30 bg-accent/[0.06] p-4 transition-colors hover:border-accent/60" | |
| 80 | + | > | |
| 81 | + | <div className="flex items-center gap-3"> | |
| 82 | + | <G1tMark size={44} /> | |
| 83 | + | <div className="min-w-0"> | |
| 84 | + | <p className="text-sm font-semibold">g1t</p> | |
| 85 | + | <p className="text-xs text-muted">Orchestrator · in every workspace</p> | |
| 86 | + | </div> | |
| 87 | + | </div> | |
| 88 | + | <p className="mt-3 grow text-[0.8125rem] leading-snug text-muted">Ask g1t anything. It answers itself or hands the work to the agent that fits, and you see the hand-off.</p> | |
| 89 | + | <p className="mt-4 border-t border-accent/20 pt-3 text-xs text-accent">Already here</p> | |
| 90 | + | </Link> | |
| 91 | + | {agents.slice(0, AGENTS_SHOWN).map((listing) => ( | |
| 92 | + | <AgentCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> | |
| 93 | + | ))} | |
| 94 | + | </div> | |
| 95 | + | )} | |
| 96 | + | </section> | |
| 97 | + | ||
| 98 | + | <section aria-labelledby="integrations"> | |
| 99 | + | <SectionHead id="integrations" title="Integrations" aside={<SeeAll to={marketplacePath(slug, "integrations")}>All {integrations.length}</SeeAll>}> | |
| 100 | + | Integrations connect what you already use and give agents new abilities, without adding pages. | |
| 101 | + | </SectionHead> | |
| 102 | + | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 103 | + | {integrations.slice(0, INTEGRATIONS_SHOWN).map((listing) => ( | |
| 104 | + | <IntegrationCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> | |
| 105 | + | ))} | |
| 106 | + | </div> | |
| 107 | + | </section> | |
| 108 | + | ||
| 109 | + | <section aria-labelledby="extensions"> | |
| 110 | + | <SectionHead id="extensions" title="Extensions" aside={<SeeAll to={marketplacePath(slug, "extensions")}>All extensions</SeeAll>}> | |
| 111 | + | Extensions add pages, data, cards and agent roles for what your team does besides code. Each is shared from a public repository, and the install screen lists every permission and every domain its data goes to. | |
| 112 | + | </SectionHead> | |
| 113 | + | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> | |
| 114 | + | {extensions.map((listing) => ( | |
| 115 | + | <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> | |
| 116 | + | ))} | |
| 117 | + | </div> | |
| 118 | + | </section> | |
| 119 | + | </div> | |
| 120 | + | ); | |
| 121 | + | } |
| 1 | + | /** | |
| 2 | + | * One extension's page: what it adds, what it may do (in words and as | |
| 3 | + | * scopes), where its data goes, where it runs, its version and source, | |
| 4 | + | * and, once installed, the switch that turns it off and the way to remove it. | |
| 5 | + | */ | |
| 6 | + | import { ArrowLeft, Globe, Power, ShieldCheck, Trash2 } from "lucide-react"; | |
| 7 | + | import { Link, data, useFetcher } from "react-router"; | |
| 8 | + | ||
| 9 | + | import { describeScope, isScope } from "@g1t/contracts/scopes"; | |
| 10 | + | import { dataDisclosure, extensionById } from "@g1t/contracts/marketplace"; | |
| 11 | + | ||
| 12 | + | import type { Route } from "./+types/extension"; | |
| 13 | + | import { ACTION, ComingBadge, ExtensionAction, ExtensionMark, TierBadge } from "../../../components/marketplace"; | |
| 14 | + | import { SubmitButton } from "../../../components/ui"; | |
| 15 | + | import { extensionListings, marketplacePath, runtimeWords } from "../../../lib/marketplace"; | |
| 16 | + | import { page } from "../../../lib/meta"; | |
| 17 | + | import { useMarketplace } from "./layout"; | |
| 18 | + | ||
| 19 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 20 | + | const extension = extensionById(params.extension); | |
| 21 | + | return page(args, { title: `${extension?.name ?? "Extension"} · Marketplace · ${params.owner} · g1t` }); | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | export function loader({ params }: Route.LoaderArgs) { | |
| 25 | + | if (!extensionById(params.extension)) throw data(null, { status: 404 }); | |
| 26 | + | return null; | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | export default function MarketplaceExtension({ params }: Route.ComponentProps) { | |
| 30 | + | const { slug, owner, username, requests, installs } = useMarketplace(); | |
| 31 | + | const manifest = extensionById(params.extension)!; | |
| 32 | + | const [listing] = extensionListings([manifest], installs, requests?.requests ?? [], username); | |
| 33 | + | const install = listing!.install; | |
| 34 | + | const fetcher = useFetcher<{ error: string | null }>(); | |
| 35 | + | const action = marketplacePath(slug, "requests"); | |
| 36 | + | const Group = ({ title, items, empty }: { title: string; items: string[]; empty: string }) => ( | |
| 37 | + | <div className="rounded-xl border border-line bg-surface p-4"> | |
| 38 | + | <h4 className="text-xs text-faint">{title}</h4> | |
| 39 | + | {items.length === 0 ? ( | |
| 40 | + | <p className="mt-1.5 text-sm text-muted">{empty}</p> | |
| 41 | + | ) : ( | |
| 42 | + | <ul className="mt-1.5 space-y-1 text-sm"> | |
| 43 | + | {items.map((item) => ( | |
| 44 | + | <li key={item}>{item}</li> | |
| 45 | + | ))} | |
| 46 | + | </ul> | |
| 47 | + | )} | |
| 48 | + | </div> | |
| 49 | + | ); | |
| 50 | + | return ( | |
| 51 | + | <div> | |
| 52 | + | <Link to={marketplacePath(slug, "extensions")} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 53 | + | <ArrowLeft size={14} /> | |
| 54 | + | All extensions | |
| 55 | + | </Link> | |
| 56 | + | <header className="mt-5 flex flex-wrap items-center gap-4"> | |
| 57 | + | <ExtensionMark manifest={manifest} size={64} /> | |
| 58 | + | <div className="min-w-0 grow basis-60"> | |
| 59 | + | <h2 className="flex flex-wrap items-center gap-2 text-xl font-semibold tracking-tight"> | |
| 60 | + | {manifest.name} | |
| 61 | + | <TierBadge tier={manifest.publisher.tier} /> | |
| 62 | + | {manifest.status !== "available" && <ComingBadge />} | |
| 63 | + | </h2> | |
| 64 | + | <p className="mt-0.5 text-sm text-muted"> | |
| 65 | + | {manifest.publisher.name} · {manifest.category} · Free | |
| 66 | + | </p> | |
| 67 | + | </div> | |
| 68 | + | <ExtensionAction listing={listing!} slug={slug} owner={owner} className="h-9 px-4 text-sm" /> | |
| 69 | + | </header> | |
| 70 | + | <p className="mt-6 max-w-3xl text-[0.9375rem] leading-relaxed text-fg-soft">{manifest.description}</p> | |
| 71 | + | ||
| 72 | + | <div className="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]"> | |
| 73 | + | <section aria-labelledby="adds" className="min-w-0"> | |
| 74 | + | <h3 id="adds" className="text-sm font-semibold"> | |
| 75 | + | What it adds to the workspace | |
| 76 | + | </h3> | |
| 77 | + | <div className="mt-3 grid gap-3 sm:grid-cols-2"> | |
| 78 | + | <Group title="Pages, in its sidebar" items={manifest.adds.pages} empty="None" /> | |
| 79 | + | <Group title="Agent roles you can add" items={manifest.adds.agent_roles} empty="None. Your agents get its tools." /> | |
| 80 | + | <Group title="Tools for agents" items={manifest.adds.tools} empty="None" /> | |
| 81 | + | <Group title="Notifications it sends" items={manifest.adds.notifications} empty="None" /> | |
| 82 | + | </div> | |
| 83 | + | </section> | |
| 84 | + | ||
| 85 | + | <aside className="space-y-4 text-sm"> | |
| 86 | + | <section aria-labelledby="may" className="rounded-xl border border-line bg-surface p-4"> | |
| 87 | + | <h3 id="may" className="flex items-center gap-1.5 text-xs text-faint"> | |
| 88 | + | <ShieldCheck size={13} /> | |
| 89 | + | It will be able to | |
| 90 | + | </h3> | |
| 91 | + | <ul className="mt-2 space-y-1.5"> | |
| 92 | + | {manifest.permissions.map((permission) => ( | |
| 93 | + | <li key={permission}>{permission}</li> | |
| 94 | + | ))} | |
| 95 | + | </ul> | |
| 96 | + | <h4 className="mt-4 text-xs text-faint">With access to</h4> | |
| 97 | + | <ul className="mt-1.5 space-y-1 text-muted"> | |
| 98 | + | {manifest.scopes.map((scope) => ( | |
| 99 | + | <li key={scope}> | |
| 100 | + | <span className="font-mono text-xs text-fg-soft">{scope}</span> | |
| 101 | + | {isScope(scope) && <span className="block text-xs">{describeScope(scope)}</span>} | |
| 102 | + | </li> | |
| 103 | + | ))} | |
| 104 | + | </ul> | |
| 105 | + | </section> | |
| 106 | + | <section aria-labelledby="where" className="rounded-xl border border-line bg-surface p-4"> | |
| 107 | + | <h3 id="where" className="flex items-center gap-1.5 text-xs text-faint"> | |
| 108 | + | <Globe size={13} /> | |
| 109 | + | Where it runs, and where data goes | |
| 110 | + | </h3> | |
| 111 | + | <p className="mt-2">{runtimeWords(manifest)}</p> | |
| 112 | + | <p className={manifest.domains.length > 0 ? "mt-1 text-warn" : "mt-1 text-muted"}>{dataDisclosure(manifest)}</p> | |
| 113 | + | </section> | |
| 114 | + | <dl className="space-y-3 rounded-xl border border-line bg-surface p-4"> | |
| 115 | + | <div> | |
| 116 | + | <dt className="text-xs text-faint">Version</dt> | |
| 117 | + | <dd className="mt-0.5">{install ? `${install.version} installed` : (manifest.version ?? "Not published yet")}</dd> | |
| 118 | + | </div> | |
| 119 | + | <div> | |
| 120 | + | <dt className="text-xs text-faint">Source</dt> | |
| 121 | + | <dd className="mt-0.5"> | |
| 122 | + | {manifest.source ? ( | |
| 123 | + | <Link to={`/${manifest.source.repo}/tree/${manifest.source.tag}`} className="font-mono text-xs hover:underline"> | |
| 124 | + | {manifest.source.repo}@{manifest.source.tag} | |
| 125 | + | </Link> | |
| 126 | + | ) : ( | |
| 127 | + | <span className="text-muted">Published with its first release</span> | |
| 128 | + | )} | |
| 129 | + | </dd> | |
| 130 | + | </div> | |
| 131 | + | <div> | |
| 132 | + | <dt className="text-xs text-faint">Price</dt> | |
| 133 | + | <dd className="mt-0.5">Free. What its agents do is billed at what it costs, like any agent's work.</dd> | |
| 134 | + | </div> | |
| 135 | + | </dl> | |
| 136 | + | {install && owner && ( | |
| 137 | + | <fetcher.Form method="post" action={action} className="flex flex-wrap gap-2"> | |
| 138 | + | <input type="hidden" name="listing" value={listing!.ref} /> | |
| 139 | + | <input type="hidden" name="enabled" value={install.enabled ? "off" : "on"} /> | |
| 140 | + | <SubmitButton fetcher={fetcher} name="intent" value="switch" className={ACTION.quiet} pending="Saving…"> | |
| 141 | + | <Power size={14} /> | |
| 142 | + | {install.enabled ? "Switch off" : "Switch on"} | |
| 143 | + | </SubmitButton> | |
| 144 | + | <SubmitButton fetcher={fetcher} name="intent" value="uninstall" className={ACTION.quiet} pending="Removing…"> | |
| 145 | + | <Trash2 size={14} /> | |
| 146 | + | Uninstall | |
| 147 | + | </SubmitButton> | |
| 148 | + | </fetcher.Form> | |
| 149 | + | )} | |
| 150 | + | </aside> | |
| 151 | + | </div> | |
| 152 | + | </div> | |
| 153 | + | ); | |
| 154 | + | } |
| 1 | + | /** | |
| 2 | + | * Extensions in the Marketplace: every listed extension, from its | |
| 3 | + | * manifest (@g1t/contracts marketplace.ts), published ones first, with what | |
| 4 | + | * the workspace installed; then how extensions are published and who | |
| 5 | + | * stands behind each tier. | |
| 6 | + | */ | |
| 7 | + | import { FIRST_PARTY_EXTENSIONS, LISTING_TIERS } from "@g1t/contracts/marketplace"; | |
| 8 | + | ||
| 9 | + | import { ExtensionCard, SectionHead, TierBadge } from "../../../components/marketplace"; | |
| 10 | + | import { TIERS, extensionListings } from "../../../lib/marketplace"; | |
| 11 | + | import { useMarketplace } from "./layout"; | |
| 12 | + | ||
| 13 | + | export default function MarketplaceExtensions() { | |
| 14 | + | const { slug, owner, username, requests, installs } = useMarketplace(); | |
| 15 | + | const listings = extensionListings(FIRST_PARTY_EXTENSIONS, installs, requests?.requests ?? [], username); | |
| 16 | + | const published = listings.filter((l) => l.manifest.status === "available"); | |
| 17 | + | const coming = listings.filter((l) => l.manifest.status !== "available"); | |
| 18 | + | return ( | |
| 19 | + | <div className="space-y-12"> | |
| 20 | + | {published.length > 0 && ( | |
| 21 | + | <section aria-labelledby="published"> | |
| 22 | + | <SectionHead id="published" title="Ready to install" aside={`${published.length}`} /> | |
| 23 | + | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 24 | + | {published.map((listing) => ( | |
| 25 | + | <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> | |
| 26 | + | ))} | |
| 27 | + | </div> | |
| 28 | + | </section> | |
| 29 | + | )} | |
| 30 | + | <section aria-labelledby="soon"> | |
| 31 | + | <SectionHead id="soon" title={published.length > 0 ? "Coming" : "Coming from g1t"} aside={`${coming.length}`}> | |
| 32 | + | Extensions add pages, data, cards and agent roles for what your team does besides code. These are g1t's own, listed before their first release; each can be installed once it is published. | |
| 33 | + | </SectionHead> | |
| 34 | + | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 35 | + | {coming.map((listing) => ( | |
| 36 | + | <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> | |
| 37 | + | ))} | |
| 38 | + | </div> | |
| 39 | + | </section> | |
| 40 | + | <section aria-labelledby="how" className="rounded-2xl border border-line bg-surface p-5 sm:p-6"> | |
| 41 | + | <h2 id="how" className="text-base font-semibold tracking-tight"> | |
| 42 | + | How extensions are shared | |
| 43 | + | </h2> | |
| 44 | + | <ul className="mt-3 max-w-3xl space-y-2 text-sm text-muted"> | |
| 45 | + | <li>An extension is a public repository on g1t with a manifest. Tagging a release publishes a version.</li> | |
| 46 | + | <li>An install keeps the version it was installed at. Updates are offered to owners, never forced.</li> | |
| 47 | + | <li>Before anything is added, the install screen lists every permission, and says which domains outside g1t its data goes to.</li> | |
| 48 | + | <li>Its pages load in a sandboxed frame from g1t's user-content domain, and reach g1t only through what it was allowed.</li> | |
| 49 | + | <li>Every call it makes is in the audit log. Owners can cap what it spends, and switch it off at once.</li> | |
| 50 | + | </ul> | |
| 51 | + | <ul className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> | |
| 52 | + | {LISTING_TIERS.map((tier) => ( | |
| 53 | + | <li key={tier} className="rounded-xl border border-line bg-bg p-4"> | |
| 54 | + | <TierBadge tier={tier} /> | |
| 55 | + | <p className="mt-2.5 text-[0.8125rem] leading-snug text-muted">{TIERS[tier].about}</p> | |
| 56 | + | </li> | |
| 57 | + | ))} | |
| 58 | + | </ul> | |
| 59 | + | </section> | |
| 60 | + | </div> | |
| 61 | + | ); | |
| 62 | + | } |
| 1 | + | /** | |
| 2 | + | * Integrations in the Marketplace: every connector a workspace can connect | |
| 3 | + | * today, connected ones first, then the ones the catalog lists as coming. | |
| 4 | + | * Each is set up on its own page under Workspace → Integrations; owners | |
| 5 | + | * connect, anyone else asks. | |
| 6 | + | */ | |
| 7 | + | import { Search } from "lucide-react"; | |
| 8 | + | import { useState } from "react"; | |
| 9 | + | import { Link } from "react-router"; | |
| 10 | + | ||
| 11 | + | import { connectorView, connectorsFor, CONNECTORS } from "@g1t/contracts/connectors"; | |
| 12 | + | ||
| 13 | + | import type { Route } from "./+types/integrations"; | |
| 14 | + | import { ConnectorMark } from "../../../components/connectors"; | |
| 15 | + | import { ComingBadge, IntegrationCard, SectionHead } from "../../../components/marketplace"; | |
| 16 | + | import { comingIntegrations, integrationListings, integrationMatches } from "../../../lib/marketplace"; | |
| 17 | + | import { loadConnected } from "../../../lib/marketplace.server"; | |
| 18 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 19 | + | import { useMarketplace } from "./layout"; | |
| 20 | + | ||
| 21 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 22 | + | const viewer = requireUser(context, request); | |
| 23 | + | const slug = params.owner.toLowerCase(); | |
| 24 | + | if (!roleIn(viewer, slug)) throw new Response(null, { status: 404 }); | |
| 25 | + | return { connected: await loadConnected(slug, viewer) }; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | export default function MarketplaceIntegrations({ loaderData }: Route.ComponentProps) { | |
| 29 | + | const { slug, owner, username, requests } = useMarketplace(); | |
| 30 | + | const [query, setQuery] = useState(""); | |
| 31 | + | const views = connectorsFor("workspace"); | |
| 32 | + | const all = integrationListings(views, loaderData.connected, requests?.requests ?? [], username, slug); | |
| 33 | + | const shown = all.filter((listing) => integrationMatches(listing, query)); | |
| 34 | + | const connected = shown.filter((listing) => listing.connected); | |
| 35 | + | const available = shown.filter((listing) => !listing.connected); | |
| 36 | + | const wanted = query.trim().toLowerCase(); | |
| 37 | + | const personal = CONNECTORS.filter((c) => !c.scopes.includes("workspace")).map((c) => connectorView(c, "personal")).filter((view) => view != null); | |
| 38 | + | const coming = comingIntegrations(views, personal).filter((view) => !wanted || [view.name, view.description, ...view.keywords].some((w) => w.toLowerCase().includes(wanted))); | |
| 39 | + | return ( | |
| 40 | + | <div className="space-y-10"> | |
| 41 | + | <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 42 | + | <label className="flex h-9 w-full max-w-sm items-center gap-2 rounded-lg border border-line bg-surface px-2.5 text-sm focus-within:border-line-strong"> | |
| 43 | + | <Search size={15} className="shrink-0 text-faint" /> | |
| 44 | + | <input | |
| 45 | + | value={query} | |
| 46 | + | onChange={(event) => setQuery(event.target.value)} | |
| 47 | + | placeholder="Find an integration" | |
| 48 | + | aria-label="Find an integration" | |
| 49 | + | autoComplete="off" | |
| 50 | + | className="min-w-0 grow bg-transparent outline-none placeholder:text-faint" | |
| 51 | + | /> | |
| 52 | + | </label> | |
| 53 | + | <Link to={`/${slug}/-/integrations`} className="text-[0.8125rem] text-muted hover:text-fg"> | |
| 54 | + | Workspace integrations settings | |
| 55 | + | </Link> | |
| 56 | + | </div> | |
| 57 | + | ||
| 58 | + | {connected.length > 0 && ( | |
| 59 | + | <section aria-labelledby="connected"> | |
| 60 | + | <SectionHead id="connected" title="Connected" aside={`${connected.length}`} /> | |
| 61 | + | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 62 | + | {connected.map((listing) => ( | |
| 63 | + | <IntegrationCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> | |
| 64 | + | ))} | |
| 65 | + | </div> | |
| 66 | + | </section> | |
| 67 | + | )} | |
| 68 | + | ||
| 69 | + | <section aria-labelledby="available"> | |
| 70 | + | <SectionHead id="available" title={connected.length > 0 ? "Available" : "Available to connect"} aside={`${available.length}`} /> | |
| 71 | + | {available.length === 0 ? ( | |
| 72 | + | <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">{wanted ? "No integration you can connect matches." : "Everything available is connected."}</p> | |
| 73 | + | ) : ( | |
| 74 | + | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 75 | + | {available.map((listing) => ( | |
| 76 | + | <IntegrationCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> | |
| 77 | + | ))} | |
| 78 | + | </div> | |
| 79 | + | )} | |
| 80 | + | </section> | |
| 81 | + | ||
| 82 | + | {coming.length > 0 && ( | |
| 83 | + | <section aria-labelledby="coming"> | |
| 84 | + | <SectionHead id="coming" title="Coming" aside={<ComingBadge />}> | |
| 85 | + | Planned, not built. Nobody can connect these yet. | |
| 86 | + | </SectionHead> | |
| 87 | + | <ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| 88 | + | {coming.map((view) => ( | |
| 89 | + | <li key={view.id} className="flex items-center gap-3 rounded-lg border border-dashed border-line px-3 py-2.5"> | |
| 90 | + | <ConnectorMark view={view} size={28} /> | |
| 91 | + | <span className="min-w-0"> | |
| 92 | + | <span className="block truncate text-sm">{view.name}</span> | |
| 93 | + | <span className="block truncate text-xs text-faint">{view.description}</span> | |
| 94 | + | </span> | |
| 95 | + | </li> | |
| 96 | + | ))} | |
| 97 | + | </ul> | |
| 98 | + | </section> | |
| 99 | + | )} | |
| 100 | + | </div> | |
| 101 | + | ); | |
| 102 | + | } |
| 1 | + | /** | |
| 2 | + | * The Marketplace: what a workspace can add, which anyone in it may | |
| 3 | + | * browse. Owners add agents and integrations; everyone else asks an owner, | |
| 4 | + | * and the agents service keeps the request (lib/marketplace.ts). It is not | |
| 5 | + | * in the dock: the Apps page and the launcher lead here, as does ⌘K. | |
| 6 | + | * | |
| 7 | + | * This layout holds the heading, the tabs and the viewer's requests, which | |
| 8 | + | * every tab reads to say what is waiting. | |
| 9 | + | */ | |
| 10 | + | import { Blocks, Bot, Inbox, Plug, Sparkles } from "lucide-react"; | |
| 11 | + | import { Link, NavLink, Outlet, data, useLocation, useRouteLoaderData } from "react-router"; | |
| 12 | + | ||
| 13 | + | import type { ExtensionInstall, InstallRequests } from "@g1t/contracts"; | |
| 14 | + | ||
| 15 | + | import type { Route } from "./+types/layout"; | |
| 16 | + | import { TabStrip } from "../../../components/ui/tab-strip"; | |
| 17 | + | import { marketplacePath, openRequests } from "../../../lib/marketplace"; | |
| 18 | + | import { page } from "../../../lib/meta"; | |
| 19 | + | import { workspaceAgents } from "../../../lib/services.server"; | |
| 20 | + | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 21 | + | ||
| 22 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 23 | + | return page(args, { title: `Marketplace · ${params.owner} · g1t` }); | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | export type MarketplaceData = { | |
| 27 | + | slug: string; | |
| 28 | + | name: string; | |
| 29 | + | owner: boolean; | |
| 30 | + | username: string; | |
| 31 | + | /** The viewer's requests (an owner's: everyone's); null when the agents service did not answer. */ | |
| 32 | + | requests: InstallRequests | null; | |
| 33 | + | /** The extensions installed in the workspace; empty when the agents service did not answer. */ | |
| 34 | + | installs: ExtensionInstall[]; | |
| 35 | + | }; | |
| 36 | + | ||
| 37 | + | export async function loader({ params, context, request }: Route.LoaderArgs): Promise<MarketplaceData> { | |
| 38 | + | const viewer = requireUser(context, request); | |
| 39 | + | const slug = params.owner.toLowerCase(); | |
| 40 | + | const role = roleIn(viewer, slug); | |
| 41 | + | if (!role) throw data(null, { status: 404 }); | |
| 42 | + | const membership = viewer.workspaces?.find((m) => m.slug === slug); | |
| 43 | + | const [requests, installs] = await Promise.all([ | |
| 44 | + | workspaceAgents | |
| 45 | + | .installRequests(slug, viewer) | |
| 46 | + | .then((result) => (result.ok ? result.value : null)) | |
| 47 | + | .catch(() => null), | |
| 48 | + | workspaceAgents | |
| 49 | + | .extensionInstalls(slug, viewer) | |
| 50 | + | .then((result) => (result.ok ? result.value : [])) | |
| 51 | + | .catch(() => []), | |
| 52 | + | ]); | |
| 53 | + | return { slug, name: membership?.name?.trim() || slug, owner: role === "owner", username: viewer.username, requests, installs }; | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /** The Marketplace's own data, from any of its pages. */ | |
| 57 | + | export function useMarketplace(): MarketplaceData { | |
| 58 | + | return useRouteLoaderData("routes/workspace/marketplace/layout") as MarketplaceData; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | export default function MarketplaceLayout({ loaderData }: Route.ComponentProps) { | |
| 62 | + | const { slug, name, owner, requests } = loaderData; | |
| 63 | + | const waiting = requests ? openRequests(requests.requests).length : 0; | |
| 64 | + | const { pathname } = useLocation(); | |
| 65 | + | const tabs = [ | |
| 66 | + | { to: marketplacePath(slug), label: "Discover", icon: <Sparkles size={14} />, end: true }, | |
| 67 | + | { to: marketplacePath(slug, "agents"), label: "Agents", icon: <Bot size={14} /> }, | |
| 68 | + | { to: marketplacePath(slug, "integrations"), label: "Integrations", icon: <Plug size={14} /> }, | |
| 69 | + | { to: marketplacePath(slug, "extensions"), label: "Extensions", icon: <Blocks size={14} /> }, | |
| 70 | + | { to: marketplacePath(slug, "requests"), label: owner ? "Requests" : "Your requests", icon: <Inbox size={14} />, count: waiting }, | |
| 71 | + | ]; | |
| 72 | + | return ( | |
| 73 | + | <main className="mx-auto max-w-6xl px-4 py-8 sm:px-8 sm:py-10"> | |
| 74 | + | <div className="flex flex-wrap items-end justify-between gap-4"> | |
| 75 | + | <div> | |
| 76 | + | <h1 className="text-2xl font-semibold tracking-tight">Marketplace</h1> | |
| 77 | + | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 78 | + | Agents, integrations and extensions {name} can add.{" "} | |
| 79 | + | {owner ? "As an owner, you add them for everyone." : "Owners add them; ask one for what you need, and they'll hear about it."} | |
| 80 | + | </p> | |
| 81 | + | </div> | |
| 82 | + | <Link to={`/${slug}/-/apps`} className="text-sm text-muted hover:text-fg"> | |
| 83 | + | Your apps | |
| 84 | + | </Link> | |
| 85 | + | </div> | |
| 86 | + | <TabStrip label="Marketplace" className="mt-6 gap-1 border-b border-line"> | |
| 87 | + | {tabs.map((tab) => ( | |
| 88 | + | <NavLink | |
| 89 | + | key={tab.to} | |
| 90 | + | to={tab.to} | |
| 91 | + | end={tab.end} | |
| 92 | + | prefetch="intent" | |
| 93 | + | data-active={tab.end ? pathname === tab.to : pathname.startsWith(tab.to)} | |
| 94 | + | className={({ isActive }) => | |
| 95 | + | `-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm whitespace-nowrap transition-colors ${ | |
| 96 | + | isActive ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg" | |
| 97 | + | }` | |
| 98 | + | } | |
| 99 | + | > | |
| 100 | + | {tab.icon} | |
| 101 | + | {tab.label} | |
| 102 | + | {tab.count != null && tab.count > 0 && <span className="rounded-full bg-warn/15 px-1.5 py-px text-xs text-warn tabular-nums">{tab.count}</span>} | |
| 103 | + | </NavLink> | |
| 104 | + | ))} | |
| 105 | + | </TabStrip> | |
| 106 | + | <div className="mt-8"> | |
| 107 | + | <Outlet /> | |
| 108 | + | </div> | |
| 109 | + | </main> | |
| 110 | + | ); | |
| 111 | + | } |
| 1 | + | /** | |
| 2 | + | * Install requests: for an owner, everything members asked the workspace | |
| 3 | + | * to add, with the way to add it, mark it added or turn it down; for | |
| 4 | + | * anyone else, their own and how each was answered. Every Marketplace form | |
| 5 | + | * posts here: asking, answering, and installing, switching off or removing | |
| 6 | + | * an extension. | |
| 7 | + | */ | |
| 8 | + | import { Link, data } from "react-router"; | |
| 9 | + | ||
| 10 | + | import { connectorsFor } from "@g1t/contracts/connectors"; | |
| 11 | + | ||
| 12 | + | import type { Route } from "./+types/requests"; | |
| 13 | + | import { RequestRow, SectionHead } from "../../../components/marketplace"; | |
| 14 | + | import { EmptyState } from "../../../components/ui"; | |
| 15 | + | import { addPath, marketplaceForm, marketplacePath, openRequests } from "../../../lib/marketplace"; | |
| 16 | + | import { workspaceAgents } from "../../../lib/services.server"; | |
| 17 | + | import { assertSameOrigin, requireUser, roleIn } from "../../../lib/session.server"; | |
| 18 | + | import { useMarketplace } from "./layout"; | |
| 19 | + | ||
| 20 | + | export async function action({ params, context, request }: Route.ActionArgs) { | |
| 21 | + | assertSameOrigin(request); | |
| 22 | + | const viewer = requireUser(context, request); | |
| 23 | + | const slug = params.owner.toLowerCase(); | |
| 24 | + | if (!roleIn(viewer, slug)) throw data(null, { status: 404 }); | |
| 25 | + | const asked = marketplaceForm(await request.formData()); | |
| 26 | + | if (!asked) return data({ error: "Nothing to do." }, { status: 400 }); | |
| 27 | + | const call = (): Promise<{ ok: true } | { ok: false; error: { code: string; message: string } }> => { | |
| 28 | + | switch (asked.intent) { | |
| 29 | + | case "request": | |
| 30 | + | return workspaceAgents.requestInstall(slug, viewer, asked.listing, asked.note); | |
| 31 | + | case "resolve": | |
| 32 | + | return workspaceAgents.resolveInstallRequest(slug, viewer, asked.id, asked.status); | |
| 33 | + | case "install": | |
| 34 | + | return workspaceAgents.installExtension(slug, viewer, asked.extension); | |
| 35 | + | case "switch": | |
| 36 | + | return workspaceAgents.setExtensionEnabled(slug, viewer, asked.listing, asked.enabled); | |
| 37 | + | case "uninstall": | |
| 38 | + | return workspaceAgents.uninstallExtension(slug, viewer, asked.listing); | |
| 39 | + | } | |
| 40 | + | }; | |
| 41 | + | const result = await call().catch(() => null); | |
| 42 | + | if (!result) return data({ error: "The request didn't go through. Try again in a moment." }, { status: 503 }); | |
| 43 | + | if (!result.ok) return data({ error: result.error.message }, { status: result.error.code === "conflict" ? 409 : 422 }); | |
| 44 | + | return { error: null }; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | export default function MarketplaceRequests() { | |
| 48 | + | const { slug, owner, requests } = useMarketplace(); | |
| 49 | + | if (!requests) { | |
| 50 | + | return <EmptyState title="Requests can't be shown right now">The agents service didn't answer. Reload in a minute.</EmptyState>; | |
| 51 | + | } | |
| 52 | + | const views = connectorsFor("workspace"); | |
| 53 | + | const waiting = openRequests(requests.requests); | |
| 54 | + | const answered = requests.requests.filter((request) => request.status !== "open"); | |
| 55 | + | if (requests.requests.length === 0) { | |
| 56 | + | return owner ? ( | |
| 57 | + | <EmptyState title="No requests">When a member asks for an agent or an integration, it shows here, and you're notified.</EmptyState> | |
| 58 | + | ) : ( | |
| 59 | + | <EmptyState title="You haven't asked for anything"> | |
| 60 | + | Find an agent or an integration and choose <b>Request</b>. The workspace's owners are notified, and you hear back when one answers. | |
| 61 | + | </EmptyState> | |
| 62 | + | ); | |
| 63 | + | } | |
| 64 | + | return ( | |
| 65 | + | <div className="space-y-10"> | |
| 66 | + | <section aria-labelledby="waiting"> | |
| 67 | + | <SectionHead id="waiting" title={owner ? "Waiting on an owner" : "Waiting"} aside={`${waiting.length}`}> | |
| 68 | + | {owner | |
| 69 | + | ? "Adding an agent from the catalog answers every request for its role. After connecting an integration, mark its requests added." | |
| 70 | + | : "Your requests the workspace's owners haven't answered yet."} | |
| 71 | + | </SectionHead> | |
| 72 | + | {waiting.length === 0 ? ( | |
| 73 | + | <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">Nothing is waiting.</p> | |
| 74 | + | ) : ( | |
| 75 | + | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 76 | + | {waiting.map((request) => ( | |
| 77 | + | <RequestRow key={request.id} request={request} slug={slug} owner={owner} addTo={owner ? addPath(request, slug, views) : null} /> | |
| 78 | + | ))} | |
| 79 | + | </ul> | |
| 80 | + | )} | |
| 81 | + | </section> | |
| 82 | + | {answered.length > 0 && ( | |
| 83 | + | <section aria-labelledby="answered"> | |
| 84 | + | <SectionHead id="answered" title="Answered" aside="Last 30 days" /> | |
| 85 | + | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 86 | + | {answered.map((request) => ( | |
| 87 | + | <RequestRow key={request.id} request={request} slug={slug} owner={owner} addTo={null} /> | |
| 88 | + | ))} | |
| 89 | + | </ul> | |
| 90 | + | </section> | |
| 91 | + | )} | |
| 92 | + | {!owner && ( | |
| 93 | + | <p className="text-sm text-muted"> | |
| 94 | + | Looking for something else? <Link to={marketplacePath(slug)} className="text-accent hover:underline">Browse the Marketplace</Link>. | |
| 95 | + | </p> | |
| 96 | + | )} | |
| 97 | + | </div> | |
| 98 | + | ); | |
| 99 | + | } |
| 4 | 4 | "private": true, | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "license": "MIT", | |
| 7 | − | "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./chat-markdown": "./src/chat-markdown.ts" }, | |
| 7 | + | "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts" }, | |
| 8 | 8 | "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" } | |
| 9 | 9 | } |
| 1 | 1 | const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"; | |
| 2 | 2 | ||
| 3 | − | export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst" | "chn" | "msg" | "agt" | "arp" | "asn" | "mem" | "rtn" | "drf" | "spc" | "pag" | "ver" | "thr" | "cmt" | "sug" | "tpl" | "fil" | "rds" | "fol" | "prp"; | |
| 3 | + | export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst" | "chn" | "msg" | "agt" | "arp" | "asn" | "mem" | "rtn" | "drf" | "spc" | "pag" | "ver" | "thr" | "cmt" | "sug" | "tpl" | "fil" | "rds" | "fol" | "prp" | "ins"; | |
| 4 | 4 | ||
| 5 | 5 | let lastMs = 0; | |
| 6 | 6 | let lastCounter = 0; |
| 28 | 28 | export * from "./identity"; | |
| 29 | 29 | export * from "./inbox"; | |
| 30 | 30 | export * from "./instances"; | |
| 31 | + | export * from "./marketplace"; | |
| 31 | 32 | export * from "./ids"; | |
| 32 | 33 | export * from "./integrations"; | |
| 33 | 34 | export * from "./members"; |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { FIRST_PARTY_EXTENSIONS, type ExtensionManifest, checkManifest, dataDisclosure, extensionFrameUrl, parseListing, versionOfTag } from "./marketplace.ts"; | |
| 5 | + | import { isScope } from "./scopes.ts"; | |
| 6 | + | ||
| 7 | + | const published: ExtensionManifest = { | |
| 8 | + | ...FIRST_PARTY_EXTENSIONS[0]!, | |
| 9 | + | id: "standup", | |
| 10 | + | status: "available", | |
| 11 | + | source: { repo: "flagon-io/standup", tag: "v1.2.0" }, | |
| 12 | + | version: "1.2.0", | |
| 13 | + | }; | |
| 14 | + | ||
| 15 | + | test("g1t's own extensions are listed as coming, with manifests publishing would accept", () => { | |
| 16 | + | const ids = new Set<string>(); | |
| 17 | + | for (const extension of FIRST_PARTY_EXTENSIONS) { | |
| 18 | + | assert.ok(!ids.has(extension.id), extension.id); | |
| 19 | + | ids.add(extension.id); | |
| 20 | + | assert.equal(extension.status, "soon", `${extension.id} isn't published yet`); | |
| 21 | + | assert.equal(extension.source, null); | |
| 22 | + | assert.equal(extension.pricing, null); | |
| 23 | + | assert.equal(extension.publisher.tier, "official"); | |
| 24 | + | const checked = checkManifest(extension, isScope); | |
| 25 | + | assert.ok(checked.ok, `${extension.id}: ${checked.ok ? "" : checked.message}`); | |
| 26 | + | } | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | test("publishing checks a manifest strictly", () => { | |
| 30 | + | assert.ok(checkManifest(published, isScope).ok); | |
| 31 | + | const refused = (changes: Partial<ExtensionManifest> | Record<string, unknown>, why: RegExp) => { | |
| 32 | + | const checked = checkManifest({ ...published, ...changes }, isScope); | |
| 33 | + | assert.equal(checked.ok, false, JSON.stringify(changes)); | |
| 34 | + | if (!checked.ok) assert.match(checked.message, why); | |
| 35 | + | }; | |
| 36 | + | refused({ id: "Bad Id" }, /id/); | |
| 37 | + | refused({ scopes: ["everything:admin"] }, /scope/); | |
| 38 | + | refused({ source: null }, /source/); | |
| 39 | + | refused({ domains: ["not a host"] }, /domains/); | |
| 40 | + | refused({ runtime: "connected", domains: [] }, /declares the domains/); | |
| 41 | + | refused({ publisher: { name: "anyone", tier: "community" } }, /Community extensions run on their publisher's servers/); | |
| 42 | + | refused({ pricing: { monthly: 5 } }, /free/); | |
| 43 | + | refused({ permissions: [] }, /permissions/); | |
| 44 | + | refused({ ui: { entry: "index.html" } }, /ui.entry/); | |
| 45 | + | // A community extension that runs on its own servers, declaring them, is fine. | |
| 46 | + | assert.ok(checkManifest({ ...published, runtime: "connected", publisher: { name: "anyone", tier: "community" }, domains: ["api.example.com"] }, isScope).ok); | |
| 47 | + | }); | |
| 48 | + | ||
| 49 | + | test("the install screen says where data goes", () => { | |
| 50 | + | assert.equal(dataDisclosure({ domains: [] }), "Its data stays in g1t."); | |
| 51 | + | assert.equal(dataDisclosure({ domains: ["api.acme.dev", "eu.acme.dev"] }), "Data leaves g1t to api.acme.dev, eu.acme.dev."); | |
| 52 | + | }); | |
| 53 | + | ||
| 54 | + | test("an extension's page loads from the user-content origin, by version", () => { | |
| 55 | + | assert.equal(extensionFrameUrl(published, "https://g1tusercontent.com/"), "https://g1tusercontent.com/x/standup/1.2.0/index.html"); | |
| 56 | + | assert.equal(extensionFrameUrl({ ...published, ui: null }, "https://g1tusercontent.com"), null); | |
| 57 | + | assert.equal(extensionFrameUrl(FIRST_PARTY_EXTENSIONS[0]!, "https://g1tusercontent.com"), null, "nothing published, nothing to load"); | |
| 58 | + | }); | |
| 59 | + | ||
| 60 | + | test("a tag publishes a version", () => { | |
| 61 | + | assert.equal(versionOfTag("v1.4.0"), "1.4.0"); | |
| 62 | + | assert.equal(versionOfTag("2.0.0-beta.1"), "2.0.0-beta.1"); | |
| 63 | + | assert.equal(versionOfTag("latest"), null); | |
| 64 | + | }); | |
| 65 | + | ||
| 66 | + | test("listings are referenced by kind and id", () => { | |
| 67 | + | assert.deepEqual(parseListing("extension:on-call"), { kind: "extension", id: "on-call" }); | |
| 68 | + | assert.equal(parseListing("app:on-call"), null); | |
| 69 | + | }); |
| 1 | + | /** | |
| 2 | + | * The Marketplace: what a workspace can add, the installs it has, and | |
| 3 | + | * members' requests to add things. Anyone in a workspace browses it; only | |
| 4 | + | * owners add things. A member asks instead, and the request reaches every | |
| 5 | + | * owner as a notification. | |
| 6 | + | * | |
| 7 | + | * Every listing has a reference, `<kind>:<id>`: | |
| 8 | + | * | |
| 9 | + | * - `agent:<template>`: an agent hired from one of the catalog's roles | |
| 10 | + | * (the agents service's templates); the agents service keeps the agent; | |
| 11 | + | * - `integration:<connector>`: a connector from the catalog | |
| 12 | + | * (./connectors.ts); the integrations service keeps the connection; | |
| 13 | + | * - `extension:<id>`: an extension, described by its manifest | |
| 14 | + | * (`ExtensionManifest`): who publishes it, where its source is, the | |
| 15 | + | * scopes it asks for, the domains its data goes to, the page it shows | |
| 16 | + | * inside g1t, and its pricing. An install (`ExtensionInstall`) pins a | |
| 17 | + | * version and can be switched off at once. | |
| 18 | + | * | |
| 19 | + | * Extensions are shared the way workflow actions are: a public | |
| 20 | + | * repository with a manifest, where tagging a release publishes a | |
| 21 | + | * version, and an install keeps the version it was installed at until an | |
| 22 | + | * owner takes an update. Nothing is paid yet, but listings carry a | |
| 23 | + | * pricing field and installs a plan, so paid listings are billing work. | |
| 24 | + | * | |
| 25 | + | * The agents service keeps installs (`extension_installs`) and requests | |
| 26 | + | * (`install_requests`). Wire shapes are snake_case. No imports, so services | |
| 27 | + | * test it under Node. | |
| 28 | + | */ | |
| 29 | + | ||
| 30 | + | /** What a listing adds to a workspace. */ | |
| 31 | + | export type ListingKind = "agent" | "integration" | "extension"; | |
| 32 | + | ||
| 33 | + | export const LISTING_KINDS: readonly ListingKind[] = ["agent", "integration", "extension"]; | |
| 34 | + | ||
| 35 | + | /** Whether a listing can be added today, or is planned. */ | |
| 36 | + | export type ListingStatus = "available" | "soon"; | |
| 37 | + | ||
| 38 | + | /** | |
| 39 | + | * Where an extension's server code runs: on g1t (official, verified and | |
| 40 | + | * internal publishers, and community code once it has an isolated | |
| 41 | + | * sandbox), or on the publisher's own servers, reached over HTTPS. | |
| 42 | + | */ | |
| 43 | + | export type ExtensionRuntime = "hosted" | "connected"; | |
| 44 | + | ||
| 45 | + | /** The workspace areas an extension can add to. */ | |
| 46 | + | export type ExtensionAdds = { | |
| 47 | + | /** Its pages, in its sidebar, in order. */ | |
| 48 | + | pages: string[]; | |
| 49 | + | /** Agent roles it brings, hired like any other. */ | |
| 50 | + | agent_roles: string[]; | |
| 51 | + | /** Tools agents can call. */ | |
| 52 | + | tools: string[]; | |
| 53 | + | /** Notification kinds it sends. */ | |
| 54 | + | notifications: string[]; | |
| 55 | + | }; | |
| 56 | + | ||
| 57 | + | /** | |
| 58 | + | * An extension, as its manifest describes it. A published one comes from | |
| 59 | + | * `.g1t/extension.json` in its source repository at a tag; first-party | |
| 60 | + | * listings not yet published (`status: "soon"`) have no source yet. | |
| 61 | + | */ | |
| 62 | + | export type ExtensionManifest = { | |
| 63 | + | /** Lowercase letters, digits and hyphens: `support`. */ | |
| 64 | + | id: string; | |
| 65 | + | name: string; | |
| 66 | + | /** One line, for cards. */ | |
| 67 | + | tagline: string; | |
| 68 | + | /** A paragraph, for its page. */ | |
| 69 | + | description: string; | |
| 70 | + | category: string; | |
| 71 | + | publisher: { name: string; tier: ListingTier }; | |
| 72 | + | status: ListingStatus; | |
| 73 | + | /** Its public repository on g1t and the tag a version was published from; null until it is published. */ | |
| 74 | + | source: { repo: string; tag: string } | null; | |
| 75 | + | /** The version a tag published (`1.4.0`); null until it is published. */ | |
| 76 | + | version: string | null; | |
| 77 | + | runtime: ExtensionRuntime; | |
| 78 | + | /** The g1t scopes its token asks for (./scopes.ts), shown in plain words before install. */ | |
| 79 | + | scopes: string[]; | |
| 80 | + | /** What it can do, in plain words, as the install screen lists it. */ | |
| 81 | + | permissions: string[]; | |
| 82 | + | /** | |
| 83 | + | * Every host outside g1t its data goes to. The install screen says | |
| 84 | + | * "Data leaves g1t to …" for each; calls anywhere else are refused. | |
| 85 | + | * Empty: its data stays in g1t. | |
| 86 | + | */ | |
| 87 | + | domains: string[]; | |
| 88 | + | /** | |
| 89 | + | * Its page inside g1t: a path served from the user-content domain | |
| 90 | + | * (g1tusercontent.com, or a self-hosted instance's own), loaded in a | |
| 91 | + | * sandboxed frame that reaches g1t only through the bridge. Null: no page. | |
| 92 | + | */ | |
| 93 | + | ui: { entry: string } | null; | |
| 94 | + | adds: ExtensionAdds; | |
| 95 | + | /** Empty while every listing is free; a later price list goes here. */ | |
| 96 | + | pricing: null; | |
| 97 | + | }; | |
| 98 | + | ||
| 99 | + | /** An extension installed in a workspace. */ | |
| 100 | + | export type ExtensionInstall = { | |
| 101 | + | id: string; | |
| 102 | + | /** `extension:<id>`. */ | |
| 103 | + | listing: string; | |
| 104 | + | /** The version installed; updates are offered, never forced. */ | |
| 105 | + | version: string; | |
| 106 | + | /** The plan it is on: `free` until listings have prices. */ | |
| 107 | + | plan: string; | |
| 108 | + | installed_by: string; | |
| 109 | + | /** RFC 3339. */ | |
| 110 | + | installed_at: string; | |
| 111 | + | /** Off: the kill switch. Its token stops working and its page doesn't load. */ | |
| 112 | + | enabled: boolean; | |
| 113 | + | /** Who last turned it off, and when; null while it is on. */ | |
| 114 | + | disabled_by: string | null; | |
| 115 | + | disabled_at: string | null; | |
| 116 | + | /** Its monthly spend cap, in micro-dollars; null: the workspace's limit applies. */ | |
| 117 | + | budget_monthly_micros: number | null; | |
| 118 | + | }; | |
| 119 | + | ||
| 120 | + | /** The plan an install records while nothing is paid. */ | |
| 121 | + | export const FREE_PLAN = "free"; | |
| 122 | + | ||
| 123 | + | /** A manifest's checks, as publishing applies them; `knownScope` says which scopes exist (./scopes.ts `isScope`). */ | |
| 124 | + | export function checkManifest(raw: unknown, knownScope: (scope: string) => boolean): { ok: true; value: ExtensionManifest } | { ok: false; message: string } { | |
| 125 | + | const m = raw as Partial<ExtensionManifest> | null; | |
| 126 | + | const bad = (message: string) => ({ ok: false as const, message }); | |
| 127 | + | if (!m || typeof m !== "object") return bad("The manifest isn't a JSON object."); | |
| 128 | + | if (typeof m.id !== "string" || !/^[a-z][a-z0-9-]{1,31}$/.test(m.id)) return bad("`id` is 2 to 32 lowercase letters, digits and hyphens, starting with a letter."); | |
| 129 | + | for (const field of ["name", "tagline", "description", "category"] as const) { | |
| 130 | + | if (typeof m[field] !== "string" || !m[field]!.trim()) return bad(`\`${field}\` is required.`); | |
| 131 | + | } | |
| 132 | + | if (!m.publisher || typeof m.publisher.name !== "string" || !LISTING_TIERS.includes(m.publisher.tier as ListingTier)) return bad("`publisher` needs a name and a tier."); | |
| 133 | + | if (m.status !== "available" && m.status !== "soon") return bad("`status` is available or soon."); | |
| 134 | + | if (m.runtime !== "hosted" && m.runtime !== "connected") return bad("`runtime` is hosted or connected."); | |
| 135 | + | if (m.runtime === "hosted" && m.publisher.tier === "community") return bad("Community extensions run on their publisher's servers (`connected`) until hosted code has an isolated sandbox."); | |
| 136 | + | if (m.status === "available" && (!m.source || !m.version)) return bad("A published extension names its source repository, tag and version."); | |
| 137 | + | if (m.source && (typeof m.source.repo !== "string" || !/^[a-z0-9-]+\/[A-Za-z0-9._-]+$/.test(m.source.repo) || typeof m.source.tag !== "string" || !m.source.tag)) return bad("`source` is a repository (`owner/name`) and a tag."); | |
| 138 | + | if (!Array.isArray(m.scopes) || m.scopes.some((scope) => typeof scope !== "string" || !knownScope(scope))) return bad("Every scope must be one g1t has."); | |
| 139 | + | if (!Array.isArray(m.permissions) || m.permissions.length === 0) return bad("List what it can do in `permissions`, in plain words."); | |
| 140 | + | if (!Array.isArray(m.domains) || m.domains.some((domain) => typeof domain !== "string" || !isHost(domain))) return bad("`domains` are host names, such as api.example.com."); | |
| 141 | + | if (m.runtime === "connected" && m.domains.length === 0) return bad("A connected extension declares the domains it runs on."); | |
| 142 | + | if (m.ui !== null && (typeof m.ui !== "object" || typeof m.ui?.entry !== "string" || !m.ui.entry.startsWith("/"))) return bad("`ui.entry` is a path, such as /index.html."); | |
| 143 | + | if (!m.adds || !["pages", "agent_roles", "tools", "notifications"].every((k) => Array.isArray((m.adds as Record<string, unknown>)[k]))) return bad("`adds` lists pages, agent_roles, tools and notifications."); | |
| 144 | + | if (m.pricing !== null) return bad("Listings are free for now: `pricing` is null."); | |
| 145 | + | return { ok: true, value: m as ExtensionManifest }; | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | function isHost(value: string): boolean { | |
| 149 | + | return /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(value); | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /** What the install screen says about where an extension's data goes. */ | |
| 153 | + | export function dataDisclosure(manifest: Pick<ExtensionManifest, "domains">): string { | |
| 154 | + | if (manifest.domains.length === 0) return "Its data stays in g1t."; | |
| 155 | + | return `Data leaves g1t to ${manifest.domains.join(", ")}.`; | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /** Where an extension's page loads from: its entry on the user-content origin, under its id and version. */ | |
| 159 | + | export function extensionFrameUrl(manifest: Pick<ExtensionManifest, "id" | "version" | "ui">, usercontentOrigin: string): string | null { | |
| 160 | + | if (!manifest.ui || !manifest.version) return null; | |
| 161 | + | return `${usercontentOrigin.replace(/\/+$/, "")}/x/${manifest.id}/${manifest.version}${manifest.ui.entry}`; | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /** A version as a tag names it: `v1.4.0` and `1.4.0` are both `1.4.0`; null when it isn't one. */ | |
| 165 | + | export function versionOfTag(tag: string): string | null { | |
| 166 | + | const match = /^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(tag.trim()); | |
| 167 | + | return match ? match[1]! : null; | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /** | |
| 171 | + | * g1t's own extensions. None is published yet: each is listed so people | |
| 172 | + | * see what is coming, and can't be installed until its first release. | |
| 173 | + | */ | |
| 174 | + | export const FIRST_PARTY_EXTENSIONS: ExtensionManifest[] = [ | |
| 175 | + | { | |
| 176 | + | id: "support", | |
| 177 | + | name: "Support", | |
| 178 | + | tagline: "Customer conversations an agent answers from your docs, escalating the rest.", | |
| 179 | + | description: | |
| 180 | + | "Conversations, escalations, macros and a knowledge base. A support agent answers what your docs cover, turns bug reports into issues in Code, and hands anything else to a person. Email to customers waits for approval.", | |
| 181 | + | category: "Customers", | |
| 182 | + | publisher: { name: "g1t", tier: "official" }, | |
| 183 | + | status: "soon", | |
| 184 | + | source: null, | |
| 185 | + | version: null, | |
| 186 | + | runtime: "hosted", | |
| 187 | + | scopes: ["issues:write", "artifacts:read", "notifications:write"], | |
| 188 | + | permissions: ["Read and reply to support email, with your approval", "Store conversations in workspace data", "Open issues from bug reports"], | |
| 189 | + | domains: [], | |
| 190 | + | ui: { entry: "/index.html" }, | |
| 191 | + | adds: { pages: ["Conversations", "Escalations", "Macros", "Knowledge"], agent_roles: ["Support Specialist"], tools: ["conversation", "macro"], notifications: ["Escalations"] }, | |
| 192 | + | pricing: null, | |
| 193 | + | }, | |
| 194 | + | { | |
| 195 | + | id: "recruiting", | |
| 196 | + | name: "Recruiting", | |
| 197 | + | tagline: "Openings, candidates and interview loops. Agents screen, people decide.", | |
| 198 | + | description: "A pipeline board, scorecards and scheduling. A recruiting agent screens applicants against your scorecard and books interview loops; offers and decisions stay with people.", | |
| 199 | + | category: "People", | |
| 200 | + | publisher: { name: "g1t", tier: "official" }, | |
| 201 | + | status: "soon", | |
| 202 | + | source: null, | |
| 203 | + | version: null, | |
| 204 | + | runtime: "hosted", | |
| 205 | + | scopes: ["artifacts:write", "notifications:write"], | |
| 206 | + | permissions: ["Store candidates and openings in workspace data", "Read and write interview events on connected calendars", "Email candidates from a shared address, with your approval"], | |
| 207 | + | domains: [], | |
| 208 | + | ui: { entry: "/index.html" }, | |
| 209 | + | adds: { pages: ["Pipeline", "Openings", "Candidates", "Interviews", "Offers"], agent_roles: ["Recruiter"], tools: ["candidate", "opening"], notifications: ["Offers to sign"] }, | |
| 210 | + | pricing: null, | |
| 211 | + | }, | |
| 212 | + | { | |
| 213 | + | id: "mail", | |
| 214 | + | name: "Mail", | |
| 215 | + | tagline: "Email on your own domain, with shared inboxes agents work in.", | |
| 216 | + | description: | |
| 217 | + | "Email that g1t runs on your domain. Shared inboxes such as support@ and sales@ work like channels: agents sort and draft, and sending needs approval or a rule you set. Spam and phishing filtering is included, at what it costs to run. Gmail and Outlook keep working alongside it.", | |
| 218 | + | category: "Communication", | |
| 219 | + | publisher: { name: "g1t", tier: "official" }, | |
| 220 | + | status: "soon", | |
| 221 | + | source: null, | |
| 222 | + | version: null, | |
| 223 | + | runtime: "hosted", | |
| 224 | + | scopes: ["notifications:write", "artifacts:read"], | |
| 225 | + | permissions: ["Host email for your domain", "Let agents read shared inboxes and draft replies", "Send only with approval or a rule you set"], | |
| 226 | + | domains: [], | |
| 227 | + | ui: { entry: "/index.html" }, | |
| 228 | + | adds: { pages: ["Inbox", "Shared inboxes", "Sent", "Rules for agents"], agent_roles: [], tools: ["mail", "thread"], notifications: ["Drafts to approve"] }, | |
| 229 | + | pricing: null, | |
| 230 | + | }, | |
| 231 | + | { | |
| 232 | + | id: "on-call", | |
| 233 | + | name: "On-call", | |
| 234 | + | tagline: "Rotations, pages and who is on call now.", | |
| 235 | + | description: "Rotations and schedules, pages through notifications, and an incident channel an operations agent keeps a timeline in.", | |
| 236 | + | category: "Engineering", | |
| 237 | + | publisher: { name: "g1t", tier: "official" }, | |
| 238 | + | status: "soon", | |
| 239 | + | source: null, | |
| 240 | + | version: null, | |
| 241 | + | runtime: "hosted", | |
| 242 | + | scopes: ["notifications:write", "issues:read"], | |
| 243 | + | permissions: ["Store rotations in workspace data", "Page people through their notifications", "Read issues an incident links to"], | |
| 244 | + | domains: [], | |
| 245 | + | ui: { entry: "/index.html" }, | |
| 246 | + | adds: { pages: ["Rotations", "Pages", "Incidents"], agent_roles: [], tools: ["rotation", "page"], notifications: ["Pages", "Your shift"] }, | |
| 247 | + | pricing: null, | |
| 248 | + | }, | |
| 249 | + | ]; | |
| 250 | + | ||
| 251 | + | /** A first-party extension by id. */ | |
| 252 | + | export function extensionById(id: string): ExtensionManifest | undefined { | |
| 253 | + | return FIRST_PARTY_EXTENSIONS.find((extension) => extension.id === id); | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | /** | |
| 257 | + | * Who stands behind a listing: g1t itself, a reviewed publisher, anyone, | |
| 258 | + | * or the workspace's own people. | |
| 259 | + | */ | |
| 260 | + | export type ListingTier = "official" | "verified" | "community" | "internal"; | |
| 261 | + | ||
| 262 | + | export const LISTING_TIERS: readonly ListingTier[] = ["official", "verified", "community", "internal"]; | |
| 263 | + | ||
| 264 | + | /** A listing's reference, split: `agent:engineering` is `{ kind: "agent", id: "engineering" }`. */ | |
| 265 | + | export type ListingRef = { kind: ListingKind; id: string }; | |
| 266 | + | ||
| 267 | + | /** Where a request stands: waiting on an owner, added, or turned down. */ | |
| 268 | + | export type InstallRequestStatus = "open" | "done" | "declined"; | |
| 269 | + | ||
| 270 | + | export const INSTALL_REQUEST_STATUSES: readonly InstallRequestStatus[] = ["open", "done", "declined"]; | |
| 271 | + | ||
| 272 | + | /** A member's request that the workspace add a listing. */ | |
| 273 | + | export type InstallRequest = { | |
| 274 | + | id: string; | |
| 275 | + | /** `<kind>:<id>`. */ | |
| 276 | + | listing: string; | |
| 277 | + | kind: ListingKind; | |
| 278 | + | /** The listing's name when it was asked for, such as `Software Engineer` or `Sentry`. */ | |
| 279 | + | name: string; | |
| 280 | + | /** Why they want it, in their words, or null. */ | |
| 281 | + | note: string | null; | |
| 282 | + | /** Who asked, by username. */ | |
| 283 | + | requested_by: string; | |
| 284 | + | /** RFC 3339. */ | |
| 285 | + | requested_at: string; | |
| 286 | + | status: InstallRequestStatus; | |
| 287 | + | /** The owner who added it or turned it down, by username. */ | |
| 288 | + | resolved_by: string | null; | |
| 289 | + | /** RFC 3339. */ | |
| 290 | + | resolved_at: string | null; | |
| 291 | + | }; | |
| 292 | + | ||
| 293 | + | /** | |
| 294 | + | * Requests as one person sees them: an owner, every request in the | |
| 295 | + | * workspace (`can_resolve`); anyone else, their own. Open ones first, then | |
| 296 | + | * the latest; answered ones for 30 days. | |
| 297 | + | */ | |
| 298 | + | export type InstallRequests = { requests: InstallRequest[]; can_resolve: boolean }; | |
| 299 | + | ||
| 300 | + | /** The longest note a request carries. */ | |
| 301 | + | export const MAX_REQUEST_NOTE = 280; | |
| 302 | + | /** The most requests one person keeps open in a workspace. */ | |
| 303 | + | export const MAX_OPEN_REQUESTS = 20; | |
| 304 | + | /** How long an answered request stays listed. */ | |
| 305 | + | export const ANSWERED_REQUESTS_DAYS = 30; | |
| 306 | + | ||
| 307 | + | /** A listing's reference: `agent:engineering`. */ | |
| 308 | + | export function listingRef(kind: ListingKind, id: string): string { | |
| 309 | + | return `${kind}:${id}`; | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | /** A reference split into kind and id; null when it is not one. */ | |
| 313 | + | export function parseListing(value: unknown): ListingRef | null { | |
| 314 | + | if (typeof value !== "string") return null; | |
| 315 | + | const match = /^(agent|integration|extension):([a-z0-9][a-z0-9_-]{0,63})$/.exec(value.trim()); | |
| 316 | + | return match ? { kind: match[1] as ListingKind, id: match[2]! } : null; | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | /** A note as kept: trimmed, at most `MAX_REQUEST_NOTE` characters, null when empty. */ | |
| 320 | + | export function cleanRequestNote(value: unknown): string | null { | |
| 321 | + | if (typeof value !== "string") return null; | |
| 322 | + | const note = value.replace(/\s+/g, " ").trim().slice(0, MAX_REQUEST_NOTE); | |
| 323 | + | return note || null; | |
| 324 | + | } |
| 14 | 14 | // Model tiers (`ModelTier`, `MODEL_TIERS`) are integrations.ts's, the | |
| 15 | 15 | // same ones runs are routed between. | |
| 16 | 16 | import type { ModelTier } from "./integrations"; | |
| 17 | + | import type { ExtensionInstall, InstallRequest, InstallRequestStatus, InstallRequests } from "./marketplace"; | |
| 17 | 18 | ||
| 18 | 19 | /** The built-in orchestrator's handle; nobody else's agent may take it. */ | |
| 19 | 20 | export const BUILTIN_AGENT_HANDLE = "g1t"; | |
| ⋯ | |||
| 713 | 714 | cardAction(input: AgentCardAction): Promise<Result<CardActionResult>>; | |
| 714 | 715 | policy(workspace: string, viewer: User): Promise<Result<AgentPolicy>>; | |
| 715 | 716 | setPolicy(workspace: string, viewer: User, policy: Partial<AgentPolicy>): Promise<Result<AgentPolicy>>; | |
| 717 | + | /** | |
| 718 | + | * The Marketplace's install requests (./marketplace.ts): every one in the | |
| 719 | + | * workspace for an owner, a member's own for anyone else. | |
| 720 | + | */ | |
| 721 | + | installRequests(workspace: string, viewer: User): Promise<Result<InstallRequests>>; | |
| 722 | + | /** | |
| 723 | + | * A member asks the workspace's owners to add a listing (`agent:<template>` | |
| 724 | + | * or `integration:<connector>`), and every owner is notified. Owners add | |
| 725 | + | * things themselves, so they don't ask. Asking again while a request for | |
| 726 | + | * the same listing is open is a conflict. | |
| 727 | + | */ | |
| 728 | + | requestInstall(workspace: string, viewer: User, listing: string, note?: string | null): Promise<Result<InstallRequest>>; | |
| 729 | + | /** An owner marks a request added (`done`) or turns it down (`declined`); whoever asked is told. */ | |
| 730 | + | resolveInstallRequest(workspace: string, viewer: User, id: string, status: Exclude<InstallRequestStatus, "open">): Promise<Result<InstallRequest>>; | |
| 731 | + | /** The extensions installed in the workspace; any member sees them. */ | |
| 732 | + | extensionInstalls(workspace: string, viewer: User): Promise<Result<ExtensionInstall[]>>; | |
| 733 | + | /** Owners install a published extension at its current version; open requests for it are answered. */ | |
| 734 | + | installExtension(workspace: string, viewer: User, extension: string): Promise<Result<ExtensionInstall>>; | |
| 735 | + | /** Owners switch an install on or off: off is the kill switch. */ | |
| 736 | + | setExtensionEnabled(workspace: string, viewer: User, listing: string, enabled: boolean): Promise<Result<ExtensionInstall>>; | |
| 737 | + | /** Owners cap what an install spends a month; null leaves it to the workspace's limit. */ | |
| 738 | + | setExtensionBudget(workspace: string, viewer: User, listing: string, monthlyMicros: number | null): Promise<Result<ExtensionInstall>>; | |
| 739 | + | uninstallExtension(workspace: string, viewer: User, listing: string): Promise<Result<null>>; | |
| 716 | 740 | }; | |
| 717 | 741 | ||
| 718 | 742 | async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> { | |
| ⋯ | |||
| 763 | 787 | cardAction: (input) => call("card_action", input), | |
| 764 | 788 | policy: (workspace, viewer) => call("policy", { workspace, viewer }), | |
| 765 | 789 | setPolicy: (workspace, viewer, policy) => call("set_policy", { workspace, viewer, policy }), | |
| 790 | + | installRequests: (workspace, viewer) => call("install_requests", { workspace, viewer }), | |
| 791 | + | requestInstall: (workspace, viewer, listing, note) => call("request_install", { workspace, viewer, listing, note: note ?? null }), | |
| 792 | + | resolveInstallRequest: (workspace, viewer, id, status) => call("resolve_install_request", { workspace, viewer, id, status }), | |
| 793 | + | extensionInstalls: (workspace, viewer) => call("extension_installs", { workspace, viewer }), | |
| 794 | + | installExtension: (workspace, viewer, extension) => call("install_extension", { workspace, viewer, extension }), | |
| 795 | + | setExtensionEnabled: (workspace, viewer, listing, enabled) => call("set_extension_enabled", { workspace, viewer, listing, enabled }), | |
| 796 | + | setExtensionBudget: (workspace, viewer, listing, monthlyMicros) => call("set_extension_budget", { workspace, viewer, listing, monthly_micros: monthlyMicros }), | |
| 797 | + | uninstallExtension: (workspace, viewer, listing) => call("uninstall_extension", { workspace, viewer, listing }), | |
| 766 | 798 | }; | |
| 767 | 799 | } | |
| 1 | + | -- The Marketplace: members' install requests (src/installs.ts), where a | |
| 2 | + | -- member asks the workspace's owners to add an agent from the catalog, an | |
| 3 | + | -- integration or an extension; and the extensions a workspace installed | |
| 4 | + | -- (src/extensions.ts). | |
| 5 | + | ||
| 6 | + | CREATE TABLE install_requests ( | |
| 7 | + | id TEXT PRIMARY KEY, | |
| 8 | + | -- By id, not slug: a renamed workspace keeps its requests. | |
| 9 | + | workspace_id TEXT NOT NULL, | |
| 10 | + | -- `agent:<template>`, `integration:<connector>` or `extension:<id>` (@g1t/contracts marketplace.ts). | |
| 11 | + | listing TEXT NOT NULL, | |
| 12 | + | -- The listing's name when it was asked for, so a request still reads | |
| 13 | + | -- after the listing changes. | |
| 14 | + | name TEXT NOT NULL, | |
| 15 | + | note TEXT, | |
| 16 | + | requested_by TEXT NOT NULL, | |
| 17 | + | requested_by_id TEXT NOT NULL, | |
| 18 | + | requested_at TEXT NOT NULL, | |
| 19 | + | -- open, done or declined. | |
| 20 | + | status TEXT NOT NULL DEFAULT 'open', | |
| 21 | + | resolved_by TEXT, | |
| 22 | + | resolved_at TEXT | |
| 23 | + | ); | |
| 24 | + | ||
| 25 | + | -- One open request per person and listing: asking twice is one request. | |
| 26 | + | CREATE UNIQUE INDEX install_requests_open ON install_requests (workspace_id, listing, requested_by_id) WHERE status = 'open'; | |
| 27 | + | CREATE INDEX install_requests_by_workspace ON install_requests (workspace_id, status, requested_at); | |
| 28 | + | ||
| 29 | + | -- Extensions installed in a workspace: which version, on which plan, who | |
| 30 | + | -- installed it, and whether it is switched on. An uninstalled extension | |
| 31 | + | -- keeps its row, with when and by whom. | |
| 32 | + | CREATE TABLE extension_installs ( | |
| 33 | + | id TEXT PRIMARY KEY, | |
| 34 | + | workspace_id TEXT NOT NULL, | |
| 35 | + | -- `extension:<id>`. | |
| 36 | + | listing TEXT NOT NULL, | |
| 37 | + | version TEXT NOT NULL, | |
| 38 | + | -- `free` until listings have prices. | |
| 39 | + | plan TEXT NOT NULL DEFAULT 'free', | |
| 40 | + | installed_by TEXT NOT NULL, | |
| 41 | + | installed_at TEXT NOT NULL, | |
| 42 | + | -- 1 on, 0 off: the kill switch. | |
| 43 | + | enabled INTEGER NOT NULL DEFAULT 1, | |
| 44 | + | disabled_by TEXT, | |
| 45 | + | disabled_at TEXT, | |
| 46 | + | -- A monthly cap on what it spends, in micro-dollars; null: the workspace's limit. | |
| 47 | + | budget_monthly_micros INTEGER, | |
| 48 | + | uninstalled_by TEXT, | |
| 49 | + | uninstalled_at TEXT | |
| 50 | + | ); | |
| 51 | + | ||
| 52 | + | -- One live install per workspace and extension. | |
| 53 | + | CREATE UNIQUE INDEX extension_installs_live ON extension_installs (workspace_id, listing) WHERE uninstalled_at IS NULL; |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { readFileSync, readdirSync } from "node:fs"; | |
| 3 | + | import { DatabaseSync } from "node:sqlite"; | |
| 4 | + | import { test } from "node:test"; | |
| 5 | + | ||
| 6 | + | import type { ExtensionManifest } from "@g1t/contracts"; | |
| 7 | + | import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace"; | |
| 8 | + | ||
| 9 | + | import { type Catalog, extensionIdOf, install, listInstalls, setBudget, setEnabled, uninstall } from "./extensions.ts"; | |
| 10 | + | import { findListing } from "./installs.ts"; | |
| 11 | + | ||
| 12 | + | /** D1, as far as installs use it, over node's SQLite with the service's migrations. */ | |
| 13 | + | function fakeD1(): D1Database { | |
| 14 | + | const db = new DatabaseSync(":memory:"); | |
| 15 | + | const dir = new URL("../migrations/", import.meta.url); | |
| 16 | + | for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8")); | |
| 17 | + | const statement = (sql: string, params: unknown[] = []) => ({ | |
| 18 | + | bind: (...values: unknown[]) => statement(sql, values), | |
| 19 | + | first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null, | |
| 20 | + | run: async () => db.prepare(sql).run(...(params as never[])), | |
| 21 | + | all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }), | |
| 22 | + | }); | |
| 23 | + | return { prepare: (sql: string) => statement(sql) } as unknown as D1Database; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | const standup: ExtensionManifest = { ...FIRST_PARTY_EXTENSIONS[0]!, id: "standup", name: "Standup", status: "available", source: { repo: "flagon-io/standup", tag: "v1.2.0" }, version: "1.2.0" }; | |
| 27 | + | const catalog: Catalog = (id) => (id === "standup" ? standup : FIRST_PARTY_EXTENSIONS.find((e) => e.id === id)); | |
| 28 | + | const ws = "wsp_acme"; | |
| 29 | + | const at = new Date("2026-10-10T12:00:00.000Z"); | |
| 30 | + | ||
| 31 | + | test("a published extension installs at its version, on the free plan, switched on", async () => { | |
| 32 | + | const db = fakeD1(); | |
| 33 | + | const done = await install(db, catalog, ws, "ins_1", "standup", "chase", at); | |
| 34 | + | assert.ok(done.ok); | |
| 35 | + | assert.deepEqual( | |
| 36 | + | { listing: done.value.listing, version: done.value.version, plan: done.value.plan, enabled: done.value.enabled, by: done.value.installed_by }, | |
| 37 | + | { listing: "extension:standup", version: "1.2.0", plan: "free", enabled: true, by: "chase" }, | |
| 38 | + | ); | |
| 39 | + | const again = await install(db, catalog, ws, "ins_2", "standup", "chase", at); | |
| 40 | + | assert.equal(!again.ok && again.error.code, "conflict"); | |
| 41 | + | assert.deepEqual((await listInstalls(db, ws)).map((i) => i.listing), ["extension:standup"]); | |
| 42 | + | assert.deepEqual(await listInstalls(db, "wsp_other"), []); | |
| 43 | + | }); | |
| 44 | + | ||
| 45 | + | test("nothing unpublished installs", async () => { | |
| 46 | + | const db = fakeD1(); | |
| 47 | + | const support = await install(db, catalog, ws, "ins_1", "support", "chase", at); | |
| 48 | + | assert.equal(!support.ok && support.error.code, "invalid"); | |
| 49 | + | assert.match(!support.ok ? support.error.message : "", /isn't published yet/); | |
| 50 | + | const missing = await install(db, catalog, ws, "ins_2", "nope", "chase", at); | |
| 51 | + | assert.equal(!missing.ok && missing.error.code, "not_found"); | |
| 52 | + | // Nor can members ask for what can't be installed. | |
| 53 | + | assert.equal(findListing("extension:support"), null); | |
| 54 | + | }); | |
| 55 | + | ||
| 56 | + | test("the kill switch turns an install off at once, and back on", async () => { | |
| 57 | + | const db = fakeD1(); | |
| 58 | + | await install(db, catalog, ws, "ins_1", "standup", "chase", at); | |
| 59 | + | const off = await setEnabled(db, ws, "extension:standup", false, "dee", at); | |
| 60 | + | assert.ok(off.ok); | |
| 61 | + | assert.equal(off.value.enabled, false); | |
| 62 | + | assert.equal(off.value.disabled_by, "dee"); | |
| 63 | + | const on = await setEnabled(db, ws, "extension:standup", true, "chase", at); | |
| 64 | + | assert.ok(on.ok); | |
| 65 | + | assert.equal(on.value.enabled, true); | |
| 66 | + | assert.equal(on.value.disabled_by, null); | |
| 67 | + | const missing = await setEnabled(db, ws, "extension:other", false, "dee", at); | |
| 68 | + | assert.equal(!missing.ok && missing.error.code, "not_found"); | |
| 69 | + | }); | |
| 70 | + | ||
| 71 | + | test("an install has a budget, and uninstalling keeps the record", async () => { | |
| 72 | + | const db = fakeD1(); | |
| 73 | + | await install(db, catalog, ws, "ins_1", "standup", "chase", at); | |
| 74 | + | const capped = await setBudget(db, ws, "extension:standup", 25_000_000); | |
| 75 | + | assert.ok(capped.ok); | |
| 76 | + | assert.equal(capped.value.budget_monthly_micros, 25_000_000); | |
| 77 | + | assert.equal((await setBudget(db, ws, "extension:standup", -1)).ok, false); | |
| 78 | + | assert.ok((await uninstall(db, ws, "extension:standup", "chase", at)).ok); | |
| 79 | + | assert.deepEqual(await listInstalls(db, ws), []); | |
| 80 | + | assert.equal((await uninstall(db, ws, "extension:standup", "chase", at)).ok, false); | |
| 81 | + | // Installed again, it is a new install. | |
| 82 | + | assert.ok((await install(db, catalog, ws, "ins_2", "standup", "chase", at)).ok); | |
| 83 | + | }); | |
| 84 | + | ||
| 85 | + | test("extension references", () => { | |
| 86 | + | assert.equal(extensionIdOf("extension:standup"), "standup"); | |
| 87 | + | assert.equal(extensionIdOf("integration:sentry"), null); | |
| 88 | + | }); |
| 1 | + | /** | |
| 2 | + | * Extensions installed in a workspace (docs.g1t.sh/guides/marketplace/): | |
| 3 | + | * the version an install keeps, its plan, who installed it, whether it is | |
| 4 | + | * switched on (the kill switch), and its monthly spend cap. Kept in | |
| 5 | + | * `extension_installs`; one live install per workspace and extension. | |
| 6 | + | * | |
| 7 | + | * Only a published listing installs: one whose manifest is `available`, | |
| 8 | + | * with a source and a version. The catalog of manifests is passed in | |
| 9 | + | * (`Catalog`): today g1t's own (@g1t/contracts FIRST_PARTY_EXTENSIONS), | |
| 10 | + | * later every published listing. Who may do what, the audit log and | |
| 11 | + | * telling people are index.ts's; this file is the rules and the table, | |
| 12 | + | * tested on SQLite. | |
| 13 | + | */ | |
| 14 | + | import type { ExtensionInstall, ExtensionManifest, FailureCode, Result } from "@g1t/contracts"; | |
| 15 | + | import { FREE_PLAN, listingRef, parseListing } from "@g1t/contracts/marketplace"; | |
| 16 | + | ||
| 17 | + | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); | |
| 18 | + | const fail = (code: FailureCode, message: string): Result<never> => ({ ok: false, error: { code, message } }); | |
| 19 | + | ||
| 20 | + | /** Finds an extension's manifest by id. */ | |
| 21 | + | export type Catalog = (id: string) => ExtensionManifest | undefined; | |
| 22 | + | ||
| 23 | + | type Row = { | |
| 24 | + | id: string; | |
| 25 | + | listing: string; | |
| 26 | + | version: string; | |
| 27 | + | plan: string; | |
| 28 | + | installed_by: string; | |
| 29 | + | installed_at: string; | |
| 30 | + | enabled: number; | |
| 31 | + | disabled_by: string | null; | |
| 32 | + | disabled_at: string | null; | |
| 33 | + | budget_monthly_micros: number | null; | |
| 34 | + | }; | |
| 35 | + | ||
| 36 | + | const COLUMNS = "id, listing, version, plan, installed_by, installed_at, enabled, disabled_by, disabled_at, budget_monthly_micros"; | |
| 37 | + | ||
| 38 | + | function toInstall(row: Row): ExtensionInstall { | |
| 39 | + | return { ...row, enabled: row.enabled === 1 }; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /** The workspace's live installs, oldest first. */ | |
| 43 | + | export async function listInstalls(db: D1Database, workspaceId: string): Promise<ExtensionInstall[]> { | |
| 44 | + | const { results } = await db.prepare(`SELECT ${COLUMNS} FROM extension_installs WHERE workspace_id = ? AND uninstalled_at IS NULL ORDER BY installed_at`).bind(workspaceId).all<Row>(); | |
| 45 | + | return results.map(toInstall); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /** Installs a published extension at its current version, on the free plan. A conflict when it is installed already. */ | |
| 49 | + | export async function install(db: D1Database, catalog: Catalog, workspaceId: string, id: string, extensionId: string, by: string, now = new Date()): Promise<Result<ExtensionInstall>> { | |
| 50 | + | const manifest = catalog(extensionId); | |
| 51 | + | if (!manifest) return fail("not_found", "There is no such extension."); | |
| 52 | + | if (manifest.status !== "available" || !manifest.version || !manifest.source) return fail("invalid", `${manifest.name} isn't published yet, so it can't be installed.`); | |
| 53 | + | const row: Row = { | |
| 54 | + | id, | |
| 55 | + | listing: listingRef("extension", manifest.id), | |
| 56 | + | version: manifest.version, | |
| 57 | + | plan: FREE_PLAN, | |
| 58 | + | installed_by: by, | |
| 59 | + | installed_at: now.toISOString(), | |
| 60 | + | enabled: 1, | |
| 61 | + | disabled_by: null, | |
| 62 | + | disabled_at: null, | |
| 63 | + | budget_monthly_micros: null, | |
| 64 | + | }; | |
| 65 | + | const inserted = await db | |
| 66 | + | .prepare( | |
| 67 | + | `INSERT INTO extension_installs (id, workspace_id, listing, version, plan, installed_by, installed_at, enabled) | |
| 68 | + | VALUES (?, ?, ?, ?, ?, ?, ?, 1) ON CONFLICT DO NOTHING RETURNING id`, | |
| 69 | + | ) | |
| 70 | + | .bind(row.id, workspaceId, row.listing, row.version, row.plan, row.installed_by, row.installed_at) | |
| 71 | + | .first<{ id: string }>(); | |
| 72 | + | return inserted ? ok(toInstall(row)) : fail("conflict", `${manifest.name} is installed already.`); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /** Switches an install on or off. Off is the kill switch: it takes effect at once. */ | |
| 76 | + | export async function setEnabled(db: D1Database, workspaceId: string, listing: string, enabled: boolean, by: string, now = new Date()): Promise<Result<ExtensionInstall>> { | |
| 77 | + | const row = await db | |
| 78 | + | .prepare( | |
| 79 | + | `UPDATE extension_installs SET enabled = ?, disabled_by = ?, disabled_at = ? | |
| 80 | + | WHERE workspace_id = ? AND listing = ? AND uninstalled_at IS NULL RETURNING ${COLUMNS}`, | |
| 81 | + | ) | |
| 82 | + | .bind(enabled ? 1 : 0, enabled ? null : by, enabled ? null : now.toISOString(), workspaceId, listing) | |
| 83 | + | .first<Row>(); | |
| 84 | + | return row ? ok(toInstall(row)) : fail("not_found", "That extension isn't installed."); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /** Sets an install's monthly spend cap; null leaves it to the workspace's limit. */ | |
| 88 | + | export async function setBudget(db: D1Database, workspaceId: string, listing: string, micros: number | null): Promise<Result<ExtensionInstall>> { | |
| 89 | + | if (micros !== null && (!Number.isSafeInteger(micros) || micros < 0)) return fail("invalid", "A budget is a whole, non-negative number of micro-dollars."); | |
| 90 | + | const row = await db | |
| 91 | + | .prepare(`UPDATE extension_installs SET budget_monthly_micros = ? WHERE workspace_id = ? AND listing = ? AND uninstalled_at IS NULL RETURNING ${COLUMNS}`) | |
| 92 | + | .bind(micros, workspaceId, listing) | |
| 93 | + | .first<Row>(); | |
| 94 | + | return row ? ok(toInstall(row)) : fail("not_found", "That extension isn't installed."); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /** Removes an install, keeping its row with who removed it and when. */ | |
| 98 | + | export async function uninstall(db: D1Database, workspaceId: string, listing: string, by: string, now = new Date()): Promise<Result<null>> { | |
| 99 | + | const row = await db | |
| 100 | + | .prepare("UPDATE extension_installs SET uninstalled_by = ?, uninstalled_at = ?, enabled = 0 WHERE workspace_id = ? AND listing = ? AND uninstalled_at IS NULL RETURNING id") | |
| 101 | + | .bind(by, now.toISOString(), workspaceId, listing) | |
| 102 | + | .first<{ id: string }>(); | |
| 103 | + | return row ? ok(null) : fail("not_found", "That extension isn't installed."); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /** The extension id in an `extension:<id>` reference, or null. */ | |
| 107 | + | export function extensionIdOf(listing: unknown): string | null { | |
| 108 | + | const parsed = parseListing(listing); | |
| 109 | + | return parsed?.kind === "extension" ? parsed.id : null; | |
| 110 | + | } |
| 14 | 14 | type AgentDelivery, | |
| 15 | 15 | type CardActionResult, | |
| 16 | 16 | type G1tEvent, | |
| 17 | + | type ExtensionInstall, | |
| 18 | + | type InstallRequest, | |
| 19 | + | type InstallRequests, | |
| 17 | 20 | type NewWorkspaceAgent, | |
| 18 | 21 | type Result, | |
| 19 | 22 | type ServiceBinding, | |
| ⋯ | |||
| 23 | 26 | askerAccess, | |
| 24 | 27 | awaitsConfirmation, | |
| 25 | 28 | chatClient, | |
| 29 | + | cleanRequestNote, | |
| 30 | + | extensionById, | |
| 26 | 31 | fail, | |
| 27 | 32 | identityClient, | |
| 28 | 33 | newId, | |
| 34 | + | notifyClient, | |
| 29 | 35 | ok, | |
| 30 | 36 | openD1, | |
| 31 | 37 | } from "@g1t/contracts"; | |
| ⋯ | |||
| 45 | 51 | import { type SessionEnv, sweep } from "./sessions.ts"; | |
| 46 | 52 | import * as views from "./views.ts"; | |
| 47 | 53 | import { monthKey } from "./budget.ts"; | |
| 54 | + | import * as extensions from "./extensions.ts"; | |
| 55 | + | import { type Answered, type Person, answerLine, findListing, listRequests, listingPath, openRequest, requestsPath, resolveListing, resolveRequest } from "./installs.ts"; | |
| 48 | 56 | ||
| 49 | 57 | export { Desk } from "./desk.ts"; | |
| 50 | 58 | ||
| ⋯ | |||
| 213 | 221 | } | |
| 214 | 222 | this.audit(a.viewer!, a.workspace, "create_agent", definition.handle, `Created @${definition.handle} (version 1)`); | |
| 215 | 223 | this.defer(this.hello(a.workspace, managed.value, id, a.viewer!)); | |
| 224 | + | // Hiring from a catalog role answers every request for that role. | |
| 225 | + | if (definition.template) this.defer(this.answerListing(a.workspace, managed.value, `agent:${definition.template}`, a.viewer!)); | |
| 216 | 226 | const row = await this.row(managed.value, definition.handle); | |
| 217 | 227 | return ok(toAgent(row!, new Date())); | |
| 218 | 228 | } | |
| 219 | 229 | ||
| 230 | + | // ── The Marketplace's install requests (./installs.ts) ───────────────── | |
| 231 | + | ||
| 232 | + | /** Requests as the viewer sees them: every one for an owner, their own for anyone else. */ | |
| 233 | + | async installRequests(a: { workspace: string; viewer: User | null }): Promise<Result<InstallRequests>> { | |
| 234 | + | if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED; | |
| 235 | + | const seen = await this.seen(a?.workspace ?? "", a?.viewer ?? null); | |
| 236 | + | if (!seen.ok) return seen; | |
| 237 | + | const owner = canManage(a.viewer, a.workspace); | |
| 238 | + | const requests = await listRequests(this.db, seen.value, a.viewer!, owner); | |
| 239 | + | return ok({ requests, can_resolve: owner }); | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | /** A member asks the owners to add something; each owner is notified. */ | |
| 243 | + | async requestInstall(a: { workspace: string; viewer: User | null; listing: unknown; note?: unknown }): Promise<Result<InstallRequest>> { | |
| 244 | + | if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED; | |
| 245 | + | const seen = await this.seen(a?.workspace ?? "", a?.viewer ?? null); | |
| 246 | + | if (!seen.ok) return seen; | |
| 247 | + | const viewer = a.viewer!; | |
| 248 | + | if ((viewer.kind ?? "user") !== "user") return fail("forbidden", "Only people ask the workspace's owners to add things."); | |
| 249 | + | if (canManage(viewer, a.workspace)) return fail("invalid", "You're an owner of this workspace: add it yourself."); | |
| 250 | + | const listing = findListing(a.listing); | |
| 251 | + | if (!listing) return fail("not_found", "That isn't something a workspace can add yet."); | |
| 252 | + | const opened = await openRequest(this.db, seen.value, newId("ins"), listing, viewer, cleanRequestNote(a.note)); | |
| 253 | + | if (!opened.ok) return opened; | |
| 254 | + | const slug = a.workspace.toLowerCase(); | |
| 255 | + | this.audit(viewer, slug, "request_install", listing.ref, `Asked the owners to add ${listing.name}`, "marketplace"); | |
| 256 | + | this.defer(this.tellOwners(slug, viewer, opened.value)); | |
| 257 | + | return opened; | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | /** An owner adds or turns down a request; whoever asked is told. */ | |
| 261 | + | async resolveInstallRequest(a: { workspace: string; viewer: User | null; id: unknown; status: unknown }): Promise<Result<InstallRequest>> { | |
| 262 | + | const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null); | |
| 263 | + | if (!managed.ok) { | |
| 264 | + | return managed.error.code === "forbidden" ? fail("forbidden", "Only the workspace's owners answer requests.") : managed; | |
| 265 | + | } | |
| 266 | + | if (a.status !== "done" && a.status !== "declined") return fail("invalid", "Answer a request with done or declined."); | |
| 267 | + | const answered = await resolveRequest(this.db, managed.value, String(a.id ?? ""), a.status, a.viewer!); | |
| 268 | + | if (!answered.ok) return answered; | |
| 269 | + | const slug = a.workspace.toLowerCase(); | |
| 270 | + | const { request } = answered.value; | |
| 271 | + | this.audit(a.viewer!, slug, "answer_install_request", request.listing, `${request.status === "done" ? "Added" : "Turned down"} ${request.name} for @${request.requested_by}`, "marketplace"); | |
| 272 | + | this.defer(this.tellRequester(slug, a.viewer!, answered.value)); | |
| 273 | + | return ok(request); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // ── Extensions installed in the workspace (./extensions.ts) ────────────── | |
| 277 | + | ||
| 278 | + | /** The workspace's installs; any member sees them. */ | |
| 279 | + | async extensionInstalls(a: { workspace: string; viewer: User | null }): Promise<Result<ExtensionInstall[]>> { | |
| 280 | + | if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED; | |
| 281 | + | const seen = await this.seen(a?.workspace ?? "", a?.viewer ?? null); | |
| 282 | + | if (!seen.ok) return seen; | |
| 283 | + | return ok(await extensions.listInstalls(this.db, seen.value)); | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | /** Owners install a published extension; whoever asked for it is told. */ | |
| 287 | + | async installExtension(a: { workspace: string; viewer: User | null; extension: unknown }): Promise<Result<ExtensionInstall>> { | |
| 288 | + | const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null); | |
| 289 | + | if (!managed.ok) return managed.error.code === "forbidden" ? fail("forbidden", "Only the workspace's owners install extensions.") : managed; | |
| 290 | + | const installed = await extensions.install(this.db, extensionById, managed.value, newId("ins"), String(a.extension ?? ""), a.viewer!.username); | |
| 291 | + | if (!installed.ok) return installed; | |
| 292 | + | const slug = a.workspace.toLowerCase(); | |
| 293 | + | this.audit(a.viewer!, slug, "install_extension", installed.value.listing, `Installed ${installed.value.listing} ${installed.value.version}`, "marketplace"); | |
| 294 | + | this.defer(this.answerListing(slug, managed.value, installed.value.listing, a.viewer!)); | |
| 295 | + | return installed; | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | /** Owners switch an install on or off. */ | |
| 299 | + | async setExtensionEnabled(a: { workspace: string; viewer: User | null; listing: unknown; enabled: unknown }): Promise<Result<ExtensionInstall>> { | |
| 300 | + | const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null); | |
| 301 | + | if (!managed.ok) return managed; | |
| 302 | + | const listing = String(a.listing ?? ""); | |
| 303 | + | if (!extensions.extensionIdOf(listing)) return fail("invalid", "Name an extension as extension:<id>."); | |
| 304 | + | const changed = await extensions.setEnabled(this.db, managed.value, listing, a.enabled === true, a.viewer!.username); | |
| 305 | + | if (changed.ok) this.audit(a.viewer!, a.workspace, a.enabled === true ? "enable_extension" : "disable_extension", listing, `${a.enabled === true ? "Switched on" : "Switched off"} ${listing}`, "marketplace"); | |
| 306 | + | return changed; | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | /** Owners cap what an install spends a month. */ | |
| 310 | + | async setExtensionBudget(a: { workspace: string; viewer: User | null; listing: unknown; monthly_micros: unknown }): Promise<Result<ExtensionInstall>> { | |
| 311 | + | const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null); | |
| 312 | + | if (!managed.ok) return managed; | |
| 313 | + | const listing = String(a.listing ?? ""); | |
| 314 | + | const micros = a.monthly_micros == null ? null : Number(a.monthly_micros); | |
| 315 | + | const changed = await extensions.setBudget(this.db, managed.value, listing, micros); | |
| 316 | + | if (changed.ok) this.audit(a.viewer!, a.workspace, "set_extension_budget", listing, `Set ${listing}'s monthly budget`, "marketplace"); | |
| 317 | + | return changed; | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | /** Owners remove an install. */ | |
| 321 | + | async uninstallExtension(a: { workspace: string; viewer: User | null; listing: unknown }): Promise<Result<null>> { | |
| 322 | + | const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null); | |
| 323 | + | if (!managed.ok) return managed; | |
| 324 | + | const listing = String(a.listing ?? ""); | |
| 325 | + | const removed = await extensions.uninstall(this.db, managed.value, listing, a.viewer!.username); | |
| 326 | + | if (removed.ok) this.audit(a.viewer!, a.workspace, "uninstall_extension", listing, `Uninstalled ${listing}`, "marketplace"); | |
| 327 | + | return removed; | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | /** Marks every open request for `listing` added, and tells each person who asked. */ | |
| 331 | + | private async answerListing(slug: string, workspaceId: string, listing: string, by: User): Promise<void> { | |
| 332 | + | try { | |
| 333 | + | const answered = await resolveListing(this.db, workspaceId, listing, by as Person); | |
| 334 | + | await Promise.all(answered.map((one) => this.tellRequester(slug.toLowerCase(), by, one))); | |
| 335 | + | } catch (error) { | |
| 336 | + | console.error("agents: requests for a listing were not answered", listing, String(error)); | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | /** Every owner hears of a new request (at most 20 of them). */ | |
| 341 | + | private async tellOwners(slug: string, asker: User, request: InstallRequest): Promise<void> { | |
| 342 | + | if (!this.env.NOTIFY) return; | |
| 343 | + | try { | |
| 344 | + | const members = await identityClient(this.env.IDENTITY).listMembers(slug, asker); | |
| 345 | + | if (!members.ok) throw new Error(members.error.message); | |
| 346 | + | const owners = members.value.filter((member) => member.role === "owner").slice(0, 20); | |
| 347 | + | const notify = notifyClient(this.env.NOTIFY); | |
| 348 | + | await Promise.all( | |
| 349 | + | owners.map((owner) => | |
| 350 | + | notify | |
| 351 | + | .notify( | |
| 352 | + | { username: owner.username }, | |
| 353 | + | { | |
| 354 | + | id: `install-request:${request.id}:${owner.username}`, | |
| 355 | + | kind: "approval", | |
| 356 | + | workspace: slug, | |
| 357 | + | title: `@${asker.username} asks you to add ${request.name}`, | |
| 358 | + | body: request.note ?? (request.kind === "agent" ? "An agent from the catalog. Add it, or turn the request down." : request.kind === "extension" ? "An extension. Install it, or turn the request down." : "An integration. Connect it, or turn the request down."), | |
| 359 | + | href: requestsPath(slug), | |
| 360 | + | actor: { kind: "user", id: asker.id, name: asker.username, avatar: asker.avatar ?? null, avatar_seed: null }, | |
| 361 | + | created_at: new Date().toISOString(), | |
| 362 | + | }, | |
| 363 | + | ) | |
| 364 | + | .catch(() => undefined), | |
| 365 | + | ), | |
| 366 | + | ); | |
| 367 | + | } catch (error) { | |
| 368 | + | console.error("agents: owners were not told of an install request", request.id, String(error)); | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | /** The person who asked hears their request was answered. */ | |
| 373 | + | private async tellRequester(slug: string, by: User, answered: Answered): Promise<void> { | |
| 374 | + | if (!this.env.NOTIFY) return; | |
| 375 | + | const { request } = answered; | |
| 376 | + | const line = answerLine(request, `@${by.username}`); | |
| 377 | + | await notifyClient(this.env.NOTIFY) | |
| 378 | + | .notify( | |
| 379 | + | { user_id: answered.requested_by_id }, | |
| 380 | + | { | |
| 381 | + | id: `install-answer:${request.id}`, | |
| 382 | + | kind: "inbox", | |
| 383 | + | workspace: slug, | |
| 384 | + | title: line.title, | |
| 385 | + | body: line.body, | |
| 386 | + | href: request.status === "done" ? listingPath(slug, request.listing) : requestsPath(slug), | |
| 387 | + | actor: { kind: "user", id: by.id, name: by.username, avatar: by.avatar ?? null, avatar_seed: null }, | |
| 388 | + | created_at: new Date().toISOString(), | |
| 389 | + | }, | |
| 390 | + | ) | |
| 391 | + | .catch((error: unknown) => console.error("agents: a requester was not told", request.id, String(error))); | |
| 392 | + | } | |
| 393 | + | ||
| 220 | 394 | /** | |
| 221 | 395 | * A new agent's first words: the DM with the person who made it is | |
| 222 | 396 | * opened, and the agent says hello there in its own voice, as a reply | |
| ⋯ | |||
| 354 | 528 | * The events service's audit contract speaks camelCase (Rust's | |
| 355 | 529 | * `NewAuditEntry`). | |
| 356 | 530 | */ | |
| 357 | − | private audit(actor: User, workspace: string, action: string, handle: string, message: string): void { | |
| 531 | + | private audit(actor: User, workspace: string, action: string, handle: string, message: string, area: "agents" | "marketplace" = "agents"): void { | |
| 358 | 532 | const kind = actor.kind === "workspace" ? "workspace" : actor.kind === "agent" ? "agent" : actor.kind === "system" ? "system" : "person"; | |
| 359 | 533 | const entry = { | |
| 360 | 534 | actorKind: kind, | |
| ⋯ | |||
| 371 | 545 | repo: null, | |
| 372 | 546 | number: null, | |
| 373 | 547 | gitRef: null, | |
| 374 | − | path: `agents/${handle}`, | |
| 548 | + | path: `${area}/${handle}`, | |
| 375 | 549 | outcome: "allowed", | |
| 376 | − | rule: "owner", | |
| 550 | + | rule: area === "marketplace" && action === "request_install" ? "member" : "owner", | |
| 377 | 551 | result: "ok", | |
| 378 | 552 | message, | |
| 379 | 553 | requestId: `req_${crypto.randomUUID()}`, | |
| ⋯ | |||
| 455 | 629 | return Response.json(await service.cardAction(args)); | |
| 456 | 630 | case "policy": | |
| 457 | 631 | return Response.json(await service.view(args, (ctx) => views.policy(ctx))); | |
| 632 | + | case "install_requests": | |
| 633 | + | return Response.json(await service.installRequests(args)); | |
| 634 | + | case "request_install": | |
| 635 | + | return Response.json(await service.requestInstall(args)); | |
| 636 | + | case "resolve_install_request": | |
| 637 | + | return Response.json(await service.resolveInstallRequest(args)); | |
| 638 | + | case "extension_installs": | |
| 639 | + | return Response.json(await service.extensionInstalls(args)); | |
| 640 | + | case "install_extension": | |
| 641 | + | return Response.json(await service.installExtension(args)); | |
| 642 | + | case "set_extension_enabled": | |
| 643 | + | return Response.json(await service.setExtensionEnabled(args)); | |
| 644 | + | case "set_extension_budget": | |
| 645 | + | return Response.json(await service.setExtensionBudget(args)); | |
| 646 | + | case "uninstall_extension": | |
| 647 | + | return Response.json(await service.uninstallExtension(args)); | |
| 458 | 648 | case "set_policy": | |
| 459 | 649 | return Response.json(await service.view(args, (ctx) => views.setPolicy(ctx, args.policy))); | |
| 460 | 650 | default: | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { readFileSync, readdirSync } from "node:fs"; | |
| 3 | + | import { DatabaseSync } from "node:sqlite"; | |
| 4 | + | import { test } from "node:test"; | |
| 5 | + | ||
| 6 | + | import { MAX_OPEN_REQUESTS, cleanRequestNote, parseListing } from "@g1t/contracts/marketplace"; | |
| 7 | + | ||
| 8 | + | import { answerLine, findListing, listRequests, listingPath, openRequest, resolveListing, resolveRequest } from "./installs.ts"; | |
| 9 | + | ||
| 10 | + | /** D1, as far as install requests use it, over node's SQLite with the service's migrations. */ | |
| 11 | + | function fakeD1(): D1Database { | |
| 12 | + | const db = new DatabaseSync(":memory:"); | |
| 13 | + | const dir = new URL("../migrations/", import.meta.url); | |
| 14 | + | for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8")); | |
| 15 | + | const statement = (sql: string, params: unknown[] = []) => ({ | |
| 16 | + | bind: (...values: unknown[]) => statement(sql, values), | |
| 17 | + | first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null, | |
| 18 | + | run: async () => db.prepare(sql).run(...(params as never[])), | |
| 19 | + | all: async () => ({ results: db.prepare(sql).all(...(params as never[])) }), | |
| 20 | + | }); | |
| 21 | + | return { prepare: (sql: string) => statement(sql) } as unknown as D1Database; | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | let ids = 0; | |
| 25 | + | const nextId = () => `ins_${++ids}`; | |
| 26 | + | const ws = "wsp_acme"; | |
| 27 | + | const ana = { id: "usr_ana", username: "ana" }; | |
| 28 | + | const bo = { id: "usr_bo", username: "bo" }; | |
| 29 | + | const owner = { id: "usr_own", username: "chase" }; | |
| 30 | + | const at = new Date("2026-10-10T12:00:00.000Z"); | |
| 31 | + | const later = (days: number) => new Date(at.getTime() + days * 86_400_000); | |
| 32 | + | ||
| 33 | + | test("only what a workspace can add today is a listing", () => { | |
| 34 | + | assert.deepEqual(findListing("agent:engineering"), { ref: "agent:engineering", kind: "agent", id: "engineering", name: "Software Engineer" }); | |
| 35 | + | assert.equal(findListing("integration:sentry")?.name, "Sentry"); | |
| 36 | + | assert.equal(findListing("agent:astronaut"), null, "no such role"); | |
| 37 | + | assert.equal(findListing("integration:slack"), null, "Slack is planned, not available"); | |
| 38 | + | assert.equal(findListing("integration:mcp"), null, "MCP clients are connected by each person, not a workspace"); | |
| 39 | + | assert.equal(findListing("extension:support"), null, "no extensions are listed yet"); | |
| 40 | + | assert.equal(findListing(42), null); | |
| 41 | + | }); | |
| 42 | + | ||
| 43 | + | test("references parse strictly, and notes are tidied", () => { | |
| 44 | + | assert.deepEqual(parseListing(" integration:alerts-webhook "), { kind: "integration", id: "alerts-webhook" }); | |
| 45 | + | assert.equal(parseListing("agent:"), null); | |
| 46 | + | assert.equal(parseListing("agent:Engineering"), null); | |
| 47 | + | assert.equal(parseListing("agent:a b"), null); | |
| 48 | + | assert.equal(cleanRequestNote(" for on-call "), "for on-call"); | |
| 49 | + | assert.equal(cleanRequestNote(" "), null); | |
| 50 | + | assert.equal(cleanRequestNote("x".repeat(400))?.length, 280); | |
| 51 | + | assert.equal(cleanRequestNote(null), null); | |
| 52 | + | }); | |
| 53 | + | ||
| 54 | + | test("a member's request is kept, and asking twice is one request", async () => { | |
| 55 | + | const db = fakeD1(); | |
| 56 | + | const listing = findListing("agent:qa")!; | |
| 57 | + | const first = await openRequest(db, ws, nextId(), listing, ana, "Flaky checks", at); | |
| 58 | + | assert.ok(first.ok); | |
| 59 | + | assert.equal(first.value.status, "open"); | |
| 60 | + | assert.equal(first.value.name, "QA Engineer"); | |
| 61 | + | assert.equal(first.value.note, "Flaky checks"); | |
| 62 | + | const again = await openRequest(db, ws, nextId(), listing, ana, null, later(0.1)); | |
| 63 | + | assert.equal(again.ok, false); | |
| 64 | + | assert.equal(!again.ok && again.error.code, "conflict"); | |
| 65 | + | // Someone else asking for the same thing is their own request. | |
| 66 | + | assert.ok((await openRequest(db, ws, nextId(), listing, bo, null, at)).ok); | |
| 67 | + | }); | |
| 68 | + | ||
| 69 | + | test("an owner sees every request; a member sees their own", async () => { | |
| 70 | + | const db = fakeD1(); | |
| 71 | + | await openRequest(db, ws, nextId(), findListing("agent:qa")!, ana, null, at); | |
| 72 | + | await openRequest(db, ws, nextId(), findListing("integration:linear")!, bo, null, later(0.01)); | |
| 73 | + | await openRequest(db, "wsp_other", nextId(), findListing("agent:docs")!, ana, null, at); | |
| 74 | + | assert.deepEqual((await listRequests(db, ws, owner, true, at)).map((r) => r.listing), ["integration:linear", "agent:qa"]); | |
| 75 | + | assert.deepEqual((await listRequests(db, ws, ana, false, at)).map((r) => r.listing), ["agent:qa"]); | |
| 76 | + | }); | |
| 77 | + | ||
| 78 | + | test("answering a request: once, by status, and answered ones age out", async () => { | |
| 79 | + | const db = fakeD1(); | |
| 80 | + | const opened = await openRequest(db, ws, nextId(), findListing("integration:sentry")!, ana, null, at); | |
| 81 | + | assert.ok(opened.ok); | |
| 82 | + | const answered = await resolveRequest(db, ws, opened.value.id, "declined", owner, later(1)); | |
| 83 | + | assert.ok(answered.ok); | |
| 84 | + | assert.equal(answered.value.request.status, "declined"); | |
| 85 | + | assert.equal(answered.value.request.resolved_by, "chase"); | |
| 86 | + | assert.equal(answered.value.requested_by_id, "usr_ana"); | |
| 87 | + | const twice = await resolveRequest(db, ws, opened.value.id, "done", owner, later(1)); | |
| 88 | + | assert.equal(!twice.ok && twice.error.code, "conflict"); | |
| 89 | + | const elsewhere = await resolveRequest(db, "wsp_other", opened.value.id, "done", owner, later(1)); | |
| 90 | + | assert.equal(!elsewhere.ok && elsewhere.error.code, "not_found"); | |
| 91 | + | // Turned down, the person may ask again. | |
| 92 | + | assert.ok((await openRequest(db, ws, nextId(), findListing("integration:sentry")!, ana, null, later(2))).ok); | |
| 93 | + | // Answered requests stay listed for 30 days. | |
| 94 | + | assert.equal((await listRequests(db, ws, owner, true, later(20))).length, 2); | |
| 95 | + | assert.equal((await listRequests(db, ws, owner, true, later(40))).length, 1, "only the open one"); | |
| 96 | + | }); | |
| 97 | + | ||
| 98 | + | test("adding a listing answers every open request for it", async () => { | |
| 99 | + | const db = fakeD1(); | |
| 100 | + | await openRequest(db, ws, nextId(), findListing("agent:support")!, ana, null, at); | |
| 101 | + | await openRequest(db, ws, nextId(), findListing("agent:support")!, bo, null, at); | |
| 102 | + | await openRequest(db, ws, nextId(), findListing("agent:sales")!, bo, null, at); | |
| 103 | + | const answered = await resolveListing(db, ws, "agent:support", owner, later(1)); | |
| 104 | + | assert.deepEqual(answered.map((a) => a.requested_by_id).sort(), ["usr_ana", "usr_bo"]); | |
| 105 | + | assert.ok(answered.every((a) => a.request.status === "done")); | |
| 106 | + | const open = (await listRequests(db, ws, owner, true, later(1))).filter((r) => r.status === "open"); | |
| 107 | + | assert.deepEqual(open.map((r) => r.listing), ["agent:sales"]); | |
| 108 | + | }); | |
| 109 | + | ||
| 110 | + | test("a person keeps at most so many requests open", async () => { | |
| 111 | + | const db = fakeD1(); | |
| 112 | + | // The catalog has fewer listings than the limit, so the same few are asked, answered and asked again. | |
| 113 | + | const listings = ["agent:engineering", "agent:qa", "agent:operations", "agent:docs", "agent:product", "agent:support", "agent:sales", "integration:sentry", "integration:datadog", "integration:linear", "integration:jira", "integration:github", "integration:webhooks", "integration:alerts-webhook", "integration:anthropic", "integration:openai", "integration:gemini", "integration:xai", "integration:mistral", "integration:deepseek"]; | |
| 114 | + | assert.equal(listings.length, MAX_OPEN_REQUESTS); | |
| 115 | + | for (const ref of listings) assert.ok((await openRequest(db, ws, nextId(), findListing(ref)!, ana, null, at)).ok, ref); | |
| 116 | + | const over = await openRequest(db, ws, nextId(), findListing("integration:groq")!, ana, null, at); | |
| 117 | + | assert.equal(!over.ok && over.error.code, "limit"); | |
| 118 | + | }); | |
| 119 | + | ||
| 120 | + | test("what the asker is told, and where it leads", () => { | |
| 121 | + | const base = { id: "ins_1", listing: "agent:qa", kind: "agent" as const, name: "QA Engineer", note: null, requested_by: "ana", requested_at: at.toISOString(), resolved_by: "chase", resolved_at: at.toISOString() }; | |
| 122 | + | assert.equal(answerLine({ ...base, status: "done" }, "@chase").title, "QA Engineer was added to the workspace"); | |
| 123 | + | assert.match(answerLine({ ...base, status: "declined" }, "@chase").title, /@chase turned down your request for QA Engineer/); | |
| 124 | + | assert.equal(listingPath("acme", "agent:qa"), "/acme/-/marketplace/agents/qa"); | |
| 125 | + | assert.equal(listingPath("acme", "integration:sentry"), "/acme/-/marketplace/integrations"); | |
| 126 | + | }); |
| 1 | + | /** | |
| 2 | + | * The Marketplace's install requests (docs.g1t.sh/guides/marketplace/): a | |
| 3 | + | * member asks the workspace's owners to add an agent from the catalog or | |
| 4 | + | * an 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 role from the agent | |
| 8 | + | * catalog (./templates.ts), or a connector the catalog marks available for | |
| 9 | + | * a workspace (@g1t/contracts/connectors). Who is told, and how, is | |
| 10 | + | * index.ts's; this file is the rules and the table, tested on SQLite. | |
| 11 | + | */ | |
| 12 | + | import type { FailureCode, InstallRequest, InstallRequestStatus, ListingKind, Result } from "@g1t/contracts"; | |
| 13 | + | import { CONNECTORS } from "@g1t/contracts/connectors"; | |
| 14 | + | import { ANSWERED_REQUESTS_DAYS, MAX_OPEN_REQUESTS, extensionById, listingRef, parseListing } from "@g1t/contracts/marketplace"; | |
| 15 | + | ||
| 16 | + | // Plain values rather than @g1t/contracts' helpers, so this file runs under Node's tests. | |
| 17 | + | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); | |
| 18 | + | const fail = (code: FailureCode, message: string): Result<never> => ({ ok: false, error: { code, message } }); | |
| 19 | + | ||
| 20 | + | import { TEMPLATES } from "./templates.ts"; | |
| 21 | + | ||
| 22 | + | /** Something the workspace can add, as a request names it. */ | |
| 23 | + | export type Listing = { ref: string; kind: ListingKind; id: string; name: string }; | |
| 24 | + | ||
| 25 | + | /** The listing `value` names, when it is one that can be added today; null otherwise. */ | |
| 26 | + | export function findListing(value: unknown): Listing | null { | |
| 27 | + | const parsed = parseListing(value); | |
| 28 | + | if (!parsed) return null; | |
| 29 | + | if (parsed.kind === "agent") { | |
| 30 | + | const template = TEMPLATES.find((t) => t.id === parsed.id); | |
| 31 | + | return template ? { ref: listingRef("agent", template.id), kind: "agent", id: template.id, name: template.title } : null; | |
| 32 | + | } | |
| 33 | + | if (parsed.kind === "extension") { | |
| 34 | + | const extension = extensionById(parsed.id); | |
| 35 | + | return extension?.status === "available" ? { ref: listingRef("extension", extension.id), kind: "extension", id: extension.id, name: extension.name } : null; | |
| 36 | + | } | |
| 37 | + | const connector = CONNECTORS.find((c) => c.id === parsed.id); | |
| 38 | + | if (!connector || connector.status !== "available" || !connector.scopes.includes("workspace")) return null; | |
| 39 | + | return { ref: listingRef("integration", connector.id), kind: "integration", id: connector.id, name: connector.name }; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type Row = { | |
| 43 | + | id: string; | |
| 44 | + | listing: string; | |
| 45 | + | name: string; | |
| 46 | + | note: string | null; | |
| 47 | + | requested_by: string; | |
| 48 | + | requested_by_id: string; | |
| 49 | + | requested_at: string; | |
| 50 | + | status: string; | |
| 51 | + | resolved_by: string | null; | |
| 52 | + | resolved_at: string | null; | |
| 53 | + | }; | |
| 54 | + | ||
| 55 | + | const COLUMNS = "id, listing, name, note, requested_by, requested_by_id, requested_at, status, resolved_by, resolved_at"; | |
| 56 | + | ||
| 57 | + | export function toRequest(row: Row): InstallRequest { | |
| 58 | + | return { | |
| 59 | + | id: row.id, | |
| 60 | + | listing: row.listing, | |
| 61 | + | kind: parseListing(row.listing)?.kind ?? "agent", | |
| 62 | + | name: row.name, | |
| 63 | + | note: row.note, | |
| 64 | + | requested_by: row.requested_by, | |
| 65 | + | requested_at: row.requested_at, | |
| 66 | + | status: (["open", "done", "declined"].includes(row.status) ? row.status : "open") as InstallRequestStatus, | |
| 67 | + | resolved_by: row.resolved_by, | |
| 68 | + | resolved_at: row.resolved_at, | |
| 69 | + | }; | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /** Who is asking or answering. */ | |
| 73 | + | export type Person = { id: string; username: string }; | |
| 74 | + | ||
| 75 | + | /** | |
| 76 | + | * Requests as `viewer` sees them: every one in the workspace for an owner, | |
| 77 | + | * their own for anyone else. Open ones first, then the latest; answered | |
| 78 | + | * ones for `ANSWERED_REQUESTS_DAYS`. At most 200. | |
| 79 | + | */ | |
| 80 | + | export async function listRequests(db: D1Database, workspaceId: string, viewer: Person, owner: boolean, now = new Date()): Promise<InstallRequest[]> { | |
| 81 | + | const since = new Date(now.getTime() - ANSWERED_REQUESTS_DAYS * 86_400_000).toISOString(); | |
| 82 | + | const mine = owner ? "" : " AND requested_by_id = ?3"; | |
| 83 | + | const statement = db.prepare( | |
| 84 | + | `SELECT ${COLUMNS} FROM install_requests | |
| 85 | + | WHERE workspace_id = ?1 AND (status = 'open' OR resolved_at >= ?2)${mine} | |
| 86 | + | ORDER BY status = 'open' DESC, requested_at DESC LIMIT 200`, | |
| 87 | + | ); | |
| 88 | + | const bound = owner ? statement.bind(workspaceId, since) : statement.bind(workspaceId, since, viewer.id); | |
| 89 | + | const { results } = await bound.all<Row>(); | |
| 90 | + | return results.map(toRequest); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /** | |
| 94 | + | * Opens a request for `listing`, as `id`. A conflict when the person has | |
| 95 | + | * one open for it already; a limit when they have `MAX_OPEN_REQUESTS` open. | |
| 96 | + | */ | |
| 97 | + | export async function openRequest( | |
| 98 | + | db: D1Database, | |
| 99 | + | workspaceId: string, | |
| 100 | + | id: string, | |
| 101 | + | listing: Listing, | |
| 102 | + | by: Person, | |
| 103 | + | note: string | null, | |
| 104 | + | now = new Date(), | |
| 105 | + | ): Promise<Result<InstallRequest>> { | |
| 106 | + | const open = await db | |
| 107 | + | .prepare("SELECT COUNT(*) AS n FROM install_requests WHERE workspace_id = ? AND requested_by_id = ? AND status = 'open'") | |
| 108 | + | .bind(workspaceId, by.id) | |
| 109 | + | .first<{ n: number }>(); | |
| 110 | + | if ((open?.n ?? 0) >= MAX_OPEN_REQUESTS) { | |
| 111 | + | return fail("limit", `You have ${MAX_OPEN_REQUESTS} requests waiting on an owner. Ask again once some are answered.`); | |
| 112 | + | } | |
| 113 | + | const row: Row = { | |
| 114 | + | id, | |
| 115 | + | listing: listing.ref, | |
| 116 | + | name: listing.name, | |
| 117 | + | note, | |
| 118 | + | requested_by: by.username, | |
| 119 | + | requested_by_id: by.id, | |
| 120 | + | requested_at: now.toISOString(), | |
| 121 | + | status: "open", | |
| 122 | + | resolved_by: null, | |
| 123 | + | resolved_at: null, | |
| 124 | + | }; | |
| 125 | + | // The unique index on open requests turns a second press into nothing. | |
| 126 | + | const inserted = await db | |
| 127 | + | .prepare( | |
| 128 | + | `INSERT INTO install_requests (id, workspace_id, listing, name, note, requested_by, requested_by_id, requested_at, status) | |
| 129 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open') ON CONFLICT DO NOTHING RETURNING id`, | |
| 130 | + | ) | |
| 131 | + | .bind(row.id, workspaceId, row.listing, row.name, row.note, row.requested_by, row.requested_by_id, row.requested_at) | |
| 132 | + | .first<{ id: string }>(); | |
| 133 | + | if (!inserted) return fail("conflict", `You've already asked for ${listing.name}. The workspace's owners have your request.`); | |
| 134 | + | return ok(toRequest(row)); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /** A request answered, with who asked, for telling them. */ | |
| 138 | + | export type Answered = { request: InstallRequest; requested_by_id: string }; | |
| 139 | + | ||
| 140 | + | /** An owner adds (`done`) or turns down (`declined`) an open request. */ | |
| 141 | + | export async function resolveRequest( | |
| 142 | + | db: D1Database, | |
| 143 | + | workspaceId: string, | |
| 144 | + | id: string, | |
| 145 | + | status: Exclude<InstallRequestStatus, "open">, | |
| 146 | + | by: Person, | |
| 147 | + | now = new Date(), | |
| 148 | + | ): Promise<Result<Answered>> { | |
| 149 | + | if (status !== "done" && status !== "declined") return fail("invalid", "Answer a request with done or declined."); | |
| 150 | + | const row = await db | |
| 151 | + | .prepare( | |
| 152 | + | `UPDATE install_requests SET status = ?, resolved_by = ?, resolved_at = ? | |
| 153 | + | WHERE id = ? AND workspace_id = ? AND status = 'open' RETURNING ${COLUMNS}`, | |
| 154 | + | ) | |
| 155 | + | .bind(status, by.username, now.toISOString(), id, workspaceId) | |
| 156 | + | .first<Row>(); | |
| 157 | + | if (row) return ok({ request: toRequest(row), requested_by_id: row.requested_by_id }); | |
| 158 | + | const exists = await db.prepare("SELECT status FROM install_requests WHERE id = ? AND workspace_id = ?").bind(id, workspaceId).first<{ status: string }>(); | |
| 159 | + | return exists ? fail("conflict", "That request was already answered.") : fail("not_found", "There is no such request."); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | /** | |
| 163 | + | * Every open request for `listing`, marked added: an owner added it, so | |
| 164 | + | * whoever asked for it has what they asked for. | |
| 165 | + | */ | |
| 166 | + | export async function resolveListing(db: D1Database, workspaceId: string, listing: string, by: Person, now = new Date()): Promise<Answered[]> { | |
| 167 | + | const { results } = await db | |
| 168 | + | .prepare( | |
| 169 | + | `UPDATE install_requests SET status = 'done', resolved_by = ?, resolved_at = ? | |
| 170 | + | WHERE workspace_id = ? AND listing = ? AND status = 'open' RETURNING ${COLUMNS}`, | |
| 171 | + | ) | |
| 172 | + | .bind(by.username, now.toISOString(), workspaceId, listing) | |
| 173 | + | .all<Row>(); | |
| 174 | + | return results.map((row) => ({ request: toRequest(row), requested_by_id: row.requested_by_id })); | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | /** Where a request is looked at in the workspace `slug`. */ | |
| 178 | + | export function requestsPath(slug: string): string { | |
| 179 | + | return `/${slug}/-/marketplace/requests`; | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /** Where the thing a request asked for is, once added. */ | |
| 183 | + | export function listingPath(slug: string, listing: string): string { | |
| 184 | + | const parsed = parseListing(listing); | |
| 185 | + | if (parsed?.kind === "agent") return `/${slug}/-/marketplace/agents/${parsed.id}`; | |
| 186 | + | if (parsed?.kind === "extension") return `/${slug}/-/marketplace/extensions/${parsed.id}`; | |
| 187 | + | return `/${slug}/-/marketplace/integrations`; | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /** What the person who asked is told when their request is answered. */ | |
| 191 | + | export function answerLine(request: InstallRequest, by: string): { title: string; body: string } { | |
| 192 | + | if (request.status === "declined") { | |
| 193 | + | return { title: `${by} turned down your request for ${request.name}`, body: "Ask them in chat if you want to know why." }; | |
| 194 | + | } | |
| 195 | + | return { | |
| 196 | + | title: `${request.name} was added to the workspace`, | |
| 197 | + | body: request.kind === "agent" ? `${by} added it from the agent catalog.` : request.kind === "extension" ? `${by} installed it.` : `${by} connected it.`, | |
| 198 | + | }; | |
| 199 | + | } |