Skip to content

g1t/services/context/src/index.ts

1,429 lines66,800 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1/**
2 * The context service: the context hub's catalog, search and scorecards.
3 *
4 * - **Catalog.** What a workspace builds and runs, as entities (projects,
5 * apps, APIs, packages, languages, owners, environments, integrations,
6 * docs) and relations between them. Built by itself: on every push to a
7 * project's default branch it reads the project's manifests and docs
8 * whose blobs changed (at most `MAX_READS` files a push) and joins them
9 * with what projects, deployments, identity and integrations know.
10 * Rebuilding is idempotent: entity ids are hashes of what they are.
11 * - **Search.** Docs, catalog entities, issues, pull requests and kept
12 * memory, embedded with Workers AI into a Vectorize index whose rows
13 * carry their workspace, project and whether they are private, so a
14 * query reads only what its reader may. Matching words in D1 answers
15 * when the index cannot, and fills in after it.
16 * - **Backfill.** Once per workspace (and again on request): the catalog
17 * for every project, memory candidates from docs, manifests and the last
18 * merged pull requests, and the search index filled. One queued job per
19 * project, capped and metered.
20 * - **Run context.** The Context section every g1t agent run starts with.
21 *
22 * Reached through service bindings: `POST /rpc/<method>`.
23 */
24
25import {
26 type Backfill,
27 type CaptureItem,
28 type Catalog,
29 type ContextStatus,
30 type Entity,
31 type EntityDetail,
32 type EntityKind,
33 ENTITY_KINDS,
34 type G1tEvent,
35 type Memory,
36 type Project,
37 type ProjectDeploys,
38 type RelationKind,
39 type RunContext,
40 type Scorecard,
41 type SearchHit,
42 type SearchKind,
43 type SearchResult,
44 type ServiceBinding,
45 type User,
46 type Viewer,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look47 ComputeGate,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put48 billingClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look49 embeddingEstimateMicros,
50 localRefusal,
51 currentMovedPath,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API52 currentWorkspaceSlug,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look53 repoMove,
54 staleMovedPaths,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API55 deploymentsClient,
56 fail,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look57 granted,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API58 identityClient,
59 integrationsClient,
60 memoryReviewClient,
61 ok,
62 projectsClient,
63 reposClient,
64 securityClient,
65 staleSlugs,
66 workClient,
67 type Result,
68} from "@g1t/contracts";
69
70import { assemble, authorsOf, integrationEntities, type EntityDraft, type FileRecord, type ProjectInput, type Surroundings } from "./assemble";
71import { extract, interesting, type FileFacts } from "./extract";
72import { composeRunContext, type ContextNote, type ProjectContext } from "./runcontext";
73import { evaluate } from "./scorecards";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look74import { allowedKinds, countVisible, indexFilter, memoryReadable, merge, projectReadable, readable, runMemoryReadable, type IndexMeta, type Reader } from "./visibility";
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API75
76type Job =
77 | { type: "backfill_project"; workspace: string; slug: string }
78 | { type: "backfill_memory"; workspace: string };
79
80type Env = {
81 DB: D1Database;
82 AI?: Ai;
83 VECTORS?: Vectorize;
84 JOBS: Queue<Job>;
85 REPOS: ServiceBinding;
86 PROJECTS: ServiceBinding;
87 WORK: ServiceBinding;
88 IDENTITY: ServiceBinding;
89 DEPLOYMENTS: ServiceBinding;
90 INTEGRATIONS: ServiceBinding;
91 SECURITY?: ServiceBinding;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put92 /** Told the month's embedding cost so far, which it charges once the month is over. */
93 BILLING?: ServiceBinding;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API94};
95
Merge branch 'main' into actions-toolkit-oidc-artifacts96/**
97 * Workers AI's embedding model: 768 dimensions, as the index was made with.
98 * Pinned, not a default staff choose in sudo: vectors from another model
99 * mean nothing beside these, so changing it means a new index, rebuilt.
100 * The catalogue (billing's `gateway_models`) lists it with its price.
101 */
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API102const EMBED_MODEL = "@cf/baai/bge-base-en-v1.5";
103/** What it costs, in millionths of a dollar per token ($0.067 per million). */
104const MICROS_PER_TOKEN = 0.067;
105/** Past this many tokens in a month, a workspace's new text goes unindexed; text search still finds it. */
106const MONTHLY_TOKENS = 20_000_000;
107/** The most text embedded for one row; the model reads 512 tokens. */
108const EMBED_CHARS = 2000;
109const EMBED_BATCH = 50;
110/** The most files read from a repository for one project's rebuild. */
111const MAX_READS = 15;
112/** Of them, workflows. */
113const MAX_WORKFLOW_READS = 3;
114/** The most projects one push rebuilds. */
115const MAX_PROJECTS_PER_PUSH = 5;
116/** A backfill's caps. */
117const BACKFILL_PROJECTS = 50;
118const BACKFILL_PULLS = 20;
119const BACKFILL_ITEMS = 30;
120/** A backfill still marked running after this long is taken to have died. */
121const BACKFILL_STALE_MS = 30 * 60 * 1000;
122const ITEM_CHARS = 4000;
123const SNIPPET_CHARS = 280;
124/** Kinds every project shares rather than owns. */
125const SHARED: Set<EntityKind> = new Set(["owner", "language", "integration"]);
126
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look127/** One compute gate per isolate, so entitlements are kept between calls. */
128let computeGate: ComputeGate | null = null;
129function gateFor(billing: ServiceBinding): ComputeGate {
130 computeGate ??= new ComputeGate(billing);
131 return computeGate;
132}
133
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API134const now = () => new Date().toISOString();
135const month = () => now().slice(0, 7);
136
137function isMember(viewer: Viewer, workspace: string): boolean {
138 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase());
139}
140
141async function sha(text: string): Promise<string> {
142 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
143 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
144}
145
146/** An entity's id: the same for the same thing, however often it is rebuilt. */
147export async function entityId(workspace: string, kind: string, key: string): Promise<string> {
148 return `ent_${(await sha(`${workspace}\u0000${kind}\u0000${key.toLowerCase()}`)).slice(0, 24)}`;
149}
150
151function snippet(text: string | null | undefined): string {
152 const line = (text ?? "").replace(/\s+/g, " ").trim();
153 return line.length <= SNIPPET_CHARS ? line : `${line.slice(0, SNIPPET_CHARS - 1)}…`;
154}
155
156type EntityRow = {
157 id: string;
158 workspace: string;
159 kind: EntityKind;
160 key: string;
161 name: string;
162 summary: string | null;
163 project_id: string | null;
164 project: string | null;
165 repo_id: string | null;
166 private: number;
167 data: string;
168 source: string;
169 ref: string | null;
170 updated_at: string;
171};
172
173type ItemRow = {
174 id: string;
175 workspace: string;
176 kind: "doc" | "issue" | "pull";
177 entity_id: string | null;
178 project: string | null;
179 private: number;
180 title: string;
181 text: string;
182 url: string | null;
183 by: string | null;
184 updated_at: string;
185};
186
187function toEntity(row: EntityRow): Entity {
188 let data: Record<string, unknown> = {};
189 try {
190 data = JSON.parse(row.data);
191 } catch {}
192 return {
193 id: row.id,
194 workspace: row.workspace,
195 kind: row.kind,
196 key: row.key,
197 name: row.name,
198 summary: row.summary,
199 project: row.project,
200 private: !!row.private,
201 data,
202 source: row.source,
203 ref: row.ref,
204 updatedAt: row.updated_at,
205 };
206}
207
208/** Where a memory came from, in a few words. */
209function memorySource(memory: Memory): string {
210 const ref = memory.source.reference ?? "";
211 const number = memory.source.number;
212 switch (memory.source.kind) {
213 case "doc":
214 return ref.split(":").slice(2).join(":") || "a doc";
215 case "review":
216 return number ? `review on #${number}` : "a review";
217 case "pr":
218 return number ? `#${number}` : "a merged pull request";
219 case "run":
220 return number ? `agent run on #${number}` : "an agent run";
221 default:
222 return `added by ${memory.createdBy}`;
223 }
224}
225
226/** What one workspace's rebuilds share: asked of other services once. */
227class WorkspaceCache {
228 private members?: Promise<string[]>;
229 private deploys?: Promise<ProjectDeploys[]>;
230 private connections?: Promise<Surroundings["integrations"]>;
231 constructor(
232 private readonly env: Env,
233 readonly workspace: string,
234 readonly actor: User,
235 ) {}
236
237 memberNames(): Promise<string[]> {
238 this.members ??= identityClient(this.env.IDENTITY)
239 .listMembers(this.workspace, this.actor)
240 .then((found) => (found.ok ? found.value.map((member) => member.username) : []))
241 .catch(() => []);
242 return this.members;
243 }
244
245 deployments(): Promise<ProjectDeploys[]> {
246 this.deploys ??= deploymentsClient(this.env.DEPLOYMENTS)
247 .overview(this.workspace, this.actor)
248 .then((found) => (found.ok ? found.value : []))
249 .catch(() => []);
250 return this.deploys;
251 }
252
253 integrations(): Promise<Surroundings["integrations"]> {
254 this.connections ??= integrationsClient(this.env.INTEGRATIONS)
255 .list(this.workspace, this.actor)
256 .then((found) =>
257 found.ok
258 ? found.value
259 .filter((connection) => connection.kind !== "models")
260 .map((connection) => ({
261 id: connection.id,
262 provider: connection.provider,
263 name: connection.name,
264 kind: connection.kind,
265 repo: connection.config.repo ?? null,
266 }))
267 : [],
268 )
269 .catch(() => []);
270 return this.connections;
271 }
272}
273
274type ScanStats = { entities: number; candidates: number; kept: number; indexed: number };
275
276class Context {
277 constructor(private readonly env: Env) {}
278
279 private get db() {
280 return this.env.DB;
281 }
282
283 private async workspaceActor(slug: string): Promise<User | null> {
284 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
285 if (!workspace) return null;
286 return {
287 id: workspace.id,
288 username: workspace.slug,
289 kind: "workspace",
290 verified: true,
291 workspaces: [{ slug: workspace.slug, role: "member" }],
292 };
293 }
294
295 // ---- Search index --------------------------------------------------------
296
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look297 /**
298 * Whether semantic search is open to a workspace: embeddings are compute,
299 * so only on a paid plan or the trial (`localRefusal`). Text search
300 * answers for everyone else. Closed when billing cannot say, which
301 * leaves text search; open where there is no billing service at all.
302 */
303 private async semanticOpen(workspace: string): Promise<boolean> {
304 if (!this.env.BILLING) return true;
305 const ent = await gateFor(this.env.BILLING).entitlements(workspace);
306 return ent != null && localRefusal(ent, "embedding", false) == null;
307 }
308
309 /**
310 * Embeds and stores rows in the search index, within the workspace's
311 * monthly allowance, reserving what it costs with billing first and
312 * settling what it did. Never throws.
313 */
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API314 private async index(workspace: string, rows: { id: string; text: string; meta: IndexMeta }[]): Promise<number> {
315 const { AI, VECTORS } = this.env;
316 if (!AI || !VECTORS || rows.length === 0) return 0;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look317 if (!(await this.semanticOpen(workspace))) return 0;
318 let reservation: string | null = null;
319 let spentTokens = 0;
320 if (this.env.BILLING) {
321 const estimate = rows.reduce((sum, row) => sum + Math.ceil(Math.min(row.text.length, EMBED_CHARS) / 4), 0);
322 const admitted = await gateFor(this.env.BILLING).admit({
323 workspace,
324 repo: { namespace: workspace, name: rows[0].meta.project ?? "" },
325 public: rows.every((row) => !row.meta.private),
326 kind: "embedding",
327 estimateMicros: embeddingEstimateMicros(estimate),
328 });
329 if (!admitted.ok) {
330 console.log("embeddings not started for", workspace, admitted.code, admitted.message);
331 return 0;
332 }
333 reservation = admitted.reservation?.id ?? null;
334 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API335 try {
336 const used = await this.db
337 .prepare("SELECT tokens FROM usage WHERE workspace = ? AND month = ?")
338 .bind(workspace, month())
339 .first<{ tokens: number }>();
340 if ((used?.tokens ?? 0) >= MONTHLY_TOKENS) return 0;
341 let stored = 0;
342 for (let at = 0; at < rows.length; at += EMBED_BATCH) {
343 const batch = rows.slice(at, at + EMBED_BATCH);
344 const texts = batch.map((row) => row.text.slice(0, EMBED_CHARS));
345 const embedded = (await AI.run(EMBED_MODEL, { text: texts })) as { data?: number[][] };
346 const vectors = (embedded.data ?? []).map((values, i) => ({
347 id: batch[i].id,
348 values,
349 metadata: batch[i].meta as unknown as Record<string, VectorizeVectorMetadata>,
350 }));
351 if (vectors.length) await VECTORS.upsert(vectors);
352 stored += vectors.length;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put353 const tokens = (rows: { text: string }[]) => rows.reduce((sum, row) => sum + Math.ceil(row.text.length / 4), 0);
354 const all = texts.map((text) => ({ text }));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look355 spentTokens += tokens(all);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put356 await this.meter(workspace, tokens(all), tokens(all.filter((_, i) => batch[i].meta.private)));
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API357 }
358 return stored;
359 } catch (error) {
360 console.error("could not index", rows.length, "rows for", workspace, error);
361 return 0;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look362 } finally {
363 if (reservation && this.env.BILLING) {
364 await gateFor(this.env.BILLING).settle(reservation, embeddingEstimateMicros(spentTokens));
365 }
366 }
367 }
368
369 /** Drops a repository's rows kept under any of `workspaces`, and their index entries. */
370 private async forgetRepo(repoId: string, workspaces: string[]): Promise<void> {
371 const marks = workspaces.map(() => "?").join(", ");
372 const items = await this.db
373 .prepare(`SELECT id FROM items WHERE repo_id = ? AND workspace IN (${marks})`)
374 .bind(repoId, ...workspaces)
375 .all<{ id: string }>();
376 for (let i = 0; i < items.results.length; i += 100) {
377 await this.unindex(items.results.slice(i, i + 100).map((row) => row.id));
378 }
379 const projects = `SELECT project_id FROM entities WHERE repo_id = ? AND workspace IN (${marks}) AND project_id IS NOT NULL`;
380 await this.db.batch([
381 this.db.prepare(`DELETE FROM relations WHERE project_id IN (${projects})`).bind(repoId, ...workspaces),
382 this.db.prepare(`DELETE FROM files WHERE project_id IN (${projects})`).bind(repoId, ...workspaces),
383 this.db.prepare(`DELETE FROM items WHERE repo_id = ? AND workspace IN (${marks})`).bind(repoId, ...workspaces),
384 this.db.prepare(`DELETE FROM scans WHERE repo_id = ? AND workspace IN (${marks})`).bind(repoId, ...workspaces),
385 this.db.prepare(`DELETE FROM entities WHERE repo_id = ? AND workspace IN (${marks})`).bind(repoId, ...workspaces),
386 ]);
387 }
388
389 /** A deleted workspace's hub: everything but its usage, which billing has already read. */
390 private async forgetWorkspace(slug: string): Promise<void> {
391 const items = await this.db.prepare("SELECT id FROM items WHERE workspace = ?").bind(slug).all<{ id: string }>();
392 for (let i = 0; i < items.results.length; i += 100) {
393 await this.unindex(items.results.slice(i, i + 100).map((row) => row.id));
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API394 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look395 await this.db.batch(
396 ["items", "relations", "entities", "scans", "backfills"].map((table) =>
397 this.db.prepare(`DELETE FROM ${table} WHERE workspace = ?`).bind(slug),
398 ),
399 );
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API400 }
401
402 private async unindex(ids: string[]): Promise<void> {
403 if (!this.env.VECTORS || ids.length === 0) return;
404 try {
405 await this.env.VECTORS.deleteByIds(ids);
406 } catch (error) {
407 console.error("could not take", ids.length, "rows out of the index", error);
408 }
409 }
410
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put411 /**
412 * Records what embedding cost. Of it, `billableTokens` are private
413 * text's: public repositories' text and searches are never charged.
414 * Billing is told the month's billable total, which it charges at cost
415 * plus its margin once the month is over (its `embedding_tokens` meter).
416 * A failure to tell billing only delays it: the next call sends the
417 * whole month again.
418 */
419 private async meter(workspace: string, tokens: number, billableTokens = 0): Promise<void> {
420 const total = await this.db
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API421 .prepare(
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put422 `INSERT INTO usage (workspace, month, tokens, cost_micros, billable_micros) VALUES (?1, ?2, ?3, ?4, ?5)
423 ON CONFLICT (workspace, month) DO UPDATE SET
424 tokens = tokens + ?3, cost_micros = cost_micros + ?4, billable_micros = billable_micros + ?5
425 RETURNING billable_micros`,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API426 )
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put427 .bind(workspace, month(), tokens, Math.ceil(tokens * MICROS_PER_TOKEN), billableTokens * MICROS_PER_TOKEN)
428 .first<{ billable_micros: number }>();
429 if (this.env.BILLING && total && billableTokens > 0) {
430 await billingClient(this.env.BILLING)
431 .notePending(workspace, "context", Math.ceil(total.billable_micros))
432 .catch((error) => console.error("could not tell billing what embedding cost", workspace, error));
433 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API434 }
435
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look436 /**
437 * A query's embedding, for semantic search. Too small to reserve one by
438 * one (a few hundred tokens, a fraction of a cent): it needs the plan or
439 * the trial, as indexing does, and is metered with the month's usage.
440 */
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API441 private async embedQuery(workspace: string, query: string): Promise<number[] | null> {
442 if (!this.env.AI || !this.env.VECTORS) return null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look443 if (!(await this.semanticOpen(workspace))) return null;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API444 try {
445 const embedded = (await this.env.AI.run(EMBED_MODEL, { text: [query.slice(0, EMBED_CHARS)] })) as { data?: number[][] };
446 await this.meter(workspace, Math.ceil(query.length / 4));
447 return embedded.data?.[0] ?? null;
448 } catch (error) {
449 console.error("could not embed a query", error);
450 return null;
451 }
452 }
453
454 // ---- Building the catalog --------------------------------------------------
455
456 /** The files of a project worth reading, with their blobs, found in a few tree reads. */
457 private async candidates(project: Project, actor: User, ref: string): Promise<{ files: { path: string; hash: string }[]; siblings: string[]; head: string | null } | null> {
458 if (project.source.kind !== "hosted") return null;
459 const repos = reposClient(this.env.REPOS);
460 const { repo, rootDir: root } = project.source;
461 const at = (path: string) => [root, path].filter(Boolean).join("/");
462 const top = await repos.tree(repo, actor, ref, root);
463 if (!top.ok) return null;
464 const files: { path: string; hash: string }[] = [];
465 const siblings = top.value.entries.map((entry) => entry.name);
466 const blobs = (entries: { name: string; hash: string; kind: string }[], dir: string) => {
467 for (const entry of entries) {
468 if (entry.kind !== "blob" && entry.kind !== "exec") continue;
469 const path = dir ? `${dir}/${entry.name}` : entry.name;
470 if (interesting(path)) files.push({ path, hash: entry.hash });
471 }
472 };
473 blobs(top.value.entries, "");
474 const dirs = new Set(top.value.entries.filter((entry) => entry.kind === "tree").map((entry) => entry.name));
475 const look = async (dir: string) => {
476 const found = await repos.tree(repo, actor, ref, at(dir)).catch(() => null);
477 return found?.ok ? found.value.entries : [];
478 };
479 for (const dir of ["docs", "doc", "runbooks"]) if (dirs.has(dir)) blobs(await look(dir), dir);
480 for (const dir of [".g1t", ".github"]) {
481 if (!dirs.has(dir)) continue;
482 const inside = await look(dir);
483 blobs(inside, dir);
484 if (inside.some((entry) => entry.name === "workflows" && entry.kind === "tree")) blobs(await look(`${dir}/workflows`), `${dir}/workflows`);
485 }
486 return { files, siblings, head: top.value.head?.hash ?? null };
487 }
488
489 /**
490 * Builds one project's place in the catalog from its default branch (or
491 * `commit`), reading only files whose blobs changed unless `force`.
492 * Indexes what changed and sends what its docs say to memory.
493 */
494 async scan(project: Project, cache: WorkspaceCache, commit: string | null, force: boolean): Promise<ScanStats> {
495 const stats: ScanStats = { entities: 0, candidates: 0, kept: 0, indexed: 0 };
496 if (project.source.kind !== "hosted") return stats;
497 const { repo, repoId, rootDir, defaultBranch } = project.source;
498 const ref = commit ?? defaultBranch;
499 const found = await this.candidates(project, cache.actor, ref);
500 if (!found) return stats;
501 const workspace = project.workspace;
502
503 // What each file says: stored for unchanged blobs, read for the rest.
504 const stored = new Map(
505 (
506 await this.db.prepare("SELECT path, hash, facts FROM files WHERE project_id = ?").bind(project.id).all<{ path: string; hash: string; facts: string }>()
507 ).results.map((row) => [row.path, row]),
508 );
509 const files: FileRecord[] = [];
510 const changed: string[] = [];
511 const writes: D1PreparedStatement[] = [];
512 let reads = 0;
513 let workflowReads = 0;
514 const repos = reposClient(this.env.REPOS);
515 for (const file of found.files) {
516 const before = stored.get(file.path);
517 const workflow = file.path.includes("/workflows/");
518 const fresh = before && before.hash === file.hash && !force;
519 const canRead = reads < MAX_READS && (!workflow || workflowReads < MAX_WORKFLOW_READS);
520 if (fresh || !canRead) {
521 if (before) files.push({ path: file.path, facts: JSON.parse(before.facts) as FileFacts });
522 continue;
523 }
524 reads++;
525 if (workflow) workflowReads++;
526 const blob = await repos.blob(repo, cache.actor, found.head ?? ref, [rootDir, file.path].filter(Boolean).join("/")).catch(() => null);
527 if (!blob?.ok || blob.value.text == null) continue;
528 const facts = extract(file.path, blob.value.text, { project: project.name, siblings: found.siblings });
529 files.push({ path: file.path, facts });
530 changed.push(file.path);
531 writes.push(
532 this.db
533 .prepare(
534 `INSERT INTO files (project_id, path, hash, facts, read_at) VALUES (?, ?, ?, ?, ?)
535 ON CONFLICT (project_id, path) DO UPDATE SET hash = excluded.hash, facts = excluded.facts, read_at = excluded.read_at`,
536 )
537 .bind(project.id, file.path, file.hash, JSON.stringify(facts), now()),
538 );
539 }
540 const present = new Set(found.files.map((file) => file.path));
541 for (const path of stored.keys()) {
542 if (!present.has(path)) writes.push(this.db.prepare("DELETE FROM files WHERE project_id = ? AND path = ?").bind(project.id, path));
543 }
544
545 // What g1t knows about it besides its files.
546 const [members, deploys, integrations, graph, log] = await Promise.all([
547 cache.memberNames(),
548 cache.deployments(),
549 cache.integrations(),
550 projectsClient(this.env.PROJECTS).graph(project.id).catch(() => ({ dependsOn: [], usedBy: [] })),
551 repos.log(repo, cache.actor, found.head ?? ref, 100).catch(() => null),
552 ]);
553 const deploy = deploys.find((d) => d.slug === project.slug) ?? null;
554 const around: Surroundings = {
555 owners: authorsOf(log?.ok ? log.value : [], members),
556 dependsOn: graph.dependsOn.map((dep) => ({ slug: dep.slug, as: dep.as })),
557 deploy: deploy
558 ? {
559 enabled: deploy.enabled,
560 production: deploy.production ? { url: deploy.production.url, commit: deploy.production.commit, deployedAt: deploy.production.deployedAt } : null,
561 previews: deploy.previews,
562 latest: deploy.latest ? { status: deploy.latest.status, kind: deploy.latest.kind, error: deploy.latest.error, createdAt: deploy.latest.createdAt } : null,
563 }
564 : null,
565 integrations,
566 };
567 const input: ProjectInput = {
568 id: project.id,
569 workspace,
570 slug: project.slug,
571 name: project.name,
572 description: project.description,
573 private: project.private,
574 repoId,
575 repo,
576 rootDir,
577 defaultBranch,
578 };
579 const built = assemble(input, files, around);
580 const drafts: EntityDraft[] = [...built.entities, ...integrationEntities(integrations)];
581
582 // Entities: upserted; the project's own that it no longer has, removed.
583 const previous = new Map(
584 (
585 await this.db
586 .prepare("SELECT id, name, summary FROM entities WHERE workspace = ? AND (project_id = ? OR project_id IS NULL)")
587 .bind(workspace, project.id)
588 .all<{ id: string; name: string; summary: string | null }>()
589 ).results.map((row) => [row.id, row]),
590 );
591 const ids: string[] = [];
592 const toIndex: { id: string; text: string; meta: IndexMeta }[] = [];
593 const at = now();
594 for (const draft of drafts) {
595 const id = await entityId(workspace, draft.kind, draft.key);
596 ids.push(id);
597 const shared = SHARED.has(draft.kind);
598 const source = draft.kind === "app" || draft.kind === "environment" ? "deployments" : draft.kind === "integration" ? "integrations" : "scan";
599 writes.push(
600 this.db
601 .prepare(
602 `INSERT INTO entities (id, workspace, kind, key, name, summary, project_id, project, repo_id, private, data, source, ref, updated_at)
603 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
604 ON CONFLICT (workspace, kind, key) DO UPDATE SET name = excluded.name, summary = excluded.summary,
605 project_id = excluded.project_id, project = excluded.project, repo_id = excluded.repo_id, private = excluded.private,
606 data = excluded.data, source = excluded.source, ref = excluded.ref, updated_at = excluded.updated_at`,
607 )
608 .bind(
609 id,
610 workspace,
611 draft.kind,
612 draft.key,
613 draft.name,
614 draft.summary,
615 shared ? null : project.id,
616 shared ? null : project.slug,
617 shared ? null : repoId,
618 shared ? 0 : project.private ? 1 : 0,
619 JSON.stringify(draft.data),
620 source,
621 draft.ref,
622 at,
623 ),
624 );
625 const before = previous.get(id);
626 if (force || !before || before.name !== draft.name || before.summary !== draft.summary) {
627 toIndex.push({
628 id,
629 text: `${draft.kind} ${draft.name}. ${draft.summary ?? ""}`,
630 meta: {
631 workspace,
632 kind: draft.kind,
633 project: shared ? "" : project.slug,
634 private: shared ? false : project.private,
635 title: draft.name,
636 snippet: snippet(draft.summary),
637 url: draft.ref ?? "",
638 source: "catalog",
639 by: "",
640 at,
641 },
642 });
643 }
644 }
645 const gone = (
646 await this.db
647 .prepare("SELECT id FROM entities WHERE project_id = ? AND id NOT IN (SELECT value FROM json_each(?))")
648 .bind(project.id, JSON.stringify(ids))
649 .all<{ id: string }>()
650 ).results.map((row) => row.id);
651 if (gone.length) {
652 writes.push(this.db.prepare("DELETE FROM entities WHERE id IN (SELECT value FROM json_each(?))").bind(JSON.stringify(gone)));
653 writes.push(this.db.prepare("DELETE FROM items WHERE entity_id IN (SELECT value FROM json_each(?))").bind(JSON.stringify(gone)));
654 }
655
656 // Relations: the project's own, replaced whole.
657 writes.push(this.db.prepare("DELETE FROM relations WHERE project_id = ?").bind(project.id));
658 for (const relation of built.relations) {
659 const [from, to] = await Promise.all([entityId(workspace, relation.from.kind, relation.from.key), entityId(workspace, relation.to.kind, relation.to.key)]);
660 writes.push(
661 this.db
662 .prepare("INSERT OR REPLACE INTO relations (workspace, from_id, kind, to_id, project_id, updated_at) VALUES (?, ?, ?, ?, ?, ?)")
663 .bind(workspace, from, relation.kind, to, project.id, at),
664 );
665 }
666
667 // Docs that changed: their pieces, for search.
668 const repoPath = `/${repo.namespace}/${repo.name}`;
669 const chunkIds: string[] = [];
670 for (const file of files) {
671 const doc = file.facts.doc;
672 if (!doc || (!changed.includes(file.path) && !force)) continue;
673 const docId = await entityId(workspace, "doc", `${project.slug}:${doc.path}`);
674 writes.push(this.db.prepare("DELETE FROM items WHERE entity_id = ?").bind(docId));
675 const url = `${repoPath}/blob/${defaultBranch}/${[rootDir, doc.path].filter(Boolean).join("/")}`;
676 doc.chunks.forEach((text, i) => {
677 const id = `${docId}:${i}`;
678 chunkIds.push(id);
679 writes.push(
680 this.db
681 .prepare(
682 `INSERT OR REPLACE INTO items (id, workspace, kind, entity_id, project_id, project, repo_id, private, title, text, url, by, updated_at)
683 VALUES (?, ?, 'doc', ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`,
684 )
685 .bind(id, workspace, docId, project.id, project.slug, repoId, project.private ? 1 : 0, `${doc.title} (${doc.path})`, text, url, at),
686 );
687 toIndex.push({
688 id,
689 text: `${doc.title}\n${text}`,
690 meta: { workspace, kind: "doc", project: project.slug, private: project.private, title: `${doc.title} (${doc.path})`, snippet: snippet(text), url, source: doc.path, by: "", at },
691 });
692 });
693 }
694 writes.push(
695 this.db
696 .prepare(
697 `INSERT INTO scans (project_id, workspace, repo_id, "commit", tests, scanned_at) VALUES (?, ?, ?, ?, ?, ?)
698 ON CONFLICT (project_id) DO UPDATE SET workspace = excluded.workspace, "commit" = excluded."commit", tests = excluded.tests, scanned_at = excluded.scanned_at`,
699 )
700 .bind(project.id, workspace, repoId, found.head, built.tests ? 1 : 0, at),
701 );
702 for (let i = 0; i < writes.length; i += 50) await this.db.batch(writes.slice(i, i + 50));
703 await this.unindex(gone);
704 stats.entities = drafts.length;
705 stats.indexed = await this.index(workspace, toIndex);
706
707 // What changed files say worth remembering, as memory candidates.
708 const items: CaptureItem[] = built.hints
709 .filter((hint) => force || changed.includes(hint.path))
710 .map((hint) => ({
711 scope: "project",
712 repoId,
713 kind: hint.kind,
714 text: hint.text,
715 confidence: hint.confidence,
716 source: "doc",
717 reference: `doc:${repoId}:${[rootDir, hint.path].filter(Boolean).join("/")}`,
718 evidence: hint.evidence,
719 }));
720 for (let i = 0; i < items.length; i += 50) {
721 const captured = await memoryReviewClient(this.env.WORK)
722 .captureMemories(workspace, items.slice(i, i + 50), "g1t")
723 .catch(() => null);
724 stats.candidates += captured?.added ?? 0;
725 stats.kept += captured?.kept ?? 0;
726 }
727 return stats;
728 }
729
730 // ---- Issues, pull requests and memory in search ------------------------
731
732 private async repoOf(repoId: string): Promise<{ namespace: string; name: string } | null> {
733 const response = await this.env.REPOS.fetch("https://repos/rpc/path_by_id", {
734 method: "POST",
735 headers: { "content-type": "application/json" },
736 body: JSON.stringify({ id: repoId }),
737 });
738 return response.ok ? ((await response.json()) as { namespace: string; name: string } | null) : null;
739 }
740
741 /** Stores and indexes issues and pull requests as search rows. */
742 private async putItems(
743 workspace: string,
744 project: Project | null,
745 repoId: string,
746 rows: { kind: "issue" | "pull"; number: number; title: string; body: string | null; by: string | null; updatedAt: string; url: string; private: boolean }[],
747 ): Promise<number> {
748 const writes: D1PreparedStatement[] = [];
749 const toIndex: { id: string; text: string; meta: IndexMeta }[] = [];
750 for (const row of rows) {
751 const id = `${row.kind}:${repoId}#${row.number}`;
752 const title = `#${row.number} ${row.title}`;
753 const text = (row.body ?? "").slice(0, ITEM_CHARS);
754 writes.push(
755 this.db
756 .prepare(
757 `INSERT OR REPLACE INTO items (id, workspace, kind, entity_id, project_id, project, repo_id, private, title, text, url, by, updated_at)
758 VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
759 )
760 .bind(id, workspace, row.kind, project?.id ?? null, project?.slug ?? null, repoId, row.private ? 1 : 0, title, text, row.url, row.by, row.updatedAt),
761 );
762 toIndex.push({
763 id,
764 text: `${row.title}\n${text}`,
765 meta: { workspace, kind: row.kind, project: project?.slug ?? "", private: row.private, title, snippet: snippet(text || row.title), url: row.url, source: row.kind === "issue" ? "issue" : "pull request", by: row.by ?? "", at: row.updatedAt },
766 });
767 }
768 for (let i = 0; i < writes.length; i += 50) await this.db.batch(writes.slice(i, i + 50));
769 return this.index(workspace, toIndex);
770 }
771
772 private async indexIssueOrPull(kind: "issue" | "pull", repoId: string, number: number): Promise<void> {
773 const path = await this.repoOf(repoId);
774 if (!path) return;
775 const workspace = path.namespace.toLowerCase();
776 const actor = await this.workspaceActor(workspace);
777 if (!actor) return;
778 const [repo, projects] = await Promise.all([reposClient(this.env.REPOS).get(path, actor), projectsClient(this.env.PROJECTS).byRepo(repoId)]);
779 if (!repo.ok || repo.value.forkOf) return;
780 const work = workClient(this.env.WORK);
781 const base = `/${path.namespace}/${path.name}`;
782 if (kind === "issue") {
783 const found = await work.getIssue(path, number, actor);
784 if (!found.ok) return;
785 const issue = found.value.issue;
786 await this.putItems(workspace, projects[0] ?? null, repoId, [
787 { kind, number, title: issue.title, body: issue.body, by: issue.author.username, updatedAt: issue.updatedAt, url: `${base}/issues/${number}`, private: repo.value.isPrivate },
788 ]);
789 } else {
790 const found = await work.getPull(path, number, actor);
791 if (!found.ok) return;
792 const pull = found.value.pull as { title: string; body: string | null; agent: string; updatedAt?: string; author?: { username: string } };
793 await this.putItems(workspace, projects[0] ?? null, repoId, [
794 { kind, number, title: pull.title, body: pull.body, by: pull.author?.username ?? pull.agent, updatedAt: pull.updatedAt ?? now(), url: `${base}/pull/${number}`, private: repo.value.isPrivate },
795 ]);
796 }
797 }
798
799 /** A memory in search while it is kept, and out of it otherwise. */
800 private async indexMemories(workspace: string, memories: Memory[]): Promise<number> {
801 const kept = memories.filter((memory) => (memory.status ?? "kept") === "kept");
802 await this.unindex(memories.filter((memory) => !kept.includes(memory)).map((memory) => `memory:${memory.id}`));
803 const projects = await projectsClient(this.env.PROJECTS)
804 .list(workspace, (await this.workspaceActor(workspace)) ?? null)
805 .then((found) => (found.ok ? found.value : []))
806 .catch(() => [] as Project[]);
807 const slugOf = (memory: Memory) =>
808 memory.scope === "project" && memory.repo
809 ? (projects.find(
810 (project) =>
811 project.source.kind === "hosted" &&
812 project.primary &&
813 project.source.repo.namespace.toLowerCase() === memory.repo!.namespace.toLowerCase() &&
814 project.source.repo.name.toLowerCase() === memory.repo!.name.toLowerCase(),
815 )?.slug ?? "")
816 : "";
817 return this.index(
818 workspace,
819 kept.map((memory) => ({
820 id: `memory:${memory.id}`,
821 text: `${memory.kind}: ${memory.text}`,
822 meta: {
823 workspace,
824 kind: "memory",
825 project: slugOf(memory),
826 // Memory is for members, whatever its project.
827 private: true,
828 title: `${memory.kind[0].toUpperCase()}${memory.kind.slice(1)}${memory.scope === "workspace" ? " (workspace)" : ""}`,
829 snippet: snippet(memory.text),
830 url: memory.repo ? `/${memory.repo.namespace}/${memory.repo.name}/memory` : `/${workspace}/-/memory`,
831 source: memorySource(memory),
832 by: memory.createdBy,
833 at: memory.updatedAt,
834 },
835 })),
836 );
837 }
838
839 // ---- Events --------------------------------------------------------------
840
841 async onEvent(event: G1tEvent): Promise<void> {
842 switch (event.type) {
843 case "git.push": {
844 if (!event.data.defaultBranch) return;
845 const projects = (await projectsClient(this.env.PROJECTS).byRepo(event.data.repoId)).slice(0, MAX_PROJECTS_PER_PUSH);
846 if (projects.length === 0) return;
847 const actor = await this.workspaceActor(projects[0].workspace);
848 if (!actor) return;
849 const cache = new WorkspaceCache(this.env, projects[0].workspace, actor);
850 for (const project of projects) await this.scan(project, cache, event.data.after, false);
851 return;
852 }
853 case "issue.opened":
854 case "issue.updated":
855 case "issue.closed":
856 return this.indexIssueOrPull("issue", event.data.repoId, event.data.number);
857 case "pull.ready":
858 case "pull.merged":
859 return this.indexIssueOrPull("pull", event.data.repoId, event.data.number);
860 case "memory.changed": {
861 const { memoryId, workspace, status } = event.data;
862 if (status !== "kept") return this.unindex([`memory:${memoryId}`]);
863 const memories = await memoryReviewClient(this.env.WORK).memoriesById(workspace, [memoryId]);
864 await this.indexMemories(workspace, memories);
865 return;
866 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look867 case "repo.transferred":
868 case "repo.renamed": {
869 // What the hub knew about the repository under its old path goes,
870 // index included (its rows carry the path: a transfer's old
871 // workspace, a rename's old name); the workspace it is in now is
872 // built again, which reads it where and as it is now. Nothing here
873 // is the only copy.
874 const move = repoMove(event)!;
875 const current = await currentMovedPath(this.env.REPOS, move);
876 const stale = staleMovedPaths(move, current);
877 if (stale.length === 0) return;
878 const workspace = current.split("/")[0]!.toLowerCase();
879 await this.forgetRepo(move.repoId, [...new Set(stale.map((path) => path.split("/")[0]!.toLowerCase()))]);
880 const actor = await this.workspaceActor(workspace);
881 if (actor) await this.backfill({ actor, workspace });
882 return;
883 }
884 case "repo.deleted":
885 case "repo.purged": {
886 // Deleted, it is hidden: what the hub knew of it goes, index
887 // included, so no search or agent finds it. A restore builds it
888 // again; a purge finds nothing left.
889 await this.forgetRepo(event.data.repoId, [event.data.namespace.toLowerCase()]);
890 return;
891 }
892 case "repo.restored": {
893 const workspace = event.data.namespace.toLowerCase();
894 const actor = await this.workspaceActor(workspace);
895 if (actor) await this.backfill({ actor, workspace });
896 return;
897 }
898 case "workspace.deleted": {
899 await this.forgetWorkspace(event.data.slug);
900 return;
901 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API902 case "workspace.renamed": {
903 const current = await currentWorkspaceSlug(this.env.IDENTITY, event.data);
904 for (const old of staleSlugs(event.data, current)) {
905 await this.db.batch(
906 ["entities", "relations", "items", "scans", "usage"].map((table) =>
907 this.db.prepare(`UPDATE OR IGNORE ${table} SET workspace = ? WHERE workspace = ?`).bind(current, old),
908 ),
909 );
910 await this.db.prepare("DELETE FROM backfills WHERE workspace = ?").bind(old).run();
911 }
912 // The index's rows carry the slug: build them again under the new one.
913 const actor = await this.workspaceActor(current);
914 if (actor) await this.backfill({ actor, workspace: current });
915 return;
916 }
917 default:
918 return;
919 }
920 }
921
922 // ---- Backfill ------------------------------------------------------------
923
924 private async backfillRow(workspace: string): Promise<Backfill | null> {
925 const row = await this.db.prepare("SELECT * FROM backfills WHERE workspace = ?").bind(workspace).first<Record<string, unknown>>();
926 if (!row) return null;
927 return {
928 workspace,
929 status: row.status as Backfill["status"],
930 by: String(row.by),
931 projects: Number(row.projects),
932 done: Number(row.done),
933 entities: Number(row.entities),
934 candidates: Number(row.candidates),
935 kept: Number(row.kept),
936 indexed: Number(row.indexed),
937 error: (row.error as string | null) ?? null,
938 startedAt: String(row.started_at),
939 finishedAt: (row.finished_at as string | null) ?? null,
940 };
941 }
942
943 async backfill(a: { actor: User; workspace: string }): Promise<Result<Backfill>> {
944 const workspace = a.workspace.toLowerCase();
945 if (!isMember(a.actor, workspace)) return fail("forbidden", "Only members can rebuild a workspace's context.");
946 const running = await this.backfillRow(workspace);
947 if (running?.status === "running" && Date.now() - Date.parse(running.startedAt) < BACKFILL_STALE_MS) return ok(running);
948 const actor = (await this.workspaceActor(workspace)) ?? a.actor;
949 const listed = await projectsClient(this.env.PROJECTS).list(workspace, actor);
950 if (!listed.ok) return listed;
951 const projects = listed.value.filter((project) => project.source.kind === "hosted").slice(0, BACKFILL_PROJECTS);
952 const at = now();
953 await this.db
954 .prepare(
955 `INSERT OR REPLACE INTO backfills (workspace, status, by, projects, done, entities, candidates, kept, indexed, error, started_at, finished_at)
956 VALUES (?, ?, ?, ?, 0, 0, 0, 0, 0, NULL, ?, ?)`,
957 )
958 .bind(workspace, projects.length ? "running" : "done", a.actor.username, projects.length, at, projects.length ? null : at)
959 .run();
960 const jobs: { body: Job }[] = [
961 ...projects.map((project) => ({ body: { type: "backfill_project", workspace, slug: project.slug } as Job })),
962 { body: { type: "backfill_memory", workspace } },
963 ];
964 for (let i = 0; i < jobs.length; i += 100) await this.env.JOBS.sendBatch(jobs.slice(i, i + 100));
965 return ok((await this.backfillRow(workspace))!);
966 }
967
968 async runJob(job: Job): Promise<void> {
969 const actor = await this.workspaceActor(job.workspace);
970 if (!actor) return;
971 if (job.type === "backfill_memory") {
972 const memories = await memoryReviewClient(this.env.WORK).searchMemories(job.workspace, null, { limit: 100 });
973 const indexed = await this.indexMemories(job.workspace, memories);
974 await this.db.prepare("UPDATE backfills SET indexed = indexed + ? WHERE workspace = ?").bind(indexed, job.workspace).run();
975 return;
976 }
977 const stats: ScanStats = { entities: 0, candidates: 0, kept: 0, indexed: 0 };
978 let error: string | null = null;
979 try {
980 const found = await projectsClient(this.env.PROJECTS).get(job.workspace, job.slug, actor);
981 if (found.ok && found.value.source.kind === "hosted") {
982 const project = found.value;
983 const source = project.source as Extract<Project["source"], { kind: "hosted" }>;
984 const cache = new WorkspaceCache(this.env, job.workspace, actor);
985 Object.assign(stats, await this.scan(project, cache, null, true));
986 if (project.primary) {
987 // Decisions from merged pull requests, and people's corrections in their reviews.
988 const seeded = await memoryReviewClient(this.env.WORK).seedFromPulls(source.repoId, BACKFILL_PULLS).catch(() => null);
989 stats.candidates += seeded?.added ?? 0;
990 stats.kept += seeded?.kept ?? 0;
991 const work = workClient(this.env.WORK);
992 const base = `/${source.repo.namespace}/${source.repo.name}`;
993 const [issues, pulls] = await Promise.all([
994 work.listIssues(source.repo, actor).catch(() => null),
995 work.listPulls(source.repo, actor, "closed").catch(() => null),
996 ]);
997 stats.indexed += await this.putItems(job.workspace, project, source.repoId, [
998 ...(issues?.ok ? issues.value.slice(0, BACKFILL_ITEMS) : []).map((issue) => ({
999 kind: "issue" as const,
1000 number: issue.number,
1001 title: issue.title,
1002 body: issue.body,
1003 by: issue.author.username,
1004 updatedAt: issue.updatedAt,
1005 url: `${base}/issues/${issue.number}`,
1006 private: project.private,
1007 })),
1008 ...(pulls?.ok ? pulls.value.filter((pull) => pull.status === "merged").slice(0, BACKFILL_ITEMS) : []).map((pull) => ({
1009 kind: "pull" as const,
1010 number: pull.number,
1011 title: pull.title,
1012 body: pull.body,
1013 by: (pull as { author?: { username: string } }).author?.username ?? pull.agent,
1014 updatedAt: pull.mergedAt ?? now(),
1015 url: `${base}/pull/${pull.number}`,
1016 private: project.private,
1017 })),
1018 ]);
1019 }
1020 }
1021 } catch (caught) {
1022 error = String(caught).slice(0, 300);
1023 console.error("backfill of", job.workspace, job.slug, "failed", caught);
1024 }
1025 await this.db
1026 .prepare(
1027 `UPDATE backfills SET done = done + 1, entities = entities + ?, candidates = candidates + ?, kept = kept + ?, indexed = indexed + ?,
1028 error = COALESCE(?, error),
1029 status = CASE WHEN done + 1 >= projects THEN 'done' ELSE status END,
1030 finished_at = CASE WHEN done + 1 >= projects THEN ? ELSE finished_at END
1031 WHERE workspace = ?`,
1032 )
1033 .bind(stats.entities, stats.candidates, stats.kept, stats.indexed, error, now(), job.workspace)
1034 .run();
1035 }
1036
1037 // ---- Reading -------------------------------------------------------------
1038
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1039 /**
1040 * Who is reading, and what of the workspace they may see. Someone who can
1041 * read every repository in it (an owner, a member while its base
1042 * permission is Read or more, its own token) sees everything; anyone else
1043 * sees the projects the projects service lists for them, which are those
1044 * whose repositories they can read.
1045 */
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1046 private async reader(workspace: string, viewer: Viewer): Promise<Reader> {
1047 const member = isMember(viewer, workspace);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1048 const full = member && !!viewer && granted(viewer, { id: "", namespace: workspace, isPrivate: true }) != null;
1049 if (full) return { workspace, member, full, visible: new Set() };
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1050 const listed = await projectsClient(this.env.PROJECTS).list(workspace, viewer).catch(() => null);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1051 const projects = listed?.ok ? listed.value : [];
1052 return {
1053 workspace,
1054 member,
1055 full,
1056 visible: new Set(projects.map((p) => p.slug)),
1057 privateVisible: projects.some((p) => p.private),
1058 repos: new Set(
1059 projects.flatMap((p) => (p.source.kind === "hosted" ? [`${p.source.repo.namespace}/${p.source.repo.name}`.toLowerCase()] : [])),
1060 ),
1061 };
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1062 }
1063
1064 private visibleRow(row: { private: number; project: string | null }, reader: Reader): boolean {
1065 return readable({ workspace: reader.workspace, kind: "entity", project: row.project ?? "", private: !!row.private }, reader);
1066 }
1067
1068 async status(a: { workspace: string; viewer: Viewer }): Promise<Result<ContextStatus>> {
1069 const workspace = a.workspace.toLowerCase();
1070 if (!isMember(a.viewer, workspace)) return fail("not_found", "There is no such workspace.");
1071 let backfill = await this.backfillRow(workspace);
1072 // The hub is never empty for long: a workspace's first look starts it.
1073 if (!backfill) {
1074 const actor = await this.workspaceActor(workspace);
1075 if (actor) {
1076 const started = await this.backfill({ actor: { ...actor, username: "g1t" }, workspace });
1077 backfill = started.ok ? started.value : null;
1078 }
1079 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1080 // Counted over what the viewer may read: a member whose base permission
1081 // is None counts only the projects they were given.
1082 const reader = await this.reader(workspace, a.viewer);
1083 const [counted, usage] = await Promise.all([
1084 this.db
1085 .prepare("SELECT kind, project, private, COUNT(*) AS n FROM entities WHERE workspace = ? GROUP BY kind, project, private")
1086 .bind(workspace)
1087 .all<{ kind: EntityKind; project: string | null; private: number; n: number }>(),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1088 this.db.prepare("SELECT tokens, cost_micros FROM usage WHERE workspace = ? AND month = ?").bind(workspace, month()).first<{ tokens: number; cost_micros: number }>(),
1089 ]);
1090 return ok({
1091 backfill,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1092 counts: countVisible(counted.results, reader),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1093 usage: { month: month(), tokens: usage?.tokens ?? 0, costMicros: usage?.cost_micros ?? 0 },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1094 // Embeddings are compute: a paid plan or the trial (semanticOpen).
1095 semantic: !!(this.env.AI && this.env.VECTORS) && (await this.semanticOpen(workspace)),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1096 });
1097 }
1098
1099 async catalog(a: { workspace: string; viewer: Viewer; kind?: EntityKind | null; project?: string | null }): Promise<Result<Catalog>> {
1100 const workspace = a.workspace.toLowerCase();
1101 const reader = await this.reader(workspace, a.viewer);
1102 let sql = "SELECT * FROM entities WHERE workspace = ?";
1103 const params: unknown[] = [workspace];
1104 if (a.kind) {
1105 sql += " AND kind = ?";
1106 params.push(a.kind);
1107 }
1108 if (a.project) {
1109 sql += " AND (project = ? OR project IS NULL)";
1110 params.push(a.project.toLowerCase());
1111 }
1112 sql += " ORDER BY kind, name COLLATE NOCASE LIMIT 2000";
1113 const rows = (await this.db.prepare(sql).bind(...params).all<EntityRow>()).results.filter((row) => this.visibleRow(row, reader));
1114 const ids = new Set(rows.map((row) => row.id));
1115 const relations = (
1116 await this.db.prepare("SELECT from_id, kind, to_id FROM relations WHERE workspace = ? LIMIT 10000").bind(workspace).all<{ from_id: string; kind: RelationKind; to_id: string }>()
1117 ).results
1118 .filter((row) => ids.has(row.from_id) && ids.has(row.to_id))
1119 .map((row) => ({ from: row.from_id, kind: row.kind, to: row.to_id }));
1120 const built = await this.db.prepare("SELECT MAX(scanned_at) AS at FROM scans WHERE workspace = ?").bind(workspace).first<{ at: string | null }>();
1121 return ok({ entities: rows.map(toEntity), relations, builtAt: built?.at ?? null });
1122 }
1123
1124 async entity(a: { workspace: string; viewer: Viewer; kind: EntityKind; id: string }): Promise<Result<EntityDetail>> {
1125 const workspace = a.workspace.toLowerCase();
1126 if (!ENTITY_KINDS.includes(a.kind)) return fail("invalid", `kind is one of ${ENTITY_KINDS.join(", ")}.`);
1127 const reader = await this.reader(workspace, a.viewer);
1128 const row =
1129 (await this.db.prepare("SELECT * FROM entities WHERE workspace = ? AND id = ?").bind(workspace, a.id).first<EntityRow>()) ??
1130 (await this.db.prepare("SELECT * FROM entities WHERE workspace = ? AND kind = ? AND key = ? COLLATE NOCASE").bind(workspace, a.kind, a.id).first<EntityRow>());
1131 if (!row || row.kind !== a.kind || !this.visibleRow(row, reader)) return fail("not_found", `There is no such ${a.kind} in ${workspace}'s catalog.`);
1132 const [out, into] = await Promise.all([
1133 this.db
1134 .prepare("SELECT r.kind AS rel, e.* FROM relations r JOIN entities e ON e.id = r.to_id WHERE r.from_id = ? LIMIT 200")
1135 .bind(row.id)
1136 .all<EntityRow & { rel: RelationKind }>(),
1137 this.db
1138 .prepare("SELECT r.kind AS rel, e.* FROM relations r JOIN entities e ON e.id = r.from_id WHERE r.to_id = ? LIMIT 200")
1139 .bind(row.id)
1140 .all<EntityRow & { rel: RelationKind }>(),
1141 ]);
1142 const relations = [
1143 ...out.results.filter((r) => this.visibleRow(r, reader)).map((r) => ({ kind: r.rel, direction: "out" as const, entity: toEntity(r) })),
1144 ...into.results.filter((r) => this.visibleRow(r, reader)).map((r) => ({ kind: r.rel, direction: "in" as const, entity: toEntity(r) })),
1145 ];
1146 return ok({ entity: toEntity(row), relations });
1147 }
1148
1149 async search(a: { workspace: string; viewer: Viewer; query: string; project?: string | null; kinds?: SearchKind[] | null; limit?: number | null }): Promise<Result<SearchResult>> {
1150 const workspace = a.workspace.toLowerCase();
1151 const query = (a.query ?? "").trim().slice(0, 500);
1152 if (!query) return fail("invalid", "Say what to search for.");
1153 const reader = await this.reader(workspace, a.viewer);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1154 if (!reader.full && !reader.member && reader.visible.size === 0) return ok({ query, hits: [], mode: "text" });
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1155 const limit = Math.min(Math.max(a.limit ?? 20, 1), 50);
1156 const kinds = allowedKinds(reader, a.kinds);
1157 const wants = (kind: SearchKind) => !kinds || kinds.includes(kind);
1158 const project = a.project?.toLowerCase() || null;
1159
1160 // Semantic: the index, filtered to what this reader may see.
1161 let semantic: SearchHit[] = [];
1162 let mode: SearchResult["mode"] = "text";
1163 const vector = await this.embedQuery(workspace, query);
1164 if (vector && this.env.VECTORS) {
1165 try {
1166 const found = await this.env.VECTORS.query(vector, { topK: 20, returnMetadata: "all", filter: indexFilter(reader, { project, kinds }) as VectorizeVectorMetadataFilter });
1167 mode = "semantic";
1168 semantic = found.matches
1169 .map((match) => ({ id: match.id, score: match.score, meta: match.metadata as unknown as IndexMeta }))
1170 .filter((match) => match.meta && readable(match.meta, reader))
1171 .map((match) => ({
1172 kind: match.meta.kind as SearchKind,
1173 id: match.id.startsWith("memory:") ? match.id.slice(7) : match.id,
1174 title: match.meta.title,
1175 snippet: match.meta.snippet,
1176 project: match.meta.project || null,
1177 url: match.meta.url || null,
1178 score: match.score,
1179 source: match.meta.source,
1180 by: match.meta.by || null,
1181 updatedAt: match.meta.at || null,
1182 }));
1183 // Memory changes after it is indexed: show only what is still kept.
1184 const memoryIds = semantic.filter((hit) => hit.kind === "memory").map((hit) => hit.id);
1185 if (memoryIds.length) {
1186 const kept = new Set(
1187 (await memoryReviewClient(this.env.WORK).memoriesById(workspace, memoryIds))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1188 .filter((memory) => (memory.status ?? "kept") === "kept" && memoryReadable(memory.repo, reader))
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1189 .map((memory) => memory.id),
1190 );
1191 semantic = semantic.filter((hit) => hit.kind !== "memory" || kept.has(hit.id));
1192 }
1193 } catch (error) {
1194 console.error("semantic search failed; matching words instead", error);
1195 }
1196 }
1197
1198 // Text: every word, in the catalog, docs, issues and pull requests, and memory.
1199 const words = query.toLowerCase().split(/\s+/).filter(Boolean).slice(0, 6);
1200 const like = (columns: string) => words.map(() => `(${columns}) LIKE ?`).join(" AND ");
1201 const patterns = words.map((word) => `%${word.replace(/[%_]/g, "")}%`);
1202 const text: SearchHit[] = [];
1203 const entityKinds = ENTITY_KINDS.filter(wants);
1204 if (entityKinds.length) {
1205 const rows = await this.db
1206 .prepare(
1207 `SELECT * FROM entities WHERE workspace = ? AND kind IN (SELECT value FROM json_each(?)) ${project ? "AND (project = ? OR project IS NULL)" : ""}
1208 AND ${like("lower(name || ' ' || COALESCE(summary, ''))")} LIMIT 30`,
1209 )
1210 .bind(workspace, JSON.stringify(entityKinds), ...(project ? [project] : []), ...patterns)
1211 .all<EntityRow>();
1212 for (const row of rows.results.filter((r) => this.visibleRow(r, reader))) {
1213 text.push({ kind: row.kind, id: row.id, title: row.name, snippet: snippet(row.summary), project: row.project, url: row.ref, score: 0.3, source: "catalog", by: null, updatedAt: row.updated_at });
1214 }
1215 }
1216 const itemKinds = (["doc", "issue", "pull"] as const).filter(wants);
1217 if (itemKinds.length) {
1218 const rows = await this.db
1219 .prepare(
1220 `SELECT * FROM items WHERE workspace = ? AND kind IN (SELECT value FROM json_each(?)) ${project ? "AND project = ?" : ""}
1221 AND ${like("lower(title || ' ' || text)")} ORDER BY updated_at DESC LIMIT 30`,
1222 )
1223 .bind(workspace, JSON.stringify(itemKinds), ...(project ? [project] : []), ...patterns)
1224 .all<ItemRow>();
1225 for (const row of rows.results.filter((r) => this.visibleRow(r, reader))) {
1226 text.push({ kind: row.kind, id: row.id, title: row.title, snippet: snippet(row.text), project: row.project, url: row.url, score: 0.2, source: row.kind === "doc" ? "doc" : row.kind, by: row.by, updatedAt: row.updated_at });
1227 }
1228 }
1229 if (reader.member && wants("memory")) {
1230 const memories = await memoryReviewClient(this.env.WORK).searchMemories(workspace, query, { limit: 10 }).catch(() => [] as Memory[]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1231 for (const memory of memories.filter((m) => memoryReadable(m.repo, reader))) {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1232 text.push({
1233 kind: "memory",
1234 id: memory.id,
1235 title: `${memory.kind[0].toUpperCase()}${memory.kind.slice(1)}${memory.scope === "workspace" ? " (workspace)" : ""}`,
1236 snippet: snippet(memory.text),
1237 project: null,
1238 url: memory.repo ? `/${memory.repo.namespace}/${memory.repo.name}/memory` : `/${workspace}/-/memory`,
1239 score: memory.pinned ? 0.35 : 0.25,
1240 source: memorySource(memory),
1241 by: memory.createdBy,
1242 updatedAt: memory.updatedAt,
1243 });
1244 }
1245 }
1246 return ok({ query, hits: merge(semantic, text, limit), mode });
1247 }
1248
1249 async scorecards(a: { workspace: string; viewer: Viewer }): Promise<Result<Scorecard[]>> {
1250 const workspace = a.workspace.toLowerCase();
1251 if (!isMember(a.viewer, workspace)) return fail("forbidden", "Scorecards are for members of the workspace.");
1252 const listed = await projectsClient(this.env.PROJECTS).list(workspace, a.viewer);
1253 if (!listed.ok) return listed;
1254 const [entities, scans, deploys, security] = await Promise.all([
1255 this.db
1256 .prepare("SELECT * FROM entities WHERE workspace = ? AND kind IN ('project', 'doc')")
1257 .bind(workspace)
1258 .all<EntityRow>(),
1259 this.db.prepare("SELECT project_id, tests FROM scans WHERE workspace = ?").bind(workspace).all<{ project_id: string; tests: number }>(),
1260 deploymentsClient(this.env.DEPLOYMENTS)
1261 .overview(workspace, a.viewer)
1262 .then((found) => (found.ok ? found.value : []))
1263 .catch(() => [] as ProjectDeploys[]),
1264 this.env.SECURITY
1265 ? securityClient(this.env.SECURITY)
1266 .workspace(workspace, a.viewer)
1267 .then((found) => (found.ok ? found.value : null))
1268 .catch(() => null)
1269 : Promise.resolve(null),
1270 ]);
1271 const tests = new Map(scans.results.map((row) => [row.project_id, !!row.tests]));
1272 const cards: Scorecard[] = [];
1273 for (const project of listed.value) {
1274 if (project.source.kind !== "hosted") continue;
1275 const own = entities.results.filter((row) => row.project_id === project.id);
1276 const entry = own.find((row) => row.kind === "project");
1277 const data = entry ? toEntity(entry).data : {};
1278 const deploy = deploys.find((d) => d.slug === project.slug);
1279 const repoId = project.source.repoId;
1280 const findings = security?.find((repo) => repo.repoId === repoId);
1281 const rules = evaluate({
1282 name: project.name,
1283 owners: Array.isArray(data.owners) ? (data.owners as string[]) : [],
1284 docs: own.filter((row) => row.kind === "doc").map((row) => String(toEntity(row).data.path ?? "")),
1285 tests: tests.get(project.id) ?? false,
1286 testCommand: Array.isArray(data.testCommands) && data.testCommands.length ? String(data.testCommands[0]) : null,
1287 deploy: deploy
1288 ? {
1289 enabled: deploy.enabled,
1290 production: deploy.production ? { url: deploy.production.url } : null,
1291 latest: deploy.latest ? { kind: deploy.latest.kind, status: deploy.latest.status, error: deploy.latest.error } : null,
1292 }
1293 : null,
1294 secretFindings: security ? (findings?.secrets ?? 0) : null,
1295 });
1296 const applies = rules.filter((rule) => rule.status !== "na");
1297 cards.push({
1298 project: project.slug,
1299 name: project.name,
1300 repo: project.source.repo,
1301 passed: applies.filter((rule) => rule.status === "pass").length,
1302 total: applies.length,
1303 rules,
1304 });
1305 }
1306 return ok(cards);
1307 }
1308
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1309 /**
1310 * The Context section for an agent starting work, holding only what the
1311 * person it acts for (`requester`) may read: an outside collaborator's run
1312 * is told the project's memory, never the workspace's, and only the
1313 * projects around it they can read. No requester is the workspace's own
1314 * step. Never fails a run: on any trouble, nothing.
1315 */
1316 async runContext(a: { repoId: string; task?: string; budget?: number; requester?: Viewer }): Promise<RunContext> {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1317 try {
1318 const projects = (await projectsClient(this.env.PROJECTS).byRepo(a.repoId)).slice(0, 2);
1319 if (projects.length === 0) return { text: null, sources: [] };
1320 const workspace = projects[0].workspace;
1321 const actor = await this.workspaceActor(workspace);
1322 if (!actor) return { text: null, sources: [] };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1323 const reader = a.requester ? await this.reader(workspace, a.requester) : null;
1324 const home = projects.find((project) => project.source.kind === "hosted");
1325 const repoKey = home?.source.kind === "hosted" ? `${home.source.repo.namespace}/${home.source.repo.name}` : "";
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1326 const cache = new WorkspaceCache(this.env, workspace, actor);
1327 const deploys = await cache.deployments();
1328 const live = new Map(deploys.map((d) => [d.slug, d]));
1329 const contexts: ProjectContext[] = [];
1330 for (const project of projects) {
1331 if (project.source.kind !== "hosted") continue;
1332 const rows = (await this.db.prepare("SELECT * FROM entities WHERE workspace = ? AND project_id = ?").bind(workspace, project.id).all<EntityRow>()).results.map(toEntity);
1333 const entry = rows.find((row) => row.kind === "project");
1334 const graph = await projectsClient(this.env.PROJECTS).graph(project.id).catch(() => ({ dependsOn: [], usedBy: [] }));
1335 const deploy = live.get(project.slug);
1336 contexts.push({
1337 slug: project.slug,
1338 name: project.name,
1339 repo: `${project.source.repo.namespace}/${project.source.repo.name}`,
1340 rootDir: project.source.rootDir,
1341 languages: Array.isArray(entry?.data.languages) ? (entry!.data.languages as string[]) : [],
1342 packages: rows.filter((row) => row.kind === "package").map((row) => row.name).slice(0, 5),
1343 testCommands: Array.isArray(entry?.data.testCommands) ? (entry!.data.testCommands as string[]) : [],
1344 owners: Array.isArray(entry?.data.owners) ? (entry!.data.owners as string[]) : [],
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1345 dependsOn: graph.dependsOn
1346 .filter((dep) => projectReadable(dep.slug, reader))
1347 .map((dep) => ({ slug: dep.slug, as: dep.as, url: live.get(dep.slug)?.production?.url ?? null })),
1348 usedBy: graph.usedBy.filter((dep) => projectReadable(dep.slug, reader)).map((dep) => ({ slug: dep.slug })),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1349 environments: deploy?.enabled
1350 ? [{ name: "Production", url: deploy.production?.url ?? null, status: deploy.latest?.kind === "production" ? deploy.latest.status : deploy.production ? "ready" : null }]
1351 : [],
1352 docs: rows.filter((row) => row.kind === "doc").map((row) => String(row.data.path ?? row.name)),
1353 });
1354 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1355 // Workspace memory (no project) only for a run its members may be told it.
1356 const slugs = new Set([...(reader && !reader.member ? [] : [""]), ...projects.map((project) => project.slug)]);
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1357 const review = memoryReviewClient(this.env.WORK);
1358 // The memories closest to the task, less the pinned ones every run already has.
1359 let memories: ContextNote[] = [];
1360 const task = (a.task ?? "").trim();
1361 const vector = task ? await this.embedQuery(workspace, task.slice(0, 1500)) : null;
1362 if (vector && this.env.VECTORS) {
1363 const found = await this.env.VECTORS.query(vector, { topK: 20, returnMetadata: "all", filter: { workspace, kind: "memory" } }).catch(() => null);
1364 const ids = (found?.matches ?? [])
1365 .filter((match) => slugs.has(String((match.metadata as { project?: string } | undefined)?.project ?? "")) && match.score >= 0.5)
1366 .map((match) => match.id.slice("memory:".length));
1367 if (ids.length) {
1368 const byId = new Map((await review.memoriesById(workspace, ids)).map((memory) => [memory.id, memory]));
1369 memories = ids
1370 .map((id) => byId.get(id))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1371 .filter((memory): memory is Memory => !!memory && (memory.status ?? "kept") === "kept" && !memory.pinned && runMemoryReadable(memory, reader, repoKey))
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1372 .slice(0, 6)
1373 .map((memory) => ({ id: memory.id, kind: memory.kind, text: memory.text, source: memorySource(memory) }));
1374 }
1375 }
1376 const shown = new Set(memories.map((note) => note.id));
1377 const decisions = (await review.searchMemories(workspace, null, { repoIds: [a.repoId], limit: 100 }).catch(() => [] as Memory[]))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1378 .filter((memory) => memory.kind === "decision" && !memory.pinned && !shown.has(memory.id) && runMemoryReadable(memory, reader, repoKey))
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1379 .sort((x, y) => y.updatedAt.localeCompare(x.updatedAt))
1380 .slice(0, 3)
1381 .map((memory) => ({ id: memory.id, kind: memory.kind, text: memory.text, source: memorySource(memory) }));
1382 return composeRunContext({ projects: contexts, memories, decisions, budget: Math.min(Math.max(a.budget ?? 4000, 500), 12_000) });
1383 } catch (error) {
1384 console.error("no run context for", a.repoId, error);
1385 return { text: null, sources: [] };
1386 }
1387 }
1388}
1389
1390export default {
1391 async fetch(request: Request, env: Env): Promise<Response> {
1392 const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/);
1393 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
1394 const service = new Context(env);
1395 const args = (await request.json().catch(() => ({}))) as any;
1396 switch (match[1]) {
1397 case "catalog":
1398 return Response.json(await service.catalog(args));
1399 case "entity":
1400 return Response.json(await service.entity(args));
1401 case "search":
1402 return Response.json(await service.search(args));
1403 case "scorecards":
1404 return Response.json(await service.scorecards(args));
1405 case "backfill":
1406 return Response.json(await service.backfill(args));
1407 case "status":
1408 return Response.json(await service.status(args));
1409 case "run_context":
1410 return Response.json(await service.runContext(args));
1411 default:
1412 return new Response("Unknown method\n", { status: 404 });
1413 }
1414 },
1415
1416 async queue(batch: MessageBatch<G1tEvent | Job>, env: Env): Promise<void> {
1417 const service = new Context(env);
1418 for (const message of batch.messages) {
1419 try {
1420 if (batch.queue === "g1t-context-jobs") await service.runJob(message.body as Job);
1421 else await service.onEvent(message.body as G1tEvent);
1422 message.ack();
1423 } catch (error) {
1424 console.error("context could not handle", (message.body as { type?: string }).type, error);
1425 message.retry();
1426 }
1427 }
1428 },
1429} satisfies ExportedHandler<Env, G1tEvent | Job>;

This file's history is long; its oldest lines are credited to the oldest commit read.