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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10200-settings-features-shows-each-scheduled-t → main 4 commits fork 8 threads · 8 unresolved
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 | ||
| 82 | 82 | |
| 83 | 83 | ## Schedule |
| 84 | 84 | |
| 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). | |
| 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. | |
| 86 | 86 | |
| 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). | |
| 90 | 88 | |
| 91 | 89 | ## MCP tools |
| 92 | 90 |
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 | ||
| 33 | 33 | |
| 34 | 34 | ## Read it |
| 35 | 35 | |
| 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. | |
| 39 | 41 | |
| 40 | 42 | ## Not done |
| 41 | 43 |
src/actions/features.ts +6 −1
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | * `FORGE_SETTING_*` in a new process, the same way an env var does. |
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | import { scheduleStatus, type ScheduleStatus } from "../apps/feature_schedule.ts"; | |
| 20 | 21 | import { lastFires, type Fired } from "../core/feature_fires.ts"; |
| 21 | 22 | import type { App } from "../app.ts"; |
| 22 | 23 | import { readFileAt } from "../apps/checkout.ts"; |
| @@ -91,6 +92,8 @@ export interface FeatureView { | ||
| 91 | 92 | readonly tools: readonly ToolSpec[]; |
| 92 | 93 | /** How the forge's last call of each scheduled tool went; empty when none has fired. */ |
| 93 | 94 | 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 |
||
| 94 | 97 | /** Where the app comes from: the upstream only when the reader may read it. */ |
| 95 | 98 | readonly provenance: Provenance; |
| 96 | 99 | } |
| @@ -223,6 +226,7 @@ function viewOf( | ||
| 223 | 226 | const kept = spec === undefined ? featureSettings(app.db, record.id) : openSettings(app.db, app.config.dataDir, record.id, spec); |
| 224 | 227 | const absorbed = getAbsorbed(app.db, record.id); |
| 225 | 228 | const env = spec === undefined ? {} : featureEnv(spec, kept); |
| 229 | const fires = absorbed === undefined ? [] : lastFires(app.db, record.id); | |
| 226 | 230 | return { |
| 227 | 231 | slug: record.slug, |
| 228 | 232 | repo: `${repo.ownerName}/${repo.name}`, |
| @@ -241,7 +245,8 @@ function viewOf( | ||
| 241 | 245 | problem, |
| 242 | 246 | provenance, |
| 243 | 247 | tools: spec?.tools ?? [], |
| 244 | fires: absorbed === undefined ? [] : lastFires(app.db, record.id), | |
| 248 | fires, | |
| 249 | schedule: absorbed === undefined ? [] : scheduleStatus(spec?.schedule ?? [], fires), | |
| 245 | 250 | settings: (spec?.settings ?? []).map((s) => { |
| 246 | 251 | const name = settingEnvName(s.key); |
| 247 | 252 | return { |
src/apps/feature_schedule.ts +62 −2
| @@ -10,11 +10,11 @@ import type { Database } from "bun:sqlite"; | ||
| 10 | 10 | import type { App } from "../app.ts"; |
| 11 | 11 | import { declaredFeature } from "../actions/features.ts"; |
| 12 | 12 | 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"; | |
| 14 | 14 | import { listApps, type AppRecord } from "../core/apps.ts"; |
| 15 | 15 | import { getRepoById } from "../core/repos.ts"; |
| 16 | 16 | import { callTool } from "./feature_tools.ts"; |
| 17 | import type { ScheduleEntry } from "./feature.ts"; | |
| 17 | import { SCHEDULE_EVERY, type ScheduleEntry } from "./feature.ts"; | |
| 18 | 18 | import { appsRuntime } from "./runtime.ts"; |
| 19 | 19 | |
| 20 | 20 | /** 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 | ||
| 153 | 153 | tick.unref(); |
| 154 | 154 | return { stop: () => clearInterval(tick) }; |
| 155 | 155 | } |
| 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 |
||
| 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
|
||
| 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 | ||
| 29 | 29 | const state = new ListState(); |
| 30 | 30 | let items: readonly FeedItem[] = []; |
| 31 | 31 | let fires: readonly Fired[] = []; |
| 32 | let lines: readonly string[] = []; | |
| 32 | 33 | void getFeature(f.app, f.user, feature.slug).then((found) => { |
| 33 | 34 | items = found.ok ? feedOf(f.app, f.user, found.value) : []; |
| 34 | 35 | fires = found.ok ? found.value.fires : []; |
| 36 | lines = found.ok ? found.value.schedule.map((x) => x.line) : []; | |
| 35 | 37 | ctx.invalidate(); |
| 36 | 38 | }); |
| 37 | 39 | 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 | ||
| 48 | 50 | } |
| 49 | 51 | const found = await getFeature(f.app, f.user, feature.slug); |
| 50 | 52 | fires = found.ok ? found.value.fires : fires; |
| 53 | lines = found.ok ? found.value.schedule.map((x) => x.line) : lines; | |
| 51 | 54 | } finally { |
| 52 | 55 | running = false; |
| 53 | 56 | ctx.invalidate(); |
| @@ -58,9 +61,9 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc | ||
| 58 | 61 | keys: [binding(["j", "k"], "move"), ...(mayRun() ? [binding(["r"], "run scheduled now")] : []), binding(["esc", "q"], "back")], |
| 59 | 62 | render(area, s) { |
| 60 | 63 | 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)]); | |
| 62 | 65 | 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("; ")}`; | |
| 64 | 67 | s.text(head.x, head.y, truncate(line, head.w), style(c.mutedFg, c.bg), head.x + head.w); |
| 65 | 68 | } |
| 66 | 69 | if (items.length === 0) { |
src/web/pages/features.ts +5 −5
| @@ -19,7 +19,7 @@ import { | ||
| 19 | 19 | provenanceFacts, |
| 20 | 20 | } from "../../actions/features.ts"; |
| 21 | 21 | 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"; | |
| 23 | 23 | import { getAppBySlug, type AppRecord } from "../../core/apps.ts"; |
| 24 | 24 | import { DAYS } from "../../apps/feature.ts"; |
| 25 | 25 | import type { FeedItem } from "../../feed/rank.ts"; |
| @@ -224,11 +224,11 @@ function featureView(ctx: Ctx, f: FeatureView, refused?: Refused, items: readonl | ||
| 224 | 224 | meta: html`${i.actor ?? i.app} · ${time(i.at)}`, |
| 225 | 225 | }))}</ul>`} |
| 226 | 226 | </section> |
| 227 | ${when(f.fires.length > 0, () => html`<section class="settings-block"> | |
| 227 | ${when(f.schedule.length > 0, () => html`<section class="settings-block"> | |
| 228 | 228 | <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 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. |
||
| 232 | 232 | trail: html`<form method="post" action="${base}/run/${encodeURIComponent(x.tool)}">${btn("Run now", { label: `Run ${x.tool} now` })}</form>`, |
| 233 | 233 | }))}</ul> |
outdated
This hand-builds the same line |
||
| 234 | 234 | </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(); | ||
| 26 | 26 | |
| 27 | 27 | const FORGE_JSON = JSON.stringify({ |
| 28 | 28 | 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" }] }, | |
| 30 | 30 | }); |
| 31 | 31 | |
| 32 | 32 | describe("features in the terminal", () => { |
| @@ -161,7 +161,7 @@ describe("features in the terminal", () => { | ||
| 161 | 161 | await t.palette("Feature feeds"); |
| 162 | 162 | await t.press("enter"); |
| 163 | 163 | 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/); | |
| 165 | 165 | clearFires(forge.db, appId); |
| 166 | 166 | t.stop(); |
| 167 | 167 | }); |
test/features.test.ts +7 −1
| @@ -54,6 +54,7 @@ const FEATURE = { | ||
| 54 | 54 | scout: { type: "bool", default: true, label: "Search the web for ideas" }, |
| 55 | 55 | }, |
| 56 | 56 | tools: { scan: { description: "Run a scan now" }, latest: { description: "The last findings", writes: false } }, |
| 57 | schedule: [{ tool: "scan", every: "1h" }], | |
| 57 | 58 | }; |
| 58 | 59 | const FORGE_JSON = JSON.stringify({ app: { run: "bun run src/server.ts" }, feature: FEATURE }); |
| 59 | 60 | |
| @@ -351,9 +352,14 @@ describe("absorbing an app", () => { | ||
| 351 | 352 | expect(page).toContain("<h3>Scheduled</h3>"); |
| 352 | 353 | expect(page).toContain("<b>the app answered 500</b>"); |
| 353 | 354 | 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"><b>the app answered 500<\/b><\/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$/); | |
| 354 | 357 | expect(((await (await api("GET", adaToken, "/radar")).json()) as { fires: unknown[] }).fires).toHaveLength(1); |
|
||
| 355 | 358 | 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"); | |
| 357 | 363 | }); |
| 358 | 364 | |
| 359 | 365 | test("the facts are plain and escaped", () => { |
This says the
featurestool returnsscheduleLine, but it returnsscheduleas status data, so it is inaccurate. Once the line is wired into the tool, state it exactly. Also say where the terminal shows it.