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

The clog line offers to turn "flow only" on, and the forge turns it off itself after the alarms stay clear for 24 h #7394

Merged opened this

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

Merged

scout/forge:scout/6395-the-clog-line-offers-to-turn-flow-only-o → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/6395-the-clog-line-offers-to-turn-flow-only-o was merged into main as d4de74f.

Review

Sign in to review this change.
13 files changed +361 −8
README.md +1 −1
@@ -388,7 +388,7 @@ This page is the front door. Everything else is a file of its own under
388388 - [`docs/WEBSPEC-PREVIEWS.md`](docs/WEBSPEC-PREVIEWS.md) — the website specification run over every preview, report-only.
389389 - [`docs/CREW.md`](docs/CREW.md) — the crew: agents, how a run works, review, live triage, the merge queue, spend, roles and invites, the sandbox, passports, and the direction a vision pull will write.
390390 - [`docs/SENTINEL.md`](docs/SENTINEL.md) — the forge hunting its own faults; [`docs/HUNT.md`](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`).
391 - Flow only: a repository's admin (a person, never an agent) sets one switch from Settings → On its own, `PATCH /api/v1/repos/:owner/:name` with `flowOnly`, MCP `repo_settings`, or `w` on the terminal's About card (`I`); turning it on makes the `flow` label. While it is on, the repository header and its admin's home say so in one line (the line links to the setting for an admin only; on the home it appears under "Show everything", not the lean apps-first view). While it is on, builders start only `flow` issues; assigning any other issue is recorded but starts no run and reads "queued: flow only" (web, terminal, API and MCP `issue`) until it gets the `flow` label or the switch goes off, and then it dispatches. Rebase, re-check, red-fix and review runs always go.
391 - Flow only: a repository's admin (a person, never an agent) sets one switch from Settings → On its own, `PATCH /api/v1/repos/:owner/:name` with `flowOnly`, MCP `repo_settings`, or `w` on the terminal's About card (`I`); turning it on makes the `flow` label. While it is on, the repository header and its admin's home say so in one line (the line links to the setting for an admin only; on the home it appears under "Show everything", not the lean apps-first view). While it is on, builders start only `flow` issues; assigning any other issue is recorded but starts no run and reads "queued: flow only" (web, terminal, API and MCP `issue`) until it gets the `flow` label or the switch goes off, and then it dispatches. Rebase, re-check, red-fix and review runs always go. On the forge's own repository, while it is off, each admin's home carries one **Flow only** button after the flow alarms (it posts to the setting's route and lands on the setting, where it turns off again); and once it is on and no flow alarm has stood for 24 h (the alarms the home shows, with the same crew ceiling) the minute's tick turns it off, once, with one feed item for admins; the terminal's About row reads "on, off after 24 h clear". The auto-off applies to the forge's own repository only (`src/flow/flow_only_auto.ts`).
392392 - Outsiders directing the crew: the terminal's About card (`I`) has a Crew row saying whether people who cannot write may direct the crew (the web page's words). A repository admin who is a person presses `d` to see the plan, then enter to confirm (esc changes nothing); it calls `setCrewOutsiders`, as MCP `outsiders` does. The key is `d`, not `o`, because `o` is the fork's upstream offer on the same card and `d` never clashes with it; a writer who is not an admin and an agent are not offered `d`.
393393 - [`docs/FLOW.md`](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h.
394394 - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [`docs/FLOW.md`](docs/FLOW.md).
src/core/event_audience.ts +2
@@ -156,6 +156,8 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
156156 kind.startsWith("backup.") ||
157157 // A clog is the whole forge's flow, the owner's to hear (`flow/clog_tick.ts`).
158158 kind.startsWith("clog.") ||
159 // The forge letting go of FLOW ONLY after 24 h clear is the admins' to hear (`flow/flow_only_auto.ts`).
160 kind === "flow_only.cleared" ||
159161 // A report from outside waits for a person who decides; its arrival is
160162 // theirs to see, never the readers' (`actions/reports.ts`).
161163 kind.startsWith("report.") ||
src/core/migration-pins/20261027130000-flow-only-alarm.pin added +1
@@ -0,0 +1,1 @@
1 c229078000fd44afbf57673a3bdac0bfbc7f83af30a7ff3dce58f86661b60db4
src/core/migrations/20261027130000-flow-only-alarm.ts added +8
@@ -0,0 +1,8 @@
1 // When a flow alarm last stood while a repository's FLOW ONLY switch was on
2 // (#6395), epoch ms: 24 h after it the forge turns the switch off itself.
3 export default `
4 CREATE TABLE flow_only_watch (
5 repo_id TEXT PRIMARY KEY REFERENCES repo(id) ON DELETE CASCADE,
6 alarm_at INTEGER NOT NULL
7 );
8 `;
src/feed/items.ts +5
@@ -695,6 +695,11 @@ function fromEvent(
695695 what: said === "" ? `replied on “${on}”` : `on “${on}”: ${clip(said)}`,
696696 };
697697 }
698 case "flow_only.cleared": {
699 const what = typeof p["note"] === "string" ? p["note"] : "";
700 if (what === "") return undefined;
701 return { ...common, kind: "map", link: `${base(repo)}/settings/crew#flow-only`, what, said: what };

The flow_only.cleared item reuses kind "map" and puts the note in both what and said, so the feed will show the sentence twice. Check that it renders once, and add a test on the rendered line.

702 }
698703 case "clog.cleared": {
699704 const note = typeof p["note"] === "string" ? p["note"] : "";
700705 if (note === "") return undefined;
src/flow/clog_tick.ts +22
@@ -11,7 +11,9 @@ import { listEvents, type EventBus } from "../core/events.ts";
1111 import { getRepo } from "../core/repos.ts";
1212 import { createLog } from "../kernel/log.ts";
1313 import { ownRepoPath } from "../kernel/own_repo.ts";
14 import { firedAlarms } from "./alarms.ts";
1415 import { CLOG_APPROVED_HOURS, clogOf } from "./clog.ts";
16 import { stepFlowOnly } from "./flow_only_auto.ts";
1517 import { CLOG_NOTE_WINDOW_MS, clogNote, flattenNote } from "./clog_note.ts";
1618 import { clogStep, type ClogEvent, type ClogState } from "./clog_watch.ts";
1719 import { readOldestApproved } from "./oldest_approved.ts";
@@ -87,10 +89,13 @@ export function startClogTick(
8789 bus: Pick<EventBus, "append">,
8890 onError: (error: unknown) => void,
8991 everyMs = 60_000,
92 onFlowOnlyOff: () => void = () => {},
93 ceilingUsd: () => number | null = () => null,
9094 ): () => void {
9195 const timer = setInterval(() => {
9296 try {
9397 tickClog(db, bus);
98 if (tickFlowOnly(db, bus, undefined, ceilingUsd())) onFlowOnlyOff();
9499 } catch (error) {
95100 onError(error);
96101 }
@@ -139,3 +144,20 @@ export function tickClog(db: Database, bus: Pick<EventBus, "append">, now = new
139144 return { event: step.event };
140145 }).immediate();
141146 }
147
148 /**
149 * The own repository's FLOW ONLY watch, one step. It counts the alarms the home
150 * shows, so it reads them with the same crew ceiling (`cachedAlarms`); without it
151 * an idle-crew alarm the ceiling silences would hold the switch on.
152 * true when the forge turned the switch off. */
153 export function tickFlowOnly(
outdated

firedAlarms(db, now) runs without the cost ceiling that cachedAlarms passes on the home, so the two disagree on which alarms stand. Pass the same ceiling.

154 db: Database,
155 bus: Pick<EventBus, "append">,
156 now = new Date(),
157 ceilingUsd: number | null = null,
158 ): boolean {
159 const [owner, name] = ownRepoPath().split("/");
160 const repo = owner && name ? getRepo(db, owner, name) : undefined;
161 if (!repo) return false;
162 return db.transaction(() => stepFlowOnly(db, bus, repo, repo.flowOnly && firedAlarms(db, now, { ceilingUsd }).length > 0, now.getTime())).immediate();
163 }
src/flow/flow_only_auto.ts added +42
@@ -0,0 +1,42 @@
1 /**
2 * FLOW ONLY lets go by itself (#6395): once the switch is on and no flow alarm
3 * has fired for 24 h, the minute's tick turns it off and says so on the feed,
4 * once. The last alarm's time lives in `flow_only_watch`; a switch found on
5 * with no record starts its 24 h then. Plain data and a clock in, one bool out.
6 */
7 import type { Database } from "bun:sqlite";
8 import type { EventBus } from "../core/events.ts";
9 import { getRepoById, updateRepoSettings, type Repo } from "../core/repos.ts";
10
11 export const FLOW_ONLY_CLEAR_MS = 24 * 3_600_000;
12
13 /** The words on the feed when the forge lets go. */
14 export const FLOW_ONLY_OFF_NOTE = "No flow alarm for 24 h: Flow only is off again.";
15
16 /** Steps the watch for `repo`; true when it turned the switch off this call. */
17 export function stepFlowOnly(
18 db: Database,
19 bus: Pick<EventBus, "append">,
20 repo: Repo,
21 alarming: boolean,
22 now: number,
23 ): boolean {
24 const fresh = getRepoById(db, repo.id);
25 if (!fresh?.flowOnly) {
26 db.query("DELETE FROM flow_only_watch WHERE repo_id = ?1").run(repo.id);
27 return false;
28 }
29 const row = db.query<{ alarm_at: number }, [string]>("SELECT alarm_at FROM flow_only_watch WHERE repo_id = ?1").get(repo.id);
30 if (alarming || !row) {
31 db.query(
32 `INSERT INTO flow_only_watch (repo_id, alarm_at) VALUES (?1, ?2)
33 ON CONFLICT (repo_id) DO UPDATE SET alarm_at = ?2`,
34 ).run(repo.id, now);
35 return false;
36 }
37 if (now - row.alarm_at < FLOW_ONLY_CLEAR_MS) return false;
38 updateRepoSettings(db, fresh, { flowOnly: false });
39 db.query("DELETE FROM flow_only_watch WHERE repo_id = ?1").run(repo.id);
40 bus.append({ kind: "flow_only.cleared", repoId: repo.id, payload: { note: FLOW_ONLY_OFF_NOTE } });
41 return true;
42 }
src/main.ts +3 −2
@@ -48,7 +48,8 @@ import { failAbandonedRuns } from "./core/runs.ts";
4848 import { DRAIN_LINGER_MAX_S, drainLingerMs, startDraining } from "./http/health.ts";
4949 import { takePresence, warmBehind } from "./apps/presence.ts";
5050 import { STALE_AFTER_MS } from "./runs/lease.ts";
51 import { startCrew } from "./crew/dispatch.ts";
51 import { forgeCeiling } from "./flow/ceiling.ts";
52 import { startCrew, sweepAssigned } from "./crew/dispatch.ts";
5253 import { CREW_OFF_IN_PREVIEW, crewOffInPreview } from "./crew/preview.ts";
5354 import { typeSafeDecider } from "./crew/decide.ts";
5455 import { graphInstagram } from "./crew/instagram.ts";
@@ -582,7 +583,7 @@ const processTick = (): void => {
582583 }
583584 };
584585 // The clog watch (#6524, #6757) reads the flow every minute; its state is the database's.
585 const stopClogTick = startClogTick(app.db, app.bus, (error) => app.log.warn("clog tick failed", { error: String(error) }));
586 const stopClogTick = startClogTick(app.db, app.bus, (error) => app.log.warn("clog tick failed", { error: String(error) }), 60_000, () => sweepAssigned(app), () => forgeCeiling(app.config));
586587 // An agent's hold that has stood 24 h is released each minute (#6918).
587588 const holdTimer = setInterval(() => {
588589 try {
src/tui/screens/about.ts +7 −2
@@ -15,6 +15,7 @@
1515 * card is an OSC 8 hyperlink besides.
1616 */
1717
18 import { ownRepoPath } from "../../kernel/own_repo.ts";
1819 import { audienceFor } from "../../core/audience.ts";
1920 import { isPinned } from "../../core/pins.ts";
2021 import { parentReleases, releasesSentence } from "../../core/parent_releases.ts";
@@ -485,10 +486,14 @@ export function upstreamRows(
485486 ];
486487 }
487488
488 /** The FLOW ONLY switch, in the settings page's words; every reader sees it. */
489 /**
490 * The FLOW ONLY switch, in the settings page's words; every reader sees it. On
491 * the forge's own repository it also says the forge lets go by itself (#6395).
492 */
489493 export function flowRow(repo: Repo | undefined): [string, string][] {
490494 if (repo === undefined) return [];
491 return [["Flow only", repo.flowOnly ? "on" : "off"]];
495 const own = ownRepoPath() === `${repo.ownerName}/${repo.name}`;
496 return [["Flow only", repo.flowOnly ? (own ? "on, off after 24 h clear" : "on") : "off"]];
492497 }
493498
494499 /** Who may direct the crew, in `OUTSIDERS_NOTE`'s words; every reader sees it. */
src/web/studio/alarm_lines.ts +26 −1
@@ -8,7 +8,11 @@ import type { Database } from "bun:sqlite";
88 import { firedAlarms, type FiredAlarm } from "../../flow/alarms.ts";
99 import { writeStamp } from "../../core/write_clock.ts";
1010 import { Stamped } from "../../kernel/stamped.ts";
11 import { canAdmin, getRepo } from "../../core/repos.ts";
12 import type { User } from "../../core/users.ts";
13 import { ownRepoPath } from "../../kernel/own_repo.ts";
1114 import { html, type Html } from "../html.ts";
15 import { btn } from "../ui.ts";
1216 import { HOME_MAX_AGE_MS } from "./home_cache.ts";
1317
1418 const caches = new WeakMap<Database, Stamped<FiredAlarm[]>>();
@@ -25,9 +29,30 @@ export function cachedAlarms(db: Database, now: Date, ceilingUsd: number | null)
2529 return cache.get("alarms", stamp, () => firedAlarms(db, now, { ceilingUsd }));
2630 }
2731
28 export function alarmLines(alarms: readonly FiredAlarm[]): Html {
32 /**
33 * `offerFor` is the repository path whose FLOW ONLY switch is off and the
34 * viewer administers (#6395): one button follows the block, a post to the same
35 * switch the setting uses, so it is same-origin, admin-only and undoable there.
36 * One for the block, not one per line, so a screen reader hears it once; the
37 * post lands on the setting, where it is turned off again, and the title says so.
38 */
39 export function alarmLines(alarms: readonly FiredAlarm[], offerFor?: string): Html {
2940 if (alarms.length === 0) return html``;
41 const offer =
42 offerFor === undefined
43 ? null
44 : html`<form method="post" action="/${offerFor}/settings/flow-only" class="inline" title="Turns Flow only on and opens its setting">
45 <input type="hidden" name="flowOnly" value="1" />${btn("Flow only", { size: "sm" })}
46 </form>`;
3047 return html`<div class="stack" role="alert">
outdated

One form per alarm line repeats the same button name for a screen reader. Use one button for the block, or add an aria-label that says which alarm. The post redirects to settings, so the person leaves the home page; say so in the button's title or accept it knowingly.

3148 ${alarms.map((a) => html`<p class="small"><a href="${a.href}">${a.says}</a></p>`)}
49 ${offer}
3250 </div>`;
3351 }
52
53 /** The own repository's path when its switch is off and `user` administers it; else undefined. */
54 export function flowOnlyOffer(db: Database, user: User): string | undefined {
55 const [owner, name] = ownRepoPath().split("/");
56 const repo = owner && name ? getRepo(db, owner, name) : undefined;
57 return repo && !repo.flowOnly && canAdmin(db, repo, user) ? `${repo.ownerName}/${repo.name}` : undefined;
58 }
src/web/studio/home.ts +2 −2
@@ -53,7 +53,7 @@ import { writableAppsOf } from "./asks.ts";
5353 import { needsYouSection } from "./asks_page.ts";
5454 import { flowCards } from "./flow_card.ts";
5555 import { composer, feedJs, feedNote, feedRegion } from "./feed.ts";
56 import { alarmLines, cachedAlarms } from "./alarm_lines.ts";
56 import { alarmLines, cachedAlarms, flowOnlyOffer } from "./alarm_lines.ts";
5757 import { clogRow, kpiRow, vitalsRow, waitingLine } from "./kpi_row.ts";
5858 import { feedCandidateCache } from "../../feed/feed.ts";
5959 import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
@@ -292,7 +292,7 @@ export async function studioHome(
292292 : liveLine(ctx)}
293293 ${figures ? timePhase(ctx.request, "vitals", () => vitalsRow(ctx.app.db)) : null}
294294 ${figures ? clogRow(ctx.app.db) : null}
295 ${figures ? alarmLines(cachedAlarms(ctx.app.db, new Date(), ceilingUsd)) : null}
295 ${figures ? alarmLines(cachedAlarms(ctx.app.db, new Date(), ceilingUsd), flowOnlyOffer(ctx.app.db, user)) : null}
296296 ${timePhase(ctx.request, "waiting", () => waitingLine(ctx.app.db, user), () => waitingCache(ctx.app.db))}
297297 ${figures
298298 ? await timePhaseAsync(ctx.request, "panels", () => pinnedPanels(ctx.app, user), () => panelCache(ctx.app.db))
test/flow-only-auto.test.ts added +140
@@ -0,0 +1,140 @@
1 /** FLOW ONLY (#6395): the alarm line offers the switch; 24 h clear turns it off, once. */
2
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
9 import { getRepoById, type Repo } from "../src/core/repos.ts";
10 import { createUser, type User } from "../src/core/users.ts";
11 import { candidatesFor } from "../src/feed/items.ts";
12 import { FLOW_ONLY_CLEAR_MS, stepFlowOnly } from "../src/flow/flow_only_auto.ts";
13 import { tickFlowOnly } from "../src/flow/clog_tick.ts";
14 import type { FiredAlarm } from "../src/flow/alarms.ts";
15 import { flowRow } from "../src/tui/screens/about.ts";
16 import { alarmLines, flowOnlyOffer } from "../src/web/studio/alarm_lines.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 let dataDir = "";
22 let app: App;
23 let alice: User;
24 let repo: Repo;
25 let was: string | undefined;
26 const T0 = Date.UTC(2026, 9, 1);
27 const fresh = (): Repo => getRepoById(app.db, repo.id)!;
28 const events = (): number =>
29 app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event WHERE kind = 'flow_only.cleared'").get()!.n;
30
31 beforeAll(async () => {
32 was = process.env.FORGE_SENTINEL_REPO;
33 process.env.FORGE_SENTINEL_REPO = "alice/forge";
34 dataDir = await mkdtemp(join(tmpdir(), "forge-flow-only-auto-"));
35 app = createApp({ dataDir, port: 0 });
36 const a = await createUser(app.db, {
37 origin: "seed",
38 trusted: true,
39 username: "alice",
40 email: "[email protected]",
41 password: "a-long-flow-only-passphrase",
42 adminIfFirst: true,
43 });
44 if (!a.ok) throw new Error(a.error);
45 alice = a.value;
46 const made = await createRepo(app, alice, { name: "forge", visibility: "public" });
47 if (!made.ok) throw new Error(String(made.error));
48 repo = made.value;
49 }, 60_000);
50
51 afterAll(async () => {
52 if (was === undefined) delete process.env.FORGE_SENTINEL_REPO;
53 else process.env.FORGE_SENTINEL_REPO = was;
54 await rm(dataDir, { recursive: true, force: true });
55 });
56
57 const alarm: FiredAlarm = { key: "red_main", says: "Main is red", since: "x", href: "/boards" };
58
59 test("an alarm line carries the Flow only button while the switch is off, for the admin", () => {
60 expect(flowOnlyOffer(app.db, alice)).toBe("alice/forge");
61 const page = String(alarmLines([alarm], "alice/forge"));
62 expect(page).toContain('action="/alice/forge/settings/flow-only"');
63 expect(page).toContain('name="flowOnly" value="1"');
64 expect(page).toContain("Flow only");
65 expect(String(alarmLines([alarm]))).not.toContain("<form");
66 });
67
68 test("no offer once the switch is on", async () => {
69 await updateRepoSettings(app, alice, repo, { flowOnly: true });
70 expect(flowOnlyOffer(app.db, alice)).toBeUndefined();
71 });
72
73 test("24 h clear turns it off once and says so; an alarm within the 24 h keeps it on", () => {
74 expect(fresh().flowOnly).toBe(true);
75 // The clock starts when the switch is first seen on.
76 expect(stepFlowOnly(app.db, app.bus, fresh(), false, T0)).toBe(false);
77 // An alarm 23 h in restarts the clock.
78 expect(stepFlowOnly(app.db, app.bus, fresh(), true, T0 + 23 * 3_600_000)).toBe(false);
79 expect(stepFlowOnly(app.db, app.bus, fresh(), false, T0 + FLOW_ONLY_CLEAR_MS + 1)).toBe(false);
80 expect(fresh().flowOnly).toBe(true);
81 // 24 h after that alarm, quiet: off, once.
82 const off = T0 + 23 * 3_600_000 + FLOW_ONLY_CLEAR_MS;
83 expect(stepFlowOnly(app.db, app.bus, fresh(), false, off)).toBe(true);
84 expect(fresh().flowOnly).toBe(false);
85 expect(stepFlowOnly(app.db, app.bus, fresh(), false, off + 60_000)).toBe(false);
86 expect(events()).toBe(1);
87 const mine = candidatesFor(app.db, alice, off).filter((c) => c.what.includes("Flow only is off again"));
88 expect(mine).toHaveLength(1);
89 });
90
91 test("the tick steps the own repository's watch", async () => {
92 await updateRepoSettings(app, alice, fresh(), { flowOnly: true });
93 const now = new Date(T0);
94 expect(tickFlowOnly(app.db, app.bus, now)).toBe(false);
95 expect(fresh().flowOnly).toBe(true);
96 expect(tickFlowOnly(app.db, app.bus, new Date(T0 + FLOW_ONLY_CLEAR_MS + 1))).toBe(true);
97 expect(fresh().flowOnly).toBe(false);
98 });
99
100 test("the tick counts the alarms the home shows: a crew ceiling that silences idle-crew does not hold the switch on", async () => {
101 const { openIssue } = await import("../src/actions/issues.ts");
102 const { createRun } = await import("../src/core/runs.ts");
103 await updateRepoSettings(app, alice, fresh(), {
104 autonomy: "assisted",
105 autonomyDirection: "keep the shop open",
106 autonomySlots: 2,
107 autonomyCeilingUsd: 15,
108 autonomyBy: alice.id,
109 });
110 const issue = openIssue(app, alice, fresh(), { title: "Waiting" });
111 if (!issue.ok) throw new Error(String(issue.error));
112 const now = T0 + 400 * 24 * 3_600_000 + 12 * 3_600_000;
113 const iso = (ms: number): string => new Date(ms).toISOString();
114 app.db.query("UPDATE issue SET created_at = ?1 WHERE id = ?2").run(iso(now - 3_600_000), issue.value.id);
115 const run = createRun(app.db, { repoId: repo.id, kind: "agent", reason: "assigned" });
116 app.db
117 .query("UPDATE run SET created_at = ?1, started_at = ?1, state = 'ok', cost_usd = 50 WHERE id = ?2")
118 .run(iso(now - 20 * 60_000), run.id);
119 await updateRepoSettings(app, alice, fresh(), { flowOnly: true });
120 app.db.query("DELETE FROM flow_only_watch").run();
121 // No ceiling: the idle crew is an alarm that stands, and holds the switch on.
122 expect(tickFlowOnly(app.db, app.bus, new Date(now))).toBe(false);
123 expect(tickFlowOnly(app.db, app.bus, new Date(now + FLOW_ONLY_CLEAR_MS + 1))).toBe(false);
124 expect(fresh().flowOnly).toBe(true);
125 // A $40 ceiling the day's spend has reached: the home shows no such alarm, so the watch lets go.
126 const later = now + 24 * 3_600_000 + 1 + FLOW_ONLY_CLEAR_MS;
127 const spent = createRun(app.db, { repoId: repo.id, kind: "agent", reason: "assigned" });
128 app.db
129 .query("UPDATE run SET created_at = ?1, started_at = ?1, state = 'ok', cost_usd = 50 WHERE id = ?2")
130 .run(iso(later - 20 * 60_000), spent.id);
131 expect(tickFlowOnly(app.db, app.bus, new Date(later), 40)).toBe(true);
132 expect(fresh().flowOnly).toBe(false);
133 });
134
135 test("the terminal's About row says the forge lets go by itself, on the own repository only", () => {
136 const on = { ...fresh(), flowOnly: true };
137 expect(flowRow(on)[0]![1]).toBe("on, off after 24 h clear");
138 expect(flowRow({ ...on, name: "other" })[0]![1]).toBe("on");
139 expect(flowRow({ ...on, flowOnly: false })[0]![1]).toBe("off");
140 });
test/flow-only-home.test.ts added +102
@@ -0,0 +1,102 @@
1 /** FLOW ONLY on the home (#6395): one button after the alarms, for the admin alone; the auto-off item reads once. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { openIssue } from "../src/actions/issues.ts";
8 import { createRepo, getRepoById, updateRepoSettings, type Repo } from "../src/core/repos.ts";
9 import { createSession } from "../src/core/sessions.ts";
10 import { createUser, type User } from "../src/core/users.ts";
11 import { FLOW_ONLY_CLEAR_MS, FLOW_ONLY_OFF_NOTE, stepFlowOnly } from "../src/flow/flow_only_auto.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14
15 quietLogs();
16
17 let dataDir = "";
18 let app: App;
19 let servers: Servers;
20 let owner: User;
21 let guest: User;
22 let repo: Repo;
23 let was: string | undefined;
24 const fresh = (): Repo => getRepoById(app.db, repo.id)!;
25 const count = (s: string, needle: string): number => s.split(needle).length - 1;
26 const get = async (path: string, who?: User): Promise<string> =>
27 (
28 await fetch(`${servers.url}${path}`, {
29 headers: who ? { cookie: `forge_session=${createSession(app.db, who.id).token}` } : {},
30 })
31 ).text();
32
33 beforeAll(async () => {
34 was = process.env.FORGE_SENTINEL_REPO;
35 process.env.FORGE_SENTINEL_REPO = "maker/svc";
36 dataDir = await mkdtemp(join(tmpdir(), "forge-flow-home-"));
37 app = createApp({ dataDir, port: 0 });
38 servers = await serve(app);
39 const made = await createUser(app.db, {
40 origin: "seed",
41 trusted: true,
42 username: "maker",
43 email: "[email protected]",
44 password: "a-good-passphrase",
45 adminIfFirst: true,
46 });
47 if (!made.ok) throw new Error("user");
48 owner = made.value;
49 const g = await createUser(app.db, {
50 origin: "seed",
51 trusted: true,
52 username: "visitor",
53 email: "[email protected]",
54 password: "a-good-passphrase",
55 });
56 if (!g.ok) throw new Error("guest");
57 guest = g.value;
58 const r = await createRepo(app.db, app.reposRoot, { ownerId: owner.id, name: "svc", visibility: "public" });
59 if (!r.ok) throw new Error("repo");
60 repo = updateRepoSettings(app.db, r.value, {
61 autonomy: "assisted",
62 autonomyDirection: "keep the shop open",
63 autonomySlots: 2,
64 autonomyCeilingUsd: 15,
65 autonomyBy: owner.id,
66 });
67 // A ready issue nobody has started: the idle-crew alarm stands.
68 const issue = openIssue(app, owner, repo, { title: "Waiting" });
69 if (!issue.ok) throw new Error(String(issue.error));
70 app.db
71 .query("UPDATE issue SET created_at = ?1 WHERE id = ?2")
72 .run(new Date(Date.now() - 3_600_000).toISOString(), issue.value.id);
73 }, 30_000);
74
75 afterAll(async () => {
76 if (was === undefined) delete process.env.FORGE_SENTINEL_REPO;
77 else process.env.FORGE_SENTINEL_REPO = was;
78 servers?.stop();
79 app?.close();
80 await rm(dataDir, { recursive: true, force: true });
81 });
82
83 test("the admin's home carries one Flow only button after the alarms; others get no form", async () => {
84 const page = await get("/?all=1", owner);
85 expect(page).toContain("no crew run has started");
86 expect(count(page, 'action="/maker/svc/settings/flow-only"')).toBe(1);
87 expect(await get("/?all=1", guest)).not.toContain("settings/flow-only");
88 expect(await get("/")).not.toContain("settings/flow-only");
89 });
90
91 test("no button once the switch is on", async () => {
92 updateRepoSettings(app.db, fresh(), { flowOnly: true });
93 expect(await get("/?all=1", owner)).not.toContain('action="/maker/svc/settings/flow-only"');
94 });
95
96 test("the auto-off item reads once on the admin's home", async () => {
97 const t = Date.now();
98 stepFlowOnly(app.db, app.bus, fresh(), false, t - FLOW_ONLY_CLEAR_MS - 1);
99 expect(stepFlowOnly(app.db, app.bus, fresh(), false, t)).toBe(true);
100 const page = await get("/?all=1", owner);
101 expect(count(page, FLOW_ONLY_OFF_NOTE)).toBe(1);
102 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs