Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

356 lines · 16.7 KBRaw History
1import type { Env } from "../env.js";
2import { computePause } from "./killswitch.js";
3import { HttpError, newId, now } from "./util.js";
4
5/**
6 * The status page: /status and GET /api/v3/diffinery/status, public, like githubstatus.com. Site
7 * administrators open incidents that name the components they affect and post updates until they
8 * are resolved. Each component's status is the worst impact of the unresolved incidents naming it,
9 * in Atlassian Statuspage's terms (which GitHub's page uses too). Two signals need no incident: an
10 * instance-wide kill switch (lib/killswitch.ts) puts what it stops under maintenance, and a summary
11 * that can't read D1 shows the website as down. The page is served by this Worker, so it is down
12 * whenever the whole Worker is: an external monitor of /healthz covers that.
13 */
14
15export const COMPONENTS = [
16 { id: "web", name: "Website and API" },
17 { id: "git", name: "Git operations" },
18 { id: "actions", name: "Actions" },
19 { id: "agents", name: "Agents and sandboxes" },
20 { id: "packages", name: "Packages" },
21 { id: "email", name: "Email" },
22] as const;
23export type ComponentId = (typeof COMPONENTS)[number]["id"];
24export const COMPONENT_IDS: readonly ComponentId[] = COMPONENTS.map((c) => c.id);
25
26export type Impact = "minor" | "major" | "critical" | "maintenance";
27export const IMPACTS: readonly Impact[] = ["minor", "major", "critical", "maintenance"];
28export type IncidentState = "investigating" | "identified" | "monitoring" | "resolved";
29export const INCIDENT_STATES: readonly IncidentState[] = ["investigating", "identified", "monitoring", "resolved"];
30
31export type ComponentStatus = "operational" | "under_maintenance" | "degraded_performance" | "partial_outage" | "major_outage";
32export type Indicator = "none" | "minor" | "major" | "critical" | "maintenance";
33
34/** Longest title and update, in characters. */
35export const MAX_TITLE = 200;
36export const MAX_BODY = 5_000;
37/** How long a resolved incident stays on the page, in days. */
38export const RECENT_DAYS = 14;
39
40/** What an unresolved incident of each impact does to the components it names. */
41export const IMPACT_STATUS: Record<Impact, ComponentStatus> = { critical: "major_outage", major: "partial_outage", minor: "degraded_performance", maintenance: "under_maintenance" };
42/** Worse is higher: maintenance is better than any outage, so an incident during one still shows. */
43const RANK: Record<ComponentStatus, number> = { operational: 0, under_maintenance: 1, degraded_performance: 2, partial_outage: 3, major_outage: 4 };
44/** The page's overall indicator and description, by its worst component. */
45const OVERALL: Record<ComponentStatus, { indicator: Indicator; description: string }> = {
46 operational: { indicator: "none", description: "All systems operational" },
47 under_maintenance: { indicator: "maintenance", description: "Service under maintenance" },
48 degraded_performance: { indicator: "minor", description: "Degraded performance" },
49 partial_outage: { indicator: "major", description: "Partial outage" },
50 major_outage: { indicator: "critical", description: "Major outage" },
51};
52
53export interface IncidentUpdate {
54 id: string;
55 incident_id: string;
56 state: IncidentState;
57 body: string;
58 author_login: string | null;
59 created_at: string;
60}
61
62export interface Incident {
63 id: string;
64 title: string;
65 impact: Impact;
66 components: ComponentId[];
67 state: IncidentState;
68 created_by: string | null;
69 created_at: string;
70 updated_at: string;
71 resolved_at: string | null;
72 /** Newest first. */
73 updates: IncidentUpdate[];
74}
75
76type IncidentRow = Omit<Incident, "components" | "updates"> & { components: string };
77
78/** Something other than an incident that sets a component's status, with a note saying why. */
79export interface Signal {
80 component: ComponentId;
81 status: ComponentStatus;
82 note: string;
83}
84
85export interface ComponentState {
86 id: ComponentId;
87 name: string;
88 status: ComponentStatus;
89 note?: string;
90}
91
92export interface Summary {
93 status: { indicator: Indicator; description: string };
94 components: ComponentState[];
95 /** Unresolved incidents, newest first. */
96 incidents: Incident[];
97 /** Incidents resolved in the last RECENT_DAYS days, most recently resolved first. */
98 resolved: Incident[];
99 updated_at: string;
100}
101
102// ---------------------------------------------------------------- deriving statuses
103
104/**
105 * Each component's status: the worst of the unresolved incidents naming it and of the signals for
106 * it, operational otherwise. A signal's note stays even when an incident is worse; it is still true.
107 */
108export function componentStates(incidents: readonly Incident[], signals: readonly Signal[]): ComponentState[] {
109 return COMPONENTS.map(({ id, name }) => {
110 let status: ComponentStatus = "operational";
111 const worse = (s: ComponentStatus) => {
112 if (RANK[s] > RANK[status]) status = s;
113 };
114 for (const i of incidents) if (i.state !== "resolved" && i.components.includes(id)) worse(IMPACT_STATUS[i.impact]);
115 const notes: string[] = [];
116 for (const s of signals) {
117 if (s.component !== id) continue;
118 worse(s.status);
119 notes.push(s.note);
120 }
121 return notes.length ? { id, name, status, note: notes.join(" ") } : { id, name, status };
122 });
123}
124
125/** The overall indicator and description, from the worst component. */
126export function overall(components: readonly ComponentState[]): Summary["status"] {
127 const worst = components.reduce<ComponentStatus>((w, c) => (RANK[c.status] > RANK[w] ? c.status : w), "operational");
128 return { ...OVERALL[worst] };
129}
130
131/** Which instance-wide kill switches are on. */
132export interface InstanceSwitches {
133 hostedJobs: boolean;
134 selfHostedJobs: boolean;
135 sandboxes: boolean;
136}
137
138/** Kill switches on for the whole instance, as signals: what they stop is under maintenance. */
139export function killSwitchSignals(k: InstanceSwitches): Signal[] {
140 const out: Signal[] = [];
141 const jobs =
142 k.hostedJobs && k.selfHostedJobs
143 ? "Actions jobs are paused by the site administrators."
144 : k.hostedJobs
145 ? "Hosted jobs are paused by the site administrators. Jobs on self-hosted runners still run."
146 : k.selfHostedJobs
147 ? "Jobs on self-hosted runners are paused by the site administrators."
148 : null;
149 if (jobs) out.push({ component: "actions", status: "under_maintenance", note: jobs });
150 if (k.sandboxes) out.push({ component: "agents", status: "under_maintenance", note: "Agent sandboxes are paused by the site administrators." });
151 return out;
152}
153
154/** The instance's kill switches. Pausing hosted compute stops hosted jobs and sandboxes; self-hosted runners have no instance-wide switch. */
155export async function instanceSwitches(env: Env): Promise<InstanceSwitches> {
156 const paused = !!(await computePause(env));
157 return { hostedJobs: paused, selfHostedJobs: false, sandboxes: paused };
158}
159
160/** The note on the website when the summary couldn't read the database. */
161export const DB_DOWN = "This instance can't read its database right now, so most pages and API calls fail, and incidents can't be shown.";
162
163/**
164 * Everything the page shows. When D1 can't be read the summary still comes back, with the website
165 * as a major outage and no incidents, so the page says something useful while the rest is down.
166 */
167export async function statusSummary(env: Env, at = new Date()): Promise<Summary> {
168 const updated_at = at.toISOString();
169 try {
170 const store = new StatusIncidents(env.DB);
171 const [incidents, resolved, switches] = await Promise.all([store.active(), store.resolvedSince(new Date(at.getTime() - RECENT_DAYS * 86400_000).toISOString()), instanceSwitches(env)]);
172 const components = componentStates(incidents, killSwitchSignals(switches));
173 return { status: overall(components), components, incidents, resolved, updated_at };
174 } catch (e) {
175 console.error("status page: reading D1 failed", e);
176 const components = componentStates([], [{ component: "web", status: "major_outage", note: DB_DOWN }]);
177 return { status: overall(components), components, incidents: [], resolved: [], updated_at };
178 }
179}
180
181// ---------------------------------------------------------------- validation
182
183export interface IncidentInput {
184 title: string;
185 impact: Impact;
186 components: ComponentId[];
187 state: IncidentState;
188 body: string;
189}
190
191export interface UpdateInput {
192 state: IncidentState;
193 body: string;
194}
195
196const str = (v: unknown) => (typeof v === "string" ? v : "");
197
198function checkState(v: unknown): IncidentState {
199 if (!INCIDENT_STATES.includes(v as IncidentState)) throw new HttpError(422, "Choose the incident's state: investigating, identified, monitoring or resolved.");
200 return v as IncidentState;
201}
202
203function checkBody(v: unknown): string {
204 // Line endings as the browser sent them, but no leading or trailing blank lines.
205 const body = str(v).replace(/\r\n?/g, "\n").replace(/^\s*\n|\s+$/g, "");
206 if (!body) throw new HttpError(422, "Write an update that says what is happening.");
207 if (body.length > MAX_BODY) throw new HttpError(422, `Keep the update to ${MAX_BODY.toLocaleString("en")} characters.`);
208 return body;
209}
210
211/** An update as sent: a state and a message, both required. */
212export function validateUpdate(raw: { state?: unknown; body?: unknown }): UpdateInput {
213 return { state: checkState(raw.state), body: checkBody(raw.body) };
214}
215
216/**
217 * A new incident as sent: a one-line title, its impact, the components it affects (at least one,
218 * each a known id, kept in the page's order) and its first update. Too long is refused, never cut.
219 */
220export function validateIncident(raw: { title?: unknown; impact?: unknown; components?: unknown; state?: unknown; body?: unknown }): IncidentInput {
221 const title = str(raw.title).replace(/\s+/g, " ").trim();
222 if (!title) throw new HttpError(422, "Give the incident a title.");
223 if (title.length > MAX_TITLE) throw new HttpError(422, `Keep the title to ${MAX_TITLE} characters; the details go in the update.`);
224 if (!IMPACTS.includes(raw.impact as Impact)) throw new HttpError(422, "Choose the incident's impact: minor, major, critical or maintenance.");
225 const list = Array.isArray(raw.components) ? raw.components : [];
226 if (!list.length) throw new HttpError(422, "Choose at least one affected component.");
227 for (const c of list) if (!COMPONENT_IDS.includes(c as ComponentId)) throw new HttpError(422, `There is no component called "${String(c).slice(0, 40)}". Choose from ${COMPONENT_IDS.join(", ")}.`);
228 const components = COMPONENT_IDS.filter((id) => list.includes(id));
229 const { state, body } = validateUpdate(raw);
230 return { title, impact: raw.impact as Impact, components, state, body };
231}
232
233// ---------------------------------------------------------------- storage
234
235const parseComponents = (s: string): ComponentId[] => {
236 try {
237 const v = JSON.parse(s) as unknown;
238 return Array.isArray(v) ? COMPONENT_IDS.filter((id) => v.includes(id)) : [];
239 } catch {
240 return [];
241 }
242};
243
244/** Status incidents and their updates in D1. */
245export class StatusIncidents {
246 constructor(private db: D1Database) {}
247
248 /** Incidents from `rows`, each with its updates, newest first. */
249 private async withUpdates(rows: IncidentRow[]): Promise<Incident[]> {
250 if (!rows.length) return [];
251 const updates = (
252 await this.db
253 .prepare("SELECT * FROM status_incident_updates WHERE incident_id IN (SELECT value FROM json_each(?)) ORDER BY created_at DESC, rowid DESC")
254 .bind(JSON.stringify(rows.map((r) => r.id)))
255 .all<IncidentUpdate>()
256 ).results;
257 return rows.map((r) => ({ ...r, components: parseComponents(r.components), updates: updates.filter((u) => u.incident_id === r.id) }));
258 }
259
260 async get(id: string): Promise<Incident | null> {
261 if (!/^[0-9a-f]{32}$/.test(id)) return null;
262 const row = await this.db.prepare("SELECT * FROM status_incidents WHERE id = ?").bind(id).first<IncidentRow>();
263 return row ? (await this.withUpdates([row]))[0]! : null;
264 }
265
266 /** Every incident, newest first. */
267 async list(o: { limit?: number } = {}): Promise<Incident[]> {
268 const limit = Math.min(Math.max(o.limit ?? 50, 1), 200);
269 return this.withUpdates((await this.db.prepare("SELECT * FROM status_incidents ORDER BY created_at DESC, rowid DESC LIMIT ?").bind(limit).all<IncidentRow>()).results);
270 }
271
272 /** Unresolved incidents, newest first. */
273 async active(): Promise<Incident[]> {
274 return this.withUpdates((await this.db.prepare("SELECT * FROM status_incidents WHERE state != 'resolved' ORDER BY created_at DESC, rowid DESC LIMIT 50").all<IncidentRow>()).results);
275 }
276
277 /** Incidents resolved after `since`, most recently resolved first. */
278 async resolvedSince(since: string): Promise<Incident[]> {
279 return this.withUpdates((await this.db.prepare("SELECT * FROM status_incidents WHERE state = 'resolved' AND resolved_at > ? ORDER BY resolved_at DESC, rowid DESC LIMIT 50").bind(since).all<IncidentRow>()).results);
280 }
281
282 /** Store an incident with its first update, together. */
283 async create(input: IncidentInput, by: string): Promise<Incident> {
284 const id = newId();
285 const at = now();
286 await this.db.batch([
287 this.db
288 .prepare("INSERT INTO status_incidents (id, title, impact, components, state, created_by, created_at, updated_at, resolved_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
289 .bind(id, input.title, input.impact, JSON.stringify(input.components), input.state, by, at, at, input.state === "resolved" ? at : null),
290 this.db.prepare("INSERT INTO status_incident_updates (id, incident_id, state, body, author_login, created_at) VALUES (?, ?, ?, ?, ?, ?)").bind(newId(), id, input.state, input.body, by, at),
291 ]);
292 return (await this.get(id))!;
293 }
294
295 /**
296 * Add an update and move the incident to its state. Resolving sets resolved_at (once); any other
297 * state clears it, so an incident resolved too soon can be reopened.
298 */
299 async addUpdate(id: string, input: UpdateInput, by: string): Promise<void> {
300 const at = now();
301 await this.db.batch([
302 this.db.prepare("INSERT INTO status_incident_updates (id, incident_id, state, body, author_login, created_at) VALUES (?, ?, ?, ?, ?, ?)").bind(newId(), id, input.state, input.body, by, at),
303 this.db.prepare("UPDATE status_incidents SET state = ?1, updated_at = ?2, resolved_at = CASE WHEN ?1 = 'resolved' THEN coalesce(resolved_at, ?2) ELSE NULL END WHERE id = ?3").bind(input.state, at, id),
304 ]);
305 }
306}
307
308// ---------------------------------------------------------------- changes (REST and web)
309
310type Actor = { id: string; login: string };
311type Audit = (e: { actorId?: string; actorLogin?: string; action: string; target?: string; detail?: unknown }) => Promise<void>;
312
313/** Open an incident with its first update, audited as `status_incident.create` (without the update's text). */
314export async function openIncident(db: D1Database, audit: Audit, me: Actor, input: IncidentInput, via: "web" | "api"): Promise<Incident> {
315 const i = await new StatusIncidents(db).create(input, me.login);
316 await audit({ actorId: me.id, actorLogin: me.login, action: "status_incident.create", target: i.id, detail: { title: i.title, impact: i.impact, components: i.components, state: i.state, via } });
317 return i;
318}
319
320/**
321 * Post an update to an incident, audited as `status_incident.resolve` when it resolves it and
322 * `status_incident.update` otherwise. 404 when there is no such incident.
323 */
324export async function postUpdate(db: D1Database, audit: Audit, me: Actor, id: string, input: UpdateInput, via: "web" | "api"): Promise<Incident> {
325 const store = new StatusIncidents(db);
326 const before = await store.get(id);
327 if (!before) throw new HttpError(404, "There is no incident with that id.");
328 await store.addUpdate(id, input, me.login);
329 await audit({ actorId: me.id, actorLogin: me.login, action: input.state === "resolved" ? "status_incident.resolve" : "status_incident.update", target: id, detail: { from: before.state, to: input.state, via } });
330 return (await store.get(id))!;
331}
332
333// ---------------------------------------------------------------- JSON
334
335/** The API's view of an incident. Who wrote it only for site administrators. */
336export const incidentJson = (env: Pick<Env, "PUBLIC_URL">, i: Incident, o: { admin?: boolean } = {}) => ({
337 id: i.id,
338 title: i.title,
339 impact: i.impact,
340 state: i.state,
341 components: i.components,
342 ...(o.admin ? { created_by: i.created_by } : {}),
343 created_at: i.created_at,
344 updated_at: i.updated_at,
345 resolved_at: i.resolved_at,
346 updates: i.updates.map((u) => ({ id: u.id, state: u.state, body: u.body, ...(o.admin ? { author: u.author_login } : {}), created_at: u.created_at })),
347 html_url: `${env.PUBLIC_URL}/status#incident-${i.id}`,
348});
349
350/** The public summary, shaped like Statuspage's /api/v2/summary.json. */
351export const summaryJson = (env: Pick<Env, "PUBLIC_URL">, s: Summary) => ({
352 status: s.status,
353 components: s.components.map((c) => ({ ...c })),
354 incidents: s.incidents.map((i) => incidentJson(env, i)),
355 updated_at: s.updated_at,
356});