Polish schedules list and editing (#1860)
* feat(schedules): polish schedules list and editing Adds host filtering, status grouping, and target resolution to the schedules screen. Extracts a shared HostFilter from sessions. Improves schedule editing with project targets and model hydration. Handles missing targets server-side instead of retrying forever. * test(mcp): update schedule mocks for createOrReplace * fix(schedules): only show host name when multiple hosts exist
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { isNewAgentSchedule } from "@/utils/schedule-format";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
export const ALL_SCHEDULE_HOSTS_FAILED_MESSAGE = "No connected hosts could load schedules";
|
||||
|
||||
export interface ScheduleHostInput {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
@@ -17,65 +18,76 @@ export interface ScheduleRuntime {
|
||||
getSnapshot(serverId: string): ScheduleRuntimeSnapshot | null | undefined;
|
||||
}
|
||||
|
||||
export interface ScheduleHostSection {
|
||||
/** A schedule tagged with the host it came from, so the flat list can render a
|
||||
* per-row host label and scope mutations without host sections. */
|
||||
export interface AggregatedSchedule extends ScheduleSummary {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
isOnline: boolean;
|
||||
schedules: ScheduleSummary[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface FetchAggregatedSchedulesInput {
|
||||
hosts: ScheduleHostInput[];
|
||||
runtime: ScheduleRuntime;
|
||||
export interface ScheduleHostError {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface FetchAggregatedSchedulesResult {
|
||||
sections: ScheduleHostSection[];
|
||||
schedules: AggregatedSchedule[];
|
||||
hostErrors: ScheduleHostError[];
|
||||
}
|
||||
|
||||
export interface FetchAggregatedSchedulesInput {
|
||||
hosts: readonly ScheduleHostInput[];
|
||||
runtime: ScheduleRuntime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch schedules across connected hosts and merge them into one flat list.
|
||||
* Connectivity is checked here at execution time (not pre-filtered by the hook)
|
||||
* so the query — retried as the runtime version changes — reliably picks a host
|
||||
* up the moment it comes online, including on a cold deep-link.
|
||||
*
|
||||
* Offline hosts are skipped. A connected host that fails contributes to
|
||||
* `hostErrors` (surfaced as a banner) while the rest still render; only when
|
||||
* every connected host fails do we throw so the screen shows a full error.
|
||||
*/
|
||||
export async function fetchAggregatedSchedules(
|
||||
input: FetchAggregatedSchedulesInput,
|
||||
): Promise<FetchAggregatedSchedulesResult> {
|
||||
const sections = await Promise.all(
|
||||
input.hosts.map(async (host): Promise<ScheduleHostSection> => {
|
||||
const schedules: AggregatedSchedule[] = [];
|
||||
const hostErrors: ScheduleHostError[] = [];
|
||||
let connectedAttempts = 0;
|
||||
|
||||
await Promise.all(
|
||||
input.hosts.map(async (host) => {
|
||||
const snapshot = input.runtime.getSnapshot(host.serverId);
|
||||
const isOnline = snapshot?.connectionStatus === "online";
|
||||
const client = input.runtime.getClient(host.serverId);
|
||||
|
||||
if (!client || !isOnline) {
|
||||
return {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
schedules: [],
|
||||
error: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
connectedAttempts += 1;
|
||||
try {
|
||||
const payload = await client.scheduleList();
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return {
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
schedules: payload.schedules.filter(isNewAgentSchedule),
|
||||
error: null,
|
||||
};
|
||||
for (const schedule of payload.schedules) {
|
||||
schedules.push({ ...schedule, serverId: host.serverId, serverName: host.serverName });
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
hostErrors.push({
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
isOnline,
|
||||
schedules: [],
|
||||
error: toErrorMessage(error),
|
||||
};
|
||||
message: toErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { sections };
|
||||
if (connectedAttempts > 0 && schedules.length === 0 && hostErrors.length === connectedAttempts) {
|
||||
throw new Error(ALL_SCHEDULE_HOSTS_FAILED_MESSAGE);
|
||||
}
|
||||
|
||||
return { schedules, hostErrors };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSchedule, scheduleBucket, type ScheduleTargetAgent } from "./schedule-derivation";
|
||||
|
||||
const NOW = Date.parse("2026-07-02T00:00:00.000Z");
|
||||
const AGENT_ID = "00000000-0000-4000-8000-000000000000";
|
||||
|
||||
function makeSchedule(overrides: Partial<ScheduleSummary>): ScheduleSummary {
|
||||
return {
|
||||
id: "schedule-1",
|
||||
name: "Nightly",
|
||||
prompt: "Run the task",
|
||||
cadence: { type: "every", everyMs: 60_000 },
|
||||
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
|
||||
status: "active",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
nextRunAt: "2026-07-02T01:00:00.000Z",
|
||||
lastRunAt: null,
|
||||
pausedAt: null,
|
||||
expiresAt: null,
|
||||
maxRuns: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function resolve(
|
||||
schedule: ScheduleSummary,
|
||||
options?: {
|
||||
agents?: Array<[string, ScheduleTargetAgent]>;
|
||||
projects?: Array<[string, string]>;
|
||||
agentDataLoaded?: boolean;
|
||||
},
|
||||
) {
|
||||
return resolveSchedule({
|
||||
schedule,
|
||||
serverId: "host-1",
|
||||
now: NOW,
|
||||
agentsByKey: new Map(options?.agents ?? []),
|
||||
projectNameByCwd: new Map(options?.projects ?? []),
|
||||
agentDataLoaded: options?.agentDataLoaded ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveSchedule state", () => {
|
||||
it("keeps active and paused schedules runnable", () => {
|
||||
expect(resolve(makeSchedule({ status: "active" })).state).toBe("active");
|
||||
expect(resolve(makeSchedule({ status: "paused" })).state).toBe("paused");
|
||||
expect(scheduleBucket("active")).toBe("runnable");
|
||||
expect(scheduleBucket("paused")).toBe("runnable");
|
||||
});
|
||||
|
||||
it("treats a past expiresAt as expired regardless of status", () => {
|
||||
const result = resolve(
|
||||
makeSchedule({ status: "active", expiresAt: "2026-07-01T00:00:00.000Z" }),
|
||||
);
|
||||
expect(result.state).toBe("expired");
|
||||
expect(result.bucket).toBe("ended");
|
||||
});
|
||||
|
||||
it("ignores an unparseable expiresAt", () => {
|
||||
expect(resolve(makeSchedule({ expiresAt: "not-a-date" })).state).toBe("active");
|
||||
});
|
||||
|
||||
it("derives finished only from completed-and-not-expired", () => {
|
||||
expect(resolve(makeSchedule({ status: "completed" })).state).toBe("finished");
|
||||
expect(
|
||||
resolve(makeSchedule({ status: "completed", expiresAt: "2026-07-01T00:00:00.000Z" })).state,
|
||||
).toBe("expired");
|
||||
});
|
||||
|
||||
it("marks an agent target gone when the client has no such agent", () => {
|
||||
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||
expect(resolve(schedule).state).toBe("targetGone");
|
||||
expect(resolve(schedule).bucket).toBe("ended");
|
||||
});
|
||||
|
||||
it("does not claim gone before the agent directory has loaded", () => {
|
||||
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||
expect(resolve(schedule, { agentDataLoaded: false }).state).toBe("active");
|
||||
});
|
||||
|
||||
it("prefers target-gone over the raw paused/completed status for a live agent target", () => {
|
||||
const paused = makeSchedule({
|
||||
status: "paused",
|
||||
target: { type: "agent", agentId: AGENT_ID },
|
||||
});
|
||||
expect(resolve(paused).state).toBe("targetGone");
|
||||
});
|
||||
|
||||
it("never claims a new-agent cwd is gone", () => {
|
||||
expect(resolve(makeSchedule({ status: "active" })).state).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSchedule target line", () => {
|
||||
it("names an agent target by its client title and provider", () => {
|
||||
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||
const result = resolve(schedule, {
|
||||
agents: [[`host-1:${AGENT_ID}`, { title: "Fix build", provider: "claude" }]],
|
||||
});
|
||||
expect(result.target).toEqual({ label: "Fix build", provider: "claude" });
|
||||
expect(result.state).toBe("active");
|
||||
});
|
||||
|
||||
it("falls back to Untitled agent when the agent has no title", () => {
|
||||
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||
const result = resolve(schedule, {
|
||||
agents: [[`host-1:${AGENT_ID}`, { title: " ", provider: "codex" }]],
|
||||
});
|
||||
expect(result.target.label).toBe("Untitled agent");
|
||||
});
|
||||
|
||||
it("labels a gone agent target as unavailable with no glyph", () => {
|
||||
const schedule = makeSchedule({ target: { type: "agent", agentId: AGENT_ID } });
|
||||
expect(resolve(schedule).target).toEqual({ label: "Agent unavailable", provider: null });
|
||||
});
|
||||
|
||||
it("names a new-agent cwd by matched project, else the shortened path", () => {
|
||||
const matched = makeSchedule({
|
||||
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
|
||||
});
|
||||
expect(resolve(matched, { projects: [["host-1:/tmp/project", "My Project"]] }).target).toEqual({
|
||||
label: "My Project",
|
||||
provider: "codex",
|
||||
});
|
||||
|
||||
const unmatched = makeSchedule({
|
||||
target: { type: "new-agent", config: { provider: "codex", cwd: "/Users/alex/work/api" } },
|
||||
});
|
||||
expect(resolve(unmatched).target).toEqual({ label: "~/work/api", provider: "codex" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { describeScheduleCwd } from "@/schedules/schedule-project-targets";
|
||||
|
||||
// Derived from existing fields only — no new protocol state. "active"/"paused"
|
||||
// mirror the stored status; the rest are computed truths the daemon does not
|
||||
// spell out in a single field.
|
||||
export type ScheduleDerivedState = "active" | "paused" | "expired" | "finished" | "targetGone";
|
||||
|
||||
export type ScheduleBucket = "runnable" | "ended";
|
||||
|
||||
export interface ScheduleTargetAgent {
|
||||
title: string | null;
|
||||
provider: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleTargetResolution {
|
||||
/** The target line: agent title, project name, or the shortened cwd. */
|
||||
label: string;
|
||||
/** Provider glyph for the row, when known. */
|
||||
provider: string | null;
|
||||
}
|
||||
|
||||
export interface ResolvedSchedule {
|
||||
state: ScheduleDerivedState;
|
||||
bucket: ScheduleBucket;
|
||||
target: ScheduleTargetResolution;
|
||||
}
|
||||
|
||||
export interface ResolveScheduleInput {
|
||||
schedule: ScheduleSummary;
|
||||
serverId: string;
|
||||
now: number;
|
||||
/** Client agent directory keyed by `${serverId}:${agentId}`. */
|
||||
agentsByKey: ReadonlyMap<string, ScheduleTargetAgent>;
|
||||
/** Known project roots keyed by `${serverId}:${cwd}`. */
|
||||
projectNameByCwd: ReadonlyMap<string, string>;
|
||||
/**
|
||||
* Whether the agent directory has finished its first load. While false we do
|
||||
* not claim an agent target is gone — absence would just be a cold cache.
|
||||
*/
|
||||
agentDataLoaded: boolean;
|
||||
}
|
||||
|
||||
function agentKey(serverId: string, agentId: string): string {
|
||||
return `${serverId}:${agentId}`;
|
||||
}
|
||||
|
||||
function isExpired(schedule: ScheduleSummary, now: number): boolean {
|
||||
if (!schedule.expiresAt) {
|
||||
return false;
|
||||
}
|
||||
const expiresAt = Date.parse(schedule.expiresAt);
|
||||
return Number.isFinite(expiresAt) && expiresAt <= now;
|
||||
}
|
||||
|
||||
function isAgentTargetGone(input: ResolveScheduleInput): boolean {
|
||||
const { schedule, serverId, agentsByKey, agentDataLoaded } = input;
|
||||
if (schedule.target.type !== "agent" || !agentDataLoaded) {
|
||||
return false;
|
||||
}
|
||||
return !agentsByKey.has(agentKey(serverId, schedule.target.agentId));
|
||||
}
|
||||
|
||||
function resolveTarget(input: ResolveScheduleInput): ScheduleTargetResolution {
|
||||
const { schedule, serverId, agentsByKey, projectNameByCwd } = input;
|
||||
if (schedule.target.type === "agent") {
|
||||
const agent = agentsByKey.get(agentKey(serverId, schedule.target.agentId));
|
||||
if (agent) {
|
||||
return { label: agent.title?.trim() || "Untitled agent", provider: agent.provider };
|
||||
}
|
||||
return { label: "Agent unavailable", provider: null };
|
||||
}
|
||||
return {
|
||||
label: describeScheduleCwd({ serverId, cwd: schedule.target.config.cwd, projectNameByCwd }),
|
||||
provider: schedule.target.config.provider,
|
||||
};
|
||||
}
|
||||
|
||||
// One badge, one truth. Order matters: expiry and a missing target are more
|
||||
// informative than the raw "completed"/"paused" status, so they win.
|
||||
function deriveState(input: ResolveScheduleInput): ScheduleDerivedState {
|
||||
const { schedule, now } = input;
|
||||
if (isExpired(schedule, now)) {
|
||||
return "expired";
|
||||
}
|
||||
if (isAgentTargetGone(input)) {
|
||||
return "targetGone";
|
||||
}
|
||||
if (schedule.status === "completed") {
|
||||
return "finished";
|
||||
}
|
||||
if (schedule.status === "paused") {
|
||||
return "paused";
|
||||
}
|
||||
return "active";
|
||||
}
|
||||
|
||||
export function scheduleBucket(state: ScheduleDerivedState): ScheduleBucket {
|
||||
return state === "active" || state === "paused" ? "runnable" : "ended";
|
||||
}
|
||||
|
||||
export function resolveSchedule(input: ResolveScheduleInput): ResolvedSchedule {
|
||||
const state = deriveState(input);
|
||||
return {
|
||||
state,
|
||||
bucket: scheduleBucket(state),
|
||||
target: resolveTarget(input),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ProjectSummary } from "@/utils/projects";
|
||||
import {
|
||||
buildProjectNameByCwd,
|
||||
buildScheduleProjectTargets,
|
||||
describeScheduleCwd,
|
||||
} from "./schedule-project-targets";
|
||||
|
||||
function makeProject(overrides: Partial<ProjectSummary>): ProjectSummary {
|
||||
return {
|
||||
projectKey: "proj",
|
||||
projectName: "Project",
|
||||
hosts: [],
|
||||
totalWorkspaceCount: 0,
|
||||
hostCount: 0,
|
||||
onlineHostCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeHost(overrides: Partial<ProjectSummary["hosts"][number]>) {
|
||||
return {
|
||||
serverId: "host-1",
|
||||
serverName: "Host 1",
|
||||
isOnline: true,
|
||||
repoRoot: "/tmp/project",
|
||||
workspaceCount: 0,
|
||||
workspaces: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildScheduleProjectTargets", () => {
|
||||
it("emits one target per online host with a repo root", () => {
|
||||
const targets = buildScheduleProjectTargets([
|
||||
makeProject({
|
||||
projectName: "Alpha",
|
||||
hosts: [makeHost({ repoRoot: "/tmp/alpha" }), makeHost({ serverId: "host-2" })],
|
||||
}),
|
||||
]);
|
||||
expect(targets).toHaveLength(2);
|
||||
expect(targets[0]).toMatchObject({
|
||||
serverId: "host-1",
|
||||
cwd: "/tmp/alpha",
|
||||
projectName: "Alpha",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips offline hosts and blank repo roots", () => {
|
||||
const targets = buildScheduleProjectTargets([
|
||||
makeProject({
|
||||
hosts: [makeHost({ isOnline: false }), makeHost({ serverId: "host-3", repoRoot: " " })],
|
||||
}),
|
||||
]);
|
||||
expect(targets).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeScheduleCwd", () => {
|
||||
it("prefers a matched project name and shortens unmatched paths", () => {
|
||||
const byCwd = buildProjectNameByCwd(
|
||||
buildScheduleProjectTargets([
|
||||
makeProject({ projectName: "Alpha", hosts: [makeHost({ repoRoot: "/tmp/alpha" })] }),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
describeScheduleCwd({ serverId: "host-1", cwd: "/tmp/alpha", projectNameByCwd: byCwd }),
|
||||
).toBe("Alpha");
|
||||
expect(
|
||||
describeScheduleCwd({ serverId: "host-1", cwd: "/Users/sam/api", projectNameByCwd: byCwd }),
|
||||
).toBe("~/api");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ProjectSummary } from "@/utils/projects";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
|
||||
export const PROJECT_OPTION_PREFIX = "project:";
|
||||
|
||||
export interface ScheduleProjectTarget {
|
||||
optionId: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function buildProjectOptionId(serverId: string, projectKey: string): string {
|
||||
return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The project roots the schedule form can target: one per online host of each
|
||||
* project, keyed by (serverId, cwd). The schedules list reuses this set to name
|
||||
* a schedule's stored cwd; the two surfaces must agree on what "a project" is.
|
||||
*/
|
||||
export function buildScheduleProjectTargets(
|
||||
projects: readonly ProjectSummary[],
|
||||
): ScheduleProjectTarget[] {
|
||||
const targets: ScheduleProjectTarget[] = [];
|
||||
for (const project of projects) {
|
||||
for (const host of project.hosts) {
|
||||
const cwd = host.repoRoot.trim();
|
||||
if (!host.isOnline || !cwd) {
|
||||
continue;
|
||||
}
|
||||
targets.push({
|
||||
optionId: buildProjectOptionId(host.serverId, project.projectKey),
|
||||
serverId: host.serverId,
|
||||
serverName: host.serverName,
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
function projectNameKey(serverId: string, cwd: string): string {
|
||||
return `${serverId}:${cwd.trim()}`;
|
||||
}
|
||||
|
||||
/** Map (serverId, cwd) -> project name for naming a schedule's stored cwd. */
|
||||
export function buildProjectNameByCwd(
|
||||
targets: readonly ScheduleProjectTarget[],
|
||||
): Map<string, string> {
|
||||
const byCwd = new Map<string, string>();
|
||||
for (const target of targets) {
|
||||
byCwd.set(projectNameKey(target.serverId, target.cwd), target.projectName);
|
||||
}
|
||||
return byCwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Name a stored cwd for display: the matching project name when the client
|
||||
* knows this root on this host, otherwise the shortened path itself. Never
|
||||
* blank, never a claim the client cannot back up.
|
||||
*/
|
||||
export function describeScheduleCwd(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
projectNameByCwd: ReadonlyMap<string, string>;
|
||||
}): string {
|
||||
return (
|
||||
input.projectNameByCwd.get(projectNameKey(input.serverId, input.cwd)) ?? shortenPath(input.cwd)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user