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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9926-the-forge-calls-an-absorbed-app-s-schedu → main 4 commits fork
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 | ||
| 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. 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). | |
| 86 | 86 | |
| 87 | 87 | ## Not done yet |
| 88 | 88 | |
| 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). | |
| 90 | 90 | |
| 91 | 91 | ## MCP tools |
| 92 | 92 |
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 @@ | ||
| 17 | 17 | * `FORGE_SETTING_*` in a new process, the same way an env var does. |
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | import { lastFires, type Fired } from "../core/feature_fires.ts"; | |
| 20 | 21 | import type { App } from "../app.ts"; |
| 21 | 22 | import { readFileAt } from "../apps/checkout.ts"; |
| 22 | 23 | import { checkSetting, featureEnv, parseFeature, settingEnvName, type FeatureSpec } from "../apps/feature.ts"; |
| @@ -88,6 +89,8 @@ export interface FeatureView { | ||
| 88 | 89 | readonly settings: readonly SettingView[]; |
| 89 | 90 | /** The MCP tools the app declares; the `features` tool calls them while it is absorbed. */ |
| 90 | 91 | 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[]; | |
| 91 | 94 | /** Where the app comes from: the upstream only when the reader may read it. */ |
| 92 | 95 | readonly provenance: Provenance; |
| 93 | 96 | } |
| @@ -238,6 +241,7 @@ function viewOf( | ||
| 238 | 241 | problem, |
| 239 | 242 | provenance, |
| 240 | 243 | tools: spec?.tools ?? [], |
| 244 | fires: absorbed === undefined ? [] : lastFires(app.db, record.id), | |
| 241 | 245 | settings: (spec?.settings ?? []).map((s) => { |
| 242 | 246 | const name = settingEnvName(s.key); |
| 243 | 247 | return { |
src/apps/feature.ts +1 −1
| @@ -68,7 +68,7 @@ export interface FeatureSpec { | ||
| 68 | 68 | readonly settings: readonly SettingSpec[]; |
| 69 | 69 | /** MCP tools the app serves through the forge's `features` tool (`feature_tools.ts`). */ |
| 70 | 70 | 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`. */ | |
| 72 | 72 | readonly schedule: readonly ScheduleEntry[]; |
| 73 | 73 | } |
| 74 | 74 |
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 { | ||
| 235 | 235 | db.run("DELETE FROM app_vital WHERE app_id = ?1", [appId]); |
| 236 | 236 | db.run("DELETE FROM app_feature WHERE app_id = ?1", [appId]); |
| 237 | 237 | db.run("DELETE FROM app_feature_setting WHERE app_id = ?1", [appId]); |
| 238 | db.run("DELETE FROM feature_fire WHERE app_id = ?1", [appId]); | |
| 238 | 239 | return db.query("DELETE FROM app WHERE id = ?1").run(appId).changes > 0; |
| 239 | 240 | })(); |
| 240 | 241 | } |
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 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | import { clearFires } from "./feature_fires.ts"; | |
| 8 | 9 | |
| 9 | 10 | export interface Absorbed { |
| 10 | 11 | readonly appId: string; |
| @@ -45,6 +46,7 @@ export function keepDeclaration(db: Database, appId: string, name: string, page: | ||
| 45 | 46 | |
| 46 | 47 | /** True when the app was absorbed. Its settings stay, so absorbing again picks them up. */ |
| 47 | 48 | export function unabsorb(db: Database, appId: string): boolean { |
| 49 | clearFires(db, appId); | |
| 48 | 50 | return db.query("DELETE FROM app_feature WHERE app_id = ?1").run(appId).changes > 0; |
| 49 | 51 | } |
| 50 | 52 |
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 { | ||
| 470 | 470 | import { captureStore, loopWatch, perfRecord, readLedger } from "../perf/live.ts"; |
| 471 | 471 | import { classOf, countryOf, repoOf, signatureOf } from "../perf/traffic.ts"; |
| 472 | 472 | import { createTrafficWriter } from "../perf/traffic_store.ts"; |
| 473 | import { startFeatureSchedule } from "../apps/feature_schedule.ts"; | |
| 473 | 474 | import { startGithubPull } from "../perf/github_traffic.ts"; |
| 474 | 475 | import { appSourcesPage } from "../web/pages/sources.ts"; |
| 475 | 476 | import { createViewWriter } from "../perf/views_store.ts"; |
| @@ -2310,6 +2311,7 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2310 | 2311 | // Page views are buffered by the beacon doors and written here; GitHub's traffic is pulled daily when a token is set. |
| 2311 | 2312 | const viewWriter = createViewWriter(app.db, app.log); |
| 2312 | 2313 | const githubPull = startGithubPull(app.db, app.log); |
| 2314 | const featureSchedule = startFeatureSchedule(app); | |
| 2313 | 2315 | const readWriter = createReadWriter(app.db, readLedger, app.log); |
| 2314 | 2316 | const counted = ( |
| 2315 | 2317 | owner: App, |
| @@ -2632,6 +2634,7 @@ export async function serve(app: App): Promise<Servers> { | ||
| 2632 | 2634 | trafficWriter.stop(); |
| 2633 | 2635 | viewWriter.stop(); |
| 2634 | 2636 | githubPull?.stop(); |
| 2637 | featureSchedule?.stop(); | |
| 2635 | 2638 | readWriter.stop(); |
| 2636 | 2639 | // A child's hooks stop answering before its database closes. |
| 2637 | 2640 | for (const key of [...new Set([...childHttps.keys(), ...childInternals.keys()])]) letGo(key); |
src/tui/screens/featurefeed.ts +13 −2
| @@ -7,10 +7,14 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { absorbedForNav, feedOf, getFeature, type NavFeature } from "../../actions/features.ts"; |
| 10 | import type { Fired } from "../../core/feature_fires.ts"; | |
| 10 | 11 | import type { FeedItem } from "../../feed/rank.ts"; |
| 11 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| 12 | 13 | import { binding } from "../app/keys.ts"; |
| 13 | 14 | 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"; | |
| 14 | 18 | import { emptyState } from "../ui/widgets.ts"; |
| 15 | 19 | import { ListState, ago, drawList, type ListRow } from "./common.ts"; |
| 16 | 20 | import type { PaletteEntry } from "./entries.ts"; |
| @@ -21,8 +25,10 @@ const oneLine = (text: string): string => text.replace(/[\p{Cc}\p{Cf}\u2028\u202 | ||
| 21 | 25 | export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Screen { |
| 22 | 26 | const state = new ListState(); |
| 23 | 27 | let items: readonly FeedItem[] = []; |
| 28 | let fires: readonly Fired[] = []; | |
| 24 | 29 | void getFeature(f.app, f.user, feature.slug).then((found) => { |
| 25 | 30 | items = found.ok ? feedOf(f.app, f.user, found.value) : []; |
| 31 | fires = found.ok ? found.value.fires : []; | |
| 26 | 32 | ctx.invalidate(); |
| 27 | 33 | }); |
| 28 | 34 | return { |
| @@ -30,12 +36,17 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc | ||
| 30 | 36 | keys: [binding(["j", "k"], "move"), binding(["esc", "q"], "back")], |
| 31 | 37 | render(area, s) { |
| 32 | 38 | 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 | } | |
| 33 | 44 | if (items.length === 0) { |
| 34 | emptyState(s, area, c, { title: "Nothing yet." }); | |
| 45 | emptyState(s, body!, c, { title: "Nothing yet." }); | |
| 35 | 46 | return; |
| 36 | 47 | } |
| 37 | 48 | 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); | |
| 39 | 50 | }, |
| 40 | 51 | key(e) { |
| 41 | 52 | 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 | ||
| 221 | 221 | meta: html`${i.actor ?? i.app} · ${time(i.at)}`, |
| 222 | 222 | }))}</ul>`} |
| 223 | 223 | </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>`)} | |
| 224 | 231 | <p class="muted small"> |
| 225 | 232 | Saving restarts ${f.name}: its process reads each setting as an environment variable, named on the right. |
| 226 | 233 | <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"; | ||
| 12 | 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 13 | 13 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 14 | 14 | import { createApp as insertApp } from "../src/core/apps.ts"; |
| 15 | import { clearFires, recordFire } from "../src/core/feature_fires.ts"; | |
| 15 | 16 | import { featureSettings, getAbsorbed } from "../src/core/features.ts"; |
| 16 | 17 | import { createUser, type User } from "../src/core/users.ts"; |
| 17 | 18 | import { createForgeApp } from "../src/tui/forge.ts"; |
| @@ -153,6 +154,17 @@ describe("features in the terminal", () => { | ||
| 153 | 154 | t.stop(); |
| 154 | 155 | }); |
| 155 | 156 | |
| 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 | ||
| 156 | 168 | test("any signed-in reader is offered it, for the apps they may read", async () => { |
| 157 | 169 | const t = boot(eve); |
| 158 | 170 | await t.palette("Feature feeds"); |
test/features.test.ts +14
| @@ -28,6 +28,7 @@ import { createApp as insertApp, deleteApp } from "../src/core/apps.ts"; | ||
| 28 | 28 | import { listEvents } from "../src/core/events.ts"; |
| 29 | 29 | import { muteRepo } from "../src/core/interactions.ts"; |
| 30 | 30 | import { readFeedPage } from "../src/feed/feed.ts"; |
| 31 | import { clearFires, recordFire } from "../src/core/feature_fires.ts"; | |
| 31 | 32 | import { absorb, featureSettings, getAbsorbed } from "../src/core/features.ts"; |
| 32 | 33 | import type { Repo } from "../src/core/repos.ts"; |
| 33 | 34 | import { createToken } from "../src/core/tokens.ts"; |
| @@ -342,6 +343,19 @@ describe("absorbing an app", () => { | ||
| 342 | 343 | expect(list).toMatch(/<dl class="row-facts"><div><dt>Last changed by<\/dt><dd>cy, \d{4}-\d{2}-\d{2}<\/dd><\/div><\/dl>/); |
| 343 | 344 | }); |
| 344 | 345 | |
| 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("<b>the app answered 500</b>"); | |
| 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 | ||
| 345 | 359 | test("the facts are plain and escaped", () => { |
| 346 | 360 | const facts = provenanceFacts({ repo: "me/r", upstream: "them/r", changedBy: "<b>x</b>", changedAt: "2026-11-01T10:00:00Z" }); |
| 347 | 361 | expect(facts).toEqual([ |