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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2029-a-setting-the-sentinel-finds-away-from-i → main 3 commits fork 8 threads · 8 unresolved
README.md +12
| @@ -652,6 +652,18 @@ one-day rule. The rule is mechanical: any close counts, with or without a | ||
| 652 | 652 | reason, so a bare close silences the setting until its value changes. Other |
| 653 | 653 | findings (`held`, `ledger`, `red`) are filed anew when they return. |
| 654 | 654 | |
| 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 | |
| 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 | ||
| 655 | 667 | - **Board:** a site admin's forge strip shows the open findings and the last |
| 656 | 668 | hunt; a guest or member sees nothing. |
| 657 | 669 | - **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 |
||
| 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 |
||
| 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 |
||
| 63 | const done = setIssueState(app, actor, repo, issue, "closed", { reason: answer === "keep" ? "dropped" : "done" }); | |
outdated
This is the write that silences the setting: |
||
| 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 { | ||
| 18 | 18 | |
| 19 | 19 | export interface WaitFacts { |
| 20 | 20 | /** 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"; | |
| 22 | 22 | /** Files stopping the pull from merging, as the last sweep found it. */ |
| 23 | 23 | readonly conflicts: number; |
| 24 | 24 | /** 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 { | ||
| 33 | 33 | } from "../core/agents.ts"; |
| 34 | 34 | import { pickBuilder, routeIssue } from "./route.ts"; |
| 35 | 35 | import { coveredByRedFix } from "./red_fix.ts"; |
| 36 | import { isSettingFinding } from "./setting_finding.ts"; | |
| 36 | 37 | import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts"; |
| 37 | 38 | import { |
| 38 | 39 | latestDecision, |
| @@ -367,7 +368,8 @@ export async function driveRepo( | ||
| 367 | 368 | !stalled(app, repo, r.issue) && |
| 368 | 369 | !atRunCap(app, repo, r.issue) && |
| 369 | 370 | 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), | |
| 371 | 373 | ); |
| 372 | 374 | if (slotted.length >= paced.slots) { |
| 373 | 375 | // 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[] { | ||
| 188 | 188 | `set by ${row.username ?? "unknown"} at ${row.updated_at}`, |
| 189 | 189 | ], |
| 190 | 190 | 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.`, | |
| 192 | 192 | kept: { value, updatedAt: row.updated_at }, |
| 193 | 193 | fix: `Put it back: clear ${key} on the capacity page (it is recorded and reversible).`, |
| 194 | 194 | }); |
| @@ -576,16 +576,26 @@ function settingKept(db: Database, f: Finding, actorId: string, at: string): boo | ||
| 576 | 576 | const decided = db |
| 577 | 577 | .query<{ n: number }, [string, string, string]>( |
| 578 | 578 | `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')) | |
| 580 | 585 | OR EXISTS (SELECT 1 FROM comment c WHERE c.issue_id = i.id AND c.author_id <> ?3 AND c.created_at >= ?2 |
| 581 | 586 | AND EXISTS (SELECT 1 FROM user u WHERE u.id = c.author_id AND u.is_agent = 0 AND u.kind = 'user')))`, |
| 582 | 587 | ) |
| 583 | 588 | .get(f.key, updatedAt, actorId); |
| 584 | 589 | 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 { | |
| 585 | 596 | db.query( |
| 586 | 597 | "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); | |
| 589 | 599 | } |
| 590 | 600 | |
| 591 | 601 | /** |
| @@ -653,7 +663,7 @@ export function hunt( | ||
| 653 | 663 | } |
| 654 | 664 | issueId = made.value.id; |
| 655 | 665 | filed++; |
Setting findings never set |
||
| 656 | if (f.urgent) handOver(app, repo, actor, made.value); | |
| 666 | if (f.urgent && f.kept === undefined) handOver(app, repo, actor, made.value); | |
Low. |
||
| 657 | 667 | } |
| 658 | 668 | app.db |
| 659 | 669 | .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 { | ||
| 220 | 220 | asksBulkSubmit, |
| 221 | 221 | asksBulkUndo, |
| 222 | 222 | asksDecide, |
| 223 | asksSetting, | |
| 223 | 224 | asksDecideUndo, |
| 224 | 225 | asksListen, |
| 225 | 226 | asksMute, |
| @@ -620,6 +621,7 @@ export function buildRouter(): Router<Handler> { | ||
| 620 | 621 | router.add("POST", "/asks/listen", asksListen); |
| 621 | 622 | router.add("POST", "/asks/mute", asksMute); |
| 622 | 623 | router.add("POST", "/asks/decide", asksDecide); |
| 624 | router.add("POST", "/asks/setting", asksSetting); | |
| 623 | 625 | router.add("POST", "/asks/decide/:id/undo", asksDecideUndo); |
| 624 | 626 | // What people asked of the crew, and what became of it (core/asked.ts). |
| 625 | 627 | 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"; | ||
| 26 | 26 | import { askLinesFor } from "../../web/studio/asks.ts"; |
| 27 | 27 | import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts"; |
| 28 | 28 | import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts"; |
| 29 | import { answerSetting, openFindings } from "../../actions/setting_ask.ts"; | |
| 29 | 30 | import { |
| 30 | 31 | applyAsksBulk, |
| 31 | 32 | latestUndoable, |
| @@ -102,6 +103,8 @@ interface OnYouItem { | ||
| 102 | 103 | readonly hand?: true; |
| 103 | 104 | /** A pull the crew handed to a person: closed, marked and opened like any row. */ |
| 104 | 105 | readonly over?: true; |
| 106 | /** A setting away from its default: the key the owner keeps or puts back (admins only). */ | |
| 107 | readonly setting?: string; | |
| 105 | 108 | /** What it waits for, and on whom: the word the web's asks row shows. */ |
| 106 | 109 | readonly waiting: WaitingFor; |
| 107 | 110 | } |
| @@ -252,6 +255,14 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 252 | 255 | ]; |
| 253 | 256 | const out: OnYouItem[] = []; |
| 254 | 257 | 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 | } | |
| 255 | 266 | for (const { id, flag } of wanted) { |
| 256 | 267 | if (seen.has(id) || have.some((item) => item.issue.id === id)) continue; |
| 257 | 268 | seen.add(id); |
| @@ -265,7 +276,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 265 | 276 | |
| 266 | 277 | /** The marked rows, or the one under the cursor when none is marked. */ |
| 267 | 278 | const targets = (): OnYouItem[] => { |
| 268 | const mine = onYou.filter((item) => item.hand !== true); | |
| 279 | const mine = onYou.filter((item) => item.hand !== true && item.setting === undefined); | |
| 269 | 280 | const picked = mine.filter((item) => marked.has(item.issue.id)); |
| 270 | 281 | const here = mine.find((item) => item === onYou[at("onyou").index]); |
| 271 | 282 | return picked.length > 0 ? picked : here === undefined ? [] : [here]; |
| @@ -372,6 +383,21 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 372 | 383 | }); |
| 373 | 384 | }; |
| 374 | 385 | |
| 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 | ||
| 375 | 401 | const undoHand = (): void => { |
| 376 | 402 | const id = lastHand; |
| 377 | 403 | if (id === undefined) { |
| @@ -531,6 +557,9 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 531 | 557 | ...(focus === "onyou" && onYou[at("onyou").index]?.hand === true |
| 532 | 558 | ? [binding(["D"], "let the decider")] |
| 533 | 559 | : []), |
| 560 | ...(focus === "onyou" && onYou[at("onyou").index]?.setting !== undefined | |
| 561 | ? [binding(["K"], "keep setting"), binding(["B"], "put back")] | |
| 562 | : []), | |
| 534 | 563 | ...(lastHand !== undefined ? [binding(["U"], "undo decider")] : []), |
| 535 | 564 | ...(focus === "onyou" && onYou.length > 0 |
| 536 | 565 | ? [ |
| @@ -669,7 +698,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 669 | 698 | } |
| 670 | 699 | if (focus === "onyou" && f.user !== undefined) { |
| 671 | 700 | 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) { | |
| 673 | 702 | if (!marked.delete(item.issue.id)) marked.add(item.issue.id); |
| 674 | 703 | return true; |
| 675 | 704 | } |
| @@ -677,6 +706,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 677 | 706 | if (e.key === "m") return clear("mute"), true; |
| 678 | 707 | if (e.key === "S") return shipSet(), true; |
| 679 | 708 | 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; | |
| 680 | 711 | if (e.key === "h") return hideAuthor(), true; |
| 681 | 712 | } |
| 682 | 713 | if (e.key === "u" && f.user) return undoSet(), true; |
| @@ -756,7 +787,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 756 | 787 | ? PULL_MARK |
| 757 | 788 | : ISSUE_MARK; |
| 758 | 789 | 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}`, | |
| 760 | 791 | meta: `${item.waiting.word} · on ${item.waiting.on}`, |
| 761 | 792 | }; |
| 762 | 793 | }), |
src/web/studio/asks.ts +108 −2
| @@ -13,6 +13,7 @@ import type { Degraded } from "../../kernel/vitals.ts"; | ||
| 13 | 13 | import { previewEnv } from "../../apps/data.ts"; |
| 14 | 14 | import { waitingFor, type WaitingFor } from "../../core/waiting_for.ts"; |
| 15 | 15 | import { crewForRepos } from "../../core/agents.ts"; |
| 16 | import { capacityDefault, isCapacityKey, storedCapacity } from "../../core/capacity.ts"; | |
| 16 | 17 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 17 | 18 | import { appsForRepos, getAppByRepo, listApps } from "../../core/apps.ts"; |
| 18 | 19 | import { latestDecision, stopStands } from "../../core/decisions.ts"; |
| @@ -105,7 +106,7 @@ export function writableAppsOf(ctx: Ctx, user: User | undefined) { | ||
| 105 | 106 | ); |
| 106 | 107 | } |
| 107 | 108 | |
| 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"; | |
| 109 | 110 | |
| 110 | 111 | /** Dollars as a person reads them: cents when small, whole when not. */ |
| 111 | 112 | const dollars = (n: number): string => (n < 10 ? n.toFixed(2) : n.toFixed(0)); |
| @@ -130,10 +131,21 @@ export interface Decision { | ||
| 130 | 131 | /** The preview pictured on a phone, when the forge has taken one. */ |
| 131 | 132 | readonly picture?: string | undefined; |
| 132 | 133 | readonly consequence: Consequence; |
| 134 | /** For "setting": the key, its value, its default, and who set it and when. */ | |
| 135 | readonly setting?: SettingAway | undefined; | |
| 133 | 136 | /** What it waits for, and on whom (`core/waiting_for.ts`). */ |
| 134 | 137 | readonly waiting: WaitingFor; |
| 135 | 138 | } |
| 136 | 139 | |
| 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 | ||
| 137 | 149 | /** What an ask asks, the answers it takes, and what happens if nobody gives one. */ |
| 138 | 150 | export interface AskWords { |
| 139 | 151 | readonly decision: string; |
| @@ -178,6 +190,11 @@ export const ASK_WORDS: Readonly<Record<DecisionKind, AskWords>> = { | ||
| 178 | 190 | options: ["Open it"], |
| 179 | 191 | ifNothing: "it stays assigned to you", |
| 180 | 192 | }, |
| 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 | }, | |
| 181 | 198 | paused: { |
| 182 | 199 | decision: "Raise today's ceiling?", |
| 183 | 200 | options: ["Raise it in settings"], |
| @@ -206,6 +223,7 @@ export const KIND_TIER: Readonly<Record<DecisionKind, Consequence>> = { | ||
| 206 | 223 | parked: { score: 40, why: "parked by the crew" }, |
| 207 | 224 | look: { score: 30, why: "green, waiting" }, |
| 208 | 225 | assigned: { score: 20, why: "assigned to you" }, |
| 226 | setting: { score: 35, why: "a setting away from its default needs your word" }, | |
| 209 | 227 | }; |
| 210 | 228 | |
| 211 | 229 | /** When a person last spoke on a conversation, or its start. */ |
| @@ -250,6 +268,8 @@ interface AskCandidate { | ||
| 250 | 268 | readonly decline?: { readonly runId: string; readonly words: string }; |
| 251 | 269 | /** For "handover": the reviews that asked for changes. */ |
| 252 | 270 | readonly handover?: Handover; |
| 271 | /** For "setting": what is away, and who moved it. */ | |
| 272 | readonly setting?: SettingAway; | |
| 253 | 273 | } |
| 254 | 274 | |
| 255 | 275 | /** |
| @@ -439,6 +459,10 @@ function askCandidatesFor( | ||
| 439 | 459 | who: "the planner", |
| 440 | 460 | decline: { runId: d.runId, words: d.words }, |
| 441 | 461 | }); |
| 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); | |
| 442 | 466 | // Open work assigned to the viewer that no ask above already covers: a |
| 443 | 467 | // pull that is an ask and assigned to them is one row, the ask's. |
| 444 | 468 | const taken = new Set(out.flatMap((a) => [a.pull?.id, a.issue?.id].filter((i) => i !== undefined))); |
| @@ -548,7 +572,7 @@ function consequencesOf( | ||
| 548 | 572 | /** The row an ask collapses into: one kind in one repository. */ |
| 549 | 573 | const groupId = (a: { kind: DecisionKind; repo: Repo; id: string }): string => |
| 550 | 574 | // 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}`; | |
| 552 | 576 | |
| 553 | 577 | /** |
| 554 | 578 | * 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 | ||
| 645 | 669 | }); |
| 646 | 670 | } |
| 647 | 671 | |
| 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 | ||
| 648 | 718 | /** Every candidate for the viewer, scored, dismissed or not. */ |
| 649 | 719 | function scoredFor( |
| 650 | 720 | db: Database, |
| @@ -720,6 +790,14 @@ export function endOf( | ||
| 720 | 790 | if (pull !== undefined && pull.state !== "open") return "acted"; |
| 721 | 791 | return getHold(db, what)?.heldBy === viewerId ? "acted" : "expired"; |
| 722 | 792 | } |
| 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 | } | |
| 723 | 801 | if (kind === "assigned") { |
| 724 | 802 | const issue = getIssueById(db, what); |
| 725 | 803 | return issue !== undefined && issue.state !== "open" ? "acted" : "expired"; |
| @@ -797,6 +875,8 @@ function lineOf(c: AskCandidate): string { | ||
| 797 | 875 | return `the crew parked #${c.issue!.number} after three failed runs`; |
| 798 | 876 | if (c.kind === "declined") |
| 799 | 877 | 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}"`; | |
| 800 | 880 | if (c.kind === "handover") |
| 801 | 881 | return `"${c.pull?.title ?? ""}" had three reviews asking for changes and waits for you`; |
| 802 | 882 | const title = c.pull?.title ?? ""; |
| @@ -826,6 +906,7 @@ export const ROW_PLURAL: Readonly<Record<DecisionKind, (n: number) => string>> = | ||
| 826 | 906 | handover: (n) => `${n} changes had three reviews asking for changes and wait for you`, |
| 827 | 907 | declined: (n) => `the planner declined ${n} times and waits for you`, |
| 828 | 908 | paused: (n) => `${n} pauses at the ceiling`, |
| 909 | setting: (n) => `${n} settings are away from their defaults`, | |
| 829 | 910 | assigned: (n) => `${n} things assigned to you`, |
| 830 | 911 | }; |
| 831 | 912 | |
| @@ -961,6 +1042,17 @@ export function decisionsFor( | ||
| 961 | 1042 | 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.`, |
| 962 | 1043 | short: `#${c.issue.number} parked after three failed runs`, |
| 963 | 1044 | }; |
| 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 | }; | |
| 964 | 1056 | if (c.kind === "declined" && c.issue && c.decline) |
| 965 | 1057 | return { |
| 966 | 1058 | kind: "declined", |
| @@ -1059,6 +1151,15 @@ export function decisionForm(d: Decision, back?: string): Html | null { | ||
| 1059 | 1151 | return html`<form ${own} action="/asks/decide"> |
| 1060 | 1152 | <input type="hidden" name="ask" value="${d.id}" /> |
| 1061 | 1153 | </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>`; | |
| 1062 | 1163 | if (pull === undefined) return null; |
| 1063 | 1164 | const base = repoBase(d.repo); |
| 1064 | 1165 | 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: | ||
| 1126 | 1227 | }), |
| 1127 | 1228 | others: [], |
| 1128 | 1229 | }; |
| 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 | }; | |
| 1129 | 1235 | if (d.kind === "declined" && d.issue) |
| 1130 | 1236 | return { |
| 1131 | 1237 | primary: btn("Answer", { |
src/web/studio/asks_page.ts +18
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { WaitingFor } from "../../core/waiting_for.ts"; |
| 11 | import { answerSetting } from "../../actions/setting_ask.ts"; | |
| 11 | 12 | import { |
| 12 | 13 | applyAsksBulk, |
| 13 | 14 | BULK_MAX, |
| @@ -169,6 +170,7 @@ const KIND_LABEL: Readonly<Record<Decision["kind"], string>> = { | ||
| 169 | 170 | declined: "Declined", |
| 170 | 171 | paused: "Paused", |
| 171 | 172 | assigned: "Assigned", |
| 173 | setting: "Setting away", | |
| 172 | 174 | }; |
| 173 | 175 | |
| 174 | 176 | /** |
| @@ -647,6 +649,22 @@ export async function asksDecide(ctx: Ctx): Promise<Response> { | ||
| 647 | 649 | return redirect(`/?decided=${done.value.id}#asks-bulk-result`); |
| 648 | 650 | } |
| 649 | 651 | |
| 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 | ||
| 650 | 668 | /** POST /asks/decide/:id/undo — put a set-aside card back on the board, within a day. */ |
| 651 | 669 | export async function asksDecideUndo(ctx: Ctx): Promise<Response> { |
| 652 | 670 | 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"; | ||
| 17 | 17 | import type { Repo } from "../src/core/repos.ts"; |
| 18 | 18 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; |
| 19 | 19 | import { getUserByName } from "../src/core/users.ts"; |
| 20 | import { markVerified } from "../src/core/verify.ts"; | |
| 20 | 21 | import type { Ctx } from "../src/http/context.ts"; |
| 21 | 22 | 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"; | |
| 23 | 27 | import { quietLogs } from "./helpers/globals.ts"; |
| 24 | 28 | |
| 25 | 29 | quietLogs(); |
| @@ -262,4 +266,110 @@ describe("the asks page", () => { | ||
| 262 | 266 | expect(card?.says).toContain("its tests passed"); |
| 263 | 267 | expect(card?.short).toContain("its tests passed"); |
| 264 | 268 | }); |
| 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 |
||
| 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 | }); | |
| 265 | 375 | }); |
test/crew-direction-autonomy.test.ts +20
| @@ -62,6 +62,7 @@ import { | ||
| 62 | 62 | import { struckOut } from "../src/crew/parks.ts"; |
| 63 | 63 | import { serve, type Servers } from "../src/http/server.ts"; |
| 64 | 64 | import { mapJson, marksFor } from "../src/web/pages/map.ts"; |
| 65 | import { listEvents } from "../src/core/events.ts"; | |
| 65 | 66 | import { quietLogs } from "./helpers/globals.ts"; |
| 66 | 67 | |
| 67 | 68 | quietLogs(); |
| @@ -166,6 +167,25 @@ afterAll(async () => { | ||
| 166 | 167 | await rm(dataDir, { recursive: true, force: true }); |
| 167 | 168 | }); |
| 168 | 169 | |
| 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 | ||
| 169 | 189 | describe("a reader's issue on an autonomous public repository", () => { |
| 170 | 190 | test("is never assigned, queued or ready, and the map says why", async () => { |
| 171 | 191 | const repo = await autonomous(); |
test/sentinel.test.ts +49 −2
| @@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 7 | 7 | import { mkdtemp, rm } from "node:fs/promises"; |
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | import keptIsAPersons from "../src/core/migrations/20261016120000-kept-is-a-person-s.ts"; | |
| 10 | 11 | import { quietLogs } from "./helpers/globals.ts"; |
| 11 | 12 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 13 | import { createRepo } from "../src/actions/repos.ts"; |
| @@ -386,6 +387,52 @@ describe("the signals inside the hour", () => { | ||
| 386 | 387 | expect(withRepo(() => sentinelTick(app, lastHunt() + 20 * 60_000))).toBeNull(); |
| 387 | 388 | }); |
| 388 | 389 | |
| 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 | ||
| 389 | 436 | test("a setting kept on purpose is not refiled until its value changes (#2001)", () => { |
| 390 | 437 | const setAt = (value: string, at: string): void => { |
| 391 | 438 | setCapacity(app.db, "checks.concurrency", value, alice.id); |
| @@ -400,9 +447,9 @@ describe("the signals inside the hour", () => { | ||
| 400 | 447 | setAt("4", new Date(base - 10 * 24 * HOUR).toISOString()); |
| 401 | 448 | expect(hunt(app, "alice/forge", base).filed).toBe(1); |
| 402 | 449 | 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"); | |
| 404 | 451 | // 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()); | |
| 406 | 453 | const comments = listComments(app.db, first.id).length; |
| 407 | 454 | expect(hunt(app, "alice/forge", base + 2 * HOUR).filed).toBe(0); |
| 408 | 455 | 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"; | ||
| 16 | 16 | import { chipsOf, setChip } from "../src/core/feed_chips.ts"; |
| 17 | 17 | import { getIssue } from "../src/core/issues.ts"; |
| 18 | 18 | 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"; | |
| 19 | 21 | import { markVerified } from "../src/core/verify.ts"; |
| 20 | 22 | import type { Repo } from "../src/core/repos.ts"; |
| 21 | 23 | import type { User } from "../src/core/users.ts"; |
| @@ -222,4 +224,29 @@ describe("terminal On you: mutes and many in one move", () => { | ||
| 222 | 224 | expect(h.text()).toContain("1 refused (bob/bobs#1: not a change)"); |
| 223 | 225 | h.stop(); |
| 224 | 226 | }); |
| 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 | }); | |
| 225 | 252 | }); |
"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.