import { normalizeOptionalString, readNonBlankString, } from "@openclaw/normalization-core/string-coerce"; import { isSystemMonitorDeclaration } from "../../cron/system-owned-declaration.js"; import type { CronJob } from "../../cron/types.js"; import { isSystemOwnedCronPayloadKind } from "../../cron/types.js"; import { CronCliError } from "./cron-cli-error.js"; import { parseCronCommandArgv, parseCronCommandEnv, parseCronIntegerOption, parseCronNoOutputTimeoutOption, parseCronStringList, } from "./shared.js"; import { parseCronThreadIdOption } from "./thread-id-shared.js"; import { readCronPayloadScript } from "./trigger-options.js"; const assignIf = ( target: Record, key: string, value: unknown, shouldAssign: boolean, ) => { if (shouldAssign) { target[key] = value; } }; export async function resolveCronEditPayloadDeliveryPatch( opts: Record, loadExistingJob: () => Promise, webhookUrl: string | undefined, commandCwd: string | undefined, ): Promise> { const patch: Record = {}; const hasSystemEventPatch = typeof opts.systemEvent === "string"; const scriptPath = readNonBlankString(opts.script); const commandShell = normalizeOptionalString(opts.command); const commandArgv = parseCronCommandArgv(opts.commandArgv); if (commandShell && commandArgv) { throw new CronCliError( "Pass command payload either with --command or --command-argv, not both.", ); } // Raw flag presence owns the set/clear mutex even when normalization omits a blank value. const hasModel = typeof opts.model === "string"; const model = normalizeOptionalString(opts.model); if (hasModel && opts.clearModel) { throw new CronCliError("Use --model or --clear-model, not both"); } const hasThinking = typeof opts.thinking === "string"; const thinking = normalizeOptionalString(opts.thinking); if (hasThinking && opts.clearThinking) { throw new CronCliError("Use --thinking or --clear-thinking, not both"); } const fallbacks = parseCronStringList(opts.fallbacks); if (typeof opts.fallbacks === "string" && opts.clearFallbacks) { throw new CronCliError("Use --fallbacks or --clear-fallbacks, not both"); } const toolsAllow = parseCronStringList(opts.tools); const timeoutSeconds = parseCronIntegerOption( opts.timeoutSeconds, "--timeout-seconds", "non-negative", ); const hasTimeoutSeconds = timeoutSeconds !== undefined; const noOutputTimeoutSeconds = parseCronNoOutputTimeoutOption(opts); const outputMaxBytes = parseCronIntegerOption(opts.outputMaxBytes, "--output-max-bytes"); const scriptTimeoutSeconds = parseCronIntegerOption( opts.scriptTimeoutSeconds, "--script-timeout-seconds", ); const scriptToolBudget = parseCronIntegerOption(opts.scriptToolBudget, "--script-tool-budget"); const hasWebhookDelivery = Boolean(webhookUrl); const hasDeliveryModeFlag = opts.announce || typeof opts.deliver === "boolean" || hasWebhookDelivery; const threadId = parseCronThreadIdOption(opts.threadId); const hasDeliveryThreadId = typeof threadId === "number"; const hasDeliveryTarget = typeof opts.channel === "string" || typeof opts.to === "string" || hasDeliveryThreadId || Boolean(opts.clearChannel) || Boolean(opts.clearTo) || Boolean(opts.clearThreadId); const hasDeliveryAccount = typeof opts.account === "string" || Boolean(opts.clearAccount); const hasBestEffort = typeof opts.bestEffortDeliver === "boolean"; if (hasWebhookDelivery && (hasDeliveryTarget || hasDeliveryAccount)) { throw new CronCliError("--webhook cannot be combined with chat delivery options."); } if (typeof opts.channel === "string" && opts.clearChannel) { throw new CronCliError("Use --channel or --clear-channel, not both"); } if (typeof opts.to === "string" && opts.clearTo) { throw new CronCliError("Use --to or --clear-to, not both"); } if (hasDeliveryThreadId && opts.clearThreadId) { throw new CronCliError("Use --thread-id or --clear-thread-id, not both"); } if (typeof opts.account === "string" && opts.clearAccount) { throw new CronCliError("Use --account or --clear-account, not both"); } // Unlike cwd, command stdin intentionally accepts empty and whitespace strings. const hasCommandInput = typeof opts.commandInput === "string"; const hasCommandSpecificPayloadField = Boolean(commandShell) || Boolean(commandArgv) || Boolean(commandCwd) || hasCommandInput || opts.commandEnv !== undefined || noOutputTimeoutSeconds !== undefined || outputMaxBytes !== undefined; const hasToolsAllowPatch = typeof opts.tools === "string" || Array.isArray(opts.tools) || Boolean(opts.clearTools); const hasAgentTurnSpecificPayloadField = typeof opts.message === "string" || Boolean(model) || Boolean(opts.clearModel) || typeof opts.fallbacks === "string" || Boolean(opts.clearFallbacks) || Boolean(thinking) || Boolean(opts.clearThinking) || typeof opts.lightContext === "boolean"; const hasScriptSpecificPayloadField = Boolean(scriptPath) || scriptTimeoutSeconds !== undefined || scriptToolBudget !== undefined; if (hasTimeoutSeconds && hasScriptSpecificPayloadField) { throw new CronCliError("Use --script-timeout-seconds for script jobs, not --timeout-seconds."); } if (hasTimeoutSeconds && hasSystemEventPatch) { throw new CronCliError("--timeout-seconds is not supported for systemEvent jobs."); } let timeoutOnlyPayloadKind: "agentTurn" | "command" | undefined; if (hasTimeoutSeconds && !hasCommandSpecificPayloadField && !hasAgentTurnSpecificPayloadField) { const existingJob = await loadExistingJob(); const existingKind = existingJob.payload.kind; if (existingKind === "script") { throw new CronCliError( "Use --script-timeout-seconds for script jobs, not --timeout-seconds.", ); } if ( existingKind === "systemEvent" || isSystemOwnedCronPayloadKind(existingKind) || isSystemMonitorDeclaration(existingJob.declarationKey) ) { throw new CronCliError(`--timeout-seconds is not supported for ${existingKind} jobs.`); } timeoutOnlyPayloadKind = existingKind; } let toolsOnlyPayloadKind: CronJob["payload"]["kind"] | undefined; if ( hasToolsAllowPatch && !hasSystemEventPatch && !hasAgentTurnSpecificPayloadField && !hasCommandSpecificPayloadField && !hasScriptSpecificPayloadField && !hasTimeoutSeconds ) { // Tool grants are shared by every payload kind; a policy-only edit must // preserve the stored execution kind instead of creating an agent turn. const existingJob = await loadExistingJob(); if (isSystemMonitorDeclaration(existingJob.declarationKey)) { throw new CronCliError("System-owned cron jobs cannot be edited by cron clients."); } toolsOnlyPayloadKind = existingJob.payload.kind; } const hasAgentTurnPayloadField = hasAgentTurnSpecificPayloadField || timeoutOnlyPayloadKind === "agentTurn" || (hasToolsAllowPatch && toolsOnlyPayloadKind === "agentTurn"); const hasCommandPayloadField = hasCommandSpecificPayloadField || timeoutOnlyPayloadKind === "command" || toolsOnlyPayloadKind === "command"; const hasAgentTurnPatch = hasAgentTurnPayloadField; const hasCommandPatch = hasCommandPayloadField; const hasScriptPatch = hasScriptSpecificPayloadField || toolsOnlyPayloadKind === "script"; const hasSystemEventOrToolsPatch = hasSystemEventPatch || toolsOnlyPayloadKind === "systemEvent"; if ( [hasSystemEventOrToolsPatch, hasAgentTurnPatch, hasCommandPatch, hasScriptPatch].filter(Boolean) .length > 1 ) { throw new CronCliError("Choose at most one payload change"); } const assignToolsAllowPatch = (payload: Record): void => { if (opts.clearTools) { // Clearing a restriction means an explicit unrestricted grant. Persisting // a wildcard avoids creating a new capless legacy job at the upgrade boundary. payload.toolsAllow = ["*"]; } else if (toolsAllow) { payload.toolsAllow = toolsAllow; } }; if (hasSystemEventOrToolsPatch) { const payload: Record = { kind: "systemEvent" }; assignIf(payload, "text", String(opts.systemEvent), hasSystemEventPatch); assignToolsAllowPatch(payload); patch.payload = payload; } else if (hasAgentTurnPatch) { const payload: Record = { kind: "agentTurn" }; assignIf(payload, "message", String(opts.message), typeof opts.message === "string"); if (opts.clearModel) { payload.model = null; } else { assignIf(payload, "model", model, Boolean(model)); } assignIf(payload, "fallbacks", fallbacks, typeof opts.fallbacks === "string"); assignIf(payload, "fallbacks", null, Boolean(opts.clearFallbacks)); if (opts.clearThinking) { payload.thinking = null; } else { assignIf(payload, "thinking", thinking, Boolean(thinking)); } assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds); assignIf(payload, "lightContext", opts.lightContext, typeof opts.lightContext === "boolean"); assignToolsAllowPatch(payload); patch.payload = payload; } else if (hasCommandPatch) { const payload: Record = { kind: "command" }; assignIf(payload, "argv", commandArgv, Boolean(commandArgv)); assignIf(payload, "argv", ["sh", "-lc", commandShell], Boolean(commandShell)); assignIf(payload, "cwd", commandCwd, Boolean(commandCwd)); assignIf(payload, "env", parseCronCommandEnv(opts.commandEnv), opts.commandEnv !== undefined); assignIf(payload, "input", opts.commandInput, hasCommandInput); assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds); assignIf( payload, "noOutputTimeoutSeconds", noOutputTimeoutSeconds, noOutputTimeoutSeconds !== undefined, ); assignIf(payload, "outputMaxBytes", outputMaxBytes, outputMaxBytes !== undefined); assignToolsAllowPatch(payload); patch.payload = payload; } else if (hasScriptPatch) { const payload: Record = { kind: "script" }; if (scriptPath) { payload.script = await readCronPayloadScript(scriptPath); } assignIf(payload, "timeoutSeconds", scriptTimeoutSeconds, scriptTimeoutSeconds !== undefined); assignIf(payload, "toolBudget", scriptToolBudget, scriptToolBudget !== undefined); assignToolsAllowPatch(payload); patch.payload = payload; } if (hasDeliveryModeFlag || hasDeliveryTarget || hasDeliveryAccount || hasBestEffort) { const delivery: Record = {}; if (hasDeliveryModeFlag) { delivery.mode = hasWebhookDelivery ? "webhook" : opts.announce || opts.deliver === true ? "announce" : "none"; } else if (opts.bestEffortDeliver === true) { // Back-compat: enabling best-effort historically implied announce mode. delivery.mode = "announce"; } if (opts.clearChannel) { delivery.channel = null; } else if (typeof opts.channel === "string") { const channel = opts.channel.trim(); delivery.channel = channel ? channel : undefined; } if (hasWebhookDelivery) { delivery.to = webhookUrl; } else if (opts.clearTo) { delivery.to = null; } else if (typeof opts.to === "string") { const to = opts.to.trim(); delivery.to = to ? to : undefined; } if (opts.clearThreadId) { delivery.threadId = null; } else if (hasDeliveryThreadId) { delivery.threadId = threadId; } if (opts.clearAccount) { delivery.accountId = null; } else if (typeof opts.account === "string") { const account = opts.account.trim(); delivery.accountId = account ? account : undefined; } if (typeof opts.bestEffortDeliver === "boolean") { delivery.bestEffort = opts.bestEffortDeliver; } patch.delivery = delivery; } return patch; }