fix(desktop): scope the cron jobs list to the active profile

Salvaged from #42654 by @digitalbase (earliest report of the leak, June 9):
the desktop sidebar and cron overlay showed EVERY profile's jobs because
GET /api/cron/jobs defaults to profile=all and the desktop never sent the
param — profileScoped() (landed in #67493) routes the backend process but
adds no endpoint filter on local pools.

- hermes.ts: getCronJobs(profile?) appends ?profile= when given; omitting
  the arg keeps the legacy unfiltered path. profileScoped() still rides
  along for process routing.
- use-session-list-actions.ts: sidebar cron refresh passes the sidebar's
  profile scope (concrete profile → own jobs; ALL_PROFILES → 'all').
- app/cron/index.tsx: the cron overlay's refresh uses the same scope so
  the overlay and sidebar (shared $cronJobs atom) always agree.
- Tests: list ?profile= contract in hermes-cron-scope.test.ts; sidebar
  scoping in use-session-list-actions.test.tsx.

Reworked onto current main per the sweeper review: threaded through the
existing profileScoped()/list-param seams instead of the original PR's
pre-refactor call sites (DesktopController has since delegated to
use-session-list-actions).
This commit is contained in:
digitalbase 2026-07-19 10:40:30 -07:00 committed by Teknium
parent 299e409f15
commit 5f2bfb6631
6 changed files with 60 additions and 7 deletions

View file

@ -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 {
@ -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)

View file

@ -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')
})
})

View file

@ -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

View file

@ -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')
})
})

View file

@ -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
})
}

View file

@ -0,0 +1 @@
digitalbase