Public: anyone can read this; the crew can change it.

A sentinel finding is filed as a `bug` on its clock, or as `polish`, so the owner never triages it #8869

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8856-a-sentinel-finding-is-filed-as-a-bug-on → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/8856-a-sentinel-finding-is-filed-as-a-bug-on was merged into main as 221a2f4.

Review

Sign in to review this change.
7 files changed +202 −26
docs/SENTINEL.md +16
@@ -25,6 +25,22 @@ issue, and only when its evidence changed; one whose issue was closed and
2525 returned is filed again. It files in `FORGE_SENTINEL_REPO` (default
2626 `trevato/forge`) as the `sentinel` agent, else the first admin.
2727
28 **How it is labelled** (#8856). Each finding declares a kind, and is filed
29 with its labels, so the bug clock (`src/flow/bugs.ts`) runs from the filing
30 and nobody has to sort it:
31
32 | Kind | Labels | Clock |
33 | --- | --- | --- |
34 | `security` (a hand-installed trigger) | `bug`, `high` | 4 h |
35 | `defect` (every other finding, and the performance watcher's slow routes) | `bug` | 48 h |
36
37 A label the repository lacks is made (`src/hunt/label.ts`, shared by the
38 sentinel and `src/perf/duty.ts`); labels already on the issue stay, and an
39 issue adopted after its record was lost is labelled again. A budget breach
40 also gets `P0 Trust`. A kind joins the table with the first detector that
41 files it: the route hunters (`src/hunt/door.ts`, `links.ts`, `contract.ts`)
42 file nothing yet, so there is no `broken` or `polish` kind.
43
2844 **What it shows.** To a site admin only: the board's forge strip and the
2945 terminal palette's `Sentinel` command (as a toast) show the count of open findings and the
3046 last hunt's time. A hunt is synchronous, so there is no "hunting" state to show.
src/crew/sentinel.ts +37 −22
@@ -23,7 +23,6 @@ import { getAgentByName } from "../core/agents.ts";
2323 import {
2424 getIssueById,
2525 getLabel,
26 setIssueLabels as writeLabels,
2726 type Issue,
2827 } from "../core/issues.ts";
2928 import { canWrite, getRepo, type Repo } from "../core/repos.ts";
@@ -48,6 +47,8 @@ import {
4847 } from "../checks/shared_red.ts";
4948 import { readRunLogTail } from "../runs/log.ts";
5049 import { clip } from "../text/clip.ts";
50 import type { FindingKind } from "../hunt/finding.ts";
51 import { addLabels, kindLabels } from "../hunt/label.ts";
5152
5253 const log = createLog("sentinel");
5354
@@ -71,6 +72,8 @@ export interface Finding {
7172 readonly evidence: readonly string[];
7273 readonly pillar: string;
7374 readonly lookFor: string;
75 /** How it is filed: its labels, and so its clock (#8856). */
76 readonly kind: FindingKind;
7477 /** A budget breach: filed at once, labelled, left unassigned for a builder (#3002). */
7578 readonly urgent?: true;
7679 /** What a person or a run would do, when it is mechanical and reversible. */
@@ -131,6 +134,8 @@ export function strayTriggers(db: Database): Finding[] {
131134 `sql: ${clip(t.sql ?? "", QUOTE_MAX)}`,
132135 ],
133136 pillar: "Trust: the database does only what the schema says",
137 // Someone's hand on the database: a trigger can refuse or rewrite any write.
138 kind: "security",
134139 lookFor: `\`SELECT name FROM sqlite_master WHERE type = 'trigger'\` lists only triggers the schema creates.`,
135140 fix: `Drop it at boot like \`dropLegacyNewAccountGuards\` does (\`DROP TRIGGER "${t.name}"\`), with a test that plants it and boots.`,
136141 }));
@@ -172,6 +177,7 @@ export function heldSlots(db: Database, now: number): Finding[] {
172177 : []),
173178 ],
174179 pillar: "Reliability: the gate always has a slot",
180 kind: "defect",
175181 lookFor: `No ${w.kind} run queued for ${QUEUE_WAIT_MAX_MS / 60_000} minutes while the slots are free; a cancelled run's process is gone with it.`,
176182 fix: "End the process of a cancelled run on the cancel path, and free its slot.",
177183 });
@@ -213,6 +219,7 @@ export function ledgerStreaks(db: Database, now: number): Finding[] {
213219 `a check passed after the first stop: run ${green.id} at ${green.finished_at}`,
214220 ],
215221 pillar: "Throughput: red checks end in a fix, not a stop",
222 kind: "defect",
216223 lookFor: "A retry decision that is not stop, or a green check after a stop.",
217224 });
218225 }
@@ -250,6 +257,7 @@ export function ledgerStreaks(db: Database, now: number): Finding[] {
250257 ...(skipped > 0 ? [`${skipped} skips of a run with nothing new are not counted`] : []),
251258 ],
252259 pillar: "Throughput: the crew spends on work, not on being refused",
260 kind: "defect",
253261 lookFor: "Fewer than 20 guard decisions a day for the repository.",
254262 });
255263 }
@@ -321,6 +329,7 @@ export function runStreaks(db: Database): Finding[] {
321329 `runs: ${hits.slice(0, 5).map((r) => r.id).join(", ")}`,
322330 ],
323331 pillar: "Trust: a green check means something",
332 kind: "defect",
324333 lookFor: "A green check run for the repository's newest head.",
325334 });
326335 } else {
@@ -344,6 +353,7 @@ export function runStreaks(db: Database): Finding[] {
344353 `runs: ${pushes.slice(0, 5).map((r) => r.id).join(", ")}`,
345354 ],
346355 pillar: "Throughput: a crew run can finish what it starts",
356 kind: "defect",
347357 lookFor: "An agent run that pushes its branch from inside its sandbox.",
348358 });
349359 }
@@ -371,6 +381,7 @@ export function repeatedErrors(now: number): Finding[] {
371381 `line: ${clip(lines[0]?.message ?? "", QUOTE_MAX)}`,
372382 ],
373383 pillar: "Reliability: a quiet log",
384 kind: "defect",
374385 lookFor: "No ERROR line of this shape in an hour.",
375386 });
376387 }
@@ -414,6 +425,7 @@ export function degradedCauses(db: Database, now: number, forgeRepoId?: string,
414425 ...sharedLines(db, cause, now, forgeRepoId, dataDir),
415426 ],
416427 pillar: "P0 Trust: the forge says when it is not well, and is well again",
428 kind: "defect",
417429 lookFor: `\`${cause}\` stays out of the degraded list for a day, or holds under ${DEGRADED_DAY_MS / 60_000} minutes.`,
418430 incident: new Date(d.opened).toISOString(),
419431 cause,
@@ -467,6 +479,7 @@ export function budgetBreaches(db: Database, now: number, forgeRepoId?: string,
467479 ...sharedLines(db, s.cause, now, forgeRepoId, dataDir),
468480 ],
469481 pillar: "P0 Trust: the forge says when it is not well, and is well again",
482 kind: "defect",
470483 lookFor: `\`${s.cause}\` is gone from the degraded list and the cause is fixed, not waited out.`,
471484 fix: s.cause.startsWith("red:")
472485 ? "Fix the failing test on main in one pull. This is the only issue for it: pulls red on the same test wait for main, and no other builder is handed it while this is open."
@@ -613,27 +626,23 @@ const bodyOf = (f: Finding, now: string): string =>
613626 ].join("\n");
614627
615628 /**
616 * A breach is crew work at once: the P0 Trust label, and no assignee, so a
617 * builder may start it on the next tick (#3002). The sentinel never builds,
618 * so it never assigns itself: a finding parked on it is held from every
619 * builder. The label may fail alone (no such label): the issue is filed
620 * either way, and the failure is said.
629 * Every finding is filed sorted (#8856): its kind's labels (`bug`, with
630 * `high` for a security hole), through `hunt/label.ts`, so the bug clock runs
631 * from the moment it is filed. A breach is crew work at once: the P0 Trust label as
632 * well, and no assignee, so a builder may start it on the next tick (#3002).
633 * The sentinel never builds, so it never assigns itself: a finding parked on
634 * it is held from every builder. Labels already on the issue stay. The
635 * labelling may fail alone: the issue is filed either way, and the failure is said.
621636 */
622 function labelBreach(app: App, repo: Repo, actor: User, issue: Issue): void {
637 function labelFinding(app: App, repo: Repo, actor: User, issue: Issue, f: Finding): void {
623638 try {
624 // The forge acts on its own finding: no access check stands between it
625 // and its label, as none stands between it and the issue it just filed.
626 const label = getLabel(app.db, repo.id, "P0 Trust");
627 if (label === undefined) log.warn("sentinel has no P0 Trust label to apply", { issue: issue.number });
628 else {
629 writeLabels(app.db, issue.id, [label.id]);
630 app.bus.append({
631 kind: "issue.labeled",
632 repoId: repo.id,
633 actorId: actor.id,
634 payload: { number: issue.number, labels: [label.name] },
635 });
639 const wanted = kindLabels(app, repo.id, f.kind);
640 if (f.urgent) {
641 const label = getLabel(app.db, repo.id, "P0 Trust");
642 if (label === undefined) log.warn("sentinel has no P0 Trust label to apply", { issue: issue.number });
643 else wanted.push(label);
636644 }
645 addLabels(app, repo.id, actor.id, issue, wanted);
637646 } catch (error) {
638647 log.error("sentinel could not label", { issue: issue.number, error: String(error) });
639648 }
@@ -986,19 +995,21 @@ function closeHeld(app: App, repo: Repo, actor: User, active: ReadonlySet<string
986995 * Read, file, record. One open issue per finding: a tracked finding whose
987996 * issue is open is commented on only when its evidence changed; one whose
988997 * issue is closed and returns is a new issue. A setting away from its default
989 * is no finding: the capacity page says so beside it (#3269).
998 * is no finding: the capacity page says so beside it (#3269). `find` is the
999 * detectors, `findFaults` unless a test files findings of its own.
9901000 */
9911001 export function hunt(
9921002 app: App,
9931003 repoPath: string,
9941004 now = Date.now(),
1005 find: typeof findFaults = findFaults,
9951006 ): HuntResult {
9961007 const at = new Date(now).toISOString();
9971008 let filed = 0;
9981009 let commented = 0;
9991010 const [owner = "", name = ""] = repoPath.split("/");
10001011 const repo = getRepo(app.db, owner, name);
1001 const findings = findFaults(app.db, now, repo?.id, app.config.dataDir);
1012 const findings = find(app.db, now, repo?.id, app.config.dataDir);
10021013 const actor = sentinelActor(app.db);
10031014 if (repo !== undefined && actor !== undefined) {
10041015 settleShared(app, repo, actor, now);
@@ -1059,7 +1070,11 @@ export function hunt(
10591070 }
10601071 issueId = made.value.id;
10611072 filed++;
1062 if (f.urgent) labelBreach(app, repo, actor, made.value);
1073 labelFinding(app, repo, actor, made.value, f);
1074 } else {
1075 // Adopted: its labels may have been lost with its record.
1076 const issue = getIssueById(app.db, issueId);
1077 if (issue !== undefined) labelFinding(app, repo, actor, issue, f);
10631078 }
10641079 app.db
10651080 .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen, incident) VALUES (?1, ?2, ?3, ?4, ?4, ?5)")
src/hunt/finding.ts +18
@@ -18,3 +18,21 @@ export const found = (detector: string, subject: string, title: string, evidence
1818
1919 export const seenLine = (path: string, status: number, location?: string | null): string =>
2020 `GET ${path} → ${status}${location ? ` → ${location}` : ""}`;
21
22 /**
23 * What a finding is, so it is filed already sorted (#8856): a security hole is
24 * a `high` bug (the 4 h clock in `flow/bugs.ts`), any other defect a bug (48 h).
25 * A kind joins this list with the first detector that files it.
26 */
27 export type FindingKind = "security" | "defect";
28
29 export const KIND_LABELS: Readonly<Record<FindingKind, readonly string[]>> = {
30 security: ["bug", "high"],
31 defect: ["bug"],
32 };
33
34 /** Each label a kind is filed with, and the colour it is made in when the repository has none by that name. */
35 export const LABEL_COLORS: Readonly<Record<string, string>> = {
36 bug: "d73a4a",
37 high: "b60205",
38 };
src/hunt/label.ts added +35
@@ -0,0 +1,35 @@
1 /**
2 * Filing a finding sorted (#8856): the labels of its kind, made in the
3 * repository when it has none by that name, added to what the issue already
4 * carries. Shared by every detector that files: the sentinel and the
5 * performance watcher. The forge acts on its own finding, so no access check
6 * stands between it and the labels, as none stands between it and the issue.
7 */
8
9 import type { App } from "../app.ts";
10 import { createLabel, getLabel, labelsFor, setIssueLabels, type Issue, type Label } from "../core/issues.ts";
11 import { KIND_LABELS, LABEL_COLORS, type FindingKind } from "./finding.ts";
12
13 export function getOrMakeLabel(app: App, repoId: string, name: string): Label | undefined {
14 const had = getLabel(app.db, repoId, name);
15 if (had !== undefined) return had;
16 const made = createLabel(app.db, repoId, name, LABEL_COLORS[name] ?? "ededed");
17 return made.ok ? made.value : undefined;
18 }
19
20 export const kindLabels = (app: App, repoId: string, kind: FindingKind): Label[] =>
21 KIND_LABELS[kind].flatMap((name) => getOrMakeLabel(app, repoId, name) ?? []);
22
23 /** Adds `wanted` to the issue's labels and says so on the bus; labels already on it stay. */
24 export function addLabels(app: App, repoId: string, actorId: string, issue: Issue, wanted: readonly Label[]): void {
25 const had = labelsFor(app.db, issue.id);
26 const added = wanted.filter((w) => !had.some((h) => h.id === w.id));
27 if (added.length === 0) return;
28 setIssueLabels(app.db, issue.id, [...had, ...added].map((l) => l.id));
29 app.bus.append({
30 kind: "issue.labeled",
31 repoId,
32 actorId,
33 payload: { number: issue.number, labels: added.map((l) => l.name) },
34 });
35 }
src/perf/duty.ts +7 −2
@@ -2,7 +2,8 @@
22 * The `performance-watcher` duty (#7559): once a minute, judge the record and
33 * file each new finding, of a route or of a git subcommand (#8158), as one
44 * issue in the forge's own repository, opened by its owner. A title already
5 * open is not filed twice, and neither is a key this process has filed.
5 * open is not filed twice, and neither is a key this process has filed. A
6 * slow route or subcommand is a defect: filed as a `bug`, on its clock (#8856).
67 */
78
89 import type { App } from "../app.ts";
@@ -10,11 +11,14 @@ import { openIssue } from "../actions/issues.ts";
1011 import { getUserById } from "../core/users.ts";
1112 import { getRepo } from "../core/repos.ts";
1213 import { listIssues } from "../core/issues.ts";
14 import type { FindingKind } from "../hunt/finding.ts";
15 import { addLabels, kindLabels } from "../hunt/label.ts";
1316 import { createGitRecord } from "./git.ts";
1417 import type { PerfRecord } from "./record.ts";
1518 import { evaluate, evaluateGit, fresh } from "./watch.ts";
1619
1720 export const TICK_MS = 60_000;
21 const KIND: FindingKind = "defect";
1822
1923 export function startPerfWatcher(
2024 app: App,
@@ -36,7 +40,8 @@ export function startPerfWatcher(
3640 const findings = [...evaluate(record, now()), ...evaluateGit(gitRecord, now())];
3741 for (const f of fresh(findings, filed)) {
3842 if (open.has(f.title)) continue;
39 openIssue(app, actor, repo, { title: f.title, body: f.evidence });
43 const made = openIssue(app, actor, repo, { title: f.title, body: f.evidence });
44 if (made.ok) addLabels(app, repo.id, actor.id, made.value, kindLabels(app, repo.id, KIND));
4045 }
4146 } catch (error) {
4247 app.log.warn("performance watcher failed", { error: String(error) });
test/perf-duty.test.ts +7 −1
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { createRepo } from "../src/actions/repos.ts";
66 import { createApp, type App } from "../src/app.ts";
7 import { listIssues } from "../src/core/issues.ts";
7 import { labelsFor, listIssues } from "../src/core/issues.ts";
88 import { getRepo } from "../src/core/repos.ts";
99 import { createUser } from "../src/core/users.ts";
1010 import { startPerfWatcher } from "../src/perf/duty.ts";
@@ -84,3 +84,9 @@ test("the watcher files a slow git subcommand once (#8158)", async () => {
8484 stop();
8585 expect(titles().filter((t) => t.startsWith("git "))).toEqual(["git rev-parse is over its budget"]);
8686 });
87
88 test("a slow route is filed as a bug, so its clock runs from the filing (#8856)", () => {
89 const repo = getRepo(app.db, "alice", "forge")!;
90 const issue = listIssues(app.db, repo.id, { state: "open" }).find((i) => i.title === "/ is over its budget: kpis")!;
91 expect(labelsFor(app.db, issue.id).map((l) => l.name)).toEqual(["bug"]);
92 });
test/sentinel.test.ts +82 −1
@@ -21,6 +21,7 @@ import { createRun } from "../src/core/runs.ts";
2121 import { createUser, type User } from "../src/core/users.ts";
2222 import { recordCap } from "../src/crew/guard.ts";
2323 import { sharedCause } from "../src/checks/shared_red.ts";
24 import { bugClockAlarm, HIGH_BUG_MS } from "../src/flow/bugs.ts";
2425 import {
2526 budgetBreaches,
2627 degradedCauses,
@@ -691,7 +692,7 @@ describe("a budget breach is crew work at once", () => {
691692 const issue = open[0]!;
692693 // The sentinel never builds, so it never assigns itself (#3002).
693694 expect(issue.assigneeId).toBeNull();
694 expect(labelsFor(bapp.db, issue.id).map((l) => l.name)).toEqual(["P0 Trust"]);
695 expect(labelsFor(bapp.db, issue.id).map((l) => l.name).sort()).toEqual(["P0 Trust", "bug"]);
695696 expect(listEvents(bapp.db, { kind: "issue.assigned" }).length).toBe(0);
696697 });
697698
@@ -979,3 +980,83 @@ describe("a degraded finding whose cause has stayed closed", () => {
979980 expect(hunt(happ, "alice/forge", t).filed).toBe(1);
980981 });
981982 });
983
984 describe("a finding is filed sorted (#8856)", () => {
985 let kapp: App;
986 let krepo: Repo;
987 let kdir: string;
988 const names = (issueId: string): string[] => labelsFor(kapp.db, issueId).map((l) => l.name).sort();
989 const openTitled = (part: string) =>
990 listIssues(kapp.db, krepo.id, { state: "open", limit: 200 }).find((i) => i.title.includes(part));
991 beforeAll(async () => {
992 kdir = await mkdtemp(join(tmpdir(), "forge-sentinel-kind-"));
993 kapp = createApp({ dataDir: kdir, port: 0 });
994 const a = await createUser(kapp.db, {
995 origin: "seed",
996 trusted: true,
997 username: "alice",
998 email: "[email protected]",
999 password: "a-good-passphrase",
1000 adminIfFirst: true,
1001 });
1002 if (!a.ok) throw new Error(a.error);
1003 const made = await createRepo(kapp, a.value, { name: "forge", visibility: "private" });
1004 if (!made.ok) throw new Error(String(made.error));
1005 krepo = made.value;
1006 kapp.db.exec(
1007 `CREATE TRIGGER planted_guard BEFORE INSERT ON repo
1008 BEGIN SELECT RAISE(ABORT, 'forge: paused'); END`,
1009 );
1010 });
1011 afterAll(async () => {
1012 kapp.db.close();
1013 await rm(kdir, { recursive: true, force: true });
1014 });
1015
1016 test("a security finding lands as a high bug, and is past its clock after 4 h", () => {
1017 const found = findFaults(kapp.db, NOW).find((f) => f.key === "trigger:planted_guard");
1018 expect(found?.kind).toBe("security");
1019 expect(hunt(kapp, "alice/forge", NOW).filed).toBe(1);
1020 const issue = openTitled("planted_guard")!;
1021 // The repository had no `bug` or `high`: the sentinel made them.
1022 expect(names(issue.id)).toEqual(["bug", "high"]);
1023 const filedAt = Date.parse(issue.createdAt);
1024 expect(bugClockAlarm(kapp.db, new Date(filedAt + HIGH_BUG_MS - 60_000)).fired).toBe(false);
1025 const alarm = bugClockAlarm(kapp.db, new Date(filedAt + HIGH_BUG_MS + 60_000));
1026 expect(alarm.fired).toBe(true);
1027 expect(alarm.href).toBe(`/alice/forge/issues/${issue.number}`);
1028 expect(alarm.says).toContain("4 h");
1029 });
1030
1031 test("still present, it only comments; re-filed after close, it keeps its labels", () => {
1032 expect(hunt(kapp, "alice/forge", NOW + 60_000).filed).toBe(0);
1033 const issue = openTitled("planted_guard")!;
1034 kapp.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(issue.id);
1035 expect(hunt(kapp, "alice/forge", NOW + 120_000).filed).toBe(1);
1036 const again = openTitled("planted_guard")!;
1037 expect(again.id).not.toBe(issue.id);
1038 expect(names(again.id)).toEqual(["bug", "high"]);
1039 });
1040
1041 test("an issue adopted after its record was lost gets its labels", () => {
1042 const lost = () => [
1043 { key: "log:lost", title: "The log says lost 3 times", evidence: ["lost"], pillar: "Reliability", lookFor: "No lost.", kind: "defect" as const },
1044 ];
1045 expect(hunt(kapp, "alice/forge", NOW, lost).filed).toBe(1);
1046 const issue = openTitled("log says lost")!;
1047 // The record is lost and so are the labels: the next hunt adopts the issue, files nothing, and labels it again.
1048 kapp.db.query("DELETE FROM sentinel_finding WHERE key = 'log:lost'").run();
1049 kapp.db.query("DELETE FROM issue_label WHERE issue_id = ?1").run(issue.id);
1050 expect(hunt(kapp, "alice/forge", NOW + 60_000, lost).filed).toBe(0);
1051 expect(openTitled("log says lost")!.id).toBe(issue.id);
1052 expect(names(issue.id)).toEqual(["bug"]);
1053 });
1054
1055 test("another defect is a bug on the 48 h clock", () => {
1056 const defect = () => [
1057 { key: "log:x", title: "The log says x 3 times", evidence: ["x"], pillar: "Reliability", lookFor: "No x.", kind: "defect" as const },
1058 ];
1059 expect(hunt(kapp, "alice/forge", NOW, defect).filed).toBe(1);
1060 expect(names(openTitled("log says x")!.id)).toEqual(["bug"]);
1061 });
1062 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs