Skip to content

Commit 6cbaf42

Browse files
authored
fix(scheduled-tasks): fix scheduled tasks schema validation (#5091)
* fix(scheduled-tasks): fix schema rejection * fix(db): fix duplicate db query
1 parent 91666b5 commit 6cbaf42

6 files changed

Lines changed: 132 additions & 30 deletions

File tree

apps/sim/app/api/schedules/[id]/route.ts

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from '@sim/workflow-authz'
1010
import { and, eq, isNull } from 'drizzle-orm'
1111
import { type NextRequest, NextResponse } from 'next/server'
12-
import { updateScheduleContract } from '@/lib/api/contracts/schedules'
12+
import { getScheduleByIdContract, updateScheduleContract } from '@/lib/api/contracts/schedules'
1313
import { parseRequest } from '@/lib/api/server'
1414
import { getSession } from '@/lib/auth'
1515
import { generateRequestId } from '@/lib/core/utils/request'
@@ -27,16 +27,7 @@ const logger = createLogger('ScheduleAPI')
2727

2828
export const dynamic = 'force-dynamic'
2929

30-
type ScheduleRow = {
31-
id: string
32-
workflowId: string | null
33-
status: string
34-
cronExpression: string | null
35-
timezone: string | null
36-
sourceType: string | null
37-
sourceWorkspaceId: string | null
38-
jobTitle: string | null
39-
}
30+
type ScheduleRow = typeof workflowSchedule.$inferSelect
4031

4132
async function fetchAndAuthorize(
4233
requestId: string,
@@ -45,16 +36,7 @@ async function fetchAndAuthorize(
4536
action: 'read' | 'write'
4637
): Promise<{ schedule: ScheduleRow; workspaceId: string | null } | NextResponse> {
4738
const [schedule] = await db
48-
.select({
49-
id: workflowSchedule.id,
50-
workflowId: workflowSchedule.workflowId,
51-
status: workflowSchedule.status,
52-
cronExpression: workflowSchedule.cronExpression,
53-
timezone: workflowSchedule.timezone,
54-
sourceType: workflowSchedule.sourceType,
55-
sourceWorkspaceId: workflowSchedule.sourceWorkspaceId,
56-
jobTitle: workflowSchedule.jobTitle,
57-
})
39+
.select()
5840
.from(workflowSchedule)
5941
.where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt)))
6042
.limit(1)
@@ -103,6 +85,37 @@ async function fetchAndAuthorize(
10385
return { schedule, workspaceId: authorization.workflow.workspaceId ?? null }
10486
}
10587

