From aa76cf6bca8be0a84a0a9be578d633f81fb13bdc Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Jul 2026 13:13:21 +0000 Subject: [PATCH 1/2] feat(cron): support configurable run timeouts --- apps/api/cron-timeout.test.ts | 51 +++++++++++++++ apps/api/cron.ts | 10 ++- apps/api/db-migrations.test.ts | 14 +++- apps/api/db-migrations.ts | 11 ++++ apps/api/db.ts | 1 + apps/api/routes/crons.test.ts | 82 +++++++++++++++++++++++- apps/api/routes/crons.ts | 54 ++++++++++++++-- apps/web/src/components/CronJobs.tsx | 63 +++++++++++++++--- apps/web/src/styles.css | 16 +++++ packages/contract/src/types.ts | 2 + packages/contract/src/validators.test.ts | 26 +++++++- packages/contract/src/validators.ts | 16 +++++ packages/mcp/README.md | 4 +- packages/mcp/src/tools/env.ts | 21 +++++- packages/mcp/src/tools/tools.test.ts | 58 +++++++++++++++++ 15 files changed, 401 insertions(+), 28 deletions(-) create mode 100644 apps/api/cron-timeout.test.ts diff --git a/apps/api/cron-timeout.test.ts b/apps/api/cron-timeout.test.ts new file mode 100644 index 0000000..61e9b00 --- /dev/null +++ b/apps/api/cron-timeout.test.ts @@ -0,0 +1,51 @@ +process.env.MOOR_DB_PATH = ":memory:"; + +import { beforeEach, describe, expect, test } from "bun:test"; + +let observedTimeout: number | undefined; + +const { default: db } = await import("./db"); +const { runCron } = await import("./cron"); + +describe("cron timeout execution", () => { + beforeEach(() => { + observedTimeout = undefined; + db.query("DELETE FROM runs").run(); + db.query("DELETE FROM crons").run(); + db.query("DELETE FROM projects").run(); + }); + + test("passes the cron's configured timeout to the container exec", async () => { + const project = db + .query("INSERT INTO projects (name) VALUES ('pipeline') RETURNING id") + .get() as { id: number }; + const cron = db + .query( + `INSERT INTO crons (project_id, name, schedule, command, timeout_ms) + VALUES (?, 'refresh', '30 7 * * *', 'run-pipeline', 86400000) + RETURNING *`, + ) + .get(project.id) as { + id: number; + project_id: number; + name: string; + schedule: string; + command: string; + timeout_ms: number; + enabled: number; + }; + + await runCron(cron, "container-id", async (_containerId, _command, opts) => { + observedTimeout = opts?.timeout_ms; + opts?.onExecId?.("exec-1"); + return { exitCode: 0, stdout: "ok", stderr: "" }; + }); + + expect(observedTimeout).toBe(86_400_000); + const run = db.query("SELECT exit_code, stdout FROM runs WHERE cron_id = ?").get(cron.id) as { + exit_code: number; + stdout: string; + }; + expect(run).toEqual({ exit_code: 0, stdout: "ok" }); + }); +}); diff --git a/apps/api/cron.ts b/apps/api/cron.ts index d67082e..6343a7f 100644 --- a/apps/api/cron.ts +++ b/apps/api/cron.ts @@ -15,6 +15,7 @@ type CronRow = { name: string; schedule: string; command: string; + timeout_ms: number; enabled: number; }; @@ -162,7 +163,11 @@ export async function tickInner() { } } -export async function runCron(cron: CronRow, containerId: string) { +export async function runCron( + cron: CronRow, + containerId: string, + execute: typeof execInContainer = execInContainer, +) { // #73: set started_at_ms and finished_at_ms so moor_runs' ms-precision // ordering (COALESCE(started_at_ms,0) DESC, id DESC) sorts cron runs // alongside build runs correctly, and so duration_ms is precise. @@ -190,8 +195,9 @@ export async function runCron(cron: CronRow, containerId: string) { }; try { - const result = await execInContainer(containerId, cron.command, { + const result = await execute(containerId, cron.command, { signal: controller.signal, + timeout_ms: cron.timeout_ms, onExecId: (id) => { entry.execId = id; }, diff --git a/apps/api/db-migrations.test.ts b/apps/api/db-migrations.test.ts index dfae33b..026119b 100644 --- a/apps/api/db-migrations.test.ts +++ b/apps/api/db-migrations.test.ts @@ -72,7 +72,7 @@ const BASELINE_SCHEMA_SQL = ` ); `; -type MigrationTable = "projects" | "runs" | "exec_runs"; +type MigrationTable = "projects" | "crons" | "runs" | "exec_runs"; function withBaselineDatabase(run: (db: Database) => void): void { const db = new Database(":memory:"); @@ -130,6 +130,7 @@ function addLegacyMigrationColumns(db: Database): void { ALTER TABLE projects ADD COLUMN source_credential_id INTEGER REFERENCES source_credentials(id); ALTER TABLE projects ADD COLUMN command TEXT; ALTER TABLE projects ADD COLUMN entrypoint TEXT; + ALTER TABLE crons ADD COLUMN timeout_ms INTEGER NOT NULL DEFAULT 600000; `); } @@ -162,7 +163,18 @@ describe("schema migrations", () => { "stdout_total_bytes", "stderr_total_bytes", ]); + expectColumns(db, "crons", ["timeout_ms"]); expectColumns(db, "exec_runs", ["started_at_ms", "finished_at_ms"]); + + const project = db + .query("INSERT INTO projects (name) VALUES ('cron-default') RETURNING id") + .get() as { id: number }; + const cron = db + .query( + "INSERT INTO crons (project_id, name, schedule, command) VALUES (?, 'c', '* * * * *', 'echo') RETURNING timeout_ms", + ) + .get(project.id) as { timeout_ms: number }; + expect(cron.timeout_ms).toBe(600_000); }); }); diff --git a/apps/api/db-migrations.ts b/apps/api/db-migrations.ts index 680e409..ae53abd 100644 --- a/apps/api/db-migrations.ts +++ b/apps/api/db-migrations.ts @@ -298,6 +298,17 @@ export const schemaMigrations: readonly Migration[] = [ ); }, }, + { + version: 20, + up(db) { + addColumnIfMissing( + db, + "crons", + "timeout_ms", + "ALTER TABLE crons ADD COLUMN timeout_ms INTEGER NOT NULL DEFAULT 600000", + ); + }, + }, ]; export const finalSchemaVersion = schemaMigrations[schemaMigrations.length - 1]?.version ?? 0; diff --git a/apps/api/db.ts b/apps/api/db.ts index 014ec2f..e4f9cbd 100644 --- a/apps/api/db.ts +++ b/apps/api/db.ts @@ -30,6 +30,7 @@ db.exec(` name TEXT NOT NULL, schedule TEXT NOT NULL, command TEXT NOT NULL, + timeout_ms INTEGER NOT NULL DEFAULT 600000, enabled INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')) ); diff --git a/apps/api/routes/crons.test.ts b/apps/api/routes/crons.test.ts index e6e533d..c274d33 100644 --- a/apps/api/routes/crons.test.ts +++ b/apps/api/routes/crons.test.ts @@ -14,8 +14,12 @@ async function errorMessage(res: Response): Promise { return ((await res.json()) as { error: string }).error; } -async function call(method: string, path: string): Promise { - const req = new Request(`http://localhost${path}`, { method }); +async function call(method: string, path: string, body?: unknown): Promise { + const req = new Request(`http://localhost${path}`, { + method, + headers: body === undefined ? undefined : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); const res = await handleCrons(req, new URL(req.url)); if (!res) throw new Error(`handleCrons returned null for ${method} ${path}`); return res; @@ -51,3 +55,77 @@ describe("#73 POST /api/crons/:id/run live-check wiring", () => { expect(runs.n).toBe(0); }); }); + +describe("cron timeout configuration", () => { + beforeEach(() => { + db.query("DELETE FROM runs").run(); + db.query("DELETE FROM crons").run(); + db.query("DELETE FROM projects").run(); + }); + + test("create defaults to 10 minutes and accepts a multi-hour timeout", async () => { + const project = db + .query("INSERT INTO projects (name) VALUES ('timeouts') RETURNING id") + .get() as { id: number }; + + const defaultRes = await call("POST", `/api/projects/${project.id}/crons`, { + name: "default", + schedule: "0 3 * * *", + command: "echo default", + }); + expect(defaultRes.status).toBe(201); + expect((await defaultRes.json()) as { timeout_ms: number }).toMatchObject({ + timeout_ms: 600_000, + }); + + const longRes = await call("POST", `/api/projects/${project.id}/crons`, { + name: "pipeline", + schedule: "30 7 * * *", + command: "run-pipeline", + timeout_ms: 6 * 60 * 60 * 1000, + }); + expect(longRes.status).toBe(201); + expect((await longRes.json()) as { timeout_ms: number }).toMatchObject({ + timeout_ms: 21_600_000, + }); + }); + + test("create and update reject invalid timeouts", async () => { + const project = db + .query("INSERT INTO projects (name) VALUES ('invalid-timeout') RETURNING id") + .get() as { id: number }; + + const createRes = await call("POST", `/api/projects/${project.id}/crons`, { + name: "bad", + schedule: "0 3 * * *", + command: "echo bad", + timeout_ms: 999, + }); + expect(createRes.status).toBe(400); + expect(await errorMessage(createRes)).toContain("timeout_ms must be an integer between"); + + const cron = db + .query( + "INSERT INTO crons (project_id, name, schedule, command) VALUES (?, 'c', '* * * * *', 'echo') RETURNING id", + ) + .get(project.id) as { id: number }; + const updateRes = await call("PUT", `/api/crons/${cron.id}`, { timeout_ms: 604_800_001 }); + expect(updateRes.status).toBe(400); + expect(await errorMessage(updateRes)).toContain("timeout_ms must be an integer between"); + }); + + test("update persists the timeout", async () => { + const project = db + .query("INSERT INTO projects (name) VALUES ('update-timeout') RETURNING id") + .get() as { id: number }; + const cron = db + .query( + "INSERT INTO crons (project_id, name, schedule, command) VALUES (?, 'c', '* * * * *', 'echo') RETURNING id", + ) + .get(project.id) as { id: number }; + + const res = await call("PUT", `/api/crons/${cron.id}`, { timeout_ms: 10_800_000 }); + expect(res.status).toBe(200); + expect((await res.json()) as { timeout_ms: number }).toMatchObject({ timeout_ms: 10_800_000 }); + }); +}); diff --git a/apps/api/routes/crons.ts b/apps/api/routes/crons.ts index 161bd58..4517034 100644 --- a/apps/api/routes/crons.ts +++ b/apps/api/routes/crons.ts @@ -1,3 +1,9 @@ +import { + CRON_TIMEOUT_DEFAULT_MS, + isJsonObject, + validateCronSchedule, + validateCronTimeoutMs, +} from "../../../packages/contract/src/index"; import { runCron } from "../cron"; import db from "../db"; import { requireNotDraining } from "../drain"; @@ -50,6 +56,7 @@ export async function handleCrons(req: Request, url: URL): Promise { - const { name, schedule, command } = await req.json(); - if (!name || !schedule || !command) { + const body: unknown = await req.json(); + if (!isJsonObject(body)) return errorResponse("Request body must be an object", 400); + + const { name, schedule, command } = body; + if ( + typeof name !== "string" || + typeof schedule !== "string" || + typeof command !== "string" || + !name.trim() || + !schedule.trim() || + !command.trim() + ) { return errorResponse("name, schedule, and command are required", 400); } + const scheduleError = validateCronSchedule(schedule); + if (scheduleError) return errorResponse(`Invalid schedule: ${scheduleError}`, 400); + + const requestedTimeout = body.timeout_ms ?? CRON_TIMEOUT_DEFAULT_MS; + const timeoutError = validateCronTimeoutMs(requestedTimeout); + if (timeoutError) return errorResponse(timeoutError, 400); + const timeoutMs = requestedTimeout as number; const row = db .query( - "INSERT INTO crons (project_id, name, schedule, command) VALUES (?, ?, ?, ?) RETURNING *", + "INSERT INTO crons (project_id, name, schedule, command, timeout_ms) VALUES (?, ?, ?, ?, ?) RETURNING *", ) - .get(projectId, name, schedule, command); + .get(projectId, name, schedule, command, timeoutMs); return Response.json(row, { status: 201 }); } async function handleUpdate(req: Request, id: number): Promise { - const body = await req.json(); + const body: unknown = await req.json(); + if (!isJsonObject(body)) return errorResponse("Request body must be an object", 400); + + if ("schedule" in body) { + if (typeof body.schedule !== "string") return errorResponse("schedule must be a string", 400); + const scheduleError = validateCronSchedule(body.schedule); + if (scheduleError) return errorResponse(`Invalid schedule: ${scheduleError}`, 400); + } + if ("timeout_ms" in body) { + const timeoutError = validateCronTimeoutMs(body.timeout_ms); + if (timeoutError) return errorResponse(timeoutError, 400); + } + const fields: string[] = []; const values: (string | number)[] = []; - for (const key of ["name", "schedule", "command", "enabled"]) { + for (const key of ["name", "schedule", "command", "timeout_ms", "enabled"]) { if (key in body) { fields.push(`${key} = ?`); - values.push(body[key]); + const value = body[key]; + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") { + return errorResponse(`${key} has an invalid value`, 400); + } + values.push(typeof value === "boolean" ? Number(value) : value); } } diff --git a/apps/web/src/components/CronJobs.tsx b/apps/web/src/components/CronJobs.tsx index 61e4fe0..d012b76 100644 --- a/apps/web/src/components/CronJobs.tsx +++ b/apps/web/src/components/CronJobs.tsx @@ -1,3 +1,4 @@ +import { CRON_TIMEOUT_DEFAULT_MS, CRON_TIMEOUT_MAX_MS } from "@moor-sh/contract"; import { useCallback, useEffect, useState } from "react"; import { api, type Cron, type Run } from "../lib/api"; @@ -15,9 +16,9 @@ function describeCron(schedule: string): string | null { if (minStep && hour === "*" && dom === "*" && mon === "*" && dow === "*") return `Every ${minStep[1]} minutes`; - if (min === "0" && hour.match(/^\*\/(\d+)$/) && dom === "*" && mon === "*" && dow === "*") { - const h = hour.match(/^\*\/(\d+)$/)![1]; - return `Every ${h} hours`; + const hourStep = hour.match(/^\*\/(\d+)$/); + if (min === "0" && hourStep && dom === "*" && mon === "*" && dow === "*") { + return `Every ${hourStep[1]} hours`; } if (min.match(/^\d+$/) && hour === "*" && dom === "*" && mon === "*" && dow === "*") @@ -71,6 +72,13 @@ function formatDuration(ms: number): string { return `${mins}m ${remSecs}s`; } +function formatTimeout(ms: number): string { + const minutes = ms / 60_000; + if (minutes % 1440 === 0) return `${minutes / 1440}d timeout`; + if (minutes % 60 === 0) return `${minutes / 60}h timeout`; + return `${minutes}m timeout`; +} + // --- Schedule Builder --- type Frequency = "minutes" | "hourly" | "daily" | "weekly" | "monthly"; @@ -254,21 +262,24 @@ export function CronJobs({ projectId }: Props) { }, [load]); const handleSave = async () => { - if (!editing?.name?.trim() || !editing?.schedule?.trim() || !editing?.command?.trim()) return; - if (!isValidCron(editing.schedule!)) return; + const schedule = editing?.schedule?.trim(); + if (!editing?.name?.trim() || !schedule || !editing.command?.trim()) return; + if (!isValidCron(schedule)) return; setSaving(true); try { if (editing.id) { await api.crons.update(editing.id, { name: editing.name, - schedule: editing.schedule, + schedule, command: editing.command, + timeout_ms: editing.timeout_ms ?? CRON_TIMEOUT_DEFAULT_MS, }); } else { await api.crons.create(projectId, { name: editing.name, - schedule: editing.schedule, + schedule, command: editing.command, + timeout_ms: editing.timeout_ms ?? CRON_TIMEOUT_DEFAULT_MS, }); } setEditing(null); @@ -315,7 +326,12 @@ export function CronJobs({ projectId }: Props) { if (!loading && initialLoad) { setInitialLoad(false); if (crons.length === 0) { - setEditing({ name: "", schedule: "", command: "" }); + setEditing({ + name: "", + schedule: "", + command: "", + timeout_ms: CRON_TIMEOUT_DEFAULT_MS, + }); } } }, [loading, initialLoad, crons.length]); @@ -325,6 +341,11 @@ export function CronJobs({ projectId }: Props) { const schedule = editing?.schedule?.trim() || ""; const valid = schedule ? isValidCron(schedule) : false; const description = schedule ? describeCron(schedule) : null; + const timeoutMinutes = (editing?.timeout_ms ?? CRON_TIMEOUT_DEFAULT_MS) / 60_000; + const timeoutValid = + Number.isInteger(timeoutMinutes) && + timeoutMinutes >= 1 && + timeoutMinutes <= CRON_TIMEOUT_MAX_MS / 60_000; if (editing) { return ( @@ -357,6 +378,18 @@ export function CronJobs({ projectId }: Props) { spellCheck={false} className={`cron-input-schedule ${schedule && !valid ? "invalid" : ""}`} /> + + setEditing({ ...editing, timeout_ms: Number(e.target.value) * 60_000 }) + } + className={`cron-input-timeout ${timeoutValid ? "" : "invalid"}`} + title="Timeout in minutes" + /> + min {schedule && } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 8ababc9..e429470 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1047,6 +1047,13 @@ code, white-space: nowrap; } +.cron-row-timeout { + width: 86px; + color: var(--text-dim); + font-size: 11px; + white-space: nowrap; +} + /* Cron form */ .cron-form-row { display: flex; @@ -1085,6 +1092,15 @@ code, width: 120px; } +.cron-input-timeout { + width: 82px; +} + +.cron-timeout-unit { + color: var(--text-dim); + font-size: 11px; +} + .cron-builder-toggle { flex-shrink: 0; font-size: 11px; diff --git a/packages/contract/src/types.ts b/packages/contract/src/types.ts index 5482efd..311b628 100644 --- a/packages/contract/src/types.ts +++ b/packages/contract/src/types.ts @@ -62,6 +62,7 @@ export type Cron = { name: string; schedule: string; command: string; + timeout_ms: number; enabled: number; created_at: IsoDateString; }; @@ -70,6 +71,7 @@ export type CreateCronRequest = { name: string; schedule: string; command: string; + timeout_ms?: number; }; export type UpdateCronRequest = Partial & { diff --git a/packages/contract/src/validators.test.ts b/packages/contract/src/validators.test.ts index 779ebd1..85250b6 100644 --- a/packages/contract/src/validators.test.ts +++ b/packages/contract/src/validators.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { validateCronSchedule, validateGithubRepoUrl, validateGithubUrl } from "./validators"; +import { + CRON_TIMEOUT_DEFAULT_MS, + CRON_TIMEOUT_MAX_MS, + validateCronSchedule, + validateCronTimeoutMs, + validateGithubRepoUrl, + validateGithubUrl, +} from "./validators"; describe("GitHub URL validators", () => { test("validateGithubUrl accepts exactly github.com and www.github.com over https", () => { @@ -66,3 +73,20 @@ describe("validateCronSchedule", () => { ); }); }); + +describe("validateCronTimeoutMs", () => { + test("accepts the default and multi-day cron timeouts", () => { + expect(validateCronTimeoutMs(CRON_TIMEOUT_DEFAULT_MS)).toBeNull(); + expect(validateCronTimeoutMs(3 * 60 * 60 * 1000)).toBeNull(); + expect(validateCronTimeoutMs(CRON_TIMEOUT_MAX_MS)).toBeNull(); + }); + + test("rejects non-integers and values outside the supported range", () => { + expect(validateCronTimeoutMs(999)).toContain("timeout_ms must be an integer between"); + expect(validateCronTimeoutMs(CRON_TIMEOUT_MAX_MS + 1)).toContain( + "timeout_ms must be an integer between", + ); + expect(validateCronTimeoutMs(60_000.5)).toContain("timeout_ms must be an integer between"); + expect(validateCronTimeoutMs("60000")).toContain("timeout_ms must be an integer between"); + }); +}); diff --git a/packages/contract/src/validators.ts b/packages/contract/src/validators.ts index 8a5f1ad..cde8513 100644 --- a/packages/contract/src/validators.ts +++ b/packages/contract/src/validators.ts @@ -60,6 +60,22 @@ const CRON_PART_PATTERNS = [ /^(\d+)-(\d+)\/(\d+)$/, ]; +export const CRON_TIMEOUT_DEFAULT_MS = 600_000; +export const CRON_TIMEOUT_MIN_MS = 60_000; +export const CRON_TIMEOUT_MAX_MS = 604_800_000; + +export function validateCronTimeoutMs(value: unknown): string | null { + if ( + typeof value !== "number" || + !Number.isInteger(value) || + value < CRON_TIMEOUT_MIN_MS || + value > CRON_TIMEOUT_MAX_MS + ) { + return `timeout_ms must be an integer between ${CRON_TIMEOUT_MIN_MS} and ${CRON_TIMEOUT_MAX_MS}`; + } + return null; +} + export function validateCronSchedule(schedule: string): string | null { const parts = schedule.trim().split(/\s+/); if (parts.length !== 5) { diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 1fccec9..851cf13 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -119,8 +119,8 @@ The server registers 53 tools. Regenerate this section with `bun run docs` after | --- | --- | --- | | `moor_env_list` | List Environment Variables | List all environment variables set for a project. | | `moor_env_set` | Set Environment Variables | Set environment variables for a project. Merges with existing vars. Automatically restarts the container if running. | -| `moor_cron_create` | Create Cron | Creates a cron schedule on a project. Schedule is a 5-field crontab string with numeric values only (no jan/sun/etc.). Day-of-week uses 0=Sunday through 6=Saturday; 7 is not accepted. | -| `moor_cron_update` | Update Cron | Updates a cron's fields by id. Schedule is validated if provided. | +| `moor_cron_create` | Create Cron | Creates a cron schedule on a project. Schedule is a 5-field crontab string with numeric values only (no jan/sun/etc.). Day-of-week uses 0=Sunday through 6=Saturday; 7 is not accepted. `timeout_ms` defaults to 10 minutes and supports up to 7 days. | +| `moor_cron_update` | Update Cron | Updates a cron's fields by id, including `timeout_ms`. Schedule and timeout are validated if provided. | | `moor_cron_delete` | Delete Cron | Deletes a cron by id. | | `moor_cron_run` | Run Cron Now | Triggers a cron to run immediately. Requires the project's container to be running. | | `moor_env_delete` | Delete Environment Variables | Removes one or more environment variables from a project. Restarts the container only if at least one key was actually deleted AND the project was running. | diff --git a/packages/mcp/src/tools/env.ts b/packages/mcp/src/tools/env.ts index aa4db57..5d771bc 100644 --- a/packages/mcp/src/tools/env.ts +++ b/packages/mcp/src/tools/env.ts @@ -1,6 +1,11 @@ import type { McpServer } from "@modelcontextprotocol/server"; import { z } from "zod"; -import { isJsonObject, validateCronSchedule } from "../../../contract/src/index"; +import { + CRON_TIMEOUT_MAX_MS, + CRON_TIMEOUT_MIN_MS, + isJsonObject, + validateCronSchedule, +} from "../../../contract/src/index"; import type { ToolContext } from "./context"; export function registerEnvTools(server: McpServer, client: ToolContext): void { const { apiResponse, resolveProject, readErrorMessage } = client; @@ -82,9 +87,16 @@ export function registerEnvTools(server: McpServer, client: ToolContext): void { name: z.string().min(1).describe("Human-readable name for the cron"), schedule: z.string().describe('5-field crontab, e.g. "0 3 * * *" for 03:00 daily'), command: z.string().min(1).describe("Shell command to run inside the project's container"), + timeout_ms: z + .number() + .int() + .min(CRON_TIMEOUT_MIN_MS) + .max(CRON_TIMEOUT_MAX_MS) + .optional() + .describe("Maximum run time in milliseconds. Defaults to 10 minutes; maximum is 7 days."), }), }, - async ({ project, name, schedule, command }) => { + async ({ project, name, schedule, command, timeout_ms }) => { const err = validateCronSchedule(schedule); if (err) throw new Error(`Invalid schedule: ${err}`); const p = await resolveProject(project); @@ -92,6 +104,7 @@ export function registerEnvTools(server: McpServer, client: ToolContext): void { name, schedule, command, + ...(timeout_ms === undefined ? {} : { timeout_ms }), }); if (!res.ok) throw new Error(`Failed to create cron: ${await readErrorMessage(res)}`); const cron = await res.json(); @@ -109,10 +122,11 @@ export function registerEnvTools(server: McpServer, client: ToolContext): void { name: z.string().min(1).optional(), schedule: z.string().optional(), command: z.string().min(1).optional(), + timeout_ms: z.number().int().min(CRON_TIMEOUT_MIN_MS).max(CRON_TIMEOUT_MAX_MS).optional(), enabled: z.boolean().optional().describe("Enable or disable the cron"), }), }, - async ({ cron_id, name, schedule, command, enabled }) => { + async ({ cron_id, name, schedule, command, timeout_ms, enabled }) => { if (schedule !== undefined) { const err = validateCronSchedule(schedule); if (err) throw new Error(`Invalid schedule: ${err}`); @@ -121,6 +135,7 @@ export function registerEnvTools(server: McpServer, client: ToolContext): void { if (name !== undefined) body.name = name; if (schedule !== undefined) body.schedule = schedule; if (command !== undefined) body.command = command; + if (timeout_ms !== undefined) body.timeout_ms = timeout_ms; if (enabled !== undefined) body.enabled = enabled ? 1 : 0; if (Object.keys(body).length === 0) { throw new Error("Provide at least one field to update"); diff --git a/packages/mcp/src/tools/tools.test.ts b/packages/mcp/src/tools/tools.test.ts index bd147b6..3230851 100644 --- a/packages/mcp/src/tools/tools.test.ts +++ b/packages/mcp/src/tools/tools.test.ts @@ -614,6 +614,39 @@ describe("env, cron, volume, and file tools", () => { expect(api.calls).toHaveLength(0); }); + test("cron create forwards a multi-hour timeout", async () => { + const { api, server } = createHarness(registerEnvTools); + api.on("POST", "/api/projects/7/crons", () => + json({ + id: 4, + enabled: 1, + name: "pipeline", + schedule: "30 7 * * *", + command: "run-pipeline", + timeout_ms: 21_600_000, + }), + ); + + await server.call("moor_cron_create", { + project: "app", + name: "pipeline", + schedule: "30 7 * * *", + command: "run-pipeline", + timeout_ms: 21_600_000, + }); + + expect(api.calls[0]).toEqual({ + method: "POST", + path: "/api/projects/7/crons", + body: { + name: "pipeline", + schedule: "30 7 * * *", + command: "run-pipeline", + timeout_ms: 21_600_000, + }, + }); + }); + test("cron update shapes enabled into the API's numeric flag", async () => { const { api, server } = createHarness(registerEnvTools); api.on("PUT", "/api/crons/3", () => @@ -639,6 +672,31 @@ describe("env, cron, volume, and file tools", () => { expect(toolText(result)).toContain('"enabled": 1'); }); + test("cron update forwards timeout_ms", async () => { + const { api, server } = createHarness(registerEnvTools); + api.on("PUT", "/api/crons/3", () => + json({ + id: 3, + enabled: 1, + name: "nightly", + schedule: "0 3 * * *", + command: "echo hi", + timeout_ms: 10_800_000, + }), + ); + + await server.call("moor_cron_update", { + cron_id: 3, + timeout_ms: 10_800_000, + }); + + expect(api.calls[0]).toEqual({ + method: "PUT", + path: "/api/crons/3", + body: { timeout_ms: 10_800_000 }, + }); + }); + test("file set requires exactly one content source via API error text", async () => { const { api, server } = createHarness(registerEnvTools); api.on("POST", "/api/projects/7/files", () => From 7f61bfea2368e4b4a9a7077ec67c9f6efbc11996 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 13 Jul 2026 13:44:20 +0000 Subject: [PATCH 2/2] fix(mcp): sync cron timeout tool descriptions --- packages/mcp/README.md | 4 ++-- packages/mcp/src/tools/env.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 851cf13..159011c 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -119,8 +119,8 @@ The server registers 53 tools. Regenerate this section with `bun run docs` after | --- | --- | --- | | `moor_env_list` | List Environment Variables | List all environment variables set for a project. | | `moor_env_set` | Set Environment Variables | Set environment variables for a project. Merges with existing vars. Automatically restarts the container if running. | -| `moor_cron_create` | Create Cron | Creates a cron schedule on a project. Schedule is a 5-field crontab string with numeric values only (no jan/sun/etc.). Day-of-week uses 0=Sunday through 6=Saturday; 7 is not accepted. `timeout_ms` defaults to 10 minutes and supports up to 7 days. | -| `moor_cron_update` | Update Cron | Updates a cron's fields by id, including `timeout_ms`. Schedule and timeout are validated if provided. | +| `moor_cron_create` | Create Cron | Creates a cron schedule on a project. Schedule is a 5-field crontab string with numeric values only (no jan/sun/etc.). Day-of-week uses 0=Sunday through 6=Saturday; 7 is not accepted. timeout_ms defaults to 10 minutes and supports up to 7 days. | +| `moor_cron_update` | Update Cron | Updates a cron's fields by id, including timeout_ms. Schedule and timeout are validated if provided. | | `moor_cron_delete` | Delete Cron | Deletes a cron by id. | | `moor_cron_run` | Run Cron Now | Triggers a cron to run immediately. Requires the project's container to be running. | | `moor_env_delete` | Delete Environment Variables | Removes one or more environment variables from a project. Restarts the container only if at least one key was actually deleted AND the project was running. | diff --git a/packages/mcp/src/tools/env.ts b/packages/mcp/src/tools/env.ts index 5d771bc..935b068 100644 --- a/packages/mcp/src/tools/env.ts +++ b/packages/mcp/src/tools/env.ts @@ -81,7 +81,7 @@ export function registerEnvTools(server: McpServer, client: ToolContext): void { { title: "Create Cron", description: - "Creates a cron schedule on a project. Schedule is a 5-field crontab string with numeric values only (no jan/sun/etc.). Day-of-week uses 0=Sunday through 6=Saturday; 7 is not accepted.", + "Creates a cron schedule on a project. Schedule is a 5-field crontab string with numeric values only (no jan/sun/etc.). Day-of-week uses 0=Sunday through 6=Saturday; 7 is not accepted. timeout_ms defaults to 10 minutes and supports up to 7 days.", inputSchema: z.object({ project: z.string().describe("Project name or ID"), name: z.string().min(1).describe("Human-readable name for the cron"), @@ -116,7 +116,8 @@ export function registerEnvTools(server: McpServer, client: ToolContext): void { "moor_cron_update", { title: "Update Cron", - description: "Updates a cron's fields by id. Schedule is validated if provided.", + description: + "Updates a cron's fields by id, including timeout_ms. Schedule and timeout are validated if provided.", inputSchema: z.object({ cron_id: z.number().int().positive().describe("Cron ID"), name: z.string().min(1).optional(),