feat(cron): enhance delivery modes and job configuration

- Updated isolated cron jobs to support new delivery modes: `announce` and `none`, improving output management.
- Refactored job configuration to remove legacy fields and streamline delivery settings.
- Enhanced the `CronJobEditor` UI to reflect changes in delivery options, including a new segmented control for delivery mode selection.
- Updated documentation to clarify the new delivery configurations and their implications for job execution.
- Improved tests to validate the new delivery behavior and ensure backward compatibility with legacy settings.

This update provides users with greater flexibility in managing how isolated jobs deliver their outputs, enhancing overall usability and clarity in job configurations.
This commit is contained in:
Tyler Yust
2026-02-03 16:53:46 -08:00
committed by Peter Steinberger
parent ab9f06f4ff
commit 3f82daefd8
56 changed files with 917 additions and 1150 deletions

View File

@@ -9,6 +9,7 @@ import type {
CronPayloadPatch,
} from "../types.js";
import type { CronServiceState } from "./state.js";
import { parseAbsoluteTimeMs } from "../parse.js";
import { computeNextRunAtMs } from "../schedule.js";
import {
normalizeOptionalAgentId,
@@ -51,7 +52,8 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) {
return undefined;
}
return job.schedule.atMs;
const atMs = parseAbsoluteTimeMs(job.schedule.at);
return atMs !== null ? atMs : undefined;
}
return computeNextRunAtMs(job.schedule, nowMs);
}
@@ -117,7 +119,6 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
wakeMode: input.wakeMode,
payload: input.payload,
delivery: input.delivery,
isolation: input.isolation,
state: {
...input.state,
},
@@ -156,9 +157,6 @@ export function applyJobPatch(job: CronJob, patch: CronJobPatch) {
if (patch.delivery) {
job.delivery = mergeCronDelivery(job.delivery, patch.delivery);
}
if (patch.isolation) {
job.isolation = patch.isolation;
}
if (patch.state) {
job.state = { ...job.state, ...patch.state };
}
@@ -251,7 +249,7 @@ function mergeCronDelivery(
};
if (typeof patch.mode === "string") {
next.mode = patch.mode;
next.mode = patch.mode === "deliver" ? "announce" : patch.mode;
}
if ("channel" in patch) {
const channel = typeof patch.channel === "string" ? patch.channel.trim() : "";

View File

@@ -1,11 +1,59 @@
import fs from "node:fs";
import type { CronJob } from "../types.js";
import type { CronServiceState } from "./state.js";
import { parseAbsoluteTimeMs } from "../parse.js";
import { migrateLegacyCronPayload } from "../payload-migration.js";
import { loadCronStore, saveCronStore } from "../store.js";
import { recomputeNextRuns } from "./jobs.js";
import { inferLegacyName, normalizeOptionalText } from "./normalize.js";
function hasLegacyDeliveryHints(payload: Record<string, unknown>) {
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 buildDeliveryFromLegacyPayload(payload: Record<string, unknown>) {
const deliver = payload.deliver;
const mode = deliver === false ? "none" : "announce";
const channelRaw =
typeof payload.channel === "string" ? payload.channel.trim().toLowerCase() : "";
const toRaw = typeof payload.to === "string" ? payload.to.trim() : "";
const next: Record<string, unknown> = { mode };
if (channelRaw) {
next.channel = channelRaw;
}
if (toRaw) {
next.to = toRaw;
}
if (typeof payload.bestEffortDeliver === "boolean") {
next.bestEffort = payload.bestEffortDeliver;
}
return next;
}
function stripLegacyDeliveryFields(payload: Record<string, unknown>) {
if ("deliver" in payload) {
delete payload.deliver;
}
if ("channel" in payload) {
delete payload.channel;
}
if ("to" in payload) {
delete payload.to;
}
if ("bestEffortDeliver" in payload) {
delete payload.bestEffortDeliver;
}
}
async function getFileMtimeMs(path: string): Promise<number | null> {
try {
const stats = await fs.promises.stat(path);
@@ -59,6 +107,78 @@ export async function ensureLoaded(state: CronServiceState) {
mutated = true;
}
}
const schedule = raw.schedule;
if (schedule && typeof schedule === "object" && !Array.isArray(schedule)) {
const sched = schedule as Record<string, unknown>;
const kind = typeof sched.kind === "string" ? sched.kind.trim().toLowerCase() : "";
if (!kind && ("at" in sched || "atMs" in sched)) {
sched.kind = "at";
mutated = true;
}
const atRaw = typeof sched.at === "string" ? sched.at.trim() : "";
const atMsRaw = sched.atMs;
const parsedAtMs =
typeof atMsRaw === "number"
? atMsRaw
: typeof atMsRaw === "string"
? parseAbsoluteTimeMs(atMsRaw)
: atRaw
? parseAbsoluteTimeMs(atRaw)
: null;
if (parsedAtMs !== null) {
sched.at = new Date(parsedAtMs).toISOString();
if ("atMs" in sched) {
delete sched.atMs;
}
mutated = true;
}
}
const delivery = raw.delivery;
if (delivery && typeof delivery === "object" && !Array.isArray(delivery)) {
const modeRaw = (delivery as { mode?: unknown }).mode;
if (typeof modeRaw === "string") {
const lowered = modeRaw.trim().toLowerCase();
if (lowered === "deliver") {
(delivery as { mode?: unknown }).mode = "announce";
mutated = true;
}
}
}
const isolation = raw.isolation;
if (isolation && typeof isolation === "object" && !Array.isArray(isolation)) {
delete raw.isolation;
mutated = true;
}
const payloadRecord =
payload && typeof payload === "object" && !Array.isArray(payload)
? (payload as Record<string, unknown>)
: null;
const payloadKind =
payloadRecord && typeof payloadRecord.kind === "string" ? payloadRecord.kind : "";
const sessionTarget =
typeof raw.sessionTarget === "string" ? raw.sessionTarget.trim().toLowerCase() : "";
const isIsolatedAgentTurn =
sessionTarget === "isolated" || (sessionTarget === "" && payloadKind === "agentTurn");
const hasDelivery = delivery && typeof delivery === "object" && !Array.isArray(delivery);
const hasLegacyDelivery = payloadRecord ? hasLegacyDeliveryHints(payloadRecord) : false;
if (isIsolatedAgentTurn && payloadKind === "agentTurn") {
if (!hasDelivery) {
raw.delivery =
payloadRecord && hasLegacyDelivery
? buildDeliveryFromLegacyPayload(payloadRecord)
: { mode: "announce" };
mutated = true;
}
if (payloadRecord && hasLegacyDelivery) {
stripLegacyDeliveryFields(payloadRecord);
mutated = true;
}
}
}
state.store = { version: 1, jobs: jobs as unknown as CronJob[] };
state.storeLoadedAtMs = state.deps.nowMs();

View File

@@ -80,12 +80,7 @@ export async function executeJob(
let deleted = false;
const finish = async (
status: "ok" | "error" | "skipped",
err?: string,
summary?: string,
outputText?: string,
) => {
const finish = async (status: "ok" | "error" | "skipped", err?: string, summary?: string) => {
const endedAt = state.deps.nowMs();
job.state.runningAtMs = undefined;
job.state.lastRunAtMs = startedAt;
@@ -124,30 +119,6 @@ export async function executeJob(
deleted = true;
emit(state, { jobId: job.id, action: "removed" });
}
if (job.sessionTarget === "isolated" && !job.delivery) {
const prefix = job.isolation?.postToMainPrefix?.trim() || "Cron";
const mode = job.isolation?.postToMainMode ?? "summary";
let body = (summary ?? err ?? status).trim();
if (mode === "full") {
// Prefer full agent output if available; fall back to summary.
const maxCharsRaw = job.isolation?.postToMainMaxChars;
const maxChars = Number.isFinite(maxCharsRaw) ? Math.max(0, maxCharsRaw as number) : 8000;
const fullText = (outputText ?? "").trim();
if (fullText) {
body = fullText.length > maxChars ? `${fullText.slice(0, maxChars)}` : fullText;
}
}
const statusPrefix = status === "ok" ? prefix : `${prefix} (${status})`;
state.deps.enqueueSystemEvent(`${statusPrefix}: ${body}`, {
agentId: job.agentId,
});
if (job.wakeMode === "now") {
state.deps.requestHeartbeatNow({ reason: `cron:${job.id}:post` });
}
}
};
try {
@@ -214,11 +185,11 @@ export async function executeJob(
message: job.payload.message,
});
if (res.status === "ok") {
await finish("ok", undefined, res.summary, res.outputText);
await finish("ok", undefined, res.summary);
} else if (res.status === "skipped") {
await finish("skipped", undefined, res.summary, res.outputText);
await finish("skipped", undefined, res.summary);
} else {
await finish("error", res.error ?? "cron job failed", res.summary, res.outputText);
await finish("error", res.error ?? "cron job failed", res.summary);
}
} catch (err) {
await finish("error", String(err));