88+
export const GET = withRouteHandler(
89+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
90+
const requestId = generateRequestId()
91+
92+
try {
93+
const session = await getSession()
94+
if (!session?.user?.id) {
95+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
96+
}
97+
98+
const parsed = await parseRequest(getScheduleByIdContract, request, context, {
99+
validationErrorResponse: () =>
100+
NextResponse.json({ error: 'Invalid request' }, { status: 400 }),
101+
})
102+
if (!parsed.success) return parsed.response
103+
104+
const { id: scheduleId } = parsed.data.params
105+
106+
// fetchAndAuthorize already loads the full row (and 404s if missing), so
107+
// return it directly — no second query.
108+
const authResult = await fetchAndAuthorize(requestId, scheduleId, session.user.id, 'read')
109+
if (authResult instanceof NextResponse) return authResult
110+
111+
return NextResponse.json({ schedule: authResult.schedule })
112+
} catch (error) {
113+
logger.error(`[${requestId}] Failed to get schedule`, { error })
114+
return NextResponse.json({ error: 'Failed to get schedule' }, { status: 500 })
115+
}
116+
}
117+
)
118+
106119
export const PUT = withRouteHandler(
107120
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
108121
const requestId = generateRequestId()

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/com
5353
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
5454
import { useFolders } from '@/hooks/queries/folders'
5555
import { useLogDetail } from '@/hooks/queries/logs'
56-
import { useWorkspaceSchedules } from '@/hooks/queries/schedules'
56+
import { useScheduleById } from '@/hooks/queries/schedules'
5757
import { downloadTableExport } from '@/hooks/queries/tables'
5858
import { useWorkflows } from '@/hooks/queries/workflows'
5959
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
@@ -693,12 +693,8 @@ interface EmbeddedScheduledTaskProps {
693693
scheduleId: string
694694
}
695695

696-
function EmbeddedScheduledTask({ workspaceId, scheduleId }: EmbeddedScheduledTaskProps) {
697-
const { data: schedules = [], isLoading, isError } = useWorkspaceSchedules(workspaceId)
698-
const schedule = useMemo(
699-
() => schedules.find((s) => s.id === scheduleId),
700-
[schedules, scheduleId]
701-
)
696+
function EmbeddedScheduledTask({ scheduleId }: EmbeddedScheduledTaskProps) {
697+
const { data: schedule, isLoading, isError } = useScheduleById(scheduleId)
702698

703699
if (isLoading && !schedule) return LOADING_SKELETON
704700

apps/sim/hooks/queries/schedules.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
deleteScheduleContract,
1212
disableScheduleContract,
1313
excludeOccurrenceContract,
14+
getScheduleByIdContract,
1415
getScheduleContract,
1516
listWorkspaceSchedulesContract,
1617
reactivateScheduleContract,
@@ -31,6 +32,7 @@ export const scheduleKeys = {
3132
details: () => [...scheduleKeys.all, 'detail'] as const,
3233
schedule: (workflowId: string, blockId: string) =>
3334
[...scheduleKeys.details(), workflowId, blockId] as const,
35+
byId: (scheduleId: string) => [...scheduleKeys.details(), scheduleId] as const,
3436
}
3537

3638
export type ScheduleData = WorkflowScheduleRow
@@ -88,6 +90,30 @@ export function useWorkspaceSchedules(workspaceId?: string) {
8890
})
8991
}
9092

93+
/**
94+
* Fetch a single schedule (job) by id. Used by the mothership resource viewer so
95+
* opening a scheduled-task artifact does a lightweight by-id read instead of the
96+
* whole-workspace `useWorkspaceSchedules` fetch (which contended with the chat
97+
* stream connection and stalled start/resume).
98+
*/
99+
export function useScheduleById(scheduleId?: string) {
100+
return useQuery({
101+
queryKey: scheduleKeys.byId(scheduleId ?? ''),
102+
queryFn: async ({ signal }) => {
103+
if (!scheduleId) throw new Error('Schedule ID required')
104+
105+
const data = await requestJson(getScheduleByIdContract, {
106+
params: { id: scheduleId },
107+
signal,
108+
})
109+
return data.schedule
110+
},
111+
enabled: Boolean(scheduleId),
112+
staleTime: 30 * 1000,
113+
placeholderData: keepPreviousData,
114+
})
115+
}
116+
91117
/**
92118
* Hook to fetch schedule data for a workflow block
93119
*/

apps/sim/lib/api/contracts/schedules.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,23 @@ export const listWorkspaceSchedulesContract = defineRouteContract({
216216
},
217217
})
218218

