Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions openspec/specs/cron-channel-targeting/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
## Purpose

Allow cron jobs to deliver their output to a specific connector channel (e.g. "telegram", "web") instead of always using the last-interacted channel. This enables scenarios where scheduled market analysis goes to Telegram while interactive chat stays on the web UI.

## Requirements

### Requirement: Channel field on CronJob
The `CronJob` interface in `src/task/cron/engine.ts` SHALL include an optional `channel?: string` field. When set, the cron job's output SHALL be delivered to that specific connector channel.

### Requirement: Channel propagation through CronFirePayload
The `CronFirePayload` interface SHALL include the `channel?: string` field, propagated from the originating `CronJob`. The cron engine SHALL pass `job.channel` into the fire event payload.

### Requirement: Channel in CRUD operations
- `CronJobCreate` SHALL accept an optional `channel` field.
- `CronJobPatch` SHALL accept an optional `channel` field.
- The cron engine `add()` SHALL persist the channel value.
- The cron engine `update()` SHALL update the channel value when provided.

### Requirement: AI tool support
The `cronAdd` and `cronUpdate` AI tools SHALL expose a `channel` parameter described as "Deliver results to a specific connector channel (e.g. 'telegram', 'web'). Defaults to last-interacted." The tools SHALL pass the channel value through to the engine.

### Requirement: ConnectorCenter targeted delivery
`ConnectorCenter.notify()` and `ConnectorCenter.notifyStream()` SHALL accept a `channel` option in `NotifyOpts`. When `channel` is provided, the method SHALL look up the connector by name via `this.get(opts.channel)` instead of using the last-interacted fallback via `this.resolveTarget()`.

### Requirement: CronListener delivery
The `CronListener` SHALL pass `payload.channel` into the `connectorCenter.notify()` call's options, enabling the fired job's output to reach the intended channel.

### Requirement: API route support
The `POST /api/cron/jobs` route SHALL accept an optional `channel` field in the request body and pass it to `cronEngine.add()`.
128 changes: 128 additions & 0 deletions openspec/specs/cron-ui-page/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
## Purpose

Provide a web UI page for managing cron jobs — listing, creating, editing, enabling/disabling, triggering, and deleting scheduled jobs. Complements the existing backend cron API routes (`/api/cron/jobs`) and the AI-facing cron tools, giving the user a visual dashboard for all scheduled tasks including heartbeat, market briefings, and trading signal monitors.

## Requirements

### Requirement: CronPage route and sidebar entry
The Cron Jobs page SHALL be registered as a new route in `ui/src/App.tsx`:
- Page type: `'cron'`
- Route path: `/cron`
- Component: `CronPage` from `ui/src/pages/CronPage.tsx`

The sidebar (`ui/src/components/Sidebar.tsx`) SHALL include a "Cron Jobs" navigation item with a clock icon, positioned after the Heartbeat entry.

#### Scenario: Navigation to Cron page
- **WHEN** the user clicks "Cron Jobs" in the sidebar
- **THEN** the app SHALL navigate to `/cron` and render `CronPage`

#### Scenario: Active state in sidebar
- **WHEN** the current path is `/cron`
- **THEN** the "Cron Jobs" nav item SHALL display with the active indicator (accent bar + highlighted text)

### Requirement: Job list display
`CronPage` SHALL fetch all cron jobs from `api.cron.list()` on mount and display them as cards. Each job card SHALL show:
- Job name (with "Heartbeat" display for `__heartbeat__` jobs)
- Status badge: green "OK" for last successful run, red "Error (Nx)" for consecutive errors, gray "Never run" for new jobs
- Schedule label: human-readable format (e.g. `every 4h`, `0 9 * * 1-5`, `once @ 2026-04-01T09:00:00Z`)
- Job ID
- Next run time (relative + absolute)
- Last run time (absolute)
- Collapsible payload preview showing the full prompt text

#### Scenario: Jobs loaded and displayed
- **WHEN** the CronPage mounts
- **THEN** all jobs from `/api/cron/jobs` SHALL be displayed as cards

#### Scenario: Heartbeat job shown separately
- **WHEN** a job with `name: '__heartbeat__'` exists
- **THEN** it SHALL be displayed first, with a "system" badge and "Heartbeat" as the display name

#### Scenario: Disabled jobs visually distinct
- **WHEN** a job has `enabled: false`
- **THEN** the card SHALL render with reduced opacity (60%)

### Requirement: Toggle enable/disable
Each job card SHALL include a `Toggle` component that enables or disables the job via `api.cron.update(id, { enabled })`. The job list SHALL refresh after toggling.

