Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
41 changes: 40 additions & 1 deletion 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 Down Expand Up @@ -103,6 +103,45 @@ 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

const authResult = await fetchAndAuthorize(requestId, scheduleId, session.user.id, 'read')
if (authResult instanceof NextResponse) return authResult

const [row] = await db
.select()
.from(workflowSchedule)
.where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt)))
.limit(1)

if (!row) {
return NextResponse.json({ error: 'Schedule not found' }, { status: 404 })
}
Comment thread
Sg312 marked this conversation as resolved.
Outdated

return NextResponse.json({ schedule: row })
} 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
44 changes: 43 additions & 1 deletion 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 { 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,34 @@ 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)
)
)
.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