Skip to content
2,452 linesCodeBlameRaw

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.

Plan: the inbox, then channels and apps, instead of discussions1/**
2 * The deployments service: a project's production, deployed from its
3 * default branch on every push, and a live preview of every branch with an
4 * open pull request, on g1t.page.
5 *
6 * It reacts to events (a pull request opened, ready, pushed to, closed or
7 * merged; a push to the default branch) for every project built from the
8 * repository, asks billing whether the workspace pays for Deployments, and
9 * asks the runner to build the commit in a sandbox. The sandbox reports
10 * back through the API with a token for that build alone; this service
11 * opens the upload of its files and puts the finished app in the Workers
12 * for Platforms namespace, where the `*.g1t.page` dispatcher finds it by
13 * hostname.
14 *
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r915 * A g1t Actions job can open a build too (`startFromWorkflow`, for
16 * `POST /repos/{owner}/{repo}/deployments/builds` and the `g1t/deploy`
17 * action) and build it in its own checkout with `g1t-runner deploy`; it
18 * reports the same way, and its time is the job's, not a sandbox's.
19 *
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas20 * Nothing here is free, and nothing is capped. On the plan, every build
21 * second is charged as it happens; requests, CPU time and custom domains
22 * are charged from the first once the month is over, all at cost plus the
23 * margin and from the plan's included usage first. No count of projects,
24 * previews or apps ever stops or pauses a workspace: apps are not metered
25 * at all. Only the workspace's spend limit pauses its apps
26 * (`holdToLimits`), and only the plan ending takes them down. A Worker
27 * runs only while it answers a request, so an app no one visits costs
28 * nothing, and a preview is taken down when its pull request closes or
29 * after its project's idle days.
Plan: the inbox, then channels and apps, instead of discussions30 *
31 * Reached through service bindings (`POST /rpc/<method>`) and, for a
32 * build's reports, through the API (`POST /jobs/<id>/<step>`).
33 */
34
35import {
36 CUSTOM_DOMAIN_TARGET,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas37 DEPLOYMENT_COSTS,
Plan: the inbox, then channels and apps, instead of discussions38 SLUG_HOLD_DAYS,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look39 ComputeGate,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r940 actionsClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look41 allows,
Plan: the inbox, then channels and apps, instead of discussions42 billingClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look43 can,
44 needs,
45 permission,
46 sandboxEstimateMicros,
47 currentMovedPath,
Plan: the inbox, then channels and apps, instead of discussions48 currentWorkspaceSlug,
Events: review requests, assignments, stops and deployments are published49 eventsClient,
Plan: the inbox, then channels and apps, instead of discussions50 fail,
51 identityClient,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member52 isProtectedWorkspace,
Merge branch 'mirroring' into artifacts-mode53 mirrorWritable,
Plan: the inbox, then channels and apps, instead of discussions54 newId,
55 ok,
Fast pages, required checks on the branch, self-hosted runners, honest incidents56 openD1,
Plan: the inbox, then channels and apps, instead of discussions57 projectsClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look58 repoMove,
Plan: the inbox, then channels and apps, instead of discussions59 reposClient,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look60 staleMovedPaths,
Plan: the inbox, then channels and apps, instead of discussions61 staleSlugs,
62 workClient,
63 type DeployKind,
64 type DeploySettings,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look65 type DetectedKind,
Plan: the inbox, then channels and apps, instead of discussions66 type DeployStatus,
67 type DeployUsage,
68 type Deployment,
69 type Domain,
70 type G1tEvent,
71 type LiveApp,
72 type Project,
73 type ProjectDeploys,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look74 type RepoMove,
Plan: the inbox, then channels and apps, instead of discussions75 type ProjectDomains,
76 type ProjectRef,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights77 workOwner,
Plan: the inbox, then channels and apps, instead of discussions78 type RepoPath,
79 type Result,
80 type ServiceBinding,
81 type User,
82 type Viewer,
83} from "@g1t/contracts";
84
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights85import { NEEDS, repoRef, trustedOutright, type Method } from "./access";
Plan: the inbox, then channels and apps, instead of discussions86import { Cloudflare, type BuiltWorker, type Manifest } from "./cloudflare";
87import { CustomHostnames } from "./custom-hostnames";
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas88import { Domains, NOT_ENABLED_NOTICE, toDomain } from "./domains";
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails89import { monthCost, movesMeter } from "./metering";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit90import { moveTargets, ownerOf, rebuildOutcome, type DroppedBuild, type MoveTarget } from "./moves";
91import { commitMissing, MAX_IDENTICAL_FAILURES, missingCommitMessage, retryDecision, type PastBuild } from "./retries";
Plan: the inbox, then channels and apps, instead of discussions92import { appHost, appUrl, label, uniqueLabel } from "./names";
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9793import { RepoDeployments, sourceOf } from "./repo-deployments";
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r994import { commitOf, jobOf, projectNamed, runTrusted, targetOf, type WorkflowBuild } from "./workflow-builds";
Plan: the inbox, then channels and apps, instead of discussions95
96type Env = {
97 DB: D1Database;
98 REPOS: ServiceBinding;
99 WORK: ServiceBinding;
100 IDENTITY: ServiceBinding;
101 BILLING: ServiceBinding;
102 RUNNER: ServiceBinding;
103 PROJECTS: ServiceBinding;
104 /** Secrets and variables: the actions service holds the one store. */
105 ACTIONS: ServiceBinding;
Events: review requests, assignments, stops and deployments are published106 /** The bus: each build that finishes is published, for the inbox, webhooks and workflows. */
107 EVENTS?: ServiceBinding;
Plan: the inbox, then channels and apps, instead of discussions108 /** Secret: scoped to Workers scripts and analytics on g1t's account. */
109 CLOUDFLARE_API_TOKEN?: string;
110 CLOUDFLARE_ACCOUNT_ID: string;
111 DISPATCH_NAMESPACE: string;
112 SITE: string;
113 /** Custom domains: hostname to app, read by the dispatcher. */
114 DOMAINS?: KVNamespace;
115 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
116 CUSTOM_HOSTNAMES_ZONE_ID?: string;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look117 /** The og service's `Screenshots`: production's screenshot, taken once per deploy. */
118 SCREENSHOTS?: { capture(input: { host: string; commit: string }): Promise<boolean> };
Plan: the inbox, then channels and apps, instead of discussions119};
120
121/** A build that has not reported in this long has died. */
122const BUILD_TIMEOUT_MS = 45 * 60 * 1000;
123/** A script in the namespace that no app holds, older than this, is removed. */
124const ORPHAN_AFTER_MS = 60 * 60 * 1000;
125const LIST_LIMIT = 50;
126const STATUS_CONTEXT = "g1t / deploy";
127/**
128 * Deliveries of `workspace.renamed` that wait for the projects service to
129 * have seen it too, before going ahead with the new slug regardless.
130 */
131const RENAME_WAITS = 3;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas132/** Why a build under way was dropped by a move; the move builds it again under the new name. */
133const MOVED_ERROR = "The repository moved: built again under its new name.";
134const RENAMED_ERROR = "The workspace was renamed: built again under its new name.";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit135/**
136 * A move's rebuild that could not start, or failed, is tried again by the
137 * sweep after this long, doubled for each failure after the first, up to
138 * `MAX_IDENTICAL_FAILURES` (see retries.ts).
139 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas140const MOVE_RETRY_MS = 60 * 60 * 1000;
Plan: the inbox, then channels and apps, instead of discussions141
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look142/** A build's report of what it found the project to be, if it is one g1t knows. */
143export function detectedKind(value: unknown): DetectedKind | null {
144 return value === "workers" || value === "static" || value === "html" ? value : null;
145}
146
Plan: the inbox, then channels and apps, instead of discussions147const now = () => new Date().toISOString();
148const month = (at = new Date()) => at.toISOString().slice(0, 7);
149
150async function sha256(text: string): Promise<string> {
151 const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
152 return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
153}
154
155function randomToken(): string {
156 return [...crypto.getRandomValues(new Uint8Array(32))].map((b) => b.toString(16).padStart(2, "0")).join("");
157}
158
159function isMember(viewer: Viewer, slug: string): boolean {
160 return !!viewer?.workspaces?.some((membership) => membership.slug === slug.toLowerCase());
161}
162
163/** The repository a project builds from. */
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look164/** One compute gate per isolate, so entitlements and prices are kept between calls. */
165let computeGate: ComputeGate | null = null;
166function gateFor(billing: ServiceBinding): ComputeGate {
167 computeGate ??= new ComputeGate(billing);
168 return computeGate;
169}
170
171/** The longest a build may run, in minutes: as long as its read token lasts. */
172const BUILD_MINUTES = 30;
173
174/**
175 * A build that was skipped before it started (its plan, its limit, or
176 * billing's refusal) as a failure, so whoever asked for it sees why.
177 */
178function notStarted(result: Result<Deployment>): Result<Deployment> {
179 if (result.ok && result.value.status === "skipped" && result.value.error) {
180 return fail("payment_required", result.value.error);
181 }
182 return result;
183}
184
Plan: the inbox, then channels and apps, instead of discussions185function repoOf(project: Project): { id: string; path: RepoPath; defaultBranch: string } {
186 if (project.source.kind !== "hosted") throw new Error("Only projects hosted on g1t deploy so far.");
187 return { id: project.source.repoId, path: project.source.repo, defaultBranch: project.source.defaultBranch };
188}
189
190type SettingsRow = {
191 project_id: string;
192 workspace: string;
193 slug: string;
194 repo_id: string;
195 enabled: number;
196 previews: number;
197 production: number;
198 build_command: string | null;
199 output_dir: string | null;
200 idle_days: number;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look201 /** Set while its repository is deleted (restorable); see `repoDeleted`. */
202 repo_deleted_at: string | null;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member203 /** Set while its workspace is deleted (restorable); see `workspaceDeleting`. */
204 workspace_deleted_at?: string | null;
Plan: the inbox, then channels and apps, instead of discussions205};
206
207type DeploymentRow = {
208 id: string;
209 project_id: string;
210 workspace: string;
211 slug: string;
212 repo_id: string;
213 repo: string;
214 kind: DeployKind;
215 branch: string | null;
216 number: number | null;
217 commit_sha: string;
218 script: string;
219 status: DeployStatus;
220 error: string | null;
221 warnings: string;
222 log: string | null;
223 token_hash: string | null;
224 trusted: number;
225 build_seconds: number | null;
226 created_by: string;
227 created_at: string;
228 finished_at: string | null;
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9229 /** Set for a build a g1t Actions job runs (migration 0011): the job pays for its time. */
230 run_id?: string | null;
231 job_id?: string | null;
232};
233
234/** What opening any build takes; see `open`. */
235type OpenInput = {
236 project: Project;
237 kind: DeployKind;
238 branch: string | null;
239 number: number | null;
240 commit: string;
241 createdBy: string;
242 settings: SettingsRow;
243 /** A push, or work by a member or an agent; see `insider`. */
244 trusted: boolean;
245 /** The g1t Actions job that builds it, for a build opened from a workflow. */
246 workflow?: { runId: string; jobId: string };
Plan: the inbox, then channels and apps, instead of discussions247};
248
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9249/** Why a build was not opened: the plan or the limit, or g1t itself. */
250type Refusal = "payment_required" | "conflict";
251
252/** A sandbox's reservation with billing; none for a workflow job's build. */
253type Compute = { reservation: string | null; microsPerSecond: number; maxRunMinutes: number | null };
254
255type Opened =
256 | { refused: string; code: Refusal; id: string }
257 | {
258 refused: null;
259 id: string;
260 token: string;
261 script: string;
262 build: { secrets: Record<string, string>; variables: Record<string, string> };
263 compute: Compute;
264 };
265
Plan: the inbox, then channels and apps, instead of discussions266type AppRow = {
267 script: string;
268 project_id: string;
269 workspace: string;
270 slug: string;
271 kind: DeployKind;
272 branch: string | null;
273 number: number | null;
274 commit_sha: string;
275 deployed_at: string;
276 created_at: string;
277 last_request_at: string | null;
278 /** Set while its workspace is over its limit; see `holdToLimits`. */
279 paused_at: string | null;
280};
281
282function toDeployment(row: DeploymentRow): Deployment {
283 return {
284 id: row.id,
285 kind: row.kind,
286 branch: row.branch,
287 number: row.number,
288 commit: row.commit_sha,
289 status: row.status,
290 url: appUrl(row.script),
291 error: row.error,
292 warnings: JSON.parse(row.warnings || "[]") as string[],
293 buildSeconds: row.build_seconds,
294 createdBy: row.created_by,
295 createdAt: row.created_at,
296 finishedAt: row.finished_at,
297 };
298}
299
300function toLive(app: AppRow): LiveApp {
301 return {
302 kind: app.kind,
303 branch: app.branch,
304 number: app.number,
305 url: appUrl(app.script),
306 commit: app.commit_sha,
307 deployedAt: app.deployed_at,
308 };
309}
310
311class Deployments {
312 constructor(private readonly env: Env) {}
313
314 private get cloudflare(): Cloudflare | null {
315 const token = this.env.CLOUDFLARE_API_TOKEN;
316 return token ? new Cloudflare(token, this.env.CLOUDFLARE_ACCOUNT_ID, this.env.DISPATCH_NAMESPACE) : null;
317 }
318
319 private get db() {
320 return this.env.DB;
321 }
322
323 private get domains(): Domains {
324 const token = this.env.CLOUDFLARE_API_TOKEN;
325 const zone = this.env.CUSTOM_HOSTNAMES_ZONE_ID;
326 return new Domains(this.env.DB, this.env.DOMAINS, token && zone ? new CustomHostnames(token, zone) : null);
327 }
328
329 private get projects() {
330 return projectsClient(this.env.PROJECTS);
331 }
332
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97333 /** A repository's deployments wherever they run: reported, from g1t Actions, and these builds. */
334 get repoDeployments(): RepoDeployments {
335 return new RepoDeployments(this.env);
336 }
337
338 /**
339 * Publishes a build's change in the repository-wide model
340 * (`deployment.created`, `deployment_status.created`), as a reported
341 * deployment's are. Never fails the build.
342 */
343 private async buildChanged(id: string, created = false): Promise<void> {
344 try {
345 const row = await this.deploymentRow(id);
346 if (!row) return;
347 const project = (await this.projects.byRepo(row.repo_id)).find((p) => p.id === row.project_id);
348 const defaultBranch = project?.source.kind === "hosted" ? project.source.defaultBranch : "main";
349 await this.repoDeployments.buildChanged(row, defaultBranch, created);
350 } catch (error) {
351 console.error("build change not published", id, String(error));
352 }
353 }
354
Plan: the inbox, then channels and apps, instead of discussions355 /** The workspace itself, as the service acts for it. */
356 private async workspaceActor(slug: string): Promise<User | null> {
357 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(slug);
358 if (!workspace) return null;
359 return {
360 id: workspace.id,
361 username: workspace.slug,
362 kind: "workspace",
363 verified: true,
364 workspaces: [{ slug: workspace.slug, role: "member" }],
365 };
366 }
367
368 /**
369 * What the project's secrets and variables available to deployments give
370 * production or a preview: its build's environment, and the same again as
371 * the running app's bindings. Untrusted builds get no secrets.
372 */
373 private async resolve(
374 project: { id: string; slug: string; repoId: string; repo: RepoPath },
375 environment: DeployKind,
376 trusted: boolean,
377 ): Promise<{ secrets: Record<string, string>; variables: Record<string, string> }> {
378 const response = await this.env.ACTIONS.fetch("https://actions/rpc/resolve_settings", {
379 method: "POST",
380 headers: { "content-type": "application/json" },
381 body: JSON.stringify({
382 repoId: project.repoId,
383 repo: project.repo,
384 projectId: project.id,
385 projectSlug: project.slug,
386 consumer: "deployments",
387 environment,
388 trusted,
389 }),
390 });
391 if (!response.ok) throw new Error(`Secrets and variables could not be read (${response.status}).`);
392 const resolved = (await response.json()) as { secrets: Record<string, string>; variables: Record<string, string> };
393 return { secrets: trusted ? resolved.secrets : {}, variables: resolved.variables };
394 }
395
396 /**
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights397 * Whether whoever a pull request is for (`workOwner`: whoever asked g1t for
398 * it, or its author) is trusted with the project's secrets: g1t itself,
399 * or someone who can push to the repository (Write or more, a member's or
400 * a collaborator's). Anyone else gets a preview built without them, as
401 * their workflows run. A change g1t made for someone is trusted as they
402 * are, never more for being g1t's.
Plan: the inbox, then channels and apps, instead of discussions403 */
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights404 private async insider(repo: RepoPath, owner: User, actor: User): Promise<boolean> {
405 if (trustedOutright(owner)) return true;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look406 const found = await identityClient(this.env.IDENTITY)
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights407 .collaboratorPermission(actor, repo.namespace, repo.name, owner.username)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look408 .catch(() => null);
409 return !!found?.ok && allows(found.value.role, "push");
Plan: the inbox, then channels and apps, instead of discussions410 }
411
412 private async settingsRow(projectId: string): Promise<SettingsRow | null> {
413 return this.db.prepare("SELECT * FROM settings WHERE project_id = ?").bind(projectId).first<SettingsRow>();
414 }
415
416 /** The name an app gets, unique among apps: production, or a branch's preview. */
417 private async scriptFor(project: Project, branch: string | null): Promise<string> {
418 const base = await label(project.workspace, project.slug, branch);
419 const holder = await this.db
420 .prepare(
421 `SELECT project_id, branch FROM apps WHERE script = ?1
422 UNION ALL SELECT project_id, branch FROM deployments WHERE script = ?1 LIMIT 1`,
423 )
424 .bind(base)
425 .first<{ project_id: string; branch: string | null }>();
426 if (!holder || (holder.project_id === project.id && (holder.branch ?? null) === branch)) return base;
427 return uniqueLabel(base, `${project.id}/${branch ?? ""}`);
428 }
429
430 private async toSettings(project: Project, row: SettingsRow | null): Promise<DeploySettings> {
431 return {
432 enabled: !!row?.enabled,
433 previews: row ? !!row.previews : true,
434 production: row ? !!row.production : true,
435 buildCommand: row?.build_command ?? null,
436 outputDir: row?.output_dir ?? null,
437 idleDays: row?.idle_days ?? 7,
438 productionUrl: appUrl(await this.scriptFor(project, null)),
439 primaryDomain: await this.domains.primary(project.id).catch(() => null),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look440 detected: await this.lastDetected(project.id),
Plan: the inbox, then channels and apps, instead of discussions441 };
442 }
443
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look444 /** What the project's last finished build found it to be; null before one has. */
445 private async lastDetected(projectId: string): Promise<DetectedKind | null> {
446 const row = await this.db
447 .prepare(
448 "SELECT detected FROM deployments WHERE project_id = ? AND detected IS NOT NULL ORDER BY finished_at DESC LIMIT 1",
449 )
450 .bind(projectId)
451 .first<{ detected: string }>()
452 .catch(() => null);
453 return detectedKind(row?.detected);
454 }
455
456 /**
457 * The project, if `viewer` may do what `method` needs on its repository
458 * (see `NEEDS`): not found when they cannot read it, refused when they can
459 * but their role is too low.
460 */
461 private async projectFor(ref: ProjectRef, viewer: Viewer, method: Method): Promise<Result<Project>> {
462 const found = await this.projects.get(ref.workspace, ref.slug, viewer);
463 if (!found.ok) return found;
464 const repo = repoRef(found.value);
465 if (!permission(viewer, repo)) return fail("not_found", "There is no such project.");
466 const capability = NEEDS[method];
467 if (!can(viewer, repo, capability)) return fail("forbidden", needs(capability));
468 return found;
Plan: the inbox, then channels and apps, instead of discussions469 }
470
471 // ---- Methods for the site and the API ------------------------------
472
473 async settings(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look474 const project = await this.projectFor(a.project, a.viewer, "settings");
Plan: the inbox, then channels and apps, instead of discussions475 if (!project.ok) return project;
476 return ok(await this.toSettings(project.value, await this.settingsRow(project.value.id)));
477 }
478
479 async updateSettings(a: {
480 actor: User;
481 project: ProjectRef;
482 changes: Partial<DeploySettings>;
483 }): Promise<Result<DeploySettings>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look484 const found = await this.projectFor(a.project, a.actor, "updateSettings");
Plan: the inbox, then channels and apps, instead of discussions485 if (!found.ok) return found;
486 const project = found.value;
487 const before = await this.toSettings(project, await this.settingsRow(project.id));
488 const next = { ...before, ...a.changes };
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97489 // A library, a tool or other, as set in its settings, does not deploy.
490 if (next.enabled && !before.enabled && project.setting?.kind && project.setting.kind !== "app" && project.setting.kind !== "docs") {
491 return fail("conflict", "This project is set to be something that doesn't deploy. Change what it is in its General settings first.");
A project is an app or a library: libraries show their package and how to ship a release, not production492 }
Plan: the inbox, then channels and apps, instead of discussions493 if (next.enabled && !before.enabled) {
494 // Turning it on starts paid work: only with the workspace's plan.
495 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
496 if (!plan.ok) return plan;
497 }
498 const idleDays = Math.min(90, Math.max(1, Math.trunc(Number(next.idleDays) || 7)));
499 const clip = (text: string | null | undefined) => (text?.trim() ? text.trim().slice(0, 500) : null);
500 await this.db
501 .prepare(
502 `INSERT INTO settings (project_id, workspace, slug, repo_id, enabled, previews, production, build_command,
503 output_dir, idle_days, updated_by, updated_at)
504 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
505 ON CONFLICT (project_id) DO UPDATE SET workspace = ?2, slug = ?3, repo_id = ?4, enabled = ?5, previews = ?6,
506 production = ?7, build_command = ?8, output_dir = ?9, idle_days = ?10, updated_by = ?11, updated_at = ?12`,
507 )
508 .bind(
509 project.id,
510 project.workspace,
511 project.slug,
512 repoOf(project).id,
513 next.enabled ? 1 : 0,
514 next.previews ? 1 : 0,
515 next.production ? 1 : 0,
516 clip(next.buildCommand),
517 clip(next.outputDir),
518 idleDays,
519 a.actor.username,
520 now(),
521 )
522 .run();
A project is an app or a library: libraries show their package and how to ship a release, not production523 // Projects keeps whether it deploys, so a project with Deployments on is an app.
524 if (next.enabled !== before.enabled) {
525 await this.projects.deploymentsChanged(project.id, next.enabled).catch((error) => console.warn("projects:", error));
526 }
Plan: the inbox, then channels and apps, instead of discussions527 // What was turned off comes down now; nothing keeps running unasked.
528 if (!next.enabled) await this.takeDownWhere(project.id, null);
529 else {
530 if (!next.previews) await this.takeDownWhere(project.id, "preview");
531 if (!next.production) await this.takeDownWhere(project.id, "production");
532 }
533 // Turned on: production goes up from the default branch at once.
534 if (next.enabled && next.production && (!before.enabled || !before.production)) {
535 await this.deployProduction(project, null, a.actor.username);
536 }
537 return ok(await this.toSettings(project, await this.settingsRow(project.id)));
538 }
539
A project is an app or a library: libraries show their package and how to ship a release, not production540 /** For projects: whether Deployments are on for a project. Reads only this service's own table. */
541 async isEnabled(a: { projectId: string }): Promise<boolean> {
542 return !!(await this.settingsRow(a.projectId))?.enabled;
543 }
544
Plan: the inbox, then channels and apps, instead of discussions545 async list(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<{ deployments: Deployment[]; live: LiveApp[] }>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look546 const project = await this.projectFor(a.project, a.viewer, "list");
Plan: the inbox, then channels and apps, instead of discussions547 if (!project.ok) return project;
548 const [deployments, apps] = await Promise.all([
549 this.db
550 .prepare("SELECT * FROM deployments WHERE project_id = ? ORDER BY id DESC LIMIT ?")
551 .bind(project.value.id, LIST_LIMIT)
552 .all<DeploymentRow>(),
553 this.db
554 .prepare("SELECT * FROM apps WHERE project_id = ? ORDER BY kind DESC, deployed_at DESC")
555 .bind(project.value.id)
556 .all<AppRow>(),
557 ]);
558 return ok({ deployments: deployments.results.map(toDeployment), live: apps.results.map(toLive) });
559 }
560
561 async get(a: { project: ProjectRef; id: string; viewer: Viewer }): Promise<Result<Deployment & { log: string | null }>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look562 const project = await this.projectFor(a.project, a.viewer, "get");
Plan: the inbox, then channels and apps, instead of discussions563 if (!project.ok) return project;
564 const row = await this.db
565 .prepare("SELECT * FROM deployments WHERE id = ? AND project_id = ?")
566 .bind(a.id, project.value.id)
567 .first<DeploymentRow>();
568 if (!row) return fail("not_found", "No such deployment.");
569 return ok({ ...toDeployment(row), log: row.log });
570 }
571
572 async redeploy(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<Deployment>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look573 const found = await this.projectFor(a.project, a.actor, "redeploy");
Plan: the inbox, then channels and apps, instead of discussions574 if (!found.ok) return found;
575 const project = found.value;
576 const settings = await this.settingsRow(project.id);
577 if (!settings?.enabled) return fail("conflict", "Deployments are off for this project.");
578 if (a.branch == null) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look579 return notStarted((await this.deployProduction(project, null, a.actor.username)) ?? fail("conflict", "There was nothing to deploy."));
Plan: the inbox, then channels and apps, instead of discussions580 }
581 // A branch's preview comes from its pull request.
582 const app = await this.db
583 .prepare("SELECT number FROM deployments WHERE project_id = ? AND branch = ? AND number IS NOT NULL ORDER BY id DESC")
584 .bind(project.id, a.branch)
585 .first<{ number: number }>();
586 if (!app) return fail("not_found", `No pull request has deployed ${a.branch}.`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look587 return notStarted((await this.deployPreview(project, app.number, a.actor.username, true)) ?? fail("conflict", "Its pull request is not open."));
Plan: the inbox, then channels and apps, instead of discussions588 }
589
590 async takeDown(a: { actor: User; project: ProjectRef; branch: string | null }): Promise<Result<true>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look591 const project = await this.projectFor(a.project, a.actor, "takeDown");
Plan: the inbox, then channels and apps, instead of discussions592 if (!project.ok) return project;
593 await this.takeDownWhere(project.value.id, a.branch == null ? "production" : "preview", a.branch ?? undefined);
594 return ok(true);
595 }
596
597 async overview(a: { workspace: string; viewer: Viewer }): Promise<Result<ProjectDeploys[]>> {
598 const workspace = a.workspace.toLowerCase();
599 if (!isMember(a.viewer, workspace)) return fail("forbidden", "Only members can see a workspace's deployments.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look600 // Only the projects whose repositories the viewer can read: the
601 // projects service lists no others.
602 const listed = await this.projects.list(workspace, a.viewer);
603 if (!listed.ok) return listed;
604 const readable = new Set(listed.value.map((project) => project.slug));
Plan: the inbox, then channels and apps, instead of discussions605 const [settings, apps, latest] = await Promise.all([
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look606 // A deleted repository's projects are hidden until it is restored.
607 this.db.prepare("SELECT slug, enabled FROM settings WHERE workspace = ? AND repo_deleted_at IS NULL").bind(workspace).all<{ slug: string; enabled: number }>(),
Plan: the inbox, then channels and apps, instead of discussions608 this.db.prepare("SELECT * FROM apps WHERE workspace = ?").bind(workspace).all<AppRow>(),
609 this.db
610 .prepare(
611 `SELECT * FROM deployments WHERE id IN (SELECT MAX(id) FROM deployments WHERE workspace = ? GROUP BY project_id)`,
612 )
613 .bind(workspace)
614 .all<DeploymentRow>(),
615 ]);
616 return ok(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look617 settings.results.filter((row) => readable.has(row.slug)).map((row) => {
Plan: the inbox, then channels and apps, instead of discussions618 const own = apps.results.filter((app) => app.slug === row.slug);
619 const production = own.find((app) => app.kind === "production");
620 const newest = latest.results.find((d) => d.slug === row.slug);
621 return {
622 slug: row.slug,
623 enabled: !!row.enabled,
624 production: production ? toLive(production) : null,
625 previews: own.filter((app) => app.kind === "preview").length,
626 latest: newest ? toDeployment(newest) : null,
627 };
628 }),
629 );
630 }
631
632 async usage(a: { workspace: string; viewer: Viewer }): Promise<Result<DeployUsage>> {
633 const slug = a.workspace.toLowerCase();
634 if (!isMember(a.viewer, slug)) return fail("forbidden", "Only members can see a workspace's usage.");
635 const [meter, apps] = await Promise.all([
636 this.db
637 .prepare("SELECT * FROM meters WHERE namespace = ? AND month = ?")
638 .bind(slug, month())
639 .first<{
640 requests: number;
641 cpu_ms: number;
642 peak_apps: number;
643 build_seconds: number;
644 build_micros: number;
645 counted_at: string | null;
646 }>(),
647 this.db.prepare("SELECT COUNT(*) AS n FROM apps WHERE workspace = ?").bind(slug).first<{ n: number }>(),
648 ]);
649 return ok({
650 month: month(),
651 requests: meter?.requests ?? 0,
652 cpuMs: meter?.cpu_ms ?? 0,
653 apps: apps?.n ?? 0,
654 peakApps: Math.max(meter?.peak_apps ?? 0, apps?.n ?? 0),
655 buildSeconds: meter?.build_seconds ?? 0,
656 buildMicros: meter?.build_micros ?? 0,
657 countedAt: meter?.counted_at ?? null,
658 });
659 }
660
661 // ---- Custom domains ------------------------------------------------
662
663 async listDomains(a: { project: ProjectRef; viewer: Viewer }): Promise<Result<ProjectDomains>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look664 const project = await this.projectFor(a.project, a.viewer, "listDomains");
Plan: the inbox, then channels and apps, instead of discussions665 if (!project.ok) return project;
666 const domains = this.domains;
667 await domains.catchUp(project.value.id).catch((error) => console.error("could not check domains", error));
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas668 const [rows, available, used, costs] = await Promise.all([
Plan: the inbox, then channels and apps, instead of discussions669 domains.forProject(project.value.id),
670 domains.available(),
671 domains.countFor(project.value.workspace),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas672 this.costs(),
Plan: the inbox, then channels and apps, instead of discussions673 ]);
674 return ok({
675 domains: rows.map(toDomain),
676 target: CUSTOM_DOMAIN_TARGET,
677 available,
678 notice: available ? null : NOT_ENABLED_NOTICE,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas679 monthlyMicros: costs.domainMonthPrice,
Plan: the inbox, then channels and apps, instead of discussions680 used,
681 });
682 }
683
684 async addDomain(a: { actor: User; project: ProjectRef; hostname: string; twin?: boolean }): Promise<Result<Domain[]>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look685 const found = await this.projectFor(a.project, a.actor, "addDomain");
Plan: the inbox, then channels and apps, instead of discussions686 if (!found.ok) return found;
687 const project = found.value;
688 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
689 if (!plan.ok) return plan;
690 const added = await this.domains.add({
691 project: { id: project.id, workspace: project.workspace, slug: project.slug },
692 script: await this.productionScript(project),
693 hostname: String(a.hostname ?? ""),
694 twin: !!a.twin,
695 by: a.actor.username,
696 });
697 if (!added.ok) return fail(added.code, added.message);
698 await this.notePeak(project.workspace);
699 return ok(added.rows.map(toDomain));
700 }
701
702 async removeDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<true>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look703 const found = await this.projectFor(a.project, a.actor, "removeDomain");
Plan: the inbox, then channels and apps, instead of discussions704 if (!found.ok) return found;
705 const row = await this.domains.byId(found.value.id, String(a.id ?? ""));
706 if (!row) return fail("not_found", "No such domain.");
707 await this.domains.remove(row);
708 return ok(true);
709 }
710
711 async refreshDomain(a: { actor: User; project: ProjectRef; id: string }): Promise<Result<Domain>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look712 const found = await this.projectFor(a.project, a.actor, "refreshDomain");
Plan: the inbox, then channels and apps, instead of discussions713 if (!found.ok) return found;
714 const domains = this.domains;
715 const row = await domains.byId(found.value.id, String(a.id ?? ""));
716 if (!row) return fail("not_found", "No such domain.");
717 const after = await domains.refresh(row, row.script ?? (await this.productionScript(found.value)), true);
718 await this.notePeak(found.value.workspace);
719 return ok(toDomain(after));
720 }
721
722 /** The script production is up under, or the name it will have. */
723 private async productionScript(project: Project): Promise<string> {
724 const app = await this.db
725 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
726 .bind(project.id)
727 .first<{ script: string }>();
728 return app?.script ?? (await this.scriptFor(project, null));
729 }
730
731 // ---- Starting builds -----------------------------------------------
732
733 /**
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9734 * Opens a deployment and starts its build in a sandbox. Skipped, with the
735 * reason recorded, when the workspace's plan is off.
Plan: the inbox, then channels and apps, instead of discussions736 */
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9737 private async start(input: OpenInput & {
738 /** Where the build reads the commit from: the project, or a pull request's fork. */
Plan: the inbox, then channels and apps, instead of discussions739 source: RepoPath;
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9740 /** Who reads it: the workspace, or whoever the pull request is for. */
Plan: the inbox, then channels and apps, instead of discussions741 reader: User;
742 }): Promise<Result<Deployment>> {
743 const { project } = input;
744 const repo = repoOf(project);
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9745 const opened = await this.open(input, { reserve: true });
746 if (opened.refused !== null) return ok(toDeployment((await this.deploymentRow(opened.id))!));
747 const { build, compute } = opened;
748 const response = await this.env.RUNNER.fetch("https://runner/rpc/start_deploy", {
749 method: "POST",
750 headers: { "content-type": "application/json" },
751 body: JSON.stringify({
752 deployId: opened.id,
753 token: opened.token,
754 workspace: project.workspace,
755 reservation: compute.reservation,
756 microsPerSecond: compute.microsPerSecond,
757 maxRunMinutes: compute.maxRunMinutes,
758 actor: input.reader,
759 source: input.source,
760 // The project, whose guardrails the build runs under: a preview's
761 // source is its pull request's working copy, not the project.
762 repo: repo.path,
763 repoId: repo.id,
764 commit: input.commit,
765 rootDir: project.source.rootDir,
766 buildCommand: input.settings.build_command,
767 outputDir: input.settings.output_dir,
768 buildEnv: build.variables,
769 buildSecrets: build.secrets,
770 }),
771 });
772 const started = response.ok ? ((await response.json()) as Result<true>) : fail("conflict", `The runner answered ${response.status}.`);
773 if (!started.ok) {
774 // Never reached a sandbox: what was reserved is given back.
775 if (compute.reservation) await gateFor(this.env.BILLING).settle(compute.reservation, 0);
776 await this.finishFailed(opened.id, started.error.message, null, null);
777 }
778 return ok(toDeployment((await this.deploymentRow(opened.id))!));
779 }
780
781 /**
782 * What every build does before anything builds it: checks the plan and
783 * the spend limit (and, for a sandbox, reserves its compute with
784 * billing), records the deployment with a token for that build alone,
785 * replaces older builds of the same app, sets the commit's status, and
786 * reads what the project's secrets and variables give builds of its
787 * kind. A refusal is the deployment's status, saying what to do; nothing
788 * is resolved for a refused build.
789 */
790 private async open(input: OpenInput, options: { reserve: boolean }): Promise<Opened> {
791 const { project } = input;
792 const repo = repoOf(project);
Plan: the inbox, then channels and apps, instead of discussions793 const script = await this.scriptFor(project, input.branch);
794 const id = newId("dpl");
795 const token = randomToken();
796 const plan = await billingClient(this.env.BILLING).hasFeature(project.workspace, "deployments");
797 const limit = await billingClient(this.env.BILLING).checkLimit(project.workspace);
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9798 const stopped = limit.ok && limit.value.state === "stopped";
Plan: the inbox, then channels and apps, instead of discussions799 const cloudflare = this.cloudflare;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look800 let refused = !plan.ok
Plan: the inbox, then channels and apps, instead of discussions801 ? plan.error.message
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9802 : stopped
Plan: the inbox, then channels and apps, instead of discussions803 ? (limit.value.message ?? "The workspace reached its usage limit.")
804 : !cloudflare
805 ? "Deployments are not set up on this g1t: it has no Cloudflare token."
806 : null;
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9807 let code: Refusal = plan.ok && !stopped ? "conflict" : "payment_required";
808 // A sandbox's build is compute: reserved with billing before it starts,
809 // and settled by its sandbox when it stops. A workflow job's build runs
810 // in the job, which pays for its own time.
811 const compute: Compute = { reservation: null, microsPerSecond: 0, maxRunMinutes: null };
812 if (!refused && options.reserve) {
813 const gate = gateFor(this.env.BILLING);
814 const ent = await gate.entitlements(project.workspace);
815 compute.microsPerSecond = await gate.microsPerSecond();
816 compute.maxRunMinutes = ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look817 const isPrivate = await reposClient(this.env.REPOS)
818 .get(repo.path, null)
819 .then((found) => !found.ok || found.value.isPrivate)
820 .catch(() => true);
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9821 const admitted = await gate.admit(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look822 {
823 workspace: project.workspace,
824 repo: repo.path,
825 public: !isPrivate,
826 kind: "deploy",
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9827 estimateMicros: sandboxEstimateMicros(Math.min(BUILD_MINUTES, compute.maxRunMinutes ?? BUILD_MINUTES), compute.microsPerSecond),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look828 },
829 ent,
830 );
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9831 if (admitted.ok) compute.reservation = admitted.reservation?.id ?? null;
832 else {
833 refused = admitted.message;
834 code = "payment_required";
835 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look836 }
Plan: the inbox, then channels and apps, instead of discussions837 await this.db
838 .prepare(
839 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9840 script, status, error, token_hash, trusted, created_by, created_at, finished_at, run_id, job_id)
841 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
Plan: the inbox, then channels and apps, instead of discussions842 )
843 .bind(
844 id,
845 project.id,
846 project.workspace,
847 project.slug,
848 repo.id,
849 `${repo.path.namespace}/${repo.path.name}`,
850 input.kind,
851 input.branch,
852 input.number,
853 input.commit,
854 script,
855 refused ? "skipped" : "queued",
856 refused,
857 refused ? null : await sha256(token),
858 input.trusted ? 1 : 0,
859 input.createdBy,
860 now(),
861 refused ? now() : null,
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9862 input.workflow?.runId ?? null,
863 input.workflow?.jobId ?? null,
Plan: the inbox, then channels and apps, instead of discussions864 )
865 .run();
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9866 if (refused) return { refused, code, id };
Plan: the inbox, then channels and apps, instead of discussions867 // Older builds of the same app are replaced by this one.
868 await this.db
869 .prepare(
870 `UPDATE deployments SET status = 'skipped', error = 'A newer commit replaced this build.', finished_at = ?
871 WHERE script = ? AND id != ? AND status IN ('queued', 'building')`,
872 )
873 .bind(now(), script, id)
874 .run();
875 await this.status(repo.id, input.commit, project, "pending", "Building", `${this.env.SITE}/${project.workspace}/${project.slug}/deployments/${id}`);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97876 await this.buildChanged(id, true);
Plan: the inbox, then channels and apps, instead of discussions877 // What the project's secrets and variables give builds of this kind.
878 const build = await this.resolve(
879 { id: project.id, slug: project.slug, repoId: repo.id, repo: repo.path },
880 input.kind,
881 input.trusted,
882 );
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9883 return { refused: null, id, token, script, build, compute };
884 }
885
886 /**
887 * For a g1t Actions job (`POST /repos/{owner}/{repo}/deployments/builds`):
888 * opens a g1t.page build of one of the repository's projects, which the
889 * job builds in its own checkout with `g1t-runner deploy`. The caller is
890 * the job's own token (`token.job`), which needs `deployments: write` and
891 * reaches this repository only. The run decides what is built: a run on
892 * the project's production branch builds production, a pull request's
893 * run its preview, a run on another branch that branch's preview. A pull
894 * request from a fork or from someone who cannot push here, and a run
895 * that needed approval, get no build secrets. The job pays for its own
896 * time, so nothing is reserved or charged here for the build; the plan
897 * and the spend limit are checked as for any build.
898 */
899 async startFromWorkflow(a: { actor: User; repo: RepoPath; project?: string | null; sha?: string | null }): Promise<Result<WorkflowBuild>> {
900 const repoPath = { namespace: String(a.repo?.namespace ?? ""), name: String(a.repo?.name ?? "") };
901 const job = jobOf(a.actor, repoPath);
902 if ("error" in job) return fail("forbidden", job.error);
903 const actor = await this.workspaceActor(repoPath.namespace);
904 if (!actor) return fail("not_found", "There is no such repository.");
905 const found = await reposClient(this.env.REPOS).get(repoPath, actor);
906 if (!found.ok) return fail("not_found", "There is no such repository.");
907 const projects = await this.projects.byRepo(found.value.id);
908 const rows = new Map(await Promise.all(projects.map(async (p) => [p.id, await this.settingsRow(p.id)] as const)));
909 const live = (row: SettingsRow | null | undefined): row is SettingsRow =>
910 !!row?.enabled && !row.repo_deleted_at && !row.workspace_deleted_at;
911 const chosen = projectNamed(projects, (p) => live(rows.get(p.id)), a.project);
912 if ("error" in chosen) return fail(chosen.code, chosen.error);
913 const project = chosen;
914 if (project.source.kind !== "hosted") return fail("conflict", "Only projects hosted on g1t deploy so far.");
915 if (!can(a.actor, repoRef(project), NEEDS.redeploy)) return fail("forbidden", needs(NEEDS.redeploy));
916 const settings = rows.get(project.id);
917 if (!live(settings)) return fail("conflict", `Deployments are off for ${project.slug}.`);
918 const repo = repoOf(project);
919
920 // The run and its job, as the actions service has them: still running.
921 const detail = await actionsClient(this.env.ACTIONS).run(repo.path, actor, job.runId);
922 if (!detail.ok) return fail("not_found", "The job's run was not found.");
923 const { run } = detail.value;
924 const running = detail.value.jobs.find((j) => j.id === job.jobId);
925 if (run.status === "completed" || !running || running.status === "completed") {
926 return fail("conflict", "The job has finished: a build is opened from a job while it runs.");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look927 }
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9928 const commit = commitOf(a.sha, run);
929 if (typeof commit !== "string") return fail("invalid", commit.error);
930
931 const target = targetOf(run, repo.defaultBranch);
932 if ("error" in target) return fail("invalid", target.error);
933 let kind: DeployKind = "production";
934 let branch: string | null = null;
935 let number: number | null = null;
936 let trusted = runTrusted({ approval: detail.value.approval, pull: null, insider: true });
937 if (target.kind === "pull") {
938 const pull = await workClient(this.env.WORK).getPull(repo.path, target.number, actor);
939 if (!pull.ok) return fail("not_found", `Pull request #${target.number} was not found.`);
940 const open = pull.value.pull;
941 if (open.status !== "open" && open.status !== "draft") {
942 return fail("conflict", `Pull request #${target.number} is not open, so it has no preview.`);
943 }
944 kind = "preview";
945 // A pull request from a fork has no branch here (see `deployPreview`).
946 branch = open.branch ?? `pr-${target.number}`;
947 number = target.number;
948 trusted = runTrusted({
949 approval: detail.value.approval,
950 pull: open,
951 insider: await this.insider(repo.path, workOwner(open), actor),
952 });
953 } else if (target.kind === "branch") {
954 kind = "preview";
955 branch = target.branch;
956 }
957 if (kind === "production" && !settings.production) return fail("conflict", `Production deploys are off for ${project.slug}.`);
958 if (kind === "preview" && !settings.previews) return fail("conflict", `Previews are off for ${project.slug}.`);
959
960 const opened = await this.open(
961 { project, kind, branch, number, commit, createdBy: run.actor ?? "g1t", settings, trusted, workflow: job },
962 { reserve: false },
963 );
964 if (opened.refused !== null) return fail(opened.code, opened.refused);
965 return ok({
966 deploy_id: opened.id,
967 token: opened.token,
968 kind,
969 project: project.slug,
970 branch,
971 number,
972 commit,
973 url: appUrl(opened.script),
974 trusted,
975 root_dir: project.source.rootDir ?? "",
976 build_command: settings.build_command,
977 output_dir: settings.output_dir,
978 build_env: opened.build.variables,
979 build_secrets: opened.build.secrets,
980 });
Plan: the inbox, then channels and apps, instead of discussions981 }
982
983 private async deployProduction(project: Project, commit: string | null, createdBy: string): Promise<Result<Deployment> | null> {
984 const settings = await this.settingsRow(project.id);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member985 if (!settings?.enabled || !settings.production || settings.repo_deleted_at || settings.workspace_deleted_at) return null;
Plan: the inbox, then channels and apps, instead of discussions986 const actor = await this.workspaceActor(project.workspace);
987 if (!actor) return null;
988 const repo = repoOf(project);
989 let head = commit;
990 if (!head) {
991 const branches = await reposClient(this.env.REPOS).branches(repo.path, actor);
992 head = branches.ok ? (branches.value.find((b) => b.name === repo.defaultBranch)?.hash ?? null) : null;
993 }
994 if (!head) return null;
995 return this.start({
996 project,
997 kind: "production",
998 branch: null,
999 number: null,
1000 commit: head,
1001 source: repo.path,
1002 reader: actor,
1003 createdBy,
1004 settings,
1005 // The default branch only moves by people and agents with access.
1006 trusted: true,
1007 });
1008 }
1009
1010 private async deployPreview(project: Project, number: number, createdBy: string, force = false): Promise<Result<Deployment> | null> {
1011 const settings = await this.settingsRow(project.id);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1012 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at || settings.workspace_deleted_at) return null;
Plan: the inbox, then channels and apps, instead of discussions1013 const actor = await this.workspaceActor(project.workspace);
1014 if (!actor) return null;
1015 const repo = repoOf(project);
1016 const detail = await workClient(this.env.WORK).getPull(repo.path, number, actor);
1017 if (!detail.ok) return null;
1018 const { pull } = detail.value;
1019 if ((pull.status !== "open" && pull.status !== "draft") || !pull.headCommit) return null;
1020 // A pull request from a fork (as g1t's agents work) has no branch here.
1021 const branch = pull.branch ?? `pr-${number}`;
1022 if (!force) {
1023 // Already built, or being built, at this commit.
1024 const same = await this.db
1025 .prepare(
1026 `SELECT id FROM deployments WHERE project_id = ? AND kind = 'preview' AND branch = ? AND commit_sha = ?
1027 AND status IN ('queued', 'building', 'ready')`,
1028 )
1029 .bind(project.id, branch, pull.headCommit)
1030 .first();
1031 if (same) return null;
1032 }
1033 return this.start({
1034 project,
1035 kind: "preview",
1036 branch,
1037 number,
1038 commit: pull.headCommit,
1039 source: pull.fork ?? repo.path,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1040 // The pull request's fork may be private: read it as whoever it is
1041 // for (whoever asked g1t for it, or its author), who is also who is
1042 // trusted or not with the project's secrets.
1043 reader: workOwner(pull),
Plan: the inbox, then channels and apps, instead of discussions1044 createdBy,
1045 settings,
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights1046 trusted: await this.insider(repo.path, workOwner(pull), actor),
Plan: the inbox, then channels and apps, instead of discussions1047 });
1048 }
1049
1050 // ---- A build's reports ---------------------------------------------
1051
1052 private async deploymentRow(id: string): Promise<DeploymentRow | null> {
1053 return this.db.prepare("SELECT * FROM deployments WHERE id = ?").bind(id).first<DeploymentRow>();
1054 }
1055
1056 /** The build, if `token` is its own and it is still under way. */
1057 private async building(id: string, token: unknown): Promise<DeploymentRow | null> {
1058 const row = await this.deploymentRow(id);
1059 if (!row?.token_hash || typeof token !== "string") return null;
1060 if (row.token_hash !== (await sha256(token))) return null;
1061 return row.status === "queued" || row.status === "building" ? row : null;
1062 }
1063
1064 async job(id: string, step: string, body: Record<string, unknown>): Promise<Response> {
1065 // Each report, for the logs: a build's own failure says why.
1066 console.log("build", id, step, typeof body.message === "string" ? body.message.slice(0, 500) : "");
1067 const row = await this.building(id, body.token);
1068 if (!row) return Response.json(fail("not_found", "No such build, or it has finished."), { status: 404 });
1069 const cloudflare = this.cloudflare;
1070 if (!cloudflare) return Response.json(fail("conflict", "Deployments are not set up."), { status: 409 });
1071 switch (step) {
1072 case "started":
1073 await this.db
1074 .prepare("UPDATE deployments SET status = 'building', started_at = ? WHERE id = ?")
1075 .bind(now(), id)
1076 .run();
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971077 await this.buildChanged(id);
Plan: the inbox, then channels and apps, instead of discussions1078 return Response.json(ok(true));
1079 case "session": {
1080 const manifest = body.manifest as Manifest | undefined;
1081 if (!manifest || typeof manifest !== "object") return Response.json(fail("invalid", "No manifest."), { status: 400 });
1082 const session = await cloudflare.openUpload(row.script, manifest);
1083 return Response.json(ok({ ...session, uploadUrl: cloudflare.uploadUrl }));
1084 }
1085 case "finish": {
1086 const worker = (body.worker ?? {}) as BuiltWorker;
1087 const seconds = Number(body.buildSeconds) || 0;
1088 const [namespace, name] = row.repo.split("/") as [string, string];
1089 try {
1090 // Running apps' secrets and variables are bound here, by g1t:
1091 // they never pass through the build's sandbox.
1092 const runtime = await this.resolve(
1093 { id: row.project_id, slug: row.slug, repoId: row.repo_id, repo: { namespace, name } },
1094 row.kind,
1095 !!row.trusted,
1096 );
1097 await cloudflare.putScript(
1098 row.script,
1099 worker,
1100 typeof body.completionJwt === "string" ? body.completionJwt : null,
1101 [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind],
1102 runtime,
1103 );
1104 } catch (error) {
1105 await this.finishFailed(id, `Cloudflare did not take the app: ${String(error).replace(/^Error: /, "")}`, String(body.log ?? ""), seconds);
1106 return Response.json(ok(false));
1107 }
1108 const at = now();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1109 const wasRedirect = await this.db.prepare("SELECT 1 FROM redirects WHERE script = ?").bind(row.script).first();
Plan: the inbox, then channels and apps, instead of discussions1110 await this.db.batch([
1111 this.db
1112 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1113 `UPDATE deployments SET status = 'ready', warnings = ?, log = ?, build_seconds = ?, finished_at = ?, detected = ?
Plan: the inbox, then channels and apps, instead of discussions1114 WHERE id = ?`,
1115 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1116 .bind(
1117 JSON.stringify(Array.isArray(body.warnings) ? body.warnings : []),
1118 String(body.log ?? ""),
1119 seconds,
1120 at,
1121 detectedKind(body.detected),
1122 id,
1123 ),
Builds say Replaced or Down once they are no longer live1124 // The build it replaces is no longer what the app serves.
Plan: the inbox, then channels and apps, instead of discussions1125 this.db
Builds say Replaced or Down once they are no longer live1126 .prepare(`UPDATE deployments SET status = 'replaced' WHERE script = ? AND id != ? AND status = 'ready'`)
1127 .bind(row.script, id),
1128 this.db
Plan: the inbox, then channels and apps, instead of discussions1129 .prepare(
1130 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at)
1131 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)
1132 ON CONFLICT (script) DO UPDATE SET commit_sha = ?8, number = ?7, deployed_at = ?9, paused_at = NULL`,
1133 )
1134 .bind(row.script, row.project_id, row.workspace, row.slug, row.kind, row.branch, row.number, row.commit_sha, at),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1135 // A name that redirected elsewhere (a move undone) is an app again.
Plan: the inbox, then channels and apps, instead of discussions1136 this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(row.script),
1137 ]);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1138 if (wasRedirect) {
1139 await this.env.DOMAINS?.delete(appHost(row.script)).catch((error) => console.error("could not drop redirect", row.script, error));
1140 }
1141 // The same app at an older name (its project moved) now redirects
1142 // here; it stays up as it was if the redirect cannot be put.
1143 await this.supersede(cloudflare, row, row.script);
Plan: the inbox, then channels and apps, instead of discussions1144 // The project's own domains serve production wherever it is up.
1145 if (row.kind === "production") {
1146 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
1147 }
1148 await this.chargeBuild(row, seconds);
1149 await this.notePeak(row.workspace);
1150 await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script));
Events: review requests, assignments, stops and deployments are published1151 await this.announce(row, null);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971152 await this.buildChanged(id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1153 // A screenshot of production as it now is, for the project's overview.
1154 if (row.kind === "production") {
1155 await this.env.SCREENSHOTS?.capture({ host: appHost(row.script), commit: row.commit_sha }).catch((error) =>
1156 console.error("could not ask for a screenshot", error),
1157 );
1158 }
Plan: the inbox, then channels and apps, instead of discussions1159 return Response.json(ok(true));
1160 }
1161 case "fail":
1162 await this.finishFailed(id, String(body.message ?? "The build failed."), body.log == null ? null : String(body.log), Number(body.buildSeconds) || null);
1163 return Response.json(ok(true));
1164 default:
1165 return Response.json(fail("not_found", "No such step."), { status: 404 });
1166 }
1167 }
1168
1169 /**
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1170 * Older names of the app `script`, now up: one project's production, or
Plan: the inbox, then channels and apps, instead of discussions1171 * its preview of one branch, has one name, so any other app row for the
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1172 * same is the app under a name it had before its project moved (its
1173 * workspace renamed, its repository renamed or transferred). Each is
1174 * replaced in the namespace by a redirect to the new name, its app row
1175 * goes, and the redirect is recorded for `SLUG_HOLD_DAYS`, both here (for
1176 * the sweep to hold the old script) and in `DOMAINS` under the old
1177 * hostname, which the dispatcher follows before running anything, so the
1178 * old address redirects even if the old script is paused. A name that
1179 * cannot be redirected is left as it is, for the next deploy or sweep.
Plan: the inbox, then channels and apps, instead of discussions1180 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1181 private async supersede(
1182 cloudflare: Cloudflare,
1183 app: { project_id: string; kind: DeployKind; branch: string | null; workspace: string },
1184 script: string,
1185 ): Promise<string[]> {
Plan: the inbox, then channels and apps, instead of discussions1186 const older = await this.db
1187 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = ? AND branch IS ? AND script != ?")
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1188 .bind(app.project_id, app.kind, app.branch, script)
Plan: the inbox, then channels and apps, instead of discussions1189 .all<{ script: string }>();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1190 const target = appHost(script);
Plan: the inbox, then channels and apps, instead of discussions1191 const done: string[] = [];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1192 for (const { script: old } of older.results) {
Plan: the inbox, then channels and apps, instead of discussions1193 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1194 await cloudflare.redirectScript(old, target);
Plan: the inbox, then channels and apps, instead of discussions1195 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1196 console.error("could not redirect", old, "to", script, error);
1197 continue;
Plan: the inbox, then channels and apps, instead of discussions1198 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1199 const at = now();
1200 const expires = new Date(Date.parse(at) + SLUG_HOLD_DAYS * 24 * 60 * 60 * 1000).toISOString();
1201 await this.db.batch([
1202 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(old),
1203 this.db
1204 .prepare(
1205 `INSERT INTO redirects (script, target, workspace, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)
1206 ON CONFLICT (script) DO UPDATE SET target = ?2, workspace = ?3, created_at = ?4, expires_at = ?5`,
1207 )
1208 .bind(old, target, app.workspace, at, expires),
1209 // Its builds are no longer live under the old name.
1210 this.db.prepare("UPDATE deployments SET status = 'replaced' WHERE script = ? AND status = 'ready'").bind(old),
1211 ]);
1212 await this.env.DOMAINS?.put(appHost(old), JSON.stringify({ script: old, redirect: target }), {
1213 expiration: Math.floor(Date.parse(expires) / 1000),
1214 }).catch((error) => console.error("could not record redirect", old, error));
1215 done.push(old);
Plan: the inbox, then channels and apps, instead of discussions1216 }
1217 return done;
1218 }
1219
1220 private async finishFailed(id: string, message: string, log: string | null, seconds: number | null): Promise<void> {
1221 const row = await this.deploymentRow(id);
1222 if (!row || (row.status !== "queued" && row.status !== "building")) return;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1223 // A commit that is gone says so plainly; git's own words stay in the log.
1224 if (commitMissing(message)) {
1225 log = log ?? message;
1226 message = missingCommitMessage(row);
1227 }
Plan: the inbox, then channels and apps, instead of discussions1228 await this.db
1229 .prepare(
1230 `UPDATE deployments SET status = 'failed', error = ?, log = COALESCE(?, log), build_seconds = ?, finished_at = ?
1231 WHERE id = ?`,
1232 )
1233 .bind(message.slice(0, 2000), log, seconds, now(), id)
1234 .run();
1235 // A failed build still used its sandbox.
1236 if (seconds) await this.chargeBuild(row, seconds);
1237 await this.statusFor(row, "failure", "Deployment failed", `${this.env.SITE}/${row.workspace}/${row.slug}/deployments/${id}`);
Events: review requests, assignments, stops and deployments are published1238 await this.announce(row, message.slice(0, 300));
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971239 await this.buildChanged(id);
Events: review requests, assignments, stops and deployments are published1240 }
1241
1242 /**
1243 * Publishes a finished build: `deployment.failed` with what went wrong,
1244 * or `deployment.succeeded`, saying whether the build of the same app
1245 * before it failed. Whoever started it is the actor when it was a
1246 * person known by id; otherwise `triggeredBy` names them, or g1t.
1247 */
1248 private async announce(row: DeploymentRow, error: string | null): Promise<void> {
1249 if (!this.env.EVENTS) return;
1250 const previous = error
1251 ? null
1252 : await this.db
1253 .prepare(
1254 `SELECT status FROM deployments
1255 WHERE script = ? AND id != ? AND created_at < ? AND status IN ('ready', 'replaced', 'down', 'failed')
1256 ORDER BY created_at DESC LIMIT 1`,
1257 )
1258 .bind(row.script, row.id, row.created_at)
1259 .first<{ status: string }>();
1260 const byId = row.created_by.startsWith("usr_");
1261 const event = {
1262 source: "deployments",
1263 repoId: row.repo_id,
1264 actor: byId ? row.created_by : null,
1265 data: {
1266 deploymentId: row.id,
1267 projectId: row.project_id,
1268 repoId: row.repo_id,
1269 workspace: row.workspace,
1270 project: row.slug,
1271 kind: row.kind,
1272 branch: row.branch,
1273 number: row.kind === "preview" ? row.number : null,
1274 commit: row.commit_sha,
1275 path: `/${row.workspace}/${row.slug}/deployments/${row.id}`,
1276 error,
1277 recovered: previous?.status === "failed",
1278 triggeredBy: byId ? "" : row.created_by,
1279 },
1280 };
1281 await eventsClient(this.env.EVENTS)
1282 .publish([error == null ? { type: "deployment.succeeded", ...event } : { type: "deployment.failed", ...event }])
1283 .catch((reason: unknown) => console.error("deployment not published", row.id, String(reason)));
Plan: the inbox, then channels and apps, instead of discussions1284 }
1285
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91286 /**
1287 * Each build is charged by the second, from the first, at the container
1288 * price plus the margin. A build a workflow job ran is not: the job's
1289 * time is charged as Actions time.
1290 */
Plan: the inbox, then channels and apps, instead of discussions1291 private async chargeBuild(row: DeploymentRow, seconds: number): Promise<void> {
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91292 if (row.run_id) return;
Plan: the inbox, then channels and apps, instead of discussions1293 const costs = await this.costs();
1294 const cost = Math.ceil(Math.ceil(seconds) * costs.buildSecond);
1295 if (cost <= 0) return;
1296 const what =
1297 row.kind === "preview"
1298 ? `the ${row.branch} preview of ${row.workspace}/${row.slug}`
1299 : `${row.workspace}/${row.slug} to production`;
1300 await billingClient(this.env.BILLING).chargeFeature({
1301 workspace: row.workspace,
1302 feature: "deployments",
1303 costMicros: cost,
1304 description: `Building ${what} (${Math.ceil(seconds)} s)`,
1305 repo: row.repo,
1306 reference: `deploy/${row.id}`,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1307 // Every second is metered; billing prices it from its price book and
1308 // tallies the month's build time.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1309 buildSeconds: Math.ceil(seconds),
Plan: the inbox, then channels and apps, instead of discussions1310 });
1311 await this.db
1312 .prepare(
1313 `INSERT INTO meters (namespace, month, build_seconds, build_micros) VALUES (?1, ?2, ?3, ?4)
1314 ON CONFLICT (namespace, month) DO UPDATE SET build_seconds = build_seconds + ?3, build_micros = build_micros + ?4`,
1315 )
1316 .bind(row.workspace, month(), Math.ceil(seconds), cost)
1317 .run();
1318 }
1319
1320 /**
1321 * What each unit costs g1t now, from billing's price book, which follows
1322 * what Cloudflare bills. The plan's figures if billing cannot say.
1323 */
1324 private async costs(): Promise<{
1325 buildSecond: number;
1326 millionRequests: number;
1327 millionCpuMs: number;
1328 domainMonth: number;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1329 /** What one custom domain is charged a month: the cost plus the margin. */
1330 domainMonthPrice: number;
Plan: the inbox, then channels and apps, instead of discussions1331 }> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1332 const a = DEPLOYMENT_COSTS;
Plan: the inbox, then channels and apps, instead of discussions1333 const book = await billingClient(this.env.BILLING)
1334 .prices()
1335 .catch(() => null);
1336 const cost = (meter: string, fallback: number) => book?.prices.find((p) => p.meter === meter)?.costMicros ?? fallback;
1337 return {
1338 buildSecond: cost("build_second", a.microsPerBuildSecond),
1339 millionRequests: cost("app_requests", a.microsPerMillionRequests),
1340 millionCpuMs: cost("app_cpu", a.microsPerMillionCpuMs),
1341 domainMonth: cost("custom_domain_month", a.microsPerDomainMonth),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1342 domainMonthPrice:
1343 book?.prices.find((p) => p.meter === "custom_domain_month")?.priceMicros ?? Math.ceil(a.microsPerDomainMonth * 1.2),
Plan: the inbox, then channels and apps, instead of discussions1344 };
1345 }
1346
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1347 /** Remembers the most apps (for information; never charged) and custom domains the workspace had at once this month. */
Plan: the inbox, then channels and apps, instead of discussions1348 private async notePeak(workspace: string): Promise<void> {
1349 await this.db
1350 .prepare(
1351 `INSERT INTO meters (namespace, month, peak_apps, peak_domains)
1352 VALUES (?1, ?2, (SELECT COUNT(*) FROM apps WHERE workspace = ?1),
1353 (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))
1354 ON CONFLICT (namespace, month) DO UPDATE SET
1355 peak_apps = MAX(peak_apps, (SELECT COUNT(*) FROM apps WHERE workspace = ?1)),
1356 peak_domains = MAX(peak_domains, (SELECT COUNT(*) FROM domains WHERE workspace = ?1 AND cf_hostname_id IS NOT NULL))`,
1357 )
1358 .bind(workspace, month())
1359 .run();
1360 }
1361
1362 /**
1363 * The check on the commit: `g1t / deploy`, or, for one of several
1364 * projects on a repository, `g1t / deploy (<project>)`.
1365 */
1366 private async status(
1367 repoId: string,
1368 sha: string,
1369 project: { slug: string; primary: boolean },
1370 state: string,
1371 description: string,
1372 targetUrl: string,
1373 ): Promise<void> {
1374 const context = project.primary ? STATUS_CONTEXT : `${STATUS_CONTEXT} (${project.slug})`;
1375 await this.env.WORK.fetch("https://work/rpc/set_commit_status", {
1376 method: "POST",
1377 headers: { "content-type": "application/json" },
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1378 body: JSON.stringify({ repoId, sha, context, state, description, targetUrl, source: "deployments" }),
Plan: the inbox, then channels and apps, instead of discussions1379 }).catch(() => undefined);
1380 }
1381
1382 private async statusFor(row: DeploymentRow, state: string, description: string, targetUrl: string): Promise<void> {
1383 const projects = await this.projects.byRepo(row.repo_id);
1384 const primary = projects.find((p) => p.id === row.project_id)?.primary ?? true;
1385 await this.status(row.repo_id, row.commit_sha, { slug: row.slug, primary }, state, description, targetUrl);
1386 }
1387
1388 // ---- Taking apps down ----------------------------------------------
1389
1390 private async removeApp(script: string): Promise<void> {
1391 await this.cloudflare?.deleteScript(script);
Builds say Replaced or Down once they are no longer live1392 await this.db.batch([
1393 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
1394 // Its build is no longer live anywhere.
1395 this.db.prepare("UPDATE deployments SET status = 'down' WHERE script = ? AND status = 'ready'").bind(script),
1396 ]);
Plan: the inbox, then channels and apps, instead of discussions1397 }
1398
1399 private async takeDownWhere(projectId: string, kind: DeployKind | null, branch?: string): Promise<void> {
1400 const apps = await this.db
1401 .prepare(
1402 `SELECT script FROM apps WHERE project_id = ?1 AND (?2 IS NULL OR kind = ?2) AND (?3 IS NULL OR branch = ?3)`,
1403 )
1404 .bind(projectId, kind, branch ?? null)
1405 .all<{ script: string }>();
1406 for (const app of apps.results) await this.removeApp(app.script);
1407 }
1408
1409 // ---- Events --------------------------------------------------------
1410
1411 /** `attempts`: which delivery of the event this is, from 1. */
1412 async onEvent(event: G1tEvent, attempts = 1): Promise<void> {
1413 switch (event.type) {
1414 case "workspace.renamed":
1415 await this.renamed(event.data, attempts);
1416 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1417 case "repo.transferred":
1418 case "repo.renamed":
1419 await this.moved(repoMove(event)!, attempts);
1420 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1421 // A repository that went or came back with its workspace is the
1422 // workspace's to handle: its apps are paused, not taken down.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1423 case "repo.deleted":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1424 if (!event.data.withWorkspace) await this.repoDeleted(event.data.repoId);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1425 break;
1426 case "repo.restored":
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1427 if (!event.data.withWorkspace) await this.repoRestored(event.data.repoId, attempts);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1428 break;
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1429 case "workspace.deleting":
1430 await this.workspaceDeleting(event.data.slug);
1431 break;
1432 case "workspace.restored":
1433 await this.workspaceRestored(event.data.slug);
1434 break;
1435 case "workspace.deleted":
1436 await this.workspacePurged(event.data.slug);
1437 break;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1438 case "repo.purged":
1439 await this.repoPurged(event.data.repoId);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971440 await this.repoDeployments.purge(event.data.repoId);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1441 break;
1442 case "repo.default_branch_changed":
1443 await this.defaultBranchChanged(event.data.repoId, event.data.to, event.actor ?? "g1t");
1444 break;
1445 case "branch.renamed":
1446 await this.branchRenamed(event.data.repoId, event.data.from, event.data.to);
1447 break;
1448
Plan: the inbox, then channels and apps, instead of discussions1449 case "pull.opened":
1450 case "pull.ready":
1451 case "pull.updated":
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1452 case "pull.reopened":
Plan: the inbox, then channels and apps, instead of discussions1453 for (const project of await this.projects.byRepo(event.data.repoId)) {
1454 await this.deployPreview(project, event.data.number, "g1t");
1455 }
1456 break;
1457 case "pull.closed":
1458 case "pull.merged":
1459 for (const project of await this.projects.byRepo(event.data.repoId)) {
1460 const apps = await this.db
1461 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'preview' AND number = ?")
1462 .bind(project.id, event.data.number)
1463 .all<{ script: string }>();
1464 for (const app of apps.results) await this.removeApp(app.script);
1465 }
1466 break;
1467 case "git.push":
1468 if (!event.data.defaultBranch) break;
Merge branch 'mirroring' into artifacts-mode1469 // A mirror deploys only while g1t leads it: standing by, or in CI
1470 // failover, the remote's own deploys stand (see contracts mirrors).
1471 if (!mirrorWritable(event.data.mirror)) break;
Plan: the inbox, then channels and apps, instead of discussions1472 for (const project of await this.projects.byRepo(event.data.repoId)) {
1473 await this.deployProduction(project, event.data.after, event.actor ?? "g1t");
1474 }
1475 break;
1476 }
1477 }
1478
1479 /**
1480 * A workspace's slug changed, and with it every app's name: production
1481 * at `<project>-<workspace>`, previews at `<project>-git-<branch>-<workspace>`.
1482 *
1483 * Its rows move to the slug it has now (asked of identity, so a delivery
1484 * twice over, or an older rename after a newer one, ends the same), and
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1485 * each app (paused or not) is built again from the same commit under its
1486 * new name; see `followMoves`. The old name keeps serving the app until
1487 * the new one is live; then it redirects to the new name (see
1488 * `supersede`), held for as long as the workspace holds its old slug.
Plan: the inbox, then channels and apps, instead of discussions1489 * Builds under way for the old name are dropped and started again under
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1490 * the new one.
Plan: the inbox, then channels and apps, instead of discussions1491 */
1492 private async renamed(renamed: { workspaceId: string; from: string; to: string }, attempts: number): Promise<void> {
1493 const current = await currentWorkspaceSlug(this.env.IDENTITY, renamed);
1494 const stale = staleSlugs(renamed, current);
1495 if (stale.length === 0) return;
1496 const marks = stale.map(() => "?").join(", ");
1497
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1498 // The workspace's projects, under any of its names: a second delivery
1499 // finds them under the new one, with whatever is left to do.
1500 const rows = await this.db
1501 .prepare(`SELECT project_id FROM settings WHERE workspace IN (${marks}, ?)`)
1502 .bind(...stale, current)
1503 .all<{ project_id: string }>();
1504 const projectIds = rows.results.map((row) => row.project_id);
1505 const projects = await this.projectsById(projectIds);
1506 // The projects service hears of the rename on its own queue: wait for
1507 // it a few deliveries, so the builds read the repository by its new name.
1508 const behind = [...projects.values()].some((p) => p.workspace !== current);
1509 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${renamed.from} renamed to ${current} yet`);
Plan: the inbox, then channels and apps, instead of discussions1510
1511 // Every row moves at once.
1512 const at = now();
1513 const statements: D1PreparedStatement[] = [];
1514 for (const slug of stale) {
1515 statements.push(
1516 this.db
1517 .prepare(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1518 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ? WHERE workspace = ? AND status IN ('queued', 'building')`,
Plan: the inbox, then channels and apps, instead of discussions1519 )
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1520 .bind(RENAMED_ERROR, at, slug),
Plan: the inbox, then channels and apps, instead of discussions1521 this.db.prepare("UPDATE settings SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1522 this.db.prepare("UPDATE apps SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1523 this.db
1524 .prepare(
1525 `UPDATE deployments SET workspace = ?1,
1526 repo = CASE WHEN substr(repo, 1, length(?2) + 1) = ?2 || '/' THEN ?1 || substr(repo, length(?2) + 1) ELSE repo END
1527 WHERE workspace = ?2`,
1528 )
1529 .bind(current, slug),
1530 this.db.prepare("UPDATE redirects SET workspace = ?1 WHERE workspace = ?2").bind(current, slug),
1531 this.domains.rename(slug, current),
1532 // Counters add up; the peak is the higher; a month charged stays charged.
1533 this.db
1534 .prepare(
1535 `INSERT INTO meters (namespace, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at)
1536 SELECT ?1, month, requests, cpu_ms, peak_apps, peak_domains, build_seconds, build_micros, counted_at, charged_at
1537 FROM meters WHERE namespace = ?2
1538 ON CONFLICT (namespace, month) DO UPDATE SET
1539 requests = requests + excluded.requests,
1540 cpu_ms = cpu_ms + excluded.cpu_ms,
1541 peak_apps = MAX(peak_apps, excluded.peak_apps),
1542 peak_domains = MAX(peak_domains, excluded.peak_domains),
1543 build_seconds = build_seconds + excluded.build_seconds,
1544 build_micros = build_micros + excluded.build_micros,
1545 counted_at = COALESCE(MAX(counted_at, excluded.counted_at), counted_at, excluded.counted_at),
1546 charged_at = COALESCE(charged_at, excluded.charged_at)`,
1547 )
1548 .bind(current, slug),
1549 this.db.prepare("DELETE FROM meters WHERE namespace = ?").bind(slug),
1550 );
1551 }
1552 await this.db.batch(statements);
1553
Projects no longer depend on each other: the dependsOn relation, its settings page (old links redirect), the overview card, a pull request's Affects panel and preview stacks, reference variables and agents' notes on what a project uses are gone; the table is dropped in a later deploy1554 // Each app again, under its new name.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1555 const followed = await this.followMoves(projectIds, {
1556 projects,
1557 adjust: (project) => this.underSlug(project, current, stale),
1558 });
1559 if (followed.failed.length > 0) {
1560 throw new Error(`could not rebuild after the rename to ${current}: ${followed.failed.join("; ")}`);
Plan: the inbox, then channels and apps, instead of discussions1561 }
1562 }
1563
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1564 /**
1565 * A repository moved: transferred to another workspace, and its projects
1566 * with it, or renamed within its own, when its own project's slug follows
1567 * its name (see the projects service). Each app's name is
1568 * `<project>-<workspace>`, so the apps of every project whose workspace or
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1569 * slug changed (production and every preview, paused or not) are built
1570 * again, from the same commit, under the new name; see `followMoves`. As
1571 * after a workspace rename, the old name keeps serving until the new one
1572 * is live, then redirects to it (see `supersede`) for `SLUG_HOLD_DAYS`.
1573 * The project's custom domains follow its production. Builds under way
1574 * are started again under the new name. What the apps used this month
1575 * stays on the old workspace's meter; from now on, the new workspace's
1576 * counts it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1577 *
1578 * Projects whose name did not change (a rename where the new name was
1579 * taken, or a project of another name) only learn the repository's new
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1580 * path. What is left to rebuild is read from the rows each time, so a
1581 * second or late delivery builds only what the first could not, and one
1582 * whose rebuild could not be queued is delivered again (and, past the
1583 * queue's retries, followed up by the sweep).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1584 */
1585 private async moved(move: RepoMove, attempts: number): Promise<void> {
1586 const current = await currentMovedPath(this.env.REPOS, move);
1587 if (staleMovedPaths(move, current).length === 0) return;
1588 const [workspace, name] = current.split("/") as [string, string];
1589 const settings = await this.db
1590 .prepare("SELECT project_id, workspace, slug FROM settings WHERE repo_id = ?")
1591 .bind(move.repoId)
1592 .all<{ project_id: string; workspace: string; slug: string }>();
1593 if (settings.results.length === 0) return;
1594
1595 // The projects service hears of the move on its own queue: wait for it
1596 // a few deliveries, so builds read the project where and as it is now.
1597 const projects = new Map<string, Project>();
1598 for (const project of await this.projects.byRepo(move.repoId)) projects.set(project.id, project);
1599 const behind = settings.results.some(({ project_id }) => {
1600 const project = projects.get(project_id);
1601 if (!project || project.source.kind !== "hosted") return false;
1602 return project.workspace !== workspace || `${project.source.repo.namespace}/${project.source.repo.name}` !== current;
1603 });
1604 if (behind && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${current} moved yet`);
1605
1606 // Its history goes with it, as the repository's issues do.
1607 const statements: D1PreparedStatement[] = [
1608 this.db.prepare("UPDATE deployments SET repo = ? WHERE repo_id = ?").bind(current, move.repoId),
1609 ];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1610 // The projects whose apps' names change, and have not been moved yet.
1611 const moving = settings.results
1612 .filter((row) => row.workspace !== workspace || (projects.get(row.project_id)?.slug ?? row.slug) !== row.slug)
1613 .map((row) => row.project_id);
1614 if (moving.length > 0) {
1615 const ids = moving.map(() => "?").join(", ");
1616 statements.push(
1617 this.db
1618 .prepare(
1619 `UPDATE deployments SET status = 'skipped', error = ?, finished_at = ?
1620 WHERE project_id IN (${ids}) AND status IN ('queued', 'building')`,
1621 )
1622 .bind(MOVED_ERROR, now(), ...moving),
1623 );
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1624 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1625 for (const projectId of moving) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1626 const slug = projects.get(projectId)?.slug ?? null;
1627 statements.push(
1628 this.db
1629 .prepare("UPDATE settings SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1630 .bind(workspace, slug, projectId),
1631 this.db
1632 .prepare("UPDATE domains SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1633 .bind(workspace, slug, projectId),
1634 this.db
1635 .prepare("UPDATE deployments SET workspace = ?1, slug = COALESCE(?2, slug) WHERE project_id = ?3")
1636 .bind(workspace, slug, projectId),
1637 // Within the workspace its apps are listed under the project's name
1638 // now. One left behind in another workspace stays as it is until the
1639 // new name is live and redirects it.
1640 this.db
1641 .prepare("UPDATE apps SET slug = COALESCE(?2, slug) WHERE project_id = ?3 AND workspace = ?1")
1642 .bind(workspace, slug, projectId),
1643 );
1644 }
1645 await this.db.batch(statements);
1646
1647 // Each app again, under its new name. App rows under the old name stay
1648 // until the new one is live, which redirects them.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1649 const followed = await this.followMoves(
1650 settings.results.map((row) => row.project_id),
1651 {
1652 projects,
1653 adjust: (found) =>
1654 found.source.kind === "hosted"
1655 ? { ...found, workspace, source: { ...found.source, repo: { ...found.source.repo, namespace: workspace, name } } }
1656 : { ...found, workspace },
1657 },
1658 );
1659 if (followed.failed.length > 0) throw new Error(`could not rebuild after the move to ${current}: ${followed.failed.join("; ")}`);
1660 }
1661
1662 /** The projects with these ids, as the projects service has them now (found through their repositories). */
1663 private async projectsById(projectIds: string[]): Promise<Map<string, Project>> {
1664 const projects = new Map<string, Project>();
1665 if (projectIds.length === 0) return projects;
1666 const repoIds = new Set<string>();
1667 for (let i = 0; i < projectIds.length; i += 50) {
1668 const chunk = projectIds.slice(i, i + 50);
1669 const rows = await this.db
1670 .prepare(`SELECT DISTINCT repo_id FROM settings WHERE project_id IN (${chunk.map(() => "?").join(", ")})`)
1671 .bind(...chunk)
1672 .all<{ repo_id: string }>();
1673 for (const { repo_id } of rows.results) repoIds.add(repo_id);
1674 }
1675 const wanted = new Set(projectIds);
1676 for (const repoId of repoIds) {
1677 for (const project of await this.projects.byRepo(repoId)) {
1678 if (wanted.has(project.id)) projects.set(project.id, project);
1679 }
1680 }
1681 return projects;
1682 }
1683
1684 /** Whether `script` is the name an app of the project has where the project is now. */
1685 private async namedNow(
1686 script: string,
1687 settings: { project_id: string; workspace: string; slug: string },
1688 branch: string | null,
1689 ): Promise<boolean> {
1690 const base = await label(settings.workspace, settings.slug, branch);
1691 return script === base || script === (await uniqueLabel(base, `${settings.project_id}/${branch ?? ""}`));
1692 }
1693
1694 /** Apps still under a name their project had before it moved, among `apps` (whose projects are in `settings`). */
1695 private async staleApps(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<AppRow[]> {
1696 const stale: AppRow[] = [];
1697 for (const app of apps) {
1698 const row = settings.get(app.project_id);
1699 if (row && !(await this.namedNow(app.script, row, app.branch))) stale.push(app);
1700 }
1701 return stale;
1702 }
1703
1704 /**
1705 * Brings the apps of the projects `projectIds` (every project when null)
1706 * under the names they have where the projects are now: each app still
1707 * under an older name, paused or not, and each build a move dropped, is
1708 * built again under its new name from the same commit, and once that is
1709 * live the old name redirects to it (`supersede`).
1710 *
1711 * Idempotent, and safe to run again at any time: an app already up under
1712 * its new name only has its old names redirected; one whose rebuild is
1713 * queued or under way is left to it; one whose workspace has no
1714 * Deployments or is over its limit waits, without a refused deployment
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1715 * each time; one whose commit is gone, or whose rebuild failed the same
1716 * way `MAX_IDENTICAL_FAILURES` times, is not tried again; with `backoff`
1717 * (the sweep), one whose rebuild was tried within `MOVE_RETRY_MS`,
1718 * doubled for each failure, waits. Returns what could not be queued, for an
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1719 * event's delivery to be retried.
1720 */
1721 private async followMoves(
1722 projectIds: string[] | null,
1723 options: { projects?: Map<string, Project>; adjust?: (project: Project) => Project; backoff?: boolean } = {},
1724 ): Promise<{ queued: number; waiting: number; failed: string[] }> {
1725 const result = { queued: 0, waiting: 0, failed: [] as string[] };
1726 if (projectIds && projectIds.length === 0) return result;
1727 const cloudflare = this.cloudflare;
1728 if (!cloudflare) return result;
1729
1730 const settingsRows =
1731 projectIds == null
1732 ? (await this.db.prepare("SELECT * FROM settings WHERE repo_deleted_at IS NULL").all<SettingsRow>()).results
1733 : (await Promise.all(projectIds.map((id) => this.settingsRow(id)))).filter((row): row is SettingsRow => row != null);
1734 const settings = new Map(settingsRows.map((row) => [row.project_id, row]));
1735 if (settings.size === 0) return result;
1736 const ids = [...settings.keys()];
1737
1738 const apps: AppRow[] = [];
1739 const dropped: DroppedBuild[] = [];
1740 for (let i = 0; i < ids.length; i += 50) {
1741 const chunk = ids.slice(i, i + 50);
1742 const marks = chunk.map(() => "?").join(", ");
1743 const [appRows, droppedRows] = await Promise.all([
1744 this.db.prepare(`SELECT * FROM apps WHERE project_id IN (${marks})`).bind(...chunk).all<AppRow>(),
1745 // Builds a move dropped, that nothing has been built in place of since.
1746 this.db
1747 .prepare(
1748 `SELECT * FROM deployments d WHERE d.project_id IN (${marks}) AND d.status = 'skipped' AND d.error IN (?, ?)
1749 AND d.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-7 days')
1750 AND NOT EXISTS (
1751 SELECT 1 FROM deployments n WHERE n.project_id = d.project_id AND n.kind = d.kind AND n.branch IS d.branch
1752 AND n.created_at > d.created_at AND n.status != 'skipped'
1753 )`,
1754 )
1755 .bind(...chunk, MOVED_ERROR, RENAMED_ERROR)
1756 .all<DeploymentRow>(),
1757 ]);
1758 apps.push(...appRows.results);
1759 dropped.push(...droppedRows.results);
1760 }
1761 const targets: MoveTarget[] = moveTargets(await this.staleApps(apps, settings), dropped);
1762 if (targets.length === 0) return result;
1763
1764 const projects = new Map(options.projects ?? []);
1765 const unknown = [...new Set(targets.map((t) => t.projectId))].filter((id) => !projects.has(id));
1766 for (const [id, project] of await this.projectsById(unknown)) projects.set(id, project);
1767 const billing = billingClient(this.env.BILLING);
1768 const open = new Map<string, boolean>();
1769 const actors = new Map<string, User | null>();
1770
1771 for (const target of targets) {
1772 const row = settings.get(target.projectId)!;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1773 const found = projects.get(target.projectId);
1774 if (!found) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1775 const project = options.adjust ? options.adjust(found) : found;
1776 const named = `${row.workspace}/${row.slug}${target.branch ? ` (${target.branch})` : ""}`;
1777 // Built only where deployments has the project now; the projects
1778 // service catches up on its own queue, and a later run builds then.
1779 if (project.workspace !== row.workspace || project.slug !== row.slug) {
1780 result.waiting++;
1781 continue;
1782 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1783 try {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1784 const script = await this.scriptFor(project, target.branch);
1785 // Already up under its new name: only its old names are left to redirect.
1786 const up = await this.db.prepare("SELECT 1 FROM apps WHERE script = ?").bind(script).first();
1787 if (up) {
1788 await this.supersede(cloudflare, { project_id: project.id, kind: target.kind, branch: target.branch, workspace: project.workspace }, script);
1789 continue;
1790 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1791 const history = await this.recentBuilds(script);
1792 const last = history[0];
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1793 if (last && (last.status === "queued" || last.status === "building")) {
1794 result.queued++;
1795 continue;
1796 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1797 // A commit that is gone, or a build that failed the same way a few
1798 // times, is not tried again; a push or a redeploy builds it.
1799 // Otherwise the sweep waits longer after each failure.
1800 if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff: options.backoff }).kind !== "build") {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1801 result.waiting++;
1802 continue;
1803 }
1804 // A workspace without Deployments, or over its limit, waits for it
1805 // rather than gathering refused deployments.
1806 if (!open.has(project.workspace)) {
1807 const [plan, limit] = await Promise.all([
1808 billing.hasFeature(project.workspace, "deployments"),
1809 billing.checkLimit(project.workspace),
1810 ]);
1811 open.set(project.workspace, plan.ok && !(limit.ok && limit.value.state === "stopped"));
1812 }
1813 if (!open.get(project.workspace)) {
1814 result.waiting++;
1815 continue;
1816 }
1817 if (!actors.has(project.workspace)) actors.set(project.workspace, await this.workspaceActor(project.workspace));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1818 const started =
1819 target.kind === "production"
1820 ? await this.deployProduction(project, target.commit, "g1t")
1821 : target.number != null
1822 ? await this.deployPreview(project, target.number, "g1t", true)
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1823 : await this.rebuildStack(project, target.branch, target.commit, actors.get(project.workspace) ?? null);
1824 const outcome = rebuildOutcome(started);
1825 if (outcome === "queued") result.queued++;
1826 else if (outcome === "failed") {
1827 const why = started?.ok ? (started.value.error ?? started.value.status) : started ? started.error.message : "";
1828 result.failed.push(`${named}: ${why}`);
1829 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1830 } catch (error) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1831 result.failed.push(`${named}: ${String(error)}`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1832 }
1833 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1834 if (result.failed.length > 0) console.error("could not rebuild moved apps", result.failed);
1835 return result;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1836 }
1837
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1838 /** An app's latest builds, newest first: enough to tell a run of identical failures (see retries.ts). */
1839 private async recentBuilds(script: string): Promise<PastBuild[]> {
1840 const rows = await this.db
1841 .prepare("SELECT status, error, commit_sha, created_at FROM deployments WHERE script = ? ORDER BY created_at DESC LIMIT ?")
1842 .bind(script, MAX_IDENTICAL_FAILURES)
1843 .all<PastBuild>();
1844 return rows.results;
1845 }
1846
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1847 /** The projects built from a repository, as this service has them (projects hides a deleted one's). */
1848 private async projectIdsFor(repoId: string): Promise<string[]> {
1849 const rows = await this.db
1850 .prepare("SELECT project_id FROM settings WHERE repo_id = ?")
1851 .bind(repoId)
1852 .all<{ project_id: string }>();
1853 return rows.results.map((row) => row.project_id);
1854 }
1855
1856 /**
1857 * A repository was deleted, restorable for a while: every app of its
1858 * projects (production and previews) comes down, builds under way are
1859 * dropped, and nothing builds for it until it is restored. Its settings
1860 * and custom domains are kept for the restore; until then a domain has
1861 * nothing up to serve, as when production is turned off.
1862 */
1863 private async repoDeleted(repoId: string): Promise<void> {
1864 const projectIds = await this.projectIdsFor(repoId);
1865 if (projectIds.length === 0) return;
1866 const at = now();
1867 await this.db.batch([
1868 this.db.prepare("UPDATE settings SET repo_deleted_at = COALESCE(repo_deleted_at, ?) WHERE repo_id = ?").bind(at, repoId),
1869 this.db
1870 .prepare(
1871 `UPDATE deployments SET status = 'skipped', error = 'The repository was deleted.', finished_at = ?
1872 WHERE repo_id = ? AND status IN ('queued', 'building')`,
1873 )
1874 .bind(at, repoId),
1875 ]);
1876 for (const projectId of projectIds) await this.takeDownWhere(projectId, null);
1877 }
1878
1879 /**
1880 * A deleted repository is back: production goes up again from its
1881 * default branch, for each project that has it on. Previews come back
1882 * with the next push to their pull requests.
1883 */
1884 private async repoRestored(repoId: string, attempts: number): Promise<void> {
1885 const deleted = await this.db
1886 .prepare("SELECT project_id FROM settings WHERE repo_id = ? AND repo_deleted_at IS NOT NULL")
1887 .bind(repoId)
1888 .all<{ project_id: string }>();
1889 const ids = deleted.results.map((row) => row.project_id);
1890 if (ids.length === 0) return;
1891 // The projects service hears of the restore on its own queue, and hides
1892 // the projects until then: wait for it a few deliveries.
1893 const projects = (await this.projects.byRepo(repoId)).filter((project) => ids.includes(project.id));
1894 if (projects.length < ids.length && attempts < RENAME_WAITS) throw new Error(`projects has not seen ${repoId} restored yet`);
1895 await this.db.prepare("UPDATE settings SET repo_deleted_at = NULL WHERE repo_id = ?").bind(repoId).run();
1896 for (const project of projects) {
1897 try {
1898 const started = await this.deployProduction(project, null, "g1t");
1899 if (started && !started.ok) console.log("could not deploy after restore", project.slug, started.error.message);
1900 } catch (error) {
1901 console.error("could not deploy after restore", project.slug, error);
1902 }
1903 }
1904 }
1905
1906 /**
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1907 * A workspace was deleted, restorable by g1t's staff for a while: every
1908 * app of its projects (production and previews) is paused, answering with
1909 * a notice and running nothing, builds under way are dropped, and nothing
1910 * builds for it until it is restored. Nothing is taken down: scripts,
1911 * settings and custom domains are kept for the restore. Never for a
1912 * protected workspace, whatever was published.
1913 */
1914 private async workspaceDeleting(slug: string): Promise<void> {
1915 const workspace = slug.toLowerCase();
1916 if (isProtectedWorkspace(workspace)) {
1917 console.error("workspace.deleting ignored for protected", workspace);
1918 return;
1919 }
1920 const at = now();
1921 await this.db.batch([
1922 this.db
1923 .prepare("UPDATE settings SET workspace_deleted_at = COALESCE(workspace_deleted_at, ?) WHERE workspace = ?")
1924 .bind(at, workspace),
1925 this.db
1926 .prepare(
1927 `UPDATE deployments SET status = 'skipped', error = 'The workspace was deleted.', finished_at = ?
1928 WHERE workspace = ? AND status IN ('queued', 'building')`,
1929 )
1930 .bind(at, workspace),
1931 ]);
1932 const cloudflare = this.cloudflare;
1933 for (const app of await this.appsOfWorkspace(workspace)) {
1934 if (app.paused_at) continue;
1935 await cloudflare?.pauseScript(app.script);
1936 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(at, app.script).run();
1937 }
1938 }
1939
1940 /**
1941 * A deleted workspace is back: it builds again, and its paused apps are
1942 * resumed as the workspace's limit allows, as `holdToLimits` resumes any
1943 * (the sweep tries again any it could not).
1944 */
1945 private async workspaceRestored(slug: string): Promise<void> {
1946 const workspace = slug.toLowerCase();
1947 await this.db.prepare("UPDATE settings SET workspace_deleted_at = NULL WHERE workspace = ?").bind(workspace).run();
1948 const rows = await this.db.prepare("SELECT * FROM settings WHERE workspace = ?").bind(workspace).all<SettingsRow>();
1949 const settings = new Map(rows.results.map((row) => [row.project_id, row]));
1950 await this.holdToLimits(await this.appsOfWorkspace(workspace), settings);
1951 }
1952
1953 /**
1954 * A deleted workspace is purged: whatever its projects still have up
1955 * comes down and their custom domains go, as for a purged repository.
1956 * Its own repositories' projects are purged with them (`repo.purged`);
1957 * this catches any building from a repository it had transferred away.
1958 */
1959 private async workspacePurged(slug: string): Promise<void> {
1960 const workspace = slug.toLowerCase();
1961 if (isProtectedWorkspace(workspace)) return;
1962 const rows = await this.db.prepare("SELECT project_id FROM settings WHERE workspace = ?").bind(workspace).all<{ project_id: string }>();
1963 for (const { project_id } of rows.results) {
1964 await this.takeDownWhere(project_id, null);
1965 await this.domains.removeWhere("project_id", project_id);
1966 }
1967 for (const app of await this.appsOfWorkspace(workspace)) await this.removeApp(app.script);
1968 await this.db.batch([
1969 this.db.prepare("DELETE FROM deployments WHERE workspace = ?").bind(workspace),
1970 this.db.prepare("DELETE FROM settings WHERE workspace = ?").bind(workspace),
1971 ]);
1972 }
1973
1974 /** The apps of a workspace's projects, and any still under its name. */
1975 private async appsOfWorkspace(workspace: string): Promise<AppRow[]> {
1976 const rows = await this.db
1977 .prepare(
1978 `SELECT * FROM apps WHERE workspace = ?1
1979 OR project_id IN (SELECT project_id FROM settings WHERE workspace = ?1)`,
1980 )
1981 .bind(workspace)
1982 .all<AppRow>();
1983 return rows.results;
1984 }
1985
1986 /**
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1987 * A deleted repository is gone for good: its projects' custom domains are
1988 * removed (from the dispatcher and from Cloudflare), any app or redirect
1989 * still up comes down, and every row kept for them goes. What they used
1990 * stays on their workspace's meter.
1991 */
1992 private async repoPurged(repoId: string): Promise<void> {
1993 const projectIds = await this.projectIdsFor(repoId);
1994 const domains = this.domains;
1995 for (const projectId of projectIds) {
1996 await this.takeDownWhere(projectId, null);
1997 // One Cloudflare does not let go of yet is left `removing`, for the sweep.
1998 await domains.removeWhere("project_id", projectId);
1999 }
2000 // The redirects left at names its apps had before.
2001 const scripts = await this.db
2002 .prepare("SELECT DISTINCT script FROM deployments WHERE repo_id = ?")
2003 .bind(repoId)
2004 .all<{ script: string }>();
2005 const hosts = scripts.results.map(({ script }) => appHost(script));
2006 for (let i = 0; i < hosts.length; i += 50) {
2007 const chunk = hosts.slice(i, i + 50);
2008 const redirects = await this.db
2009 .prepare(`SELECT script FROM redirects WHERE target IN (${chunk.map(() => "?").join(", ")})`)
2010 .bind(...chunk)
2011 .all<{ script: string }>();
2012 for (const { script } of redirects.results) {
2013 await this.cloudflare?.deleteScript(script);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2014 await this.env.DOMAINS?.delete(appHost(script)).catch((error) => console.error("could not drop redirect", script, error));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2015 await this.db.prepare("DELETE FROM redirects WHERE script = ?").bind(script).run();
2016 }
2017 }
2018 await this.db.batch([
2019 this.db.prepare("DELETE FROM deployments WHERE repo_id = ?").bind(repoId),
2020 this.db.prepare("DELETE FROM settings WHERE repo_id = ?").bind(repoId),
2021 ]);
2022 }
2023
2024 /**
2025 * The default branch is another one now: production is built from it, as
2026 * from a push to it, unless production already serves (or is building)
2027 * its commit, as when the default branch was only renamed.
2028 */
2029 private async defaultBranchChanged(repoId: string, branch: string, createdBy: string): Promise<void> {
2030 for (const found of await this.projects.byRepo(repoId)) {
2031 if (found.source.kind !== "hosted") continue;
2032 // Projects may not have heard yet: the event names the branch.
2033 const project: Project = { ...found, source: { ...found.source, defaultBranch: branch } };
2034 const actor = await this.workspaceActor(project.workspace);
2035 if (!actor) continue;
2036 const branches = await reposClient(this.env.REPOS).branches(repoOf(project).path, actor);
2037 const head = branches.ok ? branches.value.find((b) => b.name === branch)?.hash : undefined;
2038 if (!head) continue;
2039 const same = await this.db
2040 .prepare(
2041 `SELECT 1 FROM deployments WHERE project_id = ? AND kind = 'production' AND commit_sha = ?
2042 AND status IN ('queued', 'building', 'ready')`,
2043 )
2044 .bind(project.id, head)
2045 .first();
2046 if (same) continue;
2047 await this.deployProduction(project, head, createdBy);
2048 }
2049 }
2050
2051 /**
2052 * A branch was renamed: its preview is the same app, so its rows follow.
2053 * The app keeps its name until it is next built; then it goes up under
2054 * the new branch's name, and the old one redirects there (see `supersede`).
2055 */
2056 private async branchRenamed(repoId: string, from: string, to: string): Promise<void> {
2057 const projectIds = await this.projectIdsFor(repoId);
2058 if (projectIds.length === 0) return;
2059 const ids = projectIds.map(() => "?").join(", ");
2060 await this.db.batch([
2061 this.db
2062 .prepare(`UPDATE apps SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
2063 .bind(to, from, ...projectIds),
2064 this.db
2065 .prepare(`UPDATE deployments SET branch = ? WHERE kind = 'preview' AND branch = ? AND project_id IN (${ids})`)
2066 .bind(to, from, ...projectIds),
2067 ]);
2068 }
2069
Plan: the inbox, then channels and apps, instead of discussions2070 /** `project` under the workspace's slug now, whether or not projects has caught up. */
2071 private underSlug(project: Project, current: string, stale: string[]): Project {
2072 const source =
2073 project.source.kind === "hosted" && stale.includes(project.source.repo.namespace)
2074 ? { ...project.source, repo: { ...project.source.repo, namespace: current } }
2075 : project.source;
2076 return { ...project, workspace: current, source };
2077 }
2078
2079 /** A stack's preview (no pull request of its own) built again at `commit`. */
2080 private async rebuildStack(project: Project, branch: string | null, commit: string, actor: User | null): Promise<Result<Deployment> | null> {
2081 if (!actor || branch == null) return null;
2082 const settings = await this.settingsRow(project.id);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2083 if (!settings?.enabled || !settings.previews || settings.repo_deleted_at) return null;
Plan: the inbox, then channels and apps, instead of discussions2084 return this.start({
2085 project,
2086 kind: "preview",
2087 branch,
2088 number: null,
2089 commit,
2090 source: repoOf(project).path,
2091 reader: actor,
2092 createdBy: "g1t",
2093 settings,
2094 // As `stack` built it: the project's own default branch.
2095 trusted: true,
2096 });
2097 }
2098
2099 // ---- The sweep -----------------------------------------------------
2100
2101 /**
2102 * Every few minutes: builds that died are failed; usage is counted; idle
2103 * previews, the apps of workspaces whose plan ended, and scripts no app
2104 * holds come down; and a month that is over is charged past its
2105 * allowance.
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2106 *
2107 * Each step stands alone: one that fails is logged and the rest still
2108 * run. Taking idle previews down and charging months, which only read
2109 * g1t's own tables, go before the steps that wait on Cloudflare's API,
2110 * so a slow or failing API never holds them up.
Plan: the inbox, then channels and apps, instead of discussions2111 */
2112 async sweep(): Promise<void> {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2113 const step = (name: string, work: () => Promise<unknown>) => work().catch((error) => console.error(`sweep: ${name} failed`, error));
2114 await step("failing stuck builds", async () => {
2115 const cutoff = new Date(Date.now() - BUILD_TIMEOUT_MS).toISOString();
2116 const stuck = await this.db
2117 .prepare("SELECT id FROM deployments WHERE status IN ('queued', 'building') AND created_at < ?")
2118 .bind(cutoff)
2119 .all<{ id: string }>();
2120 for (const { id } of stuck.results) await this.finishFailed(id, "The build did not finish in 45 minutes.", null, null);
2121 });
2122 await step("taking down idle previews", () => this.takeDownIdle());
2123 await step("charging months", () => this.chargeMonths());
Plan: the inbox, then channels and apps, instead of discussions2124
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2125 let apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2126 // Each app is its project's workspace's, as deployments has it now: an
2127 // app still under the name it had before its project moved is the new
2128 // workspace's, and plans and limits are checked there.
2129 const settings = new Map(
2130 (await this.db.prepare("SELECT * FROM settings").all<SettingsRow>()).results.map((row) => [row.project_id, row]),
2131 );
2132 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member2133 // A deleted workspace's apps stay paused as they are, for a restore:
2134 // its plan ended with the deletion, and that must not take them down.
2135 const held = (app: AppRow) => Boolean(settings.get(app.project_id)?.workspace_deleted_at);
2136 const live = apps.filter((app) => !held(app));
2137 const workspaces = [...new Set(live.map((app) => ownerOf(app, owners)))];
Plan: the inbox, then channels and apps, instead of discussions2138
2139 // Apps of workspaces whose plan has ended come down.
2140 const billing = billingClient(this.env.BILLING);
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member2141 await this.holdToLimits(live, settings).catch((error) => console.error("could not apply limits", error));
Plan: the inbox, then channels and apps, instead of discussions2142 for (const workspace of workspaces) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2143 await step(`ending ${workspace}'s apps`, async () => {
2144 const plan = await billing.hasFeature(workspace, "deployments");
2145 if (!plan.ok && plan.error.code === "payment_required") {
2146 for (const app of live.filter((a) => ownerOf(a, owners) === workspace)) await this.removeApp(app.script);
2147 // Custom domains cost g1t by the month: they go with the plan.
2148 await this.domains.removeWhere("workspace", workspace).catch((error) => console.error("could not remove domains", error));
2149 }
2150 });
Plan: the inbox, then channels and apps, instead of discussions2151 }
2152
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2153 // Apps whose project moved and are not up under the new name yet: a
2154 // move's rebuild that could not start is tried again here.
2155 await this.followMoves(null, { backoff: true }).catch((error) => console.error("could not follow moves", error));
2156 apps = (await this.db.prepare("SELECT * FROM apps").all<AppRow>()).results;
2157
Plan: the inbox, then channels and apps, instead of discussions2158 await this.domains
2159 .sweep(async (projectId) => {
2160 const app = await this.db
2161 .prepare("SELECT script FROM apps WHERE project_id = ? AND kind = 'production'")
2162 .bind(projectId)
2163 .first<{ script: string }>();
2164 return app?.script ?? null;
2165 })
2166 .catch((error) => console.error("could not check domains", error));
2167
2168 await this.removeOrphans(apps).catch((error) => console.error("could not remove orphans", error));
2169 await this.count(apps).catch((error) => console.error("could not count usage", error));
2170 }
2171
2172 /**
2173 * Pauses the apps of workspaces that reached their limit for usage not
2174 * yet paid for, and rebuilds them from the same commit once they are
2175 * under it again. Paused apps answer with a notice and run nothing.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2176 *
2177 * The workspace is the project's now (see `ownerOf`), never the one an
2178 * app's row was written under, so an app left under its old name after
2179 * a transfer is paused only if its new workspace is over its limit. Such
2180 * an app is resumed by being built under its new name (`followMoves`),
2181 * not here.
Plan: the inbox, then channels and apps, instead of discussions2182 */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2183 private async holdToLimits(apps: AppRow[], settings: Map<string, SettingsRow>): Promise<void> {
Plan: the inbox, then channels and apps, instead of discussions2184 const cloudflare = this.cloudflare;
2185 if (!cloudflare) return;
2186 const billing = billingClient(this.env.BILLING);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2187 const owners = new Map([...settings].map(([id, row]) => [id, row.workspace]));
2188 for (const workspace of [...new Set(apps.map((app) => ownerOf(app, owners)))]) {
Plan: the inbox, then channels and apps, instead of discussions2189 const limit = await billing.checkLimit(workspace);
2190 if (!limit.ok) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2191 const theirs = apps.filter((app) => ownerOf(app, owners) === workspace);
Plan: the inbox, then channels and apps, instead of discussions2192 if (limit.value.state === "stopped") {
2193 for (const app of theirs.filter((a) => !a.paused_at)) {
2194 await cloudflare.pauseScript(app.script);
2195 await this.db.prepare("UPDATE apps SET paused_at = ? WHERE script = ?").bind(now(), app.script).run();
2196 }
2197 continue;
2198 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2199 const stale = new Set((await this.staleApps(theirs, settings)).map((app) => app.script));
2200 const paused = theirs.filter((a) => a.paused_at && !stale.has(a.script));
Plan: the inbox, then channels and apps, instead of discussions2201 if (paused.length === 0) continue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2202 const projects = await this.projectsById([...new Set(paused.map((app) => app.project_id))]);
Plan: the inbox, then channels and apps, instead of discussions2203 for (const app of paused) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2204 const project = projects.get(app.project_id);
2205 if (!project) continue;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit2206 // A failed or refused rebuild leaves it paused, to try again later:
2207 // longer after each failure, and not once its commit is gone or it
2208 // failed the same way a few times.
2209 const history = await this.recentBuilds(app.script);
2210 if (history[0]?.status === "queued" || history[0]?.status === "building") continue;
2211 const backoff = history[0]?.status === "failed";
2212 if (retryDecision(history, Date.now(), MOVE_RETRY_MS, { backoff }).kind !== "build") continue;
Plan: the inbox, then channels and apps, instead of discussions2213 const rebuilt =
2214 app.kind === "production"
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2215 ? await this.deployProduction(project, app.commit_sha, "g1t")
Plan: the inbox, then channels and apps, instead of discussions2216 : app.number != null
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2217 ? await this.deployPreview(project, app.number, "g1t", true)
Plan: the inbox, then channels and apps, instead of discussions2218 : null;
2219 if (rebuilt && !rebuilt.ok) console.log("could not resume", app.script, rebuilt.error.message);
2220 }
2221 }
2222 }
2223
2224 /**
2225 * Scripts in the namespace that no app holds, such as ones renamed. An
2226 * old address that redirects to its app's new one is held until its
2227 * redirect expires, then removed with the rest.
2228 */
2229 private async removeOrphans(apps: AppRow[]): Promise<void> {
2230 const cloudflare = this.cloudflare;
2231 if (!cloudflare) return;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2232 // Their entries in `DOMAINS` expire on their own, at the same time.
Plan: the inbox, then channels and apps, instead of discussions2233 await this.db.prepare("DELETE FROM redirects WHERE expires_at < ?").bind(now()).run();
2234 const redirects = await this.db.prepare("SELECT script FROM redirects").all<{ script: string }>();
2235 const held = new Set([...apps.map((app) => app.script), ...redirects.results.map((r) => r.script)]);
2236 const building = await this.db
2237 .prepare("SELECT script FROM deployments WHERE status IN ('queued', 'building')")
2238 .all<{ script: string }>();
2239 for (const row of building.results) held.add(row.script);
2240 const cutoff = Date.now() - ORPHAN_AFTER_MS;
2241 for (const script of await cloudflare.listScripts()) {
2242 if (!held.has(script.id) && Date.parse(script.modified_on) < cutoff) await cloudflare.deleteScript(script.id);
2243 }
2244 }
2245
2246 /** Counts this month's requests and CPU time per workspace, from analytics. */
2247 private async count(apps: AppRow[]): Promise<void> {
2248 const cloudflare = this.cloudflare;
2249 if (!cloudflare || apps.length === 0) return;
2250 const start = `${month()}-01T00:00:00Z`;
2251 const totals = await cloudflare.usage(apps.map((app) => app.script), start, now());
2252 // Analytics only counts apps that are up; the meter keeps what earlier
2253 // apps used by never going down.
2254 const perWorkspace = new Map<string, { requests: number; cpuMs: number }>();
2255 for (const app of apps) {
2256 const used = totals.get(app.script);
2257 if (!used) continue;
2258 const sum = perWorkspace.get(app.workspace) ?? { requests: 0, cpuMs: 0 };
2259 sum.requests += used.requests;
2260 sum.cpuMs += used.cpuMs;
2261 perWorkspace.set(app.workspace, sum);
2262 }
2263 const at = now();
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2264 // Written only when the count moves the meter: most sweeps it does not.
2265 const stored = new Map(
2266 (
2267 await this.db
2268 .prepare("SELECT namespace, requests, cpu_ms FROM meters WHERE month = ?")
2269 .bind(month())
2270 .all<{ namespace: string; requests: number; cpu_ms: number }>()
2271 ).results.map((row) => [row.namespace, row]),
2272 );
Plan: the inbox, then channels and apps, instead of discussions2273 for (const [workspace, used] of perWorkspace) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2274 if (!movesMeter(stored.get(workspace), used)) continue;
Plan: the inbox, then channels and apps, instead of discussions2275 await this.db
2276 .prepare(
2277 `INSERT INTO meters (namespace, month, requests, cpu_ms, counted_at) VALUES (?1, ?2, ?3, ?4, ?5)
2278 ON CONFLICT (namespace, month) DO UPDATE SET
2279 requests = MAX(requests, ?3), cpu_ms = MAX(cpu_ms, ?4), counted_at = ?5`,
2280 )
2281 .bind(workspace, month(), used.requests, used.cpuMs, at)
2282 .run();
2283 }
2284 // When each preview last answered anyone, for the idle sweep.
2285 const recent = await cloudflare.usage(
2286 apps.filter((app) => app.kind === "preview").map((app) => app.script),
2287 new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
2288 at,
2289 );
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2290 // At an hour's resolution: previews idle for days are what it finds.
2291 const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
2292 const lastRequest = new Map(apps.map((app) => [app.script, app.last_request_at]));
Plan: the inbox, then channels and apps, instead of discussions2293 for (const [script, used] of recent) {
Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails2294 const last = lastRequest.get(script);
2295 if (used.requests > 0 && (!last || last < hourAgo)) {
Plan: the inbox, then channels and apps, instead of discussions2296 await this.db.prepare("UPDATE apps SET last_request_at = ? WHERE script = ?").bind(at, script).run();
2297 }
2298 }
2299 for (const workspace of new Set(apps.map((app) => app.workspace))) await this.notePeak(workspace);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2300 // What this month's traffic and custom domains will cost, from the
2301 // first request and the first domain, so the workspace's limit counts
2302 // it now rather than when the month closes, and its Billing page shows it.
Plan: the inbox, then channels and apps, instead of discussions2303 const costs = await this.costs();
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2304 const billing = billingClient(this.env.BILLING);
2305 const meters = await this.db
2306 .prepare("SELECT namespace, requests, cpu_ms, peak_domains FROM meters WHERE month = ?")
2307 .bind(month())
2308 .all<{ namespace: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
2309 for (const meter of meters.results) {
2310 const cost = monthCost(meter, costs);
2311 await billing
2312 .notePending(meter.namespace, "deployments", cost.traffic.micros, cost.traffic.detail)
Plan: the inbox, then channels and apps, instead of discussions2313 .catch((error) => console.error("could not note pending usage", error));
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2314 if ((meter.peak_domains ?? 0) > 0) {
2315 await billing
2316 .notePending(meter.namespace, "domains", cost.domains.micros, cost.domains.detail)
2317 .catch((error) => console.error("could not note pending usage", error));
2318 }
Plan: the inbox, then channels and apps, instead of discussions2319 }
2320 }
2321
2322 /** Previews no one has visited in their project's idle days. */
2323 private async takeDownIdle(): Promise<void> {
2324 const idle = await this.db
2325 .prepare(
2326 `SELECT apps.script FROM apps JOIN settings ON settings.project_id = apps.project_id
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member2327 WHERE apps.kind = 'preview' AND settings.workspace_deleted_at IS NULL
Plan: the inbox, then channels and apps, instead of discussions2328 AND COALESCE(apps.last_request_at, apps.deployed_at) < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || settings.idle_days || ' days')`,
2329 )
2330 .all<{ script: string }>();
2331 for (const { script } of idle.results) await this.removeApp(script);
2332 }
2333
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2334 /** Charges each month that is over for its requests, CPU time and custom domains, from the first, once. */
Plan: the inbox, then channels and apps, instead of discussions2335 private async chargeMonths(): Promise<void> {
2336 const due = await this.db
2337 .prepare("SELECT * FROM meters WHERE month < ? AND charged_at IS NULL")
2338 .bind(month())
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2339 .all<{ namespace: string; month: string; requests: number; cpu_ms: number; peak_domains: number | null }>();
Plan: the inbox, then channels and apps, instead of discussions2340 const costs = await this.costs();
2341 for (const meter of due.results) {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2342 const cost = monthCost(meter, costs);
2343 if (cost.micros > 0) {
Plan: the inbox, then channels and apps, instead of discussions2344 const charged = await billingClient(this.env.BILLING).chargeFeature({
2345 workspace: meter.namespace,
2346 feature: "deployments",
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2347 costMicros: cost.micros,
2348 description: `Deployments in ${meter.month}: ${cost.description}`,
Plan: the inbox, then channels and apps, instead of discussions2349 reference: `deployments/${meter.namespace}/${meter.month}`,
2350 });
2351 if (!charged.ok) continue;
2352 }
2353 await this.db
2354 .prepare("UPDATE meters SET charged_at = ? WHERE namespace = ? AND month = ?")
2355 .bind(now(), meter.namespace, meter.month)
2356 .run();
2357 }
2358 }
2359}
2360
2361/** `POST /rpc/<method>`: the arguments are the body. */
2362async function rpc(service: Deployments, method: string, args: any, ctx: ExecutionContext): Promise<unknown> {
2363 switch (method) {
2364 case "settings":
2365 return service.settings(args);
A project is an app or a library: libraries show their package and how to ship a release, not production2366 case "is_enabled":
2367 return service.isEnabled(args);
Plan: the inbox, then channels and apps, instead of discussions2368 case "update_settings":
2369 return service.updateSettings(args);
2370 case "list":
2371 return service.list(args);
2372 case "get":
2373 return service.get(args);
2374 case "redeploy":
2375 return service.redeploy(args);
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r92376 // For the API: a g1t Actions job opening a build it runs itself.
2377 case "start_from_workflow":
2378 return service.startFromWorkflow(args);
Plan: the inbox, then channels and apps, instead of discussions2379 case "take_down":
2380 return service.takeDown(args);
2381 case "overview":
2382 return service.overview(args);
2383 case "usage":
2384 return service.usage(args);
2385 case "domains":
2386 return service.listDomains(args);
2387 case "add_domain":
2388 return service.addDomain(args);
2389 case "remove_domain":
2390 return service.removeDomain(args);
2391 case "refresh_domain":
2392 return service.refreshDomain(args);
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb972393 // A repository's deployments wherever they run (repo-deployments.ts).
2394 case "list_deployments":
2395 return service.repoDeployments.list({ ...args, source: args.source == null ? null : sourceOf(args.source) ?? "none" });
2396 case "get_deployment":
2397 return service.repoDeployments.get(args);
2398 case "list_deployment_statuses":
2399 return service.repoDeployments.statuses(args);
2400 case "list_environments":
2401 return service.repoDeployments.environments(args);
2402 case "get_environment":
2403 return service.repoDeployments.environment(args);
2404 case "create_deployment":
2405 return service.repoDeployments.create(args);
2406 case "create_deployment_status":
2407 return service.repoDeployments.createStatus(args);
2408 // For the actions service: a run's deployment to one environment.
2409 case "actions_deployment":
2410 return service.repoDeployments.fromActions(args);
Plan: the inbox, then channels and apps, instead of discussions2411 default:
2412 return undefined;
2413 }
2414}
2415
2416export default {
2417 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
2418 const { pathname } = new URL(request.url);
2419 if (request.method !== "POST") return new Response("Not found\n", { status: 404 });
2420 const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
2421 const rpcMatch = pathname.match(/^\/rpc\/([a-z_]+)$/);
2422 if (rpcMatch) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents2423 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
2424 const opened = openD1(env.DB, request);
2425 const service = new Deployments(Object.create(env, { DB: { value: opened.db } }) as Env);
Plan: the inbox, then channels and apps, instead of discussions2426 const result = await rpc(service, rpcMatch[1], body, ctx);
Fast pages, required checks on the branch, self-hosted runners, honest incidents2427 return opened.finish(result === undefined ? new Response("Unknown method\n", { status: 404 }) : Response.json(result));
Plan: the inbox, then channels and apps, instead of discussions2428 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents2429 const service = new Deployments(env);
Plan: the inbox, then channels and apps, instead of discussions2430 // A build's reports, forwarded by the API.
2431 const jobMatch = pathname.match(/^\/jobs\/([a-z0-9_]+)\/(started|session|finish|fail)$/);
2432 if (jobMatch) return service.job(jobMatch[1], jobMatch[2], body);
2433 return new Response("Not found\n", { status: 404 });
2434 },
2435
2436 async queue(batch: MessageBatch<G1tEvent>, env: Env): Promise<void> {
2437 const service = new Deployments(env);
2438 for (const message of batch.messages) {
2439 try {
2440 await service.onEvent(message.body, message.attempts);
2441 message.ack();
2442 } catch (error) {
2443 console.error("deployments could not handle", message.body.type, error);
2444 message.retry();
2445 }
2446 }
2447 },
2448
2449 async scheduled(_controller: ScheduledController, env: Env): Promise<void> {
2450 await new Deployments(env).sweep();
2451 },
2452} satisfies ExportedHandler<Env, G1tEvent>;

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