#### Scenario: Disable a job
- **WHEN** the user toggles a job from enabled to disabled
- **THEN** `PUT /api/cron/jobs/:id` SHALL be called with `{ enabled: false }` and the list SHALL reload

### Requirement: Run Now button
Each job card SHALL include a "Run" button that triggers immediate execution via `api.cron.runNow(id)`. The button SHALL show a loading state while the request is in flight and display a "Job triggered!" feedback message on success.

#### Scenario: Manual trigger
- **WHEN** the user clicks "Run" on a job
- **THEN** `POST /api/cron/jobs/:id/run` SHALL be called, triggering the job immediately

### Requirement: Delete with confirmation
Non-heartbeat job cards SHALL include a delete button (trash icon). Clicking it SHALL show inline "Yes" / "No" confirmation buttons. Confirming SHALL call `api.cron.remove(id)` and refresh the list. The heartbeat job SHALL NOT have a delete button.

#### Scenario: Delete confirmed
- **WHEN** the user clicks delete then confirms
- **THEN** `DELETE /api/cron/jobs/:id` SHALL be called and the job SHALL disappear from the list

#### Scenario: Delete cancelled
- **WHEN** the user clicks delete then clicks "No"
- **THEN** the confirmation UI SHALL dismiss without any API call

### Requirement: Create/Edit modal
The page SHALL include a "New Job" button (in the page header) and "Edit" buttons on each non-heartbeat card. Both SHALL open a modal form with the following fields:
- **Name** (text input, required)
- **Schedule Type** (select: Cron 5-field / Interval / One-shot)
- **Schedule Value** (text input with placeholder matching the selected type)
- **Channel** (select: Default / Telegram / Web)
- **Payload** (textarea, monospace, min 200px height, required)
- **Enabled** (toggle)

The modal SHALL close on backdrop click, the X button, or the Cancel button. On submit, it SHALL call `api.cron.add()` for new jobs or `api.cron.update()` for edits, then refresh the list.

#### Scenario: Create new job
- **WHEN** the user fills the form and clicks "Create"
- **THEN** `POST /api/cron/jobs` SHALL be called with the form data and the new job SHALL appear in the list

#### Scenario: Edit existing job
- **WHEN** the user edits a job and clicks "Update"
- **THEN** `PUT /api/cron/jobs/:id` SHALL be called with the changed fields

#### Scenario: Validation
- **WHEN** the user submits with empty name, schedule, or payload
- **THEN** the form SHALL display an error message and NOT call the API

### Requirement: Recent cron events
The page SHALL include a "Recent Cron Events" section that fetches the last 500 event log entries, filters to `cron.*` types, and displays the most recent 30 in a table with columns:
- Time (formatted date + time)
- Type (fire / done / error, color-coded: green for done, red for error, purple for fire)
- Job name
- Details (duration for done events, error message for error events)

#### Scenario: Events displayed
- **WHEN** the CronPage loads and cron events exist in the event log
- **THEN** the events table SHALL show the most recent 30 cron-related events

#### Scenario: No events
- **WHEN** no cron events exist
- **THEN** the table SHALL show "No cron events yet"

### Requirement: API client
The UI SHALL use the existing `ui/src/api/cron.ts` module which provides:
- `cronApi.list()` → `GET /api/cron/jobs` → `{ jobs: CronJob[] }`
- `cronApi.add(params)` → `POST /api/cron/jobs` → `{ id: string }`
- `cronApi.update(id, patch)` → `PUT /api/cron/jobs/:id`
- `cronApi.remove(id)` → `DELETE /api/cron/jobs/:id`
- `cronApi.runNow(id)` → `POST /api/cron/jobs/:id/run`

The `CronJob` type is already defined in `ui/src/api/types.ts` with fields: `id`, `name`, `enabled`, `schedule` (CronSchedule), `payload`, `state` (CronJobState), `createdAt`.

### Requirement: Consistent design system
The CronPage SHALL use the same UI components as other pages:
- `PageHeader` for title bar with job count and action buttons
- `Section` / `Card` containers from `ui/src/components/form.tsx`
- `Toggle` from `ui/src/components/Toggle.tsx`
- `inputClass` for form inputs
- Standard color tokens (`text`, `text-muted`, `bg`, `bg-secondary`, `bg-tertiary`, `border`, `accent`, `green`, `red`, `purple`)

