Skip to content
Merged
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
10 changes: 8 additions & 2 deletions apps/desktop/src/app/cron/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { requestModelOptions } from '@/lib/model-options'
import { asText } from '@/lib/text'
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 {
Expand Down Expand Up @@ -293,15 +294,20 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
const [deleting, setDeleting] = useState(false)

// Jobs live per-profile on disk and the list endpoint aggregates 'all' by
// default — scope the fetch to the sidebar's profile scope so this overlay
// and the sidebar (which share the $cronJobs atom) agree on what's shown.
const profileScope = useStore($profileScope)

const refresh = useCallback(async () => {
try {
setCronJobs(await getCronJobs())
setCronJobs(await getCronJobs(profileScope === ALL_PROFILES ? 'all' : profileScope))
} catch (err) {
notifyError(err, c.failedLoad)
} finally {
setLoading(false)
}
}, [c])
}, [c, profileScope])

useRefreshHotkey(refresh)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,25 @@ describe('refreshSessions batches slices into one request', () => {
})
)
})

it('scopes the cron-jobs fetch to the active profile (all → unified view)', async () => {
const { getCronJobs } = await import('@/hermes')
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [], total: 0, profile_totals: {} }))

const scoped = renderHook(() => useSessionListActions({ profileScope: 'work' }))

await act(async () => {
await scoped.result.current.refreshCronJobs()
})

expect(getCronJobs).toHaveBeenLastCalledWith('work')

const unified = renderHook(() => useSessionListActions({ profileScope: '__all__' }))

await act(async () => {
await unified.result.current.refreshCronJobs()
})

expect(getCronJobs).toHaveBeenLastCalledWith('all')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -123,16 +123,19 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
// Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
// synchronously (agent tool call or the cron UI), so refreshing here right
// after an agent turn surfaces a new job immediately; the interval poll keeps
// next-run/state fresh as the scheduler advances them.
// next-run/state fresh as the scheduler advances them. Jobs live per-profile
// on disk and the list endpoint aggregates 'all' by default, so scope the
// fetch to the sidebar's profile scope — a concrete profile sees only its
// own jobs; ALL_PROFILES keeps the unified view.
const refreshCronJobs = useCallback(async () => {
try {
const jobs = await getCronJobs()
const jobs = await getCronJobs(profileScope === ALL_PROFILES ? 'all' : profileScope)

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
15 changes: 15 additions & 0 deletions apps/desktop/src/hermes-cron-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,19 @@ describe('cron helpers are profile-scoped', () => {
expect(call[0].profile).toBe('coder')
}
})

it('list accepts an explicit ?profile= for endpoint-level filtering', () => {
// profileScoped() routes the backend process; the list endpoint ALSO
// aggregates 'all' by default, so callers pass an explicit profile to
// filter what the endpoint returns (sidebar / cron overlay scoping).
void getCronJobs('worker_alpha')
expect(api.mock.calls.at(-1)?.[0].path).toBe('/api/cron/jobs?profile=worker_alpha')

void getCronJobs('all')
expect(api.mock.calls.at(-1)?.[0].path).toBe('/api/cron/jobs?profile=all')

// Omitting the arg keeps the legacy unfiltered path.
void getCronJobs()
expect(api.mock.calls.at(-1)?.[0].path).toBe('/api/cron/jobs')
})
})
11 changes: 9 additions & 2 deletions apps/desktop/src/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,10 +956,17 @@ export function testMessagingPlatform(platformId: string): Promise<MessagingPlat
})
}

export function getCronJobs(): Promise<CronJob[]> {
// Cron jobs are stored per-profile (<HERMES_HOME>/cron/jobs.json), and the
// backend's list endpoint defaults to 'all'. 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 legacy 'all' default for non-profile callers.
// profileScoped() still rides along for backend-process routing.
export function getCronJobs(profile?: string): Promise<CronJob[]> {
const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : ''

return window.hermesDesktop.api<CronJob[]>({
...profileScoped(),
path: '/api/cron/jobs',
path: `/api/cron/jobs${suffix}`,
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
})
}
Expand Down
1 change: 1 addition & 0 deletions contributors/emails/gijs@digitalbase.eu
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
digitalbase
Loading