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

Settings › Features' Scheduled rows have Run now: one call of the tool, recorded as its fire #10117

Merged opened this

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

Merged

scout/forge:scout/10108-settings-features-scheduled-rows-have-ru → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/10108-settings-features-scheduled-rows-have-ru was merged into main as c59854b.

Review

Sign in to review this change.
8 files changed +316 −5
docs/features/feature-schedule.md +3 −3
@@ -33,10 +33,10 @@ One row per (app, tool) in `feature_fire` (`src/core/feature_fires.ts`), replace
3333
3434 ## Read it
3535
36 - **Page:** Settings › Features › the app, under **Scheduled** (a site admin's page, as the rest of it).
36 - **Page:** Settings › Features › the app, under **Scheduled** (a site admin's page, as the rest of it). Each row has **Run now**: the same call, recorded as the tool's fire. A stopped or asleep app is refused with a sentence, never woken; a call already running is refused, never doubled.
3737 - **API:** `GET /api/v1/features/:slug` returns `fires`: a list of `{ tool, at, ok, error, durationMs }` (`at` in ms since the epoch). It is empty until something has fired. Readable by whoever may read the feature.
38 - **Terminal:** Feature feeds › the app: a `Scheduled:` line above the feed items, tool, outcome and age.
38 - **Terminal:** Feature feeds › the app: a `Scheduled:` line above the feed items, tool, outcome and age. An admin presses `r` there to run those tools now: the same call, record and refusal sentences as Run now on the page.
3939
4040 ## Not done
4141
42 No arguments per entry, no history beyond the last fire, no "run now" button, and no wake-on-schedule for a stopped app.
42 No arguments per entry, no history beyond the last fire, and no wake-on-schedule for a stopped app.
src/apps/feature_run.ts added +24
@@ -0,0 +1,24 @@
1 /**
2 * Run now, in words: one call of a scheduled tool through `fireOne`, and what
3 * to tell the person. The page and the terminal both say it through here, so
4 * the sentences are one set.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { lastFires } from "../core/feature_fires.ts";
9 import { fireOne, type Reach } from "./feature_schedule.ts";
10
11 export const RUN_REFUSALS = {
12 not_absorbed: "Absorb the app before running its tools.",
13 not_scheduled: "That tool is not on the app's schedule.",
14 asleep: "The app's production is not running; it is not woken for a run. Redeploy it first.",
15 in_flight: "That tool is already running.",
16 } as const;
17
18 /** Call `tool` now and say how it went; `ok` is false for a refusal or a failed call. */
19 export async function runNow(db: Database, appId: string, tool: string, reach: Reach): Promise<{ ok: boolean; message: string }> {
20 const fired = await fireOne(db, appId, tool, reach);
21 if (!fired.ok) return { ok: false, message: RUN_REFUSALS[fired.why] };
22 const fire = lastFires(db, appId).find((f) => f.tool === tool);
23 return fire?.ok === false ? { ok: false, message: `${tool} ran and failed: ${fire.error ?? "failed"}.` } : { ok: true, message: `${tool} ran.` };
24 }
src/apps/feature_schedule.ts +40
@@ -67,6 +67,46 @@ export async function fireApp(db: Database, appId: string, reach: Reach, now: ()
6767 return called;
6868 }
6969
70 /** Why a run now was not made. */
71 export type FireOneRefusal = "not_absorbed" | "not_scheduled" | "asleep" | "in_flight";
72
73 /**
74 * Call one scheduled tool now, the way the schedule does, and record it as its
75 * fire. A stopped or asleep app is refused, never woken; a call already running
76 * for the same (app, tool) is refused, never doubled.
77 */
78 export async function fireOne(
79 db: Database,
80 appId: string,
81 tool: string,
82 reach: Reach,
83 now: () => number = Date.now,
84 ): Promise<{ ok: true } | { ok: false; why: FireOneRefusal }> {
85 if (getAbsorbed(db, appId) === undefined) return { ok: false, why: "not_absorbed" };
86 const key = `${appId}:${tool}`;
87 if (inFlight.has(key)) return { ok: false, why: "in_flight" };
88 const port = reach.port();
89 if (port === undefined) return { ok: false, why: "asleep" };
90 inFlight.add(key);
91 try {
92 const declared = await reach.declared().catch(() => undefined);
93 if (declared === undefined || !declared.tools.includes(tool) || !declared.schedule.some((e) => e.tool === tool)) return { ok: false, why: "not_scheduled" };
94 const started = now();
95 // callTool is bounded (WAIT_MS) and answers an error rather than throwing; a throw is still a recorded failure.
96 const answer = await callTool(port, tool, {}).catch((): { ok: false; error: string } => ({ ok: false, error: "the app did not answer" }));
97 recordFire(db, appId, {
98 tool,
99 at: started,
100 ok: answer.ok,
101 error: answer.ok ? null : answer.error,
102 durationMs: Math.max(0, now() - started),
103 });
104 return { ok: true };
105 } finally {
106 inFlight.delete(key);
107 }
108 }
109
70110 /** What `fireApp` reaches for one app in the real runtime: only a production that is running, never woken. */
71111 export function runtimeReach(app: App, record: AppRecord): Reach {
72112 return {
src/http/settings_routes.ts +2 −1
@@ -31,7 +31,7 @@ import {
3131 signInAct,
3232 } from "../web/pages/accounts.ts";
3333 import { confirmsList, confirmYes } from "../web/pages/confirms.ts";
34 import { featureAccept, featurePage, featureSave, featuresPage, featureSwitch } from "../web/pages/features.ts";
34 import { featureAccept, featurePage, featureRun, featureSave, featuresPage, featureSwitch } from "../web/pages/features.ts";
3535 import { passkeyAdd, passkeyDelete, passkeyOptions, passkeysPage } from "../web/pages/passkeys.ts";
3636 import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts";
3737 import {
@@ -185,6 +185,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
185185 router.add("GET", "/settings/features", featuresPage);
186186 router.add("GET", "/settings/features/:slug", featurePage);
187187 router.add("POST", "/settings/features/:slug", featureSave);
188 router.add("POST", "/settings/features/:slug/run/:tool", featureRun());
188189 router.add("POST", "/settings/features/:slug/absorb", featureSwitch(true));
189190 router.add("POST", "/settings/features/:slug/unabsorb", featureSwitch(false));
190191 router.add("POST", "/settings/features/:slug/accept", featureAccept);
src/tui/screens/featurefeed.ts +24 −1
@@ -6,6 +6,9 @@
66 * do: the viewer's own feed items about that repository, muted or not.
77 */
88
9 import { runNow } from "../../apps/feature_run.ts";
10 import { runtimeReach } from "../../apps/feature_schedule.ts";
11 import { getAppBySlug } from "../../core/apps.ts";
912 import { absorbedForNav, feedOf, getFeature, type NavFeature } from "../../actions/features.ts";
1013 import type { Fired } from "../../core/feature_fires.ts";
1114 import type { FeedItem } from "../../feed/rank.ts";
@@ -31,9 +34,28 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc
3134 fires = found.ok ? found.value.fires : [];
3235 ctx.invalidate();
3336 });
37 const mayRun = (): boolean => f.user?.isAdmin === true && f.user.kind !== "agent" && fires.length > 0;
38 let running = false;
39 /** Run every tool on the Scheduled line now, saying the page's sentences. */
40 async function runAll(): Promise<void> {
41 const record = getAppBySlug(f.app.db, feature.slug);
42 if (record === undefined || running) return;
43 running = true;
44 try {
45 for (const tool of fires.map((x) => x.tool)) {
46 const said = await runNow(f.app.db, record.id, tool, runtimeReach(f.app, record));
47 ctx.toast(said.message, said.ok ? "success" : "danger");
48 }
49 const found = await getFeature(f.app, f.user, feature.slug);
50 fires = found.ok ? found.value.fires : fires;
51 } finally {
52 running = false;
53 ctx.invalidate();
54 }
55 }
3456 return {
3557 title: `In the feed: ${feature.name}`,
36 keys: [binding(["j", "k"], "move"), binding(["esc", "q"], "back")],
58 keys: [binding(["j", "k"], "move"), ...(mayRun() ? [binding(["r"], "run scheduled now")] : []), binding(["esc", "q"], "back")],
3759 render(area, s) {
3860 const c = ctx.theme.c;
3961 const [head, body] = fires.length === 0 ? [undefined, area] : split(area, "v", [len(1), fill(1)]);
@@ -50,6 +72,7 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc
5072 },
5173 key(e) {
5274 if (e.key === "esc" || e.key === "q") ctx.pop();
75 else if (e.key === "r" && mayRun()) void runAll();
5376 else state.key(e, items.length);
5477 return true;
5578 },
src/web/pages/features.ts +21
@@ -18,6 +18,9 @@ import {
1818 type SettingView,
1919 provenanceFacts,
2020 } from "../../actions/features.ts";
21 import { runNow } from "../../apps/feature_run.ts";
22 import { runtimeReach, type Reach } from "../../apps/feature_schedule.ts";
23 import { getAppBySlug, type AppRecord } from "../../core/apps.ts";
2124 import { DAYS } from "../../apps/feature.ts";
2225 import type { FeedItem } from "../../feed/rank.ts";
2326 import type { Ctx } from "../../http/context.ts";
@@ -226,6 +229,7 @@ function featureView(ctx: Ctx, f: FeatureView, refused?: Refused, items: readonl
226229 <ul class="list">${f.fires.map((x) => row({
227230 title: html`<span class="mono">${x.tool}</span>`,
228231 meta: html`${x.ok ? "ok" : html`<span class="danger">${x.error ?? "failed"}</span>`} · ${time(new Date(x.at).toISOString())} · ${x.durationMs} ms`,
232 trail: html`<form method="post" action="${base}/run/${encodeURIComponent(x.tool)}">${btn("Run now", { label: `Run ${x.tool} now` })}</form>`,
229233 }))}</ul>
230234 </section>`)}
231235 <p class="muted small">
@@ -270,6 +274,23 @@ export async function featureSave(ctx: Ctx): Promise<Response> {
270274 return flashTo(ctx, back, { tone: "danger", message: changed.error.message });
271275 }
272276
277 /** POST /settings/features/:slug/run/:tool, with `reachOf` the seam a test replaces. */
outdated

The refusal sentences live only in the web page. The terminal needs the same sentences when it runs a tool, so move this map to a shared module (Both doors). Keep the plain words as they are.

278 export function featureRun(reachOf: (app: Ctx["app"], record: AppRecord) => Reach = runtimeReach) {
279 return async (ctx: Ctx): Promise<Response> => {
280 const no = requireAdmin(ctx);
281 if (no) return no;
282 const form = await readForm(ctx, "admin");
283 if (form instanceof Response) return form;
284 const slug = ctx.params["slug"] ?? "";
285 const tool = ctx.params["tool"] ?? "";
286 const record = getAppBySlug(ctx.app.db, slug);
287 if (record === undefined) return notFoundPage(ctx);
288 const back = `/settings/features/${encodeURIComponent(slug)}`;
289 const said = await runNow(ctx.app.db, record.id, tool, reachOf(ctx.app, record));
290 return flashTo(ctx, back, said.ok ? { message: said.message } : { tone: "danger", message: said.message });
291 };
292 }
293
273294 // POST /settings/features/:slug/absorb and /unabsorb
274295 export function featureSwitch(absorbing: boolean) {
275296 return async (ctx: Ctx): Promise<Response> => {
test/feature-run-now.test.ts added +182
@@ -0,0 +1,182 @@
1 /**
2 * Run now (#10108): an admin runs one scheduled tool from Settings › Features,
3 * through the schedule's own call, recorded as its fire. Tried with a fake
4 * reach: a good call, a failing one, an asleep app (never reached), a call
5 * already running (never doubled); and on a forge with no app runtime, where a
6 * non-admin gets the 404 and an admin is told the app is asleep.
7 */
8 import { Database } from "bun:sqlite";
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { RUN_REFUSALS, runNow } from "../src/apps/feature_run.ts";
16 import { fireOne, type Reach } from "../src/apps/feature_schedule.ts";
17 import { createApp as insertApp } from "../src/core/apps.ts";
18 import { lastFires, recordFire } from "../src/core/feature_fires.ts";
19 import { absorb } from "../src/core/features.ts";
20 import { migrationFiles } from "../src/core/schema.ts";
21 import { createUser } from "../src/core/users.ts";
22 import { migrate } from "../src/kernel/migrate.ts";
23 import { serve, type Servers } from "../src/http/server.ts";
24 import { promote } from "./helpers/members.ts";
25
26 const HOUR = 3_600_000;
27 const schedule = [{ tool: "scan", everyMs: HOUR }];
28 let seen: string[] = [];
29 let answer: () => Response = () => Response.json({ ok: true });
30 let hold: Promise<void> | undefined;
31 const stub = Bun.serve({
32 port: 0,
33 hostname: "127.0.0.1",
34 fetch: async (req) => {
35 seen.push(new URL(req.url).pathname);
36 if (hold) await hold;
37 return answer();
38 },
39 });
40 const reach = (over: Partial<Reach> = {}): Reach => ({ port: () => stub.port, declared: async () => ({ tools: ["scan"], schedule }), ...over });
41 afterAll(() => stub.stop(true));
42
43 describe("fireOne", () => {
44 let db: Database;
45 const fresh = () => {
46 db = new Database(":memory:");
47 migrate(db, migrationFiles);
48 absorb(db, "a1", "admin", "Radar", "/");
49 seen = [];
50 answer = () => Response.json({ ok: true });
51 hold = undefined;
52 };
53
54 test("a good call is made once and recorded as the fire, even inside the interval", async () => {
55 fresh();
56 recordFire(db, "a1", { tool: "scan", at: 1, ok: false, error: "old", durationMs: 1 });
57 expect(await fireOne(db, "a1", "scan", reach(), () => 10)).toEqual({ ok: true });
58 expect(seen).toEqual(["/forge/tools/scan"]);
59 expect(lastFires(db, "a1")).toEqual([{ tool: "scan", at: 10, ok: true, error: null, durationMs: 0 }]);
60 });
61
62 test("a failing call is recorded with its sentence", async () => {
63 fresh();
64 answer = () => new Response("no", { status: 500 });
65 expect(await fireOne(db, "a1", "scan", reach(), () => 5)).toEqual({ ok: true });
66 expect(lastFires(db, "a1")[0]).toMatchObject({ ok: false, error: "the app answered 500" });
67 });
68
69 test("an asleep app is refused and not called", async () => {
70 fresh();
71 expect(await fireOne(db, "a1", "scan", reach({ port: () => undefined }))).toEqual({ ok: false, why: "asleep" });
72 expect(seen).toEqual([]);
73 expect(lastFires(db, "a1")).toEqual([]);
74 });
75
76 test("a call already running is refused, not doubled", async () => {
77 fresh();
78 let release!: () => void;
79 hold = new Promise((r) => (release = r));
80 const first = fireOne(db, "a1", "scan", reach());
81 while (seen.length === 0) await Bun.sleep(1);
82 expect(await fireOne(db, "a1", "scan", reach())).toEqual({ ok: false, why: "in_flight" });
83 release();
84 expect(await first).toEqual({ ok: true });
85 expect(seen.length).toBe(1);
86 });
87
88 test("a tool not on the schedule, or an app not absorbed, is refused", async () => {
89 fresh();
90 expect(await fireOne(db, "a1", "other", reach())).toEqual({ ok: false, why: "not_scheduled" });
91 expect(await fireOne(db, "nope", "scan", reach())).toEqual({ ok: false, why: "not_absorbed" });
92 expect(seen).toEqual([]);
93 });
94 });
95
96 describe("what is said", () => {
97 const db = new Database(":memory:");
98 migrate(db, migrationFiles);
99 absorb(db, "a1", "admin", "Radar", "/");
100
101 test("a good call says ran; a failing one says ran and failed with why; a refusal has its sentence", async () => {
102 answer = () => Response.json({ ok: true });
103 expect(await runNow(db, "a1", "scan", reach())).toEqual({ ok: true, message: "scan ran." });
104 answer = () => new Response("no", { status: 500 });
105 expect(await runNow(db, "a1", "scan", reach())).toEqual({ ok: false, message: "scan ran and failed: the app answered 500." });
106 expect(await runNow(db, "a1", "scan", reach({ port: () => undefined }))).toEqual({ ok: false, message: RUN_REFUSALS.asleep });
107 });
108
109 test("a reach that throws is a refusal, and a dead port a recorded failure, never a throw", async () => {
110 const boom = reach({ declared: async () => { throw new Error("x"); } });
111 expect(await runNow(db, "a1", "scan", boom)).toEqual({ ok: false, message: RUN_REFUSALS.not_scheduled });
112 expect((await runNow(db, "a1", "scan", reach({ port: () => 1 }))).message).toBe("scan ran and failed: the app did not answer.");
113 });
114 });
115
116 describe("the route", () => {
117 let dataDir: string;
118 let app: App;
119 let servers: Servers;
120 let appId: string;
121 let adaCookie: string;
122 let cyCookie: string;
123 const signIn = async (username: string): Promise<string> => {
124 const r = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username, password: "a-good-passphrase" }), redirect: "manual" });
125 return `${(r.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=dev`;
126 };
127 const web = (cookie: string, path: string, post = false) =>
128 fetch(`${servers.url}${path}`, { method: post ? "POST" : "GET", headers: { cookie, origin: servers.url }, redirect: "manual", ...(post ? { body: new URLSearchParams() } : {}) });
129
130 beforeAll(async () => {
131 dataDir = await mkdtemp(join(tmpdir(), "forge-run-now-"));
132 app = createApp({ dataDir, port: 0, host: "forge.test" });
133 servers = await serve(app);
134 const make = async (username: string, adminIfFirst = false) => {
135 const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst });
136 if (!made.ok) throw new Error(made.error);
137 return made.value;
138 };
139 await make("ada", true);
140 const cy = await make("cy");
141 promote(app.db, cy);
142 const made = await createRepo(app, cy, { name: "radar", visibility: "private", description: "" });
143 if (!made.ok) throw new Error(String(made.error));
144 const forgeJson = JSON.stringify({
145 app: { run: "bun run src/server.ts" },
146 feature: { name: "Radar", page: "/", tools: { scan: { description: "Scan" } }, schedule: [{ tool: "scan", every: "1h" }] },
147 });
148 const committed = await commitToBranch(app, cy, made.value, { message: "feature", files: [{ path: "forge.json", content: forgeJson }] });
149 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
150 const row = insertApp(app.db, { repoId: made.value.id, slug: "radar", access: "private" });
151 if (!row.ok) throw new Error(row.error);
152 appId = row.value.id;
153 absorb(app.db, appId, "admin", "Radar", "/");
154 recordFire(app.db, appId, { tool: "scan", at: Date.now(), ok: true, error: null, durationMs: 3 });
155 adaCookie = await signIn("ada");
156 cyCookie = await signIn("cy");
157 }, 60_000);
158
159 afterAll(async () => {
160 servers?.stop();
161 app?.close();
162 if (dataDir) await rm(dataDir, { recursive: true, force: true });
163 });
164
165 test("the row has Run now, a POST form", async () => {
166 const page = await (await web(adaCookie, "/settings/features/radar")).text();
167 expect(page).toContain('action="/settings/features/radar/run/scan"');
168 expect(page).toContain("Run now");
169 });
170
171 test("a non-admin gets the 404", async () => {
172 expect((await web(cyCookie, "/settings/features/radar/run/scan", true)).status).toBe(404);
173 });
174
175 test("an admin on an asleep app is sent back with a sentence, and nothing is recorded", async () => {
176 const before = lastFires(app.db, appId);
177 const res = await web(adaCookie, "/settings/features/radar/run/scan", true);
178 expect(res.status).toBe(303);
179 expect(res.headers.get("location")).toBe("/settings/features/radar");
180 expect(lastFires(app.db, appId)).toEqual(before);
181 });
182 });
test/features-tui.test.ts +20
@@ -8,6 +8,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
88 import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
11 import { RUN_REFUSALS } from "../src/apps/feature_run.ts";
1112 import { absorbApp } from "../src/actions/features.ts";
1213 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1314 import { createApp, type App as Forge } from "../src/app.ts";
@@ -165,6 +166,25 @@ describe("features in the terminal", () => {
165166 t.stop();
166167 });
167168
169 test("an admin presses r on the feed screen to run the scheduled tool; the refusal is the page's sentence", async () => {
170 recordFire(forge.db, appId, { tool: "scan", at: Date.now() - 120_000, ok: true, error: null, durationMs: 3 });
171 const t = boot(ada);
172 await t.palette("Feature feeds");
173 await t.press("enter");
174 await t.press("enter");
175 await t.press("r");
176 expect(t.text()).toContain(RUN_REFUSALS.asleep);
177 t.stop();
178 const other = boot(eve);
179 await other.palette("Feature feeds");
180 await other.press("enter");
181 await other.press("enter");
182 await other.press("r");
183 expect(other.text()).not.toContain(RUN_REFUSALS.asleep);
184 other.stop();
185 clearFires(forge.db, appId);
186 });
187
168188 test("any signed-in reader is offered it, for the apps they may read", async () => {
169189 const t = boot(eve);
170190 await t.palette("Feature feeds");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs