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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9925-a-feature-declares-a-schedule-in-forge-j → main 1 commit fork
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 | ||
| 57 | 57 | |
| 58 | 58 | ## Without the crew |
| 59 | 59 | |
| 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. | |
| 61 | 61 | |
| 62 | 62 | ## Settings are not secrets, except `secret` |
| 63 | 63 | |
| @@ -80,6 +80,10 @@ A pack's app may carry `"absorb": true` (a boolean, nothing else). `pack deploy` | ||
| 80 | 80 | |
| 81 | 81 | 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. |
| 82 | 82 | |
| 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 | ||
| 83 | 87 | ## Not done yet |
| 84 | 88 | |
| 85 | 89 | Until then an absorbed app is a menu link with kept settings (Settings › Features says so). The forge does not yet fire a feature's schedule (#9883). |
src/apps/feature.ts +37 −2
| @@ -68,6 +68,39 @@ export interface FeatureSpec { | ||
| 68 | 68 | readonly settings: readonly SettingSpec[]; |
| 69 | 69 | /** MCP tools the app serves through the forge's `features` tool (`feature_tools.ts`). */ |
| 70 | 70 | readonly tools: readonly ToolSpec[]; |
| 71 | /** Tools the app asks the forge to call on a timer; empty when none. Declared only: nothing fires yet. */ | |
| 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); | |
| 71 | 104 | } |
| 72 | 105 | |
| 73 | 106 | const KEY = /^[a-z][a-zA-Z0-9]{0,39}$/; |
| @@ -117,10 +150,12 @@ export function parseFeature(forgeJson: Raw): Result<FeatureSpec | undefined, st | ||
| 117 | 150 | const envs = new Set(settings.map((s) => settingEnvName(s.key))); |
| 118 | 151 | if (envs.size !== settings.length) return bad("two settings would share one FORGE_SETTING_ name"); |
| 119 | 152 | 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}`); | |
| 121 | 154 | const tools = parseTools(block["tools"]); |
| 122 | 155 | 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 }); | |
| 124 | 159 | } |
| 125 | 160 | |
| 126 | 161 | 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")); |