diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index 8e6e575c07..62455bb85d 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -9,7 +9,7 @@ import { } from '@sim/workflow-authz' import { and, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { updateScheduleContract } from '@/lib/api/contracts/schedules' +import { getScheduleByIdContract, updateScheduleContract } from '@/lib/api/contracts/schedules' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' @@ -27,16 +27,7 @@ const logger = createLogger('ScheduleAPI') export const dynamic = 'force-dynamic' -type ScheduleRow = { - id: string - workflowId: string | null - status: string - cronExpression: string | null - timezone: string | null - sourceType: string | null - sourceWorkspaceId: string | null - jobTitle: string | null -} +type ScheduleRow = typeof workflowSchedule.$inferSelect async function fetchAndAuthorize( requestId: string, @@ -45,16 +36,7 @@ async function fetchAndAuthorize( action: 'read' | 'write' ): Promise<{ schedule: ScheduleRow; workspaceId: string | null } | NextResponse> { const [schedule] = await db - .select({ - id: workflowSchedule.id, - workflowId: workflowSchedule.workflowId, - status: workflowSchedule.status, - cronExpression: workflowSchedule.cronExpression, - timezone: workflowSchedule.timezone, - sourceType: workflowSchedule.sourceType, - sourceWorkspaceId: workflowSchedule.sourceWorkspaceId, - jobTitle: workflowSchedule.jobTitle, - }) + .select() .from(workflowSchedule) .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) .limit(1) @@ -103,6 +85,37 @@ async function fetchAndAuthorize( return { schedule, workspaceId: authorization.workflow.workspaceId ?? null } } +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const parsed = await parseRequest(getScheduleByIdContract, request, context, { + validationErrorResponse: () => + NextResponse.json({ error: 'Invalid request' }, { status: 400 }), + }) + if (!parsed.success) return parsed.response + + const { id: scheduleId } = parsed.data.params + + // fetchAndAuthorize already loads the full row (and 404s if missing), so + // return it directly — no second query. + const authResult = await fetchAndAuthorize(requestId, scheduleId, session.user.id, 'read') + if (authResult instanceof NextResponse) return authResult + + return NextResponse.json({ schedule: authResult.schedule }) + } catch (error) { + logger.error(`[${requestId}] Failed to get schedule`, { error }) + return NextResponse.json({ error: 'Failed to get schedule' }, { status: 500 }) + } + } +) + export const PUT = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const requestId = generateRequestId() diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 55219f7500..050aa22b3d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -53,7 +53,7 @@ import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/com import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import { useFolders } from '@/hooks/queries/folders' import { useLogDetail } from '@/hooks/queries/logs' -import { useWorkspaceSchedules } from '@/hooks/queries/schedules' +import { useScheduleById } from '@/hooks/queries/schedules' import { downloadTableExport } from '@/hooks/queries/tables' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' @@ -693,12 +693,8 @@ interface EmbeddedScheduledTaskProps { scheduleId: string } -function EmbeddedScheduledTask({ workspaceId, scheduleId }: EmbeddedScheduledTaskProps) { - const { data: schedules = [], isLoading, isError } = useWorkspaceSchedules(workspaceId) - const schedule = useMemo( - () => schedules.find((s) => s.id === scheduleId), - [schedules, scheduleId] - ) +function EmbeddedScheduledTask({ scheduleId }: EmbeddedScheduledTaskProps) { + const { data: schedule, isLoading, isError } = useScheduleById(scheduleId) if (isLoading && !schedule) return LOADING_SKELETON diff --git a/apps/sim/hooks/queries/schedules.ts b/apps/sim/hooks/queries/schedules.ts index 42108faf8e..c3f035a0d5 100644 --- a/apps/sim/hooks/queries/schedules.ts +++ b/apps/sim/hooks/queries/schedules.ts @@ -11,6 +11,7 @@ import { deleteScheduleContract, disableScheduleContract, excludeOccurrenceContract, + getScheduleByIdContract, getScheduleContract, listWorkspaceSchedulesContract, reactivateScheduleContract, @@ -31,6 +32,7 @@ export const scheduleKeys = { details: () => [...scheduleKeys.all, 'detail'] as const, schedule: (workflowId: string, blockId: string) => [...scheduleKeys.details(), workflowId, blockId] as const, + byId: (scheduleId: string) => [...scheduleKeys.details(), scheduleId] as const, } export type ScheduleData = WorkflowScheduleRow @@ -88,6 +90,30 @@ export function useWorkspaceSchedules(workspaceId?: string) { }) } +/** + * Fetch a single schedule (job) by id. Used by the mothership resource viewer so + * opening a scheduled-task artifact does a lightweight by-id read instead of the + * whole-workspace `useWorkspaceSchedules` fetch (which contended with the chat + * stream connection and stalled start/resume). + */ +export function useScheduleById(scheduleId?: string) { + return useQuery({ + queryKey: scheduleKeys.byId(scheduleId ?? ''), + queryFn: async ({ signal }) => { + if (!scheduleId) throw new Error('Schedule ID required') + + const data = await requestJson(getScheduleByIdContract, { + params: { id: scheduleId }, + signal, + }) + return data.schedule + }, + enabled: Boolean(scheduleId), + staleTime: 30 * 1000, + placeholderData: keepPreviousData, + }) +} + /** * Hook to fetch schedule data for a workflow block */ diff --git a/apps/sim/lib/api/contracts/schedules.ts b/apps/sim/lib/api/contracts/schedules.ts index ca48bce764..d4eaf3d5e1 100644 --- a/apps/sim/lib/api/contracts/schedules.ts +++ b/apps/sim/lib/api/contracts/schedules.ts @@ -216,6 +216,23 @@ export const listWorkspaceSchedulesContract = defineRouteContract({ }, }) +/** + * Single-schedule read by id. Used by the mothership resource viewer so opening + * a scheduled-task artifact does a lightweight by-id fetch instead of pulling + * the entire workspace schedule list (which contended with the chat stream). + */ +export const getScheduleByIdContract = defineRouteContract({ + method: 'GET', + path: '/api/schedules/[id]', + params: scheduleIdParamsSchema, + response: { + mode: 'json', + schema: z.object({ + schedule: workflowScheduleRowSchema, + }), + }, +}) + /** * Newly-created job schedules emit a partial summary with the canonical fields * the route synthesizes server-side; everything else is filled in on diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/copilot/chat/post.ts index 721aa8b551..e1347d0531 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/copilot/chat/post.ts @@ -75,6 +75,7 @@ const ResourceAttachmentSchema = z.object({ 'filefolder', 'task', 'log', + 'scheduledtask', 'generic', ]), id: z.string().min(1), @@ -91,6 +92,7 @@ const GENERIC_RESOURCE_TITLE: Record['t filefolder: 'File Folder', task: 'Task', log: 'Log', + scheduledtask: 'Scheduled Task', generic: 'Resource', } @@ -108,6 +110,7 @@ const ChatContextSchema = z.object({ 'file', 'folder', 'filefolder', + 'scheduledtask', 'integration', 'skill', ]), @@ -123,6 +126,7 @@ const ChatContextSchema = z.object({ folderId: z.string().optional(), fileFolderId: z.string().optional(), skillId: z.string().optional(), + scheduleId: z.string().optional(), }) const ChatMessageSchema = z.object({ diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 81f4eae47c..3edafe1ca9 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -1,11 +1,12 @@ import { db, dbReplica } from '@sim/db' -import { knowledgeBase } from '@sim/db/schema' +import { knowledgeBase, workflowSchedule } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission, getActiveWorkflowRecord, } from '@sim/workflow-authz' -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq, isNull, ne } from 'drizzle-orm' +import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { buildVfsFolderPathMap, canonicalBlockVfsPath, @@ -168,6 +169,16 @@ export async function processContextsServer( path: result.path, } } + if (ctx.kind === 'scheduledtask' && ctx.scheduleId && currentWorkspaceId) { + const result = await resolveScheduledTaskResource(ctx.scheduleId, currentWorkspaceId) + if (!result) return null + return { + type: 'active_resource', + tag: ctx.label ? `@${ctx.label}` : '@', + content: result.content, + path: result.path, + } + } if (ctx.kind === 'docs') { try { const { searchDocumentationServerTool } = await import( @@ -695,6 +706,9 @@ export async function resolveActiveResourceContext( case 'filefolder': { return await resolveFileFolderResource(resourceId, workspaceId) } + case 'scheduledtask': { + return await resolveScheduledTaskResource(resourceId, workspaceId) + } default: return null } @@ -718,6 +732,38 @@ async function resolveTableResource( } } +async function resolveScheduledTaskResource( + scheduleId: string, + workspaceId: string +): Promise { + const [row] = await db + .select({ id: workflowSchedule.id, jobTitle: workflowSchedule.jobTitle }) + .from(workflowSchedule) + .where( + and( + eq(workflowSchedule.id, scheduleId), + eq(workflowSchedule.sourceWorkspaceId, workspaceId), + eq(workflowSchedule.sourceType, 'job'), + isNull(workflowSchedule.archivedAt), + // Mirror the VFS materializer (workspace-vfs `materializeJobs`), which + // excludes completed jobs — otherwise we'd point at a meta.json it never + // wrote and the agent's read would dangle. + ne(workflowSchedule.status, 'completed') + ) + ) + .limit(1) + if (!row) return null + // The VFS materializes jobs at `jobs/{sanitized title}/meta.json` (see + // workspace-vfs `materializeJobs`); emit the same lightweight path pointer so + // the agent reads it via the VFS instead of us inlining the (heavy) row. + return { + type: 'active_resource', + tag: '@active_resource', + content: '', + path: `jobs/${normalizeVfsSegment(row.jobTitle || row.id)}/meta.json`, + } +} + async function resolveFileResource( fileId: string, workspaceId: string