Public: anyone can read this; the crew can change it.
A schedule fires at a clock time, and every way of running its tool is the sa… #12044
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12043-a-schedule-fires-at-a-clock-time-and-eve → main 7 commits fork
18 files
changed +623
−112
docs/features/feature-schedule.md +9
| @@ -15,6 +15,7 @@ In the app's `forge.json`, next to the `tools` the schedule names: | ||
| 15 | 15 | |
| 16 | 16 | |
| 17 | 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 | - **Clock time:** `{ "tool": "print_today", "at": "07:15", "tz": "America/Chicago" }` instead of `every` (exactly one of the two; `tz` is an IANA name, daylight saving is followed). It fires once per local day; a forge that was away fires it on its next look within a 3 h grace window and records `late`, and past the window skips the day and records `skipped`. A manual run does not move an `at` schedule (it does move an `every` one). A new or changed schedule first fires at its next due time, never on absorb; Run now is for the impatient. Every way of running a scheduled tool (schedule, page, terminal, the `features` tool) is the one recorded run; a tool the schedule does not name is not recorded. | |
| 18 | 19 | - Each entry names a tool the feature declares. A tool that is no longer declared is skipped, never called. |
| 19 | 20 | - The tool is called with no arguments, so it should be one that needs none. |
| 20 | 21 | |
| @@ -42,3 +43,11 @@ Every door shows one line per schedule entry, worded once by `scheduleMeta` in ` | ||
| 42 | 43 | ## Not done |
| 43 | 44 | |
| 44 | 45 | No arguments per entry, no history beyond the last fire, and no wake-on-schedule for a stopped app. |
| 46 | ||
| 47 | ## Run history | |
| 48 | ||
| 49 | Each scheduled tool keeps its last 30 runs, with how each started (`schedule`, `late`, `skipped`, `page`, `terminal`, `mcp`), when, whether it went well and how long it took. | |
| 50 | ||
| 51 | - **Features page:** "Run history", one list per tool, the newest 5 shown and the older ones behind "N older". | |
| 52 | - **Terminal:** open the feature under Feature feeds and press `h` to switch between the feed and the history; the Scheduled lines above (one per tool, with `at`/zone) stay. | |
| 53 | - **API:** `history` in `GET /api/v1/features/:slug`. |
src/actions/feature_tools.ts +12 −2
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { App } from "../app.ts"; |
| 10 | import { runRecorded } from "../apps/feature_schedule.ts"; | |
| 10 | 11 | import { callTool } from "../apps/feature_tools.ts"; |
| 11 | 12 | import { appsRuntime } from "../apps/runtime.ts"; |
| 12 | 13 | import { getAbsorbed } from "../core/features.ts"; |
| @@ -37,6 +38,15 @@ export async function callFeatureTool( | ||
| 37 | 38 | if (port === undefined) return refuse("not_running", "The app is not running."); |
| 38 | 39 | // A plan is told only once the call could be made: allowed, absorbed, running. |
| 39 | 40 | if (spec.writes && dryRun) return ok({ planned: `Would call ${tool} on ${found.record.slug}: ${spec.description} Send dryRun: false to do it.` }); |
| 40 | const answer = await callTool(port, tool, args); | |
| 41 | return answer.ok ? ok({ answer: answer.value }) : refuse("failed", answer.error); | |
| 41 | // A tool the schedule names is the same recorded run as the schedule's and the page's, so it | |
| 42 | // shows in the tool's line and history; any other tool (a read like `latest`) is called and not | |
| 43 | // recorded. | |
| 44 | const scheduled = declared.ok && declared.value?.schedule.some((e) => e.tool === tool) === true; | |
| 45 | if (!scheduled) { | |
| 46 | const answer = await callTool(port, tool, args); | |
| 47 | return answer.ok ? ok({ answer: answer.value }) : refuse("failed", answer.error); | |
| 48 | } | |
| 49 | const ran = await runRecorded(app.db, found.record.id, tool, port, args, "mcp"); | |
| 50 | if (!ran.ran) return refuse("failed", `${tool} is already running.`); | |
| 51 | return ran.answer.ok ? ok({ answer: ran.answer.value }) : refuse("failed", ran.answer.error); | |
| 42 | 52 | } |
src/actions/features.ts +5 −2
| @@ -18,7 +18,7 @@ | ||
| 18 | 18 | */ |
| 19 | 19 | |
| 20 | 20 | import { scheduleStatus, type ScheduleStatus } from "../apps/feature_schedule.ts"; |
| 21 | import { lastFires, type Fired } from "../core/feature_fires.ts"; | |
| 21 | import { lastFires, runHistory, scheduleSeen, type Fired, type Run } from "../core/feature_fires.ts"; | |
| 22 | 22 | import type { App } from "../app.ts"; |
| 23 | 23 | import { readFileAt } from "../apps/checkout.ts"; |
| 24 | 24 | import { checkSetting, featureEnv, parseFeature, settingEnvName, type FeatureSpec } from "../apps/feature.ts"; |
| @@ -92,6 +92,8 @@ export interface FeatureView { | ||
| 92 | 92 | readonly tools: readonly ToolSpec[]; |
| 93 | 93 | /** How the forge's last call of each scheduled tool went; empty when none has fired. */ |
| 94 | 94 | readonly fires: readonly Fired[]; |
| 95 | /** The last 30 runs of each tool, newest first, with how each was started. */ | |
| 96 | readonly history: readonly Run[]; | |
| 95 | 97 | /** Each scheduled tool with its last fire and next run; empty when not absorbed. */ |
| 96 | 98 | readonly schedule: readonly ScheduleStatus[]; |
| 97 | 99 | /** Where the app comes from: the upstream only when the reader may read it. */ |
| @@ -246,7 +248,8 @@ function viewOf( | ||
| 246 | 248 | provenance, |
| 247 | 249 | tools: spec?.tools ?? [], |
| 248 | 250 | fires, |
| 249 | schedule: absorbed === undefined ? [] : scheduleStatus(spec?.schedule ?? [], fires), | |
| 251 | history: absorbed === undefined ? [] : runHistory(app.db, record.id), | |
| 252 | schedule: absorbed === undefined ? [] : scheduleStatus(spec?.schedule ?? [], fires, Date.now(), scheduleSeen(app.db, record.id)), | |
| 250 | 253 | settings: (spec?.settings ?? []).map((s) => { |
| 251 | 254 | const name = settingEnvName(s.key); |
| 252 | 255 | return { |
src/apps/cron.ts +2 −2
| @@ -102,9 +102,9 @@ function zone(tz: string): Intl.DateTimeFormat { | ||
| 102 | 102 | return f; |
| 103 | 103 | } |
| 104 | 104 | |
| 105 | interface Wall { readonly y: number; readonly mo: number; readonly d: number; readonly h: number; readonly mi: number; readonly s: number } | |
| 105 | export interface Wall { readonly y: number; readonly mo: number; readonly d: number; readonly h: number; readonly mi: number; readonly s: number } | |
| 106 | 106 | |
| 107 | function wallAt(ms: number, tz: string): Wall { | |
| 107 | export function wallAt(ms: number, tz: string): Wall { | |
| 108 | 108 | const p: Record<string, number> = {}; |
| 109 | 109 | for (const part of zone(tz).formatToParts(ms)) if (part.type !== "literal") p[part.type] = Number(part.value); |
| 110 | 110 | return { y: p["year"]!, mo: p["month"]!, d: p["day"]!, h: p["hour"]!, mi: p["minute"]!, s: p["second"]! }; |
src/apps/feature.ts +26 −7
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | */ |
| 24 | 24 | |
| 25 | 25 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 26 | import { isTimezone } from "./cron.ts"; | |
| 26 | 27 | import { parseTools, type ToolSpec } from "./feature_tools.ts"; |
| 27 | 28 | |
| 28 | 29 | export type SettingType = "bool" | "int" | "string" | "choice" | "days" | "list" | "secret"; |
| @@ -73,11 +74,19 @@ export interface FeatureSpec { | ||
| 73 | 74 | readonly schedule: readonly ScheduleEntry[]; |
| 74 | 75 | } |
| 75 | 76 | |
| 77 | /** | |
| 78 | * One scheduled tool: an interval (`everyMs`) or a daily clock time (`at` "07:15" in the IANA zone | |
| 79 | * `tz`), never both. | |
| 80 | */ | |
| 76 | 81 | export interface ScheduleEntry { |
| 77 | 82 | readonly tool: string; |
| 78 | readonly everyMs: number; | |
| 83 | readonly everyMs?: number | undefined; | |
| 84 | readonly at?: string | undefined; | |
| 85 | readonly tz?: string | undefined; | |
| 79 | 86 | } |
| 80 | 87 | |
| 88 | const CLOCK = /^([01]\d|2[0-3]):[0-5]\d$/; | |
| 89 | ||
| 81 | 90 | const MINUTE = 60_000; |
| 82 | 91 | /** The intervals a schedule entry may name. */ |
| 83 | 92 | export const SCHEDULE_EVERY: Readonly<Record<string, number>> = { "15m": 15 * MINUTE, "1h": 60 * MINUTE, "6h": 360 * MINUTE, "1d": 1440 * MINUTE }; |
| @@ -86,20 +95,30 @@ export const SCHEDULE_MAX = 4; | ||
| 86 | 95 | /** The `schedule` of a feature block, checked against the tools the feature declares; an error names the entry. */ |
| 87 | 96 | function parseSchedule(raw: unknown, tools: readonly ToolSpec[]): Result<readonly ScheduleEntry[], string> { |
| 88 | 97 | if (raw === undefined) return ok([]); |
| 89 | if (!Array.isArray(raw)) return bad("schedule must be a list of { tool, every }"); | |
| 98 | if (!Array.isArray(raw)) return bad("schedule must be a list of { tool, every } or { tool, at, tz }"); | |
| 90 | 99 | if (raw.length > SCHEDULE_MAX) return bad(`schedule has at most ${SCHEDULE_MAX} entries`); |
| 91 | 100 | const out: ScheduleEntry[] = []; |
| 92 | 101 | for (const [i, e] of raw.entries()) { |
| 93 | 102 | const at = `schedule entry ${i + 1}`; |
| 94 | if (!isObject(e)) return bad(`${at} must be an object with tool and every`); | |
| 95 | for (const extra of Object.keys(e)) if (extra !== "tool" && extra !== "every") return bad(`${at}: unknown field ${extra}`); | |
| 103 | if (!isObject(e)) return bad(`${at} must be an object with tool and every, or tool, at and tz`); | |
| 104 | for (const extra of Object.keys(e)) if (!["tool", "every", "at", "tz"].includes(extra)) return bad(`${at}: unknown field ${extra}`); | |
| 96 | 105 | const tool = e["tool"]; |
| 97 | 106 | if (typeof tool !== "string" || !tools.some((t) => t.name === tool)) |
| 98 | 107 | return bad(`${at}: tool ${JSON.stringify(tool)} is not one of this feature's tools${tools.length ? ` (${tools.map((t) => t.name).join(", ")})` : ""}`); |
| 99 | 108 | const every = e["every"]; |
| 100 | if (typeof every !== "string" || !Object.hasOwn(SCHEDULE_EVERY, every)) | |
| 101 | return bad(`${at} (${tool}): every is one of ${Object.keys(SCHEDULE_EVERY).join(", ")}`); | |
| 102 | out.push({ tool, everyMs: SCHEDULE_EVERY[every]! }); | |
| 109 | const clock = e["at"]; | |
| 110 | const tz = e["tz"]; | |
| 111 | if ((every === undefined) === (clock === undefined)) return bad(`${at} (${tool}): give exactly one of every and at`); | |
| 112 | if (every !== undefined) { | |
| 113 | if (tz !== undefined) return bad(`${at} (${tool}): tz goes with at, not every`); | |
| 114 | if (typeof every !== "string" || !Object.hasOwn(SCHEDULE_EVERY, every)) | |
| 115 | return bad(`${at} (${tool}): every is one of ${Object.keys(SCHEDULE_EVERY).join(", ")}`); | |
| 116 | out.push({ tool, everyMs: SCHEDULE_EVERY[every]! }); | |
| 117 | continue; | |
| 118 | } | |
| 119 | if (typeof clock !== "string" || !CLOCK.test(clock)) return bad(`${at} (${tool}): at is a 24-hour clock time like "07:15"`); | |
| 120 | if (typeof tz !== "string" || !isTimezone(tz)) return bad(`${at} (${tool}): tz is an IANA zone name like "America/Chicago"`); | |
| 121 | out.push({ tool, at: clock, tz }); | |
| 103 | 122 | } |
| 104 | 123 | return ok(out); |
| 105 | 124 | } |
src/apps/feature_clock.ts added +47
| @@ -0,0 +1,47 @@ | ||
| 1 | /** | |
| 2 | * Clock times for a schedule: the most recent and the next instant a daily `at` ("07:15") happens | |
| 3 | * in an IANA zone, daylight saving included (`localToUtc` finds the instant of a wall time, so the | |
| 4 | * day of the spring change has its 23 hours and the autumn one its 25), and the plain words for a | |
| 5 | * moment in that zone. | |
| 6 | */ | |
| 7 | ||
| 8 | import { localToUtc, wallAt } from "./cron.ts"; | |
| 9 | ||
| 10 | const DAY = 86_400_000; | |
| 11 | ||
| 12 | /** `07:15` as minutes into the day. */ | |
| 13 | const parts = (at: string): [number, number] => [Number(at.slice(0, 2)), Number(at.slice(3, 5))]; | |
| 14 | ||
| 15 | /** The instant `at` happens on the zone's local date `dayOffset` days from `now`'s. */ | |
| 16 | function slotOn(at: string, tz: string, now: number, dayOffset: number): number { | |
| 17 | const w = wallAt(now, tz); | |
| 18 | const date = new Date(Date.UTC(w.y, w.mo - 1, w.d) + dayOffset * DAY); | |
| 19 | const [h, mi] = parts(at); | |
| 20 | return localToUtc(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate(), h, mi, tz); | |
| 21 | } | |
| 22 | ||
| 23 | /** The latest time `at` happened, at or before `now`. */ | |
| 24 | export function lastSlot(at: string, tz: string, now: number): number { | |
| 25 | const today = slotOn(at, tz, now, 0); | |
| 26 | return today <= now ? today : slotOn(at, tz, now, -1); | |
| 27 | } | |
| 28 | ||
| 29 | /** The first time `at` happens after `now`. */ | |
| 30 | export function nextSlot(at: string, tz: string, now: number): number { | |
| 31 | const today = slotOn(at, tz, now, 0); | |
| 32 | return today > now ? today : slotOn(at, tz, now, 1); | |
| 33 | } | |
| 34 | ||
| 35 | const labels = new Map<string, Intl.DateTimeFormat>(); | |
| 36 | ||
| 37 | /** `Fri 07:15`: a moment as the clock on that zone's wall reads. */ | |
| 38 | export function localLabel(ms: number, tz: string): string { | |
| 39 | let f = labels.get(tz); | |
| 40 | if (f === undefined) { | |
| 41 | f = new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }); | |
| 42 | labels.set(tz, f); | |
| 43 | } | |
| 44 | const p: Record<string, string> = {}; | |
| 45 | for (const part of f.formatToParts(ms)) p[part.type] = part.value; | |
| 46 | return `${p["weekday"]} ${p["hour"]}:${p["minute"]}`; | |
| 47 | } |
src/apps/feature_run.ts +3 −3
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import type { Database } from "bun:sqlite"; |
| 8 | import { lastFires } from "../core/feature_fires.ts"; | |
| 8 | import { lastFires, type How } from "../core/feature_fires.ts"; | |
| 9 | 9 | import { fireOne, type Reach } from "./feature_schedule.ts"; |
| 10 | 10 | |
| 11 | 11 | export const RUN_REFUSALS = { |
| @@ -16,8 +16,8 @@ export const RUN_REFUSALS = { | ||
| 16 | 16 | } as const; |
| 17 | 17 | |
| 18 | 18 | /** Call `tool` now and say how it went; `ok` is false for a refusal or a failed call. */ |
| 19 | export async function runNow(db: Database, appId: string, tool: string, reach: Reach): Promise<{ ok: boolean; message: string }> { | |
| 20 | const fired = await fireOne(db, appId, tool, reach); | |
| 19 | export async function runNow(db: Database, appId: string, tool: string, reach: Reach, how: How = "page"): Promise<{ ok: boolean; message: string }> { | |
| 20 | const fired = await fireOne(db, appId, tool, reach, Date.now, how); | |
| 21 | 21 | if (!fired.ok) return { ok: false, message: RUN_REFUSALS[fired.why] }; |
| 22 | 22 | const fire = lastFires(db, appId).find((f) => f.tool === tool); |
| 23 | 23 | return fire?.ok === false ? { ok: false, message: `${tool} ran and failed: ${fire.error ?? "failed"}.` } : { ok: true, message: `${tool} ran.` }; |
src/apps/feature_schedule.ts +150 −60
| @@ -10,7 +10,9 @@ 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, type Fired } from "../core/feature_fires.ts"; | |
| 13 | import { lastFires, lastScheduled, recordFire, scheduleSince, type Fired, type How, type Run } from "../core/feature_fires.ts"; | |
| 14 | import { err, type Result } from "../kernel/result.ts"; | |
| 15 | import { lastSlot, localLabel, nextSlot } from "./feature_clock.ts"; | |
| 14 | 16 | import { listApps, type AppRecord } from "../core/apps.ts"; |
| 15 | 17 | import { getRepoById } from "../core/repos.ts"; |
| 16 | 18 | import { callTool } from "./feature_tools.ts"; |
| @@ -18,15 +20,80 @@ import { SCHEDULE_EVERY, type ScheduleEntry } from "./feature.ts"; | ||
| 18 | 20 | import { appsRuntime } from "./runtime.ts"; |
| 19 | 21 | import { during } from "../perf/jobs.ts"; |
| 20 | 22 | |
| 21 | /** The entries whose interval has passed since they last fired (never fired: due). `lastFired` is ms by tool. */ | |
| 22 | export function due(schedule: readonly ScheduleEntry[], lastFired: ReadonlyMap<string, number>, now: number): ScheduleEntry[] { | |
| 23 | return schedule.filter((e) => { | |
| 24 | const last = lastFired.get(e.tool); | |
| 25 | return last === undefined || now - last >= e.everyMs; | |
| 26 | }); | |
| 23 | /** | |
| 24 | * How long after its clock time a daily `at` may still fire (and say it was late); past it the day | |
| 25 | * is skipped. | |
| 26 | */ | |
| 27 | export const GRACE_MS = 3 * 3_600_000; | |
| 28 | /** | |
| 29 | * A fire this long after its time was not the minute's look but a forge that was away: it is said | |
| 30 | * to be late. | |
| 31 | */ | |
| 32 | export const LATE_MS = 2 * 60_000; | |
| 33 | ||
| 34 | /** | |
| 35 | * What a look decides for one entry: fire it (and how it is told), skip a day it missed, or | |
| 36 | * nothing. | |
| 37 | */ | |
| 38 | export type Verdict = { readonly do: "fire"; readonly how: "schedule" | "late" } | { readonly do: "skip"; readonly slot: number } | undefined; | |
| 39 | ||
| 40 | /** | |
| 41 | * Whether the entry is due. An interval counts from the later of its last fire (any way it was | |
| 42 | * started) and when the schedule began to count; a clock time is due once per local day, counted | |
| 43 | * from the later of the schedule's own last act and when it began, so a manual run does not move it | |
| 44 | * and a new schedule waits for its next time. `lastFired` is the last run of any kind, | |
| 45 | * `lastScheduled` the schedule's own last act. | |
| 46 | */ | |
| 47 | export function due( | |
| 48 | entry: ScheduleEntry, | |
| 49 | seen: { readonly lastFired?: number | undefined; readonly lastScheduled?: number | undefined; readonly since: number }, | |
| 50 | now: number, | |
| 51 | ): Verdict { | |
| 52 | if (entry.at !== undefined && entry.tz !== undefined) { | |
| 53 | const slot = lastSlot(entry.at, entry.tz, now); | |
| 54 | if (slot <= Math.max(seen.lastScheduled ?? 0, seen.since)) return undefined; | |
| 55 | if (now - slot > GRACE_MS) return { do: "skip", slot }; | |
| 56 | return { do: "fire", how: now - slot > LATE_MS ? "late" : "schedule" }; | |
| 57 | } | |
| 58 | const every = entry.everyMs ?? Infinity; | |
| 59 | return now - Math.max(seen.lastFired ?? 0, seen.since) >= every ? { do: "fire", how: "schedule" } : undefined; | |
| 27 | 60 | } |
| 28 | 61 | |
| 62 | /** The entry as the forge compares it from one look to the next. */ | |
| 63 | const specOf = (e: ScheduleEntry): string => (e.at !== undefined ? `at ${e.at} ${e.tz}` : `every ${e.everyMs}`); | |
| 64 | ||
| 29 | 65 | const inFlight = new Set<string>(); |
| 66 | export const isRunning = (appId: string, tool: string): boolean => inFlight.has(`${appId}:${tool}`); | |
| 67 | ||
| 68 | /** | |
| 69 | * The one way a tool is run and recorded: the schedule, Run now on the page, the terminal's `r` and | |
| 70 | * the `features` tool (for a scheduled tool) all come through here, so each is the same row in the | |
| 71 | * tool's last fire and its history. `ran: false` means a call of the same tool is already running; | |
| 72 | * it is never doubled. | |
| 73 | */ | |
| 74 | export async function runRecorded( | |
| 75 | db: Database, | |
| 76 | appId: string, | |
| 77 | tool: string, | |
| 78 | port: number, | |
| 79 | args: Record<string, unknown>, | |
| 80 | how: How, | |
| 81 | now: () => number = Date.now, | |
| 82 | ): Promise<{ ran: true; answer: Result<unknown, string> } | { ran: false }> { | |
| 83 | const key = `${appId}:${tool}`; | |
| 84 | if (inFlight.has(key)) return { ran: false }; | |
| 85 | inFlight.add(key); | |
| 86 | try { | |
| 87 | const started = now(); | |
| 88 | // callTool is bounded (WAIT_MS) and answers an error rather than throwing; a throw is still a | |
| 89 | // recorded failure. | |
| 90 | const answer = await callTool(port, tool, args).catch((): Result<unknown, string> => err("the app did not answer")); | |
| 91 | recordFire(db, appId, { tool, at: started, ok: answer.ok, error: answer.ok ? null : answer.error, durationMs: Math.max(0, now() - started) }, how); | |
| 92 | return { ran: true, answer }; | |
| 93 | } finally { | |
| 94 | inFlight.delete(key); | |
| 95 | } | |
| 96 | } | |
| 30 | 97 | |
| 31 | 98 | /** What `fireApp` needs from the running forge: the production port when running, and the declared schedule. */ |
| 32 | 99 | export interface Reach { |
| @@ -35,35 +102,31 @@ export interface Reach { | ||
| 35 | 102 | declared(): Promise<{ readonly tools: readonly string[]; readonly schedule: readonly ScheduleEntry[] }>; |
| 36 | 103 | } |
| 37 | 104 | |
| 38 | /** Call every due scheduled tool of one app if it is absorbed and running; the tools called. */ | |
| 105 | /** | |
| 106 | * Look at every scheduled tool of one app if it is absorbed: call the due ones while it runs, note | |
| 107 | * the days missed; the tools called. | |
| 108 | */ | |
| 39 | 109 | export async function fireApp(db: Database, appId: string, reach: Reach, now: () => number = Date.now): Promise<string[]> { |
| 40 | 110 | if (getAbsorbed(db, appId) === undefined) return []; |
| 41 | const port = reach.port(); | |
| 42 | if (port === undefined) return []; | |
| 43 | 111 | const declared = await reach.declared(); |
| 44 | 112 | const schedule = declared.schedule.filter((e) => declared.tools.includes(e.tool)); |
| 45 | 113 | if (schedule.length === 0) return []; |
| 46 | 114 | const last = new Map(lastFires(db, appId).map((f) => [f.tool, f.at])); |
| 47 | 115 | const called: string[] = []; |
| 48 | for (const entry of due(schedule, last, now())) { | |
| 49 | const key = `${appId}:${entry.tool}`; | |
| 50 | if (inFlight.has(key)) continue; | |
| 51 | inFlight.add(key); | |
| 52 | try { | |
| 53 | const started = now(); | |
| 54 | const answer = await callTool(port, entry.tool, {}); | |
| 55 | const ended = now(); | |
| 56 | recordFire(db, appId, { | |
| 57 | tool: entry.tool, | |
| 58 | at: started, | |
| 59 | ok: answer.ok, | |
| 60 | error: answer.ok ? null : answer.error, | |
| 61 | durationMs: Math.max(0, ended - started), | |
| 62 | }); | |
| 63 | called.push(entry.tool); | |
| 64 | } finally { | |
| 65 | inFlight.delete(key); | |
| 116 | for (const entry of schedule) { | |
| 117 | const at = now(); | |
| 118 | const since = scheduleSince(db, appId, entry.tool, specOf(entry), at); | |
| 119 | const verdict = due(entry, { lastFired: last.get(entry.tool), lastScheduled: lastScheduled(db, appId, entry.tool), since }, at); | |
| 120 | if (verdict === undefined) continue; | |
| 121 | if (verdict.do === "skip") { | |
| 122 | const when = localLabel(verdict.slot, entry.tz!); | |
| 123 | recordFire(db, appId, { tool: entry.tool, at, ok: false, error: `skipped ${when}: the forge came back more than ${GRACE_MS / 3_600_000} h late`, durationMs: 0 }, "skipped"); | |
| 124 | continue; | |
| 66 | 125 | } |
| 126 | const port = reach.port(); | |
| 127 | if (port === undefined) continue; | |
| 128 | const ran = await runRecorded(db, appId, entry.tool, port, {}, verdict.how, now); | |
| 129 | if (ran.ran) called.push(entry.tool); | |
| 67 | 130 | } |
| 68 | 131 | return called; |
| 69 | 132 | } |
| @@ -82,30 +145,16 @@ export async function fireOne( | ||
| 82 | 145 | tool: string, |
| 83 | 146 | reach: Reach, |
| 84 | 147 | now: () => number = Date.now, |
| 148 | how: How = "page", | |
| 85 | 149 | ): Promise<{ ok: true } | { ok: false; why: FireOneRefusal }> { |
| 86 | 150 | if (getAbsorbed(db, appId) === undefined) return { ok: false, why: "not_absorbed" }; |
| 87 | const key = `${appId}:${tool}`; | |
| 88 | if (inFlight.has(key)) return { ok: false, why: "in_flight" }; | |
| 151 | if (isRunning(appId, tool)) return { ok: false, why: "in_flight" }; | |
| 89 | 152 | const port = reach.port(); |
| 90 | 153 | if (port === undefined) return { ok: false, why: "asleep" }; |
| 91 | inFlight.add(key); | |
| 92 | try { | |
| 93 | const declared = await reach.declared().catch(() => undefined); | |
| 94 | if (declared === undefined || !declared.tools.includes(tool) || !declared.schedule.some((e) => e.tool === tool)) return { ok: false, why: "not_scheduled" }; | |
| 95 | const started = now(); | |
| 96 | // callTool is bounded (WAIT_MS) and answers an error rather than throwing; a throw is still a recorded failure. | |
| 97 | const answer = await callTool(port, tool, {}).catch((): { ok: false; error: string } => ({ ok: false, error: "the app did not answer" })); | |
| 98 | recordFire(db, appId, { | |
| 99 | tool, | |
| 100 | at: started, | |
| 101 | ok: answer.ok, | |
| 102 | error: answer.ok ? null : answer.error, | |
| 103 | durationMs: Math.max(0, now() - started), | |
| 104 | }); | |
| 105 | return { ok: true }; | |
| 106 | } finally { | |
| 107 | inFlight.delete(key); | |
| 108 | } | |
| 154 | const declared = await reach.declared().catch(() => undefined); | |
| 155 | if (declared === undefined || !declared.tools.includes(tool) || !declared.schedule.some((e) => e.tool === tool)) return { ok: false, why: "not_scheduled" }; | |
| 156 | const ran = await runRecorded(db, appId, tool, port, {}, how, now); | |
| 157 | return ran.ran ? { ok: true } : { ok: false, why: "in_flight" }; | |
| 109 | 158 | } |
| 110 | 159 | |
| 111 | 160 | /** What `fireApp` reaches for one app in the real runtime: only a production that is running, never woken. */ |
| @@ -155,17 +204,24 @@ export function startFeatureSchedule(app: App, tickMs: number = TICK_MS): { stop | ||
| 155 | 204 | return { stop: () => clearInterval(tick) }; |
| 156 | 205 | } |
| 157 | 206 | |
| 158 | /** One scheduled tool as plain data: how often, how its last fire went, when the next is due. Times are ms since the epoch. */ | |
| 207 | /** | |
| 208 | * One scheduled tool as plain data: how often or at what clock time, how its last fire went, when | |
| 209 | * the next is due. Times are ms since the epoch. | |
| 210 | */ | |
| 159 | 211 | export interface ScheduleStatus { |
| 160 | 212 | readonly tool: string; |
| 161 | readonly every: string; | |
| 213 | /** The interval as declared (`1h`); null for a clock-time entry. */ | |
| 214 | readonly every: string | null; | |
| 215 | /** The daily clock time (`07:15`) and its IANA zone; null for an interval entry. */ | |
| 216 | readonly at: string | null; | |
| 217 | readonly tz: string | null; | |
| 162 | 218 | readonly lastAt: number | null; |
| 163 | 219 | readonly ok: boolean | null; |
| 164 | 220 | /** The failure's sentence; null when the last fire went fine or there is none. */ |
| 165 | 221 | readonly error: string | null; |
| 166 | 222 | /** How long the last fire took; null before the first. */ |
| 167 | 223 | readonly durationMs: number | null; |
| 168 | /** When it is next due: its last fire plus the interval, or null when it has never fired (due soon). */ | |
| 224 | /** When it is next due; null for an interval before the forge has begun counting (due soon). */ | |
| 169 | 225 | readonly nextAt: number | null; |
| 170 | 226 | /** The whole line as `scheduleLine` words it, for the tool's readers and the terminal. */ |
| 171 | 227 | readonly line: string; |
| @@ -176,33 +232,67 @@ function everyWord(everyMs: number): string { | ||
| 176 | 232 | return Object.entries(SCHEDULE_EVERY).find(([, ms]) => ms === everyMs)?.[0] ?? `${Math.round(everyMs / 60_000)}m`; |
| 177 | 233 | } |
| 178 | 234 | |
| 179 | /** One status per schedule entry, in the app's order, joined to the last fire of its tool. */ | |
| 180 | export function scheduleStatus(schedule: readonly ScheduleEntry[], fires: readonly Fired[]): ScheduleStatus[] { | |
| 235 | /** | |
| 236 | * One status per schedule entry, in the app's order, joined to the last fire of its tool. `since` | |
| 237 | * is when each began to count. | |
| 238 | */ | |
| 239 | export function scheduleStatus( | |
| 240 | schedule: readonly ScheduleEntry[], | |
| 241 | fires: readonly Fired[], | |
| 242 | now: number = Date.now(), | |
| 243 | since: ReadonlyMap<string, number> = new Map(), | |
| 244 | ): ScheduleStatus[] { | |
| 181 | 245 | return schedule.map((e) => { |
| 182 | 246 | const fire = fires.find((f) => f.tool === e.tool); |
| 247 | const begun = since.get(e.tool); | |
| 248 | const base = Math.max(fire?.at ?? 0, begun ?? 0); | |
| 249 | const clock = e.at !== undefined && e.tz !== undefined; | |
| 183 | 250 | const status: Omit<ScheduleStatus, "line"> = { |
| 184 | 251 | tool: e.tool, |
| 185 | every: everyWord(e.everyMs), | |
| 252 | every: clock ? null : everyWord(e.everyMs ?? 0), | |
| 253 | at: e.at ?? null, | |
| 254 | tz: e.tz ?? null, | |
| 186 | 255 | lastAt: fire?.at ?? null, |
| 187 | 256 | ok: fire?.ok ?? null, |
| 188 | 257 | durationMs: fire?.durationMs ?? null, |
| 189 | 258 | error: fire === undefined || fire.ok ? null : (fire.error ?? "failed"), |
| 190 | nextAt: fire === undefined ? null : fire.at + e.everyMs, | |
| 259 | nextAt: clock ? nextSlot(e.at!, e.tz!, now) : base === 0 ? null : base + (e.everyMs ?? 0), | |
| 191 | 260 | }; |
| 192 | 261 | return { ...status, line: scheduleLine(status) }; |
| 193 | 262 | }); |
| 194 | 263 | } |
| 195 | 264 | |
| 265 | /** `every 1h` or `at 07:15 America/Chicago`: when the tool runs, without its name. */ | |
| 266 | export const scheduleWhen = (s: Pick<ScheduleStatus, "every" | "at" | "tz">): string => (s.at !== null ? `at ${s.at} ${s.tz}` : `every ${s.every}`); | |
| 267 | ||
| 268 | /** `scan every 1h` or `print_today at 07:15 America/Chicago`: the head of the line. */ | |
| 269 | export const scheduleHead = (s: Pick<ScheduleStatus, "tool" | "every" | "at" | "tz">): string => `${s.tool} ${scheduleWhen(s)}`; | |
| 270 | ||
| 271 | /** | |
| 272 | * One run of the history as a plain line: `print_today · page · 2026-11-13 10:02 · ok · 42 ms`, the | |
| 273 | * error's sentence when it failed (UTC). | |
| 274 | */ | |
| 275 | export const runLine = (r: Run): string => | |
| 276 | `${r.tool} · ${r.how} · ${new Date(r.at).toISOString().slice(0, 16).replace("T", " ")} · ${r.ok ? "ok" : (r.error ?? "failed")}${r.how === "skipped" ? "" : ` · ${r.durationMs} ms`}`; | |
| 277 | ||
| 196 | 278 | /** `10:02`: the plain-text clock (UTC) of the line the tool and the terminal print. */ |
| 197 | 279 | export const scheduleClock = (ms: number): string => new Date(ms).toISOString().slice(11, 16); |
| 198 | 280 | |
| 199 | 281 | /** A piece of the line after the head: words, the last or next moment (ms) the reader formats, or the sentence of a failure. */ |
| 200 | 282 | export type SchedulePiece = string | { readonly last: number } | { readonly next: number } | { readonly error: string }; |
| 201 | 283 | |
| 202 | /** The wording of the line after `scan every 1h`, once, for every door: the page formats the moments and styles the error, | |
| 203 | the rest print it with `scheduleLine`. */ | |
| 284 | /** | |
| 285 | * The wording of the line after the head, once, for every door: the page formats the moments and | |
| 286 | * styles the error, the rest print it with `scheduleLine`. A clock-time entry speaks in its own | |
| 287 | * zone (`last Fri 07:15, ok · next Sat 07:15`). | |
| 288 | */ | |
| 204 | 289 | export function scheduleMeta(s: Omit<ScheduleStatus, "line">): SchedulePiece[] { |
| 205 | if (s.lastAt === null) return ["never run · next soon"]; | |
| 290 | if (s.at !== null && s.tz !== null) { | |
| 291 | const next = s.nextAt === null ? "soon" : localLabel(s.nextAt, s.tz); | |
| 292 | if (s.lastAt === null) return [`never run · next ${next}`]; | |
| 293 | return ["last ", localLabel(s.lastAt, s.tz), ", ", s.error === null ? "ok" : { error: s.error }, ` · next ${next}`]; | |
| 294 | } | |
| 295 | if (s.lastAt === null) return [s.nextAt === null ? "never run · next soon" : "never run · next ", ...(s.nextAt === null ? [] : [{ next: s.nextAt }])]; | |
| 206 | 296 | return [ |
| 207 | 297 | "last ", { last: s.lastAt }, ", ", s.error === null ? "ok" : { error: s.error }, |
| 208 | 298 | ...(s.durationMs === null ? [] : [` · ${s.durationMs} ms`]), |
| @@ -211,8 +301,8 @@ export function scheduleMeta(s: Omit<ScheduleStatus, "line">): SchedulePiece[] { | ||
| 211 | 301 | } |
| 212 | 302 | |
| 213 | 303 | /** 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, |
| 214 | `never run · next soon` before the first fire. */ | |
| 304 | `never run · next soon` before the forge has begun counting. */ | |
| 215 | 305 | export function scheduleLine(s: Omit<ScheduleStatus, "line">): string { |
| 216 | 306 | const word = (p: SchedulePiece): string => (typeof p === "string" ? p : "error" in p ? p.error : scheduleClock("last" in p ? p.last : p.next)); |
| 217 | return `${s.tool} every ${s.every} · ${scheduleMeta(s).map(word).join("")}`; | |
| 307 | return `${scheduleHead(s)} · ${scheduleMeta(s).map(word).join("")}`; | |
| 218 | 308 | } |
src/core/apps.ts +2 −1
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | import { PUBLIC_VISIBLE } from "./visibility.ts"; |
| 11 | 11 | import type { Database } from "bun:sqlite"; |
| 12 | 12 | import { id } from "../kernel/id.ts"; |
| 13 | import { clearFires } from "./feature_fires.ts"; | |
| 13 | 14 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 14 | 15 | import type { PreviewData } from "../apps/data.ts"; |
| 15 | 16 | |
| @@ -235,7 +236,7 @@ export function deleteApp(db: Database, appId: string): boolean { | ||
| 235 | 236 | db.run("DELETE FROM app_vital WHERE app_id = ?1", [appId]); |
| 236 | 237 | db.run("DELETE FROM app_feature WHERE app_id = ?1", [appId]); |
| 237 | 238 | db.run("DELETE FROM app_feature_setting WHERE app_id = ?1", [appId]); |
| 238 | db.run("DELETE FROM feature_fire WHERE app_id = ?1", [appId]); | |
| 239 | clearFires(db, appId); | |
| 239 | 240 | db.run("DELETE FROM pull_run WHERE app_id = ?1", [appId]); |
| 240 | 241 | for (const t of ["timer", "timer_run", "timer_sync"]) db.run(`DELETE FROM ${t} WHERE app_id = ?1`, [appId]); |
| 241 | 242 | return db.query("DELETE FROM app WHERE id = ?1").run(appId).changes > 0; |
src/core/feature_fires.ts +74 −12
| @@ -1,4 +1,7 @@ | ||
| 1 | /** What the forge last did for each scheduled tool of an absorbed app: the `feature_fire` rows. */ | |
| 1 | /** | |
| 2 | * What the forge last did for each tool of an absorbed app (`feature_fire`), the last 30 runs of | |
| 3 | * each (`feature_run`), and the schedule each tool was first seen with (`feature_schedule_seen`). | |
| 4 | */ | |
| 2 | 5 | |
| 3 | 6 | import type { Database } from "bun:sqlite"; |
| 4 | 7 | |
| @@ -10,6 +13,20 @@ export interface Fired { | ||
| 10 | 13 | readonly durationMs: number; |
| 11 | 14 | } |
| 12 | 15 | |
| 16 | /** | |
| 17 | * How a run was started: the clock, the clock after its time had passed, a person's hand (`page`, | |
| 18 | * `terminal`, `mcp`), or a day the clock missed. | |
| 19 | */ | |
| 20 | export type How = "schedule" | "late" | "page" | "terminal" | "mcp" | "skipped"; | |
| 21 | /** The hows that are the schedule's own: they, and only they, move a clock-time schedule. */ | |
| 22 | export const SCHEDULED_HOWS: readonly How[] = ["schedule", "late", "skipped"]; | |
| 23 | ||
| 24 | export interface Run extends Fired { | |
| 25 | readonly how: How; | |
| 26 | } | |
| 27 | ||
| 28 | export const HISTORY_KEEP = 30; | |
| 29 | ||
| 13 | 30 | /** The last fire of each scheduled tool of the app, by tool. */ |
| 14 | 31 | export function lastFires(db: Database, appId: string): Fired[] { |
| 15 | 32 | return db |
| @@ -20,19 +37,64 @@ export function lastFires(db: Database, appId: string): Fired[] { | ||
| 20 | 37 | .map((r) => ({ tool: r.tool, at: r.at, ok: r.ok === 1, error: r.error, durationMs: r.duration_ms })); |
| 21 | 38 | } |
| 22 | 39 | |
| 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 | ); | |
| 40 | /** | |
| 41 | * Keep how a run went: it is the tool's last (unless a skipped day) and joins its history, trimmed | |
| 42 | * to the last 30. | |
| 43 | */ | |
| 44 | export function recordFire(db: Database, appId: string, fire: Fired, how: How = "schedule"): void { | |
| 45 | db.transaction(() => { | |
| 46 | if (how !== "skipped") | |
| 47 | db.query("INSERT OR REPLACE INTO feature_fire (app_id, tool, at, ok, error, duration_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6)").run( | |
| 48 | appId, fire.tool, fire.at, fire.ok ? 1 : 0, fire.error, fire.durationMs, | |
| 49 | ); | |
| 50 | db.query("INSERT INTO feature_run (app_id, tool, at, ok, error, duration_ms, how) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)").run( | |
| 51 | appId, fire.tool, fire.at, fire.ok ? 1 : 0, fire.error, fire.durationMs, how, | |
| 52 | ); | |
| 53 | db.query( | |
| 54 | "DELETE FROM feature_run WHERE app_id = ?1 AND tool = ?2 AND rowid NOT IN (SELECT rowid FROM feature_run WHERE app_id = ?1 AND tool = ?2 ORDER BY at DESC, rowid DESC LIMIT ?3)", | |
| 55 | ).run(appId, fire.tool, HISTORY_KEEP); | |
| 56 | })(); | |
| 57 | } | |
| 58 | ||
| 59 | /** The app's history, newest first within each tool, tools in name order. */ | |
| 60 | export function runHistory(db: Database, appId: string): Run[] { | |
| 61 | return db | |
| 62 | .query<{ tool: string; at: number; ok: number; error: string | null; duration_ms: number; how: string }, [string]>( | |
| 63 | "SELECT tool, at, ok, error, duration_ms, how FROM feature_run WHERE app_id = ?1 ORDER BY tool, at DESC, rowid DESC", | |
| 64 | ) | |
| 65 | .all(appId) | |
| 66 | .map((r) => ({ tool: r.tool, at: r.at, ok: r.ok === 1, error: r.error, durationMs: r.duration_ms, how: r.how as How })); | |
| 67 | } | |
| 68 | ||
| 69 | /** | |
| 70 | * When the schedule itself last acted on the tool (a fire, a late fire, a skipped day), or | |
| 71 | * undefined. | |
| 72 | */ | |
| 73 | export function lastScheduled(db: Database, appId: string, tool: string): number | undefined { | |
| 74 | const marks = SCHEDULED_HOWS.map(() => "?").join(","); | |
| 75 | return db | |
| 76 | .query<{ at: number | null }, string[]>(`SELECT MAX(at) AS at FROM feature_run WHERE app_id = ?1 AND tool = ?2 AND how IN (${marks})`) | |
| 77 | .get(appId, tool, ...SCHEDULED_HOWS)?.at ?? undefined; | |
| 78 | } | |
| 79 | ||
| 80 | /** | |
| 81 | * When the tool's schedule, as `spec`, began to count: the first look at it, or at its change. A | |
| 82 | * schedule | |
| 83 | * the forge has not seen starts now, so it fires at its next due time and not at once. | |
| 84 | */ | |
| 85 | export function scheduleSince(db: Database, appId: string, tool: string, spec: string, now: number): number { | |
| 86 | const row = db.query<{ spec: string; since: number }, [string, string]>("SELECT spec, since FROM feature_schedule_seen WHERE app_id = ?1 AND tool = ?2").get(appId, tool); | |
| 87 | if (row !== null && row.spec === spec) return row.since; | |
| 88 | db.query("INSERT OR REPLACE INTO feature_schedule_seen (app_id, tool, spec, since) VALUES (?1, ?2, ?3, ?4)").run(appId, tool, spec, now); | |
| 89 | return now; | |
| 90 | } | |
| 91 | ||
| 92 | /** When each of the app's scheduled tools began to count, by tool. */ | |
| 93 | export function scheduleSeen(db: Database, appId: string): Map<string, number> { | |
| 94 | return new Map(db.query<{ tool: string; since: number }, [string]>("SELECT tool, since FROM feature_schedule_seen WHERE app_id = ?1").all(appId).map((r) => [r.tool, r.since])); | |
| 33 | 95 | } |
| 34 | 96 | |
| 35 | 97 | /** Forget the app's fires: it is no longer absorbed, or no longer exists. */ |
| 36 | 98 | export function clearFires(db: Database, appId: string): void { |
| 37 | db.query("DELETE FROM feature_fire WHERE app_id = ?1").run(appId); | |
| 99 | for (const t of ["feature_fire", "feature_run", "feature_schedule_seen"]) db.query(`DELETE FROM ${t} WHERE app_id = ?1`).run(appId); | |
| 38 | 100 | } |
src/core/migration-pins/20261114020000-feature-run.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 93dab1e93202133c0582205c9ac4cb78e36d1c58fa0eaaed4147ee5be0e804b0 |
src/core/migrations/20261114020000-feature-run.ts added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | // The history of an absorbed app's tool runs (#12043): the last 30 per tool, each with how it was | |
| 2 | // started (`schedule`, `late`, `page`, `terminal`, `mcp`, or `skipped` for a day the forge missed), | |
| 3 | // and `feature_schedule_seen`, the spec each scheduled tool had when the forge first looked at it, | |
| 4 | // so a new or changed schedule counts from then and never fires at once. Additive. | |
| 5 | export default ` | |
| 6 | CREATE TABLE feature_run ( | |
| 7 | app_id TEXT NOT NULL, | |
| 8 | tool TEXT NOT NULL, | |
| 9 | at INTEGER NOT NULL, | |
| 10 | ok INTEGER NOT NULL, | |
| 11 | error TEXT, | |
| 12 | duration_ms INTEGER NOT NULL, | |
| 13 | how TEXT NOT NULL | |
| 14 | ); | |
| 15 | CREATE INDEX feature_run_by_tool ON feature_run (app_id, tool, at); | |
| 16 | CREATE TABLE feature_schedule_seen ( | |
| 17 | app_id TEXT NOT NULL, | |
| 18 | tool TEXT NOT NULL, | |
| 19 | spec TEXT NOT NULL, | |
| 20 | since INTEGER NOT NULL, | |
| 21 | PRIMARY KEY (app_id, tool) | |
| 22 | ) WITHOUT ROWID; | |
| 23 | `; |
src/tui/screens/featurefeed.ts +17 −9
| @@ -7,10 +7,10 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import { runNow } from "../../apps/feature_run.ts"; |
| 10 | import { runtimeReach } from "../../apps/feature_schedule.ts"; | |
| 10 | import { runLine, runtimeReach } from "../../apps/feature_schedule.ts"; | |
| 11 | 11 | import { getAppBySlug } from "../../core/apps.ts"; |
| 12 | 12 | import { absorbedForNav, feedOf, getFeature, type NavFeature } from "../../actions/features.ts"; |
| 13 | import type { Fired } from "../../core/feature_fires.ts"; | |
| 13 | import type { Fired, Run } from "../../core/feature_fires.ts"; | |
| 14 | 14 | import type { FeedItem } from "../../feed/rank.ts"; |
| 15 | 15 | import type { Ctx, Screen } from "../app/app.ts"; |
| 16 | 16 | import { binding } from "../app/keys.ts"; |
| @@ -30,10 +30,13 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc | ||
| 30 | 30 | let items: readonly FeedItem[] = []; |
| 31 | 31 | let fires: readonly Fired[] = []; |
| 32 | 32 | let lines: readonly string[] = []; |
| 33 | let history: readonly Run[] = []; | |
| 34 | let showHistory = false; | |
| 33 | 35 | void getFeature(f.app, f.user, feature.slug).then((found) => { |
| 34 | 36 | items = found.ok ? feedOf(f.app, f.user, found.value) : []; |
| 35 | 37 | fires = found.ok ? found.value.fires : []; |
| 36 | 38 | lines = found.ok ? found.value.schedule.map((x) => x.line) : []; |
| 39 | history = found.ok ? found.value.history : []; | |
| 37 | 40 | ctx.invalidate(); |
| 38 | 41 | }); |
| 39 | 42 | const mayRun = (): boolean => f.user?.isAdmin === true && f.user.kind !== "agent" && fires.length > 0; |
| @@ -45,12 +48,13 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc | ||
| 45 | 48 | running = true; |
| 46 | 49 | try { |
| 47 | 50 | for (const tool of fires.map((x) => x.tool)) { |
| 48 | const said = await runNow(f.app.db, record.id, tool, runtimeReach(f.app, record)); | |
| 51 | const said = await runNow(f.app.db, record.id, tool, runtimeReach(f.app, record), "terminal"); | |
| 49 | 52 | ctx.toast(said.message, said.ok ? "success" : "danger"); |
| 50 | 53 | } |
| 51 | 54 | const found = await getFeature(f.app, f.user, feature.slug); |
| 52 | 55 | fires = found.ok ? found.value.fires : fires; |
| 53 | 56 | lines = found.ok ? found.value.schedule.map((x) => x.line) : lines; |
| 57 | history = found.ok ? found.value.history : history; | |
| 54 | 58 | } finally { |
| 55 | 59 | running = false; |
| 56 | 60 | ctx.invalidate(); |
| @@ -58,13 +62,16 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc | ||
| 58 | 62 | } |
| 59 | 63 | return { |
| 60 | 64 | title: `In the feed: ${feature.name}`, |
| 61 | keys: [binding(["j", "k"], "move"), ...(mayRun() ? [binding(["r"], "run scheduled now")] : []), binding(["esc", "q"], "back")], | |
| 65 | keys: [binding(["j", "k"], "move"), ...(history.length > 0 ? [binding(["h"], showHistory ? "show the feed" : "show run history")] : []), ...(mayRun() ? [binding(["r"], "run scheduled now")] : []), binding(["esc", "q"], "back")], | |
| 62 | 66 | render(area, s) { |
| 63 | 67 | const c = ctx.theme.c; |
| 64 | const [head, body] = lines.length === 0 ? [undefined, area] : split(area, "v", [len(1), fill(1)]); | |
| 65 | if (head !== undefined) { | |
| 66 | const line = `Scheduled: ${lines.map(oneLine).join("; ")}`; | |
| 67 | s.text(head.x, head.y, truncate(line, head.w), style(c.mutedFg, c.bg), head.x + head.w); | |
| 68 | // One line per scheduled tool (`print_today at 07:15 America/Chicago · last …`). | |
| 69 | const [head, body] = lines.length === 0 ? [undefined, area] : split(area, "v", [len(lines.length), fill(1)]); | |
| 70 | if (head !== undefined) | |
| 71 | lines.forEach((l, i) => s.text(head.x, head.y + i, truncate(oneLine(i === 0 ? `Scheduled: ${l}` : ` ${l}`), head.w), style(c.mutedFg, c.bg), head.x + head.w)); | |
| 72 | if (showHistory) { | |
| 73 | drawList(s, body!, c, state, history.map((r): ListRow => ({ title: oneLine(runLine(r)), secondary: "", meta: ago(new Date(r.at).toISOString()) }))); | |
| 74 | return; | |
| 68 | 75 | } |
| 69 | 76 | if (items.length === 0) { |
| 70 | 77 | emptyState(s, body!, c, { title: "Nothing yet." }); |
| @@ -75,8 +82,9 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc | ||
| 75 | 82 | }, |
| 76 | 83 | key(e) { |
| 77 | 84 | if (e.key === "esc" || e.key === "q") ctx.pop(); |
| 85 | else if (e.key === "h" && history.length > 0) showHistory = !showHistory; | |
| 78 | 86 | else if (e.key === "r" && mayRun()) void runAll(); |
| 79 | else state.key(e, items.length); | |
| 87 | else state.key(e, showHistory ? history.length : items.length); | |
| 80 | 88 | return true; |
| 81 | 89 | }, |
| 82 | 90 | }; |
src/web/pages/features.ts +25 −2
| @@ -19,7 +19,8 @@ import { | ||
| 19 | 19 | provenanceFacts, |
| 20 | 20 | } from "../../actions/features.ts"; |
| 21 | 21 | import { runNow } from "../../apps/feature_run.ts"; |
| 22 | import { runtimeReach, scheduleMeta, type Reach } from "../../apps/feature_schedule.ts"; | |
| 22 | import { runtimeReach, scheduleMeta, scheduleWhen, type Reach } from "../../apps/feature_schedule.ts"; | |
| 23 | import type { Run } from "../../core/feature_fires.ts"; | |
| 23 | 24 | import { getAppBySlug, type AppRecord } from "../../core/apps.ts"; |
| 24 | 25 | import { DAYS } from "../../apps/feature.ts"; |
| 25 | 26 | import type { FeedItem } from "../../feed/rank.ts"; |
| @@ -186,6 +187,21 @@ function typedValue(s: SettingView, raw: unknown): string { | ||
| 186 | 187 | return text; |
| 187 | 188 | } |
| 188 | 189 | |
| 190 | /** Rows of a tool's history shown at once on the page; the older ones are behind a disclosure. */ | |
| 191 | const HISTORY_SHOWN = 5; | |
| 192 | ||
| 193 | /** The history (already in tool order, newest first) as one list per tool. */ | |
| 194 | function historyByTool(history: readonly Run[]): [string, Run[]][] { | |
| 195 | const by = new Map<string, Run[]>(); | |
| 196 | for (const r of history) by.set(r.tool, [...(by.get(r.tool) ?? []), r]); | |
| 197 | return [...by]; | |
| 198 | } | |
| 199 | ||
| 200 | const historyRow = (r: Run) => row({ | |
| 201 | title: html`${r.how}`, | |
| 202 | meta: html`${time(new Date(r.at).toISOString(), { date: true })} · ${r.ok ? "ok" : html`<span class="danger">${r.error ?? "failed"}</span>`}${r.how === "skipped" ? "" : ` · ${r.durationMs} ms`}`, | |
| 203 | }); | |
| 204 | ||
| 189 | 205 | function featureView(ctx: Ctx, f: FeatureView, refused?: Refused, items: readonly FeedItem[] = []): Response { |
| 190 | 206 | const base = `/settings/features/${encodeURIComponent(f.slug)}`; |
| 191 | 207 | const settings = f.settings.map((shown) => { |
| @@ -229,11 +245,18 @@ function featureView(ctx: Ctx, f: FeatureView, refused?: Refused, items: readonl | ||
| 229 | 245 | ${when(f.schedule.length > 0, () => html`<section class="settings-block"> |
| 230 | 246 | <h3>Scheduled</h3> |
| 231 | 247 | <ul class="list">${f.schedule.map((x) => row({ |
| 232 | title: html`<span class="mono">${x.tool}</span> every ${x.every}`, | |
| 248 | title: html`<span class="mono">${x.tool}</span> ${scheduleWhen(x)}`, | |
| 233 | 249 | 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 }))}`, |
| 234 | 250 | trail: html`<form method="post" action="${base}/run/${encodeURIComponent(x.tool)}">${btn("Run now", { label: `Run ${x.tool} now` })}</form>`, |
| 235 | 251 | }))}</ul> |
| 236 | 252 | </section>`)} |
| 253 | ${when(f.history.length > 0, () => html`<section class="settings-block"> | |
| 254 | <h3>Run history</h3> | |
| 255 | ${historyByTool(f.history).map(([tool, runs]) => html`<h4 class="mono">${tool}</h4> | |
| 256 | <ul class="list">${runs.slice(0, HISTORY_SHOWN).map(historyRow)}</ul> | |
| 257 | ${when(runs.length > HISTORY_SHOWN, () => html`<details><summary>${runs.length - HISTORY_SHOWN} older</summary> | |
| 258 | <ul class="list">${runs.slice(HISTORY_SHOWN).map(historyRow)}</ul></details>`)}`)} | |
| 259 | </section>`)} | |
| 237 | 260 | <p class="muted small"> |
| 238 | 261 | Saving restarts ${f.name}: its process reads each setting as an environment variable, named on the right. |
| 239 | 262 | <a href="/settings/features">All features</a> |
test/feature-clock-schedule.test.ts added +162
| @@ -0,0 +1,162 @@ | ||
| 1 | /** | |
| 2 | * A schedule can fire at a clock time (#12043): `at` in an IANA zone across a daylight-saving | |
| 3 | * change, once per local day, late within the grace window and skipped past it; a manual run does | |
| 4 | * not move it; a new or changed schedule waits for its next time; every run is the one recorded | |
| 5 | * run, with a history of 30. | |
| 6 | */ | |
| 7 | import { Database } from "bun:sqlite"; | |
| 8 | import { beforeEach, describe, expect, test } from "bun:test"; | |
| 9 | import { parseFeature, type ScheduleEntry } from "../src/apps/feature.ts"; | |
| 10 | import { lastSlot, nextSlot } from "../src/apps/feature_clock.ts"; | |
| 11 | import { runNow } from "../src/apps/feature_run.ts"; | |
| 12 | import { fireApp, GRACE_MS, scheduleStatus, type Reach } from "../src/apps/feature_schedule.ts"; | |
| 13 | import { HISTORY_KEEP, lastFires, runHistory, recordFire } from "../src/core/feature_fires.ts"; | |
| 14 | import { absorb } from "../src/core/features.ts"; | |
| 15 | import { migrationFiles } from "../src/core/schema.ts"; | |
| 16 | import { migrate } from "../src/kernel/migrate.ts"; | |
| 17 | ||
| 18 | const HOUR = 3_600_000; | |
| 19 | const DAY = 24 * HOUR; | |
| 20 | const TZ = "America/Chicago"; | |
| 21 | const entry = { tool: "print_today", at: "07:15", tz: TZ }; | |
| 22 | const utc = (s: string): number => Date.parse(s); | |
| 23 | ||
| 24 | let db: Database; | |
| 25 | let calls: number; | |
| 26 | const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => (calls++, Response.json({ ok: true })) }); | |
| 27 | const reach = (schedule: ScheduleEntry[] = [entry]): Reach => ({ port: () => server.port, declared: async () => ({ tools: ["print_today"], schedule }) }); | |
| 28 | beforeEach(() => { | |
| 29 | db = new Database(":memory:"); | |
| 30 | migrate(db, migrationFiles); | |
| 31 | absorb(db, "a1", "admin", "Paper", "/"); | |
| 32 | calls = 0; | |
| 33 | }); | |
| 34 | ||
| 35 | describe("the clock", () => { | |
| 36 | test("07:15 in Chicago is 13:15Z in November (CST) and 12:15Z in summer (CDT)", () => { | |
| 37 | expect(lastSlot("07:15", TZ, utc("2026-11-13T14:00:00Z"))).toBe(utc("2026-11-13T13:15:00Z")); | |
| 38 | expect(lastSlot("07:15", TZ, utc("2026-07-13T14:00:00Z"))).toBe(utc("2026-07-13T12:15:00Z")); | |
| 39 | expect(nextSlot("07:15", TZ, utc("2026-11-13T14:00:00Z"))).toBe(utc("2026-11-14T13:15:00Z")); | |
| 40 | // before the time, the last slot is yesterday's | |
| 41 | expect(lastSlot("07:15", TZ, utc("2026-11-13T13:00:00Z"))).toBe(utc("2026-11-12T13:15:00Z")); | |
| 42 | }); | |
| 43 | ||
| 44 | test("across the autumn change (2026-11-01) the local time holds and the UTC instant moves an hour", () => { | |
| 45 | const before = lastSlot("07:15", TZ, utc("2026-10-31T20:00:00Z")); | |
| 46 | const after = lastSlot("07:15", TZ, utc("2026-11-01T20:00:00Z")); | |
| 47 | expect(before).toBe(utc("2026-10-31T12:15:00Z")); | |
| 48 | expect(after).toBe(utc("2026-11-01T13:15:00Z")); | |
| 49 | expect(after - before).toBe(DAY + HOUR); | |
| 50 | }); | |
| 51 | ||
| 52 | test("across the spring change (2026-03-08) too, and a daily at fires once on that 23-hour day", async () => { | |
| 53 | expect(lastSlot("07:15", TZ, utc("2026-03-07T20:00:00Z"))).toBe(utc("2026-03-07T13:15:00Z")); | |
| 54 | expect(lastSlot("07:15", TZ, utc("2026-03-08T20:00:00Z"))).toBe(utc("2026-03-08T12:15:00Z")); | |
| 55 | await fireApp(db, "a1", reach(), () => utc("2026-03-07T20:00:00Z")); | |
| 56 | let fired = 0; | |
| 57 | for (let t = utc("2026-03-07T20:00:00Z"); t < utc("2026-03-09T20:00:00Z"); t += 60_000) fired += (await fireApp(db, "a1", reach(), () => t)).length; | |
| 58 | expect(fired).toBe(2); | |
| 59 | expect(runHistory(db, "a1").map((r) => r.at)).toEqual([utc("2026-03-09T12:15:00Z"), utc("2026-03-08T12:15:00Z")]); | |
| 60 | }); | |
| 61 | }); | |
| 62 | ||
| 63 | describe("a clock-time schedule", () => { | |
| 64 | test("a new schedule fires at its next time, not at once; once per local day, never twice", async () => { | |
| 65 | const t0 = utc("2026-11-13T05:30:00Z"); // the moment the app was absorbed | |
| 66 | expect(await fireApp(db, "a1", reach(), () => t0)).toEqual([]); | |
| 67 | expect(await fireApp(db, "a1", reach(), () => utc("2026-11-13T13:14:00Z"))).toEqual([]); | |
| 68 | expect(await fireApp(db, "a1", reach(), () => utc("2026-11-13T13:15:00Z"))).toEqual(["print_today"]); | |
| 69 | for (const t of ["13:16", "15:00", "23:59"]) expect(await fireApp(db, "a1", reach(), () => utc(`2026-11-13T${t}:00Z`))).toEqual([]); | |
| 70 | expect(await fireApp(db, "a1", reach(), () => utc("2026-11-14T13:15:30Z"))).toEqual(["print_today"]); | |
| 71 | expect(calls).toBe(2); | |
| 72 | expect(runHistory(db, "a1").map((r) => r.how)).toEqual(["schedule", "schedule"]); | |
| 73 | }); | |
| 74 | ||
| 75 | test("a forge that was away at that minute fires on its next look inside the grace window and says it was late", async () => { | |
| 76 | await fireApp(db, "a1", reach(), () => utc("2026-11-13T05:30:00Z")); | |
| 77 | expect(await fireApp(db, "a1", reach(), () => utc("2026-11-13T13:15:00Z") + GRACE_MS)).toEqual(["print_today"]); | |
| 78 | expect(runHistory(db, "a1")[0]?.how).toBe("late"); | |
| 79 | expect(await fireApp(db, "a1", reach(), () => utc("2026-11-13T17:00:00Z"))).toEqual([]); | |
| 80 | }); | |
| 81 | ||
| 82 | test("past the grace window the day is skipped, says so, and tomorrow still fires", async () => { | |
| 83 | await fireApp(db, "a1", reach(), () => utc("2026-11-13T05:30:00Z")); | |
| 84 | const back = utc("2026-11-13T13:15:00Z") + GRACE_MS + 60_000; | |
| 85 | expect(await fireApp(db, "a1", reach(), () => back)).toEqual([]); | |
| 86 | expect(calls).toBe(0); | |
| 87 | const [skip] = runHistory(db, "a1"); | |
| 88 | expect(skip).toMatchObject({ how: "skipped", ok: false }); | |
| 89 | expect(skip?.error).toContain("skipped Fri 07:15"); | |
| 90 | expect(lastFires(db, "a1")).toEqual([]); // a missed day is not a fire | |
| 91 | expect(await fireApp(db, "a1", reach(), () => back + 60_000)).toEqual([]); | |
| 92 | expect(runHistory(db, "a1")).toHaveLength(1); | |
| 93 | expect(await fireApp(db, "a1", reach(), () => utc("2026-11-14T13:15:00Z"))).toEqual(["print_today"]); | |
| 94 | }); | |
| 95 | ||
| 96 | test("a manual run does not move an at schedule; for every it does", async () => { | |
| 97 | await fireApp(db, "a1", reach(), () => utc("2026-11-13T05:30:00Z")); | |
| 98 | expect(await runNow(db, "a1", "print_today", reach(), "terminal")).toEqual({ ok: true, message: "print_today ran." }); | |
| 99 | expect(await fireApp(db, "a1", reach(), () => utc("2026-11-13T13:15:00Z"))).toEqual(["print_today"]); | |
| 100 | const every = [{ tool: "print_today", everyMs: DAY }]; | |
| 101 | await fireApp(db, "a1", reach(every), () => utc("2026-11-20T00:00:00Z")); | |
| 102 | await runNow(db, "a1", "print_today", reach(every), "page"); | |
| 103 | const ran = lastFires(db, "a1")[0]!.at; | |
| 104 | expect(await fireApp(db, "a1", reach(every), () => ran + DAY - 1)).toEqual([]); | |
| 105 | }); | |
| 106 | ||
| 107 | test("changing the schedule starts it counting again", async () => { | |
| 108 | await fireApp(db, "a1", reach(), () => utc("2026-11-13T05:30:00Z")); | |
| 109 | const moved = [{ ...entry, at: "09:00" }]; | |
| 110 | // At 13:20Z (07:20 local) a schedule changed to 07:30 has not missed today's 07:15, and does | |
| 111 | // not fire. | |
| 112 | expect(await fireApp(db, "a1", reach([{ ...entry, at: "07:30" }]), () => utc("2026-11-13T13:20:00Z"))).toEqual([]); | |
| 113 | expect(await fireApp(db, "a1", reach(moved), () => utc("2026-11-14T14:00:00Z"))).toEqual([]); // seen: counting starts here | |
| 114 | expect(await fireApp(db, "a1", reach(moved), () => utc("2026-11-14T15:00:30Z"))).toEqual(["print_today"]); | |
| 115 | }); | |
| 116 | }); | |
| 117 | ||
| 118 | describe("one recorded run", () => { | |
| 119 | test("every way of starting a run is a row in the last fire and the history, with how", async () => { | |
| 120 | await runNow(db, "a1", "print_today", reach(), "page"); | |
| 121 | await runNow(db, "a1", "print_today", reach(), "terminal"); | |
| 122 | expect(runHistory(db, "a1").map((r) => r.how)).toEqual(["terminal", "page"]); | |
| 123 | expect(lastFires(db, "a1")).toHaveLength(1); | |
| 124 | }); | |
| 125 | ||
| 126 | test("the history keeps the last 30 per tool", () => { | |
| 127 | for (let i = 0; i < HISTORY_KEEP + 5; i++) recordFire(db, "a1", { tool: "t", at: i, ok: true, error: null, durationMs: 1 }); | |
| 128 | recordFire(db, "a1", { tool: "u", at: 1, ok: true, error: null, durationMs: 1 }); | |
| 129 | const t = runHistory(db, "a1").filter((r) => r.tool === "t"); | |
| 130 | expect(t).toHaveLength(HISTORY_KEEP); | |
| 131 | expect(t[0]!.at).toBe(HISTORY_KEEP + 4); | |
| 132 | expect(runHistory(db, "a1").filter((r) => r.tool === "u")).toHaveLength(1); | |
| 133 | }); | |
| 134 | ||
| 135 | test("the line reads as the owner's morning paper should", () => { | |
| 136 | const fire = { tool: "print_today", at: utc("2026-11-13T13:15:04Z"), ok: true, error: null, durationMs: 9000 }; | |
| 137 | const [s] = scheduleStatus([entry], [fire], utc("2026-11-13T20:00:00Z")); | |
| 138 | expect(s?.line).toBe("print_today at 07:15 America/Chicago · last Fri 07:15, ok · next Sat 07:15"); | |
| 139 | expect(scheduleStatus([entry], [], utc("2026-11-13T20:00:00Z"))[0]?.line).toBe("print_today at 07:15 America/Chicago · never run · next Sat 07:15"); | |
| 140 | }); | |
| 141 | }); | |
| 142 | ||
| 143 | describe("the declaration", () => { | |
| 144 | const parse = (schedule: unknown) => | |
| 145 | parseFeature({ app: { run: "x" }, feature: { name: "Paper", tools: { print_today: { description: "Print" } }, schedule } }); | |
| 146 | test("at with tz is accepted", () => { | |
| 147 | const ok = parse([{ tool: "print_today", at: "07:15", tz: TZ }]); | |
| 148 | expect(ok.ok && ok.value?.schedule).toEqual([{ tool: "print_today", at: "07:15", tz: TZ }]); | |
| 149 | }); | |
| 150 | test("exactly one of every and at; at needs a real zone and a clock; tz never with every", () => { | |
| 151 | for (const bad of [ | |
| 152 | { tool: "print_today" }, | |
| 153 | { tool: "print_today", every: "1d", at: "07:15", tz: TZ }, | |
| 154 | { tool: "print_today", at: "07:15" }, | |
| 155 | { tool: "print_today", at: "07:15", tz: "Mars/Olympus" }, | |
| 156 | { tool: "print_today", at: "7:15", tz: TZ }, | |
| 157 | { tool: "print_today", at: "24:00", tz: TZ }, | |
| 158 | { tool: "print_today", every: "1d", tz: TZ }, | |
| 159 | ]) | |
| 160 | expect(parse([bad]).ok).toBe(false); | |
| 161 | }); | |
| 162 | }); |
test/feature-schedule-fire.test.ts +19 −12
| @@ -38,27 +38,33 @@ beforeEach(() => { | ||
| 38 | 38 | afterEach(() => db.close()); |
| 39 | 39 | |
| 40 | 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); | |
| 41 | const every = schedule[0]!; | |
| 42 | test("an interval counts from the later of its last fire and when the schedule began; at it, it is due", () => { | |
| 43 | expect(due(every, { since: 5 }, 5 + HOUR - 1)).toBeUndefined(); | |
| 44 | expect(due(every, { since: 5 }, 5 + HOUR)).toEqual({ do: "fire", how: "schedule" }); | |
| 45 | expect(due(every, { since: 5, lastFired: 1000 }, 1000 + HOUR - 1)).toBeUndefined(); | |
| 46 | expect(due(every, { since: 5, lastFired: 1000 }, 1000 + HOUR)).toEqual({ do: "fire", how: "schedule" }); | |
| 45 | 47 | }); |
| 46 | 48 | }); |
| 47 | 49 | |
| 48 | 50 | describe("fireApp", () => { |
| 49 | 51 | 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)).toEqual([]); // the first look only begins to count | |
| 53 | expect(seen).toEqual([]); | |
| 52 | 54 | 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 | 55 | expect(await fireApp(db, "a1", reach(), () => 10_000 + HOUR)).toEqual(["scan"]); |
| 56 | expect(seen).toEqual(["/forge/tools/scan"]); | |
| 57 | expect(await fireApp(db, "a1", reach(), () => 10_000 + 2 * HOUR - 1)).toEqual([]); | |
| 58 | expect(seen.length).toBe(1); | |
| 59 | expect(lastFires(db, "a1")).toEqual([{ tool: "scan", at: 10_000 + HOUR, ok: true, error: null, durationMs: 0 }]); | |
| 60 | expect(await fireApp(db, "a1", reach(), () => 10_000 + 2 * HOUR)).toEqual(["scan"]); | |
| 56 | 61 | expect(seen.length).toBe(2); |
| 57 | 62 | }); |
| 58 | 63 | |
| 59 | 64 | test("a failing tool is recorded with its sentence", async () => { |
| 60 | 65 | answer = () => new Response("no", { status: 500 }); |
| 61 | 66 | await fireApp(db, "a1", reach(), () => 5); |
| 67 | await fireApp(db, "a1", reach(), () => 5 + HOUR); | |
| 62 | 68 | const [fire] = lastFires(db, "a1"); |
| 63 | 69 | expect(fire?.ok).toBe(false); |
| 64 | 70 | expect(fire?.error).toBe("the app answered 500"); |
| @@ -117,13 +123,14 @@ describe("fireDue", () => { | ||
| 117 | 123 | const reachOf = (r: { id: string }): Reach => |
| 118 | 124 | r.id === "a2" ? reach({ declared: async () => Promise.reject(new Error("boom")) }) : reach(); |
| 119 | 125 | await fireDue(forge, () => 10_000, reachOf); |
| 120 | expect(seen).toEqual(["/forge/tools/scan"]); | |
| 121 | 126 | 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 | 127 | await fireDue(forge, () => 10_000 + HOUR, reachOf); |
| 128 | expect(seen).toEqual(["/forge/tools/scan"]); | |
| 129 | await fireDue(forge, () => 10_000 + 2 * HOUR - 1, reachOf); | |
| 130 | expect(seen.length).toBe(1); | |
| 131 | await fireDue(forge, () => 10_000 + 2 * HOUR, reachOf); | |
| 125 | 132 | expect(seen.length).toBe(2); |
| 126 | expect(lastFires(db, "a1")[0]?.at).toBe(10_000 + HOUR); | |
| 133 | expect(lastFires(db, "a1")[0]?.at).toBe(10_000 + 2 * HOUR); | |
| 127 | 134 | }); |
| 128 | 135 | }); |
| 129 | 136 |
test/features-tui.test.ts +20
| @@ -15,6 +15,7 @@ import { createApp, type App as Forge } from "../src/app.ts"; | ||
| 15 | 15 | import { createApp as insertApp } from "../src/core/apps.ts"; |
| 16 | 16 | import { clearFires, recordFire } from "../src/core/feature_fires.ts"; |
| 17 | 17 | import { featureSettings, getAbsorbed } from "../src/core/features.ts"; |
| 18 | import { getRepoById } from "../src/core/repos.ts"; | |
| 18 | 19 | import { createUser, type User } from "../src/core/users.ts"; |
| 19 | 20 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 20 | 21 | import type { TuiIdentity } from "../src/tui/session.ts"; |
| @@ -166,6 +167,25 @@ describe("features in the terminal", () => { | ||
| 166 | 167 | t.stop(); |
| 167 | 168 | }); |
| 168 | 169 | |
| 170 | test("the feed screen words a clock-time schedule in its zone, and h shows the run history", async () => { | |
| 171 | const clock = JSON.stringify({ ...JSON.parse(FORGE_JSON), feature: { ...JSON.parse(FORGE_JSON).feature, schedule: [{ tool: "scan", at: "07:15", tz: "America/Chicago" }] } }); | |
| 172 | await commitToBranch(forge, ada, getRepoById(forge.db, repoId)!, { message: "clock", files: [{ path: "forge.json", content: clock }] }); | |
| 173 | recordFire(forge.db, appId, { tool: "scan", at: Date.now() - 120_000, ok: true, error: null, durationMs: 3 }, "terminal"); | |
| 174 | const t = boot(ada); | |
| 175 | await t.palette("Feature feeds"); | |
| 176 | await t.press("enter"); | |
| 177 | await t.press("enter"); | |
| 178 | expect(t.text()).toMatch(/Scheduled: scan at 07:15 America\/Chicago · last /); | |
| 179 | expect(t.text()).not.toContain("every 1h"); | |
| 180 | await t.press("h"); | |
| 181 | expect(t.text()).toMatch(/scan · terminal · \d{4}-\d\d-\d\d \d\d:\d\d · ok · 3 ms/); | |
| 182 | await t.press("h"); | |
| 183 | expect(t.text()).not.toContain("scan · terminal"); | |
| 184 | t.stop(); | |
| 185 | clearFires(forge.db, appId); | |
| 186 | await commitToBranch(forge, ada, getRepoById(forge.db, repoId)!, { message: "back", files: [{ path: "forge.json", content: FORGE_JSON }] }); | |
| 187 | }); | |
| 188 | ||
| 169 | 189 | test("an admin presses r on the feed screen to run the scheduled tool; the refusal is the page's sentence", async () => { |
| 170 | 190 | recordFire(forge.db, appId, { tool: "scan", at: Date.now() - 120_000, ok: true, error: null, durationMs: 3 }); |
| 171 | 191 | const t = boot(ada); |
test/features.test.ts +26
| @@ -362,6 +362,22 @@ describe("absorbing an app", () => { | ||
| 362 | 362 | expect(idle.ok && idle.value.schedule[0]!.line).toBe("scan every 1h · never run · next soon"); |
| 363 | 363 | }); |
| 364 | 364 | |
| 365 | test("the page words a clock-time schedule in its zone, and groups the run history per tool, the older ones behind a disclosure", async () => { | |
| 366 | const clock = JSON.stringify({ app: { run: "x" }, feature: { ...FEATURE, schedule: [{ tool: "scan", at: "07:15", tz: "America/Chicago" }] } }); | |
| 367 | await commitToBranch(app, cy, repo, { message: "clock", files: [{ path: "forge.json", content: clock }] }); | |
| 368 | for (let i = 0; i < 7; i++) recordFire(app.db, appId, { tool: "scan", at: Date.now() - (10 - i) * 60_000, ok: i !== 6, error: i === 6 ? "<i>boom</i>" : null, durationMs: 5 }, "page"); | |
| 369 | const page = await (await web(adaCookie, "/settings/features/radar")).text(); | |
| 370 | expect(page).toMatch(/at 07:15 America\/Chicago/); | |
| 371 | expect(page).not.toMatch(/every 1h/); | |
| 372 | expect(page).toContain("<h3>Run history</h3>"); | |
| 373 | expect(page.match(/<h4 class="mono">scan<\/h4>/g)).toHaveLength(1); | |
| 374 | expect(page).toContain("2 older</summary>"); | |
| 375 | expect(page).toContain("<i>boom</i>"); | |
| 376 | expect(page).not.toContain("<i>boom"); | |
| 377 | clearFires(app.db, appId); | |
| 378 | await commitToBranch(app, cy, repo, { message: "back", files: [{ path: "forge.json", content: FORGE_JSON }] }); | |
| 379 | }); | |
| 380 | ||
| 365 | 381 | test("the features tool with a slug says each scheduled tool's last and next run, as the page does", async () => { |
| 366 | 382 | const at = Date.now() - 120_000; |
| 367 | 383 | recordFire(app.db, appId, { tool: "scan", at, ok: false, error: "the app answered 500", durationMs: 7 }); |
| @@ -825,9 +841,19 @@ describe("absorbing an app", () => { | ||
| 825 | 841 | const done = await mcpTool(servers.url, cyRw, "features", { slug: "radar", tool: "scan", args: { depth: 2 }, dryRun: false }); |
| 826 | 842 | expect(done.value).toEqual({ tool: "scan", result: { found: 3 } }); |
| 827 | 843 | expect(hits).toEqual(['/forge/tools/scan {"args":{"depth":2}}']); |
| 844 | // The call is a recorded run: it is the tool's last fire, in its line and its history, | |
| 845 | // started by mcp. | |
| 846 | const after = await getFeature(app, ada, "radar"); | |
| 847 | expect(after.ok && after.value.fires.map((f) => [f.tool, f.ok])).toEqual([["scan", true]]); | |
| 848 | expect(after.ok && after.value.history.map((r) => [r.tool, r.how, r.ok])).toEqual([["scan", "mcp", true]]); | |
| 849 | expect(after.ok && after.value.schedule[0]!.line).toMatch(/^scan every 1h · last \d\d:\d\d, ok · \d+ ms · next \d\d:\d\d$/); | |
| 828 | 850 | // A reader still reads with a read tool. |
| 829 | 851 | const read = await mcpTool(servers.url, bobRw, "features", { slug: "radar", tool: "latest" }); |
| 830 | 852 | expect(read.value).toEqual({ tool: "latest", result: { found: 3 } }); |
| 853 | // A tool the schedule does not name is not a recorded run: history keeps only the scheduled | |
| 854 | // one. | |
| 855 | const kept = await getFeature(app, ada, "radar"); | |
| 856 | expect(kept.ok && kept.value.history.map((r) => r.tool)).toEqual(["scan"]); | |
| 831 | 857 | // A stopped app is told nothing of a plan. |
| 832 | 858 | serving(false); |
| 833 | 859 | const stopped = await mcpTool(servers.url, cyRw, "features", { slug: "radar", tool: "scan" }); |
new above
offline