openclaw / src /cli /cron-cli /register.cron-add.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f778c12 verified
Raw History Blame Contribute Delete
18.8 kB
// Cron status/list/add command registration and create-payload normalization.
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
readNonBlankString,
} from "@openclaw/normalization-core/string-coerce";
import type { Command } from "commander";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import type { CronJob } from "../../cron/types.js";
import { normalizeHttpWebhookUrl } from "../../cron/webhook-url.js";
import { sanitizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { CronCliError } from "./cron-cli-error.js";
import { listCronJobsFromGateway } from "./list-jobs.js";
import { createCronOutputCommand } from "./output-mode.js";
import { registerCronMutationOptions } from "./register.cron-options.js";
import { resolveCronCreateScheduleFromArgs } from "./schedule-options.js";
import {
coerceCronDeliveryPreviews,
enrichCronJsonWithStatus,
handleCronCliError,
parseCronCommandArgv,
parseCronCommandEnv,
parseCronIntegerOption,
parseCronNoOutputTimeoutOption,
parseCronStringList,
printCronJson,
printCronList,
warnIfCronSchedulerDisabled,
} from "./shared.js";
import { normalizeCronSessionTargetOption, parseCronThreadIdOption } from "./thread-id-shared.js";
import { readCronPayloadScript, readCronTriggerScript } from "./trigger-options.js";
export function registerCronStatusCommand(cron: Command) {
addGatewayClientOptions(
createCronOutputCommand(cron, "status")
.description("Show automations scheduler status")
.action(async (opts) => {
try {
const res = await callGatewayFromCli("cron.status", opts, {});
printCronJson(res);
} catch (err) {
handleCronCliError(err);
}
}),
);
}
export function registerCronListCommand(cron: Command) {
addGatewayClientOptions(
cron
.command("list")
.description("List automations")
.option("--all", "Include disabled jobs", false)
.option("--agent <id>", "Filter by agent id")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
const listParams: { includeDisabled: boolean; agentId?: string } = {
includeDisabled: Boolean(opts.all),
};
const agentId = normalizeOptionalString(opts.agent);
if (typeof opts.agent === "string" && !agentId) {
throw new CronCliError("--agent must not be blank");
}
if (agentId) {
listParams.agentId = sanitizeAgentId(agentId);
}
const res = await listCronJobsFromGateway(opts, listParams);
if (opts.json) {
printCronJson(enrichCronJsonWithStatus(res));
return;
}
const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? [];
const deliveryPreviews = coerceCronDeliveryPreviews(res);
printCronList(jobs, defaultRuntime, { deliveryPreviews });
} catch (err) {
handleCronCliError(err);
}
}),
);
}
export function registerCronAddCommand(cron: Command) {
addGatewayClientOptions(
registerCronMutationOptions(
createCronOutputCommand(cron, "add")
.description("Add an automation")
.argument("[scheduleOrName]", "Schedule string, or job name when using --at/--every/--cron")
.argument("[message]", "Agent message when using a positional schedule"),
"add",
)
.option("--declaration-key <key>", "Idempotent declaration identity key")
.option("--disabled", "Create job disabled", false)
.action(
async (
nameArg: string | undefined,
messageArg: string | undefined,
opts: GatewayRpcOpts & Record<string, unknown>,
cmd: Command,
) => {
try {
const hasScheduleFlag =
typeof opts.at === "string" ||
typeof opts.cron === "string" ||
typeof opts.every === "string" ||
typeof opts.onExit === "string" ||
typeof opts.streamCommand === "string";
const positionalSchedule = hasScheduleFlag ? undefined : nameArg;
const schedule = resolveCronCreateScheduleFromArgs({ ...opts, positionalSchedule });
const wakeMode = normalizeOptionalString(opts.wake) ?? "now";
if (wakeMode !== "now" && wakeMode !== "next-heartbeat") {
throw new CronCliError("--wake must be now or next-heartbeat");
}
const rawAgentId = normalizeOptionalString(opts.agent);
const agentId = rawAgentId ? sanitizeAgentId(rawAgentId) : undefined;
const hasAnnounce = Boolean(opts.announce) || opts.deliver === true;
const hasNoDeliver = opts.deliver === false;
const webhookUrl =
typeof opts.webhook === "string" ? normalizeHttpWebhookUrl(opts.webhook) : null;
if (typeof opts.webhook === "string" && !webhookUrl) {
throw new CronCliError("--webhook must be a valid http(s) URL");
}
const hasWebhook = Boolean(webhookUrl);
const deliveryFlagCount = [hasAnnounce, hasNoDeliver, hasWebhook].filter(
Boolean,
).length;
if (deliveryFlagCount > 1) {
throw new CronCliError(
"Choose at most one of --announce, --no-deliver, or --webhook",
);
}
const resolvedPayload = await (async () => {
// Main-session jobs use system events; isolated/current/session jobs use messages.
const systemEvent = normalizeOptionalString(opts.systemEvent) ?? "";
const optionMessage = normalizeOptionalString(opts.message);
const positionalMessage = normalizeOptionalString(messageArg);
const commandShell = normalizeOptionalString(opts.command);
const commandArgv = parseCronCommandArgv(opts.commandArgv);
// File arguments identify exact local paths; trimming can select another file.
const scriptPath = readNonBlankString(opts.script);
if (typeof opts.script === "string" && !scriptPath) {
throw new CronCliError("--script must not be blank");
}
const toolsAllow = parseCronStringList(opts.tools);
if (optionMessage && positionalMessage && optionMessage !== positionalMessage) {
throw new CronCliError(
"Pass the automation message either positionally or with --message, not both.",
);
}
const message = optionMessage ?? positionalMessage ?? "";
if (commandShell && commandArgv) {
throw new CronCliError(
"Pass command payload either with --command or --command-argv, not both.",
);
}
const chosen = [
Boolean(systemEvent),
Boolean(message),
Boolean(commandShell) || Boolean(commandArgv),
Boolean(scriptPath),
].filter(Boolean).length;
if (chosen !== 1) {
throw new CronCliError(
"Choose exactly one payload: --system-event, --message, --command, or --script",
);
}
if (systemEvent) {
return {
kind: "systemEvent" as const,
text: systemEvent,
...(toolsAllow ? { toolsAllow } : {}),
};
}
if (scriptPath) {
if (opts.timeoutSeconds !== undefined) {
throw new CronCliError(
"Use --script-timeout-seconds for script jobs, not --timeout-seconds.",
);
}
const scriptTimeoutSeconds = parseCronIntegerOption(
opts.scriptTimeoutSeconds,
"--script-timeout-seconds",
);
const scriptToolBudget = parseCronIntegerOption(
opts.scriptToolBudget,
"--script-tool-budget",
);
return {
kind: "script" as const,
timeoutSeconds: scriptTimeoutSeconds,
toolBudget: scriptToolBudget,
toolsAllow,
script: await readCronPayloadScript(scriptPath),
};
}
const timeoutSeconds = parseCronIntegerOption(
opts.timeoutSeconds,
"--timeout-seconds",
"non-negative",
);
if (commandShell || commandArgv) {
const noOutputTimeoutSeconds = parseCronNoOutputTimeoutOption(opts);
const outputMaxBytes = parseCronIntegerOption(
opts.outputMaxBytes,
"--output-max-bytes",
);
return {
kind: "command" as const,
argv: commandArgv ?? ["sh", "-lc", commandShell ?? ""],
cwd: normalizeOptionalString(opts.commandCwd),
env: parseCronCommandEnv(opts.commandEnv),
input: typeof opts.commandInput === "string" ? opts.commandInput : undefined,
timeoutSeconds,
noOutputTimeoutSeconds,
outputMaxBytes,
...(toolsAllow ? { toolsAllow } : {}),
};
}
return {
kind: "agentTurn" as const,
message,
model: normalizeOptionalString(opts.model),
fallbacks: parseCronStringList(opts.fallbacks),
thinking: normalizeOptionalString(opts.thinking),
timeoutSeconds,
lightContext: opts.lightContext === true ? true : undefined,
toolsAllow,
};
})();
const sessionSource = cmd.getOptionValueSource("session");
const sessionTargetRaw = normalizeOptionalString(opts.session) ?? "";
const inferredSessionTarget =
resolvedPayload.kind === "agentTurn" ||
resolvedPayload.kind === "command" ||
resolvedPayload.kind === "script"
? "isolated"
: "main";
const sessionTarget =
sessionSource === "cli"
? normalizeCronSessionTargetOption(sessionTargetRaw) || ""
: inferredSessionTarget;
const isCustomSessionTarget =
normalizeLowercaseStringOrEmpty(sessionTarget).startsWith("session:") &&
Boolean(normalizeOptionalString(sessionTarget.slice(8)));
const isIsolatedLikeSessionTarget =
sessionTarget === "isolated" || sessionTarget === "current" || isCustomSessionTarget;
if (sessionTarget !== "main" && !isIsolatedLikeSessionTarget) {
throw new CronCliError("--session must be main, isolated, current, or session:<id>");
}
if (opts.deleteAfterRun && opts.keepAfterRun) {
throw new CronCliError("Choose --delete-after-run or --keep-after-run, not both");
}
if (
sessionTarget === "main" &&
resolvedPayload.kind !== "systemEvent" &&
resolvedPayload.kind !== "script"
) {
throw new CronCliError("Main jobs require --system-event or --script.");
}
if (
resolvedPayload.kind === "script" &&
sessionTarget !== "main" &&
sessionTarget !== "isolated"
) {
throw new CronCliError("Script jobs require --session main or --session isolated.");
}
if (
isIsolatedLikeSessionTarget &&
resolvedPayload.kind !== "agentTurn" &&
resolvedPayload.kind !== "command" &&
resolvedPayload.kind !== "script"
) {
throw new CronCliError("Isolated jobs require --message, --command, or --script.");
}
if (
(opts.announce || typeof opts.deliver === "boolean") &&
(!isIsolatedLikeSessionTarget ||
(resolvedPayload.kind !== "agentTurn" &&
resolvedPayload.kind !== "command" &&
resolvedPayload.kind !== "script"))
) {
throw new CronCliError(
"--announce/--no-deliver require a non-main agentTurn, command, or script session target.",
);
}
const accountId = normalizeOptionalString(opts.account);
const threadId = parseCronThreadIdOption(opts.threadId);
const hasThreadId = typeof threadId === "number";
const hasChatDeliveryTarget =
cmd.getOptionValueSource("channel") === "cli" ||
typeof opts.to === "string" ||
Boolean(accountId) ||
hasThreadId;
if (
hasChatDeliveryTarget &&
(!isIsolatedLikeSessionTarget ||
(resolvedPayload.kind !== "agentTurn" &&
resolvedPayload.kind !== "command" &&
resolvedPayload.kind !== "script"))
) {
throw new CronCliError(
"--channel, --to, --account, and --thread-id require a non-main agentTurn, command, or script job with delivery.",
);
}
if (hasWebhook && hasChatDeliveryTarget) {
throw new CronCliError("--webhook cannot be combined with chat delivery options.");
}
const deliveryMode = hasWebhook
? "webhook"
: isIsolatedLikeSessionTarget &&
(resolvedPayload.kind === "agentTurn" ||
resolvedPayload.kind === "command" ||
resolvedPayload.kind === "script")
? hasAnnounce
? "announce"
: hasNoDeliver
? "none"
: "announce"
: undefined;
const optionName = normalizeOptionalString(opts.name);
const positionalName = hasScheduleFlag ? normalizeOptionalString(nameArg) : undefined;
if (optionName && positionalName && optionName !== positionalName) {
throw new CronCliError(
"Pass the automation name either positionally or with --name, not both.",
);
}
const name = optionName ?? positionalName ?? "";
if (!name) {
throw new CronCliError("Cron job name is required. Pass a name or --name <name>.");
}
const description = normalizeOptionalString(opts.description);
const declarationKey = normalizeOptionalString(opts.declarationKey);
if (typeof opts.declarationKey === "string" && !declarationKey) {
throw new CronCliError("--declaration-key must not be blank");
}
const displayName = normalizeOptionalString(opts.displayName);
if (typeof opts.displayName === "string" && !displayName) {
throw new CronCliError("--display-name must not be blank");
}
const pacingMin = normalizeOptionalString(opts.pacingMin);
const pacingMax = normalizeOptionalString(opts.pacingMax);
if (typeof opts.pacingMin === "string" && !pacingMin) {
throw new CronCliError("--pacing-min must not be blank");
}
if (typeof opts.pacingMax === "string" && !pacingMax) {
throw new CronCliError("--pacing-max must not be blank");
}
const sessionKey = normalizeOptionalString(opts.sessionKey);
const triggerScriptPath = readNonBlankString(opts.triggerScript);
if ((opts.triggerOnce || opts.triggerScript !== undefined) && !triggerScriptPath) {
throw new CronCliError(
`--trigger-${opts.triggerOnce ? "once requires --trigger-script" : "script must not be blank"}`,
);
}
const trigger = triggerScriptPath && {
script: await readCronTriggerScript(triggerScriptPath),
...(opts.triggerOnce ? { once: true } : {}),
};
if (
(resolvedPayload.kind === "agentTurn" || resolvedPayload.kind === "script") &&
!agentId
) {
defaultRuntime.error(
theme.warn(
"No --agent specified; the job will run with the configured default agent. " +
"Specify --agent to choose a specific agent, or set agents.defaults.systemAgent.agentId.",
),
);
}
const params = {
name,
declarationKey,
displayName,
description,
...(declarationKey && cmd.getOptionValueSource("disabled") !== "cli"
? {}
: { enabled: !opts.disabled }),
deleteAfterRun: opts.deleteAfterRun ? true : opts.keepAfterRun ? false : undefined,
agentId,
sessionKey,
schedule,
...(pacingMin || pacingMax
? {
pacing: {
...(pacingMin ? { min: pacingMin } : {}),
...(pacingMax ? { max: pacingMax } : {}),
},
}
: {}),
trigger,
sessionTarget,
wakeMode,
payload: resolvedPayload,
delivery: deliveryMode
? {
mode: deliveryMode,
channel: hasWebhook ? undefined : normalizeOptionalString(opts.channel),
to: hasWebhook ? webhookUrl : normalizeOptionalString(opts.to),
threadId: hasWebhook ? undefined : threadId,
accountId: hasWebhook ? undefined : accountId,
bestEffort: opts.bestEffortDeliver ? true : undefined,
}
: undefined,
};
const res = await callGatewayFromCli("cron.add", opts, params);
printCronJson(res);
await warnIfCronSchedulerDisabled(opts);
} catch (err) {
handleCronCliError(err);
}
},
),
);
}