Skip to content

Commit 91666b5

Browse files
authored
feat(scheduled-tasks): migrate jobs agent to scheduled tasks agent (#5090)
1 parent 7b4626e commit 91666b5

25 files changed

Lines changed: 948 additions & 573 deletions

File tree

apps/sim/app/api/copilot/chat/resources/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
createUnauthorizedResponse,
1818
} from '@/lib/copilot/request/http'
1919
import type { ChatResource, ResourceType } from '@/lib/copilot/resources/persistence'
20+
import { GENERIC_RESOURCE_TITLES } from '@/lib/copilot/resources/types'
2021
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2122

2223
const logger = createLogger('CopilotChatResourcesAPI')
@@ -27,10 +28,10 @@ const VALID_RESOURCE_TYPES = new Set<ResourceType>([
2728
'workflow',
2829
'knowledgebase',
2930
'folder',
31+
'scheduledtask',
3032
'log',
3133
'integration',
3234
])
33-
const GENERIC_TITLES = new Set(['Table', 'File', 'Workflow', 'Knowledge Base', 'Folder', 'Log'])
3435

3536
export const POST = withRouteHandler(async (req: NextRequest) => {
3637
try {
@@ -76,7 +77,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
7677

7778
let merged: ChatResource[]
7879
if (prev) {
79-
if (GENERIC_TITLES.has(prev.title) && !GENERIC_TITLES.has(resource.title)) {
80+
if (GENERIC_RESOURCE_TITLES.has(prev.title) && !GENERIC_RESOURCE_TITLES.has(resource.title)) {
8081
merged = existing.map((r) =>
8182
`${r.type}:${r.id}` === key ? { ...r, title: resource.title } : r
8283
)

apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/chat-context-kind-registry.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ReactNode } from 'react'
22
import {
3+
Calendar,
34
Database,
45
Folder as FolderIcon,
56
Library,
@@ -79,6 +80,10 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record<ChatContextKind, ChatContextKind
7980
label: 'File folder',
8081
renderIcon: ({ className }) => <FolderIcon className={className} />,
8182
},
83+
scheduledtask: {
84+
label: 'Scheduled task',
85+
renderIcon: ({ className }) => <Calendar className={className} />,
86+
},
8287
past_chat: {
8388
label: 'Past chat',
8489
renderIcon: ({ className }) => <Task className={className} />,

apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const TOOL_ICONS: Record<string, IconComponent> = {
5151
knowledge: Database,
5252
knowledge_base: Database,
5353
table: TableIcon,
54+
scheduled_task: Calendar,
5455
job: Calendar,
5556
agent: AgentIcon,
5657
custom_tool: Wrench,

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { useMemo, useState } from 'react'
4+
import { truncate } from '@sim/utils/string'
45
import {
56
Button,
67
DropdownMenu,
@@ -30,6 +31,7 @@ import { useFolders } from '@/hooks/queries/folders'
3031
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
3132
import { useLogsList } from '@/hooks/queries/logs'
3233
import { useMothershipChats } from '@/hooks/queries/mothership-chats'
34+
import { useWorkspaceSchedules } from '@/hooks/queries/schedules'
3335
import { useTablesList } from '@/hooks/queries/tables'
3436
import { useWorkflows } from '@/hooks/queries/workflows'
3537
import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
@@ -77,6 +79,7 @@ export function useAvailableResources(
7779
const { data: folders = [] } = useFolders(workspaceId)
7880
const { data: fileFolders = [] } = useWorkspaceFileFolders(workspaceId)
7981
const { data: tasks = [] } = useMothershipChats(workspaceId)
82+
const { data: schedules = [] } = useWorkspaceSchedules(workspaceId)
8083
const { data: logsData } = useLogsList(workspaceId, LOG_DROPDOWN_FILTERS)
8184
const logs = useMemo(() => (logsData?.pages ?? []).flatMap((page) => page.logs), [logsData])
8285

@@ -155,6 +158,16 @@ export function useAvailableResources(
155158
isOpen: existingKeys.has(`task:${t.id}`),
156159
})),
157160
},
161+
{
162+
type: 'scheduledtask' as const,
163+
items: schedules
164+
.filter((s) => s.sourceType === 'job')
165+
.map((s) => ({
166+
id: s.id,
167+
name: s.jobTitle || truncate(s.prompt ?? '', 40) || 'Scheduled Task',
168+
isOpen: existingKeys.has(`scheduledtask:${s.id}`),
169+
})),
170+
},
158171
{
159172
type: 'log' as const,
160173
items: logs.map((log) => {
@@ -179,6 +192,7 @@ export function useAvailableResources(
179192
files,
180193
knowledgeBases,
181194
tasks,
195+
schedules,
182196
logs,
183197
existingKeys,
184198
excludeTypes,

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

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
import { lazy, memo, Suspense, useEffect, useMemo, useRef } from 'react'
44
import { createLogger } from '@sim/logger'
5+
import { format } from 'date-fns'
56
import { useRouter } from 'next/navigation'
67
import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn'
78
import {
9+
Calendar,
810
Download,
911
FileX,
1012
Folder as FolderIcon,
@@ -24,6 +26,7 @@ import {
2426
import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils'
2527
import { triggerFileDownload } from '@/lib/uploads/client/download'
2628
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
29+
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
2730
import {
2831
FileViewer,
2932
type PreviewMode,
@@ -50,6 +53,7 @@ import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/com
5053
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
5154
import { useFolders } from '@/hooks/queries/folders'
5255
import { useLogDetail } from '@/hooks/queries/logs'
56+
import { useWorkspaceSchedules } from '@/hooks/queries/schedules'
5357
import { downloadTableExport } from '@/hooks/queries/tables'
5458
import { useWorkflows } from '@/hooks/queries/workflows'
5559
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
@@ -182,6 +186,15 @@ export const ResourceContent = memo(function ResourceContent({
182186
case 'folder':
183187
return <EmbeddedFolder key={resource.id} workspaceId={workspaceId} folderId={resource.id} />
184188

189+
case 'scheduledtask':
190+
return (
191+
<EmbeddedScheduledTask
192+
key={resource.id}
193+
workspaceId={workspaceId}
194+
scheduleId={resource.id}
195+
/>
196+
)
197+
185198
case 'log':
186199
return (
187200
<EmbeddedLog
@@ -233,6 +246,8 @@ export function ResourceActions({ workspaceId, resource }: ResourceActionsProps)
233246
)
234247
case 'log':
235248
return <EmbeddedLogActions workspaceId={workspaceId} logId={resource.id} />
249+
case 'scheduledtask':
250+
return <EmbeddedScheduledTaskActions workspaceId={workspaceId} />
236251
case 'folder':
237252
case 'generic':
238253
return null
@@ -647,6 +662,141 @@ function EmbeddedFolder({ workspaceId, folderId }: EmbeddedFolderProps) {
647662
)
648663
}
649664

665+
const SCHEDULE_STATUS_LABEL: Record<string, string> = {
666+
active: 'Active',
667+
disabled: 'Paused',
668+
completed: 'Completed',
669+
}
670+
671+
function formatScheduleInstant(iso: string | null): string {
672+
if (!iso) return '—'
673+
const date = new Date(iso)
674+
return Number.isNaN(date.getTime()) ? '—' : format(date, "EEE, MMM d 'at' h:mm a")
675+
}
676+
677+
interface ScheduledTaskFieldProps {
678+
title: string
679+
value: string
680+
}
681+
682+
function ScheduledTaskField({ title, value }: ScheduledTaskFieldProps) {
683+
return (
684+
<div className='flex flex-col gap-1'>
685+
<span className='text-[var(--text-muted)] text-caption'>{title}</span>
686+
<span className='text-[var(--text-body)] text-small'>{value}</span>
687+
</div>
688+
)
689+
}
690+
691+
interface EmbeddedScheduledTaskProps {
692+
workspaceId: string
693+
scheduleId: string
694+
}
695+
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+
)
702+
703+
if (isLoading && !schedule) return LOADING_SKELETON
704+
705+
if (!schedule) {
706+
const heading = isError ? "Couldn't load scheduled task" : 'Scheduled task not found'
707+
const detail = isError
708+
? 'Something went wrong loading this scheduled task. Try again.'
709+
: 'This scheduled task may have been deleted'
710+
return (
711+
<div className='flex h-full flex-col items-center justify-center gap-3'>
712+
<Calendar className='size-[32px] text-[var(--text-icon)]' />
713+
<div className='flex flex-col items-center gap-1'>
714+
<h2 className='font-medium text-[20px] text-[var(--text-primary)]'>{heading}</h2>
715+
<p className='text-[var(--text-body)] text-small'>{detail}</p>
716+
</div>
717+
</div>
718+
)
719+
}
720+
721+
const title = schedule.jobTitle || schedule.prompt || 'Scheduled task'
722+
const timing = schedule.cronExpression
723+
? parseCronToHumanReadable(schedule.cronExpression, schedule.timezone)
724+
: 'Runs once'
725+
const status = SCHEDULE_STATUS_LABEL[schedule.status] ?? schedule.status
726+
727+
return (
728+
<div className='flex h-full flex-col gap-6 overflow-y-auto p-6'>
729+
<div className='flex items-center gap-2'>
730+
<Calendar className='size-[16px] flex-shrink-0 text-[var(--text-icon)]' />
731+
<h2 className='truncate font-medium text-[16px] text-[var(--text-primary)]'>{title}</h2>
732+
</div>
733+
734+
<div className='grid grid-cols-2 gap-4'>
735+
<ScheduledTaskField title='Status' value={status} />
736+
<ScheduledTaskField title='Schedule' value={timing} />
737+
<ScheduledTaskField title='Next run' value={formatScheduleInstant(schedule.nextRunAt)} />
738+
<ScheduledTaskField title='Last run' value={formatScheduleInstant(schedule.lastRanAt)} />
739+
</div>
740+
741+
<div className='flex flex-col gap-1'>
742+
<span className='text-[var(--text-muted)] text-caption'>Prompt</span>
743+
<p className='whitespace-pre-wrap text-[var(--text-body)] text-small'>
744+
{schedule.prompt || '—'}
745+
</p>
746+
</div>
747+
748+
{schedule.jobHistory && schedule.jobHistory.length > 0 && (
749+
<div className='flex flex-col gap-2'>
750+
<span className='text-[var(--text-muted)] text-caption'>Recent runs</span>
751+
<div className='flex flex-col gap-2'>
752+
{schedule.jobHistory.slice(0, 5).map((run, index) => (
753+
<div
754+
key={`${run.timestamp}-${index}`}
755+
className='flex flex-col gap-1 rounded-[6px] bg-[var(--surface-4)] px-3 py-2'
756+
>
757+
<span className='text-[var(--text-tertiary)] text-caption'>
758+
{formatScheduleInstant(run.timestamp)}
759+
</span>
760+
<span className='text-[var(--text-body)] text-small'>{run.summary}</span>
761+
</div>
762+
))}
763+
</div>
764+
</div>
765+
)}
766+
</div>
767+
)
768+
}
769+
770+
interface EmbeddedScheduledTaskActionsProps {
771+
workspaceId: string
772+
}
773+
774+
function EmbeddedScheduledTaskActions({ workspaceId }: EmbeddedScheduledTaskActionsProps) {
775+
const router = useRouter()
776+
777+
const handleOpenScheduledTasks = () => {
778+
router.push(`/workspace/${workspaceId}/scheduled-tasks`)
779+
}
780+
781+
return (
782+
<Tooltip.Root>
783+
<Tooltip.Trigger asChild>
784+
<Button
785+
variant='subtle'
786+
onClick={handleOpenScheduledTasks}
787+
className={RESOURCE_TAB_ICON_BUTTON_CLASS}
788+
aria-label='Open in scheduled tasks'
789+
>
790+
<SquareArrowUpRight className={RESOURCE_TAB_ICON_CLASS} />
791+
</Button>
792+
</Tooltip.Trigger>
793+
<Tooltip.Content side='bottom'>
794+
<p>Open in scheduled tasks</p>
795+
</Tooltip.Content>
796+
</Tooltip.Root>
797+
)
798+
}
799+
650800
interface EmbeddedLogProps {
651801
workspaceId: string
652802
logId: string

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type { ElementType, ReactNode } from 'react'
44
import type { QueryClient } from '@tanstack/react-query'
55
import {
6+
Calendar,
67
Connections,
78
Database,
89
File as FileIcon,
@@ -23,6 +24,7 @@ import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color'
2324
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
2425
import { logKeys } from '@/hooks/queries/logs'
2526
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
27+
import { scheduleKeys } from '@/hooks/queries/schedules'
2628
import { tableKeys } from '@/hooks/queries/tables'
2729
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
2830
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
@@ -183,6 +185,15 @@ export const RESOURCE_REGISTRY: Record<MothershipResourceType, ResourceTypeConfi
183185
),
184186
renderDropdownItem: (props) => <DefaultDropdownItem {...props} />,
185187
},
188+
scheduledtask: {
189+
type: 'scheduledtask',
190+
label: 'Scheduled Tasks',
191+
icon: Calendar,
192+
renderTabIcon: (_resource, className) => (
193+
<Calendar className={cn(className, 'text-[var(--text-icon)]')} />
194+
),
195+
renderDropdownItem: (props) => <IconDropdownItem {...props} icon={Calendar} />,
196+
},
186197
log: {
187198
type: 'log',
188199
label: 'Logs',
@@ -241,6 +252,9 @@ const RESOURCE_INVALIDATORS: Record<
241252
task: (qc, wId) => {
242253
qc.invalidateQueries({ queryKey: mothershipChatKeys.list(wId) })
243254
},
255+
scheduledtask: (qc, wId) => {
256+
qc.invalidateQueries({ queryKey: scheduleKeys.list(wId) })
257+
},
244258
log: (qc, _wId, id) => {
245259
qc.invalidateQueries({ queryKey: logKeys.details() })
246260
qc.invalidateQueries({ queryKey: logKeys.detail(id) })

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const PORTABLE_KIND_TO_ID_FIELD = {
2727
file: 'fileId',
2828
folder: 'folderId',
2929
filefolder: 'fileFolderId',
30+
scheduledtask: 'scheduleId',
3031
knowledge: 'knowledgeId',
3132
past_chat: 'chatId',
3233
workflow: 'workflowId',
@@ -207,6 +208,8 @@ export function chipLinkToContext(link: ParsedChipLink): ChatContext {
207208
return { kind: 'folder', folderId: link.id, label: link.label }
208209
case 'filefolder':
209210
return { kind: 'filefolder', fileFolderId: link.id, label: link.label }
211+
case 'scheduledtask':
212+
return { kind: 'scheduledtask', scheduleId: link.id, label: link.label }
210213
case 'knowledge':
211214
return { kind: 'knowledge', knowledgeId: link.id, label: link.label }
212215
case 'past_chat':

apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ const RESOURCE_TO_CONTEXT: Record<
112112
task: (r) => ({ kind: 'past_chat', chatId: r.id, label: r.title }),
113113
log: (r) => ({ kind: 'logs', executionId: r.id, label: r.title }),
114114
integration: (r) => ({ kind: 'integration', blockType: r.id, label: r.title }),
115+
scheduledtask: (r) => ({ kind: 'scheduledtask', scheduleId: r.id, label: r.title }),
115116
generic: (r) => ({ kind: 'docs', label: r.title }),
116117
}
117118

0 commit comments

Comments
 (0)