This commit is contained in:
Arjun 2026-02-04 17:10:07 +05:30
parent d59c8d53c6
commit 35805068c0
6 changed files with 432 additions and 5 deletions

View file

@ -24,6 +24,9 @@ import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import * as composioHandler from './composio-handler.js'; import * as composioHandler from './composio-handler.js';
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
type InvokeChannels = ipc.InvokeChannels; type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels; type IPCChannels = ipc.IPCChannels;
@ -384,5 +387,30 @@ export function setupIpcHandlers() {
'composio:execute-action': async (_event, args) => { 'composio:execute-action': async (_event, args) => {
return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input); return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input);
}, },
// Agent schedule handlers
'agent-schedule:getConfig': async () => {
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');
await repo.ensureConfig();
return repo.getConfig();
},
'agent-schedule:getState': async () => {
const repo = container.resolve<IAgentScheduleStateRepo>('agentScheduleStateRepo');
await repo.ensureState();
return repo.getState();
},
'agent-schedule:updateAgent': async (_event, args) => {
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');
await repo.upsert(args.agentName, args.entry);
// Trigger the runner to pick up the change immediately
triggerAgentScheduleRun();
return { success: true };
},
'agent-schedule:deleteAgent': async (_event, args) => {
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');
const stateRepo = container.resolve<IAgentScheduleStateRepo>('agentScheduleStateRepo');
await repo.delete(args.agentName);
await stateRepo.deleteAgentState(args.agentName);
return { success: true };
},
}); });
} }

View file

