Skip to content

Commit 4469909

Browse files
committed
feat(scheduled-tasks): persist + run tasks via the job-schedule backend
Wire the calendar UI to the existing sourceType='job' workflow_schedule backend instead of local component state, so tasks persist and actually run as Sim agent invocations. - schema: add contexts (@-mentions resolved into the run), excludedDates (per-occurrence deletes), and endsAt (recurrence end) to workflow_schedule (migration 0235) - contracts/schedules: expose one-time `time`, contexts, endsAt on create; add the exclude_occurrence action; nullable cron in the create response - orchestration: persist the new fields, honor exclusions + end boundary via a shared computeNextRunAt, add performExcludeOccurrence - execution: forward contexts to /api/mothership/execute and recompute the next run through computeNextRunAt - mothership/execute: accept + resolve contexts like the interactive chat path - frontend: replace the local hook with React Query (create/update/delete + exclude-occurrence), expand recurrences into calendar occurrences, add the recurrence control (frequency + end) and the recurring this/all delete dialog
1 parent d67efa1 commit 4469909

24 files changed

Lines changed: 17877 additions & 224 deletions

File tree

apps/sim/app/api/mothership/execute/route.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { mothershipExecuteContract } from '@/lib/api/contracts/mothership-chats'
66
import { parseRequest } from '@/lib/api/server'
77
import { checkInternalAuth } from '@/lib/auth/hybrid'
88
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload'
9+
import { processContextsServer } from '@/lib/copilot/chat/process-contents'
910
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
1011
import {
1112
MothershipStreamV1EventType,
@@ -22,6 +23,7 @@ import {
2223
getUserEntityPermissions,
2324
isWorkspaceAccessDeniedError,
2425
} from '@/lib/workspaces/permissions/utils'
26+
import type { ChatContext } from '@/stores/panel'
2527

2628
export const maxDuration = 3600
2729

@@ -102,6 +104,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
102104
messageId: providedMessageId,
103105
requestId: providedRequestId,
104106
fileAttachments,
107+
contexts,
105108
workflowId,
106109
executionId,
107110
userMetadata,
@@ -135,12 +138,22 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
135138
workflowId,
136139
executionId,
137140
})
138-
const [workspaceContext, integrationTools, userSkillTool, userPermission] = await Promise.all([
139-
generateWorkspaceContext(workspaceId, userId),
140-
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
141-
buildUserSkillTool(workspaceId),
142-
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
143-
])
141+
const lastUserMessage = messages.filter((m) => m.role === 'user').at(-1)?.content
142+
const [workspaceContext, integrationTools, userSkillTool, userPermission, agentContexts] =
143+
await Promise.all([
144+
generateWorkspaceContext(workspaceId, userId),
145+
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
146+
buildUserSkillTool(workspaceId),
147+
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
148+
// double-cast-allowed: the contract validates contexts as open kind/label objects; processContextsServer narrows on `kind` at runtime
149+
processContextsServer(
150+
contexts as unknown as ChatContext[] | undefined,
151+
userId,
152+
lastUserMessage,
153+
workspaceId,
154+
effectiveChatId
155+
).catch(() => []),
156+
])
144157
const requestPayload: Record<string, unknown> = {
145158
messages,
146159
responseFormat,
@@ -159,6 +172,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
159172
...(isE2BDocEnabled ? { docCompiler: 'python' } : {}),
160173
...(userMetadata ? { userMetadata } : {}),
161174
...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}),
175+
...(agentContexts.length > 0 ? { contexts: agentContexts } : {}),
162176
...(integrationTools.length > 0 ? { integrationTools } : {}),
163177
...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}),
164178
...(userPermission ? { userPermission } : {}),

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import { getSession } from '@/lib/auth'
1515
import { generateRequestId } from '@/lib/core/utils/request'
1616
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1717
import { captureServerEvent } from '@/lib/posthog/server'
18-
import { performDeleteJob, performUpdateJob } from '@/lib/workflows/schedules/orchestration'
18+
import {
19+
performDeleteJob,
20+
performExcludeOccurrence,
21+
performUpdateJob,
22+
} from '@/lib/workflows/schedules/orchestration'
1923
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
2024
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
2125

