import { apiRequest } from './common.ts' import { buildAgentTaskVariables, buildTaskVariables } from './tasks.ts' import type { AgentTaskVariableValue, TaskVariableValue } from './tasks.ts' // ============================================================================ // Types // ============================================================================ /** What a schedule runs. Canvas and workflow run their Production version; an agent its published one. */ export type ScheduleTargetType = 'canvas' | 'workflow' | 'agent' export type ScheduleStatus = 'active' | 'paused' | 'completed' export type ScheduleOccurrenceStatus = 'pending' | 'triggered' | 'failed_to_trigger' | 'cancelled' /** When a schedule fires: a five-field cron in an IANA timezone, or one moment in time. */ export type ScheduleTiming = | { scheduleType: 'recurring', cronExpression: string, timezone: string } | { scheduleType: 'once', runAt: string, timezone: string } export interface Schedule { id: string workspaceId: string /** `null` means the whole workspace can see and manage it. */ teamId: string | null name: string status: ScheduleStatus targetType: ScheduleTargetType targetId: string scheduleType: 'recurring' | 'once' cronExpression: string | null runAt: string | null timezone: string /** `null` once a one-time schedule has fired, or while the schedule cannot run. */ nextRunAt: string | null resumedAt: string | null revision: number createdBy: string updatedBy: string | null createdAt: string updatedAt: string /** Only on `getSchedule`, `createSchedule`, and `updateSchedule`. */ inputs?: Record targetTitle?: string projectId?: string | null projectTitle?: string | null /** Only on `listSchedules`. The latest dispatch failed, or the run it started failed. */ needsAttention?: boolean lastOccurrence?: { id: string | null status: ScheduleOccurrenceStatus | null scheduledFor: string | null triggeredAt: string | null taskId: string | null taskStatus: string | null executionStatus: string | null promptApplicationId: string | null agentId: string | null } } export interface ScheduleOccurrence { id: string cronScheduleId: string status: ScheduleOccurrenceStatus scheduledFor: string triggeredAt: string | null taskId: string | null errorCode: string | null errorMessage: string | null executionSpec: { targetType: ScheduleTargetType, targetId: string } /** Outcome of the run it started: `completed`, `failed`, `cancelled`, `running`… */ executionStatus: string | null task: { id: string | null, status: string | null, promptApplicationId: string | null, agentId: string | null, agentSessionId: string | null } | null /** Canvas/workflow run in the target's Usage page. Pass the occurrence to `getScheduleRunLinks`. */ usageEventId: string | null workflowRunId: string | null usageAgentSessionId: string | null } export interface ScheduleTarget { id: string title: string targetType: ScheduleTargetType projectId: string projectTitle: string } export interface ScheduleTargetInputs { variables: Array<{ name: string, type: string, required?: boolean, description?: string }> /** Teams the caller may assign the schedule to. */ teamIds: string[] /** The project is restricted to teams, so the schedule must belong to one of `teamIds`. */ requiresTeam: boolean allowAllWorkspaceTeams: boolean } export interface ListSchedulesOptions { search?: string status?: ScheduleStatus targetType?: ScheduleTargetType targetId?: string projectId?: string teamId?: string /** Only schedules whose latest run failed to dispatch or failed. */ attention?: boolean sort?: 'recent' | 'next' | 'name' limit?: number offset?: number } export interface ListSchedulesResult { data: Schedule[] meta: Record } export type CreateSchedulePayload = ScheduleTiming & { targetType: ScheduleTargetType targetId: string /** Shown in the schedules list. Say what runs and when, e.g. "Relatório de vendas — segunda 9h". */ name: string /** * Values for the target's inputs, in the same shapes as Workstation tasks: `TaskVariableValue` * for a canvas or workflow, `AgentTaskVariableValue` for an agent. `vault://` refs become files. */ inputs?: Record /** * Omit to let it be resolved: workspace-wide when the project is open, the only eligible team * when there is one. Required when the project is restricted to several of the caller's teams. */ teamId?: string | null } export type UpdateSchedulePayload = Partial<{ name: string teamId: string | null targetType: ScheduleTargetType targetId: string inputs: Record scheduleType: 'recurring' | 'once' cronExpression: string | null runAt: string | null timezone: string }> // ============================================================================ // Helpers // ============================================================================ const BASE = '/v1/cron-schedules' function query(params: Record): string { const search = new URLSearchParams() for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== '') search.set(key, String(value)) } const text = search.toString() return text ? `?${text}` : '' } /** * Pick the team a new schedule belongs to, the way the app's form does. * * Throws when the choice is the user's to make, naming the options, rather than guessing. */ export function resolveScheduleTeam(target: ScheduleTargetInputs, teamId: string | null | undefined): string | null { if (teamId !== undefined) { if (teamId === null && target.requiresTeam) throw new Error('This project is restricted to teams, so the schedule must belong to one of them') return teamId } if (!target.requiresTeam) return null if (target.teamIds.length === 1) return target.teamIds[0]! if (target.teamIds.length === 0) throw new Error('You are not in any team with access to this project, so you cannot schedule it') throw new Error(`This project is restricted to teams. Ask which team owns the schedule and pass teamId: ${target.teamIds.join(', ')}`) } async function buildScheduleInputs( targetType: ScheduleTargetType, inputs: Record, ): Promise> { return targetType === 'agent' ? await buildAgentTaskVariables(inputs as Record) : await buildTaskVariables(inputs as Record) } // ============================================================================ // Reading // ============================================================================ /** Schedules visible to the caller, newest first by default. */ export async function listSchedules(options: ListSchedulesOptions = {}): Promise { const { attention, ...rest } = options return await apiRequest(`${BASE}${query({ ...rest, attention: attention ? 'true' : undefined })}`) } /** One schedule, with its inputs, target title, and project. */ export async function getSchedule(scheduleId: string): Promise { return await apiRequest(`${BASE}/${scheduleId}`) } /** Each time the schedule fired, newest first, with the outcome of the run it started. */ export async function listScheduleOccurrences( scheduleId: string, options: { limit?: number, offset?: number } = {}, ): Promise { return await apiRequest(`${BASE}/${scheduleId}/occurrences${query(options)}`) } /** * What can be scheduled: canvases and workflows with a Production version, and published agents. * `search` matches the title, or an exact id. */ export async function listScheduleTargets(options: { search?: string, limit?: number, offset?: number } = {}): Promise { return await apiRequest(`${BASE}/targets${query(options)}`) } /** The inputs a schedule must supply for a target, and which teams may own it. */ export async function getScheduleTarget(targetType: ScheduleTargetType, targetId: string): Promise { return await apiRequest(`${BASE}/targets/${targetType}/${targetId}`) } /** The next execution times the server computes for a timing, without saving anything. */ export async function previewSchedule(timing: ScheduleTiming & { count?: number, after?: string }): Promise { const { executionTimes } = await apiRequest<{ executionTimes: string[] }>(`${BASE}/preview`, { method: 'POST', body: timing, }) return executionTimes } // ============================================================================ // Writing // ============================================================================ /** * Schedule a canvas, workflow, or agent. * * Picks the owning team the way the app's form does. Inputs are validated by the server against the * target's published version. */ export async function createSchedule(payload: CreateSchedulePayload): Promise { const { targetType, targetId, inputs, name, teamId, ...timing } = payload const target = await getScheduleTarget(targetType, targetId) const resolvedTeamId = resolveScheduleTeam(target, teamId) // Pre-serialized: apiRequest would otherwise rewrite an agent input's `vaultRef: 'vault://…'` // into `{ file_url }`, which the agent API rejects. The inputs are already in their final shape. return await apiRequest(BASE, { method: 'POST', body: JSON.stringify({ ...timing, name: name.trim(), targetType, targetId, teamId: resolvedTeamId, ...(inputs && Object.keys(inputs).length ? { inputs: await buildScheduleInputs(targetType, inputs) } : {}), }), }) } /** * Change a schedule. Send only what changes: `inputs` replaces the whole input map, and switching * `scheduleType` needs the matching `cronExpression` or `runAt`. */ export async function updateSchedule(scheduleId: string, payload: UpdateSchedulePayload): Promise { const { inputs, ...rest } = payload let body: Record = rest if (inputs) { const targetType = payload.targetType ?? (await getSchedule(scheduleId)).targetType body = { ...rest, inputs: await buildScheduleInputs(targetType, inputs) } } // Pre-serialized for the same reason as createSchedule. return await apiRequest(`${BASE}/${scheduleId}`, { method: 'PATCH', body: JSON.stringify(body) }) } /** Stop creating new runs. Runs already created still go ahead. */ export async function pauseSchedule(scheduleId: string): Promise { return await apiRequest(`${BASE}/${scheduleId}/pause`, { method: 'POST' }) } /** Start again from the next due time; missed times are not replayed. Resets the auto-pause count. */ export async function resumeSchedule(scheduleId: string): Promise { return await apiRequest(`${BASE}/${scheduleId}/resume`, { method: 'POST' }) } /** Delete a schedule. Pending runs are cancelled; runs that already happened keep their history. */ export async function deleteSchedule(scheduleId: string): Promise { await apiRequest(`${BASE}/${scheduleId}`, { method: 'DELETE' }) }