Skip to content

Commit b4d94b3

Browse files
committed
fix(scheduled-tasks): fix schema rejection
1 parent 91666b5 commit b4d94b3

6 files changed

Lines changed: 133 additions & 9 deletions

File tree

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

Lines changed: 40 additions & 1 deletion
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'
@@ -103,6 +103,45 @@ async function fetchAndAuthorize(
103103
return { schedule, workspaceId: authorization.workflow.workspaceId ?? null }
104104
}
105105

106+
export const GET = withRouteHandler(
107+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
108+
const requestId = generateRequestId()
109+
110+
try {
111+
const session = await getSession()
112+
if (!session?.user?.id) {
113+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
114+
}
115+
116+
const parsed = await parseRequest(getScheduleByIdContract, request, context, {
117+
validationErrorResponse: () =>
118+
NextResponse.json({ error: 'Invalid request' }, { status: 400 }),
119+
})
120+
if (!parsed.success) return parsed.response
121+
122+
const { id: scheduleId } = parsed.data.params
123+
124+
const authResult = await fetchAndAuthorize(requestId, scheduleId, session.user.id, 'read')
125+
if (authResult instanceof NextResponse) return authResult
126+
127+
const [row] = await db
128+
.select()
129+
.from(workflowSchedule)
130+
.where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt)))
131+
.limit(1)
132+
133+
if (!row) {
134+
return NextResponse.json({ error: 'Schedule not found' }, { status: 404 })
135+
}
136+
137+
return NextResponse.json({ schedule: row })
138+
} catch (error) {
139+
logger.error(`[${requestId}] Failed to get schedule`, { error })
140+
return NextResponse.json({ error: 'Failed to get schedule' }, { status: 500 })
141+
}
142+
}
143+
)
144+
106145
export const PUT = withRouteHandler(
107146
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
108147
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: 43 additions & 1 deletion
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'
88
import { and, eq, isNull } 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,34 @@ 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+
)
749+
)
750+
.limit(1)
751+
if (!row) return null
752+
// The VFS materializes jobs at `jobs/{sanitized title}/meta.json` (see
753+
// workspace-vfs `materializeJobs`); emit the same lightweight path pointer so
754+
// the agent reads it via the VFS instead of us inlining the (heavy) row.
755+
return {
756+
type: 'active_resource',
757+
tag: '@active_resource',
758+
content: '',
759+
path: `jobs/${normalizeVfsSegment(row.jobTitle || row.id)}/meta.json`,
760+
}
761+
}
762+
721763
async function resolveFileResource(
722764
fileId: string,
723765
workspaceId: string

0 commit comments

Comments
 (0)