Skip to content
Closed
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
26 changes: 18 additions & 8 deletions apps/desktop/src/app/cron/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { AlertTriangle, Clock } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron'
import { notify, notifyError } from '@/store/notifications'
import { $profileScope, ALL_PROFILES } from '@/store/profile'

import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
Expand Down Expand Up @@ -251,6 +252,12 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
// sidebar and this overlay never drift — a delete here clears the sidebar row
// immediately. `loading` only gates the first paint before the atom is filled.
const jobs = useStore($cronJobs)
// Cron jobs are per-profile; follow the same scope as the sidebar. A concrete
// profile lists (and creates) just that profile's jobs; ALL_PROFILES shows the
// unified view and creates fall back to the backend 'default'.
const profileScope = useStore($profileScope)
const cronListProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
const cronCreateProfile = profileScope === ALL_PROFILES ? undefined : profileScope
const [loading, setLoading] = useState(jobs.length === 0)
const [query, setQuery] = useState('')
const [busyJobId, setBusyJobId] = useState<null | string>(null)
Expand All @@ -267,13 +274,13 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt

const refresh = useCallback(async () => {
try {
setCronJobs(await getCronJobs())
setCronJobs(await getCronJobs(cronListProfile))
} catch (err) {
notifyError(err, c.failedLoad)
} finally {
setLoading(false)
}
}, [c])
}, [c, cronListProfile])

useRefreshHotkey(refresh)

Expand Down Expand Up @@ -377,12 +384,15 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt

async function handleEditorSave(values: EditorValues) {
if (editor.mode === 'create') {
const created = await createCronJob({
prompt: values.prompt,
schedule: values.schedule,
name: values.name || undefined,
deliver: values.deliver || DEFAULT_DELIVER
})
const created = await createCronJob(
{
prompt: values.prompt,
schedule: values.schedule,
name: values.name || undefined,
deliver: values.deliver || DEFAULT_DELIVER
},
cronCreateProfile
)

updateCronJobs(rows => [...rows, created])
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,13 +286,17 @@ export function DesktopController() {
// next-run/state fresh as the scheduler advances them.
const refreshCronJobs = useCallback(async () => {
try {
const jobs = await getCronJobs()
// Scope to the active profile (cron jobs live per-profile on disk) so the
// sidebar shows only this profile's jobs; ALL_PROFILES → 'all' for the
// unified browse view. Single-profile users resolve to 'default'.
const cronProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
const jobs = await getCronJobs(cronProfile)

setCronJobs(jobs)
} catch {
// Non-fatal: the cron section just keeps its last-known jobs.
}
}, [])
}, [profileScope])

const refreshSessions = useCallback(async () => {
const requestId = refreshSessionsRequestRef.current + 1
Expand Down
65 changes: 64 additions & 1 deletion apps/desktop/src/hermes.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { listAllProfileSessions, listSessions } from './hermes'
import { createCronJob, getCronJobs, listAllProfileSessions, listSessions } from './hermes'

const emptySessionsResponse = {
limit: 0,
Expand Down Expand Up @@ -47,3 +47,66 @@ describe('Hermes REST session helpers', () => {
)
})
})

describe('Hermes REST cron helpers', () => {
let api: ReturnType<typeof vi.fn>

beforeEach(() => {
api = vi.fn().mockResolvedValue([])
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { api }
})
})

afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(window, 'hermesDesktop')
})

it('scopes the cron job listing to a concrete profile', async () => {
await getCronJobs('coder')

expect(api).toHaveBeenCalledWith({ path: '/api/cron/jobs?profile=coder' })
})

it('passes profile=all for the unified cron view', async () => {
await getCronJobs('all')

expect(api).toHaveBeenCalledWith({ path: '/api/cron/jobs?profile=all' })
})

it('omits the profile query when none is given (legacy default)', async () => {
await getCronJobs()

expect(api).toHaveBeenCalledWith({ path: '/api/cron/jobs' })
})

it('encodes profile names with reserved characters', async () => {
await getCronJobs('team a/b')

expect(api).toHaveBeenCalledWith({ path: '/api/cron/jobs?profile=team%20a%2Fb' })
})

it('creates a cron job in the given profile', async () => {
const body = { prompt: 'do thing', schedule: '0 9 * * *' }
await createCronJob(body, 'coder')

expect(api).toHaveBeenCalledWith({
path: '/api/cron/jobs?profile=coder',
method: 'POST',
body
})
})

it('creates a cron job without a profile (backend defaults to default)', async () => {
const body = { prompt: 'do thing', schedule: '0 9 * * *' }
await createCronJob(body)

expect(api).toHaveBeenCalledWith({
path: '/api/cron/jobs',
method: 'POST',
body
})
})
})
19 changes: 15 additions & 4 deletions apps/desktop/src/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,9 +498,15 @@ export function testMessagingPlatform(platformId: string): Promise<MessagingPlat
})
}

export function getCronJobs(): Promise<CronJob[]> {
// Cron jobs are stored per-profile (<HERMES_HOME>/cron/jobs.json). Pass a
// concrete profile key to list just that profile's jobs, or 'all' for the
// unified cross-profile view. Omitting the arg keeps the backend's legacy
// 'all' default, so non-profile callers are unaffected.
export function getCronJobs(profile?: string): Promise<CronJob[]> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes only request.path, but current Electron routing selects the target backend from request.profile before it conditionally adds a global-remote query parameter (apps/desktop/electron/main.ts:7916-7928). A named profile with its own pooled or remote backend would still hit the primary backend. Scope the helper with ...profileScoped() instead.


return window.hermesDesktop.api<CronJob[]>({
path: '/api/cron/jobs'
path: `/api/cron/jobs${suffix}`
})
}

Expand All @@ -518,9 +524,14 @@ export async function getCronJobRuns(jobId: string, limit = 20): Promise<Session
return runs ?? []
}

export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
// Create in a specific profile's store. Omitting `profile` lets the backend
// default to 'default' (~/.hermes). Callers viewing a concrete profile pass it
// so the new job lands in — and shows up under — the profile being viewed.
export function createCronJob(body: CronJobCreatePayload, profile?: string): Promise<CronJob> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''

return window.hermesDesktop.api<CronJob>({
path: '/api/cron/jobs',
path: `/api/cron/jobs${suffix}`,
method: 'POST',
body
})
Expand Down