@ -51,6 +51,9 @@ import { Separator } from "@/components/ui/separator"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { OnboardingModal } from '@/components/onboarding-modal' import { OnboardingModal } from '@/components/onboarding-modal'
import { BackgroundTaskDetail } from '@/components/background-task-detail'
import { AgentScheduleConfig } from '@x/shared/dist/agent-schedule.js'
import { AgentScheduleState } from '@x/shared/dist/agent-schedule-state.js'
type DirEntry = z.infer<typeof workspace.DirEntry> type DirEntry = z.infer<typeof workspace.DirEntry>
type RunEventType = z.infer<typeof RunEvent> type RunEventType = z.infer<typeof RunEvent>
@ -499,6 +502,22 @@ function App() {
// Onboarding state // Onboarding state
const [showOnboarding, setShowOnboarding] = useState(false) const [showOnboarding, setShowOnboarding] = useState(false)
// Background tasks state
type BackgroundTaskItem = {
name: string
description?: string
schedule: z.infer<typeof AgentScheduleConfig>["agents"][string]["schedule"]
enabled: boolean
startingMessage?: string
status?: z.infer<typeof AgentScheduleState>["agents"][string]["status"]
nextRunAt?: string | null
lastRunAt?: string | null
lastError?: string | null
runCount?: number
}
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTaskItem[]>([])
const [selectedBackgroundTask, setSelectedBackgroundTask] = useState<string | null>(null)
// Keep runIdRef in sync with runId state (for use in event handlers to avoid stale closures) // Keep runIdRef in sync with runId state (for use in event handlers to avoid stale closures)
useEffect(() => { useEffect(() => {
runIdRef.current = runId runIdRef.current = runId
@ -663,6 +682,63 @@ function App() {
loadRuns() loadRuns()
}, [loadRuns]) }, [loadRuns])
// Load background tasks
const loadBackgroundTasks = useCallback(async () => {
try {
const [configResult, stateResult] = await Promise.all([
window.ipc.invoke('agent-schedule:getConfig', null),
window.ipc.invoke('agent-schedule:getState', null),
])
const tasks: BackgroundTaskItem[] = Object.entries(configResult.agents).map(([name, entry]) => {
const state = stateResult.agents[name]
return {
name,
description: entry.description,
schedule: entry.schedule,
enabled: entry.enabled ?? true,
startingMessage: entry.startingMessage,
status: state?.status,
nextRunAt: state?.nextRunAt,
lastRunAt: state?.lastRunAt,
lastError: state?.lastError,
runCount: state?.runCount ?? 0,
}
})
setBackgroundTasks(tasks)
} catch (err) {
console.error('Failed to load background tasks:', err)
}
}, [])
// Load background tasks on mount
useEffect(() => {
loadBackgroundTasks()
}, [loadBackgroundTasks])
// Handle toggling background task enabled state
const handleToggleBackgroundTask = useCallback(async (taskName: string, enabled: boolean) => {
const task = backgroundTasks.find(t => t.name === taskName)
if (!task) return
try {
await window.ipc.invoke('agent-schedule:updateAgent', {
agentName: taskName,
entry: {
schedule: task.schedule,
enabled,
startingMessage: task.startingMessage,
description: task.description,
},
})
// Reload to get updated state
await loadBackgroundTasks()
} catch (err) {
console.error('Failed to update background task:', err)
}
}, [backgroundTasks, loadBackgroundTasks])
// Load a specific run and populate conversation // Load a specific run and populate conversation
const loadRun = useCallback(async (id: string) => { const loadRun = useCallback(async (id: string) => {
try { try {
@ -1169,6 +1245,7 @@ function App() {
setPendingAskHumanRequests(new Map()) setPendingAskHumanRequests(new Map())
setAllPermissionRequests(new Map()) setAllPermissionRequests(new Map())
setPermissionResponses(new Map()) setPermissionResponses(new Map())
setSelectedBackgroundTask(null)
}, []) }, [])
const handleChatInputSubmit = (text: string) => { const handleChatInputSubmit = (text: string) => {
@ -1193,6 +1270,8 @@ function App() {
// Clear forward history when navigating to a new file // Clear forward history when navigating to a new file
setFileHistoryForward([]) setFileHistoryForward([])
setSelectedPath(path) setSelectedPath(path)
// Clear background task selection when navigating to a file
setSelectedBackgroundTask(null)
}, [selectedPath]) }, [selectedPath])
const navigateBack = useCallback(() => { const navigateBack = useCallback(() => {
@ -1686,7 +1765,16 @@ function App() {
const conversationContentClassName = hasConversation const conversationContentClassName = hasConversation
? "mx-auto w-full max-w-4xl pb-28" ? "mx-auto w-full max-w-4xl pb-28"
: "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0" : "mx-auto w-full max-w-4xl min-h-full items-center justify-center pb-0"
const headerTitle = selectedPath ? selectedPath : (isGraphOpen ? 'Graph View' : 'Chat') const headerTitle = selectedPath
? selectedPath
: isGraphOpen
? 'Graph View'
: selectedBackgroundTask
? `Background Task: ${selectedBackgroundTask}`
: 'Chat'
const selectedTask = selectedBackgroundTask
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
: null
return ( return (
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
@ -1716,8 +1804,18 @@ function App() {
currentRunId={runId} currentRunId={runId}
tasksActions={{ tasksActions={{
onNewChat: handleNewChat, onNewChat: handleNewChat,
onSelectRun: loadRun, onSelectRun: (runIdToLoad) => {
setSelectedBackgroundTask(null)
loadRun(runIdToLoad)
},
onSelectBackgroundTask: (taskName) => {
setSelectedBackgroundTask(taskName)
setSelectedPath(null)
setIsGraphOpen(false)
},
}} }}
backgroundTasks={backgroundTasks}
selectedBackgroundTask={selectedBackgroundTask}
/> />
<SidebarInset className="overflow-hidden! min-h-0"> <SidebarInset className="overflow-hidden! min-h-0">
{/* Header with sidebar triggers */} {/* Header with sidebar triggers */}
@ -1819,6 +1917,21 @@ function App() {
</pre> </pre>
</div> </div>
) )
) : selectedTask ? (
<div className="flex-1 min-h-0 overflow-hidden">
<BackgroundTaskDetail
name={selectedTask.name}
description={selectedTask.description}
schedule={selectedTask.schedule}
enabled={selectedTask.enabled}
status={selectedTask.status}
nextRunAt={selectedTask.nextRunAt}
lastRunAt={selectedTask.lastRunAt}
lastError={selectedTask.lastError}
runCount={selectedTask.runCount}
onToggleEnabled={(enabled) => handleToggleBackgroundTask(selectedTask.name, enabled)}
/>
</div>
) : ( ) : (
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]"> <Conversation className="relative flex-1 overflow-y-auto [scrollbar-gutter:stable]">

View file

@ -0,0 +1,175 @@
import { Bot, Calendar, Clock, AlertCircle, CheckCircle } from "lucide-react"
import { Switch } from "@/components/ui/switch"
interface BackgroundTaskSchedule {
type: "cron" | "window" | "once"
expression?: string
cron?: string
startTime?: string
endTime?: string
runAt?: string
}
interface BackgroundTaskDetailProps {
name: string
description?: string
schedule: BackgroundTaskSchedule
enabled: boolean
status?: "scheduled" | "running" | "finished" | "failed" | "triggered"
nextRunAt?: string | null
lastRunAt?: string | null
lastError?: string | null
runCount?: number
onToggleEnabled: (enabled: boolean) => void
}
function formatScheduleDescription(schedule: BackgroundTaskSchedule): string {
switch (schedule.type) {
case "cron":
return `Runs on cron schedule: ${schedule.expression}`
case "window":
return `Runs once between ${schedule.startTime} and ${schedule.endTime} based on: ${schedule.cron}`
case "once":
return `Runs once at ${schedule.runAt}`
default:
return "Unknown schedule type"
}
}
function formatDateTime(isoString: string | null | undefined): string {
if (!isoString) return "Never"
try {
const date = new Date(isoString)
return date.toLocaleString()
} catch {
return isoString
}
}
export function BackgroundTaskDetail({
name,
description,
schedule,
enabled,
status,
nextRunAt,
lastRunAt,
lastError,
runCount = 0,
onToggleEnabled,
}: BackgroundTaskDetailProps) {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b border-border px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10">
<Bot className="size-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold truncate">{name}</h1>
<p className="text-sm text-muted-foreground">Background Agent</p>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Description */}
{description && (
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Description</h2>
<p className="text-sm">{description}</p>
</section>
)}
{/* Schedule */}
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Schedule</h2>
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
<div className="flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground" />
<span className="text-sm font-medium capitalize">{schedule.type} Schedule</span>
</div>
<p className="text-sm text-muted-foreground">
{formatScheduleDescription(schedule)}
</p>
</div>
</section>
{/* Enabled Toggle - hide for completed one-time schedules */}
{status === "triggered" ? (
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Status</h2>
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center gap-2">
<CheckCircle className="size-4 text-green-500" />
<p className="text-sm font-medium">Completed</p>
</div>
<p className="text-xs text-muted-foreground mt-1">
This one-time agent has finished running and will not run again.
</p>
</div>
</section>
) : (
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Status</h2>
<div className="flex items-center justify-between bg-muted/50 rounded-lg p-4">
<div>
<p className="text-sm font-medium">{enabled ? "Enabled" : "Disabled"}</p>
<p className="text-xs text-muted-foreground">
{enabled ? "This agent will run according to its schedule" : "This agent is paused and will not run"}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={onToggleEnabled}
/>
</div>
</section>
)}
{/* Run Statistics */}
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Run History</h2>
<div className="grid grid-cols-2 gap-4">
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-2xl font-semibold">{runCount}</p>
<p className="text-xs text-muted-foreground">Total Runs</p>
</div>
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-sm font-medium">{formatDateTime(lastRunAt)}</p>
<p className="text-xs text-muted-foreground">Last Run</p>
</div>
</div>
</section>
{/* Next Run */}
{nextRunAt && schedule.type !== "once" && (
<section>
<h2 className="text-sm font-medium text-muted-foreground mb-2">Next Scheduled Run</h2>
<div className="bg-muted/50 rounded-lg p-4">
<div className="flex items-center gap-2">
<Clock className="size-4 text-muted-foreground" />
<span className="text-sm">{formatDateTime(nextRunAt)}</span>
</div>
</div>
</section>
)}
{/* Last Error */}
{lastError && (
<section>
<h2 className="text-sm font-medium text-red-500 mb-2">Last Error</h2>
<div className="bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-start gap-2">
<AlertCircle className="size-4 text-red-500 mt-0.5 shrink-0" />
<p className="text-sm text-red-700 dark:text-red-400">{lastError}</p>
</div>
</div>
</section>
)}
</div>
</div>
)
}

View file

@ -3,6 +3,7 @@
import * as React from "react" import * as React from "react"
import { useState } from "react" import { useState } from "react"
import { import {
Bot,
ChevronRight, ChevronRight,
ChevronsDownUp, ChevronsDownUp,
ChevronsUpDown, ChevronsUpDown,
@ -78,9 +79,27 @@ type RunListItem = {
agentId: string agentId: string
} }
type BackgroundTaskItem = {
name: string
description?: string
schedule: {
type: "cron" | "window" | "once"
expression?: string
cron?: string
startTime?: string
endTime?: string
runAt?: string
}
enabled: boolean
status?: "scheduled" | "running" | "finished" | "failed" | "triggered"
nextRunAt?: string | null
lastRunAt?: string | null
}
type TasksActions = { type TasksActions = {
onNewChat: () => void onNewChat: () => void
onSelectRun: (runId: string) => void onSelectRun: (runId: string) => void
onSelectBackgroundTask?: (taskName: string) => void
} }
type SidebarContentPanelProps = { type SidebarContentPanelProps = {
@ -93,6 +112,8 @@ type SidebarContentPanelProps = {
runs?: RunListItem[] runs?: RunListItem[]
currentRunId?: string | null currentRunId?: string | null
tasksActions?: TasksActions tasksActions?: TasksActions
backgroundTasks?: BackgroundTaskItem[]
selectedBackgroundTask?: string | null
} & React.ComponentProps<typeof Sidebar> } & React.ComponentProps<typeof Sidebar>
const sectionTitles = { const sectionTitles = {
@ -110,6 +131,8 @@ export function SidebarContentPanel({
runs = [], runs = [],
currentRunId, currentRunId,
tasksActions, tasksActions,
backgroundTasks = [],
selectedBackgroundTask,
...props ...props
}: SidebarContentPanelProps) { }: SidebarContentPanelProps) {
const { activeSection } = useSidebarSection() const { activeSection } = useSidebarSection()
@ -137,6 +160,8 @@ export function SidebarContentPanel({
runs={runs} runs={runs}
currentRunId={currentRunId} currentRunId={currentRunId}
actions={tasksActions} actions={tasksActions}
backgroundTasks={backgroundTasks}
selectedBackgroundTask={selectedBackgroundTask}
/> />
)} )}
</SidebarContent> </SidebarContent>
@ -653,15 +678,40 @@ function Tree({
) )
} }
// Get status indicator color
function getStatusColor(status?: string, enabled?: boolean): string {
// Disabled agents always show gray
if (enabled === false) {
return "bg-gray-400"
}
switch (status) {
case "running":
return "bg-blue-500"
case "finished":
return "bg-green-500"
case "failed":
return "bg-red-500"
case "triggered":
return "bg-gray-400"
case "scheduled":
default:
return "bg-yellow-500"
}
}
// Tasks Section // Tasks Section
function TasksSection({ function TasksSection({
runs, runs,
currentRunId, currentRunId,
actions, actions,
backgroundTasks = [],
selectedBackgroundTask,
}: { }: {
runs: RunListItem[] runs: RunListItem[]
currentRunId?: string | null currentRunId?: string | null
actions?: TasksActions actions?: TasksActions
backgroundTasks?: BackgroundTaskItem[]
selectedBackgroundTask?: string | null
}) { }) {
return ( return (
<SidebarGroup className="flex-1 flex flex-col overflow-hidden"> <SidebarGroup className="flex-1 flex flex-col overflow-hidden">
@ -677,6 +727,35 @@ function TasksSection({
</SidebarMenu> </SidebarMenu>
</div> </div>
<SidebarGroupContent className="flex-1 overflow-y-auto"> <SidebarGroupContent className="flex-1 overflow-y-auto">
{/* Background Tasks Section */}
{backgroundTasks.length > 0 && (
<>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
Background Tasks
</div>
<SidebarMenu>
{backgroundTasks.map((task) => (
<SidebarMenuItem key={task.name}>
<SidebarMenuButton
isActive={selectedBackgroundTask === task.name}
onClick={() => actions?.onSelectBackgroundTask?.(task.name)}
className="gap-2"
>
<div className="relative">
<Bot className="size-4 shrink-0" />
<span
className={`absolute -bottom-0.5 -right-0.5 size-2 rounded-full ${getStatusColor(task.status, task.enabled)} ${task.status === "running" && task.enabled ? "animate-pulse" : ""}`}
/>
</div>
<span className={`truncate text-sm ${!task.enabled ? "text-muted-foreground" : ""}`}>
{task.name}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</>
)}
{runs.length > 0 && ( {runs.length > 0 && (
<> <>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground"> <div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">

View file

@ -146,15 +146,19 @@ You can add a ` + "`description`" + ` field to describe what the agent does. Thi
} }
` + "```" + ` ` + "```" + `
### Schedule State ### Schedule State (Read-Only)
The runner tracks execution state in ` + "`~/.rowboat/config/agent-schedule-state.json`" + `: **IMPORTANT: Do NOT modify ` + "`agent-schedule-state.json`" + `** - it is managed automatically by the background runner.
The runner automatically tracks execution state in ` + "`~/.rowboat/config/agent-schedule-state.json`" + `:
- ` + "`status`" + `: scheduled, running, finished, failed, triggered (for once-schedules) - ` + "`status`" + `: scheduled, running, finished, failed, triggered (for once-schedules)
- ` + "`lastRunAt`" + `: When the agent last ran - ` + "`lastRunAt`" + `: When the agent last ran
- ` + "`nextRunAt`" + `: When the agent will run next - ` + "`nextRunAt`" + `: When the agent will run next
- ` + "`lastError`" + `: Error message if the last run failed - ` + "`lastError`" + `: Error message if the last run failed
- ` + "`runCount`" + `: Total number of runs - ` + "`runCount`" + `: Total number of runs
When you add an agent to ` + "`agent-schedule.json`" + `, the runner will automatically create and manage its state entry. You only need to edit ` + "`agent-schedule.json`" + `.
## Agent File Format ## Agent File Format
Agent files are **Markdown files with YAML frontmatter**. The frontmatter contains configuration (model, tools), and the body contains the instructions. Agent files are **Markdown files with YAML frontmatter**. The frontmatter contains configuration (model, tools), and the body contains the instructions.
@ -544,7 +548,7 @@ Use the search tool to find information on the web.
5. When creating multi-agent workflows, create an orchestrator agent 5. When creating multi-agent workflows, create an orchestrator agent
6. Add other agents as tools with ` + "`type: agent`" + ` for chaining 6. Add other agents as tools with ` + "`type: agent`" + ` for chaining
7. Use ` + "`listMcpServers`" + ` and ` + "`listMcpTools`" + ` when adding MCP integrations 7. Use ` + "`listMcpServers`" + ` and ` + "`listMcpTools`" + ` when adding MCP integrations
8. Configure schedules in ` + "`~/.rowboat/config/agent-schedule.json`" + ` 8. Configure schedules in ` + "`~/.rowboat/config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file)
9. Confirm work done and outline next steps once changes are complete 9. Confirm work done and outline next steps once changes are complete
`; `;

View file

@ -3,6 +3,8 @@ import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, Work
import { ListToolsResponse } from './mcp.js'; import { ListToolsResponse } from './mcp.js';
import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js'; import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js';
import { LlmModelConfig } from './models.js'; import { LlmModelConfig } from './models.js';
import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js';
import { AgentScheduleState } from './agent-schedule-state.js';
// ============================================================================ // ============================================================================
// Runtime Validation Schemas (Single Source of Truth) // Runtime Validation Schemas (Single Source of Truth)
@ -353,6 +355,32 @@ const ipcSchemas = {
}), }),
res: z.null(), res: z.null(),
}, },
// Agent schedule channels
'agent-schedule:getConfig': {
req: z.null(),
res: AgentScheduleConfig,
},
'agent-schedule:getState': {
req: z.null(),
res: AgentScheduleState,
},
'agent-schedule:updateAgent': {
req: z.object({
agentName: z.string(),
entry: AgentScheduleEntry,
}),
res: z.object({
success: z.literal(true),
}),
},
'agent-schedule:deleteAgent': {
req: z.object({
agentName: z.string(),
}),
res: z.object({
success: z.literal(true),
}),
},
} as const; } as const;
// ============================================================================ // ============================================================================