mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat: improve cron create delivery ergonomics
Summary: - Add Hermes-style schedule-first cron create parsing while preserving flagged create options. - Support webhook create/edit delivery and clear stale webhook/chat delivery fields across mode changes. - Update cron docs and schedule identity normalization tests. Verification: - pnpm test src/cron/schedule-identity.test.ts src/cli/cron-cli.test.ts src/cron/service.jobs.test.ts -- --reporter=verbose - pnpm test src/cli/cron-cli.test.ts src/cron/service.jobs.test.ts -- --reporter=verbose - pnpm check:test-types - pnpm check:import-cycles - pnpm check:docs - pnpm check:changed via Crabbox run_8c44bcb158da, exit 0 - autoreview branch diff clean
This commit is contained in:
committed by
GitHub
parent
4b18234fc1
commit
2209faef40
@@ -15,9 +15,8 @@ Cron is the Gateway's built-in scheduler. It persists jobs, wakes the agent at t
|
||||
<Steps>
|
||||
<Step title="Add a one-shot reminder">
|
||||
```bash
|
||||
openclaw cron add \
|
||||
openclaw cron create "2026-02-01T16:00:00Z" \
|
||||
--name "Reminder" \
|
||||
--at "2026-02-01T16:00:00Z" \
|
||||
--session main \
|
||||
--system-event "Reminder: check the cron docs draft" \
|
||||
--wake now \
|
||||
@@ -215,12 +214,11 @@ Failure notifications follow a separate destination path:
|
||||
</Tab>
|
||||
<Tab title="Recurring isolated job">
|
||||
```bash
|
||||
openclaw cron add \
|
||||
openclaw cron create "0 7 * * *" \
|
||||
"Summarize overnight updates." \
|
||||
--name "Morning brief" \
|
||||
--cron "0 7 * * *" \
|
||||
--tz "America/Los_Angeles" \
|
||||
--session isolated \
|
||||
--message "Summarize overnight updates." \
|
||||
--announce \
|
||||
--channel slack \
|
||||
--to "channel:C1234567890"
|
||||
@@ -239,6 +237,14 @@ Failure notifications follow a separate destination path:
|
||||
--announce
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Webhook output">
|
||||
```bash
|
||||
openclaw cron create "0 18 * * 1-5" \
|
||||
"Summarize today's deploys as JSON." \
|
||||
--name "Deploy digest" \
|
||||
--webhook "https://example.invalid/openclaw/cron"
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Webhooks
|
||||
@@ -411,12 +417,14 @@ openclaw cron runs --id <jobId> --run-id <runId>
|
||||
openclaw cron remove <jobId>
|
||||
|
||||
# Agent selection (multi-agent setups)
|
||||
openclaw cron add --name "Ops sweep" --cron "0 6 * * *" --session isolated --message "Check ops queue" --agent ops
|
||||
openclaw cron create "0 6 * * *" "Check ops queue" --name "Ops sweep" --session isolated --agent ops
|
||||
openclaw cron edit <jobId> --clear-agent
|
||||
```
|
||||
|
||||
`openclaw cron run <jobId>` returns after enqueueing the manual run. Use `--wait` for shutdown hooks, maintenance scripts, or other automation that must block until the queued run finishes. Wait mode polls the exact returned `runId`; it exits `0` for status `ok` and non-zero for `error`, `skipped`, or a wait timeout.
|
||||
|
||||
`openclaw cron create` is an alias for `openclaw cron add`, and new jobs can use a positional schedule (`"0 9 * * 1"`, `"every 1h"`, `"20m"`, or an ISO timestamp) followed by a positional agent prompt. Use `--webhook <url>` on `cron add|create` or `cron edit` to POST the finished run payload to an HTTP endpoint. Webhook delivery cannot be combined with chat delivery flags such as `--announce`, `--channel`, `--to`, `--thread-id`, or `--account`.
|
||||
|
||||
<Note>
|
||||
Model override note:
|
||||
|
||||
|
||||
+25
-3
@@ -14,6 +14,26 @@ Manage cron jobs for the Gateway scheduler.
|
||||
Run `openclaw cron --help` for the full command surface. See [Cron jobs](/automation/cron-jobs) for the conceptual guide.
|
||||
</Tip>
|
||||
|
||||
## Create jobs quickly
|
||||
|
||||
`openclaw cron create` is an alias for `openclaw cron add`. For new jobs, put the schedule first and the prompt second:
|
||||
|
||||
```bash
|
||||
openclaw cron create "0 7 * * *" \
|
||||
"Summarize overnight updates." \
|
||||
--name "Morning brief" \
|
||||
--agent ops
|
||||
```
|
||||
|
||||
Use `--webhook <url>` when the job should POST the finished payload instead of delivering to a chat target:
|
||||
|
||||
```bash
|
||||
openclaw cron create "0 18 * * 1-5" \
|
||||
"Summarize today's deploys as JSON." \
|
||||
--name "Deploy digest" \
|
||||
--webhook "https://example.invalid/openclaw/cron"
|
||||
```
|
||||
|
||||
## Sessions
|
||||
|
||||
`--session` accepts `main`, `isolated`, `current`, or `session:<id>`.
|
||||
@@ -50,6 +70,8 @@ Isolated cron chat delivery is shared between the agent and the runner:
|
||||
- `webhook` posts the finished payload to a URL.
|
||||
- `none` disables runner fallback delivery.
|
||||
|
||||
Use `cron add|create --webhook <url>` or `cron edit <job-id> --webhook <url>` to set webhook delivery. Do not combine `--webhook` with chat delivery flags such as `--announce`, `--no-deliver`, `--channel`, `--to`, `--thread-id`, or `--account`.
|
||||
|
||||
`--announce` is runner fallback delivery for the final reply. `--no-deliver` disables that fallback but does not remove the agent's `message` tool when a chat route is available.
|
||||
|
||||
Reminders created from an active chat preserve the live chat delivery target for fallback announce delivery. Internal session keys may be lowercase; do not use them as a source of truth for case-sensitive provider IDs such as Matrix room IDs.
|
||||
@@ -219,11 +241,10 @@ openclaw cron edit <job-id> --announce --channel telegram --to "-1001234567890"
|
||||
Create an isolated job with lightweight bootstrap context:
|
||||
|
||||
```bash
|
||||
openclaw cron add \
|
||||
openclaw cron create "0 7 * * *" \
|
||||
"Summarize overnight updates." \
|
||||
--name "Lightweight morning brief" \
|
||||
--cron "0 7 * * *" \
|
||||
--session isolated \
|
||||
--message "Summarize overnight updates." \
|
||||
--light-context \
|
||||
--no-deliver
|
||||
```
|
||||
@@ -270,6 +291,7 @@ Delivery tweaks:
|
||||
|
||||
```bash
|
||||
openclaw cron edit <job-id> --announce --channel slack --to "channel:C1234567890"
|
||||
openclaw cron edit <job-id> --webhook "https://example.invalid/openclaw/cron"
|
||||
openclaw cron edit <job-id> --best-effort-deliver
|
||||
openclaw cron edit <job-id> --no-best-effort-deliver
|
||||
openclaw cron edit <job-id> --no-deliver
|
||||
|
||||
@@ -75,7 +75,9 @@ function stableJson(value) {
|
||||
}
|
||||
|
||||
export function sanitizeDisplayValue(value) {
|
||||
return String(value).replace(/[\p{Cc}]/gu, "?").slice(0, 240);
|
||||
return String(value)
|
||||
.replace(/[\p{Cc}]/gu, "?")
|
||||
.slice(0, 240);
|
||||
}
|
||||
|
||||
export function markdownCode(value) {
|
||||
@@ -242,10 +244,7 @@ export function renderAuthorizedDependencyComment(override) {
|
||||
if (override.reason) {
|
||||
lines.push(`- Reason: ${markdownCode(override.reason)}`);
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
"A later push changes the PR head SHA and requires a fresh security approval.",
|
||||
);
|
||||
lines.push("", "A later push changes the PR head SHA and requires a fresh security approval.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -278,19 +277,20 @@ export function renderBlockedDependencyComment({
|
||||
`- ${markdownCode(change.path)} changed ${change.fields.map(markdownCode).join(", ")}.`,
|
||||
);
|
||||
}
|
||||
const removalSteps = lockfileChanges.length > 0
|
||||
? [
|
||||
"",
|
||||
"To remove accidental lockfile residue, restore the lockfile changes from the target branch:",
|
||||
"",
|
||||
"```bash",
|
||||
"git fetch origin",
|
||||
`git checkout ${baseRef} -- ${lockfileChanges.map(shellQuote).join(" ")}`,
|
||||
'git commit -m "Remove dependency lockfile change"',
|
||||
"git push",
|
||||
"```",
|
||||
]
|
||||
: [];
|
||||
const removalSteps =
|
||||
lockfileChanges.length > 0
|
||||
? [
|
||||
"",
|
||||
"To remove accidental lockfile residue, restore the lockfile changes from the target branch:",
|
||||
"",
|
||||
"```bash",
|
||||
"git fetch origin",
|
||||
`git checkout ${baseRef} -- ${lockfileChanges.map(shellQuote).join(" ")}`,
|
||||
'git commit -m "Remove dependency lockfile change"',
|
||||
"git push",
|
||||
"```",
|
||||
]
|
||||
: [];
|
||||
return [
|
||||
dependencyGraphGuardMarker,
|
||||
"",
|
||||
@@ -328,7 +328,9 @@ function githubApi(token) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
|
||||
const error = new Error(
|
||||
`${response.status} ${response.statusText}: ${await response.text()}`,
|
||||
);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
@@ -477,39 +479,49 @@ async function main() {
|
||||
if (!labelNames.has(label)) {
|
||||
return;
|
||||
}
|
||||
await api.request(`${issuePath}/labels/${encodeURIComponent(label)}`, {
|
||||
method: "DELETE",
|
||||
}).catch(ignoreUnavailableWritePermission(`label "${label}" removal`));
|
||||
await api
|
||||
.request(`${issuePath}/labels/${encodeURIComponent(label)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.catch(ignoreUnavailableWritePermission(`label "${label}" removal`));
|
||||
};
|
||||
const addLabelIfMissing = async (label) => {
|
||||
if (labelNames.has(label)) {
|
||||
return;
|
||||
}
|
||||
await api.request(`${issuePath}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ labels: [label] }),
|
||||
}).catch(ignoreUnavailableWritePermission(`label "${label}" update`));
|
||||
await api
|
||||
.request(`${issuePath}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ labels: [label] }),
|
||||
})
|
||||
.catch(ignoreUnavailableWritePermission(`label "${label}" update`));
|
||||
};
|
||||
const deleteCommentIfPresent = async (comment) => {
|
||||
if (!comment) {
|
||||
return;
|
||||
}
|
||||
await api.request(`/repos/${owner}/${repo}/issues/comments/${comment.id}`, {
|
||||
method: "DELETE",
|
||||
}).catch(ignoreUnavailableWritePermission("comment deletion"));
|
||||
await api
|
||||
.request(`/repos/${owner}/${repo}/issues/comments/${comment.id}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.catch(ignoreUnavailableWritePermission("comment deletion"));
|
||||
};
|
||||
const upsertComment = async (comment, body) => {
|
||||
if (comment) {
|
||||
await api.request(`/repos/${owner}/${repo}/issues/comments/${comment.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ body }),
|
||||
}).catch(ignoreUnavailableWritePermission("comment update"));
|
||||
await api
|
||||
.request(`/repos/${owner}/${repo}/issues/comments/${comment.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ body }),
|
||||
})
|
||||
.catch(ignoreUnavailableWritePermission("comment update"));
|
||||
return;
|
||||
}
|
||||
await api.request(`${issuePath}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
}).catch(ignoreUnavailableWritePermission("comment creation"));
|
||||
await api
|
||||
.request(`${issuePath}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
})
|
||||
.catch(ignoreUnavailableWritePermission("comment creation"));
|
||||
};
|
||||
|
||||
if (dependencyGraphFiles.length === 0) {
|
||||
|
||||
+148
-2
@@ -79,8 +79,11 @@ type CronUpdatePatch = {
|
||||
};
|
||||
|
||||
type CronAddParams = {
|
||||
schedule?: { kind?: string; at?: string; staggerMs?: number };
|
||||
name?: string;
|
||||
schedule?: { kind?: string; at?: string; expr?: string; everyMs?: number; staggerMs?: number };
|
||||
payload?: {
|
||||
kind?: string;
|
||||
message?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
lightContext?: boolean;
|
||||
@@ -398,6 +401,111 @@ describe("cron cli", () => {
|
||||
expect(params?.delivery?.mode).toBe("announce");
|
||||
});
|
||||
|
||||
it("accepts positional cron create name with webhook delivery", async () => {
|
||||
const params = await runCronAddAndGetParams([
|
||||
"Webhook reminder",
|
||||
"--at",
|
||||
"20m",
|
||||
"--system-event",
|
||||
"Summarize the latest status",
|
||||
"--webhook",
|
||||
" https://example.invalid/openclaw ",
|
||||
]);
|
||||
|
||||
expect(params?.name).toBe("Webhook reminder");
|
||||
expect(params?.sessionTarget).toBe("main");
|
||||
expect(params?.delivery).toEqual({
|
||||
mode: "webhook",
|
||||
to: "https://example.invalid/openclaw",
|
||||
channel: undefined,
|
||||
threadId: undefined,
|
||||
accountId: undefined,
|
||||
bestEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts Hermes-style positional cron schedule and message on cron create", async () => {
|
||||
const params = await runCronAddAndGetParams([
|
||||
"0 2 * * *",
|
||||
"Pull the top bug from the issue tracker, attempt a fix, and open a draft PR.",
|
||||
"--name",
|
||||
"Nightly bug fix",
|
||||
"--agent",
|
||||
"ops",
|
||||
]);
|
||||
|
||||
expect(params?.name).toBe("Nightly bug fix");
|
||||
expect(params?.schedule).toMatchObject({ kind: "cron", expr: "0 2 * * *" });
|
||||
expect(params?.sessionTarget).toBe("isolated");
|
||||
expect(params?.payload?.kind).toBe("agentTurn");
|
||||
expect(params?.payload?.message).toBe(
|
||||
"Pull the top bug from the issue tracker, attempt a fix, and open a draft PR.",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts Hermes-style every interval schedule on cron create", async () => {
|
||||
const params = await runCronAddAndGetParams([
|
||||
"every 1h",
|
||||
"Summarize what changed.",
|
||||
"--name",
|
||||
"Pricing monitor",
|
||||
"--no-deliver",
|
||||
]);
|
||||
|
||||
expect(params?.schedule).toEqual({ kind: "every", everyMs: 3_600_000 });
|
||||
expect(params?.payload?.message).toBe("Summarize what changed.");
|
||||
expect(params?.delivery?.mode).toBe("none");
|
||||
});
|
||||
|
||||
it("rejects conflicting positional and option messages on cron create", async () => {
|
||||
await expectCronCommandExit([
|
||||
"cron",
|
||||
"create",
|
||||
"0 2 * * *",
|
||||
"Positional prompt",
|
||||
"--name",
|
||||
"Mixed prompt",
|
||||
"--message",
|
||||
"Option prompt",
|
||||
]);
|
||||
|
||||
expectRuntimeErrorContaining("Pass the cron job message either positionally or with --message");
|
||||
});
|
||||
|
||||
it("rejects ambiguous cron add names", async () => {
|
||||
await expectCronCommandExit([
|
||||
"cron",
|
||||
"add",
|
||||
"Positional",
|
||||
"--name",
|
||||
"Option",
|
||||
"--cron",
|
||||
"* * * * *",
|
||||
"--system-event",
|
||||
"tick",
|
||||
]);
|
||||
|
||||
expectRuntimeErrorContaining("Pass the cron job name either positionally or with --name");
|
||||
});
|
||||
|
||||
it("rejects webhook delivery mixed with chat delivery on cron add", async () => {
|
||||
await expectCronCommandExit([
|
||||
"cron",
|
||||
"add",
|
||||
"Mixed delivery",
|
||||
"--cron",
|
||||
"* * * * *",
|
||||
"--message",
|
||||
"hello",
|
||||
"--webhook",
|
||||
"https://example.invalid/openclaw",
|
||||
"--to",
|
||||
"channel:C123",
|
||||
]);
|
||||
|
||||
expectRuntimeErrorContaining("--webhook cannot be combined with chat delivery options");
|
||||
});
|
||||
|
||||
it("infers sessionTarget from payload when --session is omitted", async () => {
|
||||
await runCronCommand([
|
||||
"cron",
|
||||
@@ -938,10 +1046,48 @@ describe("cron cli", () => {
|
||||
patch?: { payload?: { kind?: string }; delivery?: { mode?: string } };
|
||||
}>("cron.update");
|
||||
|
||||
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
||||
expect(patch?.patch?.payload).toBeUndefined();
|
||||
expect(patch?.patch?.delivery?.mode).toBe("none");
|
||||
});
|
||||
|
||||
it("sets webhook delivery without forcing an agentTurn payload on cron edit", async () => {
|
||||
await runCronCommand([
|
||||
"cron",
|
||||
"edit",
|
||||
"job-1",
|
||||
"--webhook",
|
||||
" https://example.invalid/cron ",
|
||||
"--best-effort-deliver",
|
||||
]);
|
||||
|
||||
const patch = getGatewayCallParams<{
|
||||
patch?: {
|
||||
payload?: { kind?: string };
|
||||
delivery?: { mode?: string; to?: string; bestEffort?: boolean };
|
||||
};
|
||||
}>("cron.update");
|
||||
|
||||
expect(patch?.patch?.payload).toBeUndefined();
|
||||
expect(patch?.patch?.delivery).toEqual({
|
||||
mode: "webhook",
|
||||
to: "https://example.invalid/cron",
|
||||
bestEffort: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects webhook delivery mixed with announce delivery on cron edit", async () => {
|
||||
await expectCronCommandExit([
|
||||
"cron",
|
||||
"edit",
|
||||
"job-1",
|
||||
"--webhook",
|
||||
"https://example.invalid/cron",
|
||||
"--announce",
|
||||
]);
|
||||
|
||||
expectRuntimeErrorContaining("Choose at most one of --announce, --no-deliver, or --webhook");
|
||||
});
|
||||
|
||||
it("updates delivery account without requiring --message on cron edit", async () => {
|
||||
const patch = await runCronEditAndGetPatch(["--account", " coordinator "]);
|
||||
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
||||
|
||||
@@ -10,7 +10,7 @@ import { theme } from "../../terminal/theme.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { parsePositiveIntOrUndefined } from "../program/helpers.js";
|
||||
import { resolveCronCreateSchedule } from "./schedule-options.js";
|
||||
import { resolveCronCreateScheduleFromArgs } from "./schedule-options.js";
|
||||
import {
|
||||
getCronChannelOptions,
|
||||
coerceCronDeliveryPreviews,
|
||||
@@ -78,7 +78,9 @@ export function registerCronAddCommand(cron: Command) {
|
||||
.command("add")
|
||||
.alias("create")
|
||||
.description("Add a cron job")
|
||||
.requiredOption("--name <name>", "Job name")
|
||||
.argument("[scheduleOrName]", "Schedule string, or job name when using --at/--every/--cron")
|
||||
.argument("[message]", "Agent message when using a positional schedule")
|
||||
.option("--name <name>", "Job name")
|
||||
.option("--description <text>", "Optional description")
|
||||
.option("--disabled", "Create job disabled", false)
|
||||
.option("--delete-after-run", "Delete one-shot job after it succeeds", false)
|
||||
@@ -113,6 +115,7 @@ export function registerCronAddCommand(cron: Command) {
|
||||
.option("--announce", "Fallback-deliver final text to a chat", false)
|
||||
.option("--deliver", "Deprecated (use --announce). Fallback-delivers final text to a chat.")
|
||||
.option("--no-deliver", "Disable runner fallback delivery")
|
||||
.option("--webhook <url>", "POST the finished payload to a webhook URL")
|
||||
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`, "last")
|
||||
.option(
|
||||
"--to <dest>",
|
||||
@@ -122,161 +125,206 @@ export function registerCronAddCommand(cron: Command) {
|
||||
.option("--account <id>", "Channel account id for delivery (multi-account setups)")
|
||||
.option("--best-effort-deliver", "Do not fail the job if delivery fails", false)
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts: GatewayRpcOpts & Record<string, unknown>, cmd?: Command) => {
|
||||
try {
|
||||
const schedule = resolveCronCreateSchedule({
|
||||
at: opts.at,
|
||||
cron: opts.cron,
|
||||
every: opts.every,
|
||||
exact: opts.exact,
|
||||
stagger: opts.stagger,
|
||||
tz: opts.tz,
|
||||
});
|
||||
.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";
|
||||
const positionalSchedule = hasScheduleFlag ? undefined : nameArg;
|
||||
const schedule = resolveCronCreateScheduleFromArgs({
|
||||
at: opts.at,
|
||||
cron: opts.cron,
|
||||
every: opts.every,
|
||||
exact: opts.exact,
|
||||
positionalSchedule,
|
||||
stagger: opts.stagger,
|
||||
tz: opts.tz,
|
||||
});
|
||||
|
||||
const wakeMode = normalizeOptionalString(opts.wake) ?? "now";
|
||||
if (wakeMode !== "now" && wakeMode !== "next-heartbeat") {
|
||||
throw new Error("--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 deliveryFlagCount = [hasAnnounce, hasNoDeliver].filter(Boolean).length;
|
||||
if (deliveryFlagCount > 1) {
|
||||
throw new Error("Choose at most one of --announce or --no-deliver");
|
||||
}
|
||||
|
||||
const payload = (() => {
|
||||
const systemEvent = normalizeOptionalString(opts.systemEvent) ?? "";
|
||||
const message = normalizeOptionalString(opts.message) ?? "";
|
||||
const chosen = [Boolean(systemEvent), Boolean(message)].filter(Boolean).length;
|
||||
if (chosen !== 1) {
|
||||
throw new Error("Choose exactly one payload: --system-event or --message");
|
||||
const wakeMode = normalizeOptionalString(opts.wake) ?? "now";
|
||||
if (wakeMode !== "now" && wakeMode !== "next-heartbeat") {
|
||||
throw new Error("--wake must be now or next-heartbeat");
|
||||
}
|
||||
if (systemEvent) {
|
||||
return { kind: "systemEvent" as const, text: systemEvent };
|
||||
|
||||
const rawAgentId = normalizeOptionalString(opts.agent);
|
||||
const agentId = rawAgentId ? sanitizeAgentId(rawAgentId) : undefined;
|
||||
|
||||
const optionSource =
|
||||
typeof cmd?.getOptionValueSource === "function"
|
||||
? (name: string) => cmd.getOptionValueSource(name)
|
||||
: () => undefined;
|
||||
|
||||
const hasAnnounce = Boolean(opts.announce) || opts.deliver === true;
|
||||
const hasNoDeliver = opts.deliver === false;
|
||||
const webhookUrl = normalizeOptionalString(opts.webhook);
|
||||
const hasWebhook = typeof opts.webhook === "string";
|
||||
const deliveryFlagCount = [hasAnnounce, hasNoDeliver, hasWebhook].filter(
|
||||
Boolean,
|
||||
).length;
|
||||
if (deliveryFlagCount > 1) {
|
||||
throw new Error("Choose at most one of --announce, --no-deliver, or --webhook");
|
||||
}
|
||||
const timeoutSeconds = parsePositiveIntOrUndefined(opts.timeoutSeconds);
|
||||
return {
|
||||
kind: "agentTurn" as const,
|
||||
message,
|
||||
model: normalizeOptionalString(opts.model),
|
||||
thinking: normalizeOptionalString(opts.thinking),
|
||||
timeoutSeconds:
|
||||
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
|
||||
lightContext: opts.lightContext === true ? true : undefined,
|
||||
toolsAllow: parseCronToolsAllow(opts.tools),
|
||||
|
||||
const payload = (() => {
|
||||
const systemEvent = normalizeOptionalString(opts.systemEvent) ?? "";
|
||||
const optionMessage = normalizeOptionalString(opts.message);
|
||||
const positionalMessage = normalizeOptionalString(messageArg);
|
||||
if (optionMessage && positionalMessage && optionMessage !== positionalMessage) {
|
||||
throw new Error(
|
||||
"Pass the cron job message either positionally or with --message, not both.",
|
||||
);
|
||||
}
|
||||
const message = optionMessage ?? positionalMessage ?? "";
|
||||
const chosen = [Boolean(systemEvent), Boolean(message)].filter(Boolean).length;
|
||||
if (chosen !== 1) {
|
||||
throw new Error("Choose exactly one payload: --system-event or --message");
|
||||
}
|
||||
if (systemEvent) {
|
||||
return { kind: "systemEvent" as const, text: systemEvent };
|
||||
}
|
||||
const timeoutSeconds = parsePositiveIntOrUndefined(opts.timeoutSeconds);
|
||||
return {
|
||||
kind: "agentTurn" as const,
|
||||
message,
|
||||
model: normalizeOptionalString(opts.model),
|
||||
thinking: normalizeOptionalString(opts.thinking),
|
||||
timeoutSeconds:
|
||||
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
|
||||
lightContext: opts.lightContext === true ? true : undefined,
|
||||
toolsAllow: parseCronToolsAllow(opts.tools),
|
||||
};
|
||||
})();
|
||||
|
||||
const sessionSource = optionSource("session");
|
||||
const sessionTargetRaw = normalizeOptionalString(opts.session) ?? "";
|
||||
const inferredSessionTarget = payload.kind === "agentTurn" ? "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 Error("--session must be main, isolated, current, or session:<id>");
|
||||
}
|
||||
|
||||
if (opts.deleteAfterRun && opts.keepAfterRun) {
|
||||
throw new Error("Choose --delete-after-run or --keep-after-run, not both");
|
||||
}
|
||||
|
||||
if (sessionTarget === "main" && payload.kind !== "systemEvent") {
|
||||
throw new Error("Main jobs require --system-event (systemEvent).");
|
||||
}
|
||||
if (isIsolatedLikeSessionTarget && payload.kind !== "agentTurn") {
|
||||
throw new Error(
|
||||
"Isolated/current/custom-session jobs require --message (agentTurn).",
|
||||
);
|
||||
}
|
||||
if (
|
||||
(opts.announce || typeof opts.deliver === "boolean") &&
|
||||
(!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")
|
||||
) {
|
||||
throw new Error(
|
||||
"--announce/--no-deliver require a non-main agentTurn session target.",
|
||||
);
|
||||
}
|
||||
|
||||
const accountId = normalizeOptionalString(opts.account);
|
||||
const threadId = parseCronThreadIdOption(opts.threadId);
|
||||
const hasThreadId = typeof threadId === "number";
|
||||
const hasChatDeliveryTarget =
|
||||
optionSource("channel") === "cli" ||
|
||||
typeof opts.to === "string" ||
|
||||
Boolean(accountId) ||
|
||||
hasThreadId;
|
||||
|
||||
if (
|
||||
(accountId || hasThreadId) &&
|
||||
(!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")
|
||||
) {
|
||||
throw new Error(
|
||||
"--account and --thread-id require a non-main agentTurn job with delivery.",
|
||||
);
|
||||
}
|
||||
if (hasWebhook && hasChatDeliveryTarget) {
|
||||
throw new Error("--webhook cannot be combined with chat delivery options.");
|
||||
}
|
||||
|
||||
const deliveryMode = hasWebhook
|
||||
? "webhook"
|
||||
: isIsolatedLikeSessionTarget && payload.kind === "agentTurn"
|
||||
? hasAnnounce
|
||||
? "announce"
|
||||
: hasNoDeliver
|
||||
? "none"
|
||||
: "announce"
|
||||
: undefined;
|
||||
|
||||
const optionName = normalizeOptionalString(opts.name);
|
||||
const positionalName = hasScheduleFlag ? normalizeOptionalString(nameArg) : undefined;
|
||||
if (optionName && positionalName && optionName !== positionalName) {
|
||||
throw new Error(
|
||||
"Pass the cron job name either positionally or with --name, not both.",
|
||||
);
|
||||
}
|
||||
const name = optionName ?? positionalName ?? "";
|
||||
if (!name) {
|
||||
throw new Error("Cron job name is required. Pass a name or --name <name>.");
|
||||
}
|
||||
|
||||
const description = normalizeOptionalString(opts.description);
|
||||
|
||||
const sessionKey = normalizeOptionalString(opts.sessionKey);
|
||||
|
||||
if (payload.kind === "agentTurn" && !agentId) {
|
||||
defaultRuntime.error(
|
||||
theme.warn(
|
||||
"No --agent specified; the job will run with the configured default agent. " +
|
||||
"Specify --agent to choose a specific agent.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const params = {
|
||||
name,
|
||||
description,
|
||||
enabled: !opts.disabled,
|
||||
deleteAfterRun: opts.deleteAfterRun ? true : opts.keepAfterRun ? false : undefined,
|
||||
agentId,
|
||||
sessionKey,
|
||||
schedule,
|
||||
sessionTarget,
|
||||
wakeMode,
|
||||
payload,
|
||||
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 optionSource =
|
||||
typeof cmd?.getOptionValueSource === "function"
|
||||
? (name: string) => cmd.getOptionValueSource(name)
|
||||
: () => undefined;
|
||||
const sessionSource = optionSource("session");
|
||||
const sessionTargetRaw = normalizeOptionalString(opts.session) ?? "";
|
||||
const inferredSessionTarget = payload.kind === "agentTurn" ? "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 Error("--session must be main, isolated, current, or session:<id>");
|
||||
const res = await callGatewayFromCli("cron.add", opts, params);
|
||||
printCronJson(res);
|
||||
await warnIfCronSchedulerDisabled(opts);
|
||||
} catch (err) {
|
||||
handleCronCliError(err);
|
||||
}
|
||||
|
||||
if (opts.deleteAfterRun && opts.keepAfterRun) {
|
||||
throw new Error("Choose --delete-after-run or --keep-after-run, not both");
|
||||
}
|
||||
|
||||
if (sessionTarget === "main" && payload.kind !== "systemEvent") {
|
||||
throw new Error("Main jobs require --system-event (systemEvent).");
|
||||
}
|
||||
if (isIsolatedLikeSessionTarget && payload.kind !== "agentTurn") {
|
||||
throw new Error("Isolated/current/custom-session jobs require --message (agentTurn).");
|
||||
}
|
||||
if (
|
||||
(opts.announce || typeof opts.deliver === "boolean") &&
|
||||
(!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")
|
||||
) {
|
||||
throw new Error("--announce/--no-deliver require a non-main agentTurn session target.");
|
||||
}
|
||||
|
||||
const accountId = normalizeOptionalString(opts.account);
|
||||
const threadId = parseCronThreadIdOption(opts.threadId);
|
||||
const hasThreadId = typeof threadId === "number";
|
||||
|
||||
if (
|
||||
(accountId || hasThreadId) &&
|
||||
(!isIsolatedLikeSessionTarget || payload.kind !== "agentTurn")
|
||||
) {
|
||||
throw new Error(
|
||||
"--account and --thread-id require a non-main agentTurn job with delivery.",
|
||||
);
|
||||
}
|
||||
|
||||
const deliveryMode =
|
||||
isIsolatedLikeSessionTarget && payload.kind === "agentTurn"
|
||||
? hasAnnounce
|
||||
? "announce"
|
||||
: hasNoDeliver
|
||||
? "none"
|
||||
: "announce"
|
||||
: undefined;
|
||||
|
||||
const name = normalizeOptionalString(opts.name) ?? "";
|
||||
if (!name) {
|
||||
throw new Error("Cron job name is required. Pass --name <name>.");
|
||||
}
|
||||
|
||||
const description = normalizeOptionalString(opts.description);
|
||||
|
||||
const sessionKey = normalizeOptionalString(opts.sessionKey);
|
||||
|
||||
if (payload.kind === "agentTurn" && !agentId) {
|
||||
defaultRuntime.error(
|
||||
theme.warn(
|
||||
"No --agent specified; the job will run with the configured default agent. " +
|
||||
"Specify --agent to choose a specific agent.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const params = {
|
||||
name,
|
||||
description,
|
||||
enabled: !opts.disabled,
|
||||
deleteAfterRun: opts.deleteAfterRun ? true : opts.keepAfterRun ? false : undefined,
|
||||
agentId,
|
||||
sessionKey,
|
||||
schedule,
|
||||
sessionTarget,
|
||||
wakeMode,
|
||||
payload,
|
||||
delivery: deliveryMode
|
||||
? {
|
||||
mode: deliveryMode,
|
||||
channel: normalizeOptionalString(opts.channel),
|
||||
to: normalizeOptionalString(opts.to),
|
||||
threadId,
|
||||
accountId,
|
||||
bestEffort: opts.bestEffortDeliver ? true : undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const res = await callGatewayFromCli("cron.add", opts, params);
|
||||
printCronJson(res);
|
||||
await warnIfCronSchedulerDisabled(opts);
|
||||
} catch (err) {
|
||||
handleCronCliError(err);
|
||||
}
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ export function registerCronEditCommand(cron: Command) {
|
||||
.option("--announce", "Fallback-deliver final text to a chat")
|
||||
.option("--deliver", "Deprecated (use --announce). Fallback-delivers final text to a chat.")
|
||||
.option("--no-deliver", "Disable runner fallback delivery")
|
||||
.option("--webhook <url>", "POST the finished payload to a webhook URL")
|
||||
.option("--channel <channel>", `Delivery channel (${getCronChannelOptions()})`)
|
||||
.option(
|
||||
"--to <dest>",
|
||||
@@ -155,8 +156,14 @@ export function registerCronEditCommand(cron: Command) {
|
||||
"Isolated jobs cannot use --system-event; use --message or --session main.",
|
||||
);
|
||||
}
|
||||
if (opts.announce && typeof opts.deliver === "boolean") {
|
||||
throw new Error("Choose --announce or --no-deliver (not multiple).");
|
||||
const hasWebhookDelivery = typeof opts.webhook === "string";
|
||||
const deliveryModeFlagCount = [
|
||||
Boolean(opts.announce),
|
||||
typeof opts.deliver === "boolean",
|
||||
hasWebhookDelivery,
|
||||
].filter(Boolean).length;
|
||||
if (deliveryModeFlagCount > 1) {
|
||||
throw new Error("Choose at most one of --announce, --no-deliver, or --webhook.");
|
||||
}
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (typeof opts.name === "string") {
|
||||
@@ -248,13 +255,17 @@ export function registerCronEditCommand(cron: Command) {
|
||||
if (rawTimeoutSeconds !== undefined && !hasTimeoutSeconds) {
|
||||
throw new Error("Invalid --timeout-seconds (must be a positive integer).");
|
||||
}
|
||||
const hasDeliveryModeFlag = opts.announce || typeof opts.deliver === "boolean";
|
||||
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;
|
||||
const hasDeliveryAccount = typeof opts.account === "string";
|
||||
const hasBestEffort = typeof opts.bestEffortDeliver === "boolean";
|
||||
if (hasWebhookDelivery && (hasDeliveryTarget || hasDeliveryAccount)) {
|
||||
throw new Error("--webhook cannot be combined with chat delivery options.");
|
||||
}
|
||||
const hasAgentTurnPayloadField =
|
||||
typeof opts.message === "string" ||
|
||||
Boolean(model) ||
|
||||
@@ -266,10 +277,11 @@ export function registerCronEditCommand(cron: Command) {
|
||||
opts.clearTools;
|
||||
const hasAgentTurnPatch =
|
||||
hasAgentTurnPayloadField ||
|
||||
hasDeliveryModeFlag ||
|
||||
Boolean(opts.announce) ||
|
||||
opts.deliver === true ||
|
||||
hasDeliveryTarget ||
|
||||
hasDeliveryAccount ||
|
||||
hasBestEffort;
|
||||
(hasBestEffort && !hasWebhookDelivery);
|
||||
if (hasSystemEventPatch && hasAgentTurnPatch) {
|
||||
throw new Error("Choose at most one payload change");
|
||||
}
|
||||
@@ -301,7 +313,11 @@ export function registerCronEditCommand(cron: Command) {
|
||||
if (hasDeliveryModeFlag || hasDeliveryTarget || hasDeliveryAccount || hasBestEffort) {
|
||||
const delivery: Record<string, unknown> = {};
|
||||
if (hasDeliveryModeFlag) {
|
||||
delivery.mode = opts.announce || opts.deliver === true ? "announce" : "none";
|
||||
delivery.mode = hasWebhookDelivery
|
||||
? "webhook"
|
||||
: opts.announce || opts.deliver === true
|
||||
? "announce"
|
||||
: "none";
|
||||
} else if (
|
||||
opts.bestEffortDeliver === true ||
|
||||
(hasAgentTurnPayloadField && hasBestEffort)
|
||||
@@ -313,7 +329,10 @@ export function registerCronEditCommand(cron: Command) {
|
||||
const channel = opts.channel.trim();
|
||||
delivery.channel = channel ? channel : undefined;
|
||||
}
|
||||
if (typeof opts.to === "string") {
|
||||
if (hasWebhookDelivery) {
|
||||
const webhook = normalizeOptionalString(opts.webhook) ?? "";
|
||||
delivery.to = webhook ? webhook : undefined;
|
||||
} else if (typeof opts.to === "string") {
|
||||
const to = opts.to.trim();
|
||||
delivery.to = to ? to : undefined;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ type ScheduleOptionInput = {
|
||||
tz?: unknown;
|
||||
};
|
||||
|
||||
type PositionalScheduleInput = {
|
||||
positionalSchedule?: unknown;
|
||||
};
|
||||
|
||||
type NormalizedScheduleOptions = {
|
||||
at: string;
|
||||
cronExpr: string;
|
||||
@@ -37,6 +41,30 @@ export function resolveCronCreateSchedule(options: ScheduleOptionInput): CronSch
|
||||
return schedule;
|
||||
}
|
||||
|
||||
export function resolveCronCreateScheduleFromArgs(
|
||||
options: ScheduleOptionInput & PositionalScheduleInput,
|
||||
): CronSchedule {
|
||||
const positionalSchedule = normalizeOptionalString(options.positionalSchedule);
|
||||
if (!positionalSchedule) {
|
||||
return resolveCronCreateSchedule(options);
|
||||
}
|
||||
const normalized = normalizeScheduleOptions(options);
|
||||
if (countChosenSchedules(normalized) > 0) {
|
||||
throw new Error("Choose a positional schedule or one of --at, --every, or --cron.");
|
||||
}
|
||||
const every = parseEverySchedule(positionalSchedule);
|
||||
return resolveCronCreateSchedule({
|
||||
...options,
|
||||
at: every
|
||||
? undefined
|
||||
: looksLikeCronExpression(positionalSchedule)
|
||||
? undefined
|
||||
: positionalSchedule,
|
||||
cron: looksLikeCronExpression(positionalSchedule) ? positionalSchedule : undefined,
|
||||
every,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveCronEditScheduleRequest(
|
||||
options: ScheduleOptionInput,
|
||||
): CronEditScheduleRequest {
|
||||
@@ -94,6 +122,16 @@ function countChosenSchedules(options: NormalizedScheduleOptions): number {
|
||||
.length;
|
||||
}
|
||||
|
||||
function parseEverySchedule(value: string): string | undefined {
|
||||
const match = /^every\s+(.+)$/iu.exec(value.trim());
|
||||
return match?.[1]?.trim() || undefined;
|
||||
}
|
||||
|
||||
function looksLikeCronExpression(value: string): boolean {
|
||||
const parts = value.trim().split(/\s+/u);
|
||||
return parts.length === 5 || parts.length === 6;
|
||||
}
|
||||
|
||||
function resolveDirectSchedule(options: NormalizedScheduleOptions): CronSchedule | undefined {
|
||||
if (options.tz && options.every) {
|
||||
throw new Error("--tz is only valid with --cron or offset-less --at");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { coerceFiniteScheduleNumber } from "./schedule-number.js";
|
||||
import { normalizeCronStaggerMs } from "./stagger.js";
|
||||
|
||||
function readString(record: Record<string, unknown>, key: string): string | undefined {
|
||||
return normalizeOptionalString(record[key]);
|
||||
@@ -9,6 +10,10 @@ function readNumber(record: Record<string, unknown>, key: string): number | unde
|
||||
return coerceFiniteScheduleNumber(record[key]);
|
||||
}
|
||||
|
||||
function readStaggerMs(record: Record<string, unknown>): number | undefined {
|
||||
return normalizeCronStaggerMs(record.staggerMs);
|
||||
}
|
||||
|
||||
function schedulePayloadFromRecord(
|
||||
schedule: Record<string, unknown>,
|
||||
):
|
||||
@@ -23,7 +28,7 @@ function schedulePayloadFromRecord(
|
||||
const everyMs = readNumber(schedule, "everyMs");
|
||||
const anchorMs = readNumber(schedule, "anchorMs");
|
||||
const tz = readString(schedule, "tz");
|
||||
const staggerMs = readNumber(schedule, "staggerMs");
|
||||
const staggerMs = readStaggerMs(schedule);
|
||||
const kind =
|
||||
rawKind === "at" || rawKind === "every" || rawKind === "cron"
|
||||
? rawKind
|
||||
|
||||
@@ -76,6 +76,65 @@ describe("applyJobPatch", () => {
|
||||
expect(job.delivery).toEqual({ mode: "webhook", to: "https://example.invalid/cron" });
|
||||
});
|
||||
|
||||
it("clears chat delivery fields when switching delivery to webhook", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-webhook-switch", {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
to: "-100123",
|
||||
threadId: 42,
|
||||
accountId: "coordinator",
|
||||
});
|
||||
|
||||
applyJobPatch(job, {
|
||||
delivery: { mode: "webhook", to: "https://example.invalid/cron" },
|
||||
});
|
||||
|
||||
expect(job.delivery).toEqual({
|
||||
mode: "webhook",
|
||||
to: "https://example.invalid/cron",
|
||||
bestEffort: undefined,
|
||||
failureDestination: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears webhook delivery targets when switching delivery to announce", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-announce-switch", {
|
||||
mode: "webhook",
|
||||
to: "https://example.invalid/cron",
|
||||
});
|
||||
|
||||
applyJobPatch(job, {
|
||||
delivery: { mode: "announce" },
|
||||
});
|
||||
|
||||
expect(job.delivery).toEqual({
|
||||
mode: "announce",
|
||||
channel: undefined,
|
||||
to: undefined,
|
||||
threadId: undefined,
|
||||
accountId: undefined,
|
||||
bestEffort: undefined,
|
||||
failureDestination: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit chat targets when switching webhook delivery to announce", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-announce-switch-target", {
|
||||
mode: "webhook",
|
||||
to: "https://example.invalid/cron",
|
||||
});
|
||||
|
||||
applyJobPatch(job, {
|
||||
delivery: { mode: "announce", channel: "telegram", to: "-100123" },
|
||||
});
|
||||
|
||||
expect(job.delivery).toMatchObject({
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
to: "-100123",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies explicit delivery patches", () => {
|
||||
const job = createIsolatedAgentTurnJob("job-2", {
|
||||
mode: "announce",
|
||||
|
||||
@@ -889,7 +889,16 @@ function mergeCronDelivery(
|
||||
};
|
||||
|
||||
if (typeof patch.mode === "string") {
|
||||
const previousMode = next.mode;
|
||||
next.mode = (patch.mode as string) === "deliver" ? "announce" : patch.mode;
|
||||
if (previousMode !== next.mode && (previousMode === "webhook" || next.mode === "webhook")) {
|
||||
next.to = undefined;
|
||||
}
|
||||
if (next.mode === "webhook") {
|
||||
next.channel = undefined;
|
||||
next.threadId = undefined;
|
||||
next.accountId = undefined;
|
||||
}
|
||||
}
|
||||
if ("channel" in patch) {
|
||||
next.channel = normalizeOptionalString(patch.channel);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
dependencyGuardCommentHeadSha,
|
||||
dependencyFieldChanges,
|
||||
@@ -71,13 +70,7 @@ describe("dependency guard script", () => {
|
||||
scripts: { test: "new" },
|
||||
},
|
||||
),
|
||||
).toEqual([
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
"overrides",
|
||||
"packageManager",
|
||||
"pnpm",
|
||||
]);
|
||||
).toEqual(["optionalDependencies", "peerDependencies", "overrides", "packageManager", "pnpm"]);
|
||||
});
|
||||
|
||||
it("accepts only security-member override commands for the current head sha", () => {
|
||||
|
||||
@@ -146,9 +146,7 @@ describe("dependency guard workflow", () => {
|
||||
expect(codeowners).toContain(
|
||||
"/test/scripts/dependency-guard-workflow.test.ts @openclaw/openclaw-secops",
|
||||
);
|
||||
expect(codeowners).toContain(
|
||||
"/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops",
|
||||
);
|
||||
expect(codeowners).toContain("/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops");
|
||||
expect(codeowners).toContain("/package-lock.json @openclaw/openclaw-secops");
|
||||
expect(codeowners).toContain("/npm-shrinkwrap.json @openclaw/openclaw-secops");
|
||||
expect(codeowners).toContain("/extensions/*/package-lock.json @openclaw/openclaw-secops");
|
||||
|
||||
Reference in New Issue
Block a user