219+
/**
220+
* Single-schedule read by id. Used by the mothership resource viewer so opening
221+
* a scheduled-task artifact does a lightweight by-id fetch instead of pulling
222+
* the entire workspace schedule list (which contended with the chat stream).
223+
*/
224+
export const getScheduleByIdContract = defineRouteContract({
225+
method: 'GET',
226+
path: '/api/schedules/[id]',
227+
params: scheduleIdParamsSchema,
228+
response: {
229+
mode: 'json',
230+
schema: z.object({
231+
schedule: workflowScheduleRowSchema,
232+
}),
233+
},
234+
})
235+
219236
/**
220237
* Newly-created job schedules emit a partial summary with the canonical fields
221238
* the route synthesizes server-side; everything else is filled in on

apps/sim/lib/copilot/chat/post.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const ResourceAttachmentSchema = z.object({
7575
'filefolder',
7676
'task',
7777
'log',
78+
'scheduledtask',
7879
'generic',
7980
]),
8081
id: z.string().min(1),
@@ -91,6 +92,7 @@ const GENERIC_RESOURCE_TITLE: Record<z.infer<typeof ResourceAttachmentSchema>['t
9192
filefolder: 'File Folder',
9293
task: 'Task',
9394
log: 'Log',
95+
scheduledtask: 'Scheduled Task',
9496
generic: 'Resource',
9597
}
9698

@@ -108,6 +110,7 @@ const ChatContextSchema = z.object({
108110
'file',
109111
'folder',
110112
'filefolder',
113+
'scheduledtask',
111114
'integration',
112115
'skill',
113116
]),
@@ -123,6 +126,7 @@ const ChatContextSchema = z.object({
123126
folderId: z.string().optional(),
124127
fileFolderId: z.string().optional(),
125128
skillId: z.string().optional(),
129+
scheduleId: z.string().optional(),
126130
})
127131

128132
const ChatMessageSchema = z.object({

apps/sim/lib/copilot/chat/process-contents.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { db, dbReplica } from '@sim/db'
2-
import { knowledgeBase } from '@sim/db/schema'
2+
import { knowledgeBase, workflowSchedule } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import {
55
authorizeWorkflowByWorkspacePermission,
66
getActiveWorkflowRecord,
77
} from '@sim/workflow-authz'
8-
import { and, eq, isNull } from 'drizzle-orm'
8+
import { and, eq, isNull, ne } from 'drizzle-orm'
9+
import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment'
910
import {
1011
buildVfsFolderPathMap,
1112
canonicalBlockVfsPath,
@@ -168,6 +169,16 @@ export async function processContextsServer(
168169
path: result.path,
169170
}
170171
}
172+
if (ctx.kind === 'scheduledtask' && ctx.scheduleId && currentWorkspaceId) {
173+
const result = await resolveScheduledTaskResource(ctx.scheduleId, currentWorkspaceId)
174+
if (!result) return null
175+
return {
176+
type: 'active_resource',
177+
tag: ctx.label ? `@${ctx.label}` : '@',
178+
content: result.content,
179+
path: result.path,
180+
}
181+
}
171182
if (ctx.kind === 'docs') {
172183
try {
173184
const { searchDocumentationServerTool } = await import(
@@ -695,6 +706,9 @@ export async function resolveActiveResourceContext(
695706
case 'filefolder': {
696707
return await resolveFileFolderResource(resourceId, workspaceId)
697708
}
709+
case 'scheduledtask': {
710+
return await resolveScheduledTaskResource(resourceId, workspaceId)
711+
}
698712
default:
699713
return null
700714
}
@@ -718,6 +732,38 @@ async function resolveTableResource(
718732
}
719733
}
720734

735+
async function resolveScheduledTaskResource(
736+
scheduleId: string,
737+
workspaceId: string
738+
): Promise<AgentContext | null> {
739+
const [row] = await db
740+
.select({ id: workflowSchedule.id, jobTitle: workflowSchedule.jobTitle })
741+
.from(workflowSchedule)
742+
.where(
743+
and(
744+
eq(workflowSchedule.id, scheduleId),
745+
eq(workflowSchedule.sourceWorkspaceId, workspaceId),
746+
eq(workflowSchedule.sourceType, 'job'),
747+
isNull(workflowSchedule.archivedAt),
748+
// Mirror the VFS materializer (workspace-vfs `materializeJobs`), which
749+
// excludes completed jobs — otherwise we'd point at a meta.json it never
750+
// wrote and the agent's read would dangle.
751+
ne(workflowSchedule.status, 'completed')
752+
)
753+
)
754+
.limit(1)
755+
if (!row) return null
756+
// The VFS materializes jobs at `jobs/{sanitized title}/meta.json` (see
757+
// workspace-vfs `materializeJobs`); emit the same lightweight path pointer so
758+
// the agent reads it via the VFS instead of us inlining the (heavy) row.
759+
return {
760+
type: 'active_resource',
761+
tag: '@active_resource',
762+
content: '',
763+
path: `jobs/${normalizeVfsSegment(row.jobTitle || row.id)}/meta.json`,
764+
}
765+
}
766+
721767
async function resolveFileResource(
722768
fileId: string,
723769
workspaceId: string

0 commit comments

Comments
 (0)