* feat(workspaces): align agent and schedule automation Make workspace identity the shared placement boundary for MCP and CLI, while caller identity determines parentage. Keep heartbeats minimal and cron-based without changing legacy rolling interval semantics. * test(cli): expect canonical schedule cadence * fix(automation): preserve workspace and schedule compatibility * fix(automation): preserve compatibility edges * fix(workspaces): align MCP lifecycle resolution * fix(workspaces): preserve creation intent * fix(workspaces): preserve branch and schedule identity
164 lines
6.1 KiB
TypeScript
164 lines
6.1 KiB
TypeScript
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
describeCron,
|
|
everyMsToParts,
|
|
formatCadence,
|
|
formatNextRun,
|
|
isNewAgentSchedule,
|
|
scheduleProductName,
|
|
partsToEveryMs,
|
|
resolveScheduleTitle,
|
|
validateCron,
|
|
} from "./schedule-format";
|
|
|
|
function createSchedule(input: {
|
|
name?: string | null;
|
|
prompt?: string;
|
|
title?: string | null;
|
|
targetType?: "agent" | "new-agent";
|
|
}): ScheduleSummary {
|
|
return {
|
|
id: "schedule-1",
|
|
name: input.name ?? null,
|
|
prompt: input.prompt ?? "Run the task",
|
|
cadence: { type: "every", everyMs: 60_000 },
|
|
target:
|
|
input.targetType === "agent"
|
|
? { type: "agent", agentId: "00000000-0000-4000-8000-000000000000" }
|
|
: {
|
|
type: "new-agent",
|
|
config: {
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
title: input.title,
|
|
},
|
|
},
|
|
status: "active",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
nextRunAt: null,
|
|
lastRunAt: null,
|
|
pausedAt: null,
|
|
expiresAt: null,
|
|
maxRuns: null,
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("schedule title helpers", () => {
|
|
it("identifies new-agent schedules", () => {
|
|
expect(isNewAgentSchedule(createSchedule({ targetType: "new-agent" }))).toBe(true);
|
|
expect(isNewAgentSchedule(createSchedule({ targetType: "agent" }))).toBe(false);
|
|
});
|
|
|
|
it("labels engine records by product meaning", () => {
|
|
expect(scheduleProductName(createSchedule({ targetType: "new-agent" }))).toBe("Schedule");
|
|
expect(scheduleProductName(createSchedule({ targetType: "agent" }))).toBe("Heartbeat");
|
|
});
|
|
|
|
it("resolves display titles by name, config title, prompt, then fallback", () => {
|
|
expect(
|
|
resolveScheduleTitle(createSchedule({ name: "Morning run", title: "Config title" })),
|
|
).toBe("Morning run");
|
|
expect(resolveScheduleTitle(createSchedule({ name: " ", title: "Config title" }))).toBe(
|
|
"Config title",
|
|
);
|
|
expect(
|
|
resolveScheduleTitle(createSchedule({ name: " ", title: " ", prompt: "\nPrompt line" })),
|
|
).toBe("Prompt line");
|
|
expect(resolveScheduleTitle(createSchedule({ name: " ", title: " ", prompt: "\n " }))).toBe(
|
|
"Untitled schedule",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("interval formatting", () => {
|
|
it("round-trips interval parts and formats cadence labels", () => {
|
|
expect(everyMsToParts(2 * 24 * 60 * 60_000)).toEqual({ value: 2, unit: "days" });
|
|
expect(everyMsToParts(3 * 60 * 60_000)).toEqual({ value: 3, unit: "hours" });
|
|
expect(everyMsToParts(90_000)).toEqual({ value: 2, unit: "minutes" });
|
|
expect(everyMsToParts(0)).toEqual({ value: 1, unit: "hours" });
|
|
|
|
expect(partsToEveryMs(2, "hours")).toBe(2 * 60 * 60_000);
|
|
expect(partsToEveryMs(0, "minutes")).toBe(60_000);
|
|
expect(formatCadence({ type: "every", everyMs: 2 * 60 * 60_000 })).toBe("Every 2 hours");
|
|
});
|
|
});
|
|
|
|
describe("describeCron", () => {
|
|
it("humanizes common fixed-time cron shapes", () => {
|
|
expect(describeCron({ type: "cron", expression: "* * * * *" })).toBe("Every minute");
|
|
expect(describeCron({ type: "cron", expression: "0 * * * *" })).toBe("Every hour");
|
|
expect(describeCron({ type: "cron", expression: "15 * * * *" })).toBe("Every hour at :15");
|
|
expect(describeCron({ type: "cron", expression: "0 9 * * *" })).toBe("Daily at 09:00 UTC");
|
|
expect(describeCron({ type: "cron", expression: "0 9 * * 1-5" })).toBe("Weekdays at 09:00 UTC");
|
|
expect(describeCron({ type: "cron", expression: "0 9 * * 0,6" })).toBe("Weekends at 09:00 UTC");
|
|
expect(describeCron({ type: "cron", expression: "0 9 * * 1" })).toBe("Mondays at 09:00 UTC");
|
|
});
|
|
|
|
it("labels fixed-time cron cadences with their stored timezone", () => {
|
|
expect(
|
|
describeCron({
|
|
type: "cron",
|
|
expression: "0 9 * * *",
|
|
timezone: "America/New_York",
|
|
}),
|
|
).toBe("Daily at 09:00 America/New_York");
|
|
expect(
|
|
formatCadence({
|
|
type: "cron",
|
|
expression: "0 9 * * 1-5",
|
|
timezone: "Europe/Madrid",
|
|
}),
|
|
).toBe("Weekdays at 09:00 Europe/Madrid");
|
|
});
|
|
|
|
it("keeps timezone-less fixed-time cron cadences labeled as UTC", () => {
|
|
expect(formatCadence({ type: "cron", expression: "0 9 * * *" })).toBe("Daily at 09:00 UTC");
|
|
});
|
|
|
|
it("returns null for invalid or unrecognized valid cron expressions", () => {
|
|
expect(describeCron({ type: "cron", expression: "not a cron" })).toBeNull();
|
|
expect(describeCron({ type: "cron", expression: "*/5 * * * *" })).toBeNull();
|
|
expect(formatCadence({ type: "cron", expression: "0 9 * * *" })).toBe("Daily at 09:00 UTC");
|
|
});
|
|
});
|
|
|
|
describe("validateCron", () => {
|
|
it("accepts structurally valid cron expressions", () => {
|
|
expect(validateCron("*/5 9-17 * 1,6 1-5")).toBeNull();
|
|
});
|
|
|
|
it("rejects step fields with extra slash tokens", () => {
|
|
expect(validateCron("*/5/2 * * * *")).toBe("Invalid minute step");
|
|
});
|
|
|
|
it("rejects malformed fields with targeted messages", () => {
|
|
expect(validateCron("")).toBe("Enter a cron expression");
|
|
expect(validateCron("* * *")).toBe("Cron expressions must have 5 fields");
|
|
expect(validateCron("60 * * * *")).toBe("Invalid minute value");
|
|
expect(validateCron("* 24 * * *")).toBe("Invalid hour value");
|
|
expect(validateCron("* * 31-1 * *")).toBe("Invalid day-of-month range");
|
|
expect(validateCron("* * * */0 *")).toBe("Invalid month step");
|
|
expect(validateCron("* * * * mon")).toBe("Invalid day-of-week value");
|
|
});
|
|
});
|
|
|
|
describe("formatNextRun", () => {
|
|
it("formats next-run distance from the current clock", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
|
|
expect(formatNextRun(null)).toBe("");
|
|
expect(formatNextRun("not-a-date")).toBe("");
|
|
expect(formatNextRun("2026-01-01T00:00:15.000Z")).toBe("soon");
|
|
expect(formatNextRun("2026-01-01T00:30:00.000Z")).toBe("in 30m");
|
|
expect(formatNextRun("2026-01-01T03:00:00.000Z")).toBe("in 3h");
|
|
expect(formatNextRun("2026-01-03T00:00:00.000Z")).toBe("in 2d");
|
|
});
|
|
});
|