Skip to content

Commit 1bd3ea0

Browse files
committed
chore(scheduled-tasks): remove the scheduled-task logic
Scheduled tasks are retired. This removes the `sourceType = 'job'` half of `workflow_schedule` from the application, leaving the workflow Schedule trigger (`sourceType = 'workflow'`) untouched. Gone: - the job orchestration layer (`lib/workflows/schedules/orchestration.ts`) and the agent-job runner in `background/schedule-execution.ts` - the job claim/dispatch half of the schedules execute tick - POST /api/schedules (job creation) and the job branches of GET /api/schedules and PUT/DELETE /api/schedules/[id] - the copilot job tools and handlers, the `scheduledtask` resource type and chat-context kind, and the VFS `jobs/` materialization - the scheduled-task analytics events and the job variant of the schedule-disabled email Kept on purpose: `scheduled-tasks/components/schedule-calendar/**` and `scheduled-tasks/utils/**`, which the agents module will reuse. `packages/db/schema.ts` is deliberately untouched — the columns stay for now and come out in a follow-up with a proper expand/contract migration. The generated copilot catalog and VFS snapshot types are regenerated from the matching copilot PR, which removes the tools and the `jobs` snapshot field at the source. Verified: 23/23 type-check, biome, api-validation, production build, and the full vitest suite (18361 passing; the one failure in executor/handlers/pi/cloud-review-tools.test.ts predates this branch).
1 parent f37fe21 commit 1bd3ea0

49 files changed

Lines changed: 85 additions & 3694 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/emails/preview/route.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ const emailTemplates = {
118118
'schedule-disabled': () =>
119119
renderScheduleDisabledEmail({
120120
recipientName: 'John',
121-
kind: 'workflow',
122121
resourceName: 'Daily digest',
123122
reason: 'consecutive_failures',
124123
failedCount: 100,
@@ -127,10 +126,9 @@ const emailTemplates = {
127126
'schedule-disabled-auth': () =>
128127
renderScheduleDisabledEmail({
129128
recipientName: 'John',
130-
kind: 'job',
131129
resourceName: 'Weekly report',
132130
reason: 'authentication_error',
133-
manageLink: 'https://sim.ai/workspace/ws_123/scheduled-tasks',
131+
manageLink: 'https://sim.ai/workspace/ws_123/w/wf_456',
134132
}),
135133
} as const
136134

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

Lines changed: 1 addition & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
8989
}))
9090

9191
import type { CopilotLifecycleOptions } from '@/lib/copilot/request/lifecycle/run'
92-
import {
93-
buildExecuteResponsePayload,
94-
CALLER_VISIBLE_SERVER_TOOLS,
95-
POST,
96-
} from '@/app/api/mothership/execute/route'
92+
import { buildExecuteResponsePayload, POST } from '@/app/api/mothership/execute/route'
9793

9894
type Payload = Parameters<typeof buildExecuteResponsePayload>[0]
9995

@@ -102,25 +98,6 @@ function resultWithToolCalls(names: string[]): Payload {
10298
}
10399

