mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 02:28:27 +00:00
feat(cron): default isolated jobs to announce delivery and enhance scheduling options
- Updated isolated cron jobs to default to `announce` delivery mode, improving user experience. - Enhanced scheduling options to accept ISO 8601 timestamps for `schedule.at`, while still supporting epoch milliseconds. - Refined documentation to clarify delivery modes and scheduling formats. - Adjusted related CLI commands and UI components to reflect these changes, ensuring consistency across the platform. - Improved handling of legacy delivery fields for backward compatibility. This update streamlines the configuration of isolated jobs, making it easier for users to manage job outputs and schedules.
This commit is contained in:
committed by
Peter Steinberger
parent
511c656cbc
commit
0bb0dfc9bc
@@ -181,12 +181,15 @@ JOB SCHEMA (for add action):
|
||||
|
||||
SCHEDULE TYPES (schedule.kind):
|
||||
- "at": One-shot at absolute time
|
||||
{ "kind": "at", "atMs": <unix-ms-timestamp> }
|
||||
{ "kind": "at", "at": "<ISO-8601 timestamp>" } // preferred
|
||||
{ "kind": "at", "atMs": <unix-ms-timestamp> } // also accepted
|
||||
- "every": Recurring interval
|
||||
{ "kind": "every", "everyMs": <interval-ms>, "anchorMs": <optional-start-ms> }
|
||||
- "cron": Cron expression
|
||||
{ "kind": "cron", "expr": "<cron-expression>", "tz": "<optional-timezone>" }
|
||||
|
||||
ISO timestamps without an explicit timezone are treated as UTC.
|
||||
|
||||
PAYLOAD TYPES (payload.kind):
|
||||
- "systemEvent": Injects text as system event into session
|
||||
{ "kind": "systemEvent", "text": "<message>" }
|
||||
@@ -195,6 +198,7 @@ PAYLOAD TYPES (payload.kind):
|
||||
|
||||
DELIVERY (isolated-only, top-level):
|
||||
{ "mode": "none|announce|deliver", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
|
||||
- Default for isolated agentTurn jobs (when delivery omitted): "announce"
|
||||
|
||||
LEGACY DELIVERY (payload, only when delivery is omitted):
|
||||
{ "deliver": <optional-bool>, "channel": "<optional>", "to": "<optional>", "bestEffortDeliver": <optional-bool> }
|
||||
|
||||
@@ -65,6 +65,36 @@ describe("cron cli", () => {
|
||||
expect(params?.payload?.thinking).toBe("low");
|
||||
});
|
||||
|
||||
it("defaults isolated cron add to announce delivery", async () => {
|
||||
callGatewayFromCli.mockClear();
|
||||
|
||||
const { registerCronCli } = await import("./cron-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCronCli(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"cron",
|
||||
"add",
|
||||
"--name",
|
||||
"Daily",
|
||||
"--cron",
|
||||
"* * * * *",
|
||||
"--session",
|
||||
"isolated",
|
||||
"--message",
|
||||
"hello",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const addCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.add");
|
||||
const params = addCall?.[2] as { delivery?: { mode?: string } };
|
||||
|
||||
expect(params?.delivery?.mode).toBe("announce");
|
||||
});
|
||||
|
||||
it("sends agent id on cron add", async () => {
|
||||
callGatewayFromCli.mockClear();
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ export function registerCronAddCommand(cron: Command) {
|
||||
)
|
||||
.option("--post-max-chars <n>", "Max chars when --post-mode=full (default 8000)", "8000")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts: GatewayRpcOpts & Record<string, unknown>) => {
|
||||
.action(async (opts: GatewayRpcOpts & Record<string, unknown>, cmd?: Command) => {
|
||||
try {
|
||||
const schedule = (() => {
|
||||
const at = typeof opts.at === "string" ? opts.at : "";
|
||||
@@ -148,6 +148,14 @@ export function registerCronAddCommand(cron: Command) {
|
||||
? sanitizeAgentId(opts.agent.trim())
|
||||
: undefined;
|
||||
|
||||
const hasAnnounce = Boolean(opts.announce);
|
||||
const hasDeliver = opts.deliver === true;
|
||||
const hasNoDeliver = opts.deliver === false;
|
||||
const deliveryFlagCount = [hasAnnounce, hasDeliver, hasNoDeliver].filter(Boolean).length;
|
||||
if (deliveryFlagCount > 1) {
|
||||
throw new Error("Choose at most one of --announce, --deliver, or --no-deliver");
|
||||
}
|
||||
|
||||
const payload = (() => {
|
||||
const systemEvent = typeof opts.systemEvent === "string" ? opts.systemEvent.trim() : "";
|
||||
const message = typeof opts.message === "string" ? opts.message.trim() : "";
|
||||
@@ -159,15 +167,6 @@ export function registerCronAddCommand(cron: Command) {
|
||||
return { kind: "systemEvent" as const, text: systemEvent };
|
||||
}
|
||||
const timeoutSeconds = parsePositiveIntOrUndefined(opts.timeoutSeconds);
|
||||
const hasAnnounce = Boolean(opts.announce);
|
||||
const hasDeliver = opts.deliver === true;
|
||||
const hasNoDeliver = opts.deliver === false;
|
||||
const deliveryFlagCount = [hasAnnounce, hasDeliver, hasNoDeliver].filter(
|
||||
Boolean,
|
||||
).length;
|
||||
if (deliveryFlagCount > 1) {
|
||||
throw new Error("Choose at most one of --announce, --deliver, or --no-deliver");
|
||||
}
|
||||
return {
|
||||
kind: "agentTurn" as const,
|
||||
message,
|
||||
@@ -179,15 +178,6 @@ export function registerCronAddCommand(cron: Command) {
|
||||
: undefined,
|
||||
timeoutSeconds:
|
||||
timeoutSeconds && Number.isFinite(timeoutSeconds) ? timeoutSeconds : undefined,
|
||||
channel:
|
||||
typeof opts.channel === "string" && opts.channel.trim()
|
||||
? opts.channel.trim()
|
||||
: "last",
|
||||
to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined,
|
||||
bestEffortDeliver:
|
||||
!hasAnnounce && !hasDeliver && !hasNoDeliver && opts.bestEffortDeliver
|
||||
? true
|
||||
: undefined,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -204,8 +194,30 @@ export function registerCronAddCommand(cron: Command) {
|
||||
throw new Error("--announce/--deliver/--no-deliver require --session isolated.");
|
||||
}
|
||||
|
||||
const optionSource =
|
||||
typeof cmd?.getOptionValueSource === "function"
|
||||
? (name: string) => cmd.getOptionValueSource(name)
|
||||
: () => undefined;
|
||||
const hasLegacyPostConfig =
|
||||
optionSource("postPrefix") === "cli" ||
|
||||
optionSource("postMode") === "cli" ||
|
||||
optionSource("postMaxChars") === "cli";
|
||||
|
||||
if (
|
||||
hasLegacyPostConfig &&
|
||||
(sessionTarget !== "isolated" || payload.kind !== "agentTurn")
|
||||
) {
|
||||
throw new Error(
|
||||
"--post-prefix/--post-mode/--post-max-chars require --session isolated.",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasLegacyPostConfig && (hasAnnounce || hasDeliver || hasNoDeliver)) {
|
||||
throw new Error("Choose legacy main-summary options or a delivery mode (not both).");
|
||||
}
|
||||
|
||||
const isolation =
|
||||
sessionTarget === "isolated"
|
||||
sessionTarget === "isolated" && hasLegacyPostConfig
|
||||
? {
|
||||
postToMainPrefix:
|
||||
typeof opts.postPrefix === "string" && opts.postPrefix.trim()
|
||||
@@ -216,12 +228,25 @@ export function registerCronAddCommand(cron: Command) {
|
||||
? opts.postMode
|
||||
: undefined,
|
||||
postToMainMaxChars:
|
||||
typeof opts.postMaxChars === "string" && /^\d+$/.test(opts.postMaxChars)
|
||||
opts.postMode === "full" &&
|
||||
typeof opts.postMaxChars === "string" &&
|
||||
/^\d+$/.test(opts.postMaxChars)
|
||||
? Number.parseInt(opts.postMaxChars, 10)
|
||||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const deliveryMode =
|
||||
sessionTarget === "isolated" && payload.kind === "agentTurn" && !hasLegacyPostConfig
|
||||
? hasAnnounce
|
||||
? "announce"
|
||||
: hasDeliver
|
||||
? "deliver"
|
||||
: hasNoDeliver
|
||||
? "none"
|
||||
: "announce"
|
||||
: undefined;
|
||||
|
||||
const nameRaw = typeof opts.name === "string" ? opts.name : "";
|
||||
const name = nameRaw.trim();
|
||||
if (!name) {
|
||||
@@ -243,20 +268,18 @@ export function registerCronAddCommand(cron: Command) {
|
||||
sessionTarget,
|
||||
wakeMode,
|
||||
payload,
|
||||
delivery:
|
||||
payload.kind === "agentTurn" &&
|
||||
sessionTarget === "isolated" &&
|
||||
(opts.announce || typeof opts.deliver === "boolean")
|
||||
? {
|
||||
mode: opts.announce ? "announce" : opts.deliver === true ? "deliver" : "none",
|
||||
channel:
|
||||
typeof opts.channel === "string" && opts.channel.trim()
|
||||
? opts.channel.trim()
|
||||
: "last",
|
||||
to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined,
|
||||
bestEffort: opts.bestEffortDeliver ? true : undefined,
|
||||
}
|
||||
: undefined,
|
||||
delivery: deliveryMode
|
||||
? {
|
||||
mode: deliveryMode,
|
||||
channel:
|
||||
typeof opts.channel === "string" && opts.channel.trim()
|
||||
? opts.channel.trim()
|
||||
: undefined,
|
||||
to: typeof opts.to === "string" && opts.to.trim() ? opts.to.trim() : undefined,
|
||||
bestEffort:
|
||||
deliveryMode === "deliver" && opts.bestEffortDeliver ? true : undefined,
|
||||
}
|
||||
: undefined,
|
||||
isolation,
|
||||
};
|
||||
|
||||
|
||||
@@ -134,4 +134,50 @@ describe("normalizeCronJobCreate", () => {
|
||||
expect(delivery.channel).toBe("telegram");
|
||||
expect(delivery.to).toBe("7200373102");
|
||||
});
|
||||
|
||||
it("defaults isolated agentTurn delivery to announce", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
name: "default-announce",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.mode).toBe("announce");
|
||||
});
|
||||
|
||||
it("does not override explicit legacy delivery fields", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
name: "legacy deliver",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
deliver: true,
|
||||
to: "7200373102",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
|
||||
expect(normalized.delivery).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not override legacy isolation settings", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
name: "legacy isolation",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
},
|
||||
isolation: { postToMainPrefix: "Cron" },
|
||||
}) as unknown as Record<string, unknown>;
|
||||
|
||||
expect(normalized.delivery).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +85,19 @@ function coerceDelivery(delivery: UnknownRecord) {
|
||||
return next;
|
||||
}
|
||||
|
||||
function hasLegacyDeliveryHints(payload: UnknownRecord) {
|
||||
if (typeof payload.deliver === "boolean") {
|
||||
return true;
|
||||
}
|
||||
if (typeof payload.bestEffortDeliver === "boolean") {
|
||||
return true;
|
||||
}
|
||||
if (typeof payload.to === "string" && payload.to.trim()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function unwrapJob(raw: UnknownRecord) {
|
||||
if (isRecord(raw.data)) {
|
||||
return raw.data;
|
||||
@@ -159,6 +172,21 @@ export function normalizeCronJobInput(
|
||||
next.sessionTarget = "isolated";
|
||||
}
|
||||
}
|
||||
const hasDelivery = "delivery" in next && next.delivery !== undefined;
|
||||
const payload = isRecord(next.payload) ? next.payload : null;
|
||||
const payloadKind = payload && typeof payload.kind === "string" ? payload.kind : "";
|
||||
const sessionTarget = typeof next.sessionTarget === "string" ? next.sessionTarget : "";
|
||||
const hasLegacyIsolation = isRecord(next.isolation);
|
||||
const hasLegacyDelivery = payload ? hasLegacyDeliveryHints(payload) : false;
|
||||
if (
|
||||
!hasDelivery &&
|
||||
!hasLegacyIsolation &&
|
||||
!hasLegacyDelivery &&
|
||||
sessionTarget === "isolated" &&
|
||||
payloadKind === "agentTurn"
|
||||
) {
|
||||
next.delivery = { mode: "announce" };
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
|
||||
Reference in New Issue
Block a user