fix(desktop): read cron run-history from the owning gateway
When Hermes Desktop works against a REGISTERED gateway connection, cron jobs execute on that gateway and persist their run sessions in the gateway's state.db. But every REST call in the app — the cron surface included — carried only `profile`, so `hermes:api` routed it through the local profile pool and `_list_cron_job_runs_sync` read a local state.db with zero `source='cron'` rows. Every job showed "No runs yet" while the same endpoint on the gateway returned the real runs (#87882). Fix at the routing seam: - HermesApiRequest gains an optional `connectionId`. The renderer's cron helpers (list/get/runs/delivery-targets/create/update/pause/resume/ trigger/delete/blueprints) now tag the active registry connection via a new connectionScoped() twin of profileScoped(), fed from the same setApiRequestConnection seam store/gateway already maintains for the plugin socket. - The hermes:api main-process handler resolves a tagged request through ensureRegistryBackend — the SAME pool the job list and WS traffic use — instead of the legacy profile route. Shared remote/cloud hosts (one gateway, many profiles) get the path scoped with ?profile= via the new pathWithProfileScope helper, factored out of pathWithGlobalRemoteProfile. - '' / 'local' / absent connectionId keep the byte-identical v1 route, so single-source and connection-config-remote users are unaffected. This covers the run-history panel, the sidebar cron peek, and every other cron surface in one place, since they all funnel through the same helpers. Fixes #87882
This commit is contained in:
@@ -15,6 +15,7 @@ import assert from 'node:assert/strict'
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
apiRequestRegistryConnectionId,
|
||||
AT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
normalizeSshConfig,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
pathWithProfileScope,
|
||||
profileHasRemoteConnection,
|
||||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
@@ -279,6 +281,37 @@ test('resolveProfileBackendRoute only tags a descriptor when the backend is shar
|
||||
}
|
||||
})
|
||||
|
||||
// --- registry-pinned REST routing (cron run history on remote gateways, #87882) ---
|
||||
|
||||
test('apiRequestRegistryConnectionId extracts a genuinely non-local connection id', () => {
|
||||
assert.equal(apiRequestRegistryConnectionId({ connectionId: 'gw-tailscale', path: '/api/cron/jobs' }), 'gw-tailscale')
|
||||
assert.equal(apiRequestRegistryConnectionId({ connectionId: ' gw-1 ', path: '/x' }), 'gw-1')
|
||||
})
|
||||
|
||||
test('apiRequestRegistryConnectionId resolves null for the legacy/local routes', () => {
|
||||
assert.equal(apiRequestRegistryConnectionId({ path: '/api/cron/jobs' }), null)
|
||||
assert.equal(apiRequestRegistryConnectionId({ connectionId: '', path: '/x' }), null)
|
||||
assert.equal(apiRequestRegistryConnectionId({ connectionId: 'local', path: '/x' }), null)
|
||||
assert.equal(apiRequestRegistryConnectionId({ connectionId: null, path: '/x' }), null)
|
||||
assert.equal(apiRequestRegistryConnectionId(null), null)
|
||||
assert.equal(apiRequestRegistryConnectionId(undefined), null)
|
||||
})
|
||||
|
||||
test('pathWithProfileScope scopes shared-remote requests to the profile unconditionally', () => {
|
||||
// A sharedRemote registry gateway serves every profile from one host; the
|
||||
// run-history read must land on the profile that owns the job's sessions.
|
||||
assert.equal(
|
||||
pathWithProfileScope('/api/cron/jobs/job-1/runs?limit=20', 'research'),
|
||||
'/api/cron/jobs/job-1/runs?limit=20&profile=research'
|
||||
)
|
||||
})
|
||||
|
||||
test('pathWithProfileScope keeps an explicit profile query and no-ops on empty profile', () => {
|
||||
assert.equal(pathWithProfileScope('/api/cron/jobs?profile=all', 'research'), '/api/cron/jobs?profile=all')
|
||||
assert.equal(pathWithProfileScope('/api/cron/jobs', ''), '/api/cron/jobs')
|
||||
assert.equal(pathWithProfileScope('/api/cron/jobs', null), '/api/cron/jobs')
|
||||
})
|
||||
|
||||
// --- pathWithGlobalRemoteProfile ---
|
||||
|
||||
test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => {
|
||||
|
||||
@@ -446,9 +446,23 @@ function resolveProfileBackendRoute(profile, opts: ProfileRouteOptions = {}): Pr
|
||||
* serving backend is not already scoped to that profile.
|
||||
*/
|
||||
function pathWithGlobalRemoteProfile(path, profile, opts: ProfileRouteOptions = {}) {
|
||||
if (!resolveProfileBackendRoute(profile, opts).scopePath) {
|
||||
return path
|
||||
}
|
||||
|
||||
return pathWithProfileScope(path, profile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unconditionally scope a REST path to a profile via `?profile=`. Used by the
|
||||
* global-remote route above and by registry `sharedRemote` connections (one
|
||||
* gateway host serving every profile, scoped per request). An explicit
|
||||
* `?profile=` already on the path wins; an empty profile is a no-op.
|
||||
*/
|
||||
function pathWithProfileScope(path, profile) {
|
||||
const scopedProfile = connectionScopeKey(profile)
|
||||
|
||||
if (!resolveProfileBackendRoute(profile, opts).scopePath) {
|
||||
if (!scopedProfile) {
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -475,6 +489,23 @@ function pathWithGlobalRemoteProfile(path, profile, opts: ProfileRouteOptions =
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry connection a REST request is explicitly pinned to, or null for the
|
||||
* legacy profile-routed path. `''`/`'local'` mean the local pool — callers
|
||||
* only detour through the registry for a genuinely non-local connection, so
|
||||
* single-source users keep the byte-identical v1 route.
|
||||
*/
|
||||
function apiRequestRegistryConnectionId(request): null | string {
|
||||
const raw = request && typeof request === 'object' ? (request as { connectionId?: unknown }).connectionId : ''
|
||||
const id = String(raw ?? '').trim()
|
||||
|
||||
if (!id || id === 'local') {
|
||||
return null
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
function tokenPreview(value) {
|
||||
const raw = String(value || '')
|
||||
|
||||
@@ -588,6 +619,7 @@ function cookiesHavePrivyAccessToken(cookies) {
|
||||
}
|
||||
|
||||
export {
|
||||
apiRequestRegistryConnectionId,
|
||||
AT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
@@ -607,6 +639,7 @@ export {
|
||||
normalizeSshConfig,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
pathWithProfileScope,
|
||||
PRIVY_ACCESS_COOKIE_VARIANTS,
|
||||
PRIVY_SESSION_COOKIE_VARIANTS,
|
||||
profileHasRemoteConnection,
|
||||
|
||||
@@ -56,6 +56,7 @@ import { decideBootstrapRepair } from './bootstrap-repair-guard'
|
||||
import { runBootstrap } from './bootstrap-runner'
|
||||
import { applyConnectionChange, resolveTerminalConnection } from './connection-apply'
|
||||
import {
|
||||
apiRequestRegistryConnectionId,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
@@ -73,6 +74,7 @@ import {
|
||||
normalizeSshConfig,
|
||||
normAuthMode,
|
||||
pathWithGlobalRemoteProfile,
|
||||
pathWithProfileScope,
|
||||
profileHasRemoteConnection,
|
||||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
@@ -12316,6 +12318,41 @@ async function getJsonForBackend(descriptor, path, opts: any = {}) {
|
||||
return fetchJson(url, descriptor.token, opts)
|
||||
}
|
||||
|
||||
// Any-method REST call against a resolved backend descriptor — the descriptor
|
||||
// analogue of the hermes:api handler's own auth split: OAuth backends prefer a
|
||||
// native bearer (cookieless RFC 8252 flow) and fall back to the OAuth cookie
|
||||
// partition; token/local descriptors use the static session-token header.
|
||||
async function fetchJsonForBackend(
|
||||
descriptor,
|
||||
path,
|
||||
opts: { method?: string; body?: unknown; upload?: unknown; timeoutMs?: number } = {}
|
||||
) {
|
||||
const url = `${descriptor.baseUrl}${path}`
|
||||
|
||||
if (descriptor.authMode === 'oauth') {
|
||||
// The OAuth cookie path rides electron.net with JSON headers; multipart
|
||||
// isn't wired there. Fail loudly rather than corrupting the upload.
|
||||
if (opts.upload) {
|
||||
throw new Error('File uploads are not supported against OAuth-gated remote backends yet.')
|
||||
}
|
||||
|
||||
const nativeAt = await ensureNativeAccessToken(descriptor.baseUrl).catch(() => null)
|
||||
|
||||
if (nativeAt) {
|
||||
return fetchJson(url, null, { method: opts.method, body: opts.body, timeoutMs: opts.timeoutMs, bearer: nativeAt })
|
||||
}
|
||||
|
||||
return fetchJsonViaOauthSession(url, { method: opts.method, body: opts.body, timeoutMs: opts.timeoutMs })
|
||||
}
|
||||
|
||||
return fetchJson(url, descriptor.token, {
|
||||
method: opts.method,
|
||||
body: opts.body,
|
||||
upload: opts.upload,
|
||||
timeoutMs: opts.timeoutMs
|
||||
})
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
|
||||
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
|
||||
// Capability-gated login (RFC 8252). Probe the gateway's public /api/status:
|
||||
@@ -12724,6 +12761,29 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) {
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
// Registry-pinned request (request.connectionId): the renderer is working
|
||||
// against a REGISTERED gateway connection, so the data — cron jobs and their
|
||||
// run sessions included — lives in THAT host's state.db, not any local
|
||||
// profile's. Resolve the backend through the registry (same pool the job
|
||||
// list and WS traffic use) instead of the legacy profile route; a shared
|
||||
// remote/cloud host serves every profile via ?profile=, so scope the path.
|
||||
// '' / 'local' fall through to the byte-identical v1 route below (#87882).
|
||||
const registryConnectionId = apiRequestRegistryConnectionId(request)
|
||||
|
||||
if (registryConnectionId) {
|
||||
const connection: any = await ensureRegistryBackend(registryConnectionId, request?.profile)
|
||||
const requestPath = connection.sharedRemote
|
||||
? pathWithProfileScope(request.path, request?.profile)
|
||||
: request.path
|
||||
|
||||
return fetchJsonForBackend(connection, requestPath, {
|
||||
method: request?.method,
|
||||
body: request?.body,
|
||||
upload: request?.upload,
|
||||
timeoutMs: resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
})
|
||||
}
|
||||
|
||||
// Remote-profile session requests would otherwise hit the local primary off
|
||||
// each profile's on-disk state.db — fine for local profiles, but a remote
|
||||
// profile's sessions live on its remote host, so the UI's IDs 404 (or mutations
|
||||
|
||||
Vendored
+6
@@ -1004,6 +1004,12 @@ export interface HermesApiRequest {
|
||||
// (window) backend. Read-only cross-profile data is served by the primary, so
|
||||
// this is only needed for profile-scoped live/settings calls.
|
||||
profile?: string | null
|
||||
// Route this REST call to a specific REGISTERED gateway connection (v2
|
||||
// registry). Data owned by a remote gateway — cron jobs and their run
|
||||
// sessions — lives in that host's state.db, so requests for it must resolve
|
||||
// through the owning connection, not the local profile pool. Omit / '' /
|
||||
// 'local' keep the legacy profile-routed path.
|
||||
connectionId?: string | null
|
||||
}
|
||||
|
||||
export interface HermesNotification {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getCronJobs,
|
||||
pauseCronJob,
|
||||
resumeCronJob,
|
||||
setApiRequestConnection,
|
||||
setApiRequestProfile,
|
||||
triggerCronJob,
|
||||
updateCronJob
|
||||
@@ -29,6 +30,7 @@ describe('cron helpers are profile-scoped', () => {
|
||||
|
||||
afterEach(() => {
|
||||
setApiRequestProfile(null)
|
||||
setApiRequestConnection(null)
|
||||
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
@@ -57,6 +59,37 @@ describe('cron helpers are profile-scoped', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('omits connectionId when the local pool serves the active gateway', () => {
|
||||
void getCronJobRuns('job-1')
|
||||
expect(api.mock.calls.at(-1)?.[0]).not.toHaveProperty('connectionId')
|
||||
})
|
||||
|
||||
// Contract: with a registered gateway connection active, cron run sessions
|
||||
// live in THAT gateway's state.db — not in any local profile's. Every cron
|
||||
// helper must tag the owning connection so the main process routes the REST
|
||||
// call to the same backend the job list (and its runs) actually live on.
|
||||
// Without it, run history read a local state.db with zero cron rows and
|
||||
// every job showed "No runs yet" (#87882).
|
||||
it('forwards the active registry connection to every cron helper', () => {
|
||||
setApiRequestProfile('research')
|
||||
setApiRequestConnection('gw-tailscale')
|
||||
|
||||
void getCronJobs('research')
|
||||
void getCronJob('job-1')
|
||||
void getCronJobRuns('job-1')
|
||||
void createCronJob({ name: 'nightly', prompt: 'run', schedule: '0 3 * * *' } as never)
|
||||
void updateCronJob('job-1', { enabled: false } as never)
|
||||
void pauseCronJob('job-1')
|
||||
void resumeCronJob('job-1')
|
||||
void triggerCronJob('job-1')
|
||||
void deleteCronJob('job-1')
|
||||
|
||||
for (const call of api.mock.calls) {
|
||||
expect((call[0] as { connectionId?: string }).connectionId).toBe('gw-tailscale')
|
||||
expect(call[0].profile).toBe('research')
|
||||
}
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
@@ -284,6 +284,15 @@ export function setApiRequestConnection(connectionId: null | string): void {
|
||||
_apiConnectionId = connectionId || null
|
||||
}
|
||||
|
||||
// Registry connection scope for a REST request. A registered remote gateway
|
||||
// owns its own state.db — cron jobs and their run sessions live THERE — so
|
||||
// requests for gateway-owned data must carry the connection id for the main
|
||||
// process to route them to that host (hermes:api's registry branch). Null /
|
||||
// 'local' resolves to no tag, keeping single-source users byte-identical.
|
||||
function connectionScoped(): { connectionId?: string } {
|
||||
return _apiConnectionId ? { connectionId: _apiConnectionId } : {}
|
||||
}
|
||||
|
||||
/** Registry connection id that connection-scoped WS calls should target
|
||||
* (null → the local pool). Read-only twin of setApiRequestConnection. */
|
||||
export function getApiRequestConnection(): null | string {
|
||||
@@ -1545,6 +1554,7 @@ export function getCronJobs(profile?: string): Promise<CronJob[]> {
|
||||
|
||||
return window.hermesDesktop.api<CronJob[]>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs${suffix}`,
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
@@ -1553,6 +1563,7 @@ export function getCronJobs(profile?: string): Promise<CronJob[]> {
|
||||
export function getCronJob(jobId: string): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`
|
||||
})
|
||||
}
|
||||
@@ -1560,6 +1571,7 @@ export function getCronJob(jobId: string): Promise<CronJob> {
|
||||
export async function getCronJobRuns(jobId: string, limit = 20): Promise<SessionInfo[]> {
|
||||
const { runs } = await window.hermesDesktop.api<{ runs: SessionInfo[] }>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`
|
||||
})
|
||||
|
||||
@@ -1572,6 +1584,7 @@ export async function getCronJobRuns(jobId: string, limit = 20): Promise<Session
|
||||
export async function getCronDeliveryTargets(): Promise<CronDeliveryTarget[]> {
|
||||
const { targets } = await window.hermesDesktop.api<{ targets: CronDeliveryTarget[] }>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: '/api/cron/delivery-targets'
|
||||
})
|
||||
|
||||
@@ -1581,6 +1594,7 @@ export async function getCronDeliveryTargets(): Promise<CronDeliveryTarget[]> {
|
||||
export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: '/api/cron/jobs',
|
||||
method: 'POST',
|
||||
body
|
||||
@@ -1590,6 +1604,7 @@ export function createCronJob(body: CronJobCreatePayload): Promise<CronJob> {
|
||||
export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
|
||||
method: 'PUT',
|
||||
body: { updates }
|
||||
@@ -1599,6 +1614,7 @@ export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise<C
|
||||
export function pauseCronJob(jobId: string): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`,
|
||||
method: 'POST'
|
||||
})
|
||||
@@ -1607,6 +1623,7 @@ export function pauseCronJob(jobId: string): Promise<CronJob> {
|
||||
export function resumeCronJob(jobId: string): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`,
|
||||
method: 'POST'
|
||||
})
|
||||
@@ -1615,6 +1632,7 @@ export function resumeCronJob(jobId: string): Promise<CronJob> {
|
||||
export function triggerCronJob(jobId: string): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`,
|
||||
method: 'POST',
|
||||
timeoutMs: CRON_TRIGGER_REQUEST_TIMEOUT_MS
|
||||
@@ -1624,6 +1642,7 @@ export function triggerCronJob(jobId: string): Promise<CronJob> {
|
||||
export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/jobs/${encodeURIComponent(jobId)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
@@ -1643,6 +1662,7 @@ export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> {
|
||||
export function getAutomationBlueprints(): Promise<{ blueprints: AutomationBlueprint[] }> {
|
||||
return window.hermesDesktop.api<{ blueprints: AutomationBlueprint[] }>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: '/api/cron/blueprints',
|
||||
timeoutMs: STARTUP_REQUEST_TIMEOUT_MS
|
||||
})
|
||||
@@ -1654,6 +1674,7 @@ export function instantiateAutomationBlueprint(
|
||||
): Promise<CronJob> {
|
||||
return window.hermesDesktop.api<CronJob>({
|
||||
...profileScoped(),
|
||||
...connectionScoped(),
|
||||
path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`,
|
||||
method: 'POST',
|
||||
body
|
||||
|
||||
Reference in New Issue
Block a user