/** * `dench cron ` — CLI surface for managing Convex-native * cron triggers. * * Each cron is a row in the Convex `triggers` table that, when due, * spawns a fresh chat thread (the `target: "chat_turn"` path — new * default) running in YOLO mode and seeded with the cron's prompt as * the first user message. Scheduling is owned by the * `@convex-dev/crons` component: each create/update/enable mutation * registers a component cron under name=triggerId; the component fires * `internal.functions.triggersNode.fireDenchTrigger` at the precise * scheduled time, which then POSTs to `/api/runs/spawn-{chat,child}` * to start the workflow. * * Auth: prefers the unified Dench API key from `DENCH_API_KEY` * (sandbox envVar baked at create time). Falls back to `dench login` * session token. The server-side `requireCronAccess` helper accepts * both formats; we just pass whichever bearer the parent runtime * resolved through `sessionToken`. * * Convex calls generally go direct via `ConvexHttpClient` (same pattern as * `cli/crm.ts`). Run-now and delete go through REST: run-now starts a Vercel * Workflow, while delete coordinates upstream app-event cleanup. * * Subcommands: * list List all cron jobs in the org. * get Single job detail. * create --prompt … --every … Create a new cron. * update [flags] Patch any subset of fields. * delete [--force] Permanently delete. * enable Re-arm a paused cron. * disable Soft-pause without delete. * run [--wait] Manual fire; --wait blocks. * secret Mint/rotate a signing secret (any type). * events List a connected app's event types. * runs [--limit N] [--status] Run history per trigger. * history [--limit N] Flat history across all crons. * help Print this help. * * Trigger types accepted by `create --type`: * schedule (default) | webhook | composio (app event) | crm (CRM event). * composio/crm POST to /api/cron/jobs; the others use the Convex mutation. * * Schedule flags accepted by `create` and `update`: * --every 30s | 5m | 2h | 1d | 12h… * --every-ms Same, raw milliseconds. * --cron 5-field POSIX cron, e.g. "0 9 * * 1-5". * --at ISO 8601 timestamp (one-shot; auto-disable). */ import type { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; import { CliArgError, getFlag, hasFlag, shift as shiftRaw, } from "./lib/cli-args"; class CronCliError extends Error {} type CronCliContext = { convex: ConvexHttpClient; args: string[]; jsonOutput: boolean; sessionToken?: string; }; function shift(args: string[], expected: string): string { try { return shiftRaw(args, expected); } catch (error) { if (error instanceof CliArgError) throw new CronCliError(error.message); throw error; } } function out(ctx: CronCliContext, value: unknown): void { if (ctx.jsonOutput) { console.log(JSON.stringify(value, null, 2)); return; } if (value === null || value === undefined) { console.log("(empty)"); return; } console.log(JSON.stringify(value, null, 2)); } // ─── Auth helpers (mirror cli/agent.ts shape) ───────────────────────────── function getApiBase(): string { const base = ( process.env.DENCH_API_URL ?? process.env.DENCH_BASE_URL ?? "http://localhost:3000" ).replace(/\/+$/, ""); if (!base) { throw new CronCliError( "DENCH_API_URL is not configured. Pass it as an env var or run from a sandbox where it is baked in.", ); } return base; } function apiKeyHeaders(token: string): Record { if (!token) { throw new CronCliError( "Missing auth token. Provide DENCH_API_KEY in env or run `dench login` first.", ); } return { "content-type": "application/json", authorization: `Bearer ${token}`, }; } async function postJson( url: string, headers: Record, body: unknown, ): Promise { const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body ?? {}), }); const text = await response.text(); let json: unknown; try { json = text ? JSON.parse(text) : null; } catch { json = { raw: text }; } if (!response.ok) { const detail = json && typeof json === "object" && "error" in json ? String((json as Record).error) : JSON.stringify(json); throw new CronCliError(`${url} failed (${response.status}): ${detail}`); } return json; } async function getJson( url: string, headers: Record, ): Promise { const response = await fetch(url, { method: "GET", headers }); const text = await response.text(); let json: unknown; try { json = text ? JSON.parse(text) : null; } catch { json = { raw: text }; } if (!response.ok) { const detail = json && typeof json === "object" && "error" in json ? String((json as Record).error) : JSON.stringify(json); throw new CronCliError(`${url} failed (${response.status}): ${detail}`); } return json; } async function deleteJson( url: string, headers: Record, ): Promise { const response = await fetch(url, { method: "DELETE", headers }); const text = await response.text(); let json: unknown; try { json = text ? JSON.parse(text) : null; } catch { json = { raw: text }; } if (!response.ok) { const detail = json && typeof json === "object" && "error" in json ? String((json as Record).error) : JSON.stringify(json); throw new CronCliError(`${url} failed (${response.status}): ${detail}`); } return json; } async function callQuery( ctx: CronCliContext, fn: Parameters[0], args: Record = {}, ): Promise { return ctx.convex.query(fn, { ...args, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never); } async function callMutation( ctx: CronCliContext, fn: Parameters[0], args: Record = {}, ): Promise { return ctx.convex.mutation(fn, { ...args, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never); } // ─── Schedule flag parser ──────────────────────────────────────────────── const DURATION_RE = /^(\d+)\s*(ms|s|m|h|d)$/i; export function parseDurationFlagToMs(input: string): number | null { const m = DURATION_RE.exec(input.trim()); if (!m) return null; const n = parseInt(m[1], 10); if (!Number.isFinite(n) || n <= 0) return null; switch (m[2].toLowerCase()) { case "ms": return n; case "s": return n * 1000; case "m": return n * 60_000; case "h": return n * 3_600_000; case "d": return n * 86_400_000; } return null; } export type CronScheduleSpec = | { kind: "every"; everyMs: number; anchorMs?: number } | { kind: "cron"; expr: string; tz?: string } | { kind: "at"; at: string }; /** * Pull a schedule out of the args, accepting any of `--every`, * `--every-ms`, `--cron`, `--at`. Returns undefined if NONE of those * flags are present (the caller decides whether that's an error). * * `--at` implicitly drops `--every` / `--cron` if both are passed. */ export function consumeScheduleFlags( args: string[], ): CronScheduleSpec | undefined { const at = getFlag(args, "--at"); if (at) { const ts = new Date(at).getTime(); if (Number.isNaN(ts)) { throw new CronCliError( `--at must be ISO 8601 (got "${at}"). Example: --at 2026-05-04T17:00:00Z`, ); } return { kind: "at", at }; } const cronExpr = getFlag(args, "--cron"); if (cronExpr) { const tz = getFlag(args, "--tz"); return { kind: "cron", expr: cronExpr, tz }; } const everyRaw = getFlag(args, "--every"); const everyMsRaw = getFlag(args, "--every-ms"); if (everyMsRaw) { const ms = parseInt(everyMsRaw, 10); if (!Number.isFinite(ms) || ms < 1000) { throw new CronCliError( `--every-ms must be a positive integer >= 1000 (got "${everyMsRaw}")`, ); } return { kind: "every", everyMs: ms }; } if (everyRaw) { const ms = parseDurationFlagToMs(everyRaw); if (ms == null || ms < 1000) { throw new CronCliError( `--every must be a duration like "30s", "5m", "2h", "1d" (got "${everyRaw}")`, ); } return { kind: "every", everyMs: ms }; } return undefined; } function formatScheduleForList(schedule: unknown): string { if (!schedule || typeof schedule !== "object") return String(schedule ?? "?"); const s = schedule as Record; if (s.kind === "every" && typeof s.everyMs === "number") { const ms = s.everyMs; if (ms >= 86_400_000) return `every ${Math.round(ms / 86_400_000)}d`; if (ms >= 3_600_000) return `every ${Math.round(ms / 3_600_000)}h`; if (ms >= 60_000) return `every ${Math.round(ms / 60_000)}m`; return `every ${Math.round(ms / 1000)}s`; } if (s.kind === "cron" && typeof s.expr === "string") { return `cron "${s.expr}"${s.tz ? ` (${s.tz})` : ""}`; } if (s.kind === "at" && typeof s.at === "string") return `at ${s.at}`; return JSON.stringify(s); } function formatRowsAsTable(jobs: unknown[]): string { if (jobs.length === 0) return "(no cron jobs)"; const rows: Array<[string, string, string, string, string]> = jobs.map( (raw) => { const j = raw as Record; const id = String(j.id ?? "?"); const name = String(j.name ?? ""); const enabled = j.enabled === false ? "off" : "on"; const sched = formatScheduleForList( (j as { schedule?: unknown }).schedule, ); const next = typeof (j.state as Record | undefined)?.nextRunAtMs === "number" ? new Date( (j.state as Record).nextRunAtMs as number, ).toISOString() : "—"; return [id, enabled, name.slice(0, 30), sched, next]; }, ); const widths = [ Math.max(...rows.map((r) => r[0].length), 8), 3, Math.max(...rows.map((r) => r[2].length), 4), Math.max(...rows.map((r) => r[3].length), 8), Math.max(...rows.map((r) => r[4].length), 24), ]; const header = ["job id", "en", "name", "schedule", "next fire"] .map((h, i) => h.padEnd(widths[i])) .join(" "); const lines = rows.map((r) => r.map((cell, i) => cell.padEnd(widths[i])).join(" "), ); return [header, "-".repeat(header.length), ...lines].join("\n"); } // ─── Subcommands ────────────────────────────────────────────────────────── const listCronJobsRef = makeFunctionReference<"query">( "functions/triggers:listCronJobs", ); const getCronJobRef = makeFunctionReference<"query">( "functions/triggers:getCronJob", ); const listCronRunsRef = makeFunctionReference<"query">( "functions/triggers:listCronRuns", ); const listOrgCronRunHistoryRef = makeFunctionReference<"query">( "functions/triggers:listOrgCronRunHistory", ); const createCronTriggerRef = makeFunctionReference<"mutation">( "functions/triggers:createCronTrigger", ); const updateCronTriggerRef = makeFunctionReference<"mutation">( "functions/triggers:updateCronTrigger", ); const setCronEnabledRef = makeFunctionReference<"mutation">( "functions/triggers:setCronEnabled", ); async function runList(ctx: CronCliContext): Promise { const enabledOnly = hasFlag(ctx.args, "--enabled-only"); const result = (await callQuery(ctx, listCronJobsRef, { enabledOnly })) as { jobs: unknown[]; }; if (ctx.jsonOutput) { out(ctx, result); return; } console.log(formatRowsAsTable(result.jobs ?? [])); } async function runGet(ctx: CronCliContext): Promise { const jobId = shift(ctx.args, "job id"); const job = await callQuery(ctx, getCronJobRef, { jobId }); out(ctx, job); } async function runCreate(ctx: CronCliContext): Promise { const name = shift( ctx.args, 'name (positional, e.g. dench cron create "Daily standup digest" --prompt … --every 1d)', ); const prompt = getFlag(ctx.args, "--prompt"); if (!prompt || !prompt.trim()) { throw new CronCliError( "--prompt is required (becomes the user message of each cron-fired chat thread)", ); } const environment = getFlag(ctx.args, "--environment"); if (environment && !/^[a-z][a-z0-9-]{0,63}$/.test(environment)) throw new CronCliError("Invalid environment name"); const description = getFlag(ctx.args, "--description"); const overlapPolicy = getFlag(ctx.args, "--overlap") as | "skip" | "queue" | "kill_old" | undefined; if (overlapPolicy && !["skip", "queue", "kill_old"].includes(overlapPolicy)) { throw new CronCliError( `--overlap must be one of skip|queue|kill_old (got "${overlapPolicy}")`, ); } const disabled = hasFlag(ctx.args, "--disabled"); const deleteAfterRun = hasFlag(ctx.args, "--delete-after-run"); const target = getFlag(ctx.args, "--target") as "chat_turn" | undefined; if (target && target !== "chat_turn") { throw new CronCliError(`--target must be chat_turn (got "${target}")`); } const requestedType = getFlag(ctx.args, "--type") ?? "schedule"; const triggerType = (requestedType === "app" ? "composio" : requestedType) as | "schedule" | "webhook" | "composio" | "crm"; if (!["schedule", "webhook", "composio", "crm"].includes(triggerType)) { throw new CronCliError( `--type must be schedule|webhook|app|crm (got "${requestedType}").`, ); } // App-event (composio) and CRM-event automations are created through the // REST `/api/cron/jobs` POST: composio needs the upstream subscribe + // connection-ownership flow (Node runtime, project Composio key), and CRM // rides the same path for one create surface. schedule + webhook keep the // direct Convex mutation below. if (triggerType === "composio" || triggerType === "crm") { return await runCreateAppOrCrm(ctx, { triggerType, name, prompt, environment, description, overlapPolicy, disabled, deleteAfterRun, target, }); } const isWebhook = triggerType === "webhook"; let schedule: CronScheduleSpec | undefined; if (isWebhook) { // Webhook automations fire on demand / via their inbound URL — no schedule. if (consumeScheduleFlags(ctx.args)) { throw new CronCliError( "Webhook automations don't take a schedule. Omit --every/--cron/--at.", ); } } else { schedule = consumeScheduleFlags(ctx.args); if (!schedule) { throw new CronCliError( "One of --every / --cron / --at is required for schedule automations. Examples:\n" + " dench cron create \"Hourly health check\" --prompt 'Check uptime…' --every 1h\n" + ' dench cron create "Weekday digest" --prompt \'Pull yesterday…\' --cron "0 9 * * 1-5"\n' + " dench cron create \"Inbound lead\" --prompt 'Triage the lead…' --type webhook", ); } } const created = (await callMutation(ctx, createCronTriggerRef, { name, prompt, environment, triggerType, schedule, description, overlapPolicy, enabled: disabled ? false : undefined, deleteAfterRun: deleteAfterRun ? true : undefined, target, })) as { jobId?: string }; // A webhook automation's URL is live (unsigned) as soon as it exists. Only // mint a signing secret when asked (--signed). `--no-secret` was the old // opt-out and is now the default; consume it so it stays accepted. const legacyNoSecret = hasFlag(ctx.args, "--no-secret"); const signed = hasFlag(ctx.args, "--signed") && !legacyNoSecret; if (isWebhook && created.jobId) { if (signed) { const secret = await mintAutomationSecret(ctx, created.jobId); out(ctx, { ...created, ...secret }); return; } out(ctx, { ...created, webhookUrl: automationWebhookUrl(created.jobId), webhookAccess: "open", note: "Live now — plain POSTs to webhookUrl fire this automation. Keep the URL private. `dench cron secret ` turns on HMAC signing.", }); return; } out(ctx, created); } function automationWebhookUrl(jobId: string): string { return `${getApiBase()}/api/webhooks/automation/${encodeURIComponent(jobId)}`; } /** * Turn on (or rotate) HMAC signing for an automation via the Next REST route * (secret encryption runs in the Node route, not Convex's V8 runtime). Returns * the plaintext secret + URL — shown once. */ async function mintAutomationSecret( ctx: CronCliContext, jobId: string, ): Promise<{ webhookUrl: string | null; webhookSecret: string | null; webhookAccess: "signed"; }> { const url = `${getApiBase()}/api/cron/jobs/${encodeURIComponent(jobId)}/webhook-signing`; const res = (await postJson( url, apiKeyHeaders(ctx.sessionToken ?? ""), {}, )) as { webhookUrl?: string | null; webhookSecret?: string | null }; return { webhookUrl: res.webhookUrl ?? null, webhookSecret: res.webhookSecret ?? null, webhookAccess: "signed", }; } async function runSecret(ctx: CronCliContext): Promise { const jobId = shift(ctx.args, "job id"); if (hasFlag(ctx.args, "--off")) { const url = `${getApiBase()}/api/cron/jobs/${encodeURIComponent(jobId)}/webhook-signing`; const res = (await deleteJson( url, apiKeyHeaders(ctx.sessionToken ?? ""), )) as { webhookUrl?: string | null; webhookAccess?: "open" | "off" }; out(ctx, { jobId, webhookUrl: res.webhookUrl ?? null, webhookAccess: res.webhookAccess ?? null, note: res.webhookAccess === "open" ? "Signing is off — the URL now accepts plain (unsigned) POSTs." : "Signing is off — this automation's URL no longer accepts requests.", }); return; } const secret = await mintAutomationSecret(ctx, jobId); out(ctx, { jobId, ...secret, note: "Secret shown once — store it now. Re-running rotates it (invalidates the old one). `--off` turns signing off.", }); } export type AppOrCrmCreateBase = { environment?: string; triggerType: "composio" | "crm"; name: string; prompt: string; description?: string; overlapPolicy?: "skip" | "queue" | "kill_old"; disabled: boolean; deleteAfterRun: boolean; target?: "chat_turn"; }; /** * Build the `/api/cron/jobs` POST body for an app-event (composio) or * CRM-event automation from the create flags. Pure (no network) so the flag * parsing + validation + payload shape can be unit-tested directly. Consumes * the type-specific flags off `args` (composio: --connection/--event/--toolkit/ * --trigger-config; crm: --object/--event/--where/--where-logic) and throws a * `CronCliError` with discovery hints when a required input is missing. */ export function buildAppOrCrmCreateBody( args: string[], base: AppOrCrmCreateBase, ): Record { // Neither type takes a schedule — reject one early instead of silently // dropping it server-side. if (consumeScheduleFlags(args)) { throw new CronCliError( `${base.triggerType} automations don't take a schedule. Omit --every/--cron/--at.`, ); } const body: Record = { name: base.name, prompt: base.prompt, environment: base.environment, triggerType: base.triggerType, description: base.description, overlapPolicy: base.overlapPolicy, enabled: base.disabled ? false : undefined, deleteAfterRun: base.deleteAfterRun ? true : undefined, target: base.target, }; if (base.triggerType === "composio") { const connection = getFlag(args, "--connection"); const event = getFlag(args, "--event"); if (!connection?.trim() || !event?.trim()) { throw new CronCliError( "app-event automations need --connection and --event .\n" + " Discover apps: dench apps --json (use connectedAccountId + toolkit)\n" + " Discover events: dench cron events (use the event slug)\n" + "Example:\n" + ' dench cron create "New GitHub star" --prompt "Thank the new stargazer" \\\n' + " --type app --connection ca_123 --event GITHUB_STAR_ADDED_EVENT --toolkit github", ); } const toolkit = getFlag(args, "--toolkit"); const triggerConfigRaw = getFlag(args, "--trigger-config"); let triggerConfig: unknown; if (triggerConfigRaw) { try { triggerConfig = JSON.parse(triggerConfigRaw); } catch { throw new CronCliError( `--trigger-config must be valid JSON (got ${triggerConfigRaw})`, ); } } body.composioConnectionId = connection.trim(); body.composioTriggerSlug = event.trim(); if (toolkit?.trim()) body.composioToolkitSlug = toolkit.trim(); if (triggerConfig !== undefined) body.triggerConfig = triggerConfig; } else { const object = getFlag(args, "--object"); const event = getFlag(args, "--event"); if (!object?.trim() || !event?.trim()) { throw new CronCliError( "CRM-event automations need --object and --event create|update|delete.\n" + " Discover objects: dench crm objects list\n" + " Discover fields: dench crm fields list (filter on field _id via --where)\n" + "Example:\n" + ' dench cron create "New company" --prompt "Research the new company" \\\n' + " --type crm --object --event create", ); } if (!["create", "update", "delete"].includes(event)) { throw new CronCliError( `--event must be create|update|delete for CRM automations (got "${event}")`, ); } const whereRaw = getFlag(args, "--where"); const whereLogic = (getFlag(args, "--where-logic") ?? "AND") as | "AND" | "OR"; if (!["AND", "OR"].includes(whereLogic)) { throw new CronCliError( `--where-logic must be AND|OR (got "${whereLogic}")`, ); } const crmTriggerConfig: Record = { eventType: event, objectId: object.trim(), }; if (whereRaw) { let whereGroups: unknown; try { whereGroups = JSON.parse(whereRaw); } catch { throw new CronCliError( "--where must be valid JSON: an array of groups like " + '\'[{"logic":"AND","conditions":[{"fieldId":"","operator":"equals","value":"x"}]}]\'', ); } if (!Array.isArray(whereGroups)) { throw new CronCliError( "--where must be a JSON array of where-groups (each { logic, conditions }).", ); } crmTriggerConfig.whereGroups = whereGroups; crmTriggerConfig.whereLogic = whereLogic; } body.crmTriggerConfig = crmTriggerConfig; } return body; } /** * Create an app-event (composio) or CRM-event automation via the REST * `/api/cron/jobs` POST. composio creation also subscribes the trigger * instance upstream + verifies the org owns the connection (handled * server-side in the Node route); CRM creation validates the object + * filter field ids in the mutation. */ async function runCreateAppOrCrm( ctx: CronCliContext, base: AppOrCrmCreateBase, ): Promise { const body = buildAppOrCrmCreateBody(ctx.args, base); const url = `${getApiBase()}/api/cron/jobs`; const created = await postJson( url, apiKeyHeaders(ctx.sessionToken ?? ""), body, ); out(ctx, created); } /** * List the app-event types a connected app publishes, so the caller can pick * a `--event ` for `dench cron create --type composio`. Backed by * `GET /api/cron/trigger-types` (project-level Composio catalogue). */ async function runEvents(ctx: CronCliContext): Promise { const toolkit = shift( ctx.args, "toolkit slug (e.g. github — run `dench apps --json` to see each app's slug)", ); const url = `${getApiBase()}/api/cron/trigger-types?toolkit=${encodeURIComponent(toolkit)}`; const result = (await getJson( url, apiKeyHeaders(ctx.sessionToken ?? ""), )) as { items?: Array<{ slug?: string; name?: string; description?: string }>; }; if (ctx.jsonOutput) { out(ctx, result); return; } const items = result.items ?? []; if (items.length === 0) { console.log(`(no app events found for "${toolkit}")`); return; } for (const item of items) { console.log(`${item.slug ?? "?"} — ${item.name ?? ""}`); if (item.description) console.log(` ${item.description}`); } } async function runUpdate(ctx: CronCliContext): Promise { const jobId = shift(ctx.args, "job id"); const environment = getFlag(ctx.args, "--environment"); const clearEnvironment = hasFlag(ctx.args, "--clear-environment"); if ( environment && (clearEnvironment || !/^[a-z][a-z0-9-]{0,63}$/.test(environment)) ) throw new CronCliError( "Choose a valid --environment name or --clear-environment", ); const name = getFlag(ctx.args, "--name"); const prompt = getFlag(ctx.args, "--prompt"); const description = getFlag(ctx.args, "--description"); const overlapPolicy = getFlag(ctx.args, "--overlap") as | "skip" | "queue" | "kill_old" | undefined; const enabledRaw = getFlag(ctx.args, "--enabled"); let enabled: boolean | undefined; if (enabledRaw !== undefined) { if (enabledRaw === "true") enabled = true; else if (enabledRaw === "false") enabled = false; else { throw new CronCliError( `--enabled must be true|false (got "${enabledRaw}")`, ); } } const schedule = consumeScheduleFlags(ctx.args); const result = await callMutation(ctx, updateCronTriggerRef, { jobId, environment: clearEnvironment ? null : environment, name, prompt, description, overlapPolicy, enabled, schedule, }); out(ctx, result); } async function runDelete(ctx: CronCliContext): Promise { const jobId = shift(ctx.args, "job id"); const result = await deleteJson( `${getApiBase()}/api/cron/jobs/${encodeURIComponent(jobId)}`, apiKeyHeaders(ctx.sessionToken ?? ""), ); out(ctx, result); } async function runEnable(ctx: CronCliContext, enabled: boolean): Promise { const jobId = shift(ctx.args, "job id"); const result = await callMutation(ctx, setCronEnabledRef, { jobId, enabled, }); out(ctx, result); } async function runRunNow(ctx: CronCliContext): Promise { const jobId = shift(ctx.args, "job id"); const wait = hasFlag(ctx.args, "--wait"); const url = `${getApiBase()}/api/cron/jobs/${encodeURIComponent(jobId)}/run-now`; const fired = (await postJson( url, apiKeyHeaders(ctx.sessionToken ?? ""), {}, )) as { runId?: string; threadId?: string | null }; if (!wait) { out(ctx, fired); return; } // Poll the run status until terminal, with a 5-minute safety timeout. if (!fired.runId) { out(ctx, fired); return; } const start = Date.now(); const TIMEOUT_MS = 5 * 60_000; while (Date.now() - start < TIMEOUT_MS) { const runs = (await callQuery(ctx, listCronRunsRef, { jobId, limit: 5, })) as { entries: Array<{ ts: number; status?: string }> }; const matching = runs.entries.find(() => true); if (matching && matching.status) { out(ctx, { ...fired, finalStatus: matching.status, durationMs: Date.now() - start, }); return; } await new Promise((resolve) => setTimeout(resolve, 3000)); } out(ctx, { ...fired, status: "wait_timeout", waitedMs: TIMEOUT_MS }); } async function runRuns(ctx: CronCliContext): Promise { const jobId = shift(ctx.args, "job id"); const limitRaw = getFlag(ctx.args, "--limit"); const limit = limitRaw ? parseInt(limitRaw, 10) : 50; const status = getFlag(ctx.args, "--status"); const result = (await callQuery(ctx, listCronRunsRef, { jobId, limit })) as { entries: Array>; }; const filtered = status ? { entries: result.entries.filter((e) => e.status === status), } : result; out(ctx, filtered); } async function runHistory(ctx: CronCliContext): Promise { const limitRaw = getFlag(ctx.args, "--limit"); const limit = limitRaw ? parseInt(limitRaw, 10) : 50; const status = getFlag(ctx.args, "--status"); const result = (await callQuery(ctx, listOrgCronRunHistoryRef, { limit, })) as { entries: Array> }; const filtered = status ? { entries: result.entries.filter((e) => e.status === status), } : result; out(ctx, filtered); } // ─── Help ───────────────────────────────────────────────────────────────── function cronHelp(): void { console.log(cronHelpText()); } export function cronHelpText(): string { return `Usage: dench cron dench routine (alias) dench routines (alias) Browse: dench cron list [--enabled-only] [--json] dench cron get [--json] dench cron history [--limit 50] [--status ok|error|skipped] [--json] dench cron runs [--limit 50] [--status ok|error|skipped] [--json] dench cron events [--json] # app-event types for a connected app Create / update: Add --environment to bind a ready reusable setup. New runs use its latest ready revision; --clear-environment removes the binding on update. dench cron create "Daily standup digest" \\ --prompt "Pull yesterday's commits + Slack #standup msgs and post a summary…" \\ --type schedule # default. also: webhook | app | crm (see below) --every 1d # or --cron "0 9 * * 1-5" or --at 2026-05-04T09:00:00Z [--description "..."] # human-readable note shown in dashboard / list [--overlap skip|queue|kill_old] # default: skip [--disabled] # default: enabled [--delete-after-run] # one-shot at-schedule; default false [--target chat_turn] # default: chat_turn (fires a fresh chat thread) [--json] # Webhook automation (fires on demand or when anything POSTs to its URL). # Live + unsigned by default; prints webhookUrl. Add --signed to also mint # an HMAC signing secret (shown ONCE). dench cron create "Inbound lead triage" --prompt "Triage the lead…" --type webhook [--signed] # App-event automation (fires when something happens in a connected app). # 1. dench integrations --json → connectedAccountId + toolkit # 2. dench cron events → event slug dench cron create "New GitHub star" --prompt "Thank the new stargazer" \\ --type app --connection --event \\ [--toolkit ] [--trigger-config ''] # CRM-event automation (fires on a CRM object create/update/delete). # 1. dench crm objects list → objectId # 2. dench crm fields list → field _id (for --where filters) dench cron create "New company added" --prompt "Research the new company" \\ --type crm --object --event create|update|delete \\ [--where '[{"logic":"AND","conditions":[{"fieldId":"","operator":"equals","value":"x"}]}]'] \\ [--where-logic AND|OR] dench cron update [--name N] [--prompt P] [--description D] \\ [--every X | --cron EXPR | --at ISO] [--enabled true|false] [--overlap …] Lifecycle: dench cron enable dench cron disable dench cron delete dench cron run [--wait] [--json] # manual fire; --wait blocks until run finishes dench cron secret [--off] [--json] # turn on / rotate HMAC signing (any type; makes non-webhook URLs live). --off turns it off. Schedule formats: --every Repeat every N. Accepts 30s, 5m, 2h, 1d, 12h, etc. --every-ms Same, raw milliseconds (>= 1000ms). --cron 5-field POSIX cron (minute hour day-of-month month day-of-week). Examples: "*/30 * * * *" (every 30 min), "0 9 * * 1-5" (weekdays 9am) --tz Optional timezone for --cron (e.g. "America/New_York"). Default UTC. --at ISO 8601 timestamp for a one-shot fire. Auto-disables after firing. Examples: # Remind me to drink water every 30 min dench cron create "Water reminder" --prompt "Send a friendly nudge to drink water." --every 30m # Daily 9am standup digest from CRM + workspace dench cron create "Standup digest" \\ --prompt "Pull yesterday's CRM Activity entries and post a summary to /workspace/standups/$(date +%%Y-%%m-%%d).md" \\ --cron "0 9 * * 1-5" # One-shot launch dench cron create "May 4 launch" \\ --prompt "Send launch tweet from @denchhq quoting today's blog post." \\ --at 2026-05-04T17:00:00Z # Pause and re-enable dench cron disable dench cron enable Auth: prefers DENCH_API_KEY (sandbox envVar). Falls back to a session token from \`dench login\` for local terminals. The Bearer token must carry either \`crons:read\` (queries) or \`crons:write\` (mutations) scope, or the wildcard \`*\`. `; } // ─── Entry point ────────────────────────────────────────────────────────── export async function runCronCommand(opts: { convex: ConvexHttpClient; args: string[]; sessionToken?: string; }): Promise { const args = [...opts.args]; const jsonOutput = hasFlag(args, "--json"); const ctx: CronCliContext = { convex: opts.convex, args, jsonOutput, sessionToken: opts.sessionToken, }; const subcommand = args.shift(); if (!subcommand || subcommand === "help" || subcommand === "--help") { cronHelp(); return; } switch (subcommand) { case "list": return await runList(ctx); case "get": return await runGet(ctx); case "create": return await runCreate(ctx); case "update": return await runUpdate(ctx); case "delete": return await runDelete(ctx); case "enable": return await runEnable(ctx, true); case "disable": return await runEnable(ctx, false); case "run": return await runRunNow(ctx); case "secret": return await runSecret(ctx); case "events": return await runEvents(ctx); case "runs": return await runRuns(ctx); case "history": return await runHistory(ctx); default: throw new CronCliError( `Unknown cron subcommand: ${subcommand}. Run 'dench cron help'.`, ); } }