#### Scenario: Visual consistency
- **WHEN** the CronPage is rendered alongside other pages (Heartbeat, Settings, etc.)
- **THEN** the styling SHALL be visually consistent with the rest of the application
2 changes: 2 additions & 0 deletions src/connectors/web/routes/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function createCronRoutes(ctx: EngineContext) {
payload: string
schedule: { kind: string; at?: string; every?: string; cron?: string }
enabled?: boolean
channel?: string
}>()
if (!body.name || !body.payload || !body.schedule?.kind) {
return c.json({ error: 'name, payload, and schedule are required' }, 400)
Expand All @@ -26,6 +27,7 @@ export function createCronRoutes(ctx: EngineContext) {
payload: body.payload,
schedule: body.schedule as CronSchedule,
enabled: body.enabled,
channel: body.channel,
})
return c.json({ id })
} catch (err) {
Expand Down
6 changes: 4 additions & 2 deletions src/core/connector-center.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface NotifyOpts {
kind?: 'message' | 'notification'
media?: MediaAttachment[]
source?: 'heartbeat' | 'cron' | 'manual'
/** Override: deliver to this specific channel instead of last-interacted. */
channel?: string
}

/** Result of a notify() call. */
Expand Down Expand Up @@ -89,7 +91,7 @@ export class ConnectorCenter {
* Falls back to the first registered connector if no interaction yet.
*/
async notify(text: string, opts?: NotifyOpts): Promise<NotifyResult> {
const target = this.resolveTarget()
const target = opts?.channel ? this.get(opts.channel) : this.resolveTarget()
if (!target) return { delivered: false }

const payload = this.buildPayload(text, opts)
Expand All @@ -103,7 +105,7 @@ export class ConnectorCenter {
* Otherwise drains the stream and falls back to send() with the completed result.
*/
async notifyStream(stream: StreamableResult, opts?: NotifyOpts): Promise<NotifyResult> {
const target = this.resolveTarget()
const target = opts?.channel ? this.get(opts.channel) : this.resolveTarget()
if (!target) {
await stream // drain to prevent hanging generator
return { delivered: false }
Expand Down
9 changes: 9 additions & 0 deletions src/task/cron/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export interface CronJob {
enabled: boolean
schedule: CronSchedule
payload: string
/** Deliver results to this specific connector channel (e.g. "telegram", "web"). */
channel?: string
state: CronJobState
createdAt: number
}
Expand All @@ -45,6 +47,8 @@ export interface CronFirePayload {
jobId: string
jobName: string
payload: string
/** Target connector channel for delivery. */
channel?: string
}

// ==================== CRUD Types ====================
Expand All @@ -54,13 +58,15 @@ export interface CronJobCreate {
schedule: CronSchedule
payload: string
enabled?: boolean
channel?: string
}

export interface CronJobPatch {
name?: string
schedule?: CronSchedule
payload?: string
enabled?: boolean
channel?: string
}

// ==================== Engine Interface ====================
Expand Down Expand Up @@ -163,6 +169,7 @@ export function createCronEngine(opts: CronEngineOpts): CronEngine {
jobId: job.id,
jobName: job.name,
payload: job.payload,
channel: job.channel,
} satisfies CronFirePayload)

job.state.lastStatus = 'ok'
Expand Down Expand Up @@ -220,6 +227,7 @@ export function createCronEngine(opts: CronEngineOpts): CronEngine {
enabled: params.enabled ?? true,
schedule: params.schedule,
payload: params.payload,
channel: params.channel,
state: {
nextRunAtMs: computeNextRun(params.schedule, currentMs),
lastRunAtMs: null,
Expand All @@ -246,6 +254,7 @@ export function createCronEngine(opts: CronEngineOpts): CronEngine {
if (patch.name !== undefined) job.name = patch.name
if (patch.payload !== undefined) job.payload = patch.payload
if (patch.enabled !== undefined) job.enabled = patch.enabled
if (patch.channel !== undefined) job.channel = patch.channel

if (patch.schedule !== undefined) {
job.schedule = patch.schedule
Expand Down
3 changes: 2 additions & 1 deletion src/task/cron/listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,12 @@ export function createCronListener(opts: CronListenerOpts): CronListener {
historyPreamble: 'The following is the recent cron session conversation. This is an automated cron job execution.',
})

// Send notification through the last-interacted connector
// Send notification through the targeted connector (or last-interacted fallback)
try {
await connectorCenter.notify(result.text, {
media: result.media,
source: 'cron',
channel: payload.channel,
})
} catch (sendErr) {
console.warn(`cron-listener: send failed for job ${payload.jobId}:`, sendErr)
Expand Down
9 changes: 6 additions & 3 deletions src/task/cron/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,13 @@ export function createCronTools(cronEngine: CronEngine) {
payload: z.string().describe('The reminder/instruction text delivered to you when the job fires'),
schedule: scheduleSchema.optional().describe('When the job should run'),
enabled: z.boolean().optional().describe('Whether the job starts enabled (default: true)'),
channel: z.string().optional().describe('Deliver results to a specific connector channel (e.g. "telegram", "web"). Defaults to last-interacted.'),
sessionTarget: z
.enum(['main', 'isolated'])
.optional()
.describe('Where to run: "main" injects into heartbeat session (default), "isolated" runs in a fresh session'),
}),
execute: async ({ name, payload, schedule, enabled }) => {
execute: async ({ name, payload, schedule, enabled, channel }) => {
if (!schedule) {
return { error: 'schedule is required' }
}
Expand All @@ -75,6 +76,7 @@ export function createCronTools(cronEngine: CronEngine) {
payload,
schedule,
enabled,
channel,
})
return { id }
},
Expand All @@ -91,14 +93,15 @@ export function createCronTools(cronEngine: CronEngine) {
payload: z.string().optional().describe('New payload text'),
schedule: scheduleSchema.optional().describe('New schedule'),
enabled: z.boolean().optional().describe('Enable or disable the job'),
channel: z.string().optional().describe('Deliver results to a specific connector channel (e.g. "telegram", "web").'),
sessionTarget: z
.enum(['main', 'isolated'])
.optional()
.describe('New session target'),
}),
execute: async ({ id, name, payload, schedule, enabled }) => {
execute: async ({ id, name, payload, schedule, enabled, channel }) => {
try {
await cronEngine.update(id, { name, payload, schedule, enabled })
await cronEngine.update(id, { name, payload, schedule, enabled, channel })
return { ok: true }
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) }
Expand Down
5 changes: 4 additions & 1 deletion ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import { TradingPage } from './pages/TradingPage'
import { ConnectorsPage } from './pages/ConnectorsPage'
import { DevPage } from './pages/DevPage'
import { HeartbeatPage } from './pages/HeartbeatPage'
import { CronPage } from './pages/CronPage'
import { ToolsPage } from './pages/ToolsPage'
import { AgentStatusPage } from './pages/AgentStatusPage'

export type Page =
| 'chat' | 'portfolio' | 'events' | 'agent-status' | 'heartbeat' | 'market-data' | 'news' | 'connectors'
| 'chat' | 'portfolio' | 'events' | 'agent-status' | 'heartbeat' | 'cron' | 'market-data' | 'news' | 'connectors'
| 'trading'
| 'ai-provider' | 'settings' | 'tools' | 'dev'

Expand All @@ -27,6 +28,7 @@ export const ROUTES: Record<Page, string> = {
'events': '/events',
'agent-status': '/agent-status',
'heartbeat': '/heartbeat',
'cron': '/cron',
'market-data': '/market-data',
'news': '/news',
'connectors': '/connectors',
Expand Down Expand Up @@ -70,6 +72,7 @@ export function App() {
<Route path="/events" element={<EventsPage />} />
<Route path="/agent-status" element={<AgentStatusPage />} />
<Route path="/heartbeat" element={<HeartbeatPage />} />
<Route path="/cron" element={<CronPage />} />
<Route path="/market-data" element={<MarketDataPage />} />
<Route path="/news" element={<NewsPage />} />
<Route path="/data-sources" element={<Navigate to="/market-data" replace />} />
Expand Down
4 changes: 2 additions & 2 deletions ui/src/api/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const cronApi = {
return res.json()
},

async add(params: { name: string; payload: string; schedule: CronSchedule; enabled?: boolean }): Promise<{ id: string }> {
async add(params: { name: string; payload: string; schedule: CronSchedule; enabled?: boolean; channel?: string }): Promise<{ id: string }> {
const res = await fetch('/api/cron/jobs', {
method: 'POST',
headers,
Expand All @@ -21,7 +21,7 @@ export const cronApi = {
return res.json()
},

async update(id: string, patch: Partial<{ name: string; payload: string; schedule: CronSchedule; enabled: boolean }>): Promise<void> {
async update(id: string, patch: Partial<{ name: string; payload: string; schedule: CronSchedule; enabled: boolean; channel: string }>): Promise<void> {
const res = await fetch(`/api/cron/jobs/${id}`, {
method: 'PUT',
headers,
Expand Down
1 change: 1 addition & 0 deletions ui/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export interface CronJob {
enabled: boolean
schedule: CronSchedule
payload: string
channel?: string
state: CronJobState
createdAt: number
}
Expand Down
10 changes: 10 additions & 0 deletions ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ const NAV_ITEMS: NavItem[] = [
</svg>
),
},
{
page: 'cron' as const,
label: 'Cron Jobs',
icon: (active: boolean) => (
<svg width="18" height="18" viewBox="0 0 24 24" fill={active ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
),
},
{
page: 'market-data',
label: 'Market Data',
Expand Down
Loading