104100
describe('buildExecuteResponsePayload', () => {
105-
// The scheduled-task runner branches on whether the agent called
106-
// complete_scheduled_task (background/schedule-execution.ts reads
107-
// responseBody.toolCalls). This filter used to admit only integration tools
108-
// and mcp-*, so that check was permanently false: a job completed itself, the
109-
// signal was dropped here, and the runner's post-run bookkeeping wrote
110-
// status='active' with a fresh nextRunAt straight back over the completion —
111-
// the job then reran forever, each time telling the model it was done.
112-
it('keeps complete_scheduled_task so the schedule runner can see it', () => {
113-
const payload = buildExecuteResponsePayload(
114-
resultWithToolCalls(['complete_scheduled_task']),
115-
'chat-1',
116-
[]
117-
)
118-
119-
expect(payload.toolCalls.map((tc: { name: string }) => tc.name)).toContain(
120-
'complete_scheduled_task'
121-
)
122-
})
123-
124101
it('still admits integration and mcp tool calls, and still drops other server tools', () => {
125102
const payload = buildExecuteResponsePayload(
126103
resultWithToolCalls(['gmail_send', 'mcp-notion-create', 'read', 'edit_workflow']),
@@ -131,13 +108,6 @@ describe('buildExecuteResponsePayload', () => {
131108
const names = payload.toolCalls.map((tc: { name: string }) => tc.name)
132109
expect(names).toEqual(['gmail_send', 'mcp-notion-create'])
133110
})
134-
135-
// Guards the cross-file contract: the literal the runner greps for must be in
136-
// the allowlist above. These live in different files and nothing else ties
137-
// them together.
138-
it('exposes the exact tool name the schedule runner looks for', () => {
139-
expect(CALLER_VISIBLE_SERVER_TOOLS.has('complete_scheduled_task')).toBe(true)
140-
})
141111
})
142112

143113
describe('mothership private trace provenance transport', () => {

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

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -89,26 +89,14 @@ function encodeNdjson(value: unknown): Uint8Array {
8989
return ndjsonEncoder.encode(`${JSON.stringify(value)}\n`)
9090
}
9191

92-
/**
93-
* Server-owned tools whose invocation the CALLER must see, even though they are
94-
* not client/integration tools. The scheduled-task runner branches on whether
95-
* the agent called complete_scheduled_task; filtering it out of the response
96-
* made that check permanently false, so a completed job was rescheduled by the
97-
* runner's own post-run bookkeeping.
98-
*/
99-
export const CALLER_VISIBLE_SERVER_TOOLS = new Set(['complete_scheduled_task'])
100-
10192
export function buildExecuteResponsePayload(
10293
result: Awaited<ReturnType<typeof runHeadlessCopilotLifecycle>>,
10394
effectiveChatId: string,
10495
integrationTools: Array<{ name: string }>
10596
) {
10697
const clientToolNames = new Set(integrationTools.map((t) => t.name))
10798
const clientToolCalls = (result.toolCalls || []).filter(
108-
(tc: { name: string }) =>
109-
clientToolNames.has(tc.name) ||
110-
tc.name.startsWith('mcp-') ||
111-
CALLER_VISIBLE_SERVER_TOOLS.has(tc.name)
99+
(tc: { name: string }) => clientToolNames.has(tc.name) || tc.name.startsWith('mcp-')
112100
)
113101

114102
return {

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

Lines changed: 1 addition & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,7 @@ import { parseRequest } from '@/lib/api/server'
1414
import { getSession } from '@/lib/auth'
1515
import { generateRequestId } from '@/lib/core/utils/request'
1616
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
17-
import { captureServerEvent } from '@/lib/posthog/server'
18-
import {
19-
performDeleteJob,
20-
performExcludeOccurrence,
21-
performUpdateJob,
22-
} from '@/lib/workflows/schedules/orchestration'
2317
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
24-
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
2518

2619
const logger = createLogger('ScheduleAPI')
2720

@@ -46,18 +39,6 @@ async function fetchAndAuthorize(
4639
return NextResponse.json({ error: 'Schedule not found' }, { status: 404 })
4740
}
4841

49-
if (schedule.sourceType === 'job') {
50-
if (!schedule.sourceWorkspaceId) {
51-
return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 })
52-
}
53-
const permission = await verifyWorkspaceMembership(userId, schedule.sourceWorkspaceId)
54-
const canWrite = permission === 'admin' || permission === 'write'
55-
if (!permission || (action === 'write' && !canWrite)) {
56-
return NextResponse.json({ error: 'Not authorized' }, { status: 403 })
57-
}
58-
return { schedule, workspaceId: schedule.sourceWorkspaceId }
59-
}
60-
6142
if (!schedule.workflowId) {
6243
logger.warn(`[${requestId}] Schedule has no workflow: ${scheduleId}`)
6344
return NextResponse.json({ error: 'Schedule has no associated workflow' }, { status: 400 })
@@ -178,95 +159,6 @@ export const PUT = withRouteHandler(
178159
return NextResponse.json({ message: 'Schedule disabled successfully' })
179160
}
180161

181-
if (action === 'update') {
182-
if (schedule.sourceType !== 'job') {
183-
return NextResponse.json(
184-
{ error: 'Only standalone job schedules can be edited' },
185-
{ status: 400 }
186-
)
187-
}
188-
189-
if (!workspaceId) {
190-
return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 })
191-
}
192-
193-
const updateResult = await performUpdateJob({
194-
jobId: scheduleId,
195-
workspaceId,
196-
userId: session.user.id,
197-
actorName: session.user.name,
198-
actorEmail: session.user.email,
199-
title: validatedBody.title,
200-
prompt: validatedBody.prompt,
201-
timezone: validatedBody.timezone,
202-
lifecycle: validatedBody.lifecycle,
203-
maxRuns: validatedBody.maxRuns,
204-
cronExpression: validatedBody.cronExpression,
205-
time: validatedBody.time,
206-
endsAt: validatedBody.endsAt,
207-
contexts: validatedBody.contexts,
208-
secretScope: validatedBody.secretScope,
209-
mountedSecrets: validatedBody.mountedSecrets,
210-
request,
211-
})
212-
if (!updateResult.success) {
213-
return NextResponse.json(
214-
{ error: updateResult.error || 'Failed to update schedule' },
215-
{
216-
status:
217-
updateResult.errorCode === 'forbidden'
218-
? 403
219-
: updateResult.errorCode === 'validation'
220-
? 400
221-
: 500,
222-
}
223-
)
224-
}
225-
226-
logger.info(`[${requestId}] Updated job schedule: ${scheduleId}`)
227-
228-
return NextResponse.json({ message: 'Schedule updated successfully' })
229-
}
230-
231-
if (action === 'exclude_occurrence') {
232-
if (schedule.sourceType !== 'job') {
233-
return NextResponse.json(
234-
{ error: 'Only standalone job schedules have occurrences' },
235-
{ status: 400 }
236-
)
237-
}
238-
if (!workspaceId) {
239-
return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 })
240-
}
241-
242-
const excludeResult = await performExcludeOccurrence({
243-
jobId: scheduleId,
244-
workspaceId,
245-
userId: session.user.id,
246-
actorName: session.user.name,
247-
actorEmail: session.user.email,
248-
occurrence: validatedBody.occurrence,
249-
request,
250-
})
251-
if (!excludeResult.success) {
252-
return NextResponse.json(
253-
{ error: excludeResult.error || 'Failed to delete occurrence' },
254-
{
255-
status:
256-
excludeResult.errorCode === 'not_found'
257-
? 404
258-
: excludeResult.errorCode === 'validation'
259-
? 400
260-
: 500,
261-
}
262-
)
263-
}
264-
265-
logger.info(`[${requestId}] Excluded occurrence on job schedule: ${scheduleId}`)
266-
267-
return NextResponse.json({ message: 'Occurrence deleted successfully' })
268-
}
269-
270162
// reactivate
271163
if (schedule.status === 'active') {
272164
return NextResponse.json({ message: 'Schedule is already active' })
@@ -341,27 +233,6 @@ export const DELETE = withRouteHandler(
341233
if (result instanceof NextResponse) return result
342234
const { schedule, workspaceId } = result
343235

344-
if (schedule.sourceType === 'job') {
345-
if (!workspaceId) {
346-
return NextResponse.json({ error: 'Job has no workspace' }, { status: 400 })
347-
}
348-
const deleteResult = await performDeleteJob({
349-
jobId: scheduleId,
350-
workspaceId,
351-
userId: session.user.id,
352-
actorName: session.user.name,
353-
actorEmail: session.user.email,
354-
request,
355-
})
356-
if (!deleteResult.success) {
357-
return NextResponse.json(
358-
{ error: deleteResult.error || 'Failed to delete schedule' },
359-
{ status: deleteResult.errorCode === 'not_found' ? 404 : 500 }
360-
)
361-
}
362-
return NextResponse.json({ message: 'Schedule deleted successfully' })
363-
}
364-
365236
await db.delete(workflowSchedule).where(eq(workflowSchedule.id, scheduleId))
366237

367238
logger.info(`[${requestId}] Deleted schedule: ${scheduleId}`)
@@ -375,7 +246,7 @@ export const DELETE = withRouteHandler(
375246
resourceType: AuditResourceType.SCHEDULE,
376247
resourceId: scheduleId,
377248
resourceName: schedule.jobTitle ?? undefined,
378-
description: `Deleted ${schedule.sourceType === 'job' ? 'job' : 'schedule'} "${schedule.jobTitle ?? scheduleId}"`,
249+
description: `Deleted schedule "${schedule.jobTitle ?? scheduleId}"`,
379250
metadata: {
380251
sourceType: schedule.sourceType,
381252
cronExpression: schedule.cronExpression,
@@ -384,13 +255,6 @@ export const DELETE = withRouteHandler(
384255
request,
385256
})
386257

387-
captureServerEvent(
388-
session.user.id,
389-
'scheduled_task_deleted',
390-
{ workspace_id: workspaceId ?? '' },
391-
workspaceId ? { groups: { workspace: workspaceId } } : undefined
392-
)
393-
394258
return NextResponse.json({ message: 'Schedule deleted successfully' })
395259
} catch (error) {
396260
logger.error(`[${requestId}] Error deleting schedule`, error)

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

Lines changed: 5 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ const orderByLimitMock = vi.fn()
2121
const {
2222
mockVerifyCronAuth,
2323
mockExecuteScheduleJob,
24-
mockExecuteJobInline,
2524
mockReleaseScheduleLock,
2625
mockEnqueue,
2726
mockGetJob,
@@ -37,7 +36,6 @@ const {
3736
} = vi.hoisted(() => ({
3837
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
3938
mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined),
40-
mockExecuteJobInline: vi.fn().mockResolvedValue(undefined),
4139
mockReleaseScheduleLock: vi.fn().mockResolvedValue(undefined),
4240
mockEnqueue: vi.fn().mockResolvedValue('job-id-1'),
4341
mockGetJob: vi.fn().mockResolvedValue(null),
@@ -63,7 +61,6 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({
6361

6462
vi.mock('@/background/schedule-execution', () => ({
6563
executeScheduleJob: mockExecuteScheduleJob,
66-
executeJobInline: mockExecuteJobInline,
6764
releaseScheduleLock: mockReleaseScheduleLock,
6865
applyScheduleFailureUpdate: mockApplyScheduleFailureUpdate,
6966
}))
@@ -205,18 +202,6 @@ function createBillingAttribution(workspaceId: string, actorUserId = `owner-${wo
205202
}
206203
}
207204

208-
const SINGLE_JOB = [
209-
{
210-
id: 'job-1',
211-
cronExpression: '0 * * * *',
212-
failedCount: 0,
213-
infraRetryCount: 0,
214-
timezone: 'UTC',
215-
lastQueuedAt: undefined,
216-
sourceType: 'job',
217-
},
218-
]
219-
220205
function conditionContains(
221206
condition: unknown,
222207
predicate: (entry: Record<string, unknown>) => boolean
@@ -313,8 +298,6 @@ describe('Scheduled Workflow Execution API Route', () => {
313298
mockCancelJob.mockResolvedValue(undefined)
314299
mockExecuteScheduleJob.mockReset()
315300
mockExecuteScheduleJob.mockResolvedValue(undefined)
316-
mockExecuteJobInline.mockReset()
317-
mockExecuteJobInline.mockResolvedValue(undefined)
318301
mockReleaseScheduleLock.mockReset()
319302
mockReleaseScheduleLock.mockResolvedValue(undefined)
320303
mockAssertBillingAttributionSnapshot.mockReset()
@@ -375,21 +358,6 @@ describe('Scheduled Workflow Execution API Route', () => {
375358
expect(result.processedCount).toBe(2)
376359
})
377360

378-
it('should execute mothership jobs inline', async () => {
379-
dbChainMockFns.limit.mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'job-1' }])
380-
dbChainMockFns.returning.mockReturnValueOnce(SINGLE_JOB)
381-
382-
await runScheduleTick('test-request-id')
383-
expect(mockExecuteJobInline).toHaveBeenCalledWith(
384-
expect.objectContaining({
385-
scheduleId: 'job-1',
386-
cronExpression: '0 * * * *',
387-
failedCount: 0,
388-
now: expect.any(String),
389-
})
390-
)
391-
})
392-
393361
it('should enqueue schedule with one atomic system actor and payer snapshot', async () => {
394362
dbChainMockFns.limit
395363
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
@@ -1024,7 +992,11 @@ describe('Scheduled Workflow Execution API Route', () => {
1024992
const result = await runScheduleTick('test-request-id')
1025993

1026994
expect(result.processedCount).toBe(100)
1027-
expect(dbChainMockFns.limit).toHaveBeenCalledWith(100)
995+
// The workflow claim is capped by SCHEDULE_WORKFLOW_ENQUEUE_LIMIT
996+
// (SCHEDULE_EXECUTION_CONCURRENCY_LIMIT 30 x SCHEDULE_ENQUEUE_BUDGET_MULTIPLIER 2),
997+
// not by WORKFLOW_CHUNK_SIZE. This used to read 100, which only ever matched
998+
// the separate job claim's chunk size rather than the budget under test.
999+
expect(dbChainMockFns.limit).toHaveBeenCalledWith(60)
10281000
expect(mockEnqueue).toHaveBeenCalledTimes(100)
10291001
})
10301002

0 commit comments

Comments
 (0)