From 3d2b1c5e4742179e23a7385717d20a533ba8b4c6 Mon Sep 17 00:00:00 2001 From: ekko Date: Fri, 17 Apr 2026 16:48:24 +0800 Subject: [PATCH] fix: job edit schedule format error and refactor services directory - Fix #25: job update sends schedule as plain string but upstream expects { kind, expr, display } object, causing "'str' object has no attribute 'get'" - Move hermes-cli.ts, hermes.ts, hermes-profile.ts into services/hermes/ for multi-agent namespacing consistency - Fix ts-node Set spread compatibility in filesystem.ts Co-Authored-By: Claude Opus 4.6 --- packages/client/src/api/hermes/jobs.ts | 2 +- .../src/components/hermes/jobs/JobFormModal.vue | 13 +++++++++++++ packages/server/src/index.ts | 4 ++-- packages/server/src/routes/hermes/config.ts | 4 ++-- packages/server/src/routes/hermes/filesystem.ts | 8 ++++---- packages/server/src/routes/hermes/logs.ts | 2 +- packages/server/src/routes/hermes/profiles.ts | 2 +- packages/server/src/routes/hermes/sessions.ts | 2 +- packages/server/src/routes/hermes/weixin.ts | 4 ++-- packages/server/src/routes/webhook.ts | 2 +- .../server/src/services/{ => hermes}/hermes-cli.ts | 0 .../src/services/{ => hermes}/hermes-profile.ts | 0 packages/server/src/services/{ => hermes}/hermes.ts | 2 +- tests/server/profiles-routes.test.ts | 4 ++-- tests/server/proxy-handler.test.ts | 10 +++++++++- 15 files changed, 40 insertions(+), 19 deletions(-) rename packages/server/src/services/{ => hermes}/hermes-cli.ts (100%) rename packages/server/src/services/{ => hermes}/hermes-profile.ts (100%) rename packages/server/src/services/{ => hermes}/hermes.ts (98%) diff --git a/packages/client/src/api/hermes/jobs.ts b/packages/client/src/api/hermes/jobs.ts index 6aafc07..5873bb4 100644 --- a/packages/client/src/api/hermes/jobs.ts +++ b/packages/client/src/api/hermes/jobs.ts @@ -45,7 +45,7 @@ export interface CreateJobRequest { export interface UpdateJobRequest { name?: string - schedule?: string + schedule?: string | { kind: string; expr: string; display: string } prompt?: string deliver?: string skills?: string[] diff --git a/packages/client/src/components/hermes/jobs/JobFormModal.vue b/packages/client/src/components/hermes/jobs/JobFormModal.vue index 52040ed..0f57b5e 100644 --- a/packages/client/src/components/hermes/jobs/JobFormModal.vue +++ b/packages/client/src/components/hermes/jobs/JobFormModal.vue @@ -48,6 +48,8 @@ const targetOptions = computed(() => [ { label: t('jobs.local'), value: 'local' }, ]) +const originalSchedule = ref<{ kind: string; expr: string; display: string } | null>(null) + onMounted(async () => { if (props.jobId) { try { @@ -60,6 +62,9 @@ onMounted(async () => { deliver: job.deliver || 'origin', repeat_times: typeof job.repeat === 'number' ? job.repeat : (typeof job.repeat === 'object' ? job.repeat.times : null), } + if (typeof job.schedule === 'object' && job.schedule) { + originalSchedule.value = job.schedule + } } catch (e: any) { message.error(t('jobs.loadFailed') + ': ' + e.message) } @@ -86,6 +91,14 @@ async function handleSave() { repeat: formData.value.repeat_times ?? undefined, } + if (isEdit.value && originalSchedule.value) { + (payload as any).schedule = { + kind: originalSchedule.value.kind, + expr: formData.value.schedule, + display: formData.value.schedule, + } + } + if (isEdit.value) { await jobsStore.updateJob(props.jobId!, payload) message.success(t('jobs.jobUpdated')) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2925cb6..1978569 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -10,7 +10,7 @@ import { config } from './config' import { hermesRoutes, setupTerminalWebSocket, proxyMiddleware } from './routes/hermes' import { uploadRoutes } from './routes/upload' import { webhookRoutes } from './routes/webhook' -import * as hermesCli from './services/hermes-cli' +import * as hermesCli from './services/hermes/hermes-cli' import { getToken, authMiddleware } from './services/auth' function getLocalVersion(): string { @@ -232,7 +232,7 @@ function bindShutdown() { async function ensureApiServerConfig() { const { readFileSync, writeFileSync, existsSync, copyFileSync } = await import('fs') const yaml = (await import('js-yaml')).default - const { getActiveConfigPath } = await import('./services/hermes-profile') + const { getActiveConfigPath } = await import('./services/hermes/hermes-profile') const configPath = getActiveConfigPath() const defaults: Record = { diff --git a/packages/server/src/routes/hermes/config.ts b/packages/server/src/routes/hermes/config.ts index f0d8ca0..314c652 100644 --- a/packages/server/src/routes/hermes/config.ts +++ b/packages/server/src/routes/hermes/config.ts @@ -3,8 +3,8 @@ import { readFile, writeFile, copyFile } from 'fs/promises' import { chmod } from 'fs/promises' import { join } from 'path' import YAML from 'js-yaml' -import { restartGateway } from '../../services/hermes-cli' -import { getActiveConfigPath, getActiveEnvPath, getActiveProfileDir } from '../../services/hermes-profile' +import { restartGateway } from '../../services/hermes/hermes-cli' +import { getActiveConfigPath, getActiveEnvPath, getActiveProfileDir } from '../../services/hermes/hermes-profile' // Platform sections that require gateway restart after config change const PLATFORM_SECTIONS = new Set([ diff --git a/packages/server/src/routes/hermes/filesystem.ts b/packages/server/src/routes/hermes/filesystem.ts index eb623d6..7d427ab 100644 --- a/packages/server/src/routes/hermes/filesystem.ts +++ b/packages/server/src/routes/hermes/filesystem.ts @@ -2,8 +2,8 @@ import Router from '@koa/router' import { readdir, readFile, stat, writeFile, mkdir, copyFile } from 'fs/promises' import { join, resolve } from 'path' import YAML from 'js-yaml' -import { getActiveProfileDir, getActiveConfigPath, getActiveAuthPath, getActiveEnvPath } from '../../services/hermes-profile' -import * as hermesCli from '../../services/hermes-cli' +import { getActiveProfileDir, getActiveConfigPath, getActiveAuthPath, getActiveEnvPath } from '../../services/hermes/hermes-profile' +import * as hermesCli from '../../services/hermes/hermes-cli' // --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) --- // Maps provider key → { api_key_envs: all env var aliases for API key, base_url_env: env var for base URL } @@ -513,7 +513,7 @@ fsRoutes.get('/api/hermes/available-models', async (ctx) => { for (const result of results) { if (result.status === 'fulfilled' && result.value.models.length > 0) { const { key, label, base_url, models } = result.value - groups.push({ provider: key, label, base_url, models: [...new Set(models)] }) + groups.push({ provider: key, label, base_url, models: Array.from(new Set(models)) }) } else if (result.status === 'rejected') { console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`) } @@ -524,7 +524,7 @@ fsRoutes.get('/api/hermes/available-models', async (ctx) => { const dedupedGroups: typeof groups = [] const seenProviders = new Map() for (const g of groups) { - g.models = [...new Set(g.models)] + g.models = Array.from(new Set(g.models)) const existingIdx = seenProviders.get(g.provider) if (existingIdx !== undefined) { // Merge models into existing group diff --git a/packages/server/src/routes/hermes/logs.ts b/packages/server/src/routes/hermes/logs.ts index ca1d96c..fcffc4a 100644 --- a/packages/server/src/routes/hermes/logs.ts +++ b/packages/server/src/routes/hermes/logs.ts @@ -1,5 +1,5 @@ import Router from '@koa/router' -import * as hermesCli from '../../services/hermes-cli' +import * as hermesCli from '../../services/hermes/hermes-cli' export const logRoutes = new Router() diff --git a/packages/server/src/routes/hermes/profiles.ts b/packages/server/src/routes/hermes/profiles.ts index 3ccb048..b774635 100644 --- a/packages/server/src/routes/hermes/profiles.ts +++ b/packages/server/src/routes/hermes/profiles.ts @@ -4,7 +4,7 @@ import { mkdir, writeFile } from 'fs/promises' import { basename, join } from 'path' import { tmpdir, homedir } from 'os' import YAML from 'js-yaml' -import * as hermesCli from '../../services/hermes-cli' +import * as hermesCli from '../../services/hermes/hermes-cli' const apiServerDefaults = { enabled: true, diff --git a/packages/server/src/routes/hermes/sessions.ts b/packages/server/src/routes/hermes/sessions.ts index cc5eff5..58d630d 100644 --- a/packages/server/src/routes/hermes/sessions.ts +++ b/packages/server/src/routes/hermes/sessions.ts @@ -1,5 +1,5 @@ import Router from '@koa/router' -import * as hermesCli from '../../services/hermes-cli' +import * as hermesCli from '../../services/hermes/hermes-cli' export const sessionRoutes = new Router() diff --git a/packages/server/src/routes/hermes/weixin.ts b/packages/server/src/routes/hermes/weixin.ts index c8a9dc5..712e661 100644 --- a/packages/server/src/routes/hermes/weixin.ts +++ b/packages/server/src/routes/hermes/weixin.ts @@ -3,8 +3,8 @@ import axios from 'axios' import { readFile, writeFile } from 'fs/promises' import { chmod } from 'fs/promises' import { resolve } from 'path' -import { restartGateway } from '../../services/hermes-cli' -import { getActiveEnvPath } from '../../services/hermes-profile' +import { restartGateway } from '../../services/hermes/hermes-cli' +import { getActiveEnvPath } from '../../services/hermes/hermes-profile' const envPath = () => getActiveEnvPath() const ILINK_BASE = 'https://ilinkai.weixin.qq.com' diff --git a/packages/server/src/routes/webhook.ts b/packages/server/src/routes/webhook.ts index 19a5fe3..7e738be 100644 --- a/packages/server/src/routes/webhook.ts +++ b/packages/server/src/routes/webhook.ts @@ -1,5 +1,5 @@ import Router from '@koa/router' -import { emitWebhook } from '../services/hermes' +import { emitWebhook } from '../services/hermes/hermes' export const webhookRoutes = new Router() diff --git a/packages/server/src/services/hermes-cli.ts b/packages/server/src/services/hermes/hermes-cli.ts similarity index 100% rename from packages/server/src/services/hermes-cli.ts rename to packages/server/src/services/hermes/hermes-cli.ts diff --git a/packages/server/src/services/hermes-profile.ts b/packages/server/src/services/hermes/hermes-profile.ts similarity index 100% rename from packages/server/src/services/hermes-profile.ts rename to packages/server/src/services/hermes/hermes-profile.ts diff --git a/packages/server/src/services/hermes.ts b/packages/server/src/services/hermes/hermes.ts similarity index 98% rename from packages/server/src/services/hermes.ts rename to packages/server/src/services/hermes/hermes.ts index 5ccdfa9..c4bb896 100644 --- a/packages/server/src/services/hermes.ts +++ b/packages/server/src/services/hermes/hermes.ts @@ -1,4 +1,4 @@ -import { config } from '../config' +import { config } from '../../config' const UPSTREAM = config.upstream.replace(/\/$/, '') diff --git a/tests/server/profiles-routes.test.ts b/tests/server/profiles-routes.test.ts index dc64660..6f53fa4 100644 --- a/tests/server/profiles-routes.test.ts +++ b/tests/server/profiles-routes.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // Mock hermes-cli -vi.mock('../../packages/server/src/services/hermes-cli', () => ({ +vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({ listProfiles: vi.fn(), getProfile: vi.fn(), createProfile: vi.fn(), @@ -16,7 +16,7 @@ vi.mock('../../packages/server/src/services/hermes-cli', () => ({ importProfile: vi.fn(), })) -import * as hermesCli from '../../packages/server/src/services/hermes-cli' +import * as hermesCli from '../../packages/server/src/services/hermes/hermes-cli' describe('Profile Routes', () => { beforeEach(() => { diff --git a/tests/server/proxy-handler.test.ts b/tests/server/proxy-handler.test.ts index f055cad..e720589 100644 --- a/tests/server/proxy-handler.test.ts +++ b/tests/server/proxy-handler.test.ts @@ -120,7 +120,15 @@ describe('Proxy Handler', () => { }) it('returns 502 on connection failure', async () => { - mockFetch.mockRejectedValue(new Error('ECONNREFUSED')) + // waitForGatewayReady loops calling fetch(healthUrl) until res.ok or timeout. + // Return ok:true for health checks so the loop exits immediately (gateway + // "ready"), then the retry fetch also fails with ECONNREFUSED → 502. + mockFetch.mockImplementation((url: string) => { + if (typeof url === 'string' && url.includes('/health')) { + return Promise.resolve({ ok: true }) + } + return Promise.reject(new Error('ECONNREFUSED')) + }) const ctx = createMockCtx() await proxy(ctx)