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

A capacity setting away from its default is one line beside it on the capacity page, and the sentinel files no issue for it #3303

Merged opened this

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

Merged

scout/forge:scout/3269-a-capacity-setting-away-from-its-default → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/3269-a-capacity-setting-away-from-its-default was merged into main as 9fa2e88.

Review

Sign in to review this change.
14 files changed +226 −288
README.md +23 −27
@@ -1248,35 +1248,31 @@ given up.
12481248 ### The sentinel
12491249
12501250 The forge hunts its own faults (`docs/CREW.md`): a hunt an hour on the
1251 autonomy clock, sooner when a held slot or a setting left away from its
1252 default is new. Each finding is one issue with its evidence, filed in
1253 `FORGE_SENTINEL_REPO` (default `trevato/forge`) as the `sentinel` agent, else
1254 the first admin. Evidence is scrubbed of anything credential-shaped before it
1255 is filed.
1251 autonomy clock, sooner when a held slot is new. Each finding is one issue with
1252 its evidence, filed in `FORGE_SENTINEL_REPO` (default `trevato/forge`) as the
1253 `sentinel` agent, else the first admin. Evidence is scrubbed of anything
1254 credential-shaped before it is filed. Other findings (`held`, `ledger`, `red`)
1255 are filed anew when they return.
12561256
1257 A setting kept on purpose is a decision, not drift. Closing a `setting:<key>`
1258 finding's issue, or commenting on it (a person, not the filer and not an agent), records the value
1259 and its `updated_at` in `sentinel_kept`; while both match, the sentinel files
1260 nothing and comments nothing for that key. Changing the value on the capacity
1261 page ends the keeping, and the setting is reported again under the usual
1262 one-day rule. The rule is mechanical: any close counts, with or without a
1263 reason, so a bare close silences the setting until its value changes. Other
1264 findings (`held`, `ledger`, `red`) are filed anew when they return.
1257 A capacity setting away from its default is not a finding: the sentinel files
1258 nothing for it (#3269). On `/settings/capacity` the row of each such setting
1259 carries one muted line, "changed from X N days ago by who", and its **Put back**
1260 button (in the row's own form, beside Save) restores the default through
1261 `changeCapacity` (same validation, record, event and reversal). The line stays
1262 until the value is back at the default; nothing else reminds anyone. The
1263 JSON API and MCP carry the same fact: a setting away from its default has a
1264 `changed` field (`at`, `by`) in the capacity listing (`listCapacity`).
12651265
1266 A setting finding is the site admin's ask, never a crew run: it is not
1267 assigned, and the autonomy clock leaves it out of the crew's ready list (`crew/setting_finding.ts`). It shows on the home (kind `setting`,
1268 one card per key: the value, its default, who set it and when) and in the
1269 terminal's On you card, with two answers — **Keep** and **Put back**. On the
1270 web they are `POST /asks/setting` (`key`, `answer=keep|put`, same-origin only);
1271 in the terminal, `K` keeps and `B` puts back the row under the cursor. Only a
1272 signed-in site admin can answer, whatever they can write: anyone else, and any
1273 agent, gets `not_found`/`unauthorized` and nothing changes. Keep closes the
1274 finding and records the value in `sentinel_kept`; Put back goes through the
1275 capacity page's own door (`changeCapacity`: same validation, record, event and
1276 reversal). The close is the gate: Put back changes the setting, and Keep records
1277 the value, only after a finding has closed. If none can (every one is on a
1278 mirrored repository, or the close is refused), nothing changes and the page and
1279 the terminal say so (`unclosable`). Only a person's close keeps a setting.
1266 A `setting:<key>` finding filed before that is closed on the next pass with a
1267 comment pointing at the capacity page (`closeSettingFindings` in
1268 `crew/setting_finding.ts`). Until it closes, it is the site admin's ask, never a
1269 crew run: not assigned, left out of the crew's ready list, and shown on the home
1270 (kind `setting`) and in the terminal's On you card with **Keep** and **Put
1271 back** (`POST /asks/setting`, `key`, `answer=keep|put`, same-origin; `K` and `B`
1272 in the terminal). Only a signed-in site admin can answer. Keep and Put back act
1273 only after the finding has closed; if none can (a mirrored repository, or the
1274 close is refused) nothing changes and the page says so (`unclosable`). This is
1275 the legacy path, kept until no open setting finding is left.
12801276
12811277 - **Board:** a site admin's forge strip shows the open findings and the last
12821278 hunt; a guest or member sees nothing.
docs/CREW.md +3 −4
@@ -39,10 +39,9 @@ the `denyRead` list) and not the boundary itself.
3939 The forge hunts its own faults. `src/crew/sentinel.ts` runs on the autonomy
4040 clock (a hunt an hour; a signal may bring one sooner, never within ten
4141 minutes). It reads what the forge already holds — the run table, the decision
42 ledger (`retry stop` streaks, `guard` parks, a direction at its cap), capacity
43 settings away from their defaults for more than a day, `sqlite_master` for
44 triggers the schema does not create, and the error ring — and names each fault
45 with the evidence quoted (secret-shaped text scrubbed).
42 ledger (`retry stop` streaks, `guard` parks, a direction at its cap),
43 `sqlite_master` for triggers the schema does not create, and the error ring —
44 and names each fault with the evidence quoted (secret-shaped text scrubbed).
4645
4746 Each finding is exactly one open issue, keyed so it is never filed twice; a
4847 finding still there is a comment, and only when the evidence changed. The board
docs/SENTINEL.md +2 −2
@@ -5,7 +5,8 @@ the forge already holds and files what is wrong, with the evidence quoted.
55
66 **When.** On the autonomy clock: a hunt an hour, and one sooner (never within
77 ten minutes of the last) when a signal is new — queued checks waiting while no
8 run uses a slot, or a capacity setting away from its default for over a day.
8 run uses a slot. A capacity setting away from its default is no signal and
9 no finding: it is a line on `/settings/capacity` (#3269).
910
1011 **What it reads.**
1112
@@ -14,7 +15,6 @@ run uses a slot, or a capacity setting away from its default for over a day.
1415 | a slot held by a finished run | `run`: queued work older than 30 min, nothing running or every lease lapsed |
1516 | a check never green; pushes that cannot land | the last 20 `check` / `agent` run summaries per repository |
1617 | `retry` stops, guard parks | the decision ledger (`decisionsSince`) |
17 | a setting left away from default | `forge_setting` against `capacityDefault` |
1818 | a hand-installed trigger | `sqlite_master` against a scratch database migrated from the schema |
1919 | the same ERROR line again and again | the error ring (`kernel/log.ts`) |
2020
src/actions/capacity.ts +16
@@ -7,6 +7,7 @@
77 * an event: the key, from what to what, and who.
88 */
99
10 import type { Database } from "bun:sqlite";
1011 import type { App } from "../app.ts";
1112 import {
1213 CAPACITY,
@@ -34,6 +35,8 @@ export interface CapacityRow {
3435 readonly source: "setting" | "default";
3536 readonly default: string;
3637 readonly env: string;
38 /** For a setting away from its default: when and by whom it was set. */
39 readonly changed?: { readonly at: string; readonly by: string };
3740 }
3841
3942 function admin(actor: User | undefined): Result<User, "not_found"> {
@@ -42,6 +45,18 @@ function admin(actor: User | undefined): Result<User, "not_found"> {
4245 : err("not_found");
4346 }
4447
48 /** Who set `key` and when, only while its stored value is not the default. */
49 export function changedOf(db: Database, key: CapacityKey): { changed?: { at: string; by: string } } {
50 const value = storedCapacity(db, key);
51 if (value === undefined || value === bootDefault(db, key)) return {};
52 const row = db
53 .query<{ updated_at: string; username: string | null }, [string]>(
54 "SELECT s.updated_at, u.username FROM forge_setting s LEFT JOIN user u ON u.id = s.updated_by WHERE s.key = ?1",
55 )
56 .get(key);
57 return row === null ? {} : { changed: { at: row.updated_at, by: row.username ?? "someone" } };
58 }
59
4560 export function listCapacity(
4661 app: App,
4762 actor: User | undefined,
@@ -57,6 +72,7 @@ export function listCapacity(
5772 source: storedCapacity(app.db, key) === undefined ? "default" : "setting",
5873 default: bootDefault(app.db, key),
5974 env: CAPACITY[key].env,
75 ...changedOf(app.db, key),
6076 })),
6177 changes: capacityChanges(app.db),
6278 });
src/crew/sentinel.ts +10 −87
@@ -16,11 +16,6 @@
1616 import { Database } from "bun:sqlite";
1717 import type { App } from "../app.ts";
1818 import { addComment, openIssue, setIssueState } from "../actions/issues.ts";
19 import {
20 CAPACITY_KEYS,
21 capacityDefault,
22 storedCapacity,
23 } from "../core/capacity.ts";
2419 import { decisionsSince } from "../core/decisions.ts";
2520 import { getAgentByName } from "../core/agents.ts";
2621 import { getIssueById, getLabel, setIssueLabels as writeLabels, type Issue } from "../core/issues.ts";
@@ -34,6 +29,7 @@ import { spans } from "../kernel/degraded_history.ts";
3429 import { QUEUE_WAIT_MAX_MS } from "../kernel/vitals.ts";
3530 import { STALE_AFTER_MS } from "../runs/lease.ts";
3631 import { isOutageSummary } from "./network.ts";
32 import { closeSettingFindings } from "./setting_finding.ts";
3733
3834 const log = createLog("sentinel");
3935
@@ -42,8 +38,6 @@ const DAY_MS = 24 * HOUR_MS;
4238 /** A hunt an hour; a signal may bring one sooner, but never within this. */
4339 export const HUNT_EVERY_MS = HOUR_MS;
4440 export const SIGNAL_GAP_MS = 10 * 60_000;
45 /** A setting away from its default this long is a finding. */
46 export const SETTING_AWAY_MS = DAY_MS;
4741 /** A cause degraded longer than this in its day is a finding. */
4842 export const DEGRADED_DAY_MS = 10 * 60_000;
4943 /** How many alike make a streak. */
@@ -63,8 +57,6 @@ export interface Finding {
6357 readonly urgent?: true;
6458 /** What a person or a run would do, when it is mechanical and reversible. */
6559 readonly fix?: string;
66 /** A setting finding: the value and `updated_at` a person may have kept (#2001). */
67 readonly kept?: { readonly value: string; readonly updatedAt: string };
6860 /** A degraded finding: when its cause first opened. Same cause and time, same incident (#2553). */
6961 readonly incident?: string;
7062 }
@@ -168,37 +160,6 @@ export function heldSlots(db: Database, now: number): Finding[] {
168160 return out;
169161 }
170162
171 /** A capacity setting that is not its default, for more than a day. */
172 export function settingsAway(db: Database, now: number): Finding[] {
173 const out: Finding[] = [];
174 for (const key of CAPACITY_KEYS) {
175 const value = storedCapacity(db, key);
176 if (value === undefined || value === capacityDefault(key)) continue;
177 const row = db
178 .query<
179 { updated_at: string; username: string | null },
180 [string]
181 >("SELECT s.updated_at, u.username FROM forge_setting s LEFT JOIN user u ON u.id = s.updated_by WHERE s.key = ?1")
182 .get(key);
183 if (row === null || now - new Date(row.updated_at).getTime() < SETTING_AWAY_MS)
184 continue;
185 const days = Math.floor((now - new Date(row.updated_at).getTime()) / DAY_MS);
186 out.push({
187 key: `setting:${key}`,
188 title: `Setting ${key} has been away from its default for ${days} days`,
189 evidence: [
190 `${key} = "${value}", default "${capacityDefault(key)}"`,
191 `set by ${row.username ?? "unknown"} at ${row.updated_at}`,
192 ],
193 pillar: "Trust: a setting is a decision, not a leftover",
194 lookFor: `${key} is back at "${capacityDefault(key)}", or a person says why it stays: the owner is asked in the inbox, Keep or Put back, and a person's close or comment keeps it, reported again only if its value changes. No agent is assigned; a run cannot change a setting.`,
195 kept: { value, updatedAt: row.updated_at },
196 fix: `Put it back: clear ${key} on the capacity page (it is recorded and reversible).`,
197 });
198 }
199 return out;
200 }
201
202163 /** The crew's stops and guard parks, read from the decision ledger. */
203164 export function ledgerStreaks(db: Database, now: number): Finding[] {
204165 const out: Finding[] = [];
@@ -486,7 +447,6 @@ export function findFaults(db: Database, now = Date.now()): Finding[] {
486447 const found = [
487448 ...budgetBreaches(db, now),
488449 ...heldSlots(db, now),
489 ...settingsAway(db, now),
490450 ...strayTriggers(db),
491451 ...ledgerStreaks(db, now),
492452 ...runStreaks(db),
@@ -583,43 +543,6 @@ function labelBreach(app: App, repo: Repo, actor: User, issue: Issue): void {
583543 }
584544 }
585545
586 /** The finding is a setting whose kept value and `updated_at` are on record. */
587 function keptRecorded(db: Database, f: Finding): boolean {
588 if (f.kept === undefined) return false;
589 const kept = db
590 .query<{ value: string; updated_at: string }, [string]>("SELECT value, updated_at FROM sentinel_kept WHERE key = ?1")
591 .get(f.key);
592 return kept !== null && kept.value === f.kept.value && kept.updated_at === f.kept.updatedAt;
593 }
594
595 /**
596 * Whether a person decided to keep this setting as it now stands: its
597 * finding was closed, or explained in a comment by a person (not the sentinel, not an agent),
598 * after the value was last set. A decision is recorded with the value and
599 * `updated_at` it was made at; the same pair is kept, any other is drift.
600 */
601 function settingKept(db: Database, f: Finding, actorId: string, at: string): boolean {
602 if (f.kept === undefined) return false;
603 if (keptRecorded(db, f)) return true;
604 const { value, updatedAt } = f.kept;
605 const decided = db
606 .query<{ n: number }, [string, string, string]>(
607 `SELECT COUNT(*) AS n FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id
608 WHERE t.key = ?1 AND ((i.state = 'closed' AND i.closed_at >= ?2
609 AND (SELECT e.actor_id FROM event e
610 WHERE e.kind = 'issue.closed' AND e.repo_id = i.repo_id
611 AND json_extract(e.payload, '$.number') = i.number
612 ORDER BY e.id DESC LIMIT 1)
613 IN (SELECT id FROM user WHERE is_agent = 0 AND kind = 'user'))
614 OR EXISTS (SELECT 1 FROM comment c WHERE c.issue_id = i.id AND c.author_id <> ?3 AND c.created_at >= ?2
615 AND EXISTS (SELECT 1 FROM user u WHERE u.id = c.author_id AND u.is_agent = 0 AND u.kind = 'user')))`,
616 )
617 .get(f.key, updatedAt, actorId);
618 if (decided === null || decided.n === 0) return false;
619 recordKept(db, f.key, value, updatedAt, at);
620 return true;
621 }
622
623546 /** Remember that a person kept `key` as it stands: this value, set at `updatedAt`. */
624547 export function recordKept(db: Database, key: string, value: string, updatedAt: string, at: string): void {
625548 db.query(
@@ -720,8 +643,8 @@ function closeHeld(app: App, repo: Repo, actor: User, active: ReadonlySet<string
720643 /**
721644 * Read, file, record. One open issue per finding: a tracked finding whose
722645 * issue is open is commented on only when its evidence changed; one whose
723 * issue is closed and returns is a new issue — except a setting a person
724 * kept (`settingKept`), which stays quiet until its value changes.
646 * issue is closed and returns is a new issue. A setting away from its default
647 * is no finding: the capacity page says so beside it (#3269).
725648 */
726649 export function hunt(
727650 app: App,
@@ -738,7 +661,6 @@ export function hunt(
738661 if (repo !== undefined && actor !== undefined) {
739662 for (const f of findings) {
740663 try {
741 if (settingKept(app.db, f, actor.id, at)) continue;
742664 const evidence = f.evidence.join("\n");
743665 const tracked = app.db
744666 .query<
@@ -787,7 +709,7 @@ export function hunt(
787709 }
788710 issueId = made.value.id;
789711 filed++;
790 if (f.urgent && f.kept === undefined) labelBreach(app, repo, actor, made.value);
712 if (f.urgent) labelBreach(app, repo, actor, made.value);
791713 }
792714 app.db
793715 .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen, incident) VALUES (?1, ?2, ?3, ?4, ?4, ?5)")
@@ -797,8 +719,10 @@ export function hunt(
797719 }
798720 }
799721 }
800 if (repo !== undefined && actor !== undefined)
722 if (repo !== undefined && actor !== undefined) {
801723 closeHeld(app, repo, actor, new Set(findings.map((f) => f.key)), now);
724 closeSettingFindings(app, repo, actor);
725 }
802726 // A hunt with nowhere to file is recorded as such, never as a quiet one.
803727 const unfiled = repo === undefined || actor === undefined;
804728 app.db
@@ -856,8 +780,7 @@ export function sentinelSummary(db: Database): string | null {
856780 }
857781
858782 /**
859 * Hunt when it is time: an hour since the last, or a signal (a slot held, a
860 * setting away) not yet tracked, and not within ten minutes of the last.
783 * Hunt when it is time: an hour since the last, or a signal (a slot held) not yet tracked, and not within ten minutes of the last.

Docs (README sentinel section, docs/SENTINEL.md, docs/CREW.md) still describe the removed setting finding and its sooner hunt; update them.

861784 */
862785 export function sentinelTick(app: App, now = Date.now()): HuntResult | null {
863786 const repoPath = sentinelRepoPath();
@@ -869,7 +792,7 @@ export function sentinelTick(app: App, now = Date.now()): HuntResult | null {
869792 // A breach is not held back by the gap: the crew hears within a tick.
870793 const signals = [
871794 ...breaches,
872 ...(since < SIGNAL_GAP_MS ? [] : [...heldSlots(app.db, now), ...settingsAway(app.db, now)]),
795 ...(since < SIGNAL_GAP_MS ? [] : heldSlots(app.db, now)),
873796 ];
874797 const tracked = new Set(
875798 app.db
@@ -879,7 +802,7 @@ export function sentinelTick(app: App, now = Date.now()): HuntResult | null {
879802 .all()
880803 .map((r) => r.key),
881804 );
882 if (!signals.some((s) => !tracked.has(s.key) && !keptRecorded(app.db, s))) return null;
805 if (!signals.some((s) => !tracked.has(s.key))) return null;
883806 }
884807 return hunt(app, repoPath, now);
885808 }
src/crew/setting_finding.ts +38
@@ -5,6 +5,14 @@
55 */
66
77 import type { Database } from "bun:sqlite";
8 import type { App } from "../app.ts";
9 import { addComment, setIssueState } from "../actions/issues.ts";
10 import { getIssueById } from "../core/issues.ts";
11 import type { Repo } from "../core/repos.ts";
12 import type { User } from "../core/users.ts";
13 import { createLog } from "../kernel/log.ts";
14
15 const log = createLog("sentinel");
816
917 /** Whether this issue is the open finding of a `setting:<key>` the sentinel reported. */
1018 export function isSettingFinding(db: Database, issueId: string): boolean {
@@ -16,3 +24,33 @@ export function isSettingFinding(db: Database, issueId: string): boolean {
1624 .get(issueId) !== null
1725 );
1826 }
27
28 /**
29 * A setting away from its default is a line on the capacity page, not an
30 * issue (#3269). Findings filed before that are closed, with a pointer.
31 */
32 export function closeSettingFindings(app: App, repo: Repo, actor: User): void {
33 const open = app.db
34 .query<{ issue_id: string }, []>(
35 `SELECT DISTINCT t.issue_id FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id
36 WHERE i.state = 'open' AND t.key LIKE 'setting:%'`,
37 )
38 .all();
39 for (const t of open) {
40 try {
41 const issue = getIssueById(app.db, t.issue_id);
42 if (issue === undefined || issue.repoId !== repo.id) continue;
43 const said = addComment(
44 app,
45 actor,
46 repo,
47 issue,
48 "A setting away from its default is now one line beside it on the capacity page (/settings/capacity), with Put back; the sentinel files no issue for it. Closing.",
49 );
50 if (!said.ok) continue;
51 setIssueState(app, actor, repo, issue, "closed", { reason: "done" });
52 } catch (error) {
53 log.error("sentinel could not close a setting finding", { issue: t.issue_id, error: String(error) });
54 }
55 }
56 }
src/web/pages/capacity.ts +22 −4
@@ -6,7 +6,7 @@
66 * and each one is listed below the settings: who, when, from what to what.
77 */
88
9 import { changeCapacity, listCapacity } from "../../actions/capacity.ts";
9 import { changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
1010 import { CAPACITY, type CapacityKey } from "../../core/capacity.ts";
1111 import type { Ctx } from "../../http/context.ts";
1212 import { notFoundPage, signInFirst } from "../chrome.ts";
@@ -22,6 +22,22 @@ function requireAdmin(ctx: Ctx): Response | undefined {
2222 return undefined;
2323 }
2424
25 const DAY_MS = 86_400_000;
26
27 /** "today", "1 day ago", "9 days ago". */
28 export function daysAgo(at: string, now: number): string {
29 const days = Math.max(0, Math.floor((now - Date.parse(at)) / DAY_MS));
30 return days === 0 ? "today" : `${days} day${days === 1 ? "" : "s"} ago`;
31 }
32
33 /** One muted line beside a setting away from its default; Put back is in the row's trail form. */
34 function awayLine(s: CapacityRow, now: number) {
35 if (s.changed === undefined) return null;
36 return html`<span class="muted small">changed from
37 <span class="mono">${s.default === "" ? "auto" : s.default}</span>
38 ${daysAgo(s.changed.at, now)} by ${s.changed.by}</span>`;
outdated

row-fields is the trail form class in DESIGN.md; here it wraps a line inside the row meta. Move Put back into the trail form, or document this as a component.

39 }
40
2541 function input(key: CapacityKey, value: string) {
2642 const kind = CAPACITY[key].kind;
2743 if (kind.type === "choice")
@@ -55,6 +71,7 @@ export function capacityPage(ctx: Ctx): Response {
5571 const listed = listCapacity(ctx.app, ctx.auth.user);
5672 if (!listed.ok) return notFoundPage(ctx);
5773 const { settings, changes } = listed.value;
74 const now = Date.now();
5875 const rows = settings.map((s) =>
5976 row({
6077 lead: icon("activity"),
@@ -63,7 +80,8 @@ export function capacityPage(ctx: Ctx): Response {
6380 ? html`Set here; the boot default is
6481 <span class="mono">${s.default === "" ? "auto" : s.default}</span>
6582 (<span class="mono">${s.env}</span>).`
66 : html`The boot default, from <span class="mono">${s.env}</span>.`}`,
83 : html`The boot default, from <span class="mono">${s.env}</span>.`}
84 ${awayLine(s, now)}`,
6785 trail: html`<form
6886 class="row-fields"
6987 method="post"
@@ -79,11 +97,11 @@ export function capacityPage(ctx: Ctx): Response {
7997 ${when(
8098 s.source === "setting",
8199 () =>
82 html`${btn("Default", {
100 html`${btn(s.changed === undefined ? "Default" : "Put back", {
83101 size: "sm",
84102 name: "reset",
85103 value: "1",
86 label: `Back to the default for ${s.label}`,
104 label: s.changed === undefined ? `Back to the default for ${s.label}` : `Put ${s.label} back to the default`,
87105 })}`,
88106 )}
89107 </form>`,
test/asks.test.ts +29 −9
@@ -14,7 +14,8 @@ import { openPull } from "../src/actions/pulls.ts";
1414 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1515 import { TESTS_PASSED_TAIL } from "../src/apps/runtime.ts";
1616 import { recordDecision, resolveOpen } from "../src/core/decisions.ts";
17 import type { Repo } from "../src/core/repos.ts";
17 import { createIssue } from "../src/core/issues.ts";
18 import { getRepo, type Repo } from "../src/core/repos.ts";
1819 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
1920 import { getUserByName } from "../src/core/users.ts";
2021 import { markVerified } from "../src/core/verify.ts";
@@ -283,6 +284,26 @@ describe("the asks page", () => {
283284 .query("UPDATE forge_setting SET updated_at = ?2 WHERE key = ?1")
284285 .run(key, new Date(Date.now() - 10 * 86_400_000).toISOString());
285286 };
287 // The sentinel no longer files a setting (#3269); an issue it filed
288 // before still waits for an answer, so these tests plant one.
289 const fileLegacy = (path: string): void => {
290 const [owner = "", name = ""] = path.split("/");
291 const target = getRepo(app.db, owner, name)!;
292 const author = getUserByName(app.db, "alice")!;
293 const rows = app.db.query<{ key: string }, []>("SELECT key FROM forge_setting").all();
294 for (const { key } of rows) {
295 const made = createIssue(app.db, {
296 repoId: target.id,
297 authorId: author.id,
298 title: `Sentinel: Setting ${key} has been away from its default for 10 days`,
299 body: `${key} is away`,
300 });
301 if (!made.ok) throw new Error(made.error);
302 app.db
303 .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES (?1, ?2, '', 'a', 'a')")
304 .run(`setting:${key}`, made.value.id);
305 }
306 };
286307 const post = (cookie: string, fields: Record<string, string>, origin = servers.url) =>
287308 fetch(`${servers.url}/asks/setting`, {
288309 method: "POST",
@@ -305,8 +326,8 @@ describe("the asks page", () => {
305326 reset();
306327 away("checks.concurrency", "4");
307328 away("cells.egress", "report");
308 hunt(app, "alice/garden");
309 hunt(app, "alice/garden", Date.now() + 3 * 3_600_000);
329 fileLegacy("alice/garden");
330 fileLegacy("alice/garden");
310331 expect(settingCards().sort()).toEqual(["setting:cells.egress", "setting:checks.concurrency"]);
311332 const card = decisionsFor({ app, auth: { user: getUserByName(app.db, "alice")! } } as unknown as Ctx, [repo]).find(
312333 (d) => d.id === "setting:checks.concurrency",
@@ -323,12 +344,11 @@ describe("the asks page", () => {
323344 expect(listEvents(app.db, { kind: "issue.assigned" }).length).toBe(0);
324345 });
325346
326 test("Keep closes the finding as kept, and it stays quiet", async () => {
347 test("Keep closes the finding as kept", async () => {
327348 const res = await post(alice, { key: "checks.concurrency", answer: "keep" });
328349 expect(res.status).toBe(303);
329350 expect(settingCards()).toEqual(["setting:cells.egress"]);
330351 expect(app.db.query("SELECT value FROM sentinel_kept WHERE key = 'setting:checks.concurrency'").get()).toEqual({ value: "4" });
331 expect(hunt(app, "alice/garden", Date.now() + 5 * 3_600_000).filed).toBe(0);
332352 expect(storedCapacity(app.db, "checks.concurrency")).toBe("4");
333353 const closed = app.db
334354 .query<{ close_reason: string }, []>("SELECT close_reason FROM issue WHERE title LIKE 'Sentinel: Setting checks.concurrency %'")
@@ -348,7 +368,7 @@ describe("the asks page", () => {
348368 test("a stranger, a bad answer and a foreign origin change nothing", async () => {
349369 reset();
350370 away("cells.egress", "report");
351 hunt(app, "alice/garden");
371 fileLegacy("alice/garden");
352372 await post(bob, { key: "cells.egress", answer: "put" });
353373 await post(alice, { key: "cells.egress", answer: "burn" });
354374 await post(alice, { key: "nope", answer: "put" });
@@ -361,7 +381,7 @@ describe("the asks page", () => {
361381 test("Put back changes nothing when no finding can be closed, and says why", async () => {
362382 reset();
363383 away("cells.egress", "report");
364 hunt(app, "alice/garden");
384 fileLegacy("alice/garden");
365385 const alice0 = getUserByName(app.db, "alice")!;
366386 const made = createOrigin(app.db, dataDir, {
367387 userId: alice0.id,
@@ -392,7 +412,7 @@ describe("the asks page", () => {
392412 test("Put back changes nothing when the close is refused for a reason other than a mirror", () => {
393413 reset();
394414 away("cells.egress", "report");
395 hunt(app, "alice/garden");
415 fileLegacy("alice/garden");
396416 // a pull cannot be closed with an issue's reason, so setIssueState refuses
397417 app.db.query("UPDATE issue SET kind = 'pull' WHERE title LIKE 'Sentinel: Setting cells.egress %'").run();
398418 const alice0 = getUserByName(app.db, "alice")!;
@@ -413,7 +433,7 @@ describe("the asks page", () => {
413433 const plot = await createRepo(app, carolUser, { name: "plot", visibility: "public" });
414434 if (!plot.ok) throw new Error(String(plot.error));
415435 away("cells.egress", "report");
416 hunt(app, "carol/plot");
436 fileLegacy("carol/plot");
417437 expect(app.db.query("SELECT COUNT(*) AS n FROM sentinel_finding WHERE key = 'setting:cells.egress'").get()).toEqual({ n: 1 });
418438 await post(carol, { key: "cells.egress", answer: "keep" });
419439 await post(carol, { key: "cells.egress", answer: "put" });
test/board.test.ts +7 −6
@@ -20,11 +20,10 @@ import { mergePull, openPull } from "../src/actions/pulls.ts";
2020 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
2121 import { getAppByRepo } from "../src/core/apps.ts";
2222 import { hunt } from "../src/crew/sentinel.ts";
23 import { setCapacity } from "../src/core/capacity.ts";
2423 import { recordDecision } from "../src/core/decisions.ts";
2524 import { recordLook } from "../src/core/looks.ts";
2625 import { getPull } from "../src/core/pulls.ts";
27 import { updateRepoSettings, type Repo } from "../src/core/repos.ts";
26 import { getRepo, updateRepoSettings, type Repo } from "../src/core/repos.ts";
2827 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
2928 import { submitReview } from "../src/core/reviews.ts";
3029 import { createUser, getUserByName, type User } from "../src/core/users.ts";
@@ -570,10 +569,12 @@ describe("the board's forge strip", () => {
570569 test("the sentinel's line is the admin's alone, and absent before the first hunt", async () => {
571570 expect(await board("root")).not.toContain("sentinel");
572571
573 setCapacity(app.db, "checks.concurrency", "4", admin.id);
572 // A check queued two hours with nothing running: a held slot, a finding.
573 const stuck = getRepo(app.db, "root", "stuck")!;
574 const queued = createRun(app.db, { repoId: stuck.id, kind: "check" });
574575 app.db
575 .query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'checks.concurrency'")
576 .run("2020-01-01T00:00:00.000Z");
576 .query("UPDATE run SET created_at = ?2 WHERE id = ?1")
577 .run(queued.id, new Date(Date.now() - 2 * 3_600_000).toISOString());
577578 process.env["FORGE_SENTINEL_REPO"] = "root/stuck";
578579 try {
579580 expect(hunt(app, "root/stuck").filed).toBeGreaterThan(0);
@@ -586,7 +587,7 @@ describe("the board's forge strip", () => {
586587 expect(guest).not.toMatch(/sentinel/i);
587588 } finally {
588589 delete process.env["FORGE_SENTINEL_REPO"];
589 setCapacity(app.db, "checks.concurrency", "1", admin.id);
590 app.db.query("DELETE FROM run WHERE id = ?1").run(queued.id);
590591 }
591592 });
592593 });
test/capacity-cpus.test.ts +7 −5
@@ -13,6 +13,8 @@ import { join } from "node:path";
1313 import { createApp } from "../src/app.ts";
1414 import {
1515 CAPACITY,
16 CAPACITY_KEYS,
17 bootDefault,
1618 capacityDefault,
1719 capacityEnv,
1820 capacityValue,
@@ -22,7 +24,7 @@ import {
2224 setCapacity,
2325 shardsForCpus,
2426 } from "../src/core/capacity.ts";
25 import { settingsAway } from "../src/crew/sentinel.ts";
27 import { changedOf } from "../src/actions/capacity.ts";
2628 import { createUser } from "../src/core/users.ts";
2729
2830 const ENV = ["FORGE_CHECK_SHARDS", "FORGE_CHECKS_CONCURRENCY"] as const;
@@ -70,7 +72,7 @@ describe("the defaults follow the CPUs", () => {
7072 });
7173 });
7274
73 describe("a stored value overrides the default; the sentinel compares with it", () => {
75 describe("a stored value overrides the default; the capacity page compares with it", () => {
7476 test("80 CPUs: 4 shards and 6 checks are the defaults, 8 shards is away", async () => {
7577 delete process.env.FORGE_CHECK_SHARDS;
7678 delete process.env.FORGE_CHECKS_CONCURRENCY;
@@ -98,15 +100,15 @@ describe("a stored value overrides the default; the sentinel compares with it",
98100 setCapacity(app.db, "crew.concurrency", "5", a.value.id);
99101 const old = new Date(Date.now() - 3 * 24 * 3600_000).toISOString();
100102 app.db.query("UPDATE forge_setting SET updated_at = ?1").run(old);
101 expect(settingsAway(app.db, Date.now()).map((f) => f.key)).toEqual(["setting:crew.concurrency"]);
103 expect(CAPACITY_KEYS.filter((k) => changedOf(app.db, k).changed !== undefined)).toEqual(["crew.concurrency"]);
102104
103105 // A stored value overrides the default, even a cell's smaller one.
104106 setCapacity(app.db, "checks.shards", "8", a.value.id);
105107 app.db.query("UPDATE forge_setting SET updated_at = ?1").run(old);
106108 expect(capacityValue(app.db, "checks.shards")).toBe("8");
107109 expect(capacityEnv(app.db, 2)).toEqual({ FORGE_CHECK_SHARDS: "8" });
108 const away = settingsAway(app.db, Date.now()).find((f) => f.key === "setting:checks.shards");
109 expect(away?.evidence[0]).toBe('checks.shards = "8", default "4"');
110 expect(changedOf(app.db, "checks.shards").changed?.by).toBe("alice");
111 expect(bootDefault(app.db, "checks.shards")).toBe("4");
110112 } finally {
111113 app.close();
112114 await rm(dataDir, { recursive: true, force: true });
test/capacity-crew.test.ts +4 −6
@@ -19,7 +19,7 @@ import {
1919 memoryLimitBytes,
2020 setCapacity,
2121 } from "../src/core/capacity.ts";
22 import { settingsAway } from "../src/crew/sentinel.ts";
22 import { changedOf } from "../src/actions/capacity.ts";
2323
2424 const GiB = 1024 ** 3;
2525
@@ -77,13 +77,13 @@ describe("the crew's default, and what overrides it", () => {
7777 expect(capacityValue(app.db, "crew.concurrency")).toBe(capacityDefault("crew.concurrency"));
7878 });
7979
80 test("the sentinel flags a value away from the memory default, not one at it", () => {
80 test("the capacity page marks a value away from the memory default, not one at it", () => {
8181 const now = Date.now();
8282 const age = () =>
8383 app.db
8484 .query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'crew.concurrency'")
8585 .run(new Date(now - 3 * 86_400_000).toISOString());
86 const crew = () => settingsAway(app.db, now).filter((f) => f.key === "setting:crew.concurrency");
86 const crew = () => (changedOf(app.db, "crew.concurrency").changed === undefined ? [] : ["changed"]);
8787 const fallback = capacityDefault("crew.concurrency");
8888 setCapacity(app.db, "crew.concurrency", fallback, "system");
8989 age();
@@ -91,8 +91,6 @@ describe("the crew's default, and what overrides it", () => {
9191 const other = fallback === "5" ? "6" : "5";
9292 setCapacity(app.db, "crew.concurrency", other, "system");
9393 age();
94 const found = crew();
95 expect(found).toHaveLength(1);
96 expect(found[0]!.evidence[0]).toBe(`crew.concurrency = "${other}", default "${fallback}"`);
94 expect(crew()).toEqual(["changed"]);
9795 });
9896 });
test/capacity.test.ts +21
@@ -21,6 +21,7 @@ import { enableApp } from "../src/actions/apps.ts";
2121 import { AppsRuntime } from "../src/apps/runtime.ts";
2222 import { startChecks, type ChecksHandle } from "../src/checks/service.ts";
2323 import {
24 CAPACITY,
2425 capacityChanges,
2526 capacityCount,
2627 capacityDefault,
@@ -393,6 +394,26 @@ describe("report mode is loud to admins, and to nobody else", () => {
393394 expect(api).not.toContain("gress");
394395 });
395396
397 test("away from its default: one muted line with Put back, escaped; none for a default (#3269)", async () => {
398 for (const k of ["checks.shards", "cells.egress", "checks.concurrency"]) changeCapacity(app, alice, k, null);
399 changeCapacity(app, alice, "crew.concurrency", null);
400 const other = capacityDefault("crew.concurrency") === "5" ? "6" : "5";
401 expect(changeCapacity(app, alice, "crew.concurrency", other).ok).toBe(true);
402 app.db.query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'crew.concurrency'").run(new Date(Date.now() - 3 * 86_400_000).toISOString());
403 app.db.query("UPDATE forge_setting SET updated_by = ?1 WHERE key = 'crew.concurrency'").run(bob.id);
404 app.db.query("UPDATE user SET username = ?2 WHERE id = ?1").run(bob.id, "bo<b>b");
405 const shown = (await page()).replace(/\s+/g, " ");
406 expect(shown).toContain(`changed from <span class="mono">${capacityDefault("crew.concurrency")}</span> 3 days ago by bo&lt;b&gt;b`);
407 expect(shown).not.toContain("bo<b>b");
408 expect(shown.match(/changed from/g)).toHaveLength(1);
409 expect(shown).toContain('<span class="muted small">changed from');
410 expect(shown).not.toContain('class="away"');
411 expect(shown).toContain(`Put ${CAPACITY["crew.concurrency"].label} back to the default`);
412 app.db.query("UPDATE user SET username = 'bob' WHERE id = ?1").run(bob.id);
413 expect(changeCapacity(app, alice, "crew.concurrency", null).ok).toBe(true);
414 expect(await page()).not.toContain("changed from");
415 });
416
396417 test("enforced: no warning anywhere", async () => {
397418 expect(changeCapacity(app, alice, "cells.egress", "enforce").ok).toBe(true);
398419 expect(await page()).not.toContain('id="egress-reported"');
test/sentinel.test.ts +26 −134
@@ -20,6 +20,7 @@ import { listEvents } from "../src/core/events.ts";
2020 import { getRepoById, type Repo } from "../src/core/repos.ts";
2121 import { createRun } from "../src/core/runs.ts";
2222 import { createUser, type User } from "../src/core/users.ts";
23 import { isSettingFinding } from "../src/crew/setting_finding.ts";
2324 import { recordCap } from "../src/crew/guard.ts";
2425 import {
2526 budgetBreaches,
@@ -194,7 +195,7 @@ describe("the faults of the day", () => {
194195 // 3. pushes that can only succeed by leaving the sandbox.
195196 for (let n = 0; n < 3; n++)
196197 plantRun("agent", "failed", "git push: operation not permitted", ago((20 + n) * HOUR));
197 // 4. a setting away from its default for a week.
198 // 4. a setting away from its default for a week: a line on the capacity page, no finding (#3269).
198199 setCapacity(app.db, "checks.concurrency", "4", alice.id);
199200 app.db
200201 .query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'checks.concurrency'")
@@ -212,7 +213,6 @@ describe("the faults of the day", () => {
212213 [
213214 `gate:${repo.id}`,
214215 `push:${repo.id}`,
215 "setting:checks.concurrency",
216216 "slot:check",
217217 "trigger:steward_guard_repo",
218218 ].sort(),
@@ -222,7 +222,7 @@ describe("the faults of the day", () => {
222222 expect(byKey.get(`gate:${repo.id}`)!.evidence.join("\n")).toContain(
223223 "nested cell: no network namespace",
224224 );
225 expect(byKey.get("setting:checks.concurrency")!.evidence[0]).toContain('"4", default "1"');
225 expect(byKey.has("setting:checks.concurrency")).toBe(false);
226226 expect(byKey.get("trigger:steward_guard_repo")!.evidence.join("\n")).toContain(
227227 "paused for new accounts",
228228 );
@@ -230,19 +230,19 @@ describe("the faults of the day", () => {
230230
231231 test("a hunt files each once, and a second hunt files nothing", () => {
232232 const first = hunt(app, "alice/forge", NOW);
233 expect(first.filed).toBe(5);
233 expect(first.filed).toBe(4);
234234 const open = () => listIssues(app.db, repo.id, { state: "open", limit: 200 });
235 expect(open().length).toBe(5);
235 expect(open().length).toBe(4);
236236 const slot = open().find((i) => i.title.includes("wait with no slot"))!;
237237 expect(slot.body).toContain("**Evidence**");
238238 expect(slot.body).toContain("**What to look for when fixed:**");
239239 expect(slot.body).toContain("**Pillar:**");
240240
241241 const second = hunt(app, "alice/forge", NOW + 30 * 60_000);
242 expect(second).toMatchObject({ found: 5, filed: 0, commented: 0 });
243 expect(open().length).toBe(5);
244 expect(sentinelStanding(app.db).openFindings).toBe(5);
245 expect(sentinelLine(app.db)).toBe("The sentinel has 5 open findings");
242 expect(second).toMatchObject({ found: 4, filed: 0, commented: 0 });
243 expect(open().length).toBe(4);
244 expect(sentinelStanding(app.db).openFindings).toBe(4);
245 expect(sentinelLine(app.db)).toBe("The sentinel has 4 open findings");
246246 });
247247
248248 test("a finding whose evidence changed is a comment, not a new issue", () => {
@@ -477,137 +477,29 @@ describe("the signals inside the hour", () => {
477477 }
478478 };
479479
480 test("a new setting away hunts after ten minutes; tracked, it does not", () => {
480 test("a setting away from its default brings no hunt and files nothing (#3269)", () => {
481481 setCapacity(app.db, "cells.egress", "report", alice.id);
482482 app.db
483483 .query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'cells.egress'")
484484 .run(ago(10 * 24 * HOUR));
485485 const l = lastHunt();
486 expect(withRepo(() => sentinelTick(app, l + 5 * 60_000))).toBeNull();
487 const r = withRepo(() => sentinelTick(app, l + 20 * 60_000));
488 expect(r?.filed).toBe(1);
489 expect(withRepo(() => sentinelTick(app, lastHunt() + 20 * 60_000))).toBeNull();
490 });
491
492 /** Close an issue the way the close path does: the state, and the event naming who. */
493 const closeBy = (issueId: string, actorId: string, at: string): void => {
494 app.db.query("UPDATE issue SET state = 'closed', closed_at = ?2 WHERE id = ?1").run(issueId, at);
495 const i = app.db.query<{ number: number; repo_id: string }, [string]>("SELECT number, repo_id FROM issue WHERE id = ?1").get(issueId)!;
496 app.bus.append({ kind: "issue.closed", repoId: i.repo_id, actorId, payload: { number: i.number } });
497 };
498 const planter = async (name: string): Promise<User> => {
499 const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase" });
500 if (!made.ok) throw new Error(made.error);
501 app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(made.value.id);
502 return made.value;
503 };
504
505 test("an agent's close does not keep a setting, and the migration takes back one it did (#2029)", async () => {
506 const bot = await planter("plannerbot");
507 app.db.query("DELETE FROM issue WHERE title LIKE 'Sentinel: Setting %'").run();
508 app.db.query("DELETE FROM sentinel_kept").run();
509 const base = Date.now();
510 setCapacity(app.db, "checks.concurrency", "4", alice.id);
511 const setAt = new Date(base - 10 * 24 * HOUR).toISOString();
512 app.db.query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'checks.concurrency'").run(setAt);
513 hunt(app, "alice/forge", base);
514 const issue = app.db.query<{ id: string }, []>("SELECT id FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Setting checks.concurrency %'").get()!;
515 closeBy(issue.id, bot.id, new Date(base + 1000).toISOString());
516 expect(hunt(app, "alice/forge", base + 2 * HOUR).filed).toBe(1);
517 expect(app.db.query("SELECT COUNT(*) AS n FROM sentinel_kept").get()).toEqual({ n: 0 });
518 // a row an agent's close made before this rule, beside one a person's close made
519 const plant = (key: string): void => {
520 app.db.query("INSERT INTO sentinel_kept (key, value, updated_at, kept_at) VALUES (?1, '4', ?2, ?2)").run(key, setAt);
521 };
522 plant("setting:checks.concurrency");
523 plant("setting:cells.egress");
524 const mine = app.db.query<{ id: string }, []>("SELECT id FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Setting checks.concurrency %'").get()!;
525 closeBy(mine.id, bot.id, new Date(base + 3000).toISOString());
526 setCapacity(app.db, "cells.egress", "report", alice.id);
527 app.db.query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'cells.egress'").run(setAt);
528 hunt(app, "alice/forge", base + 4 * HOUR);
529 const egress = app.db.query<{ id: string }, []>("SELECT id FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Setting cells.egress %'").get()!;
530 closeBy(egress.id, alice.id, new Date(base + 5000).toISOString());
531 app.db.exec(keptIsAPersons);
532 const left = app.db.query<{ key: string }, []>("SELECT key FROM sentinel_kept").all().map((r) => r.key);
533 expect(left).toEqual(["setting:cells.egress"]);
534 setCapacity(app.db, "checks.concurrency", "1", alice.id);
486 expect(withRepo(() => sentinelTick(app, l + 20 * 60_000))).toBeNull();
487 hunt(app, "alice/forge", l + 2 * HOUR);
488 expect(app.db.query("SELECT COUNT(*) AS n FROM sentinel_finding WHERE key LIKE 'setting:%'").get()).toEqual({ n: 0 });
489 expect(app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM issue WHERE title LIKE 'Sentinel: Setting %'").get()!.n).toBe(0);
535490 setCapacity(app.db, "cells.egress", "enforce", alice.id);
536491 });
537492
538 test("a setting kept on purpose is not refiled until its value changes (#2001)", () => {
539 const setAt = (value: string, at: string): void => {
540 setCapacity(app.db, "checks.concurrency", value, alice.id);
541 app.db.query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'checks.concurrency'").run(at);
542 };
543 const issuesFor = (): number =>
544 app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM issue WHERE title LIKE 'Sentinel: Setting checks.concurrency %'").get()!.n;
545 app.db.query("DELETE FROM issue WHERE title LIKE 'Sentinel: Setting %'").run();
546 app.db.query("DELETE FROM sentinel_kept").run();
547 app.db.query("DELETE FROM forge_setting WHERE key IN ('cells.egress', 'checks.concurrency')").run();
548 const base = Date.now();
549 setAt("4", new Date(base - 10 * 24 * HOUR).toISOString());
550 expect(hunt(app, "alice/forge", base).filed).toBe(1);
551 const first = app.db.query<{ id: string; body: string }, []>("SELECT id, body FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Setting checks.concurrency %'").get()!;
552 expect(first.body).toContain("Keep or Put back");
553 // closed with a reason: kept
554 closeBy(first.id, alice.id, new Date(base).toISOString());
555 const comments = listComments(app.db, first.id).length;
556 expect(hunt(app, "alice/forge", base + 2 * HOUR).filed).toBe(0);
557 expect(hunt(app, "alice/forge", base + 4 * HOUR).filed).toBe(0);
558 expect(issuesFor()).toBe(1);
559 expect(listComments(app.db, first.id).length).toBe(comments);
560 // the value changes, and later is away a day: reported again
561 setAt("6", new Date(base + 3 * HOUR).toISOString());
562 expect(hunt(app, "alice/forge", base + 6 * HOUR).filed).toBe(0); // not yet a day away
563 expect(hunt(app, "alice/forge", base + 30 * HOUR).filed).toBe(1);
564 expect(issuesFor()).toBe(2);
565 expect(app.db.query("SELECT value FROM forge_setting WHERE key = 'checks.concurrency'").get()).toEqual({ value: "6" });
566 app.db.query("DELETE FROM forge_setting WHERE key = 'checks.concurrency'").run();
567 });
568
569 test("a comment saying why a setting stays keeps it too (#2001)", async () => {
570 const owner = await createUser(app.db, {
571 origin: "seed",
572 trusted: true,
573 username: "carol",
574 email: "[email protected]",
575 password: "a-good-passphrase",
576 });
577 if (!owner.ok) throw new Error(owner.error);
578 app.db.query("DELETE FROM issue WHERE title LIKE 'Sentinel: Setting %'").run();
579 app.db.query("DELETE FROM sentinel_kept").run();
580 const base = Date.now();
581 setCapacity(app.db, "checks.concurrency", "4", alice.id);
582 app.db.query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'checks.concurrency'").run(new Date(base - 10 * 24 * HOUR).toISOString());
583 hunt(app, "alice/forge", base);
584 const issue = app.db.query<{ id: string; body: string }, []>("SELECT id, body FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Setting checks.concurrency %'").get()!;
585 const said = addComment(app.db, { issueId: issue.id, authorId: owner.value.id, body: "Kept on purpose: P0 wants it." });
586 expect(said.ok).toBe(true);
587 app.db.query("UPDATE comment SET created_at = ?2 WHERE issue_id = ?1").run(issue.id, new Date(base + 1000).toISOString());
588 const before = listComments(app.db, issue.id).length;
589 expect(hunt(app, "alice/forge", base + 2 * HOUR).filed).toBe(0);
590 expect(listComments(app.db, issue.id).length).toBe(before);
591 expect(app.db.query("SELECT value FROM sentinel_kept WHERE key = 'setting:checks.concurrency'").get()).toEqual({ value: "4" });
592 setCapacity(app.db, "checks.concurrency", "1", alice.id);
593 });
594
595 test("a comment from an agent does not keep a setting (#2001)", async () => {
596 app.db.query("DELETE FROM issue WHERE title LIKE 'Sentinel: Setting %'").run();
597 app.db.query("DELETE FROM sentinel_kept").run();
598 const base = Date.now();
599 setCapacity(app.db, "checks.concurrency", "4", alice.id);
600 app.db.query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'checks.concurrency'").run(new Date(base - 10 * 24 * HOUR).toISOString());
601 hunt(app, "alice/forge", base);
602 const issue = app.db.query<{ id: string }, []>("SELECT id FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Setting checks.concurrency %'").get()!;
603 const bot = await createUser(app.db, { origin: "seed", trusted: true, username: "reviewbot", email: "[email protected]", password: "a-good-passphrase" });
604 if (!bot.ok) throw new Error(bot.error);
605 app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(bot.value.id);
606 addComment(app.db, { issueId: issue.id, authorId: bot.value.id, body: "Looks fine to me." });
607 app.db.query("UPDATE comment SET created_at = ?2 WHERE issue_id = ?1").run(issue.id, new Date(base + 1000).toISOString());
608 hunt(app, "alice/forge", base + 2 * HOUR);
609 expect(app.db.query("SELECT COUNT(*) AS n FROM sentinel_kept").get()).toEqual({ n: 0 });
610 setCapacity(app.db, "checks.concurrency", "1", alice.id);
493 test("an open setting finding from before closes on the next pass, with a pointer to the capacity page (#3269)", () => {
494 const old = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: "Sentinel: Setting checks.concurrency has been away from its default for 9 days" });
495 if (!old.ok) throw new Error(String(old.error));
496 app.db.query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES ('setting:checks.concurrency', ?1, '', 'a', 'a')").run(old.value.id);
497 hunt(app, "alice/forge", Date.now());
498 const row = app.db.query<{ state: string; close_reason: string }, [string]>("SELECT state, close_reason FROM issue WHERE id = ?1").get(old.value.id)!;
499 expect(row).toEqual({ state: "closed", close_reason: "done" });
500 expect(listComments(app.db, old.value.id).some((c) => c.body.includes("/settings/capacity"))).toBe(true);
501 // closed ones are still known as setting findings
502 expect(isSettingFinding(app.db, old.value.id)).toBe(true);
611503 });
612504
613505 test("a newly held slot hunts too", () => {
@@ -890,7 +782,7 @@ describe("a degraded finding whose cause has stayed closed", () => {
890782 await rm(hdir, { recursive: true, force: true });
891783 });
892784
893 test("23 hours closed stays open; a person's comment and a setting keep theirs at 25", () => {
785 test("23 hours closed stays open; a person's comment keeps theirs at 25, and a setting finding closes", () => {
894786 // A setting finding, tracked as the sentinel would.
895787 const s = createIssue(happ.db, { repoId: hrepo.id, authorId: halice.id, title: "Sentinel: Setting x" });
896788 if (!s.ok) throw new Error(String(s.error));
@@ -909,7 +801,7 @@ describe("a degraded finding whose cause has stayed closed", () => {
909801 expect(said.length).toBe(1);
910802 expect(said[0]!.body).toContain(new Date(T0).toISOString());
911803 expect(state("disk")).toBe("open");
912 expect(state("Setting x")).toBe("open");
804 expect(state("Setting x")).toBe("closed");
913805 });
914806
915807 test("a finding that comes back is a new issue", () => {
test/tui-onyou.test.ts +18 −4
@@ -14,7 +14,7 @@ import { createRepo } from "../src/actions/repos.ts";
1414 import { signUp } from "../src/actions/users.ts";
1515 import { createApp, type App as Forge } from "../src/app.ts";
1616 import { chipsOf, setChip } from "../src/core/feed_chips.ts";
17 import { getIssue } from "../src/core/issues.ts";
17 import { createIssue, getIssue } from "../src/core/issues.ts";
1818 import { mutedRepos, recordInteraction } from "../src/core/interactions.ts";
1919 import { setCapacity, storedCapacity } from "../src/core/capacity.ts";
2020 import { UNCLOSABLE_WORDS } from "../src/actions/setting_ask.ts";
@@ -231,12 +231,26 @@ describe("terminal On you: mutes and many in one move", () => {
231231 h.stop();
232232 });
233233
234 // The sentinel files no setting now (#3269); one it filed before still waits for an answer.
235 const legacySetting = (key: string): void => {
236 const made = createIssue(forge.db, {
237 repoId: lamp.id,
238 authorId: alice.id,
239 title: `Sentinel: Setting ${key} has been away from its default for 10 days`,
240 body: `${key} is away`,
241 });
242 if (!made.ok) throw new Error(made.error);
243 forge.db
244 .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES (?1, ?2, '', 'a', 'a')")
245 .run(`setting:${key}`, made.value.id);
246 };
247
234248 test("a setting away from its default is Kept or Put back from the terminal", async () => {
235249 setCapacity(forge.db, "cells.egress", "report", alice.id);
236250 forge.db
237251 .query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'cells.egress'")
238252 .run(new Date(Date.now() - 10 * 86_400_000).toISOString());
239 hunt(forge, "alice/lamp");
253 legacySetting("cells.egress");
240254 const h = await boot();
241255 for (let i = 0; i < 20; i++) await h.press("j"); // the setting row is last, and the cursor stops there
242256 expect(h.text()).toContain("setting cells.egress");
@@ -247,7 +261,7 @@ describe("terminal On you: mutes and many in one move", () => {
247261 forge.db
248262 .query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'cells.egress'")
249263 .run(new Date(Date.now() - 10 * 86_400_000).toISOString());
250 hunt(forge, "alice/lamp", Date.now() + 3 * 3_600_000);
264 legacySetting("cells.egress");
251265 await h.press("r");
252266 for (let i = 0; i < 20; i++) await h.press("j"); // the setting row is last, and the cursor stops there
253267 await h.press("K");
@@ -263,7 +277,7 @@ describe("terminal On you: mutes and many in one move", () => {
263277 .run(new Date(Date.now() - 9 * 86_400_000).toISOString());
264278 forge.db.query("DELETE FROM sentinel_kept").run();
265279 forge.db.query("DELETE FROM sentinel_finding").run();
266 hunt(forge, "alice/lamp", Date.now() + 6 * 3_600_000);
280 legacySetting("cells.egress");
267281 const made = createOrigin(forge.db, dir, {
268282 userId: alice.id,
269283 kind: "github",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs