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

A setting the sentinel finds away from its default is the owner's ask (Keep or Put back), never a crew run #2043

Merged opened this

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

Merged

scout/forge:scout/2029-a-setting-the-sentinel-finds-away-from-i → main 3 commits fork 8 threads · 8 unresolved

  • Merged. scout/2029-a-setting-the-sentinel-finds-away-from-i was merged into main as e1821fe.

Review

Sign in to review this change.
15 files changed +511 −15
README.md +12
@@ -652,6 +652,18 @@ one-day rule. The rule is mechanical: any close counts, with or without a
652652 reason, so a bare close silences the setting until its value changes. Other
653653 findings (`held`, `ledger`, `red`) are filed anew when they return.
654654
655 A setting finding is the site admin's ask, never a crew run: it is not
656 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`,
657 one card per key: the value, its default, who set it and when) and in the

"the dispatcher skips it": no code in this pull does that. Autonomy's ready filter still picks setting findings and assigns them to a builder. Either make this true, with a test, or remove it.

658 terminal's On you card, with two answers — **Keep** and **Put back**. On the
659 web they are `POST /asks/setting` (`key`, `answer=keep|put`, same-origin only);
660 in the terminal, `K` keeps and `B` puts back the row under the cursor. Only a
661 signed-in site admin can answer, whatever they can write: anyone else, and any
662 agent, gets `not_found`/`unauthorized` and nothing changes. Keep closes the
663 finding and records the value in `sentinel_kept`; Put back goes through the
664 capacity page's own door (`changeCapacity`: same validation, record, event and
665 reversal) and then closes the finding. Only a person's close keeps a setting.
666
655667 - **Board:** a site admin's forge strip shows the open findings and the last
656668 hunt; a guest or member sees nothing.
657669 - **Terminal:** press `ctrl+k`, run **Sentinel** — the same line as a toast.
src/actions/setting_ask.ts added +71
@@ -0,0 +1,71 @@
1 /**
2 * The owner's answer to a setting the sentinel found away from its default
3 * (#2029): Keep it, or Put it back. Both close the finding as a person, so
4 * the close is a person's on the record. Putting back goes through
5 * `changeCapacity`, the capacity page's own door: the same validation, the
6 * same record and event, the same reversal.
7 */
8
9 import type { App } from "../app.ts";
10 import { isCapacityKey, storedCapacity } from "../core/capacity.ts";
11 import { getIssueById } from "../core/issues.ts";
12 import { canWrite, getRepoById } from "../core/repos.ts";
13 import type { User } from "../core/users.ts";
14 import { recordKept } from "../crew/sentinel.ts";
15 import { err, ok, type Result } from "../kernel/result.ts";
16 import { changeCapacity } from "./capacity.ts";
17 import { setIssueState } from "./issues.ts";
18
19 export type SettingAnswer = "keep" | "put";
20
21 /** The open findings for `setting:<key>` in repositories this person writes. */
22 export function openFindings(app: App, actor: User, key: string): string[] {
23 return app.db
24 .query<{ issue_id: string }, [string]>(
25 `SELECT t.issue_id FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id
26 WHERE t.key = ?1 AND i.state = 'open' ORDER BY i.created_at`,
27 )
28 .all(`setting:${key}`)
29 .map((r) => r.issue_id)
30 .filter((id) => {
31 const issue = getIssueById(app.db, id);
32 const repo = issue && getRepoById(app.db, issue.repoId);
33 return issue !== undefined && repo !== undefined && canWrite(app.db, repo, actor);
34 });
35 }
36
37 export function answerSetting(
38 app: App,
39 actor: User | undefined,
40 key: unknown,
41 answer: unknown,
42 now = new Date(),
43 ): Result<{ key: string; answer: SettingAnswer }, "unauthorized" | "not_found" | "forbidden" | "invalid"> {
44 if (!actor || actor.kind === "agent") return err("unauthorized");

High: this only rejects agents. Any writer of the finding's repo can keep and silence a security setting such as cells.egress=report for good. Require actor.isAdmin here for both answers, and return not_found like actions/capacity.ts does.

45 if (!actor.isAdmin) return err("not_found");
46 if (typeof key !== "string" || !isCapacityKey(key)) return err("not_found");
47 if (answer !== "keep" && answer !== "put") return err("invalid");
48 const findings = openFindings(app, actor, key);
49 if (findings.length === 0) return err("not_found");
50 if (answer === "put") {
51 const put = changeCapacity(app, actor, key, null);

Low. Put back changes the capacity before any close is tried. If every close is then refused (e.g. a mirror), the setting has been put back but the caller sees forbidden, and the page says "only a forge admin puts a setting back", which is not true here. The finding also stays open. Fix: check that the close is allowed first (or close first), and only then call changeCapacity.

52 if (!put.ok) return err(put.error === "not_found" ? "forbidden" : "invalid");
53 }
54 const stored = storedCapacity(app.db, key);
55 const row = app.db
56 .query<{ updated_at: string }, [string]>("SELECT updated_at FROM forge_setting WHERE key = ?1")
57 .get(key);
58 let closed = 0;
59 for (const id of findings) {
60 const issue = getIssueById(app.db, id);
61 const repo = issue && getRepoById(app.db, issue.repoId);
62 if (issue === undefined || repo === undefined) continue;
outdated

The result is discarded. If the close is refused (a mirror, for example), the finding stays open, Keep still writes sentinel_kept, and the caller gets ok. Return an error when the close is refused, or at least record kept only for closes that went through.

63 const done = setIssueState(app, actor, repo, issue, "closed", { reason: answer === "keep" ? "dropped" : "done" });
outdated

This is the write that silences the setting: sentinel_kept means it is never reported again until the value changes. Today a non-admin reaches it. Once the admin check above is in, this write is only reachable by an admin.

64 if (done.ok) closed++;
65 }
66 // A kept value is recorded only when a close went through; otherwise the finding is still open.
67 if (closed === 0) return err("forbidden");
68 if (answer === "keep" && stored !== undefined && row !== null)
69 recordKept(app.db, `setting:${key}`, stored, row.updated_at, now.toISOString());
70 return ok({ key, answer });
71 }
src/core/migrations/20261016120000-kept-is-a-person-s.ts added +22
@@ -0,0 +1,22 @@
1 // Only a person's close or comment keeps a setting (#2029). A kept row made
2 // by an agent's close (the planner closed the `cells.egress` finding, #2021)
3 // is taken back, so the owner is asked again. A row whose finding was last
4 // closed by a person, or that a person commented on after the value was set,
5 // stays.
6 export default `
7 DELETE FROM sentinel_kept
8 WHERE EXISTS (
9 SELECT 1 FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id
10 WHERE t.key = sentinel_kept.key AND i.state = 'closed')
11 AND NOT EXISTS (
12 SELECT 1 FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id
13 WHERE t.key = sentinel_kept.key
14 AND ((SELECT e.actor_id FROM event e
15 WHERE e.kind = 'issue.closed' AND e.repo_id = i.repo_id
16 AND json_extract(e.payload, '$.number') = i.number
17 ORDER BY e.id DESC LIMIT 1)
18 IN (SELECT id FROM user WHERE is_agent = 0 AND kind = 'user')
19 OR EXISTS (SELECT 1 FROM comment c JOIN user u ON u.id = c.author_id
20 WHERE c.issue_id = i.id AND c.created_at >= sentinel_kept.updated_at
21 AND u.is_agent = 0 AND u.kind = 'user')));
22 `;
src/core/waiting_for.ts +1 −1
@@ -18,7 +18,7 @@ export interface WaitingFor {
1818
1919 export interface WaitFacts {
2020 /** The ask's kind (`DecisionKind`). */
21 readonly kind: "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned";
21 readonly kind: "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "setting";
2222 /** Files stopping the pull from merging, as the last sweep found it. */
2323 readonly conflicts: number;
2424 /** Whether the pull or issue the ask is about is assigned to the viewer. */
src/crew/autonomy.ts +3 −1
@@ -33,6 +33,7 @@ import {
3333 } from "../core/agents.ts";
3434 import { pickBuilder, routeIssue } from "./route.ts";
3535 import { coveredByRedFix } from "./red_fix.ts";
36 import { isSettingFinding } from "./setting_finding.ts";
3637 import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts";
3738 import {
3839 latestDecision,
@@ -367,7 +368,8 @@ export async function driveRepo(
367368 !stalled(app, repo, r.issue) &&
368369 !atRunCap(app, repo, r.issue) &&
369370 parkedCount(app, repo, r.issue.id) < PARK_STRIKES &&
370 !coveredByRedFix(app.db, repo.id, r.issue),
371 !coveredByRedFix(app.db, repo.id, r.issue) &&
372 !isSettingFinding(app.db, r.issue.id),
371373 );
372374 if (slotted.length >= paced.slots) {
373375 // The builders are busy; the planner can still refill the map, when
src/crew/sentinel.ts +15 −5
@@ -188,7 +188,7 @@ export function settingsAway(db: Database, now: number): Finding[] {
188188 `set by ${row.username ?? "unknown"} at ${row.updated_at}`,
189189 ],
190190 pillar: "Trust: a setting is a decision, not a leftover",
191 lookFor: `${key} is back at "${capacityDefault(key)}", or someone says why it stays: close this issue with a reason (or comment one; a bare close keeps it too) and the setting is kept, reported again only if its value changes.`,
191 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.`,
192192 kept: { value, updatedAt: row.updated_at },
193193 fix: `Put it back: clear ${key} on the capacity page (it is recorded and reversible).`,
194194 });
@@ -576,16 +576,26 @@ function settingKept(db: Database, f: Finding, actorId: string, at: string): boo
576576 const decided = db
577577 .query<{ n: number }, [string, string, string]>(
578578 `SELECT COUNT(*) AS n FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id
579 WHERE t.key = ?1 AND ((i.state = 'closed' AND i.closed_at >= ?2)
579 WHERE t.key = ?1 AND ((i.state = 'closed' AND i.closed_at >= ?2
580 AND (SELECT e.actor_id FROM event e
581 WHERE e.kind = 'issue.closed' AND e.repo_id = i.repo_id
582 AND json_extract(e.payload, '$.number') = i.number
583 ORDER BY e.id DESC LIMIT 1)
584 IN (SELECT id FROM user WHERE is_agent = 0 AND kind = 'user'))
580585 OR EXISTS (SELECT 1 FROM comment c WHERE c.issue_id = i.id AND c.author_id <> ?3 AND c.created_at >= ?2
581586 AND EXISTS (SELECT 1 FROM user u WHERE u.id = c.author_id AND u.is_agent = 0 AND u.kind = 'user')))`,
582587 )
583588 .get(f.key, updatedAt, actorId);
584589 if (decided === null || decided.n === 0) return false;
590 recordKept(db, f.key, value, updatedAt, at);
591 return true;
592 }
593
594 /** Remember that a person kept `key` as it stands: this value, set at `updatedAt`. */
595 export function recordKept(db: Database, key: string, value: string, updatedAt: string, at: string): void {
585596 db.query(
586597 "INSERT INTO sentinel_kept (key, value, updated_at, kept_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(key) DO UPDATE SET value = ?2, updated_at = ?3, kept_at = ?4",
587 ).run(f.key, value, updatedAt, at);
588 return true;
598 ).run(key, value, updatedAt, at);
589599 }
590600
591601 /**
@@ -653,7 +663,7 @@ export function hunt(
653663 }
654664 issueId = made.value.id;
655665 filed++;

Setting findings never set urgent (only the finding at line 430 does), so this guard changes nothing for them. The crew run comes from autonomy assigning a ready issue (crew/autonomy.ts:361-397), and setting findings are still ready there. Exclude them there instead.

656 if (f.urgent) handOver(app, repo, actor, made.value);
666 if (f.urgent && f.kept === undefined) handOver(app, repo, actor, made.value);

Low. kept is set only on setting findings, and those are never urgent, so f.kept === undefined is always true wherever f.urgent is. The guard does nothing. The real fix is isSettingFinding in autonomy. Drop this guard, or test a case it actually changes.

657667 }
658668 app.db
659669 .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES (?1, ?2, ?3, ?4, ?4)")
src/crew/setting_finding.ts added +18
@@ -0,0 +1,18 @@
1 /**
2 * A setting the sentinel found away from its default is the owner's ask
3 * (#2029), never a crew run. The finding is filed by a crew member, so the
4 * autonomy clock would otherwise count it ready and hand it to a builder.
5 */
6
7 import type { Database } from "bun:sqlite";
8
9 /** Whether this issue is the open finding of a `setting:<key>` the sentinel reported. */
10 export function isSettingFinding(db: Database, issueId: string): boolean {
11 return (
12 db
13 .query<{ n: number }, [string]>(
14 "SELECT 1 AS n FROM sentinel_finding WHERE issue_id = ?1 AND key LIKE 'setting:%'",
15 )
16 .get(issueId) !== null
17 );
18 }
src/http/server.ts +2
@@ -220,6 +220,7 @@ import {
220220 asksBulkSubmit,
221221 asksBulkUndo,
222222 asksDecide,
223 asksSetting,
223224 asksDecideUndo,
224225 asksListen,
225226 asksMute,
@@ -620,6 +621,7 @@ export function buildRouter(): Router<Handler> {
620621 router.add("POST", "/asks/listen", asksListen);
621622 router.add("POST", "/asks/mute", asksMute);
622623 router.add("POST", "/asks/decide", asksDecide);
624 router.add("POST", "/asks/setting", asksSetting);
623625 router.add("POST", "/asks/decide/:id/undo", asksDecideUndo);
624626 // What people asked of the crew, and what became of it (core/asked.ts).
625627 router.add("GET", "/asked", askedPage);
src/tui/screens/dashboard.ts +34 −3
@@ -26,6 +26,7 @@ import { getIssueById, listAssignedTo, type Issue } from "../../core/issues.ts";
2626 import { askLinesFor } from "../../web/studio/asks.ts";
2727 import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts";
2828 import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts";
29 import { answerSetting, openFindings } from "../../actions/setting_ask.ts";
2930 import {
3031 applyAsksBulk,
3132 latestUndoable,
@@ -102,6 +103,8 @@ interface OnYouItem {
102103 readonly hand?: true;
103104 /** A pull the crew handed to a person: closed, marked and opened like any row. */
104105 readonly over?: true;
106 /** A setting away from its default: the key the owner keeps or puts back (admins only). */
107 readonly setting?: string;
105108 /** What it waits for, and on whom: the word the web's asks row shows. */
106109 readonly waiting: WaitingFor;
107110 }
@@ -252,6 +255,14 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
252255 ];
253256 const out: OnYouItem[] = [];
254257 const seen = new Set<string>();
258 if (f.user.isAdmin)
259 for (const id of rows.filter((row) => row.kind === "setting").flatMap((row) => row.items)) {
260 const key = id.slice("setting:".length);
261 const issue = getIssueById(f.db, openFindings(f.app, f.user, key)[0] ?? "");
262 const repo = issue === undefined ? undefined : getRepoById(f.db, issue.repoId);
263 if (issue !== undefined && repo !== undefined)
264 out.push({ issue, repo, waiting: waitingOf(issue.id), setting: key });
265 }
255266 for (const { id, flag } of wanted) {
256267 if (seen.has(id) || have.some((item) => item.issue.id === id)) continue;
257268 seen.add(id);
@@ -265,7 +276,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
265276
266277 /** The marked rows, or the one under the cursor when none is marked. */
267278 const targets = (): OnYouItem[] => {
268 const mine = onYou.filter((item) => item.hand !== true);
279 const mine = onYou.filter((item) => item.hand !== true && item.setting === undefined);
269280 const picked = mine.filter((item) => marked.has(item.issue.id));
270281 const here = mine.find((item) => item === onYou[at("onyou").index]);
271282 return picked.length > 0 ? picked : here === undefined ? [] : [here];
@@ -372,6 +383,21 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
372383 });
373384 };
374385
386 /** Keep or Put back the setting under the cursor, through the action the web uses. */
387 const answerHere = (answer: "keep" | "put"): void => {
388 const here = onYou[at("onyou").index];
389 if (here?.setting === undefined) {
390 ctx.toast("only a setting card is kept or put back", "attn");
391 return;
392 }
393 const done = answerSetting(f.app, f.user, here.setting, answer);
394 reload();
395 ctx.toast(
396 done.ok ? (answer === "keep" ? "kept" : "put back") : `refused: ${done.error}`,
397 done.ok ? "success" : "danger",
398 );
399 };
400
375401 const undoHand = (): void => {
376402 const id = lastHand;
377403 if (id === undefined) {
@@ -531,6 +557,9 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
531557 ...(focus === "onyou" && onYou[at("onyou").index]?.hand === true
532558 ? [binding(["D"], "let the decider")]
533559 : []),
560 ...(focus === "onyou" && onYou[at("onyou").index]?.setting !== undefined
561 ? [binding(["K"], "keep setting"), binding(["B"], "put back")]
562 : []),
534563 ...(lastHand !== undefined ? [binding(["U"], "undo decider")] : []),
535564 ...(focus === "onyou" && onYou.length > 0
536565 ? [
@@ -669,7 +698,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
669698 }
670699 if (focus === "onyou" && f.user !== undefined) {
671700 const item = onYou[at("onyou").index];
672 if (e.key === "space" && item !== undefined && item.hand !== true) {
701 if (e.key === "space" && item !== undefined && item.hand !== true && item.setting === undefined) {
673702 if (!marked.delete(item.issue.id)) marked.add(item.issue.id);
674703 return true;
675704 }
@@ -677,6 +706,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
677706 if (e.key === "m") return clear("mute"), true;
678707 if (e.key === "S") return shipSet(), true;
679708 if (e.key === "D" && item !== undefined) return handToDecider(), true;
709 if (e.key === "K") return answerHere("keep"), true;
710 if (e.key === "B") return answerHere("put"), true;
680711 if (e.key === "h") return hideAuthor(), true;
681712 }
682713 if (e.key === "u" && f.user) return undoSet(), true;
@@ -756,7 +787,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
756787 ? PULL_MARK
757788 : ISSUE_MARK;
758789 return {
759 title: `${marked.has(item.issue.id) ? "[x] " : ""}${item.hand === true ? "parked " : item.over === true ? "handed over " : ""}${mark} #${item.issue.number} ${item.issue.title} · ${item.repo.ownerName}/${item.repo.name}`,
790 title: `${marked.has(item.issue.id) ? "[x] " : ""}${item.setting !== undefined ? `setting ${item.setting} · K keep · B put back · ` : ""}${item.hand === true ? "parked " : item.over === true ? "handed over " : ""}${mark} #${item.issue.number} ${item.issue.title} · ${item.repo.ownerName}/${item.repo.name}`,
760791 meta: `${item.waiting.word} · on ${item.waiting.on}`,
761792 };
762793 }),
src/web/studio/asks.ts +108 −2
@@ -13,6 +13,7 @@ import type { Degraded } from "../../kernel/vitals.ts";
1313 import { previewEnv } from "../../apps/data.ts";
1414 import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts";
1515 import { crewForRepos } from "../../core/agents.ts";
16 import { capacityDefault, isCapacityKey, storedCapacity } from "../../core/capacity.ts";
1617 import { appsRuntime } from "../../apps/runtime.ts";
1718 import { appsForRepos, getAppByRepo, listApps } from "../../core/apps.ts";
1819 import { latestDecision, stopStands } from "../../core/decisions.ts";
@@ -105,7 +106,7 @@ export function writableAppsOf(ctx: Ctx, user: User | undefined) {
105106 );
106107 }
107108
108 export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned";
109 export type DecisionKind = "stopped" | "look" | "review" | "paused" | "parked" | "declined" | "handover" | "assigned" | "setting";
109110
110111 /** Dollars as a person reads them: cents when small, whole when not. */
111112 const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0));
@@ -130,10 +131,21 @@ export interface Decision {
130131 /** The preview pictured on a phone, when the forge has taken one. */
131132 readonly picture?: string | undefined;
132133 readonly consequence: Consequence;
134 /** For "setting": the key, its value, its default, and who set it and when. */
135 readonly setting?: SettingAway | undefined;
133136 /** What it waits for, and on whom (`core/waiting_for.ts`). */
134137 readonly waiting: WaitingFor;
135138 }
136139
140 /** A capacity setting away from its default, as the sentinel's finding reports it. */
141 export interface SettingAway {
142 readonly key: string;
143 readonly value: string;
144 readonly default: string;
145 readonly by: string;
146 readonly at: string;
147 }
148
137149 /** What an ask asks, the answers it takes, and what happens if nobody gives one. */
138150 export interface AskWords {
139151 readonly decision: string;
@@ -178,6 +190,11 @@ export const ASK_WORDS: Readonly<Record<DecisionKind, AskWords>> = {
178190 options: ["Open it"],
179191 ifNothing: "it stays assigned to you",
180192 },
193 setting: {
194 decision: "A setting is away from its default: keep it, or put it back?",
195 options: ["Keep", "Put back"],
196 ifNothing: "the setting stays as it is, and this stays here",
197 },
181198 paused: {
182199 decision: "Raise today's ceiling?",
183200 options: ["Raise it in settings"],
@@ -206,6 +223,7 @@ export const KIND_TIER: Readonly<Record<DecisionKind, Consequence>> = {
206223 parked: { score: 40, why: "parked by the crew" },
207224 look: { score: 30, why: "green, waiting" },
208225 assigned: { score: 20, why: "assigned to you" },
226 setting: { score: 35, why: "a setting away from its default needs your word" },
209227 };
210228
211229 /** When a person last spoke on a conversation, or its start. */
@@ -250,6 +268,8 @@ interface AskCandidate {
250268 readonly decline?: { readonly runId: string; readonly words: string };
251269 /** For "handover": the reviews that asked for changes. */
252270 readonly handover?: Handover;
271 /** For "setting": what is away, and who moved it. */
272 readonly setting?: SettingAway;
253273 }
254274
255275 /**
@@ -439,6 +459,10 @@ function askCandidatesFor(
439459 who: "the planner",
440460 decline: { runId: d.runId, words: d.words },
441461 });
462 // A setting the sentinel found away from its default is the owner's to keep
463 // or put back, never a crew run: one ask per key, from its newest open finding.
464 if (user.isAdmin)
465 for (const f of settingFindings(db, repos)) out.push(f);
442466 // Open work assigned to the viewer that no ask above already covers: a
443467 // pull that is an ask and assigned to them is one row, the ask's.
444468 const taken = new Set(out.flatMap((a) => [a.pull?.id, a.issue?.id].filter((i) => i !== undefined)));
@@ -548,7 +572,7 @@ function consequencesOf(
548572 /** The row an ask collapses into: one kind in one repository. */
549573 const groupId = (a: { kind: DecisionKind; repo: Repo; id: string }): string =>
550574 // Work assigned to you is each its own row: they share no decision.
551 a.kind === "assigned" ? a.id : `${a.kind}:${a.repo.id}`;
575 a.kind === "assigned" || a.kind === "setting" ? a.id : `${a.kind}:${a.repo.id}`;
552576
553577 /**
554578 * The asks the viewer has not set aside: a muted repository's asks are all
@@ -645,6 +669,52 @@ function waitingOf(db: Database, user: User | undefined, a: AskCandidate): Waiti
645669 });
646670 }
647671
672 /** One candidate per capacity key still away whose finding is open in these repositories. */
673 function settingFindings(db: Database, repos: readonly Repo[]): AskCandidate[] {
674 if (repos.length === 0) return [];
675 const byId = new Map(repos.map((r) => [r.id, r]));
676 const seen = new Set<string>();
677 const out: AskCandidate[] = [];
678 const rows = db
679 .query<{ key: string; issue_id: string }, [string]>(
680 `SELECT t.key, t.issue_id FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id
681 WHERE t.key LIKE 'setting:%' AND i.state = 'open' AND i.repo_id IN (SELECT value FROM json_each(?1))
682 ORDER BY i.created_at DESC`,
683 )
684 .all(JSON.stringify([...byId.keys()]));
685 for (const r of rows) {
686 const key = r.key.slice("setting:".length);
687 if (seen.has(key) || !isCapacityKey(key)) continue;
688 const value = storedCapacity(db, key);
689 if (value === undefined || value === capacityDefault(key)) continue;
690 const issue = getIssueById(db, r.issue_id);
691 const repo = issue && byId.get(issue.repoId);
692 if (issue === undefined || repo === undefined) continue;
693 seen.add(key);
694 const set = db
695 .query<{ updated_at: string; username: string | null }, [string]>(
696 "SELECT s.updated_at, u.username FROM forge_setting s LEFT JOIN user u ON u.id = s.updated_by WHERE s.key = ?1",
697 )
698 .get(key);
699 out.push({
700 kind: "setting",
701 id: `setting:${key}`,
702 since: issue.createdAt,
703 repo,
704 issue,
705 who: "the sentinel",
706 setting: {
707 key,
708 value,
709 default: capacityDefault(key),
710 by: set?.username ?? "unknown",
711 at: set?.updated_at ?? "",
712 },
713 });
714 }
715 return out;
716 }
717
648718 /** Every candidate for the viewer, scored, dismissed or not. */
649719 function scoredFor(
650720 db: Database,
@@ -720,6 +790,14 @@ export function endOf(
720790 if (pull !== undefined && pull.state !== "open") return "acted";
721791 return getHold(db, what)?.heldBy === viewerId ? "acted" : "expired";
722792 }
793 if (kind === "setting") {
794 const open = db
795 .query<{ n: number }, [string]>(
796 "SELECT COUNT(*) AS n FROM sentinel_finding t JOIN issue i ON i.id = t.issue_id WHERE t.key = ?1 AND i.state = 'open'",
797 )
798 .get(`setting:${what}`);
799 return open !== null && open.n > 0 ? "expired" : "acted";
800 }
723801 if (kind === "assigned") {
724802 const issue = getIssueById(db, what);
725803 return issue !== undefined && issue.state !== "open" ? "acted" : "expired";
@@ -797,6 +875,8 @@ function lineOf(c: AskCandidate): string {
797875 return `the crew parked #${c.issue!.number} after three failed runs`;
798876 if (c.kind === "declined")
799877 return "the planner declined the direction and waits for you";
878 if (c.kind === "setting" && c.setting)
879 return `${c.setting.key} is "${c.setting.value}", not its default "${c.setting.default}"`;
800880 if (c.kind === "handover")
801881 return `"${c.pull?.title ?? ""}" had three reviews asking for changes and waits for you`;
802882 const title = c.pull?.title ?? "";
@@ -826,6 +906,7 @@ export const ROW_PLURAL: Readonly<Record<DecisionKind, (n: number) => string>> =
826906 handover: (n) => `${n} changes had three reviews asking for changes and wait for you`,
827907 declined: (n) => `the planner declined ${n} times and waits for you`,
828908 paused: (n) => `${n} pauses at the ceiling`,
909 setting: (n) => `${n} settings are away from their defaults`,
829910 assigned: (n) => `${n} things assigned to you`,
830911 };
831912
@@ -961,6 +1042,17 @@ export function decisionsFor(
9611042 says: `Three runs on #${c.issue.number} failed or were declined within an hour, so the crew parked it. Say what to try and it starts again.`,
9621043 short: `#${c.issue.number} parked after three failed runs`,
9631044 };
1045 if (c.kind === "setting" && c.issue && c.setting)
1046 return {
1047 kind: "setting",
1048 id: `setting:${c.setting.key}`,
1049 repo: c.repo,
1050 issue: c.issue,
1051 who: c.who,
1052 setting: c.setting,
1053 says: `${c.setting.key} is "${c.setting.value}" and its default is "${c.setting.default}". ${c.setting.by} set it at ${c.setting.at}. Keep it, or put it back (recorded, and reversible on the capacity page).`,
1054 short: `${c.setting.key} is "${c.setting.value}", default "${c.setting.default}", set by ${c.setting.by}`,
1055 };
9641056 if (c.kind === "declined" && c.issue && c.decline)
9651057 return {
9661058 kind: "declined",
@@ -1059,6 +1151,15 @@ export function decisionForm(d: Decision, back?: string): Html | null {
10591151 return html`<form ${own} action="/asks/decide">
10601152 <input type="hidden" name="ask" value="${d.id}" />
10611153 </form>`;
1154 if (d.kind === "setting" && d.setting)
1155 return html`<form ${own}-keep action="/asks/setting">
1156 <input type="hidden" name="key" value="${d.setting.key}" />
1157 <input type="hidden" name="answer" value="keep" />
1158 </form>
1159 <form ${own}-put action="/asks/setting">
1160 <input type="hidden" name="key" value="${d.setting.key}" />
1161 <input type="hidden" name="answer" value="put" />
1162 </form>`;
10621163 if (pull === undefined) return null;
10631164 const base = repoBase(d.repo);
10641165 const where = back === undefined ? null : html`<input type="hidden" name="back" value="${back}" />`;
@@ -1126,6 +1227,11 @@ export function decisionButtons(ctx: Ctx, d: Decision): { primary: Html; others:
11261227 }),
11271228 others: [],
11281229 };
1230 if (d.kind === "setting" && d.setting)
1231 return {
1232 primary: btn("Keep", { size: "sm", form: `${askFormId(d)}-keep`, variant: "primary" }),
1233 others: [btn("Put back", { size: "sm", form: `${askFormId(d)}-put` })],
1234 };
11291235 if (d.kind === "declined" && d.issue)
11301236 return {
11311237 primary: btn("Answer", {
src/web/studio/asks_page.ts +18
@@ -8,6 +8,7 @@
88 */
99
1010 import type { WaitingFor } from "../../core/waiting_for.ts";
11 import { answerSetting } from "../../actions/setting_ask.ts";
1112 import {
1213 applyAsksBulk,
1314 BULK_MAX,
@@ -169,6 +170,7 @@ const KIND_LABEL: Readonly<Record<Decision["kind"], string>> = {
169170 declined: "Declined",
170171 paused: "Paused",
171172 assigned: "Assigned",
173 setting: "Setting away",
172174 };
173175
174176 /**
@@ -647,6 +649,22 @@ export async function asksDecide(ctx: Ctx): Promise<Response> {
647649 return redirect(`/?decided=${done.value.id}#asks-bulk-result`);
648650 }
649651
652 /** POST /asks/setting — the owner's Keep or Put back on a setting away from its default. */
653 export async function asksSetting(ctx: Ctx): Promise<Response> {
654 if (!ctx.auth.user) return signInFirst(ctx);
655 const form = await readForm(ctx, "rw");
656 if (form instanceof Response) return form;
657 const done = answerSetting(ctx.app, ctx.auth.user, field(form, "key"), field(form, "answer"));
658 if (!done.ok) {
659 const why: Record<string, string> = {
660 forbidden: "only a forge admin puts a setting back",
661 invalid: "that answer was not Keep or Put back",
662 };
663 return await asksScreen(ctx, nothingDone(why[done.error] ?? "it no longer asks you"));
664 }
665 return redirect("/");
666 }
667
650668 /** POST /asks/decide/:id/undo — put a set-aside card back on the board, within a day. */
651669 export async function asksDecideUndo(ctx: Ctx): Promise<Response> {
652670 if (!ctx.auth.user) return signInFirst(ctx);
test/asks.test.ts +111 −1
@@ -17,9 +17,13 @@ import { recordDecision, resolveOpen } from "../src/core/decisions.ts";
1717 import type { Repo } from "../src/core/repos.ts";
1818 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
1919 import { getUserByName } from "../src/core/users.ts";
20 import { markVerified } from "../src/core/verify.ts";
2021 import type { Ctx } from "../src/http/context.ts";
2122 import { serve, type Servers } from "../src/http/server.ts";
22 import { decisionsFor } from "../src/web/studio/asks.ts";
23 import { setCapacity, storedCapacity, capacityChanges, capacityDefault } from "../src/core/capacity.ts";
24 import { listEvents } from "../src/core/events.ts";
25 import { hunt } from "../src/crew/sentinel.ts";
26 import { ASK_WORDS, decisionsFor } from "../src/web/studio/asks.ts";
2327 import { quietLogs } from "./helpers/globals.ts";
2428
2529 quietLogs();
@@ -262,4 +266,110 @@ describe("the asks page", () => {
262266 expect(card?.says).toContain("its tests passed");
263267 expect(card?.short).toContain("its tests passed");
264268 });
269 describe("a setting away from its default (#2029)", () => {
270 const away = (key: "checks.concurrency" | "cells.egress", value: string): void => {
271 setCapacity(app.db, key, value, getUserByName(app.db, "alice")!.id);
272 app.db
273 .query("UPDATE forge_setting SET updated_at = ?2 WHERE key = ?1")
274 .run(key, new Date(Date.now() - 10 * 86_400_000).toISOString());
275 };
276 const post = (cookie: string, fields: Record<string, string>, origin = servers.url) =>
277 fetch(`${servers.url}/asks/setting`, {
278 method: "POST",
279 headers: { cookie, origin },
280 body: new URLSearchParams(fields),
281 redirect: "manual",
282 });
283 const settingCards = (): string[] =>
284 decisionsFor({ app, auth: { user: getUserByName(app.db, "alice")! } } as unknown as Ctx, [repo])
285 .filter((d) => d.kind === "setting")
286 .map((d) => d.id);
287 const reset = (): void => {
288 app.db.query("DELETE FROM forge_setting").run();
289 app.db.query("DELETE FROM sentinel_kept").run();
290 app.db.query("DELETE FROM sentinel_finding").run();
291 app.db.query("DELETE FROM issue WHERE title LIKE 'Sentinel: %'").run();
292 };
293
294 test("one ask per key, with the key, value, default, who and when; the crew is not dispatched", async () => {
295 reset();
296 away("checks.concurrency", "4");
297 away("cells.egress", "report");
298 hunt(app, "alice/garden");
299 hunt(app, "alice/garden", Date.now() + 3 * 3_600_000);
300 expect(settingCards().sort()).toEqual(["setting:cells.egress", "setting:checks.concurrency"]);
301 const card = decisionsFor({ app, auth: { user: getUserByName(app.db, "alice")! } } as unknown as Ctx, [repo]).find(
302 (d) => d.id === "setting:checks.concurrency",
303 )!;
304 expect(card.says).toContain('"4"');
305 expect(card.says).toContain("alice");
306 expect(card.says).toContain("checks.concurrency");
307 expect(ASK_WORDS.setting.options).toEqual(["Keep", "Put back"]);
308 const page = await get(alice, "/");
309 expect(page).toContain("Keep");
310 expect(page).toContain("Put back");
311 // not for a person who cannot put it back
312 expect(await get(bob, "/")).not.toContain("Put back");
313 expect(listEvents(app.db, { kind: "issue.assigned" }).length).toBe(0);
314 });
315
316 test("Keep closes the finding as kept, and it stays quiet", async () => {
317 const res = await post(alice, { key: "checks.concurrency", answer: "keep" });
318 expect(res.status).toBe(303);
319 expect(settingCards()).toEqual(["setting:cells.egress"]);
320 expect(app.db.query("SELECT value FROM sentinel_kept WHERE key = 'setting:checks.concurrency'").get()).toEqual({ value: "4" });
321 expect(hunt(app, "alice/garden", Date.now() + 5 * 3_600_000).filed).toBe(0);
322 expect(storedCapacity(app.db, "checks.concurrency")).toBe("4");
323 const closed = app.db
324 .query<{ close_reason: string }, []>("SELECT close_reason FROM issue WHERE title LIKE 'Sentinel: Setting checks.concurrency %'")
325 .get();
326 expect(closed?.close_reason).toBe("dropped");
327 });
328
329 test("Put back clears it on the capacity path, recorded, and closes the finding", async () => {
330 const res = await post(alice, { key: "cells.egress", answer: "put" });
331 expect(res.status).toBe(303);
332 expect(storedCapacity(app.db, "cells.egress")).toBeUndefined();
333 expect(capacityChanges(app.db).some((c) => c.key === "cells.egress" && c.to === capacityDefault("cells.egress"))).toBe(true);
334 expect(listEvents(app.db, { kind: "capacity.changed" }).some((e) => e.payload["key"] === "cells.egress")).toBe(true);
335 expect(settingCards()).toEqual([]);
336 });
337
338 test("a stranger, a bad answer and a foreign origin change nothing", async () => {
339 reset();
340 away("cells.egress", "report");
outdated

bob cannot write alice/garden, so this misses the real hole. Add a collaborator who is not an admin and assert that keep changes nothing.

341 hunt(app, "alice/garden");
342 await post(bob, { key: "cells.egress", answer: "put" });
343 await post(alice, { key: "cells.egress", answer: "burn" });
344 await post(alice, { key: "nope", answer: "put" });
345 await post(alice, { key: "cells.egress", answer: "put" }, "https://evil.example");
346 expect(storedCapacity(app.db, "cells.egress")).toBe("report");
347 expect(settingCards()).toEqual(["setting:cells.egress"]);
348 reset();
349 });
350
351 test("a writer of the finding's repo who is not an admin cannot Keep or Put back", async () => {
352 reset();
353 const carol = await signUp("carol");
354 const carolUser = getUserByName(app.db, "carol")!;
355 markVerified(app.db, carolUser.id, new Date().toISOString());
356 const plot = await createRepo(app, carolUser, { name: "plot", visibility: "public" });
357 if (!plot.ok) throw new Error(String(plot.error));
358 away("cells.egress", "report");
359 hunt(app, "carol/plot");
360 expect(app.db.query("SELECT COUNT(*) AS n FROM sentinel_finding WHERE key = 'setting:cells.egress'").get()).toEqual({ n: 1 });
361 await post(carol, { key: "cells.egress", answer: "keep" });
362 await post(carol, { key: "cells.egress", answer: "put" });
363 expect(app.db.query("SELECT COUNT(*) AS n FROM sentinel_kept").get()).toEqual({ n: 0 });
364 expect(storedCapacity(app.db, "cells.egress")).toBe("report");
365 const open = app.db
366 .query("SELECT COUNT(*) AS n FROM issue WHERE title LIKE 'Sentinel: Setting cells.egress %' AND state = 'open'")
367 .get();
368 expect(open).toEqual({ n: 1 });
369 // the admin still can
370 await post(alice, { key: "cells.egress", answer: "keep" });
371 expect(app.db.query("SELECT COUNT(*) AS n FROM sentinel_kept").get()).toEqual({ n: 1 });
372 reset();
373 });
374 });
265375 });
test/crew-direction-autonomy.test.ts +20
@@ -62,6 +62,7 @@ import {
6262 import { struckOut } from "../src/crew/parks.ts";
6363 import { serve, type Servers } from "../src/http/server.ts";
6464 import { mapJson, marksFor } from "../src/web/pages/map.ts";
65 import { listEvents } from "../src/core/events.ts";
6566 import { quietLogs } from "./helpers/globals.ts";
6667
6768 quietLogs();
@@ -166,6 +167,25 @@ afterAll(async () => {
166167 await rm(dataDir, { recursive: true, force: true });
167168 });
168169
170 describe("a setting finding on an autonomous repository (#2029)", () => {
171 test("is the owner's ask: the autonomy clock never assigns it and no run starts", async () => {
172 const repo = await autonomous();
173 const finding = ok(openIssue(app, alice, repo, { title: "Sentinel: Setting cells.egress is away from its default" }));
174 app.db
175 .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES ('setting:cells.egress', ?1, '', ?2, ?2)")
176 .run(finding.id, new Date().toISOString());
177 const other = ok(openIssue(app, alice, repo, { title: "Round the corners" }));
178 const before = listEvents(app.db, { kind: "issue.assigned" }).filter((e) => e.payload["issue"] === finding.id).length;
179 for (let tick = 0; tick < 3; tick++) await driveRepo(app, fresh(repo));
180 const after = getIssue(app.db, repo.id, finding.number)!;
181 expect(after.assigneeId).toBeNull();
182 expect(runsOn(repo, after)).toEqual([]);
183 expect(listEvents(app.db, { kind: "issue.assigned" }).filter((e) => e.payload["issue"] === finding.id).length).toBe(before);
184 // the crew still works what is not an ask
185 expect(getIssue(app.db, repo.id, other.number)!.assigneeId).not.toBeNull();
186 });
187 });
188
169189 describe("a reader's issue on an autonomous public repository", () => {
170190 test("is never assigned, queued or ready, and the map says why", async () => {
171191 const repo = await autonomous();
test/sentinel.test.ts +49 −2
@@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
77 import { mkdtemp, rm } from "node:fs/promises";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
10 import keptIsAPersons from "../src/core/migrations/20261016120000-kept-is-a-person-s.ts";
1011 import { quietLogs } from "./helpers/globals.ts";
1112 import { createApp, type App } from "../src/app.ts";
1213 import { createRepo } from "../src/actions/repos.ts";
@@ -386,6 +387,52 @@ describe("the signals inside the hour", () => {
386387 expect(withRepo(() => sentinelTick(app, lastHunt() + 20 * 60_000))).toBeNull();
387388 });
388389
390 /** Close an issue the way the close path does: the state, and the event naming who. */
391 const closeBy = (issueId: string, actorId: string, at: string): void => {
392 app.db.query("UPDATE issue SET state = 'closed', closed_at = ?2 WHERE id = ?1").run(issueId, at);
393 const i = app.db.query<{ number: number; repo_id: string }, [string]>("SELECT number, repo_id FROM issue WHERE id = ?1").get(issueId)!;
394 app.bus.append({ kind: "issue.closed", repoId: i.repo_id, actorId, payload: { number: i.number } });
395 };
396 const planter = async (name: string): Promise<User> => {
397 const made = await createUser(app.db, { trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase" });
398 if (!made.ok) throw new Error(made.error);
399 app.db.query("UPDATE user SET is_agent = 1 WHERE id = ?1").run(made.value.id);
400 return made.value;
401 };
402
403 test("an agent's close does not keep a setting, and the migration takes back one it did (#2029)", async () => {
404 const bot = await planter("plannerbot");
405 app.db.query("DELETE FROM issue WHERE title LIKE 'Sentinel: Setting %'").run();
406 app.db.query("DELETE FROM sentinel_kept").run();
407 const base = Date.now();
408 setCapacity(app.db, "checks.concurrency", "4", alice.id);
409 const setAt = new Date(base - 10 * 24 * HOUR).toISOString();
410 app.db.query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'checks.concurrency'").run(setAt);
411 hunt(app, "alice/forge", base);
412 const issue = app.db.query<{ id: string }, []>("SELECT id FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Setting checks.concurrency %'").get()!;
413 closeBy(issue.id, bot.id, new Date(base + 1000).toISOString());
414 expect(hunt(app, "alice/forge", base + 2 * HOUR).filed).toBe(1);
415 expect(app.db.query("SELECT COUNT(*) AS n FROM sentinel_kept").get()).toEqual({ n: 0 });
416 // a row an agent's close made before this rule, beside one a person's close made
417 const plant = (key: string): void => {
418 app.db.query("INSERT INTO sentinel_kept (key, value, updated_at, kept_at) VALUES (?1, '4', ?2, ?2)").run(key, setAt);
419 };
420 plant("setting:checks.concurrency");
421 plant("setting:cells.egress");
422 const mine = app.db.query<{ id: string }, []>("SELECT id FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Setting checks.concurrency %'").get()!;
423 closeBy(mine.id, bot.id, new Date(base + 3000).toISOString());
424 setCapacity(app.db, "cells.egress", "report", alice.id);
425 app.db.query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'cells.egress'").run(setAt);
426 hunt(app, "alice/forge", base + 4 * HOUR);
427 const egress = app.db.query<{ id: string }, []>("SELECT id FROM issue WHERE state = 'open' AND title LIKE 'Sentinel: Setting cells.egress %'").get()!;
428 closeBy(egress.id, alice.id, new Date(base + 5000).toISOString());
429 app.db.exec(keptIsAPersons);
430 const left = app.db.query<{ key: string }, []>("SELECT key FROM sentinel_kept").all().map((r) => r.key);
431 expect(left).toEqual(["setting:cells.egress"]);
432 setCapacity(app.db, "checks.concurrency", "1", alice.id);
433 setCapacity(app.db, "cells.egress", "enforce", alice.id);
434 });
435
389436 test("a setting kept on purpose is not refiled until its value changes (#2001)", () => {
390437 const setAt = (value: string, at: string): void => {
391438 setCapacity(app.db, "checks.concurrency", value, alice.id);
@@ -400,9 +447,9 @@ describe("the signals inside the hour", () => {
400447 setAt("4", new Date(base - 10 * 24 * HOUR).toISOString());
401448 expect(hunt(app, "alice/forge", base).filed).toBe(1);
402449 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()!;
403 expect(first.body).toContain("close this issue with a reason");
450 expect(first.body).toContain("Keep or Put back");
404451 // closed with a reason: kept
405 app.db.query("UPDATE issue SET state = 'closed', closed_at = ?2 WHERE id = ?1").run(first.id, new Date(base).toISOString());
452 closeBy(first.id, alice.id, new Date(base).toISOString());
406453 const comments = listComments(app.db, first.id).length;
407454 expect(hunt(app, "alice/forge", base + 2 * HOUR).filed).toBe(0);
408455 expect(hunt(app, "alice/forge", base + 4 * HOUR).filed).toBe(0);
test/tui-onyou.test.ts +27
@@ -16,6 +16,8 @@ import { createApp, type App as Forge } from "../src/app.ts";
1616 import { chipsOf, setChip } from "../src/core/feed_chips.ts";
1717 import { getIssue } from "../src/core/issues.ts";
1818 import { mutedRepos, recordInteraction } from "../src/core/interactions.ts";
19 import { setCapacity, storedCapacity } from "../src/core/capacity.ts";
20 import { hunt } from "../src/crew/sentinel.ts";
1921 import { markVerified } from "../src/core/verify.ts";
2022 import type { Repo } from "../src/core/repos.ts";
2123 import type { User } from "../src/core/users.ts";
@@ -222,4 +224,29 @@ describe("terminal On you: mutes and many in one move", () => {
222224 expect(h.text()).toContain("1 refused (bob/bobs#1: not a change)");
223225 h.stop();
224226 });
227
228 test("a setting away from its default is Kept or Put back from the terminal", async () => {
229 setCapacity(forge.db, "cells.egress", "report", alice.id);
230 forge.db
231 .query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'cells.egress'")
232 .run(new Date(Date.now() - 10 * 86_400_000).toISOString());
233 hunt(forge, "alice/lamp");
234 const h = await boot();
235 for (let i = 0; i < 20; i++) await h.press("j"); // the setting row is last, and the cursor stops there
236 expect(h.text()).toContain("setting cells.egress");
237 await h.press("B");
238 expect(storedCapacity(forge.db, "cells.egress")).toBeUndefined();
239 expect(h.text()).not.toContain("setting cells.egress");
240 setCapacity(forge.db, "cells.egress", "report", alice.id);
241 forge.db
242 .query("UPDATE forge_setting SET updated_at = ?1 WHERE key = 'cells.egress'")
243 .run(new Date(Date.now() - 10 * 86_400_000).toISOString());
244 hunt(forge, "alice/lamp", Date.now() + 3 * 3_600_000);
245 await h.press("r");
246 for (let i = 0; i < 20; i++) await h.press("j"); // the setting row is last, and the cursor stops there
247 await h.press("K");
248 expect(storedCapacity(forge.db, "cells.egress")).toBe("report");
249 expect(h.text()).not.toContain("setting cells.egress");
250 h.stop();
251 });
225252 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs