Files
aiturk-hermes-ide/apps/desktop/src/plugins/hermes-bots/cron-load.test.ts
T

178 lines
5.9 KiB
TypeScript

/**
* `loadRoutines` — the pane's one read, and the two things it must survive.
*
* 1. Profile scope (#37). cron.manage is scoped to the bot's OWN cron store
* through the core RPC's optional `profile` param: a bot's profile can run
* a separate gateway, or keep cron in ~/.hermes/profiles/<name>/cron/.
* Older gateways ignore the unknown param, so the `[bot:]` tag filter in
* selectRoutineJobs stays the fallback.
* 2. Legacy delegated routines are paused inline before the list returns —
* they carry a pre-hardening prompt whose shell command was built by
* interpolation. A single pause RPC failing used to reject the WHOLE query:
* the pane showed "Could not load cronjobs" over a list that had loaded
* fine, and the 20s poll retried the failing pause inside a failing query
* forever. Each pause now swallows its own error, and the paused-state
* overlay only claims the jobs the gateway actually paused.
*
* The RPC boundary is `host.request` rather than `requestForBot`, so the real
* routing layer (route resolution, error coercion) runs.
*/
import type * as HermesSdk from '@hermes/plugin-sdk'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RoutineJob } from './types'
const request = vi.fn()
vi.mock('@hermes/plugin-sdk', async importOriginal => {
const sdk = await importOriginal<typeof HermesSdk>()
return { ...sdk, host: { ...sdk.host, request } }
})
const { loadRoutines } = await import('./cron')
const LEGACY_PREFIX = 'You are running the scheduled routine "'
/** The shape a pre-hardening routine was persisted with. */
function legacyJob(id: string, title: string, bot: string): RoutineJob {
return {
enabled: true,
job_id: id,
name: `[bot:${bot}] ${title}`,
prompt_preview: `${LEGACY_PREFIX}${title}" for agent '${bot}'`,
state: 'scheduled'
}
}
/** Every cron.manage call, as `[action, name]`. */
function callLog() {
return request.mock.calls.map(([, params]) => [params.action, params.name])
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('the bot\u2019s own cron store', () => {
it('scopes the list and every inline pause to the bot profile', async () => {
request.mockImplementation(async (_method: string, params: Record<string, unknown>) =>
params.action === 'list' ? { jobs: [legacyJob('legacy', 'Audit', 'research')] } : { success: true }
)
await loadRoutines('research')
expect(request.mock.calls.map(([method, params]) => [method, params])).toEqual([
['cron.manage', { action: 'list', include_disabled: true, profile: 'research' }],
['cron.manage', { action: 'pause', name: 'legacy', profile: 'research' }]
])
})
it('omits the scope entirely when no owner is resolved', async () => {
request.mockResolvedValue({ jobs: [] })
await loadRoutines(undefined)
expect(request).toHaveBeenCalledWith('cron.manage', { action: 'list', include_disabled: true })
})
})
describe('pausing a legacy delegated routine cannot fail the list', () => {
it('returns the whole list and overlays only the pauses that landed', async () => {
const jobs: RoutineJob[] = [
legacyJob('legacy-fails', 'Audit', 'research'),
legacyJob('legacy-pauses', 'Build', 'research'),
{
enabled: true,
job_id: 'normal',
name: '[bot:research] Report',
prompt: 'Summarize the day',
state: 'scheduled'
}
]
request.mockImplementation(async (_method: string, params: Record<string, unknown>) => {
if (params.action === 'list') {
return { jobs }
}
if (params.name === 'legacy-fails') {
throw new Error('gateway rejected the pause')
}
return { success: true }
})
// Pre-fix this rejected with the pause error.
const result = await loadRoutines('research')
expect(result.jobs).toHaveLength(3)
const byId = Object.fromEntries(result.jobs!.map(job => [job.job_id, job]))
// The failed pause keeps its server state and is retried on the next poll;
// claiming it as paused would tell the user a job is safe when it is not.
expect(byId['legacy-fails']).toMatchObject({ enabled: true, state: 'scheduled' })
expect(byId['legacy-pauses']).toMatchObject({ enabled: false, state: 'paused' })
expect(byId.normal.enabled).toBe(true)
expect(callLog()).toEqual([
['list', undefined],
['pause', 'legacy-fails'],
['pause', 'legacy-pauses']
])
})
it('still resolves with the list when every pause fails', async () => {
request.mockImplementation(async (_method: string, params: Record<string, unknown>) => {
if (params.action === 'list') {
return { jobs: [legacyJob('only', 'Watch', 'ops')] }
}
throw new Error('gateway down')
})
const result = await loadRoutines('ops')
expect(result.jobs![0]).toMatchObject({ enabled: true, job_id: 'only' })
})
})
describe('the security pause is one-shot, not a poll loop', () => {
it('pauses the persisted routine once and leaves the rest of the record intact', async () => {
const persisted: RoutineJob = {
...legacyJob('legacy-job', 'Audit', 'research'),
repeat: { completed: 1, times: 3 } as unknown as string,
schedule: 'every 2h'
}
request.mockImplementation(async (_method: string, params: Record<string, unknown>) => {
if (params.action === 'list') {
return { jobs: [persisted] }
}
persisted.enabled = false
persisted.state = 'paused'
return { success: true }
})
const first = await loadRoutines('research')
expect(first.jobs![0]).toMatchObject({
enabled: false,
job_id: 'legacy-job',
repeat: { completed: 1, times: 3 },
schedule: 'every 2h',
state: 'paused'
})
request.mockClear()
await loadRoutines('research')
// Already paused: the next poll must not re-attempt the pause forever.
expect(callLog()).toEqual([['list', undefined]])
})
})