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

A feature declares a schedule in forge.json, and a malformed one is refused with a reason that names it #9947

Merged opened this

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

Merged

mason/forge:mason/9925-a-feature-declares-a-schedule-in-forge-j → main 1 commit fork

  • Merged. mason/9925-a-feature-declares-a-schedule-in-forge-j was merged into main as 902cb66.

Review

Sign in to review this change.
3 files changed +68 −3
docs/features/absorb.md +5 −1
@@ -57,7 +57,7 @@ What the feed holds about an app's repository (runs, pulls, issues, looks) shows
5757
5858 ## Without the crew
5959
60 Absorbing, settings, the menu entry and the page use no model and start no agent run. They work with the crew off and no model credential on the forge. `test/apps-features.test.ts` runs the whole flow that way. The forge never hands an app the crew's credential. A feature's schedule is the app's own: the app reads `FORGE_SETTING_*` and runs its own timer. An app that wants a model's judgment on top, such as an issue written from what it found, must do its work without one and treat the model as optional.
60 Absorbing, settings, the menu entry and the page use no model and start no agent run. They work with the crew off and no model credential on the forge. `test/apps-features.test.ts` runs the whole flow that way. The forge never hands an app the crew's credential. An app that wants a model's judgment on top, such as an issue written from what it found, must do its work without one and treat the model as optional.
6161
6262 ## Settings are not secrets, except `secret`
6363
@@ -80,6 +80,10 @@ A pack's app may carry `"absorb": true` (a boolean, nothing else). `pack deploy`
8080
8181 Settings stay behind on a fork. A fork of another forge's app carries its `feature` block, which declares and does nothing; the new forge has its own settings (the declared defaults until an admin changes them) and its own admin decides on the absorb.
8282
83 ## Schedule
84
85 A feature may declare `"schedule": [{ "tool": "scan", "every": "1h" }]`: at most 4 entries, each naming one of the feature's own `tools` and an `every` of `15m`, `1h`, `6h` or `1d`. Anything else is refused with the field and the entry named. The forge keeps it as `schedule` (`tool`, `everyMs`) on the parsed feature. Firing is not done yet: nothing calls the tool on that interval (#9883).
86
8387 ## Not done yet
8488
8589 Until then an absorbed app is a menu link with kept settings (Settings › Features says so). The forge does not yet fire a feature's schedule (#9883).
src/apps/feature.ts +37 −2
@@ -68,6 +68,39 @@ export interface FeatureSpec {
6868 readonly settings: readonly SettingSpec[];
6969 /** MCP tools the app serves through the forge's `features` tool (`feature_tools.ts`). */
7070 readonly tools: readonly ToolSpec[];
71 /** Tools the app asks the forge to call on a timer; empty when none. Declared only: nothing fires yet. */
72 readonly schedule: readonly ScheduleEntry[];
73 }
74
75 export interface ScheduleEntry {
76 readonly tool: string;
77 readonly everyMs: number;
78 }
79
80 const MINUTE = 60_000;
81 /** The intervals a schedule entry may name. */
82 export const SCHEDULE_EVERY: Readonly<Record<string, number>> = { "15m": 15 * MINUTE, "1h": 60 * MINUTE, "6h": 360 * MINUTE, "1d": 1440 * MINUTE };
83 export const SCHEDULE_MAX = 4;
84
85 /** The `schedule` of a feature block, checked against the tools the feature declares; an error names the entry. */
86 function parseSchedule(raw: unknown, tools: readonly ToolSpec[]): Result<readonly ScheduleEntry[], string> {
87 if (raw === undefined) return ok([]);
88 if (!Array.isArray(raw)) return bad("schedule must be a list of { tool, every }");
89 if (raw.length > SCHEDULE_MAX) return bad(`schedule has at most ${SCHEDULE_MAX} entries`);
90 const out: ScheduleEntry[] = [];
91 for (const [i, e] of raw.entries()) {
92 const at = `schedule entry ${i + 1}`;
93 if (!isObject(e)) return bad(`${at} must be an object with tool and every`);
94 for (const extra of Object.keys(e)) if (extra !== "tool" && extra !== "every") return bad(`${at}: unknown field ${extra}`);
95 const tool = e["tool"];
96 if (typeof tool !== "string" || !tools.some((t) => t.name === tool))
97 return bad(`${at}: tool ${JSON.stringify(tool)} is not one of this feature's tools${tools.length ? ` (${tools.map((t) => t.name).join(", ")})` : ""}`);
98 const every = e["every"];
99 if (typeof every !== "string" || !Object.hasOwn(SCHEDULE_EVERY, every))
100 return bad(`${at} (${tool}): every is one of ${Object.keys(SCHEDULE_EVERY).join(", ")}`);
101 out.push({ tool, everyMs: SCHEDULE_EVERY[every]! });
102 }
103 return ok(out);
71104 }
72105
73106 const KEY = /^[a-z][a-zA-Z0-9]{0,39}$/;
@@ -117,10 +150,12 @@ export function parseFeature(forgeJson: Raw): Result<FeatureSpec | undefined, st
117150 const envs = new Set(settings.map((s) => settingEnvName(s.key)));
118151 if (envs.size !== settings.length) return bad("two settings would share one FORGE_SETTING_ name");
119152 for (const extra of Object.keys(block))
120 if (!["name", "summary", "page", "settings", "tools"].includes(extra)) return bad(`unknown field ${extra}`);
153 if (!["name", "summary", "page", "settings", "tools", "schedule"].includes(extra)) return bad(`unknown field ${extra}`);
121154 const tools = parseTools(block["tools"]);
122155 if (!tools.ok) return tools;
123 return ok({ tools: tools.value, name: name.trim(), summary: typeof summary === "string" && summary.trim() !== "" ? summary.trim() : null, page, settings });
156 const schedule = parseSchedule(block["schedule"], tools.value);
157 if (!schedule.ok) return schedule;
158 return ok({ tools: tools.value, schedule: schedule.value, name: name.trim(), summary: typeof summary === "string" && summary.trim() !== "" ? summary.trim() : null, page, settings });
124159 }
125160
126161 function parseSetting(key: string, raw: unknown): Result<SettingSpec, string> {
test/feature-schedule.test.ts added +26
@@ -0,0 +1,26 @@
1 import { expect, test } from "bun:test";
2 import { parseFeature } from "../src/apps/feature.ts";
3
4 const tools = { scan: { description: "Run a scan" }, latest: { description: "Last", writes: false } };
5 const parse = (schedule?: unknown) => parseFeature({ feature: { name: "Radar", tools, ...(schedule === undefined ? {} : { schedule }) } });
6 const refused = (schedule: unknown, text: string) => {
7 const r = parse(schedule);
8 expect(r.ok).toBe(false);
9 if (!r.ok) expect(r.error).toContain(text);
10 };
11
12 test("absent schedule is empty", () => {
13 const r = parse();
14 expect(r.ok && r.value?.schedule).toEqual([]);
15 });
16
17 test("a valid entry carries its interval in ms", () => {
18 const r = parse([{ tool: "scan", every: "1h" }, { tool: "latest", every: "1d" }]);
19 expect(r.ok && r.value?.schedule).toEqual([{ tool: "scan", everyMs: 3_600_000 }, { tool: "latest", everyMs: 86_400_000 }]);
20 });
21
22 test("an unknown tool is refused, naming it", () => refused([{ tool: "nope", every: "1h" }], 'schedule entry 1: tool "nope"'));
23 test("an unknown interval is refused, naming the entry", () => refused([{ tool: "scan", every: "5m" }], "schedule entry 1 (scan): every is one of"));
24 test("more than 4 entries are refused", () => refused(Array(5).fill({ tool: "scan", every: "1h" }), "at most 4 entries"));
25 test("a non-array is refused", () => refused({ tool: "scan", every: "1h" }, "schedule must be a list"));
26 test("an entry with an extra field is refused", () => refused([{ tool: "scan", every: "1h", x: 1 }], "unknown field x"));

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs