mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
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:
parent
299e409f15
commit
5f2bfb6631
6 changed files with 60 additions and 7 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
1
contributors/emails/gijs@digitalbase.eu
Normal file
1
contributors/emails/gijs@digitalbase.eu
Normal file
|
|
@ -0,0 +1 @@
|
|||
digitalbase
|
||||
Loading…
Add table
Add a link
Reference in a new issue