- Unify resolveProviderAndModel across CLI and server, supporting provider/model syntax (e.g. codex/gpt-5.4) for agent runs and schedules - Add --provider flag to schedule create CLI and both MCP schedule tools - Add structured debug logging to workspace fetch hydrate, sidebar refresh, and real-time update paths - Update orchestrate skill references
219 lines
6.6 KiB
TypeScript
219 lines
6.6 KiB
TypeScript
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
|
import type { CommandError, CommandOptions } from "../../output/index.js";
|
|
import type {
|
|
CreateScheduleInput,
|
|
ScheduleCadence,
|
|
ScheduleDaemonClient,
|
|
ScheduleListItem,
|
|
ScheduleRecord,
|
|
ScheduleTarget,
|
|
} from "./types.js";
|
|
import { parseDuration } from "../../utils/duration.js";
|
|
import { resolveProviderAndModel } from "../../utils/provider-model.js";
|
|
|
|
export interface ScheduleCommandOptions extends CommandOptions {
|
|
host?: string;
|
|
}
|
|
|
|
export async function connectScheduleClient(
|
|
host: string | undefined,
|
|
): Promise<{ client: ScheduleDaemonClient; host: string }> {
|
|
const resolvedHost = getDaemonHost({ host });
|
|
try {
|
|
const client = (await connectToDaemon({
|
|
host,
|
|
})) as unknown as ScheduleDaemonClient;
|
|
return { client, host: resolvedHost };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
throw {
|
|
code: "DAEMON_NOT_RUNNING",
|
|
message: `Cannot connect to daemon at ${resolvedHost}: ${message}`,
|
|
details: "Start the daemon with: paseo daemon start",
|
|
} satisfies CommandError;
|
|
}
|
|
}
|
|
|
|
export function toScheduleCommandError(code: string, action: string, error: unknown): CommandError {
|
|
if (error && typeof error === "object" && "code" in error) {
|
|
return error as CommandError;
|
|
}
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
code,
|
|
message: `Failed to ${action}: ${message}`,
|
|
};
|
|
}
|
|
|
|
export function formatCadence(cadence: ScheduleCadence): string {
|
|
if (cadence.type === "cron") {
|
|
return `cron:${cadence.expression}`;
|
|
}
|
|
return `every:${formatDurationMs(cadence.everyMs)}`;
|
|
}
|
|
|
|
export function formatTarget(target: ScheduleTarget | ScheduleListItem["target"]): string {
|
|
if (target.type === "self") {
|
|
return `self:${target.agentId.slice(0, 7)}`;
|
|
}
|
|
if (target.type === "agent") {
|
|
return `agent:${target.agentId.slice(0, 7)}`;
|
|
}
|
|
const modelSuffix = target.config.model ? `/${target.config.model}` : "";
|
|
return `new-agent:${target.config.provider}${modelSuffix}`;
|
|
}
|
|
|
|
export function formatDurationMs(durationMs: number): string {
|
|
const parts: string[] = [];
|
|
let remainingMs = durationMs;
|
|
const hours = Math.floor(remainingMs / (60 * 60 * 1000));
|
|
if (hours > 0) {
|
|
parts.push(`${hours}h`);
|
|
remainingMs -= hours * 60 * 60 * 1000;
|
|
}
|
|
const minutes = Math.floor(remainingMs / (60 * 1000));
|
|
if (minutes > 0) {
|
|
parts.push(`${minutes}m`);
|
|
remainingMs -= minutes * 60 * 1000;
|
|
}
|
|
const seconds = Math.floor(remainingMs / 1000);
|
|
if (seconds > 0 || parts.length === 0) {
|
|
parts.push(`${seconds}s`);
|
|
}
|
|
return parts.join("");
|
|
}
|
|
|
|
export function parseScheduleCreateInput(options: {
|
|
prompt: string;
|
|
every?: string;
|
|
cron?: string;
|
|
name?: string;
|
|
target?: string;
|
|
provider?: string;
|
|
maxRuns?: string;
|
|
expiresIn?: string;
|
|
}): CreateScheduleInput {
|
|
const prompt = options.prompt.trim();
|
|
if (!prompt) {
|
|
throw {
|
|
code: "INVALID_PROMPT",
|
|
message: "Schedule prompt cannot be empty",
|
|
} satisfies CommandError;
|
|
}
|
|
|
|
const cadenceCount = Number(options.every !== undefined) + Number(options.cron !== undefined);
|
|
if (cadenceCount !== 1) {
|
|
throw {
|
|
code: "INVALID_CADENCE",
|
|
message: "Specify exactly one of --every or --cron",
|
|
} satisfies CommandError;
|
|
}
|
|
|
|
const cadence: ScheduleCadence = options.every
|
|
? { type: "every", everyMs: parseDuration(options.every) }
|
|
: { type: "cron", expression: options.cron!.trim() };
|
|
|
|
const targetValue = options.target?.trim();
|
|
const hasExplicitProviderSelection = options.provider !== undefined;
|
|
const resolvedProviderModel = resolveProviderAndModel({
|
|
provider: options.provider,
|
|
defaultProvider: "claude",
|
|
});
|
|
const newAgentTarget: ScheduleTarget = {
|
|
type: "new-agent",
|
|
config: {
|
|
provider: resolvedProviderModel.provider,
|
|
cwd: process.cwd(),
|
|
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
|
|
},
|
|
};
|
|
let target: ScheduleTarget;
|
|
if (!targetValue) {
|
|
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
|
|
if (currentAgentId && !hasExplicitProviderSelection) {
|
|
target = { type: "self", agentId: currentAgentId };
|
|
} else {
|
|
target = newAgentTarget;
|
|
}
|
|
} else if (targetValue === "self") {
|
|
if (hasExplicitProviderSelection) {
|
|
throw {
|
|
code: "INVALID_TARGET",
|
|
message: "--provider can only be used with a new-agent target",
|
|
details: "Use --target new-agent or omit --target to create a new agent schedule",
|
|
} satisfies CommandError;
|
|
}
|
|
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
|
|
if (!currentAgentId) {
|
|
throw {
|
|
code: "INVALID_TARGET",
|
|
message: "--target self requires running inside a Paseo agent",
|
|
} satisfies CommandError;
|
|
}
|
|
target = { type: "self", agentId: currentAgentId };
|
|
} else if (targetValue === "new-agent") {
|
|
target = newAgentTarget;
|
|
} else {
|
|
if (hasExplicitProviderSelection) {
|
|
throw {
|
|
code: "INVALID_TARGET",
|
|
message: "--provider can only be used with a new-agent target",
|
|
details: "Use --target new-agent or omit --target to create a new agent schedule",
|
|
} satisfies CommandError;
|
|
}
|
|
target = {
|
|
type: "agent",
|
|
agentId: targetValue,
|
|
};
|
|
}
|
|
|
|
const maxRuns =
|
|
options.maxRuns === undefined ? undefined : parsePositiveInt(options.maxRuns, "--max-runs");
|
|
const expiresAt =
|
|
options.expiresIn === undefined
|
|
? undefined
|
|
: new Date(Date.now() + parseDuration(options.expiresIn)).toISOString();
|
|
|
|
return {
|
|
prompt,
|
|
cadence,
|
|
target,
|
|
...(options.name?.trim() ? { name: options.name.trim() } : {}),
|
|
...(maxRuns !== undefined ? { maxRuns } : {}),
|
|
...(expiresAt ? { expiresAt } : {}),
|
|
};
|
|
}
|
|
|
|
function parsePositiveInt(value: string, flag: string): number {
|
|
const parsed = Number.parseInt(value, 10);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
throw {
|
|
code: "INVALID_INTEGER",
|
|
message: `${flag} must be a positive integer`,
|
|
} satisfies CommandError;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export interface ScheduleRow {
|
|
id: string;
|
|
name: string | null;
|
|
cadence: string;
|
|
target: string;
|
|
status: string;
|
|
nextRunAt: string | null;
|
|
lastRunAt: string | null;
|
|
}
|
|
|
|
export function toScheduleRow(schedule: ScheduleListItem | ScheduleRecord): ScheduleRow {
|
|
return {
|
|
id: schedule.id,
|
|
name: schedule.name,
|
|
cadence: formatCadence(schedule.cadence),
|
|
target: formatTarget(schedule.target),
|
|
status: schedule.status,
|
|
nextRunAt: schedule.nextRunAt,
|
|
lastRunAt: schedule.lastRunAt,
|
|
};
|
|
}
|