@@ -185,6 +189,9 @@ export const PUT = withRouteHandler(
185189
lifecycle: validatedBody.lifecycle,
186190
maxRuns: validatedBody.maxRuns,
187191
cronExpression: validatedBody.cronExpression,
192+
time: validatedBody.time,
193+
endsAt: validatedBody.endsAt,
194+
contexts: validatedBody.contexts,
188195
request,
189196
})
190197
if (!updateResult.success) {
@@ -199,6 +206,45 @@ export const PUT = withRouteHandler(
199206
return NextResponse.json({ message: 'Schedule updated successfully' })
200207
}
201208

209+
if (action === 'exclude_occurrence') {
210+
if (schedule.sourceType !== 'job') {
211+
return NextResponse.json(
212+
{ error: 'Only standalone job schedules have occurrences' },
213+
{ status: 400 }
214+
)
215+
}
216+
if (!workspaceId) {
217+
return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 })
218+
}
219+
220+
const excludeResult = await performExcludeOccurrence({
221+
jobId: scheduleId,
222+
workspaceId,
223+
userId: session.user.id,
224+
actorName: session.user.name,
225+
actorEmail: session.user.email,
226+
occurrence: validatedBody.occurrence,
227+
request,
228+
})
229+
if (!excludeResult.success) {
230+
return NextResponse.json(
231+
{ error: excludeResult.error || 'Failed to delete occurrence' },
232+
{
233+
status:
234+
excludeResult.errorCode === 'not_found'
235+
? 404
236+
: excludeResult.errorCode === 'validation'
237+
? 400
238+
: 500,
239+
}
240+
)
241+
}
242+
243+
logger.info(`[${requestId}] Excluded occurrence on job schedule: ${scheduleId}`)
244+
245+
return NextResponse.json({ message: 'Occurrence deleted successfully' })
246+
}
247+
202248
// reactivate
203249
if (schedule.status === 'active') {
204250
return NextResponse.json({ message: 'Schedule is already active' })

apps/sim/app/api/schedules/route.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,19 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
214214
)
215215
if (!parsed.success) return parsed.response
216216

217-
const { workspaceId, title, prompt, cronExpression, timezone, lifecycle, maxRuns, startDate } =
218-
parsed.data.body
217+
const {
218+
workspaceId,
219+
title,
220+
prompt,
221+
cronExpression,
222+
time,
223+
timezone,
224+
lifecycle,
225+
maxRuns,
226+
endsAt,
227+
startDate,
228+
contexts,
229+
} = parsed.data.body
219230

220231
const permission = await verifyWorkspaceMembership(session.user.id, workspaceId)
221232
if (permission !== 'admin' && permission !== 'write') {
@@ -230,10 +241,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
230241
title,
231242
prompt,
232243
cronExpression,
244+
time,
233245
timezone,
234246
lifecycle,
235247
maxRuns,
248+
endsAt,
236249
startDate,
250+
contexts,
237251
request: req,
238252
})
239253
if (!result.success || !result.schedule) {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { TaskDeleteDialog } from './task-delete-dialog'
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
'use client'
2+
3+
import {
4+
Calendar,
5+
ChipConfirmModal,
6+
ChipModal,
7+
ChipModalBody,
8+
ChipModalFooter,
9+
ChipModalHeader,
10+
} from '@/components/emcn'
11+
import type { ScheduledTask } from '@/app/workspace/[workspaceId]/scheduled-tasks/utils/schedule-events'
12+
13+
interface TaskDeleteDialogProps {
14+
/** The task targeted for deletion, or `null` to keep the dialog closed. */
15+
task: ScheduledTask | null
16+
onClose: () => void
17+
/** Delete just the targeted occurrence of a recurring task. */
18+
onDeleteOccurrence: (task: ScheduledTask) => void
19+
/** Delete a one-time task, or the entire recurring series. */
20+
onDeleteSeries: (task: ScheduledTask) => void
21+
}
22+
23+
/**
24+
* Deletion confirmation for a scheduled task. A one-time task takes a single
25+
* confirm; a recurring task offers the calendar-app choice between deleting
26+
* this occurrence and deleting the whole series.
27+
*/
28+
export function TaskDeleteDialog({
29+
task,
30+
onClose,
31+
onDeleteOccurrence,
32+
onDeleteSeries,
33+
}: TaskDeleteDialogProps) {
34+
if (task && !task.recurring) {
35+
return (
36+
<ChipConfirmModal
37+
open
38+
onOpenChange={(open) => {
39+
if (!open) onClose()
40+
}}
41+
title='Delete scheduled task'
42+
text='This task will be removed from the calendar and will not run.'
43+
confirm={{
44+
label: 'Delete',
45+
onClick: () => {
46+
onDeleteSeries(task)
47+
onClose()
48+
},
49+
}}
50+
/>
51+
)
52+
}
53+
54+
return (
55+
<ChipModal
56+
open={task !== null}
57+
onOpenChange={(open) => {
58+
if (!open) onClose()
59+
}}
60+
size='sm'
61+
srTitle='Delete recurring task'
62+
>
63+
{task && (
64+
<>
65+
<ChipModalHeader icon={Calendar} onClose={onClose}>
66+
Delete recurring task
67+
</ChipModalHeader>
68+
<ChipModalBody>
69+
<p className='px-2 text-[var(--text-body)] text-sm'>
70+
This is a recurring task. Delete only this occurrence, or the entire series?
71+
</p>
72+
</ChipModalBody>
73+
<ChipModalFooter
74+
onCancel={onClose}
75+
secondaryActions={[
76+
{
77+
label: 'This task',
78+
variant: 'destructive',
79+
onClick: () => {
80+
onDeleteOccurrence(task)
81+
onClose()
82+
},
83+
},
84+
]}
85+
primaryAction={{
86+
label: 'All tasks',
87+
variant: 'destructive',
88+
onClick: () => {
89+
onDeleteSeries(task)
90+
onClose()
91+
},
92+
}}
93+
/>
94+
</>
95+
)}
96+
</ChipModal>
97+
)
98+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { type TaskDraft, TaskModal } from './task-modal'
1+
export { type TaskDraft, type TaskEditSeed, TaskModal } from './task-modal'

0 commit comments

Comments
 (0)