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:
Mohamed Boudra
2026-07-02 12:05:35 +02:00
committed by GitHub
parent 2263469342
commit 6513b56571
21 changed files with 1981 additions and 551 deletions
@@ -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)
);
}