Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 34 additions & 21 deletions apps/sim/app/api/schedules/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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

Expand Down
26 changes: 26 additions & 0 deletions apps/sim/hooks/queries/schedules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
deleteScheduleContract,
disableScheduleContract,
excludeOccurrenceContract,
getScheduleByIdContract,
getScheduleContract,
listWorkspaceSchedulesContract,
reactivateScheduleContract,
Expand All @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down
17 changes: 17 additions & 0 deletions apps/sim/lib/api/contracts/schedules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/lib/copilot/chat/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const ResourceAttachmentSchema = z.object({
'filefolder',
'task',
'log',
'scheduledtask',
'generic',
]),
id: z.string().min(1),
Expand All @@ -91,6 +92,7 @@ const GENERIC_RESOURCE_TITLE: Record<z.infer<typeof ResourceAttachmentSchema>['t
filefolder: 'File Folder',
task: 'Task',
log: 'Log',
scheduledtask: 'Scheduled Task',
generic: 'Resource',
}

Expand All @@ -108,6 +110,7 @@ const ChatContextSchema = z.object({
'file',
'folder',
'filefolder',
'scheduledtask',
'integration',
'skill',
]),
Expand All @@ -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({
Expand Down
50 changes: 48 additions & 2 deletions apps/sim/lib/copilot/chat/process-contents.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
}
Expand All @@ -718,6 +732,38 @@ async function resolveTableResource(
}
}

async function resolveScheduledTaskResource(
scheduleId: string,
workspaceId: string
): Promise<AgentContext | null> {
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
Expand Down
Loading