Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
410 linesCodeBlameRaw
1/**
2 * Deprecated: g1t no longer runs agents of its own. Kept only until
3 * apps/mobile drops its Customize screens, which import it; delete it
4 * afterwards.
5 */
6/**
7 * The Marketplace: what adds functionality to a workspace, the installs it
8 * has, and members' requests to add things. Extensions add pages, data and
9 * cards; integrations connect what a team already uses so agents can work
10 * with it. Agents are not listed here: Agents mode has templates to start
11 * one from. Anyone in a workspace browses the Marketplace; only owners add
12 * things. A member asks instead, and the request reaches every owner as a
13 * notification.
14 *
15 * Every listing has a reference, `<kind>:<id>`:
16 *
17 * - `integration:<connector>`: a connector from the catalog
18 * (./connectors.ts); the integrations service keeps the connection;
19 * - `extension:<id>`: an extension, described by its manifest
20 * (`ExtensionManifest`): who publishes it, where its source is, the
21 * scopes it asks for, the domains its data goes to, the page it shows
22 * inside g1t, and its pricing. An install (`ExtensionInstall`) pins a
23 * version and can be switched off at once.
24 *
25 * Extensions are shared the way workflow actions are: a public
26 * repository with a manifest, where tagging a release publishes a
27 * version, and an install keeps the version it was installed at until an
28 * owner takes an update. Nothing is paid yet, but listings carry a
29 * pricing field and installs a plan, so paid listings are billing work.
30 *
31 * The agents service keeps installs (`extension_installs`) and requests
32 * (`install_requests`). Wire shapes are snake_case. No imports, so services
33 * test it under Node.
34 */
35
36/**
37 * What a listing adds to a workspace: a g1t integration, a plugin
38 * (`plugin:<name@marketplace>`), a skill from a marketplace
39 * (`skill:<name@marketplace>/<skill>`), a connector from the directory
40 * (`connector:<id>`), or an extension (g1t's own, announced). Requests
41 * (./customize.ts) name any of them.
42 */
43export type ListingKind = "integration" | "extension" | "plugin" | "skill" | "connector";
44
45export const LISTING_KINDS: readonly ListingKind[] = ["integration", "extension", "plugin", "skill", "connector"];
46
47/** Whether a listing can be added today, or is planned. */
48export type ListingStatus = "available" | "soon";
49
50/**
51 * Where an extension's server code runs: on g1t (official, verified and
52 * internal publishers, and community code once it has an isolated
53 * sandbox), or on the publisher's own servers, reached over HTTPS.
54 */
55export type ExtensionRuntime = "hosted" | "connected";
56
57/** The workspace areas an extension can add to. */
58export type ExtensionAdds = {
59 /** Its pages, in its sidebar, in order. */
60 pages: string[];
61 /** Agent roles it brings: templates, configured like any other agent. */
62 agent_roles: string[];
63 /** Cards it shows in chat and elsewhere, in words. */
64 cards: string[];
65 /** Tools agents can call. */
66 tools: string[];
67 /** Notification kinds it sends. */
68 notifications: string[];
69};
70
71/**
72 * An extension, as its manifest describes it. A published one comes from
73 * `.g1t/extension.json` in its source repository at a tag; first-party
74 * listings not yet published (`status: "soon"`) have no source yet.
75 */
76export type ExtensionManifest = {
77 /** Lowercase letters, digits and hyphens: `support`. */
78 id: string;
79 name: string;
80 /** One line, for cards. */
81 tagline: string;
82 /** A paragraph, for its page. */
83 description: string;
84 category: string;
85 publisher: { name: string; tier: ListingTier };
86 status: ListingStatus;
87 /** Its public repository on g1t and the tag a version was published from; null until it is published. */
88 source: { repo: string; tag: string } | null;
89 /** The version a tag published (`1.4.0`); null until it is published. */
90 version: string | null;
91 runtime: ExtensionRuntime;
92 /** The g1t scopes its token asks for (./scopes.ts), shown in plain words before install. */
93 scopes: string[];
94 /** What it can do, in plain words, as the install screen lists it. */
95 permissions: string[];
96 /**
97 * Every host outside g1t its data goes to. The install screen says
98 * "Data leaves g1t to …" for each; calls anywhere else are refused.
99 * Empty: its data stays in g1t.
100 */
101 domains: string[];
102 /**
103 * The system outside g1t it bridges, in words (`the CRM you connect`),
104 * when its data goes wherever the workspace points it rather than to
105 * fixed domains. The install screen says so. Absent or null: none.
106 */
107 bridges?: string | null;
108 /**
109 * Its page inside g1t: a path served from the user-content domain
110 * (g1tusercontent.com, or a self-hosted instance's own), loaded in a
111 * sandboxed frame that reaches g1t only through the bridge. Null: no page.
112 */
113 ui: { entry: string } | null;
114 adds: ExtensionAdds;
115 /** Empty while every listing is free; a later price list goes here. */
116 pricing: null;
117};
118
119/** An extension installed in a workspace. */
120export type ExtensionInstall = {
121 id: string;
122 /** `extension:<id>`. */
123 listing: string;
124 /** The version installed; updates are offered, never forced. */
125 version: string;
126 /** The plan it is on: `free` until listings have prices. */
127 plan: string;
128 installed_by: string;
129 /** RFC 3339. */
130 installed_at: string;
131 /** Off: the kill switch. Its token stops working and its page doesn't load. */
132 enabled: boolean;
133 /** Who last turned it off, and when; null while it is on. */
134 disabled_by: string | null;
135 disabled_at: string | null;
136 /** Its monthly spend cap, in micro-dollars; null: the workspace's limit applies. */
137 budget_monthly_micros: number | null;
138};
139
140/** The plan an install records while nothing is paid. */
141export const FREE_PLAN = "free";
142
143/** A manifest's checks, as publishing applies them; `knownScope` says which scopes exist (./scopes.ts `isScope`). */
144export function checkManifest(raw: unknown, knownScope: (scope: string) => boolean): { ok: true; value: ExtensionManifest } | { ok: false; message: string } {
145 const m = raw as Partial<ExtensionManifest> | null;
146 const bad = (message: string) => ({ ok: false as const, message });
147 if (!m || typeof m !== "object") return bad("The manifest isn't a JSON object.");
148 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.");
149 for (const field of ["name", "tagline", "description", "category"] as const) {
150 if (typeof m[field] !== "string" || !m[field]!.trim()) return bad(`\`${field}\` is required.`);
151 }
152 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.");
153 if (m.status !== "available" && m.status !== "soon") return bad("`status` is available or soon.");
154 if (m.runtime !== "hosted" && m.runtime !== "connected") return bad("`runtime` is hosted or connected.");
155 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.");
156 if (m.status === "available" && (!m.source || !m.version)) return bad("A published extension names its source repository, tag and version.");
157 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.");
158 if (!Array.isArray(m.scopes) || m.scopes.some((scope) => typeof scope !== "string" || !knownScope(scope))) return bad("Every scope must be one g1t has.");
159 if (!Array.isArray(m.permissions) || m.permissions.length === 0) return bad("List what it can do in `permissions`, in plain words.");
160 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.");
161 if (m.runtime === "connected" && m.domains.length === 0) return bad("A connected extension declares the domains it runs on.");
162 if (m.bridges != null && (typeof m.bridges !== "string" || !m.bridges.trim())) return bad("`bridges` names the system it connects to, in words.");
163 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.");
164 if (!m.adds || !["pages", "cards", "agent_roles", "tools", "notifications"].every((k) => Array.isArray((m.adds as Record<string, unknown>)[k]))) return bad("`adds` lists pages, cards, agent_roles, tools and notifications.");
165 if (m.pricing !== null) return bad("Listings are free for now: `pricing` is null.");
166 return { ok: true, value: m as ExtensionManifest };
167}
168
169function isHost(value: string): boolean {
170 return /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(value);
171}
172
173/** What the install screen says about where an extension's data goes. */
174export function dataDisclosure(manifest: Pick<ExtensionManifest, "domains" | "bridges">): string {
175 const to = [...manifest.domains, ...(manifest.bridges ? [manifest.bridges] : [])];
176 if (to.length === 0) return "Its data stays in g1t.";
177 return `Data leaves g1t to ${to.join(", ")}.`;
178}
179
180/** Where an extension's page loads from: its entry on the user-content origin, under its id and version. */
181export function extensionFrameUrl(manifest: Pick<ExtensionManifest, "id" | "version" | "ui">, usercontentOrigin: string): string | null {
182 if (!manifest.ui || !manifest.version) return null;
183 return `${usercontentOrigin.replace(/\/+$/, "")}/x/${manifest.id}/${manifest.version}${manifest.ui.entry}`;
184}
185
186/** 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. */
187export function versionOfTag(tag: string): string | null {
188 const match = /^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/.exec(tag.trim());
189 return match ? match[1]! : null;
190}
191
192/** What every first-party listing shares before its first release. */
193const UNRELEASED = {
194 publisher: { name: "g1t", tier: "official" as const },
195 status: "soon" as const,
196 source: null,
197 version: null,
198 runtime: "hosted" as const,
199 ui: { entry: "/index.html" },
200 pricing: null,
201};
202
203/**
204 * g1t's own extensions. None is published yet: each is listed so people
205 * see what is coming, and can't be installed until its first release. The
206 * last three are connected systems: each bridges a system a team already
207 * runs, so agents work across it and g1t together.
208 */
209export const FIRST_PARTY_EXTENSIONS: ExtensionManifest[] = [
210 {
211 ...UNRELEASED,
212 id: "mail",
213 name: "Mail",
214 tagline: "Email on your own domain, with shared inboxes agents work in.",
215 description:
216 "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.",
217 category: "Communication",
218 scopes: ["notifications:write", "artifacts:read"],
219 permissions: ["Host email for your domain", "Let agents read shared inboxes and draft replies", "Send only with approval or a rule you set"],
220 domains: [],
221 adds: { pages: ["Inbox", "Shared inboxes", "Sent", "Rules for agents"], cards: ["Email threads in chat"], agent_roles: [], tools: ["mail", "thread"], notifications: ["Drafts to approve"] },
222 },
223 {
224 ...UNRELEASED,
225 id: "support",
226 name: "Support",
227 tagline: "Customer conversations an agent answers from your docs, escalating the rest.",
228 description:
229 "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.",
230 category: "Customers",
231 scopes: ["issues:write", "artifacts:read", "notifications:write"],
232 permissions: ["Read and reply to support email, with your approval", "Store conversations in workspace data", "Open issues from bug reports"],
233 domains: [],
234 adds: {
235 pages: ["Conversations", "Escalations", "Macros", "Knowledge"],
236 cards: ["Customer cards in chat", "Bug reports as issues in Code"],
237 agent_roles: ["Support Specialist"],
238 tools: ["conversation", "macro"],
239 notifications: ["Escalations"],
240 },
241 },
242 {
243 ...UNRELEASED,
244 id: "crm",
245 name: "CRM",
246 tagline: "Accounts, deals and a pipeline, with agents that keep it current.",
247 description:
248 "Accounts and contacts, deals by stage and a weekly forecast, kept as workspace data. Agents log calls and email, draft follow-ups for a person to send, and flag deals that have gone quiet.",
249 category: "Customers",
250 scopes: ["artifacts:write", "notifications:write"],
251 permissions: ["Store accounts, contacts and deals in workspace data", "Read email threads with customers in shared inboxes", "Draft follow-ups that a person sends"],
252 domains: [],
253 adds: { pages: ["Pipeline", "Deals", "Accounts", "Forecast"], cards: ["Account cards in chat"], agent_roles: ["Sales Ops"], tools: ["deal", "account"], notifications: ["Deals gone quiet"] },
254 },
255 {
256 ...UNRELEASED,
257 id: "recruiting",
258 name: "Recruiting",
259 tagline: "Openings, candidates and interview loops. Agents screen, people decide.",
260 description:
261 "A pipeline board, scorecards and scheduling. A recruiting agent screens applicants against your scorecard and books interview loops; offers and decisions stay with people.",
262 category: "People",
263 scopes: ["artifacts:write", "notifications:write"],
264 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"],
265 domains: [],
266 adds: {
267 pages: ["Pipeline", "Openings", "Candidates", "Interviews", "Offers"],
268 cards: ["Candidate cards in chat"],
269 agent_roles: ["Recruiter"],
270 tools: ["candidate", "opening"],
271 notifications: ["Offers to sign"],
272 },
273 },
274 {
275 ...UNRELEASED,
276 id: "on-call",
277 name: "On-call",
278 tagline: "Rotations, pages and who is on call now.",
279 description: "Rotations and schedules, pages through notifications, and an incident channel an operations agent keeps a timeline in.",
280 category: "Engineering",
281 scopes: ["notifications:write", "issues:read"],
282 permissions: ["Store rotations in workspace data", "Page people through their notifications", "Read issues an incident links to"],
283 domains: [],
284 adds: { pages: ["Rotations", "Pages", "Incidents"], cards: ["Who's on call, in chat"], agent_roles: [], tools: ["rotation", "page"], notifications: ["Pages", "Your shift"] },
285 },
286 {
287 ...UNRELEASED,
288 id: "helpdesk-bridge",
289 name: "Helpdesk bridge",
290 tagline: "Work tickets from the helpdesk you already run, next to your code and chat.",
291 description:
292 "Connects the helpdesk your team already uses. Its tickets show in g1t with the customer and the conversation; agents draft replies there for a person to send, and a bug report becomes an issue in Code that stays linked to its ticket.",
293 category: "Connected systems",
294 scopes: ["issues:write", "notifications:write"],
295 permissions: ["Read tickets and customers in the helpdesk you connect", "Draft replies there, sent by a person or a rule you set", "Open issues in Code linked to a ticket"],
296 domains: [],
297 bridges: "the helpdesk you connect",
298 adds: { pages: ["Tickets", "Linked issues"], cards: ["Ticket cards in chat"], agent_roles: [], tools: ["ticket"], notifications: ["Tickets assigned to you"] },
299 },
300 {
301 ...UNRELEASED,
302 id: "crm-bridge",
303 name: "CRM bridge",
304 tagline: "The accounts and deals in the CRM you already use, in g1t and in chat.",
305 description:
306 "Connects the CRM your team already uses. Accounts and deals show in g1t and in chat, and agents log calls, update stages and draft follow-ups there, each change in the audit log.",
307 category: "Connected systems",
308 scopes: ["notifications:write"],
309 permissions: ["Read accounts, contacts and deals in the CRM you connect", "Log activity and update deal stages there", "Draft follow-ups that a person sends"],
310 domains: [],
311 bridges: "the CRM you connect",
312 adds: { pages: ["Accounts", "Deals"], cards: ["Account cards in chat"], agent_roles: [], tools: ["account", "deal"], notifications: ["Deals gone quiet"] },
313 },
314 {
315 ...UNRELEASED,
316 id: "erp-bridge",
317 name: "ERP bridge",
318 tagline: "Orders, invoices and stock from your ERP, for agents that answer and reconcile.",
319 description:
320 "Connects the ERP your business runs on. Agents look up orders, invoices and stock to answer questions in chat and support, and reconcile what doesn't match. Anything that changes money or stock waits for a person's approval.",
321 category: "Connected systems",
322 scopes: ["notifications:write"],
323 permissions: ["Read orders, invoices and stock in the ERP you connect", "Propose changes there, each approved by a person"],
324 domains: [],
325 bridges: "the ERP you connect",
326 adds: { pages: ["Orders", "Invoices", "Approvals"], cards: ["Order cards in chat"], agent_roles: [], tools: ["order", "invoice"], notifications: ["Changes to approve"] },
327 },
328];
329
330/** A first-party extension by id. */
331export function extensionById(id: string): ExtensionManifest | undefined {
332 return FIRST_PARTY_EXTENSIONS.find((extension) => extension.id === id);
333}
334
335/**
336 * Who stands behind a listing: g1t itself, a reviewed publisher, anyone,
337 * or the workspace's own people.
338 */
339export type ListingTier = "official" | "verified" | "community" | "internal";
340
341export const LISTING_TIERS: readonly ListingTier[] = ["official", "verified", "community", "internal"];
342
343/**
344 * Who stands behind every integration: the connector catalog
345 * (./connectors.ts) is g1t's own, so each connector is Official.
346 */
347export const CONNECTOR_PUBLISHER: { name: string; tier: ListingTier } = { name: "g1t", tier: "official" };
348
349/** A listing's reference, split: `integration:sentry` is `{ kind: "integration", id: "sentry" }`. */
350export type ListingRef = { kind: ListingKind; id: string };
351
352/** Where a request stands: waiting on an owner, added, or turned down. */
353export type InstallRequestStatus = "open" | "done" | "declined";
354
355export const INSTALL_REQUEST_STATUSES: readonly InstallRequestStatus[] = ["open", "done", "declined"];
356
357/** A member's request that the workspace add a listing. */
358export type InstallRequest = {
359 id: string;
360 /** `<kind>:<id>`. */
361 listing: string;
362 kind: ListingKind;
363 /** The listing's name when it was asked for, such as `Sentry` or `Support`. */
364 name: string;
365 /** Why they want it, in their words, or null. */
366 note: string | null;
367 /** Who asked, by username. */
368 requested_by: string;
369 /** RFC 3339. */
370 requested_at: string;
371 status: InstallRequestStatus;
372 /** The owner who added it or turned it down, by username. */
373 resolved_by: string | null;
374 /** RFC 3339. */
375 resolved_at: string | null;
376};
377
378/**
379 * Requests as one person sees them: an owner, every request in the
380 * workspace (`can_resolve`); anyone else, their own. Open ones first, then
381 * the latest; answered ones for 30 days.
382 */
383export type InstallRequests = { requests: InstallRequest[]; can_resolve: boolean };
384
385/** The longest note a request carries. */
386export const MAX_REQUEST_NOTE = 280;
387/** The most requests one person keeps open in a workspace. */
388export const MAX_OPEN_REQUESTS = 20;
389/** How long an answered request stays listed. */
390export const ANSWERED_REQUESTS_DAYS = 30;
391
392/** A listing's reference: `integration:sentry`. */
393export function listingRef(kind: ListingKind, id: string): string {
394 return `${kind}:${id}`;
395}
396
397/** A reference split into kind and id; null when it is not one. */
398export function parseListing(value: unknown): ListingRef | null {
399 if (typeof value !== "string") return null;
400 const match = /^(integration|extension|connector):([a-z0-9][a-z0-9_-]{0,63})$|^(plugin|skill):([A-Za-z0-9][A-Za-z0-9._@/-]{0,199})$/.exec(value.trim());
401 if (match && match[3]) return { kind: match[3] as ListingKind, id: match[4]! };
402 return match ? { kind: match[1] as ListingKind, id: match[2]! } : null;
403}
404
405/** A note as kept: trimmed, at most `MAX_REQUEST_NOTE` characters, null when empty. */
406export function cleanRequestNote(value: unknown): string | null {
407 if (typeof value !== "string") return null;
408 const note = value.replace(/\s+/g, " ").trim().slice(0, MAX_REQUEST_NOTE);
409 return note || null;
410}