Skip to content

g1t/services/projects/src/index.ts

1,195 lines53,879 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.

Projects: what a workspace builds and runs, first on every page1/**
2 * The projects service: what a workspace builds and runs.
3 *
4 * A project has one source, where its code lives: today a repository hosted
5 * on g1t and a root directory in it. Everything about running it
6 * (deployments, environments, domains, secrets and variables) hangs off the
7 * project; other services key their data by its id. Every repository gets
8 * a project of its own name: when it is created (from `repo.created`), and
9 * for repositories made before projects existed, the first time their
10 * workspace's projects are asked for.
11 *
12 * Reached through service bindings: `POST /rpc/<method>`.
13 */
14
Project dependencies: addresses, preview stacks, Affects, and agents who know15import { parse as parseYaml } from "yaml";
16
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look17import { NEEDS, repoRef } from "./access";
Fast pages, required checks on the branch, self-hosted runners, honest incidents18import { effectiveDescription, ownDescription } from "./description";
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9719import {
20 type KindFacts,
21 type RootFiles,
22 MANIFESTS,
23 detectKind,
24 detectedKind,
25 goFilesToRead,
26 kindSetting,
27 neverRuns,
28 nextSetting,
29 resolveKind,
30 runsSetting,
31} from "./kind";
32import { cleanLinks, cleanUrl, projectLinks } from "./links";
Projects: when each was last pushed, and how active it is lately33import { ACTIVE, decayed, raised } from "./activity";
Projects: pinned and recent projects, per person per workspace34import { MAX_PINS, MAX_RECENT, MAX_VISITS, placePin, recentAfterPins, reorderPins } from "./pins";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains35import { renameStatements } from "./rename";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look36import { moveStatements, slugOf, strandedQuery } from "./transfer";
Agents and memory, checks and conflicts, profiles, slug renames, custom domains37
Projects: what a workspace builds and runs, first on every page38import {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look39 can,
40 currentMovedPath,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains41 currentWorkspaceSlug,
Projects: what a workspace builds and runs, first on every page42 fail,
43 identityClient,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains44 staleSlugs,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look45 needs,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member46 isProtectedWorkspace,
Projects: what a workspace builds and runs, first on every page47 newId,
48 ok,
Fast pages, required checks on the branch, self-hosted runners, honest incidents49 openD1,
A project is an app or a library: libraries show their package and how to ship a release, not production50 packagesClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look51 permission,
52 repoMove,
Projects: what a workspace builds and runs, first on every page53 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look54 staleMovedPaths,
Project dependencies: addresses, preview stacks, Affects, and agents who know55 type Dependencies,
56 type DependencyLink,
A project is an app or a library: libraries show their package and how to ship a release, not production57 type Ecosystem,
Projects: what a workspace builds and runs, first on every page58 type G1tEvent,
59 type NewProject,
60 type Project,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9761 type ProjectChanges,
62 type ProjectKind,
A project is an app or a library: libraries show their package and how to ship a release, not production63 type ProjectEcosystem,
Project dependencies: addresses, preview stacks, Affects, and agents who know64 type ProjectGraph,
Projects: pinned and recent projects, per person per workspace65 type ProjectShortcuts,
Projects: what a workspace builds and runs, first on every page66 type Repo,
67 type Result,
68 type ServiceBinding,
69 type User,
70 type Viewer,
71} from "@g1t/contracts";
72
73type Env = {
74 DB: D1Database;
75 REPOS: ServiceBinding;
76 IDENTITY: ServiceBinding;
A project is an app or a library: libraries show their package and how to ship a release, not production77 PACKAGES: ServiceBinding;
78 DEPLOYMENTS: ServiceBinding;
Projects: what a workspace builds and runs, first on every page79};
80
81type Row = {
82 id: string;
83 workspace: string;
84 slug: string;
85 name: string;
Fast pages, required checks on the branch, self-hosted runners, honest incidents86 /** The project's own description; null while it follows its repository's. */
Projects: what a workspace builds and runs, first on every page87 description: string | null;
Fast pages, required checks on the branch, self-hosted runners, honest incidents88 /** Its repository's description, kept from repos. */
89 repo_description?: string | null;
Projects: what a workspace builds and runs, first on every page90 source_kind: string;
91 repo_id: string;
92 repo_namespace: string;
93 repo_name: string;
94 repo_private: number;
95 default_branch: string;
96 root_dir: string;
97 is_primary: number;
98 created_by: string;
99 created_at: string;
100 updated_at: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look101 /** Set while its repository is deleted; the project is hidden until it is restored. */
102 repo_deleted_at: string | null;
103 /** When its repository was archived; null while it is not. */
104 repo_archived_at?: string | null;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97105 /** What a person set it to be and where it runs; null left to detection (migrations/0010_kind_and_links.sql). */
106 kind_setting?: string | null;
107 runs_setting?: string | null;
108 production_url?: string | null;
109 /** Its own homepage; null follows `repo_website`. */
110 homepage?: string | null;
111 repo_website?: string | null;
112 docs_url?: string | null;
113 /** Its other links, as JSON (src/links.ts). */
114 links?: string | null;
A project is an app or a library: libraries show their package and how to ship a release, not production115 detected_kind?: string | null;
116 detected_detail?: string | null;
117 detected_ecosystem?: string | null;
118 /** The default branch's commit its files were read at; null until they have been. */
119 detected_commit?: string | null;
120 linked_package?: string | null;
121 deployments_on?: number | null;
Projects: when each was last pushed, and how active it is lately122 /** When its repository was last pushed to (migrations/0009_activity.sql). */
123 pushed_at?: string | null;
124 /** How active it is lately, as of `active_at` (src/activity.ts). */
125 activity?: number | null;
126 active_at?: string | null;
Projects: what a workspace builds and runs, first on every page127};
128
A project is an app or a library: libraries show their package and how to ship a release, not production129/** How a package its repository publishes is named in why a project is a library. */
130const ECOSYSTEM_NAME: Record<Ecosystem, string> = {
131 container: "container image",
132 npm: "npm package",
133 composer: "Composer package",
134 cargo: "crate",
135 go: "Go module",
Projects: a library publishing a Maven, NuGet or RubyGems package says so136 maven: "Maven package",
137 nuget: "NuGet package",
138 rubygems: "gem",
A project is an app or a library: libraries show their package and how to ship a release, not production139};
140
141/** How many projects one listing reads the files of, in the background, before it has. */
142const DETECT_PER_LIST = 10;
143
Projects: what a workspace builds and runs, first on every page144const now = () => new Date().toISOString();
145
Project dependencies: addresses, preview stacks, Affects, and agents who know146/** A variable's name for a dependency's address, spelled as secrets' names are. */
147const ALIAS = /^[A-Z_][A-Z0-9_]{0,99}$/;
148/** How many dependencies a project may declare. */
149const MAX_DEPENDENCIES = 50;
150
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look151type LinkRow = { slug: string; name: string; alias: string | null; source: "ui" | "file"; repo_id: string; repo_namespace: string; repo_private: number };
Project dependencies: addresses, preview stacks, Affects, and agents who know152type NodeRow = { id: string; slug: string; workspace: string; alias: string | null };
153
Projects: what a workspace builds and runs, first on every page154function toProject(row: Row): Project {
Fast pages, required checks on the branch, self-hosted runners, honest incidents155 const { description, inherited } = effectiveDescription(row);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97156 const setting = { kind: kindSetting(row.kind_setting), runs: runsSetting(row.runs_setting) };
157 const facts: Omit<KindFacts, "setting"> = {
A project is an app or a library: libraries show their package and how to ship a release, not production158 deploymentsOn: row.deployments_on == null ? null : !!row.deployments_on,
159 linkedPackage: row.linked_package ?? null,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97160 detected: row.detected_commit == null ? null : { kind: kindSetting(row.detected_kind), detail: row.detected_detail ?? "" },
A project is an app or a library: libraries show their package and how to ship a release, not production161 };
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97162 const { kind, reason, runs } = resolveKind({ setting, ...facts });
Projects: what a workspace builds and runs, first on every page163 return {
164 id: row.id,
165 workspace: row.workspace,
166 slug: row.slug,
167 name: row.name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents168 description,
169 descriptionInherited: inherited,
Projects: what a workspace builds and runs, first on every page170 source: {
171 kind: "hosted",
172 repoId: row.repo_id,
173 repo: { namespace: row.repo_namespace, name: row.repo_name },
174 rootDir: row.root_dir,
175 defaultBranch: row.default_branch,
176 },
177 private: !!row.repo_private,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look178 archived: !!row.repo_archived_at,
Projects: what a workspace builds and runs, first on every page179 primary: !!row.is_primary,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97180 setting,
A project is an app or a library: libraries show their package and how to ship a release, not production181 kind,
182 kindReason: reason,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97183 runs,
184 productionUrl: row.production_url ?? null,
185 detected: detectedKind(facts),
A project is an app or a library: libraries show their package and how to ship a release, not production186 ecosystem: (row.detected_ecosystem as ProjectEcosystem | null) ?? null,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97187 links: projectLinks(row),
Projects: what a workspace builds and runs, first on every page188 createdBy: row.created_by,
189 createdAt: row.created_at,
190 updatedAt: row.updated_at,
Projects: when each was last pushed, and how active it is lately191 pushedAt: row.pushed_at ?? null,
192 activity: decayed(row.activity ?? 0, row.active_at ?? null, now()),
Projects: what a workspace builds and runs, first on every page193 };
194}
195
196function isMember(viewer: Viewer, workspace: string): boolean {
197 return !!viewer?.workspaces?.some((m) => m.slug === workspace.toLowerCase());
198}
199
200class Projects {
A project is an app or a library: libraries show their package and how to ship a release, not production201 /** `defer` runs work after the answer is sent: the request's waitUntil. */
202 constructor(
203 private readonly env: Env,
204 private readonly defer: (work: Promise<unknown>) => void = () => {},
205 ) {}
Projects: what a workspace builds and runs, first on every page206
207 private get db() {
208 return this.env.DB;
209 }
210
211 private async workspaceActor(slug: string): Promise<User | null> {
212 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
213 if (!workspace) return null;
214 return {
215 id: workspace.id,
216 username: workspace.slug,
217 kind: "workspace",
218 verified: true,
219 workspaces: [{ slug: workspace.slug, role: "member" }],
220 };
221 }
222
223 /** A free slug for `wanted` in the workspace. */
224 private async freeSlug(workspace: string, wanted: string): Promise<string> {
225 const base = slugOf(wanted) || "project";
226 for (let n = 1; n < 100; n++) {
227 const slug = n === 1 ? base : `${base}-${n}`;
228 const taken = await this.db
229 .prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?")
230 .bind(workspace, slug)
231 .first();
232 if (!taken) return slug;
233 }
234 return `${base}-${crypto.randomUUID().slice(0, 6)}`;
235 }
236
237 /** Gives a repository its own project, unless it has one. */
238 private async ensureFor(repo: Repo, createdBy: string): Promise<void> {
239 if (repo.forkOf) return;
240 const existing = await this.db.prepare("SELECT id FROM projects WHERE repo_id = ?").bind(repo.id).first();
241 if (existing) {
242 await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look243 .prepare(
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97244 "UPDATE projects SET repo_private = ?, default_branch = ?, repo_name = ?, repo_archived_at = ?, repo_description = ?, repo_website = ? WHERE repo_id = ?",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look245 )
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97246 .bind(repo.isPrivate ? 1 : 0, repo.defaultBranch, repo.name, repo.archivedAt ?? null, repo.description ?? null, repo.website ?? null, repo.id)
Projects: what a workspace builds and runs, first on every page247 .run();
248 return;
249 }
250 const at = now();
251 await this.db
252 .prepare(
Fast pages, required checks on the branch, self-hosted runners, honest incidents253 // No description of its own: it shows the repository's, as that changes.
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97254 `INSERT INTO projects (id, workspace, slug, name, description, repo_description, repo_website, repo_id, repo_namespace, repo_name, repo_private,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look255 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97256 VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, '', 1, ?, ?, ?, ?)
Projects: what a workspace builds and runs, first on every page257 ON CONFLICT (workspace, slug) DO NOTHING`,
258 )
259 .bind(
260 newId("prj"),
261 repo.namespace.toLowerCase(),
262 await this.freeSlug(repo.namespace.toLowerCase(), repo.name),
263 repo.name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents264 repo.description ?? null,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97265 repo.website ?? null,
Projects: what a workspace builds and runs, first on every page266 repo.id,
267 repo.namespace,
268 repo.name,
269 repo.isPrivate ? 1 : 0,
270 repo.defaultBranch,
271 createdBy,
272 at,
273 at,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look274 repo.archivedAt ?? null,
Projects: what a workspace builds and runs, first on every page275 )
276 .run();
277 }
278
279 /** Projects for a workspace's repositories made before projects existed. Once. */
280 private async backfill(workspace: string): Promise<void> {
281 const done = await this.db.prepare("SELECT 1 FROM backfilled WHERE workspace = ?").bind(workspace).first();
282 if (done) return;
283 const actor = await this.workspaceActor(workspace);
284 if (!actor) return;
285 const list = await reposClient(this.env.REPOS).list(actor, { namespace: workspace });
286 for (const repo of list) {
287 if (repo.namespace.toLowerCase() === workspace) await this.ensureFor(repo, "g1t");
288 }
289 await this.db.prepare("INSERT OR REPLACE INTO backfilled (workspace, at) VALUES (?, ?)").bind(workspace, now()).run();
290 }
291
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look292 /**
293 * Whether the viewer can read the project's repository. The workspace's
294 * own token sees all its projects, also one left building from a
295 * repository that moved to another workspace.
296 */
Projects: what a workspace builds and runs, first on every page297 private visible(row: Row, viewer: Viewer): boolean {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look298 if (viewer?.kind === "workspace" && isMember(viewer, row.workspace)) return true;
299 return permission(viewer, repoRef(row)) != null;
300 }
301
302 /**
303 * Whether the actor may change the project: not found when they cannot
304 * read its repository, refused when their role there is too low.
305 */
306 private changeable(row: Row, actor: User, capability: (typeof NEEDS)[keyof typeof NEEDS]): Result<true> {
307 if (!this.visible(row, actor)) return fail("not_found", "There is no such project.");
308 if (!can(actor, repoRef(row), capability)) return fail("forbidden", needs(capability));
309 return ok(true);
Projects: what a workspace builds and runs, first on every page310 }
311
312 async list(a: { workspace: string; viewer: Viewer }): Promise<Result<Project[]>> {
313 const workspace = a.workspace.toLowerCase();
314 await this.backfill(workspace);
315 const rows = await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look316 .prepare("SELECT * FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL ORDER BY name COLLATE NOCASE")
Projects: what a workspace builds and runs, first on every page317 .bind(workspace)
318 .all<Row>();
A project is an app or a library: libraries show their package and how to ship a release, not production319 // Projects whose files have not been read yet are read after this answer,
320 // a few at a time; until then they show as apps, as before.
321 const unread = rows.results.filter((row) => row.detected_commit == null).slice(0, DETECT_PER_LIST);
322 if (unread.length) this.defer(Promise.all(unread.map((row) => this.detect(row).catch((error) => console.warn("detect", row.slug, error)))));
Projects: what a workspace builds and runs, first on every page323 return ok(rows.results.filter((row) => this.visible(row, a.viewer)).map(toProject));
324 }
325
326 async get(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Project>> {
327 const workspace = a.workspace.toLowerCase();
328 await this.backfill(workspace);
329 const row = await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look330 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Projects: what a workspace builds and runs, first on every page331 .bind(workspace, a.slug.toLowerCase())
332 .first<Row>();
333 if (!row || !this.visible(row, a.viewer)) return fail("not_found", "There is no such project.");
A project is an app or a library: libraries show their package and how to ship a release, not production334 if (row.detected_commit == null) {
335 // Once per project: what its files say, read now so its first page is right.
336 const read = await this.detect(row).catch((error) => (console.warn("detect", row.slug, error), null));
337 if (read) return ok(toProject(read));
338 }
Projects: what a workspace builds and runs, first on every page339 return ok(toProject(row));
340 }
341
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member342 /** How many projects a workspace shows: what deleting it would take with it. */
343 async count(a: { workspace: string }): Promise<number> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look344 const row = await this.db
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member345 .prepare("SELECT count(*) AS n FROM projects WHERE workspace = ? AND repo_deleted_at IS NULL")
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look346 .bind(a.workspace.toLowerCase())
347 .first<{ n: number }>();
348 return row?.n ?? 0;
349 }
350
351 /** The repository as it is now, read as its workspace; null when it is gone or deleted. */
352 private async repoById(repoId: string): Promise<Repo | null> {
Projects: what a workspace builds and runs, first on every page353 const path = await this.env.REPOS.fetch("https://repos/rpc/path_by_id", {
354 method: "POST",
355 headers: { "content-type": "application/json" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look356 body: JSON.stringify({ id: repoId }),
Projects: what a workspace builds and runs, first on every page357 });
358 const repoPath = path.ok ? ((await path.json()) as { namespace: string; name: string } | null) : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look359 if (!repoPath) return null;
Projects: what a workspace builds and runs, first on every page360 const actor = await this.workspaceActor(repoPath.namespace);
361 const repo = actor ? await reposClient(this.env.REPOS).get(repoPath, actor) : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look362 return repo?.ok ? repo.value : null;
363 }
364
365 async byRepo(a: { repoId: string }): Promise<Project[]> {
366 const rows = await this.db
367 .prepare("SELECT * FROM projects WHERE repo_id = ? ORDER BY is_primary DESC, created_at")
368 .bind(a.repoId)
369 .all<Row>();
370 // A deleted repository's projects are hidden until it is restored.
371 if (rows.results.length > 0) return rows.results.filter((row) => !row.repo_deleted_at).map(toProject);
372 // A repository from before projects: give it its own now.
373 const repo = await this.repoById(a.repoId);
374 if (!repo) return [];
375 await this.ensureFor(repo, "g1t");
376 const again = await this.db
377 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL")
378 .bind(a.repoId)
379 .all<Row>();
Projects: what a workspace builds and runs, first on every page380 return again.results.map(toProject);
381 }
382
383 async create(a: { actor: User; workspace: string; input: NewProject }): Promise<Result<Project>> {
384 const workspace = a.workspace.toLowerCase();
385 if (!isMember(a.actor, workspace)) return fail("forbidden", "Only members can add projects to a workspace.");
386 if (a.input.repo.namespace.toLowerCase() !== workspace) {
387 return fail("invalid", "A project builds from one of its own workspace's repositories.");
388 }
389 const repo = await reposClient(this.env.REPOS).get(a.input.repo, a.actor);
390 if (!repo.ok) return repo;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look391 if (!can(a.actor, repo.value, NEEDS.create)) return fail("forbidden", needs(NEEDS.create));
Projects: what a workspace builds and runs, first on every page392 if (repo.value.forkOf) return fail("invalid", "A pull request's working copy cannot be a project's source.");
393 const name = a.input.name.trim();
394 if (!name || name.length > 100) return fail("invalid", "A project's name is 1 to 100 characters.");
395 const rootDir = (a.input.rootDir ?? "").trim().replace(/^\/+|\/+$/g, "");
396 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
397 const slug = slugOf(name);
398 if (!slug) return fail("invalid", "Give the project a name with letters or digits.");
399 const taken = await this.db.prepare("SELECT 1 FROM projects WHERE workspace = ? AND slug = ?").bind(workspace, slug).first();
400 if (taken) return fail("conflict", `${workspace} already has a project called ${slug}.`);
401 const primary = !(await this.db.prepare("SELECT 1 FROM projects WHERE repo_id = ?").bind(repo.value.id).first());
402 const at = now();
403 const id = newId("prj");
404 await this.db
405 .prepare(
Fast pages, required checks on the branch, self-hosted runners, honest incidents406 `INSERT INTO projects (id, workspace, slug, name, description, repo_description, repo_id, repo_namespace, repo_name, repo_private,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look407 default_branch, root_dir, is_primary, created_by, created_at, updated_at, repo_archived_at)
Fast pages, required checks on the branch, self-hosted runners, honest incidents408 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Projects: what a workspace builds and runs, first on every page409 )
410 .bind(
411 id,
412 workspace,
413 slug,
414 name,
Fast pages, required checks on the branch, self-hosted runners, honest incidents415 ownDescription(null, a.input.description ?? null),
416 repo.value.description ?? null,
Projects: what a workspace builds and runs, first on every page417 repo.value.id,
418 repo.value.namespace,
419 repo.value.name,
420 repo.value.isPrivate ? 1 : 0,
421 repo.value.defaultBranch,
422 rootDir,
423 primary ? 1 : 0,
424 a.actor.username,
425 at,
426 at,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look427 repo.value.archivedAt ?? null,
Projects: what a workspace builds and runs, first on every page428 )
429 .run();
430 return ok(toProject((await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(id).first<Row>())!));
431 }
432
433 async update(a: {
434 actor: User;
435 workspace: string;
436 slug: string;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97437 changes: ProjectChanges;
Projects: what a workspace builds and runs, first on every page438 }): Promise<Result<Project>> {
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97439 const changes = a.changes ?? {};
Projects: what a workspace builds and runs, first on every page440 const workspace = a.workspace.toLowerCase();
441 const row = await this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look442 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Projects: what a workspace builds and runs, first on every page443 .bind(workspace, a.slug.toLowerCase())
444 .first<Row>();
445 if (!row) return fail("not_found", "There is no such project.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look446 const allowed = this.changeable(row, a.actor, NEEDS.update);
447 if (!allowed.ok) return allowed;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97448 const name = changes.name?.trim() || row.name;
Fast pages, required checks on the branch, self-hosted runners, honest incidents449 // Blank or null goes back to following the repository's description.
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97450 const description = ownDescription(row.description, changes.description);
451 const rootDir = changes.rootDir === undefined ? row.root_dir : String(changes.rootDir).trim().replace(/^\/+|\/+$/g, "");
Projects: what a workspace builds and runs, first on every page452 if (rootDir.split("/").some((part) => part === "..")) return fail("invalid", "The root directory is inside the repository.");
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97453 const known = (value: unknown, allowed: readonly string[], what: string) =>
454 value === undefined || allowed.includes(value as string) ? null : fail("invalid", `${what} is one of ${allowed.join(", ")}.`);
455 const badKind = known(changes.kind, ["auto", "app", "library", "tool", "docs", "other"], "kind");
456 if (badKind) return badKind;
457 const badRuns = known(changes.runs, ["auto", "g1t", "elsewhere"], "runs");
458 if (badRuns) return badRuns;
459 const before = { kind: kindSetting(row.kind_setting), runs: runsSetting(row.runs_setting) };
460 const setting = nextSetting(before, { kind: changes.kind as ProjectKind | "auto" | undefined, runs: changes.runs });
461 // Something that never runs, while Deployments run, would leave apps up
462 // that no page offers: a person turns them off first.
463 if (neverRuns(setting) && !neverRuns(before) && (row.deployments_on ?? (await this.deploymentsOn(row.id)))) {
A project is an app or a library: libraries show their package and how to ship a release, not production464 return fail("conflict", "Deployments are on for this project. Turn them off in its Deployments settings first.");
465 }
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97466 const url = (value: string | null | undefined, current: string | null | undefined, what: string) =>
467 value === undefined ? { ok: true as const, value: current ?? null } : cleanUrl(value, what);
468 const production = url(changes.productionUrl, row.production_url, "The production address");
469 if (!production.ok) return fail("invalid", production.message);
470 const homepage = url(changes.homepage, row.homepage, "The homepage");
471 if (!homepage.ok) return fail("invalid", homepage.message);
472 const docs = url(changes.docsUrl, row.docs_url, "The docs address");
473 if (!docs.ok) return fail("invalid", docs.message);
474 let linksJson = row.links ?? null;
475 if (changes.links !== undefined) {
476 const links = cleanLinks(changes.links);
477 if (!links.ok) return fail("invalid", links.message);
478 linksJson = links.value.length ? JSON.stringify(links.value) : null;
479 }
Projects: what a workspace builds and runs, first on every page480 await this.db
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97481 .prepare(
482 `UPDATE projects SET name = ?, description = ?, root_dir = ?, kind_setting = ?, runs_setting = ?, production_url = ?,
483 homepage = ?, docs_url = ?, links = ?, updated_at = ? WHERE id = ?`,
484 )
485 .bind(
486 name.slice(0, 100),
487 description,
488 rootDir,
489 setting.kind,
490 setting.runs,
491 production.value,
492 homepage.value,
493 docs.value,
494 linksJson,
495 now(),
496 row.id,
497 )
Projects: what a workspace builds and runs, first on every page498 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production499 let saved = (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>())!;
500 // Another root has other files: read them again.
501 if (rootDir !== row.root_dir) saved = (await this.detect({ ...saved, detected_commit: null }).catch(() => null)) ?? saved;
502 return ok(toProject(saved));
Projects: what a workspace builds and runs, first on every page503 }
504
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97505 async publicLinks(a: { repos: { namespace: string; name: string }[] }): Promise<Record<string, string>> {
506 const keys = [...new Set((Array.isArray(a.repos) ? a.repos : []).slice(0, 100).map((r) => `${r.namespace}/${r.name}`.toLowerCase()))];
507 if (keys.length === 0) return {};
508 const rows = await this.db
509 .prepare(
510 `SELECT * FROM projects WHERE is_primary = 1 AND repo_private = 0 AND repo_deleted_at IS NULL
511 AND lower(repo_namespace || '/' || repo_name) IN (${keys.map(() => "?").join(", ")})`,
512 )
513 .bind(...keys)
514 .all<Row>();
515 const out: Record<string, string> = {};
516 for (const row of rows.results) {
517 const project = toProject(row);
518 const link = (project.runs === "elsewhere" ? project.productionUrl : null) ?? project.links.homepage ?? project.links.docs;
519 if (link) out[`${row.repo_namespace}/${row.repo_name}`.toLowerCase()] = link;
520 }
521 return out;
522 }
523
A project is an app or a library: libraries show their package and how to ship a release, not production524 async deploymentsChanged(a: { projectId: string; enabled: boolean }): Promise<void> {
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97525 // Turned on, it runs on g1t: one said to run elsewhere moved here.
526 await this.db
527 .prepare(
528 "UPDATE projects SET deployments_on = ?1, runs_setting = CASE WHEN ?1 = 1 AND runs_setting = 'elsewhere' THEN 'g1t' ELSE runs_setting END WHERE id = ?2",
529 )
530 .bind(a.enabled ? 1 : 0, a.projectId)
531 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production532 }
533
Projects: pinned and recent projects, per person per workspace534 // ---- Pinned and recent ---------------------------------------------------
535
536 /** The person's pins in the workspace, in order, as rows: hidden projects left out. */
537 private async pinRows(userId: string, workspace: string): Promise<Row[]> {
538 const rows = await this.db
539 .prepare(
540 `SELECT p.* FROM pins n JOIN projects p ON p.id = n.project_id
541 WHERE n.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY n.position, n.pinned_at`,
542 )
543 .bind(userId, workspace.toLowerCase())
544 .all<Row>();
545 return rows.results;
546 }
547
548 /** Writes the person's pins in the workspace in this order, by project id. */
549 private async writePins(userId: string, order: string[], dropped: string[] = []): Promise<void> {
550 const at = now();
551 await this.db.batch([
552 ...dropped.map((id) => this.db.prepare("DELETE FROM pins WHERE user_id = ? AND project_id = ?").bind(userId, id)),
553 ...order.map((id, position) =>
554 this.db
555 .prepare(
556 `INSERT INTO pins (user_id, project_id, position, pinned_at) VALUES (?, ?, ?, ?)
557 ON CONFLICT (user_id, project_id) DO UPDATE SET position = excluded.position`,
558 )
559 .bind(userId, id, position, at),
560 ),
561 ]);
562 }
563
564 /** The person behind a call about their own pins, or why there is none. */
565 private person(viewer: Viewer): Result<User> {
566 if (!viewer) return fail("unauthenticated", "Sign in to pin projects.");
567 if (viewer.kind && viewer.kind !== "user") return fail("forbidden", "Pins are a person's own: use a personal access token.");
568 return ok(viewer);
569 }
570
571 /** A project the person can see, for pinning. */
572 private async pinnable(actor: User, workspace: string, slug: string): Promise<Result<Row>> {
573 const row = await this.row(workspace, slug);
574 if (!row || !this.visible(row, actor)) return fail("not_found", "There is no such project.");
575 return ok(row);
576 }
577
578 async shortcuts(a: { workspace: string; viewer: Viewer }): Promise<ProjectShortcuts> {
579 const who = this.person(a.viewer);
580 if (!who.ok) return { pinned: [], recent: [] };
581 const workspace = a.workspace.toLowerCase();
582 const [pinned, visited] = await Promise.all([
583 this.pinRows(who.value.id, workspace),
584 this.db
585 .prepare(
586 `SELECT p.* FROM visits v JOIN projects p ON p.id = v.project_id
587 WHERE v.user_id = ? AND p.workspace = ? AND p.repo_deleted_at IS NULL ORDER BY v.visited_at DESC LIMIT ?`,
588 )
589 .bind(who.value.id, workspace, MAX_PINS + MAX_RECENT)
590 .all<Row>(),
591 ]);
592 const shown = (row: Row) => this.visible(row, who.value);
593 const pins = pinned.filter(shown);
594 return {
595 pinned: pins.map(toProject),
596 recent: recentAfterPins(visited.results.filter(shown), pins.map((row) => row.id)).map(toProject),
597 };
598 }
599
600 async pin(a: { actor: User; workspace: string; slug: string; position?: number | null }): Promise<Result<Project[]>> {
601 const who = this.person(a.actor);
602 if (!who.ok) return who;
603 const found = await this.pinnable(who.value, a.workspace, a.slug);
604 if (!found.ok) return found;
605 const current = await this.pinRows(who.value.id, found.value.workspace);
606 const order = placePin(
607 current.map((row) => row.id),
608 found.value.id,
609 a.position,
610 );
611 if (!order) return fail("conflict", `You can pin ${MAX_PINS} projects in a workspace. Unpin one first.`);
612 await this.writePins(who.value.id, order);
613 return ok((await this.pinRows(who.value.id, found.value.workspace)).filter((row) => this.visible(row, who.value)).map(toProject));
614 }
615
616 async unpin(a: { actor: User; workspace: string; slug: string }): Promise<Result<Project[]>> {
617 const who = this.person(a.actor);
618 if (!who.ok) return who;
619 const row = await this.row(a.workspace, a.slug);
620 if (!row) return fail("not_found", "There is no such project.");
621 const rest = (await this.pinRows(who.value.id, row.workspace)).map((pinned) => pinned.id).filter((id) => id !== row.id);
622 await this.writePins(who.value.id, rest, [row.id]);
623 return ok((await this.pinRows(who.value.id, row.workspace)).filter((pinned) => this.visible(pinned, who.value)).map(toProject));
624 }
625
626 async reorderPins(a: { actor: User; workspace: string; slugs: string[] }): Promise<Result<Project[]>> {
627 const who = this.person(a.actor);
628 if (!who.ok) return who;
629 if (!Array.isArray(a.slugs)) return fail("invalid", "Give the pinned projects' slugs, in order.");
630 const workspace = a.workspace.toLowerCase();
631 const current = await this.pinRows(who.value.id, workspace);
632 const idOf = new Map(current.map((row) => [row.slug, row.id]));
633 const wanted = a.slugs.map((slug) => idOf.get(String(slug).toLowerCase()) ?? String(slug));
634 const order = reorderPins(
635 current.map((row) => row.id),
636 wanted,
637 );
638 if (!order.ok) return fail("invalid", order.message);
639 await this.writePins(who.value.id, order.order);
640 return ok((await this.pinRows(who.value.id, workspace)).filter((row) => this.visible(row, who.value)).map(toProject));
641 }
642
643 async visited(a: { actor: User; projectId: string }): Promise<void> {
644 const who = this.person(a.actor);
645 if (!who.ok || !a.projectId) return;
646 await this.db.batch([
647 this.db
648 .prepare(
649 `INSERT INTO visits (user_id, project_id, visited_at) VALUES (?, ?, ?)
650 ON CONFLICT (user_id, project_id) DO UPDATE SET visited_at = excluded.visited_at`,
651 )
652 .bind(who.value.id, a.projectId, now()),
653 // Only their latest few are kept.
654 this.db
655 .prepare(
656 `DELETE FROM visits WHERE user_id = ?1 AND project_id NOT IN
657 (SELECT project_id FROM visits WHERE user_id = ?1 ORDER BY visited_at DESC LIMIT ?2)`,
658 )
659 .bind(who.value.id, MAX_VISITS),
660 ]);
661 }
662
A project is an app or a library: libraries show their package and how to ship a release, not production663 // ---- App or library --------------------------------------------------
664
665 /**
666 * Reads what decides whether the project is a library: the manifests at
667 * its root at `commit` (the default branch's head when null), a package
668 * its repository publishes, and, the first time, whether Deployments are
669 * on. Files are read again only for a commit they were not read at.
670 * `packages` reads only the packages. Returns the row as it is now.
671 */
672 private async detect(row: Row, commit: string | null = null, only?: "packages"): Promise<Row> {
673 const actor = await this.workspaceActor(row.workspace);
674 if (!actor) return row;
675 const repo = { namespace: row.repo_namespace, name: row.repo_name };
676 const sets: string[] = [];
677 const values: unknown[] = [];
678 const set = (column: string, value: unknown) => {
679 sets.push(`${column} = ?`);
680 values.push(value);
681 };
682 const filesRead = only === "packages" || (commit != null && commit === row.detected_commit);
683 const [files, linked, deploying] = await Promise.all([
684 filesRead ? null : this.readRoot(repo, actor, commit, row.root_dir),
685 this.linkedPackage(row, actor),
686 row.deployments_on == null && only !== "packages" ? this.deploymentsOn(row.id) : null,
687 ]);
688 if (files) {
689 const found = files.commit ? detectKind(files.root) : null;
690 set("detected_kind", found?.kind ?? null);
691 set("detected_detail", found?.detail ?? null);
692 set("detected_ecosystem", found?.ecosystem ?? null);
693 set("detected_commit", files.commit);
694 }
695 if (linked !== undefined) set("linked_package", linked);
696 if (deploying != null) set("deployments_on", deploying ? 1 : 0);
697 if (sets.length === 0) return row;
698 await this.db
699 .prepare(`UPDATE projects SET ${sets.join(", ")} WHERE id = ?`)
700 .bind(...values, row.id)
701 .run();
702 return (await this.db.prepare("SELECT * FROM projects WHERE id = ?").bind(row.id).first<Row>()) ?? row;
703 }
704
705 /** The project's root at a commit: its names, and the few files detection reads. Commit '' when it has none. */
706 private async readRoot(
707 repo: { namespace: string; name: string },
708 actor: User,
709 commit: string | null,
710 rootDir: string,
711 ): Promise<{ commit: string; root: RootFiles } | null> {
712 const client = reposClient(this.env.REPOS);
713 const empty = { commit: "", root: { entries: [], text: {} } };
714 const tree = await client.tree(repo, actor, commit, rootDir);
715 if (!tree.ok) return null;
716 const head = commit ?? tree.value.head?.hash ?? "";
717 if (!head) return empty;
718 const entries = tree.value.entries.map((entry) => (entry.kind === "tree" ? `${entry.name}/` : entry.name));
719 const at = (name: string) => (rootDir ? `${rootDir}/${name}` : name);
720 const names = [...MANIFESTS.filter((name) => entries.includes(name)), ...(entries.includes("go.mod") ? goFilesToRead(entries) : [])];
721 const below = (dir: string) => client.tree(repo, actor, head, at(dir)).catch(() => null);
722 const [texts, src, pub] = await Promise.all([
723 Promise.all(
724 names.map(async (name) => {
725 const blob = await client.blob(repo, actor, head, at(name)).catch(() => null);
726 return [name, blob?.ok ? (blob.value.text ?? "") : ""] as const;
727 }),
728 ),
729 entries.includes("Cargo.toml") && entries.includes("src/") ? below("src") : null,
730 entries.includes("composer.json") && entries.includes("public/") ? below("public") : null,
731 ]);
732 const list = (view: Awaited<ReturnType<typeof below>>) => (view?.ok ? view.value.entries.map((entry) => entry.name) : undefined);
733 return { commit: head, root: { entries, text: Object.fromEntries(texts), src: list(src), public: list(pub) } };
734 }
735
736 /** A package other than a container image that the repository publishes, named; undefined when packages could not say. */
737 private async linkedPackage(row: Row, actor: User): Promise<string | null | undefined> {
738 const listed = await packagesClient(this.env.PACKAGES)
739 .list(row.workspace, actor, { repoId: row.repo_id })
740 .catch(() => null);
741 if (!listed?.ok) return undefined;
742 // An image is how an app ships too; it says nothing about being a library.
743 const pkg = listed.value.find((p) => p.ecosystem !== "container");
744 if (!pkg) return null;
745 return `${ECOSYSTEM_NAME[pkg.ecosystem]} ${pkg.ecosystem === "npm" ? `@${pkg.workspace}/${pkg.name}` : pkg.name}`;
746 }
747
748 /** Whether Deployments are on for the project, asked of deployments once; after that it tells this service. */
749 private async deploymentsOn(projectId: string): Promise<boolean | null> {
750 const answer = await this.env.DEPLOYMENTS.fetch("https://deployments/rpc/is_enabled", {
751 method: "POST",
752 headers: { "content-type": "application/json" },
753 body: JSON.stringify({ projectId }),
754 }).catch(() => null);
755 if (!answer?.ok) return null;
756 const value = (await answer.json().catch(() => null)) as unknown;
757 return typeof value === "boolean" ? value : null;
758 }
759
Project dependencies: addresses, preview stacks, Affects, and agents who know760 // ---- Dependencies ------------------------------------------------------
761
762 private async row(workspace: string, slug: string): Promise<Row | null> {
763 return this.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look764 .prepare("SELECT * FROM projects WHERE workspace = ? AND slug = ? AND repo_deleted_at IS NULL")
Project dependencies: addresses, preview stacks, Affects, and agents who know765 .bind(workspace.toLowerCase(), slug.toLowerCase())
766 .first<Row>();
767 }
768
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look769 /** A project's dependencies; for a viewer, only the projects whose repositories they can read. */
770 private async links(projectId: string, viewer?: Viewer): Promise<Dependencies> {
Project dependencies: addresses, preview stacks, Affects, and agents who know771 const [out, into] = await Promise.all([
772 this.db
773 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look774 `SELECT p.slug, p.name, d.alias, d.source, p.repo_id, p.repo_namespace, p.repo_private FROM dependencies d JOIN projects p ON p.id = d.depends_on_id
775 WHERE d.project_id = ? AND p.repo_deleted_at IS NULL ORDER BY p.name COLLATE NOCASE`,
Project dependencies: addresses, preview stacks, Affects, and agents who know776 )
777 .bind(projectId)
778 .all<LinkRow>(),
779 this.db
780 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look781 `SELECT p.slug, p.name, d.alias, d.source, p.repo_id, p.repo_namespace, p.repo_private FROM dependencies d JOIN projects p ON p.id = d.project_id
782 WHERE d.depends_on_id = ? AND p.repo_deleted_at IS NULL ORDER BY p.name COLLATE NOCASE`,
Project dependencies: addresses, preview stacks, Affects, and agents who know783 )
784 .bind(projectId)
785 .all<LinkRow>(),
786 ]);
787 const link = (r: LinkRow): DependencyLink => ({ slug: r.slug, name: r.name, as: r.alias, source: r.source });
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look788 const shown = (r: LinkRow) => viewer === undefined || permission(viewer, repoRef(r)) != null;
789 return { dependsOn: out.results.filter(shown).map(link), usedBy: into.results.filter(shown).map(link) };
Project dependencies: addresses, preview stacks, Affects, and agents who know790 }
791
792 /** Whether `from` already reaches `to` through dependencies. */
793 private async reaches(from: string, to: string): Promise<boolean> {
794 const seen = new Set<string>([from]);
795 let frontier = [from];
796 while (frontier.length > 0) {
797 const marks = frontier.map(() => "?").join(", ");
798 const next = await this.db
799 .prepare(`SELECT depends_on_id AS id FROM dependencies WHERE project_id IN (${marks})`)
800 .bind(...frontier)
801 .all<{ id: string }>();
802 frontier = [];
803 for (const { id } of next.results) {
804 if (id === to) return true;
805 if (!seen.has(id)) {
806 seen.add(id);
807 frontier.push(id);
808 }
809 }
810 }
811 return false;
812 }
813
814 async dependencies(a: { workspace: string; slug: string; viewer: Viewer }): Promise<Result<Dependencies>> {
815 const row = await this.row(a.workspace, a.slug);
816 if (!row || !this.visible(row, a.viewer)) return fail("not_found", "There is no such project.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look817 return ok(await this.links(row.id, a.viewer));
Project dependencies: addresses, preview stacks, Affects, and agents who know818 }
819
820 /** Records `row` using `target`, after the checks every way of declaring one shares. */
821 private async declare(row: Row, target: Row, alias: string | null, source: "ui" | "file", by: string): Promise<Result<true>> {
822 if (target.id === row.id) return fail("invalid", "A project cannot depend on itself.");
823 if (alias != null && !ALIAS.test(alias)) {
824 return fail("invalid", "The variable's name is capital letters, digits and underscores, such as API_URL.");
825 }
826 if (await this.reaches(target.id, row.id)) {
827 return fail("conflict", `${target.slug} already depends on ${row.slug}, directly or through others; that would be a cycle.`);
828 }
829 const count = await this.db
830 .prepare("SELECT COUNT(*) AS n FROM dependencies WHERE project_id = ?")
831 .bind(row.id)
832 .first<{ n: number }>();
833 if ((count?.n ?? 0) >= MAX_DEPENDENCIES) return fail("invalid", `A project can depend on at most ${MAX_DEPENDENCIES} others.`);
834 await this.db
835 .prepare(
836 `INSERT INTO dependencies (project_id, depends_on_id, alias, source, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)
837 ON CONFLICT (project_id, depends_on_id) DO UPDATE SET alias = excluded.alias, source = excluded.source`,
838 )
839 .bind(row.id, target.id, alias, source, by, now())
840 .run();
841 return ok(true);
842 }
843
844 async addDependency(a: { actor: User; workspace: string; slug: string; on: string; as: string | null }): Promise<Result<Dependencies>> {
845 const [row, target] = await Promise.all([this.row(a.workspace, a.slug), this.row(a.workspace, a.on)]);
846 if (!row) return fail("not_found", "There is no such project.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look847 const allowed = this.changeable(row, a.actor, NEEDS.addDependency);
848 if (!allowed.ok) return allowed;
849 // A project the actor cannot read is not there for them to depend on.
850 if (!target || !this.visible(target, a.actor)) return fail("not_found", `${a.workspace} has no project called ${a.on}.`);
Project dependencies: addresses, preview stacks, Affects, and agents who know851 const existing = await this.db
852 .prepare("SELECT source FROM dependencies WHERE project_id = ? AND depends_on_id = ?")
853 .bind(row.id, target.id)
854 .first<{ source: string }>();
855 if (existing?.source === "file") return fail("conflict", "This dependency is declared in .g1t/project.yml; change it there.");
856 const alias = a.as?.trim() ? a.as.trim().toUpperCase() : null;
857 const done = await this.declare(row, target, alias, "ui", a.actor.username);
858 if (!done.ok) return done;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look859 return ok(await this.links(row.id, a.actor));
Project dependencies: addresses, preview stacks, Affects, and agents who know860 }
861
862 async removeDependency(a: { actor: User; workspace: string; slug: string; on: string }): Promise<Result<Dependencies>> {
863 const [row, target] = await Promise.all([this.row(a.workspace, a.slug), this.row(a.workspace, a.on)]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look864 if (!row) return fail("not_found", "There is no such project.");
865 const allowed = this.changeable(row, a.actor, NEEDS.removeDependency);
866 if (!allowed.ok) return allowed;
867 if (!target) return fail("not_found", "There is no such dependency.");
Project dependencies: addresses, preview stacks, Affects, and agents who know868 const removed = await this.db
869 .prepare("DELETE FROM dependencies WHERE project_id = ? AND depends_on_id = ? AND source = 'ui' RETURNING project_id")
870 .bind(row.id, target.id)
871 .first();
872 if (!removed) return fail("conflict", "This dependency is declared in .g1t/project.yml, or does not exist; change the file.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look873 return ok(await this.links(row.id, a.actor));
Project dependencies: addresses, preview stacks, Affects, and agents who know874 }
875
876 async graph(a: { projectId: string }): Promise<ProjectGraph> {
877 const [out, into] = await Promise.all([
878 this.db
879 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look880 `SELECT p.id, p.slug, p.workspace, d.alias FROM dependencies d JOIN projects p ON p.id = d.depends_on_id
881 WHERE d.project_id = ? AND p.repo_deleted_at IS NULL`,
Project dependencies: addresses, preview stacks, Affects, and agents who know882 )
883 .bind(a.projectId)
884 .all<NodeRow>(),
885 this.db
886 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look887 `SELECT p.id, p.slug, p.workspace, d.alias FROM dependencies d JOIN projects p ON p.id = d.project_id
888 WHERE d.depends_on_id = ? AND p.repo_deleted_at IS NULL`,
Project dependencies: addresses, preview stacks, Affects, and agents who know889 )
890 .bind(a.projectId)
891 .all<NodeRow>(),
892 ]);
893 const node = (r: NodeRow) => ({ id: r.id, slug: r.slug, workspace: r.workspace, as: r.alias });
894 return { dependsOn: out.results.map(node), usedBy: into.results.map(node) };
895 }
896
897 /** For the runner: each project on a repository, with what it uses and what uses it. */
898 async contextForRepo(a: { repoId: string }): Promise<{ slug: string; name: string; dependencies: Dependencies }[]> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look899 const rows = await this.db
900 .prepare("SELECT * FROM projects WHERE repo_id = ? AND repo_deleted_at IS NULL")
901 .bind(a.repoId)
902 .all<Row>();
Project dependencies: addresses, preview stacks, Affects, and agents who know903 return Promise.all(rows.results.map(async (row) => ({ slug: row.slug, name: row.name, dependencies: await this.links(row.id) })));
904 }
905
906 /**
907 * A project's `.g1t/project.yml` at a commit of its default branch:
908 *
909 * dependsOn:
910 * - project: api
911 * as: API_URL
912 *
913 * Its dependencies replace the ones the file declared before. Ones that
914 * cannot be kept (an unknown project, a cycle) are left out.
915 */
916 private async syncFile(row: Row, commit: string): Promise<void> {
917 const actor = await this.workspaceActor(row.workspace);
918 if (!actor) return;
919 const path = row.root_dir ? `${row.root_dir}/.g1t/project.yml` : ".g1t/project.yml";
920 const blob = await reposClient(this.env.REPOS).blob({ namespace: row.repo_namespace, name: row.repo_name }, actor, commit, path);
921 if (!blob.ok || blob.value.text == null) {
922 // No file (any more): what it declared goes with it.
923 await this.db.prepare("DELETE FROM dependencies WHERE project_id = ? AND source = 'file'").bind(row.id).run();
924 return;
925 }
926 let declared: unknown[] = [];
927 try {
928 const parsed = parseYaml(blob.value.text) as { dependsOn?: unknown } | null;
929 if (Array.isArray(parsed?.dependsOn)) declared = parsed.dependsOn;
930 } catch (error) {
931 console.error("could not read", path, "of", row.slug, error);
932 return;
933 }
934 await this.db.prepare("DELETE FROM dependencies WHERE project_id = ? AND source = 'file'").bind(row.id).run();
935 for (const entry of declared.slice(0, MAX_DEPENDENCIES)) {
936 const item = entry as { project?: unknown; as?: unknown } | string;
937 const on = typeof item === "string" ? item : typeof item?.project === "string" ? item.project : null;
938 if (!on) continue;
939 const target = await this.row(row.workspace, on);
940 if (!target) continue;
941 const alias = typeof item === "object" && typeof item.as === "string" ? item.as.trim().toUpperCase() : null;
942 await this.declare(row, target, alias, "file", "g1t");
943 }
944 }
945
Projects: when each was last pushed, and how active it is lately946 /** Something happened to a repository's code or work: its projects are that much more active. */
947 private async active(repoId: string, at: string, pushed: boolean): Promise<void> {
948 const rows = await this.db
949 .prepare("SELECT id, activity, active_at FROM projects WHERE repo_id = ?")
950 .bind(repoId)
951 .all<{ id: string; activity: number | null; active_at: string | null }>();
952 if (rows.results.length === 0) return;
953 await this.db.batch(
954 rows.results.map((row) => {
955 const next = raised(row.activity ?? 0, row.active_at, at);
956 return pushed
957 ? this.db
958 .prepare("UPDATE projects SET activity = ?, active_at = ?, pushed_at = MAX(COALESCE(pushed_at, ''), ?) WHERE id = ?")
959 .bind(next.score, next.at, at, row.id)
960 : this.db.prepare("UPDATE projects SET activity = ?, active_at = ? WHERE id = ?").bind(next.score, next.at, row.id);
961 }),
962 );
963 }
964
Projects: what a workspace builds and runs, first on every page965 async onEvent(event: G1tEvent): Promise<void> {
Projects: when each was last pushed, and how active it is lately966 if (ACTIVE.has(event.type)) {
967 const repoId = event.repoId ?? ("repoId" in event.data ? (event.data as { repoId?: string }).repoId : undefined);
968 if (repoId) await this.active(repoId, event.time, event.type === "git.push");
969 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains970 if (event.type === "workspace.renamed") {
971 const current = await currentWorkspaceSlug(this.env.IDENTITY, event.data);
972 const statements = renameStatements(staleSlugs(event.data, current), current);
973 if (statements.length) await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
974 return;
975 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look976 const move = repoMove(event);
977 if (move) {
978 const current = await currentMovedPath(this.env.REPOS, move);
979 const stale = staleMovedPaths(move, current);
980 if (stale.length === 0) return;
981 const statements = moveStatements(stale, current, move.repoId);
982 await this.db.batch(statements.map(({ sql, params }) => this.db.prepare(sql).bind(...params)));
983 // A project whose slug the destination already had moves under a free one.
984 const query = strandedQuery(stale, current, move.repoId);
985 if (!query) return;
986 const workspace = current.split("/")[0]!;
987 const stranded = await this.db.prepare(query.sql).bind(...query.params).all<Row>();
988 for (const row of stranded.results) {
989 const slug = await this.freeSlug(workspace, row.slug);
990 console.log("project", row.id, "moved to", workspace, "as", slug, "since", row.slug, "was taken");
991 await this.db
992 .prepare("UPDATE projects SET workspace = ?, slug = ?, repo_namespace = ?, updated_at = ? WHERE id = ?")
993 .bind(workspace, slug, workspace, now(), row.id)
994 .run();
995 }
996 return;
997 }
998 if (event.type === "repo.deleted") {
999 // Hidden, not gone: a restore brings its projects back as they were.
1000 await this.db
1001 .prepare("UPDATE projects SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?")
1002 .bind(now(), event.data.repoId)
1003 .run();
1004 return;
1005 }
1006 if (event.type === "repo.restored") {
1007 await this.db
1008 .prepare("UPDATE projects SET repo_deleted_at = NULL, repo_private = ? WHERE repo_id = ?")
1009 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
1010 .run();
1011 return;
1012 }
1013 if (event.type === "repo.purged") {
1014 const ids = "SELECT id FROM projects WHERE repo_id = ?1";
1015 await this.db.batch([
1016 this.db
1017 .prepare(`DELETE FROM dependencies WHERE project_id IN (${ids}) OR depends_on_id IN (${ids})`)
1018 .bind(event.data.repoId),
Projects: pinned and recent projects, per person per workspace1019 // Pins and visits of what is gone go with it.
1020 this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(event.data.repoId),
1021 this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(event.data.repoId),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1022 this.db.prepare("DELETE FROM projects WHERE repo_id = ?").bind(event.data.repoId),
1023 ]);
1024 return;
1025 }
1026 if (event.type === "repo.updated" || event.type === "repo.visibility_changed") {
1027 // Who may see its projects follows who may see the repository.
1028 await this.db
1029 .prepare("UPDATE projects SET repo_private = ? WHERE repo_id = ?")
1030 .bind(event.data.isPrivate ? 1 : 0, event.data.repoId)
1031 .run();
Fast pages, required checks on the branch, self-hosted runners, honest incidents1032 if (event.type === "repo.updated") {
1033 // Projects without a description of their own show the repository's.
1034 // Asked of repos, so changes delivered out of order end the same.
1035 const repo = await this.repoById(event.data.repoId);
1036 if (repo) {
1037 await this.db
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971038 .prepare("UPDATE projects SET repo_description = ?, repo_website = ? WHERE repo_id = ?")
1039 .bind(repo.description ?? null, repo.website ?? null, event.data.repoId)
Fast pages, required checks on the branch, self-hosted runners, honest incidents1040 .run();
1041 }
1042 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1043 return;
1044 }
1045 if (event.type === "repo.archived" || event.type === "repo.unarchived") {
1046 // Asked of repos, so changes delivered out of order end the same.
1047 const repo = await this.repoById(event.data.repoId);
1048 const archivedAt = repo ? (repo.archivedAt ?? null) : event.data.archived ? now() : null;
1049 await this.db.prepare("UPDATE projects SET repo_archived_at = ? WHERE repo_id = ?").bind(archivedAt, event.data.repoId).run();
1050 return;
1051 }
1052 if (event.type === "repo.default_branch_changed") {
1053 // Asked of repos, so changes delivered out of order end the same.
1054 const repo = await this.repoById(event.data.repoId);
1055 await this.db
1056 .prepare("UPDATE projects SET default_branch = ? WHERE repo_id = ?")
1057 .bind(repo?.defaultBranch ?? event.data.to, event.data.repoId)
1058 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production1059 // Another branch has other files: they are read again from its head.
1060 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
1061 for (const row of rows.results) await this.detect({ ...row, detected_commit: null });
1062 return;
1063 }
1064 if (
1065 (event.type === "package.published" || event.type === "package.deleted" || event.type === "package.version_deleted") &&
1066 event.data.repoId
1067 ) {
1068 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
1069 for (const row of rows.results) await this.detect(row, null, "packages");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1070 return;
1071 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1072 if (event.type === "workspace.deleting") {
1073 // Hidden, not gone: every project it shows, including any building
1074 // from a repository it transferred away, until staff restore it or it
1075 // is purged. Those already hidden stay as they were.
1076 if (isProtectedWorkspace(event.data.slug)) return;
1077 const at = now();
1078 await this.db
1079 .prepare(
1080 "UPDATE projects SET repo_deleted_at = ?1, workspace_deleted_at = ?1 WHERE workspace = ?2 AND repo_deleted_at IS NULL",
1081 )
1082 .bind(at, event.data.slug.toLowerCase())
1083 .run();
1084 return;
1085 }
1086 if (event.type === "workspace.restored") {
1087 await this.db
1088 .prepare(
1089 "UPDATE projects SET repo_deleted_at = NULL, workspace_deleted_at = NULL WHERE workspace = ? AND workspace_deleted_at IS NOT NULL",
1090 )
1091 .bind(event.data.slug.toLowerCase())
1092 .run();
1093 return;
1094 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1095 if (event.type === "workspace.deleted") {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1096 // Its own repositories' projects go with them (`repo.purged`); any
1097 // building from a repository it transferred away goes now. It is not
1098 // backfilled again.
1099 const slug = event.data.slug.toLowerCase();
1100 const ids = "SELECT id FROM projects WHERE workspace = ?1";
1101 await this.db.batch([
1102 this.db.prepare(`DELETE FROM dependencies WHERE project_id IN (${ids}) OR depends_on_id IN (${ids})`).bind(slug),
Projects: pinned and recent projects, per person per workspace1103 this.db.prepare(`DELETE FROM pins WHERE project_id IN (${ids})`).bind(slug),
1104 this.db.prepare(`DELETE FROM visits WHERE project_id IN (${ids})`).bind(slug),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1105 this.db.prepare("DELETE FROM projects WHERE workspace = ?").bind(slug),
1106 this.db.prepare("DELETE FROM backfilled WHERE workspace = ?").bind(slug),
1107 ]);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1108 return;
1109 }
Project dependencies: addresses, preview stacks, Affects, and agents who know1110 if (event.type === "git.push" && event.data.defaultBranch) {
1111 const rows = await this.db.prepare("SELECT * FROM projects WHERE repo_id = ?").bind(event.data.repoId).all<Row>();
A project is an app or a library: libraries show their package and how to ship a release, not production1112 for (const row of rows.results) {
1113 await this.syncFile(row, event.data.after);
1114 await this.detect(row, event.data.after);
1115 }
Project dependencies: addresses, preview stacks, Affects, and agents who know1116 return;
1117 }
Projects: what a workspace builds and runs, first on every page1118 if (event.type !== "repo.created") return;
1119 const actor = await this.workspaceActor(event.data.namespace);
1120 if (!actor) return;
1121 const repo = await reposClient(this.env.REPOS).get({ namespace: event.data.namespace, name: event.data.name }, actor);
1122 if (repo.ok) await this.ensureFor(repo.value, event.actor ?? "g1t");
1123 }
1124}
1125
Fast pages, required checks on the branch, self-hosted runners, honest incidents1126/** One RPC method's answer. */
1127async function answer(service: Projects, method: string, args: any): Promise<Response> {
1128 switch (method) {
1129 case "list":
1130 return Response.json(await service.list(args));
1131 case "get":
1132 return Response.json(await service.get(args));
1133 case "by_repo":
1134 return Response.json(await service.byRepo(args));
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1135 case "count":
1136 return Response.json(await service.count(args));
Fast pages, required checks on the branch, self-hosted runners, honest incidents1137 case "create":
1138 return Response.json(await service.create(args));
1139 case "update":
1140 return Response.json(await service.update(args));
A project is an app or a library: libraries show their package and how to ship a release, not production1141 case "deployments_changed":
1142 await service.deploymentsChanged(args);
1143 return Response.json(null);
Fast pages, required checks on the branch, self-hosted runners, honest incidents1144 case "dependencies":
1145 return Response.json(await service.dependencies(args));
1146 case "add_dependency":
1147 return Response.json(await service.addDependency(args));
1148 case "remove_dependency":
1149 return Response.json(await service.removeDependency(args));
1150 case "graph":
1151 return Response.json(await service.graph(args));
Projects: pinned and recent projects, per person per workspace1152 case "shortcuts":
1153 return Response.json(await service.shortcuts(args));
1154 case "pin":
1155 return Response.json(await service.pin(args));
1156 case "unpin":
1157 return Response.json(await service.unpin(args));
1158 case "reorder_pins":
1159 return Response.json(await service.reorderPins(args));
1160 case "visited":
1161 await service.visited(args);
1162 return Response.json(null);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971163 case "public_links":
1164 return Response.json(await service.publicLinks(args));
Fast pages, required checks on the branch, self-hosted runners, honest incidents1165 case "context_for_repo":
1166 return Response.json(await service.contextForRepo(args));
1167 default:
1168 return new Response("Unknown method\n", { status: 404 });
1169 }
1170}
1171
Projects: what a workspace builds and runs, first on every page1172export default {
A project is an app or a library: libraries show their package and how to ship a release, not production1173 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Projects: what a workspace builds and runs, first on every page1174 const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/);
1175 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
Fast pages, required checks on the branch, self-hosted runners, honest incidents1176 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
1177 const opened = openD1(env.DB, request);
A project is an app or a library: libraries show their package and how to ship a release, not production1178 const service = new Projects(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
Projects: what a workspace builds and runs, first on every page1179 const args = (await request.json().catch(() => ({}))) as any;
Fast pages, required checks on the branch, self-hosted runners, honest incidents1180 return opened.finish(await answer(service, match[1], args));
Projects: what a workspace builds and runs, first on every page1181 },
1182
1183 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
1184 const service = new Projects(env);
1185 for (const message of batch.messages) {
1186 try {
1187 await service.onEvent(message.body);
1188 message.ack();
1189 } catch (error) {
1190 console.error("projects could not handle", message.body.type, error);
1191 message.retry();
1192 }
1193 }
1194 },
1195} satisfies ExportedHandler<Env, G1tEvent>;

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