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

The forge calls an absorbed app's scheduled tool on time, once, and remembers how it went #9975

Merged opened this

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

Merged

scout/forge:scout/9926-the-forge-calls-an-absorbed-app-s-schedu → main 4 commits fork

  • Merged. scout/9926-the-forge-calls-an-absorbed-app-s-schedu was merged into main as 0ca3900.

Review

Sign in to review this change.
16 files changed +402 −5
docs/features/absorb.md +2 −2
@@ -82,11 +82,11 @@ Settings stay behind on a fork. A fork of another forge's app carries its `featu
8282
8383 ## Schedule
8484
85 A feature may declare `"schedule": [{ "tool": "scan", "every": "1h" }]`: at most 4 entries, each naming one of the feature's own `tools` and an `every` of `15m`, `1h`, `6h` or `1d`. Anything else is refused with the field and the entry named. The forge keeps it as `schedule` (`tool`, `everyMs`) on the parsed feature. Firing is not done yet: nothing calls the tool on that interval (#9883).
85 A feature may declare `"schedule": [{ "tool": "scan", "every": "1h" }]`: at most 4 entries, each naming one of the feature's own `tools` and an `every` of `15m`, `1h`, `6h` or `1d`. Anything else is refused with the field and the entry named. The forge keeps it as `schedule` (`tool`, `everyMs`) on the parsed feature. The forge calls it on that interval while the app is absorbed and running, and remembers how it went: see [feature-schedule.md](feature-schedule.md).
8686
8787 ## Not done yet
8888
89 Until then an absorbed app is a menu link with kept settings (Settings › Features says so). The forge does not yet fire a feature's schedule (#9883).
89 An absorbed app is a menu link with kept settings (Settings › Features says so).
9090
9191 ## MCP tools
9292
docs/features/feature-schedule.md added +42
@@ -0,0 +1,42 @@
1 # Scheduled tools: the forge calls an absorbed app's tool on time
2
3 An app can ask the forge to call one of its own declared tools on an interval. No model and no crew is involved: the forge makes the same loopback call the `features` MCP tool makes (`callTool`, `POST /forge/tools/<tool>` with `{"args":{}}`) and keeps how it went.
4
5 ## Declare it
6
7 In the app's `forge.json`, next to the `tools` the schedule names:
8
9 ```json
10 "feature": {
11 "name": "Radar",
12 "tools": { "scan": { "description": "Run a scan now" } },
13 "schedule": [{ "tool": "scan", "every": "1h" }]
14 }
15 ```
16
17 - At most 4 entries. `every` is `15m`, `1h`, `6h` or `1d`. A wrong entry is refused with the field named (`src/apps/feature.ts`).
18 - Each entry names a tool the feature declares. A tool that is no longer declared is skipped, never called.
19 - The tool is called with no arguments, so it should be one that needs none.
20
21 ## When it fires
22
23 `src/apps/feature_schedule.ts` looks once a minute (the timer is off under `bun test`). A tool is due when it has never fired or its interval has passed since its last fire, so two looks in one window fire once. The forge calls it only when:
24
25 - an admin has absorbed the app (an app that is not absorbed is never called), and
26 - its production is running. A stopped or asleep app is skipped and never woken; its tool fires on the first look after it is running.
27
28 One app at a time, one tool at a time: a slow tool delays the next, and a tool is never called while its previous call is still running. A failing look at one app is logged and does not stop the others.
29
30 ## What the forge records
31
32 One row per (app, tool) in `feature_fire` (`src/core/feature_fires.ts`), replaced on each fire: when it started, whether the app answered `ok`, the error sentence when it did not (`the app answered 500`, a timeout), and how many milliseconds it took. Un-absorbing an app, or deleting it, clears its rows; absorbing again starts from never fired.
33
34 ## Read it
35
36 - **Page:** Settings › Features › the app, under **Scheduled** (a site admin's page, as the rest of it).
37 - **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.
39
40 ## Not done
41
42 No arguments per entry, no history beyond the last fire, no "run now" button, and no wake-on-schedule for a stopped app.
src/actions/features.ts +4
@@ -17,6 +17,7 @@
1717 * `FORGE_SETTING_*` in a new process, the same way an env var does.
1818 */
1919
20 import { lastFires, type Fired } from "../core/feature_fires.ts";
2021 import type { App } from "../app.ts";
2122 import { readFileAt } from "../apps/checkout.ts";
2223 import { checkSetting, featureEnv, parseFeature, settingEnvName, type FeatureSpec } from "../apps/feature.ts";
@@ -88,6 +89,8 @@ export interface FeatureView {
8889 readonly settings: readonly SettingView[];
8990 /** The MCP tools the app declares; the `features` tool calls them while it is absorbed. */
9091 readonly tools: readonly ToolSpec[];
92 /** How the forge's last call of each scheduled tool went; empty when none has fired. */
93 readonly fires: readonly Fired[];
9194 /** Where the app comes from: the upstream only when the reader may read it. */
9295 readonly provenance: Provenance;
9396 }
@@ -238,6 +241,7 @@ function viewOf(
238241 problem,
239242 provenance,
240243 tools: spec?.tools ?? [],
244 fires: absorbed === undefined ? [] : lastFires(app.db, record.id),
241245 settings: (spec?.settings ?? []).map((s) => {
242246 const name = settingEnvName(s.key);
243247 return {
src/apps/feature.ts +1 −1
@@ -68,7 +68,7 @@ export interface FeatureSpec {
6868 readonly settings: readonly SettingSpec[];
6969 /** MCP tools the app serves through the forge's `features` tool (`feature_tools.ts`). */
7070 readonly tools: readonly ToolSpec[];
71 /** Tools the app asks the forge to call on a timer; empty when none. Declared only: nothing fires yet. */
71 /** Tools the app asks the forge to call on a timer; empty when none. Fired by `apps/feature_schedule.ts`. */
7272 readonly schedule: readonly ScheduleEntry[];
7373 }
7474
src/apps/feature_schedule.ts added +115
@@ -0,0 +1,115 @@
1 /**
2 * An absorbed app's declared schedule, run: on a tick the forge calls each due
3 * tool through the same loopback call the `features` tool uses
4 * (`callTool`), with no model and no crew. The last fire of each (app, tool) is
5 * kept in `feature_fire`. An app that is not running is skipped, never woken;
6 * an app that is not absorbed is never called.
7 */
8
9 import type { Database } from "bun:sqlite";
10 import type { App } from "../app.ts";
11 import { declaredFeature } from "../actions/features.ts";
12 import { getAbsorbed } from "../core/features.ts";
13 import { lastFires, recordFire } from "../core/feature_fires.ts";
14 import { listApps, type AppRecord } from "../core/apps.ts";
15 import { getRepoById } from "../core/repos.ts";
16 import { callTool } from "./feature_tools.ts";
17 import type { ScheduleEntry } from "./feature.ts";
18 import { appsRuntime } from "./runtime.ts";
19
20 /** The entries whose interval has passed since they last fired (never fired: due). `lastFired` is ms by tool. */
21 export function due(schedule: readonly ScheduleEntry[], lastFired: ReadonlyMap<string, number>, now: number): ScheduleEntry[] {
22 return schedule.filter((e) => {
23 const last = lastFired.get(e.tool);
24 return last === undefined || now - last >= e.everyMs;
25 });
26 }
27
28 const inFlight = new Set<string>();
29
30 /** What `fireApp` needs from the running forge: the production port when running, and the declared schedule. */
31 export interface Reach {
32 port(): number | undefined;
33 /** The feature as the app declares it now: its tools and its schedule. */
34 declared(): Promise<{ readonly tools: readonly string[]; readonly schedule: readonly ScheduleEntry[] }>;
35 }
36
37 /** Call every due scheduled tool of one app if it is absorbed and running; the tools called. */
38 export async function fireApp(db: Database, appId: string, reach: Reach, now: () => number = Date.now): Promise<string[]> {
39 if (getAbsorbed(db, appId) === undefined) return [];
40 const port = reach.port();
41 if (port === undefined) return [];
42 const declared = await reach.declared();
43 const schedule = declared.schedule.filter((e) => declared.tools.includes(e.tool));
44 if (schedule.length === 0) return [];
45 const last = new Map(lastFires(db, appId).map((f) => [f.tool, f.at]));
46 const called: string[] = [];
47 for (const entry of due(schedule, last, now())) {
48 const key = `${appId}:${entry.tool}`;
49 if (inFlight.has(key)) continue;
50 inFlight.add(key);
51 try {
52 const started = now();
53 const answer = await callTool(port, entry.tool, {});
54 const ended = now();
55 recordFire(db, appId, {
56 tool: entry.tool,
57 at: started,
58 ok: answer.ok,
59 error: answer.ok ? null : answer.error,
60 durationMs: Math.max(0, ended - started),
61 });
62 called.push(entry.tool);
63 } finally {
64 inFlight.delete(key);
65 }
66 }
67 return called;
68 }
69
70 /** What `fireApp` reaches for one app in the real runtime: only a production that is running, never woken. */
71 export function runtimeReach(app: App, record: AppRecord): Reach {
72 return {
73 port: () => {
74 const status = appsRuntime(app)?.envStatus(record, "production");
75 return status?.state === "running" ? status.port : undefined;
76 },
77 declared: async () => {
78 const repo = getRepoById(app.db, record.repoId);
79 if (repo === undefined) return { tools: [], schedule: [] };
80 const declared = await declaredFeature(app, repo);
81 const spec = declared.ok ? declared.value : undefined;
82 return { tools: (spec?.tools ?? []).map((t) => t.name), schedule: spec?.schedule ?? [] };
83 },
84 };
85 }
86
87 /** Every app, one after another: a slow app delays the next, never overlaps itself. `reachOf` is the seam a test replaces. */
88 export async function fireDue(
89 app: App,
90 now: () => number = Date.now,
91 reachOf: (record: AppRecord) => Reach = (record) => runtimeReach(app, record),
92 ): Promise<void> {
93 for (const record of listApps(app.db)) {
94 try {
95 await fireApp(app.db, record.id, reachOf(record), now);
96 } catch (error) {
97 app.log.warn("could not fire a scheduled tool", { app: record.slug, error: String(error) });
98 }
99 }
100 }
101
102 const TICK_MS = 60_000;
103
104 /** The timer. Off under `bun test` (NODE_ENV=test) so no sweep outlives a test; a test that wants it calls `fireDue`. */
105 export function startFeatureSchedule(app: App, tickMs: number = TICK_MS): { stop(): void } | undefined {
106 if (process.env["NODE_ENV"] === "test") return undefined;
107 let running = false;
108 const tick = setInterval(() => {
109 if (running) return;
110 running = true;
111 void fireDue(app).finally(() => (running = false));
112 }, tickMs);
113 tick.unref();
114 return { stop: () => clearInterval(tick) };
115 }
src/core/apps.ts +1
@@ -235,6 +235,7 @@ export function deleteApp(db: Database, appId: string): boolean {
235235 db.run("DELETE FROM app_vital WHERE app_id = ?1", [appId]);
236236 db.run("DELETE FROM app_feature WHERE app_id = ?1", [appId]);
237237 db.run("DELETE FROM app_feature_setting WHERE app_id = ?1", [appId]);
238 db.run("DELETE FROM feature_fire WHERE app_id = ?1", [appId]);
238239 return db.query("DELETE FROM app WHERE id = ?1").run(appId).changes > 0;
239240 })();
240241 }
src/core/feature_fires.ts added +38
@@ -0,0 +1,38 @@
1 /** What the forge last did for each scheduled tool of an absorbed app: the `feature_fire` rows. */
2
3 import type { Database } from "bun:sqlite";
4
5 export interface Fired {
6 readonly tool: string;
7 readonly at: number;
8 readonly ok: boolean;
9 readonly error: string | null;
10 readonly durationMs: number;
11 }
12
13 /** The last fire of each scheduled tool of the app, by tool. */
14 export function lastFires(db: Database, appId: string): Fired[] {
15 return db
16 .query<{ tool: string; at: number; ok: number; error: string | null; duration_ms: number }, [string]>(
17 "SELECT tool, at, ok, error, duration_ms FROM feature_fire WHERE app_id = ?1 ORDER BY tool",
18 )
19 .all(appId)
20 .map((r) => ({ tool: r.tool, at: r.at, ok: r.ok === 1, error: r.error, durationMs: r.duration_ms }));
21 }
22
23 /** Keep how a fire went, replacing the tool's last. */
24 export function recordFire(db: Database, appId: string, fire: Fired): void {
25 db.query("INSERT OR REPLACE INTO feature_fire (app_id, tool, at, ok, error, duration_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6)").run(
26 appId,
27 fire.tool,
28 fire.at,
29 fire.ok ? 1 : 0,
30 fire.error,
31 fire.durationMs,
32 );
33 }
34
35 /** Forget the app's fires: it is no longer absorbed, or no longer exists. */
36 export function clearFires(db: Database, appId: string): void {
37 db.query("DELETE FROM feature_fire WHERE app_id = ?1").run(appId);
38 }
src/core/features.ts +2
@@ -5,6 +5,7 @@
55 */
66
77 import type { Database } from "bun:sqlite";
8 import { clearFires } from "./feature_fires.ts";
89
910 export interface Absorbed {
1011 readonly appId: string;
@@ -45,6 +46,7 @@ export function keepDeclaration(db: Database, appId: string, name: string, page:
4546
4647 /** True when the app was absorbed. Its settings stay, so absorbing again picks them up. */
4748 export function unabsorb(db: Database, appId: string): boolean {
49 clearFires(db, appId);
4850 return db.query("DELETE FROM app_feature WHERE app_id = ?1").run(appId).changes > 0;
4951 }
5052
src/core/migration-pins/20261109050000-feature-fire.pin added +1
@@ -0,0 +1,1 @@
1 980407b4a92eeb895e2bc880869ef1168ae058ef4b63fc288080c2a373998fab
src/core/migrations/20261109050000-feature-fire.ts added +14
@@ -0,0 +1,14 @@
1 // The last time the forge called an absorbed app's scheduled tool, and how it
2 // went (`apps/feature_schedule.ts`): one row per (app, tool), replaced on each
3 // fire. `error` is the sentence on a failure, null on success. Additive.
4 export default `
5 CREATE TABLE feature_fire (
6 app_id TEXT NOT NULL,
7 tool TEXT NOT NULL,
8 at INTEGER NOT NULL,
9 ok INTEGER NOT NULL,
10 error TEXT,
11 duration_ms INTEGER NOT NULL,
12 PRIMARY KEY (app_id, tool)
13 ) WITHOUT ROWID;
14 `;
src/http/server.ts +3
@@ -470,6 +470,7 @@ import {
470470 import { captureStore, loopWatch, perfRecord, readLedger } from "../perf/live.ts";
471471 import { classOf, countryOf, repoOf, signatureOf } from "../perf/traffic.ts";
472472 import { createTrafficWriter } from "../perf/traffic_store.ts";
473 import { startFeatureSchedule } from "../apps/feature_schedule.ts";
473474 import { startGithubPull } from "../perf/github_traffic.ts";
474475 import { appSourcesPage } from "../web/pages/sources.ts";
475476 import { createViewWriter } from "../perf/views_store.ts";
@@ -2310,6 +2311,7 @@ export async function serve(app: App): Promise<Servers> {
23102311 // Page views are buffered by the beacon doors and written here; GitHub's traffic is pulled daily when a token is set.
23112312 const viewWriter = createViewWriter(app.db, app.log);
23122313 const githubPull = startGithubPull(app.db, app.log);
2314 const featureSchedule = startFeatureSchedule(app);
23132315 const readWriter = createReadWriter(app.db, readLedger, app.log);
23142316 const counted = (
23152317 owner: App,
@@ -2632,6 +2634,7 @@ export async function serve(app: App): Promise<Servers> {
26322634 trafficWriter.stop();
26332635 viewWriter.stop();
26342636 githubPull?.stop();
2637 featureSchedule?.stop();
26352638 readWriter.stop();
26362639 // A child's hooks stop answering before its database closes.
26372640 for (const key of [...new Set([...childHttps.keys(), ...childInternals.keys()])]) letGo(key);
src/tui/screens/featurefeed.ts +13 −2
@@ -7,10 +7,14 @@
77 */
88
99 import { absorbedForNav, feedOf, getFeature, type NavFeature } from "../../actions/features.ts";
10 import type { Fired } from "../../core/feature_fires.ts";
1011 import type { FeedItem } from "../../feed/rank.ts";
1112 import type { Ctx, Screen } from "../app/app.ts";
1213 import { binding } from "../app/keys.ts";
1314 import type { Session } from "../session.ts";
15 import { style } from "../term/style.ts";
16 import { truncate } from "../term/width.ts";
17 import { fill, len, split } from "../ui/layout.ts";
1418 import { emptyState } from "../ui/widgets.ts";
1519 import { ListState, ago, drawList, type ListRow } from "./common.ts";
1620 import type { PaletteEntry } from "./entries.ts";
@@ -21,8 +25,10 @@ const oneLine = (text: string): string => text.replace(/[\p{Cc}\p{Cf}\u2028\u202
2125 export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Screen {
2226 const state = new ListState();
2327 let items: readonly FeedItem[] = [];
28 let fires: readonly Fired[] = [];
2429 void getFeature(f.app, f.user, feature.slug).then((found) => {
2530 items = found.ok ? feedOf(f.app, f.user, found.value) : [];
31 fires = found.ok ? found.value.fires : [];
2632 ctx.invalidate();
2733 });
2834 return {
@@ -30,12 +36,17 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc
3036 keys: [binding(["j", "k"], "move"), binding(["esc", "q"], "back")],
3137 render(area, s) {
3238 const c = ctx.theme.c;
39 const [head, body] = fires.length === 0 ? [undefined, area] : split(area, "v", [len(1), fill(1)]);
40 if (head !== undefined) {
41 const line = `Scheduled: ${fires.map((x) => `${x.tool} ${x.ok ? "ok" : oneLine(x.error ?? "failed")} ${ago(new Date(x.at).toISOString())}`).join("; ")}`;
42 s.text(head.x, head.y, truncate(line, head.w), style(c.mutedFg, c.bg), head.x + head.w);
43 }
3344 if (items.length === 0) {
34 emptyState(s, area, c, { title: "Nothing yet." });
45 emptyState(s, body!, c, { title: "Nothing yet." });
3546 return;
3647 }
3748 const rows: ListRow[] = items.map((i) => ({ title: oneLine(i.what), secondary: oneLine(i.actor ?? i.app), meta: ago(i.at) }));
38 drawList(s, area, c, state, rows);
49 drawList(s, body!, c, state, rows);
3950 },
4051 key(e) {
4152 if (e.key === "esc" || e.key === "q") ctx.pop();
src/web/pages/features.ts +7
@@ -221,6 +221,13 @@ function featureView(ctx: Ctx, f: FeatureView, refused?: Refused, items: readonl
221221 meta: html`${i.actor ?? i.app} · ${time(i.at)}`,
222222 }))}</ul>`}
223223 </section>
224 ${when(f.fires.length > 0, () => html`<section class="settings-block">
225 <h3>Scheduled</h3>
226 <ul class="list">${f.fires.map((x) => row({
227 title: html`<span class="mono">${x.tool}</span>`,
228 meta: html`${x.ok ? "ok" : html`<span class="danger">${x.error ?? "failed"}</span>`} · ${time(new Date(x.at).toISOString())} · ${x.durationMs} ms`,
229 }))}</ul>
230 </section>`)}
224231 <p class="muted small">
225232 Saving restarts ${f.name}: its process reads each setting as an environment variable, named on the right.
226233 <a href="/settings/features">All features</a>
test/feature-schedule-fire.test.ts added +133
@@ -0,0 +1,133 @@
1 /**
2 * The forge calls an absorbed app's scheduled tool when it is due, once per
3 * window, only while production runs, and remembers how it went. The clock is
4 * injected; nothing waits on wall time.
5 */
6
7 import { Database } from "bun:sqlite";
8 import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9 import { due, fireApp, fireDue, startFeatureSchedule, type Reach } from "../src/apps/feature_schedule.ts";
10 import { deleteApp } from "../src/core/apps.ts";
11 import { lastFires, recordFire } from "../src/core/feature_fires.ts";
12 import { absorb, unabsorb } from "../src/core/features.ts";
13 import { migrationFiles } from "../src/core/schema.ts";
14 import { migrate } from "../src/kernel/migrate.ts";
15
16 const HOUR = 3_600_000;
17 const schedule = [{ tool: "scan", everyMs: HOUR }];
18 let db: Database;
19 let seen: string[];
20 let answer: () => Response;
21 const server = Bun.serve({
22 port: 0,
23 hostname: "127.0.0.1",
24 fetch: (req) => {
25 seen.push(new URL(req.url).pathname);
26 return answer();
27 },
28 });
29 const reach = (over: Partial<Reach> = {}): Reach => ({ port: () => server.port, declared: async () => ({ tools: ["scan"], schedule }), ...over });
30
31 beforeEach(() => {
32 db = new Database(":memory:");
33 migrate(db, migrationFiles);
34 absorb(db, "a1", "admin", "Radar", "/");
35 seen = [];
36 answer = () => Response.json({ ok: true });
37 });
38 afterEach(() => db.close());
39
40 describe("due", () => {
41 test("never fired is due; inside the interval is not; at it is", () => {
42 expect(due(schedule, new Map(), 5)).toEqual(schedule);
43 expect(due(schedule, new Map([["scan", 1000]]), 1000 + HOUR - 1)).toEqual([]);
44 expect(due(schedule, new Map([["scan", 1000]]), 1000 + HOUR)).toEqual(schedule);
45 });
46 });
47
48 describe("fireApp", () => {
49 test("calls a due tool through /forge/tools and keeps how it went; two ticks in a window fire once", async () => {
50 expect(await fireApp(db, "a1", reach(), () => 10_000)).toEqual(["scan"]);
51 expect(seen).toEqual(["/forge/tools/scan"]);
52 expect(await fireApp(db, "a1", reach(), () => 10_000 + HOUR - 1)).toEqual([]);
53 expect(seen.length).toBe(1);
54 expect(lastFires(db, "a1")).toEqual([{ tool: "scan", at: 10_000, ok: true, error: null, durationMs: 0 }]);
55 expect(await fireApp(db, "a1", reach(), () => 10_000 + HOUR)).toEqual(["scan"]);
56 expect(seen.length).toBe(2);
57 });
58
59 test("a failing tool is recorded with its sentence", async () => {
60 answer = () => new Response("no", { status: 500 });
61 await fireApp(db, "a1", reach(), () => 5);
62 const [fire] = lastFires(db, "a1");
63 expect(fire?.ok).toBe(false);
64 expect(fire?.error).toBe("the app answered 500");
65 });
66
67 test("an asleep or stopped app is skipped and not called", async () => {
68 expect(await fireApp(db, "a1", reach({ port: () => undefined }), () => 5)).toEqual([]);
69 expect(seen).toEqual([]);
70 expect(lastFires(db, "a1")).toEqual([]);
71 });
72
73 test("an un-absorbed app is never called", async () => {
74 expect(await fireApp(db, "other", reach(), () => 5)).toEqual([]);
75 expect(seen).toEqual([]);
76 });
77
78 test("a scheduled tool the app no longer declares is not called", async () => {
79 const stale = reach({ declared: async () => ({ tools: ["other"], schedule }) });
80 expect(await fireApp(db, "a1", stale, () => 5)).toEqual([]);
81 expect(seen).toEqual([]);
82 expect(lastFires(db, "a1")).toEqual([]);
83 });
84 });
85
86 const addApp = (id: string, slug: string): void => {
87 db.run("PRAGMA foreign_keys = OFF");
88 db.run("INSERT INTO app (id, repo_id, slug, access) VALUES (?1, ?2, ?3, 'private')", [id, `repo-${id}`, slug]);
89 };
90
91 describe("what is kept", () => {
92 const fire = { tool: "scan", at: 1, ok: true, error: null, durationMs: 2 };
93
94 test("un-absorbing forgets the fires", () => {
95 recordFire(db, "a1", fire);
96 recordFire(db, "other", fire);
97 expect(unabsorb(db, "a1")).toBe(true);
98 expect(lastFires(db, "a1")).toEqual([]);
99 expect(lastFires(db, "other")).toHaveLength(1);
100 });
101
102 test("deleting the app forgets the fires", () => {
103 addApp("a1", "radar");
104 recordFire(db, "a1", fire);
105 expect(deleteApp(db, "a1")).toBe(true);
106 expect(lastFires(db, "a1")).toEqual([]);
107 });
108 });
109
110 describe("fireDue", () => {
111 test("sweeps every app with the injected clock, once per window, and survives one that throws", async () => {
112 addApp("a1", "radar");
113 addApp("a2", "broken");
114 absorb(db, "a2", "admin", "Broken", "/");
115 const warned: string[] = [];
116 const forge = { db, log: { warn: (m: string) => warned.push(m) } } as never;
117 const reachOf = (r: { id: string }): Reach =>
118 r.id === "a2" ? reach({ declared: async () => Promise.reject(new Error("boom")) }) : reach();
119 await fireDue(forge, () => 10_000, reachOf);
120 expect(seen).toEqual(["/forge/tools/scan"]);
121 expect(warned).toEqual(["could not fire a scheduled tool"]);
122 await fireDue(forge, () => 10_000 + HOUR - 1, reachOf);
123 expect(seen.length).toBe(1);
124 await fireDue(forge, () => 10_000 + HOUR, reachOf);
125 expect(seen.length).toBe(2);
126 expect(lastFires(db, "a1")[0]?.at).toBe(10_000 + HOUR);
127 });
128 });
129
130 test("the timer does not start under the test harness", () => {
131 expect(startFeatureSchedule({} as never)).toBeUndefined();
132 server.stop(true);
133 });
test/features-tui.test.ts +12
@@ -12,6 +12,7 @@ import { absorbApp } from "../src/actions/features.ts";
1212 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1313 import { createApp, type App as Forge } from "../src/app.ts";
1414 import { createApp as insertApp } from "../src/core/apps.ts";
15 import { clearFires, recordFire } from "../src/core/feature_fires.ts";
1516 import { featureSettings, getAbsorbed } from "../src/core/features.ts";
1617 import { createUser, type User } from "../src/core/users.ts";
1718 import { createForgeApp } from "../src/tui/forge.ts";
@@ -153,6 +154,17 @@ describe("features in the terminal", () => {
153154 t.stop();
154155 });
155156
157 test("the feed screen says how the last scheduled calls went, on one line", async () => {
158 recordFire(forge.db, appId, { tool: "scan", at: Date.now() - 120_000, ok: false, error: "the app answered 500", durationMs: 3 });
159 const t = boot(ada);
160 await t.palette("Feature feeds");
161 await t.press("enter");
162 await t.press("enter");
163 expect(t.text()).toContain("Scheduled: scan the app answered 500");
164 clearFires(forge.db, appId);
165 t.stop();
166 });
167
156168 test("any signed-in reader is offered it, for the apps they may read", async () => {
157169 const t = boot(eve);
158170 await t.palette("Feature feeds");
test/features.test.ts +14
@@ -28,6 +28,7 @@ import { createApp as insertApp, deleteApp } from "../src/core/apps.ts";
2828 import { listEvents } from "../src/core/events.ts";
2929 import { muteRepo } from "../src/core/interactions.ts";
3030 import { readFeedPage } from "../src/feed/feed.ts";
31 import { clearFires, recordFire } from "../src/core/feature_fires.ts";
3132 import { absorb, featureSettings, getAbsorbed } from "../src/core/features.ts";
3233 import type { Repo } from "../src/core/repos.ts";
3334 import { createToken } from "../src/core/tokens.ts";
@@ -342,6 +343,19 @@ describe("absorbing an app", () => {
342343 expect(list).toMatch(/<dl class="row-facts"><div><dt>Last changed by<\/dt><dd>cy, \d{4}-\d{2}-\d{2}<\/dd><\/div><\/dl>/);
343344 });
344345
346 test("how the forge's scheduled calls went shows on the feature's page and in the API, escaped", async () => {
347 recordFire(app.db, appId, { tool: "scan", at: Date.now() - 120_000, ok: false, error: "<b>the app answered 500</b>", durationMs: 42 });
348 const view = await getFeature(app, ada, "radar");
349 expect(view.ok && view.value.fires).toEqual([{ tool: "scan", at: expect.any(Number), ok: false, error: "<b>the app answered 500</b>", durationMs: 42 }]);
350 const page = await (await web(adaCookie, "/settings/features/radar")).text();
351 expect(page).toContain("<h3>Scheduled</h3>");
352 expect(page).toContain("&lt;b&gt;the app answered 500&lt;/b&gt;");
353 expect(page).not.toContain("<b>the app answered 500");
354 expect(((await (await api("GET", adaToken, "/radar")).json()) as { fires: unknown[] }).fires).toHaveLength(1);
355 clearFires(app.db, appId);
356 expect(await (await web(adaCookie, "/settings/features/radar")).text()).not.toContain("<h3>Scheduled</h3>");
357 });
358
345359 test("the facts are plain and escaped", () => {
346360 const facts = provenanceFacts({ repo: "me/r", upstream: "them/r", changedBy: "<b>x</b>", changedAt: "2026-11-01T10:00:00Z" });
347361 expect(facts).toEqual([

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs