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

Merged opened this in answer to

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

Merged

scout/forge:scout/12043-a-schedule-fires-at-a-clock-time-and-eve → main 7 commits fork

  • Merged. scout/12043-a-schedule-fires-at-a-clock-time-and-eve was merged into main as ab6dc02.

Review

Sign in to review this change.
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:
1515 ```
1616
1717 - 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.
1819 - Each entry names a tool the feature declares. A tool that is no longer declared is skipped, never called.
1920 - The tool is called with no arguments, so it should be one that needs none.
2021
@@ -42,3 +43,11 @@ Every door shows one line per schedule entry, worded once by `scheduleMeta` in `
4243 ## Not done
4344
4445 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 @@
77 */
88
99 import type { App } from "../app.ts";
10 import { runRecorded } from "../apps/feature_schedule.ts";
1011 import { callTool } from "../apps/feature_tools.ts";
1112 import { appsRuntime } from "../apps/runtime.ts";
1213 import { getAbsorbed } from "../core/features.ts";
@@ -37,6 +38,15 @@ export async function callFeatureTool(
3738 if (port === undefined) return refuse("not_running", "The app is not running.");
3839 // A plan is told only once the call could be made: allowed, absorbed, running.
3940 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);
4252 }
src/actions/features.ts +5 −2
@@ -18,7 +18,7 @@
1818 */
1919
2020 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";
2222 import type { App } from "../app.ts";
2323 import { readFileAt } from "../apps/checkout.ts";
2424 import { checkSetting, featureEnv, parseFeature, settingEnvName, type FeatureSpec } from "../apps/feature.ts";
@@ -92,6 +92,8 @@ export interface FeatureView {
9292 readonly tools: readonly ToolSpec[];
9393 /** How the forge's last call of each scheduled tool went; empty when none has fired. */
9494 readonly fires: readonly Fired[];
95 /** The last 30 runs of each tool, newest first, with how each was started. */
96 readonly history: readonly Run[];
9597 /** Each scheduled tool with its last fire and next run; empty when not absorbed. */
9698 readonly schedule: readonly ScheduleStatus[];
9799 /** Where the app comes from: the upstream only when the reader may read it. */
@@ -246,7 +248,8 @@ function viewOf(
246248 provenance,
247249 tools: spec?.tools ?? [],
248250 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)),
250253 settings: (spec?.settings ?? []).map((s) => {
251254 const name = settingEnvName(s.key);
252255 return {
src/apps/cron.ts +2 −2
@@ -102,9 +102,9 @@ function zone(tz: string): Intl.DateTimeFormat {
102102 return f;
103103 }
104104
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 }
106106
107 function wallAt(ms: number, tz: string): Wall {
107 export function wallAt(ms: number, tz: string): Wall {
108108 const p: Record<string, number> = {};
109109 for (const part of zone(tz).formatToParts(ms)) if (part.type !== "literal") p[part.type] = Number(part.value);
110110 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 @@
2323 */
2424
2525 import { err, ok, type Result } from "../kernel/result.ts";
26 import { isTimezone } from "./cron.ts";
2627 import { parseTools, type ToolSpec } from "./feature_tools.ts";
2728
2829 export type SettingType = "bool" | "int" | "string" | "choice" | "days" | "list" | "secret";
@@ -73,11 +74,19 @@ export interface FeatureSpec {
7374 readonly schedule: readonly ScheduleEntry[];
7475 }
7576
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 */
7681 export interface ScheduleEntry {
7782 readonly tool: string;
78 readonly everyMs: number;
83 readonly everyMs?: number | undefined;
84 readonly at?: string | undefined;
85 readonly tz?: string | undefined;
7986 }
8087
88 const CLOCK = /^([01]\d|2[0-3]):[0-5]\d$/;
89
8190 const MINUTE = 60_000;
8291 /** The intervals a schedule entry may name. */
8392 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;
8695 /** The `schedule` of a feature block, checked against the tools the feature declares; an error names the entry. */
8796 function parseSchedule(raw: unknown, tools: readonly ToolSpec[]): Result<readonly ScheduleEntry[], string> {
8897 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 }");
9099 if (raw.length > SCHEDULE_MAX) return bad(`schedule has at most ${SCHEDULE_MAX} entries`);
91100 const out: ScheduleEntry[] = [];
92101 for (const [i, e] of raw.entries()) {
93102 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}`);
96105 const tool = e["tool"];
97106 if (typeof tool !== "string" || !tools.some((t) => t.name === tool))
98107 return bad(`${at}: tool ${JSON.stringify(tool)} is not one of this feature's tools${tools.length ? ` (${tools.map((t) => t.name).join(", ")})` : ""}`);
99108 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 });
103122 }
104123 return ok(out);
105124 }
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 @@
55 */
66
77 import type { Database } from "bun:sqlite";
8 import { lastFires } from "../core/feature_fires.ts";
8 import { lastFires, type How } from "../core/feature_fires.ts";
99 import { fireOne, type Reach } from "./feature_schedule.ts";
1010
1111 export const RUN_REFUSALS = {
@@ -16,8 +16,8 @@ export const RUN_REFUSALS = {
1616 } as const;
1717
1818 /** 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);
2121 if (!fired.ok) return { ok: false, message: RUN_REFUSALS[fired.why] };
2222 const fire = lastFires(db, appId).find((f) => f.tool === tool);
2323 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";
1010 import type { App } from "../app.ts";
1111 import { declaredFeature } from "../actions/features.ts";
1212 import { getAbsorbed } from "../core/features.ts";
13 import { lastFires, recordFire, 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";
1416 import { listApps, type AppRecord } from "../core/apps.ts";
1517 import { getRepoById } from "../core/repos.ts";
1618 import { callTool } from "./feature_tools.ts";
@@ -18,15 +20,80 @@ import { SCHEDULE_EVERY, type ScheduleEntry } from "./feature.ts";
1820 import { appsRuntime } from "./runtime.ts";
1921 import { during } from "../perf/jobs.ts";
2022
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;
2760 }
2861
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
2965 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 }
3097
3198 /** What `fireApp` needs from the running forge: the production port when running, and the declared schedule. */
3299 export interface Reach {
@@ -35,35 +102,31 @@ export interface Reach {
35102 declared(): Promise<{ readonly tools: readonly string[]; readonly schedule: readonly ScheduleEntry[] }>;
36103 }
37104
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 */
39109 export async function fireApp(db: Database, appId: string, reach: Reach, now: () => number = Date.now): Promise<string[]> {
40110 if (getAbsorbed(db, appId) === undefined) return [];
41 const port = reach.port();
42 if (port === undefined) return [];
43111 const declared = await reach.declared();
44112 const schedule = declared.schedule.filter((e) => declared.tools.includes(e.tool));
45113 if (schedule.length === 0) return [];
46114 const last = new Map(lastFires(db, appId).map((f) => [f.tool, f.at]));
47115 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;
66125 }
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);
67130 }
68131 return called;
69132 }
@@ -82,30 +145,16 @@ export async function fireOne(
82145 tool: string,
83146 reach: Reach,
84147 now: () => number = Date.now,
148 how: How = "page",
85149 ): Promise<{ ok: true } | { ok: false; why: FireOneRefusal }> {
86150 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" };
89152 const port = reach.port();
90153 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" };
109158 }
110159
111160 /** 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
155204 return { stop: () => clearInterval(tick) };
156205 }
157206
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 */
159211 export interface ScheduleStatus {
160212 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;
162218 readonly lastAt: number | null;
163219 readonly ok: boolean | null;
164220 /** The failure's sentence; null when the last fire went fine or there is none. */
165221 readonly error: string | null;
166222 /** How long the last fire took; null before the first. */
167223 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). */
169225 readonly nextAt: number | null;
170226 /** The whole line as `scheduleLine` words it, for the tool's readers and the terminal. */
171227 readonly line: string;
@@ -176,33 +232,67 @@ function everyWord(everyMs: number): string {
176232 return Object.entries(SCHEDULE_EVERY).find(([, ms]) => ms === everyMs)?.[0] ?? `${Math.round(everyMs / 60_000)}m`;
177233 }
178234
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[] {
181245 return schedule.map((e) => {
182246 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;
183250 const status: Omit<ScheduleStatus, "line"> = {
184251 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,
186255 lastAt: fire?.at ?? null,
187256 ok: fire?.ok ?? null,
188257 durationMs: fire?.durationMs ?? null,
189258 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),
191260 };
192261 return { ...status, line: scheduleLine(status) };
193262 });
194263 }
195264
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
196278 /** `10:02`: the plain-text clock (UTC) of the line the tool and the terminal print. */
197279 export const scheduleClock = (ms: number): string => new Date(ms).toISOString().slice(11, 16);
198280
199281 /** A piece of the line after the head: words, the last or next moment (ms) the reader formats, or the sentence of a failure. */
200282 export type SchedulePiece = string | { readonly last: number } | { readonly next: number } | { readonly error: string };
201283
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 */
204289 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 }])];
206296 return [
207297 "last ", { last: s.lastAt }, ", ", s.error === null ? "ok" : { error: s.error },
208298 ...(s.durationMs === null ? [] : [` · ${s.durationMs} ms`]),
@@ -211,8 +301,8 @@ export function scheduleMeta(s: Omit<ScheduleStatus, "line">): SchedulePiece[] {
211301 }
212302
213303 /** 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. */
215305 export function scheduleLine(s: Omit<ScheduleStatus, "line">): string {
216306 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("")}`;
218308 }
src/core/apps.ts +2 −1
@@ -10,6 +10,7 @@
1010 import { PUBLIC_VISIBLE } from "./visibility.ts";
1111 import type { Database } from "bun:sqlite";
1212 import { id } from "../kernel/id.ts";
13 import { clearFires } from "./feature_fires.ts";
1314 import { err, ok, type Result } from "../kernel/result.ts";
1415 import type { PreviewData } from "../apps/data.ts";
1516
@@ -235,7 +236,7 @@ export function deleteApp(db: Database, appId: string): boolean {
235236 db.run("DELETE FROM app_vital WHERE app_id = ?1", [appId]);
236237 db.run("DELETE FROM app_feature WHERE app_id = ?1", [appId]);
237238 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);
239240 db.run("DELETE FROM pull_run WHERE app_id = ?1", [appId]);
240241 for (const t of ["timer", "timer_run", "timer_sync"]) db.run(`DELETE FROM ${t} WHERE app_id = ?1`, [appId]);
241242 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 */
25
36 import type { Database } from "bun:sqlite";
47
@@ -10,6 +13,20 @@ export interface Fired {
1013 readonly durationMs: number;
1114 }
1215
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
1330 /** The last fire of each scheduled tool of the app, by tool. */
1431 export function lastFires(db: Database, appId: string): Fired[] {
1532 return db
@@ -20,19 +37,64 @@ export function lastFires(db: Database, appId: string): Fired[] {
2037 .map((r) => ({ tool: r.tool, at: r.at, ok: r.ok === 1, error: r.error, durationMs: r.duration_ms }));
2138 }
2239
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]));
3395 }
3496
3597 /** Forget the app's fires: it is no longer absorbed, or no longer exists. */
3698 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);
38100 }
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 @@
77 */
88
99 import { runNow } from "../../apps/feature_run.ts";
10 import { runtimeReach } from "../../apps/feature_schedule.ts";
10 import { runLine, runtimeReach } from "../../apps/feature_schedule.ts";
1111 import { getAppBySlug } from "../../core/apps.ts";
1212 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";
1414 import type { FeedItem } from "../../feed/rank.ts";
1515 import type { Ctx, Screen } from "../app/app.ts";
1616 import { binding } from "../app/keys.ts";
@@ -30,10 +30,13 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc
3030 let items: readonly FeedItem[] = [];
3131 let fires: readonly Fired[] = [];
3232 let lines: readonly string[] = [];
33 let history: readonly Run[] = [];
34 let showHistory = false;
3335 void getFeature(f.app, f.user, feature.slug).then((found) => {
3436 items = found.ok ? feedOf(f.app, f.user, found.value) : [];
3537 fires = found.ok ? found.value.fires : [];
3638 lines = found.ok ? found.value.schedule.map((x) => x.line) : [];
39 history = found.ok ? found.value.history : [];
3740 ctx.invalidate();
3841 });
3942 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
4548 running = true;
4649 try {
4750 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");
4952 ctx.toast(said.message, said.ok ? "success" : "danger");
5053 }
5154 const found = await getFeature(f.app, f.user, feature.slug);
5255 fires = found.ok ? found.value.fires : fires;
5356 lines = found.ok ? found.value.schedule.map((x) => x.line) : lines;
57 history = found.ok ? found.value.history : history;
5458 } finally {
5559 running = false;
5660 ctx.invalidate();
@@ -58,13 +62,16 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc
5862 }
5963 return {
6064 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")],
6266 render(area, s) {
6367 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;
6875 }
6976 if (items.length === 0) {
7077 emptyState(s, body!, c, { title: "Nothing yet." });
@@ -75,8 +82,9 @@ export function featureFeedScreen(f: Session, ctx: Ctx, feature: NavFeature): Sc
7582 },
7683 key(e) {
7784 if (e.key === "esc" || e.key === "q") ctx.pop();
85 else if (e.key === "h" && history.length > 0) showHistory = !showHistory;
7886 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);
8088 return true;
8189 },
8290 };
src/web/pages/features.ts +25 −2
@@ -19,7 +19,8 @@ import {
1919 provenanceFacts,
2020 } from "../../actions/features.ts";
2121 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";
2324 import { getAppBySlug, type AppRecord } from "../../core/apps.ts";
2425 import { DAYS } from "../../apps/feature.ts";
2526 import type { FeedItem } from "../../feed/rank.ts";
@@ -186,6 +187,21 @@ function typedValue(s: SettingView, raw: unknown): string {
186187 return text;
187188 }
188189
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
189205 function featureView(ctx: Ctx, f: FeatureView, refused?: Refused, items: readonly FeedItem[] = []): Response {
190206 const base = `/settings/features/${encodeURIComponent(f.slug)}`;
191207 const settings = f.settings.map((shown) => {
@@ -229,11 +245,18 @@ function featureView(ctx: Ctx, f: FeatureView, refused?: Refused, items: readonl
229245 ${when(f.schedule.length > 0, () => html`<section class="settings-block">
230246 <h3>Scheduled</h3>
231247 <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)}`,
233249 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 }))}`,
234250 trail: html`<form method="post" action="${base}/run/${encodeURIComponent(x.tool)}">${btn("Run now", { label: `Run ${x.tool} now` })}</form>`,
235251 }))}</ul>
236252 </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>`)}
237260 <p class="muted small">
238261 Saving restarts ${f.name}: its process reads each setting as an environment variable, named on the right.
239262 <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(() => {
3838 afterEach(() => db.close());
3939
4040 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" });
4547 });
4648 });
4749
4850 describe("fireApp", () => {
4951 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([]);
5254 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 }]);
5555 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"]);
5661 expect(seen.length).toBe(2);
5762 });
5863
5964 test("a failing tool is recorded with its sentence", async () => {
6065 answer = () => new Response("no", { status: 500 });
6166 await fireApp(db, "a1", reach(), () => 5);
67 await fireApp(db, "a1", reach(), () => 5 + HOUR);
6268 const [fire] = lastFires(db, "a1");
6369 expect(fire?.ok).toBe(false);
6470 expect(fire?.error).toBe("the app answered 500");
@@ -117,13 +123,14 @@ describe("fireDue", () => {
117123 const reachOf = (r: { id: string }): Reach =>
118124 r.id === "a2" ? reach({ declared: async () => Promise.reject(new Error("boom")) }) : reach();
119125 await fireDue(forge, () => 10_000, reachOf);
120 expect(seen).toEqual(["/forge/tools/scan"]);
121126 expect(warned).toEqual(["could not fire a scheduled tool"]);
122 await fireDue(forge, () => 10_000 + HOUR - 1, reachOf);
123 expect(seen.length).toBe(1);
124127 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);
125132 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);
127134 });
128135 });
129136
test/features-tui.test.ts +20
@@ -15,6 +15,7 @@ import { createApp, type App as Forge } from "../src/app.ts";
1515 import { createApp as insertApp } from "../src/core/apps.ts";
1616 import { clearFires, recordFire } from "../src/core/feature_fires.ts";
1717 import { featureSettings, getAbsorbed } from "../src/core/features.ts";
18 import { getRepoById } from "../src/core/repos.ts";
1819 import { createUser, type User } from "../src/core/users.ts";
1920 import { createForgeApp } from "../src/tui/forge.ts";
2021 import type { TuiIdentity } from "../src/tui/session.ts";
@@ -166,6 +167,25 @@ describe("features in the terminal", () => {
166167 t.stop();
167168 });
168169
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
169189 test("an admin presses r on the feed screen to run the scheduled tool; the refusal is the page's sentence", async () => {
170190 recordFire(forge.db, appId, { tool: "scan", at: Date.now() - 120_000, ok: true, error: null, durationMs: 3 });
171191 const t = boot(ada);
test/features.test.ts +26
@@ -362,6 +362,22 @@ describe("absorbing an app", () => {
362362 expect(idle.ok && idle.value.schedule[0]!.line).toBe("scan every 1h · never run · next soon");
363363 });
364364
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("&lt;i&gt;boom&lt;/i&gt;");
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
365381 test("the features tool with a slug says each scheduled tool's last and next run, as the page does", async () => {
366382 const at = Date.now() - 120_000;
367383 recordFire(app.db, appId, { tool: "scan", at, ok: false, error: "the app answered 500", durationMs: 7 });
@@ -825,9 +841,19 @@ describe("absorbing an app", () => {
825841 const done = await mcpTool(servers.url, cyRw, "features", { slug: "radar", tool: "scan", args: { depth: 2 }, dryRun: false });
826842 expect(done.value).toEqual({ tool: "scan", result: { found: 3 } });
827843 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$/);
828850 // A reader still reads with a read tool.
829851 const read = await mcpTool(servers.url, bobRw, "features", { slug: "radar", tool: "latest" });
830852 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"]);
831857 // A stopped app is told nothing of a plan.
832858 serving(false);
833859 const stopped = await mcpTool(servers.url, cyRw, "features", { slug: "radar", tool: "scan" });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs