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

Settings › Features shows each scheduled tool's last run, how it went and its next run #10235

Merged opened this

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

Merged

mason/forge:mason/10200-settings-features-shows-each-scheduled-t → main 4 commits fork 8 threads · 8 unresolved

  • Merged. mason/10200-settings-features-shows-each-scheduled-t was merged into main as 4c2e967.

Review

Sign in to review this change.
9 files changed +116 −20
docs/features/absorb.md +2 −4
@@ -82,11 +82,9 @@ 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. 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).
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.
outdated

This says the features tool returns scheduleLine, but it returns schedule as status data, so it is inaccurate. Once the line is wired into the tool, state it exactly. Also say where the terminal shows it.

8686
87 ## Not done yet
88
89 An absorbed app is a menu link with kept settings (Settings › Features says so).
87 Settings › Features › the app, the `features` tool and the terminal's feed screen each show one line per entry: when it last ran, how it went and when it runs next. What each door shows, and the data behind it, is in [feature-schedule.md](feature-schedule.md).
9088
9189 ## MCP tools
9290
docs/features/feature-schedule.md +5 −3
@@ -33,9 +33,11 @@ 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). 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.
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. An admin presses `r` there to run those tools now: the same call, record and refusal sentences as Run now on the page.
36 Every door shows one line per schedule entry, worded once by `scheduleMeta` in `src/apps/feature_schedule.ts`: `scan every 1h · last 10:02, ok · 42 ms · next 11:02`. When the last call failed, its error sentence stands in for `ok`. Before the first call the line reads `scan every 1h · never run · next soon`.
37
38 - **Page:** Settings › Features › the app, under **Scheduled** (a site admin's page, as the rest of it). The last run reads as the page's relative clock (`2m ago`) and the next as its day; both carry the exact instant in their tooltip. A failure is in the danger colour. 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.
39 - **API and the `features` tool:** `GET /api/v1/features/:slug` returns `fires`, a list of `{ tool, at, ok, error, durationMs }` (`at` in ms since the epoch), empty until something has fired. It also returns `schedule`, one `{ tool, every, lastAt, ok, error, durationMs, nextAt, line }` per entry while the app is absorbed. `line` is the plain-text sentence above, with clocks in UTC. Readable by whoever may read the feature.
40 - **Terminal:** Feature feeds › the app: a `Scheduled:` line above the feed items, the entries' `line`s joined by `; `. An admin presses `r` there to run those tools now: the same call, record and refusal sentences as Run now on the page.
3941
4042 ## Not done
4143
src/actions/features.ts +6 −1
@@ -17,6 +17,7 @@
1717 * `FORGE_SETTING_*` in a new process, the same way an env var does.
1818 */
1919
20 import { scheduleStatus, type ScheduleStatus } from "../apps/feature_schedule.ts";
2021 import { lastFires, type Fired } from "../core/feature_fires.ts";
2122 import type { App } from "../app.ts";
2223 import { readFileAt } from "../apps/checkout.ts";
@@ -91,6 +92,8 @@ export interface FeatureView {
9192 readonly tools: readonly ToolSpec[];
9293 /** How the forge's last call of each scheduled tool went; empty when none has fired. */
9394 readonly fires: readonly Fired[];
95 /** Each scheduled tool with its last fire and next run; empty when not absorbed. */
96 readonly schedule: readonly ScheduleStatus[];

The tool returns ScheduleStatus objects, not the line. Box 1 wants the line reused by the MCP tool; add the line here, or the tool consumer must format it. The TUI (src/tui/screens/featurefeed.ts reads .fires) also needs to show these lines. Both doors must show the same thing, so use schedule and the shared line there.

9497 /** Where the app comes from: the upstream only when the reader may read it. */
9598 readonly provenance: Provenance;
9699 }
@@ -223,6 +226,7 @@ function viewOf(
223226 const kept = spec === undefined ? featureSettings(app.db, record.id) : openSettings(app.db, app.config.dataDir, record.id, spec);
224227 const absorbed = getAbsorbed(app.db, record.id);
225228 const env = spec === undefined ? {} : featureEnv(spec, kept);
229 const fires = absorbed === undefined ? [] : lastFires(app.db, record.id);
226230 return {
227231 slug: record.slug,
228232 repo: `${repo.ownerName}/${repo.name}`,
@@ -241,7 +245,8 @@ function viewOf(
241245 problem,
242246 provenance,
243247 tools: spec?.tools ?? [],
244 fires: absorbed === undefined ? [] : lastFires(app.db, record.id),
248 fires,
249 schedule: absorbed === undefined ? [] : scheduleStatus(spec?.schedule ?? [], fires),
245250 settings: (spec?.settings ?? []).map((s) => {
246251 const name = settingEnvName(s.key);
247252 return {
src/apps/feature_schedule.ts +62 −2
@@ -10,11 +10,11 @@ import type { Database } from "bun:sqlite";
1010 import type { App } from "../app.ts";
1111 import { declaredFeature } from "../actions/features.ts";
1212 import { getAbsorbed } from "../core/features.ts";
13 import { lastFires, recordFire } from "../core/feature_fires.ts";
13 import { lastFires, recordFire, type Fired } from "../core/feature_fires.ts";
1414 import { listApps, type AppRecord } from "../core/apps.ts";
1515 import { getRepoById } from "../core/repos.ts";
1616 import { callTool } from "./feature_tools.ts";
17 import type { ScheduleEntry } from "./feature.ts";
17 import { SCHEDULE_EVERY, type ScheduleEntry } from "./feature.ts";
1818 import { appsRuntime } from "./runtime.ts";
1919
2020 /** The entries whose interval has passed since they last fired (never fired: due). `lastFired` is ms by tool. */
@@ -153,3 +153,63 @@ export function startFeatureSchedule(app: App, tickMs: number = TICK_MS): { stop
153153 tick.unref();
154154 return { stop: () => clearInterval(tick) };
155155 }
156
157 /** One scheduled tool as plain data: how often, how its last fire went, when the next is due. Times are ms since the epoch. */
158 export interface ScheduleStatus {
159 readonly tool: string;
160 readonly every: string;
161 readonly lastAt: number | null;
162 readonly ok: boolean | null;
163 /** The failure's sentence; null when the last fire went fine or there is none. */
164 readonly error: string | null;
165 /** How long the last fire took; null before the first. */
166 readonly durationMs: number | null;
167 /** When it is next due: its last fire plus the interval, or null when it has never fired (due soon). */
168 readonly nextAt: number | null;
169 /** The whole line as `scheduleLine` words it, for the tool's readers and the terminal. */
170 readonly line: string;
171 }
172
173 /** The interval as the app declared it (`15m`, `1h`, `6h`, `1d`). */
174 function everyWord(everyMs: number): string {
175 return Object.entries(SCHEDULE_EVERY).find(([, ms]) => ms === everyMs)?.[0] ?? `${Math.round(everyMs / 60_000)}m`;
176 }
177
178 /** One status per schedule entry, in the app's order, joined to the last fire of its tool. */
179 export function scheduleStatus(schedule: readonly ScheduleEntry[], fires: readonly Fired[]): ScheduleStatus[] {
180 return schedule.map((e) => {
181 const fire = fires.find((f) => f.tool === e.tool);
182 const status: Omit<ScheduleStatus, "line"> = {
183 tool: e.tool,
184 every: everyWord(e.everyMs),
outdated

The ?? "failed" fallback is repeated in scheduleMeta; decide it once.

185 lastAt: fire?.at ?? null,
186 ok: fire?.ok ?? null,
187 durationMs: fire?.durationMs ?? null,
188 error: fire === undefined || fire.ok ? null : (fire.error ?? "failed"),
189 nextAt: fire === undefined ? null : fire.at + e.everyMs,
190 };
191 return { ...status, line: scheduleLine(status) };
192 });
193 }
outdated

scheduleLine is exported but nothing in src/ calls it, so it is dead code, and the test passes on a formatter that production never runs. Fix: have the page and the features tool consume it. Either add line: scheduleLine(s) to each ScheduleStatus in scheduleStatus, or have the page render from it. Then the test covers what users see.

194
195 /** `10:02`: the plain-text clock (UTC) of the line the tool and the terminal print. */
196 export const scheduleClock = (ms: number): string => new Date(ms).toISOString().slice(11, 16);
197
198 /** A piece of the line after the head: words, the last or next moment (ms) the reader formats, or the sentence of a failure. */
outdated

Principle 8: 'next on the next look' is scheduler jargon. Say 'next soon' or 'due now'.

199 export type SchedulePiece = string | { readonly last: number } | { readonly next: number } | { readonly error: string };
200
201 /** The wording of the line after `scan every 1h`, once, for every door: the page formats the moments and styles the error, the rest print it with `scheduleLine`. */
202 export function scheduleMeta(s: Omit<ScheduleStatus, "line">): SchedulePiece[] {
203 if (s.lastAt === null) return ["never run · next soon"];
204 return [
205 "last ", { last: s.lastAt }, ", ", s.error === null ? "ok" : { error: s.error },
206 ...(s.durationMs === null ? [] : [` · ${s.durationMs} ms`]),
207 " · next ", { next: s.nextAt ?? s.lastAt },
208 ];
209 }
210
211 /** The line for one entry: `scan every 1h · last 10:02, ok · 42 ms · next 11:02` (UTC), the error's sentence when it failed, `never run · next soon` before the first fire. */
212 export function scheduleLine(s: Omit<ScheduleStatus, "line">): string {
213 const word = (p: SchedulePiece): string => (typeof p === "string" ? p : "error" in p ? p.error : scheduleClock("last" in p ? p.last : p.next));
214 return `${s.tool} every ${s.every} · ${scheduleMeta(s).map(word).join("")}`;
215 }
src/tui/screens/featurefeed.ts +5 −2
@@ -29,9 +29,11 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc
2929 const state = new ListState();
3030 let items: readonly FeedItem[] = [];
3131 let fires: readonly Fired[] = [];
32 let lines: readonly string[] = [];
3233 void getFeature(f.app, f.user, feature.slug).then((found) => {
3334 items = found.ok ? feedOf(f.app, f.user, found.value) : [];
3435 fires = found.ok ? found.value.fires : [];
36 lines = found.ok ? found.value.schedule.map((x) => x.line) : [];
3537 ctx.invalidate();
3638 });
3739 const mayRun = (): boolean => f.user?.isAdmin === true && f.user.kind !== "agent" && fires.length > 0;
@@ -48,6 +50,7 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc
4850 }
4951 const found = await getFeature(f.app, f.user, feature.slug);
5052 fires = found.ok ? found.value.fires : fires;
53 lines = found.ok ? found.value.schedule.map((x) => x.line) : lines;
5154 } finally {
5255 running = false;
5356 ctx.invalidate();
@@ -58,9 +61,9 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc
5861 keys: [binding(["j", "k"], "move"), ...(mayRun() ? [binding(["r"], "run scheduled now")] : []), binding(["esc", "q"], "back")],
5962 render(area, s) {
6063 const c = ctx.theme.c;
61 const [head, body] = fires.length === 0 ? [undefined, area] : split(area, "v", [len(1), fill(1)]);
64 const [head, body] = lines.length === 0 ? [undefined, area] : split(area, "v", [len(1), fill(1)]);
6265 if (head !== undefined) {
63 const line = `Scheduled: ${fires.map((x) => `${x.tool} ${x.ok ? "ok" : oneLine(x.error ?? "failed")} ${ago(new Date(x.at).toISOString())}`).join("; ")}`;
66 const line = `Scheduled: ${lines.map(oneLine).join("; ")}`;
6467 s.text(head.x, head.y, truncate(line, head.w), style(c.mutedFg, c.bg), head.x + head.w);
6568 }
6669 if (items.length === 0) {
src/web/pages/features.ts +5 −5
@@ -19,7 +19,7 @@ import {
1919 provenanceFacts,
2020 } from "../../actions/features.ts";
2121 import { runNow } from "../../apps/feature_run.ts";
22 import { runtimeReach, type Reach } from "../../apps/feature_schedule.ts";
22 import { runtimeReach, scheduleMeta, type Reach } from "../../apps/feature_schedule.ts";
2323 import { getAppBySlug, type AppRecord } from "../../core/apps.ts";
2424 import { DAYS } from "../../apps/feature.ts";
2525 import type { FeedItem } from "../../feed/rank.ts";
@@ -224,11 +224,11 @@ function featureView(ctx: Ctx, f: FeatureView, refused?: Refused, items: readonl
224224 meta: html`${i.actor ?? i.app} · ${time(i.at)}`,
225225 }))}</ul>`}
226226 </section>
227 ${when(f.fires.length > 0, () => html`<section class="settings-block">
227 ${when(f.schedule.length > 0, () => html`<section class="settings-block">
228228 <h3>Scheduled</h3>
229 <ul class="list">${f.fires.map((x) => row({
230 title: html`<span class="mono">${x.tool}</span>`,
231 meta: html`${x.ok ? "ok" : html`<span class="danger">${x.error ?? "failed"}</span>`} · ${time(new Date(x.at).toISOString())} · ${x.durationMs} ms`,
229 <ul class="list">${f.schedule.map((x) => row({
230 title: html`<span class="mono">${x.tool}</span> every ${x.every}`,
231 meta: html`${scheduleMeta(x).map((p) => typeof p === "string" ? p : "error" in p ? html`<span class="danger">${p.error}</span>` : "last" in p ? time(new Date(p.last).toISOString()) : time(new Date(p.next).toISOString(), { date: true }))}`,
outdated

Principles 3/6/8 and the design system: a hand-rolled <time> plus 'UTC' next to time() makes two clocks in one row, and p.at === x.lastAt is a fragile way to choose. Use time() for both, tagged by the piece. Also durationMs (the old · N ms) is gone; keep it.

Non-blocking: an hourly tool's next run reads as a date here (2026-11-09), which tells a person little on a phone. Word future instants in time() as its own change, as you noted.

232232 trail: html`<form method="post" action="${base}/run/${encodeURIComponent(x.tool)}">${btn("Run now", { label: `Run ${x.tool} now` })}</form>`,
233233 }))}</ul>
outdated

This hand-builds the same line scheduleLine builds, so the two can drift. The page needs the time() clock, so it could split the text around the clock, or scheduleLine could take a clock formatter. Keep one function that decides the wording (never run · next on the next look, last X, ok, next Y) and have the page use it. Also check that a failed run still shows the danger styling.

234234 </section>`)}
test/feature-schedule-line.test.ts added +22
@@ -0,0 +1,22 @@
1 /** The line Settings › Features and the `features` tool show for each scheduled tool (#10200). */
2 import { describe, expect, test } from "bun:test";
3 import { scheduleLine, scheduleStatus } from "../src/apps/feature_schedule.ts";
4
5 const HOUR = 3_600_000;
6 const schedule = [{ tool: "scan", everyMs: HOUR }];
7 const at = Date.UTC(2026, 10, 9, 10, 2);
8
9 describe("scheduleLine", () => {
10 test("ok: last run, how it went, next run", () => {
11 const [s] = scheduleStatus(schedule, [{ tool: "scan", at, ok: true, error: null, durationMs: 5 }]);
12 expect(scheduleLine(s!)).toBe("scan every 1h · last 10:02, ok · 5 ms · next 11:02");
13 });
14 test("failed: the error's sentence", () => {
15 const [s] = scheduleStatus(schedule, [{ tool: "scan", at, ok: false, error: "the app answered 500", durationMs: 5 }]);
16 expect(scheduleLine(s!)).toBe("scan every 1h · last 10:02, the app answered 500 · 5 ms · next 11:02");
17 });
18 test("never run", () => {
19 const [s] = scheduleStatus(schedule, []);
20 expect(scheduleLine(s!)).toBe("scan every 1h · never run · next soon");
21 });
22 });
test/features-tui.test.ts +2 −2
@@ -26,7 +26,7 @@ quietLogs();
2626
2727 const FORGE_JSON = JSON.stringify({
2828 app: { run: "bun run src/server.ts" },
29 feature: { name: "Radar", page: "/", settings: { runHourUtc: { type: "int", default: 13, min: 0, max: 23, label: "Hour (UTC)" }, apiToken: { type: "secret", label: "API token" } } },
29 feature: { name: "Radar", page: "/", settings: { runHourUtc: { type: "int", default: 13, min: 0, max: 23, label: "Hour (UTC)" }, apiToken: { type: "secret", label: "API token" } }, tools: { scan: { description: "Run a scan now" } }, schedule: [{ tool: "scan", every: "1h" }] },
3030 });
3131
3232 describe("features in the terminal", () => {
@@ -161,7 +161,7 @@ describe("features in the terminal", () => {
161161 await t.palette("Feature feeds");
162162 await t.press("enter");
163163 await t.press("enter");
164 expect(t.text()).toContain("Scheduled: scan the app answered 500");
164 expect(t.text()).toMatch(/Scheduled: scan every 1h · last \d\d:\d\d, the app answered 500 · 3 ms · next \d\d:\d\d/);
165165 clearFires(forge.db, appId);
166166 t.stop();
167167 });
test/features.test.ts +7 −1
@@ -54,6 +54,7 @@ const FEATURE = {
5454 scout: { type: "bool", default: true, label: "Search the web for ideas" },
5555 },
5656 tools: { scan: { description: "Run a scan now" }, latest: { description: "The last findings", writes: false } },
57 schedule: [{ tool: "scan", every: "1h" }],
5758 };
5859 const FORGE_JSON = JSON.stringify({ app: { run: "bun run src/server.ts" }, feature: FEATURE });
5960
@@ -351,9 +352,14 @@ describe("absorbing an app", () => {
351352 expect(page).toContain("<h3>Scheduled</h3>");
352353 expect(page).toContain("&lt;b&gt;the app answered 500&lt;/b&gt;");
353354 expect(page).not.toContain("<b>the app answered 500");
355 expect(page).toMatch(/every 1h<\/span\s*>[\s\S]*?last <time datetime="[^"]+"[^>]*>[^<]+<\/time\s*>, <span class="danger">&lt;b&gt;the app answered 500&lt;\/b&gt;<\/span> · 42 ms · next <time datetime="[^"]+"[^>]*>\d{4}-\d\d-\d\d<\/time\s*>/);
356 expect(view.ok && view.value.schedule[0]!.line).toMatch(/^scan every 1h · last \d\d:\d\d, <b>the app answered 500<\/b> · 42 ms · next \d\d:\d\d$/);
354357 expect(((await (await api("GET", adaToken, "/radar")).json()) as { fires: unknown[] }).fires).toHaveLength(1);

toContain("next") is weak: the page already says 'next' elsewhere. Assert the full row, for example last with a <time and next on the next look for the never-run case. Add a test for the tool payload and the TUI screen.

355358 clearFires(app.db, appId);
356 expect(await (await web(adaCookie, "/settings/features/radar")).text()).not.toContain("<h3>Scheduled</h3>");
359 const never = await (await web(adaCookie, "/settings/features/radar")).text();
360 expect(never).toContain("never run · next soon");
361 const idle = await getFeature(app, ada, "radar");
362 expect(idle.ok && idle.value.schedule[0]!.line).toBe("scan every 1h · never run · next soon");
357363 });
358364
359365 test("the facts are plain and escaped", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs