Merge pull request #55331 from xxxigm/fix/desktop-projects-stale-backend

fix(desktop): handle stale backend when creating projects (#54999)
This commit is contained in:
brooklyn! 2026-06-30 11:47:04 -05:00 committed by GitHub
commit f99ba56df4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 150 additions and 13 deletions

View file

@ -1358,6 +1358,8 @@ export const en: Translations = {
copyPath: 'Copy path',
removeFromSidebar: 'Hide from sidebar',
createFailed: 'Could not create project',
staleBackend:
'Update the Hermes backend to create projects — your backend is older than this desktop app (Settings → Updates → Backend).',
deleteConfirm: 'This removes the saved project from Hermes. Files, git repos, and worktrees stay untouched.',
startWork: 'New worktree',
newWorktreeTitle: 'New worktree',

View file

@ -1466,6 +1466,8 @@ export const ja = defineLocale({
copyPath: 'パスをコピー',
removeFromSidebar: 'サイドバーから削除',
createFailed: 'プロジェクトを作成できませんでした',
staleBackend:
'プロジェクトを作成するには Hermes バックエンドを更新してください。バックエンドがこのデスクトップアプリより古いです(設定 → 更新 → バックエンド)。',
deleteConfirm:
'Hermes から保存済みプロジェクトを削除します。ファイル・git リポジトリ・ワークツリーはそのまま残ります。',
startWork: '新しいワークツリー',

View file

@ -1104,6 +1104,7 @@ export interface Translations {
copyPath: string
removeFromSidebar: string
createFailed: string
staleBackend: string
deleteConfirm: string
startWork: string
newWorktreeTitle: string

View file

@ -1419,6 +1419,8 @@ export const zhHant = defineLocale({
copyPath: '複製路徑',
removeFromSidebar: '從側邊欄移除',
createFailed: '無法建立專案',
staleBackend:
'請更新 Hermes 後端以建立專案——目前後端比桌面應用舊(設定 → 更新 → 後端)。',
deleteConfirm: '這會從 Hermes 中移除已儲存的專案。檔案、git 儲存庫和工作樹維持不變。',
startWork: '新增工作樹',
newWorktreeTitle: '新增工作樹',

View file

@ -1538,6 +1538,8 @@ export const zh: Translations = {
copyPath: '复制路径',
removeFromSidebar: '从侧边栏移除',
createFailed: '无法创建项目',
staleBackend:
'请更新 Hermes 后端以创建项目——当前后端比桌面应用旧(设置 → 更新 → 后端)。',
deleteConfirm: '这会从 Hermes 中移除已保存的项目。文件、git 仓库和工作树保持不变。',
startWork: '新建工作树',
newWorktreeTitle: '新建工作树',

View file

@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { isMissingRpcMethod } from './gateway-rpc'
describe('isMissingRpcMethod', () => {
it('detects JSON-RPC method-not-found errors', () => {
expect(isMissingRpcMethod(new Error('unknown method: projects.create'))).toBe(true)
expect(isMissingRpcMethod(new Error('Method not found'))).toBe(true)
expect(isMissingRpcMethod(new Error('RPC failed: -32601'))).toBe(true)
})
it('ignores unrelated failures', () => {
expect(isMissingRpcMethod(new Error('Hermes gateway is not connected'))).toBe(false)
expect(isMissingRpcMethod(new Error('no such project'))).toBe(false)
})
})

View file

@ -0,0 +1,6 @@
/** True when a JSON-RPC call failed because the backend predates the method. */
export function isMissingRpcMethod(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /method not found|-32601|unknown method|no such method/i.test(message)
}

View file

@ -5,15 +5,26 @@ import { $sidebarAgentsGrouped } from '@/store/layout'
import {
$activeProjectId,
$projectScope,
$projectsRpcAvailable,
$worktreeRefreshToken,
ALL_PROJECTS,
createProject,
enterProject,
exitProjectScope,
openProjectCreate,
pickProjectFolder,
refreshProjects,
refreshWorktrees
} from './projects'
vi.mock('@/i18n', () => ({
translateNow: (key: string) => key
}))
vi.mock('@/store/notifications', () => ({
notify: vi.fn()
}))
vi.mock('@/lib/desktop-fs', () => ({
desktopDefaultCwd: vi.fn(),
isDesktopFsRemoteMode: vi.fn(),
@ -33,6 +44,8 @@ const selectDesktopPaths = vi.mocked(fs.selectDesktopPaths)
const gw = await import('@/store/gateway')
const activeGateway = vi.mocked(gw.activeGateway)
const notifications = await import('@/store/notifications')
const notify = vi.mocked(notifications.notify)
describe('project scope', () => {
beforeEach(() => {
@ -115,6 +128,7 @@ describe('createProject', () => {
vi.clearAllMocks()
$sidebarAgentsGrouped.set(false)
$activeProjectId.set(null)
$projectsRpcAvailable.set(null)
})
it('creates the project and flips into the grouped view so a blank slate shows it', async () => {
@ -139,4 +153,44 @@ describe('createProject', () => {
expect($sidebarAgentsGrouped.get()).toBe(true)
expect($activeProjectId.get()).toBe('p_new')
})
it('marks the backend stale and surfaces a friendly error when projects.create is missing', async () => {
activeGateway.mockReturnValue({
connectionState: 'open',
request: vi.fn().mockRejectedValue(new Error('unknown method: projects.create'))
} as never)
await expect(createProject({ folders: ['/srv/demo'], name: 'Demo' })).rejects.toThrow(
'sidebar.projects.staleBackend'
)
expect($projectsRpcAvailable.get()).toBe(false)
})
})
describe('projects RPC capability', () => {
beforeEach(() => {
vi.clearAllMocks()
$projectsRpcAvailable.set(null)
})
it('marks the backend stale when projects.list is missing', async () => {
activeGateway.mockReturnValue({
connectionState: 'open',
request: vi.fn().mockRejectedValue(new Error('unknown method: projects.list'))
} as never)
await refreshProjects()
expect($projectsRpcAvailable.get()).toBe(false)
})
it('blocks opening the create dialog once the backend is known stale', () => {
$projectsRpcAvailable.set(false)
openProjectCreate()
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'warning', message: 'sidebar.projects.staleBackend' })
)
})
})

View file

@ -4,8 +4,11 @@ import { liveSessionProjectId, type SidebarProjectTree } from '@/app/chat/sideba
import type { HermesGitBranch } from '@/global'
import { desktopDefaultCwd, selectDesktopPaths, writeDesktopFileText } from '@/lib/desktop-fs'
import { desktopGit } from '@/lib/desktop-git'
import { isMissingRpcMethod } from '@/lib/gateway-rpc'
import { persistentAtom } from '@/lib/persisted'
import { translateNow } from '@/i18n'
import { activeGateway, ensureActiveGatewayOpen } from '@/store/gateway'
import { notify } from '@/store/notifications'
import { setSidebarAgentsGrouped } from '@/store/layout'
import { requestFreshSession } from '@/store/profile'
import { $selectedStoredSessionId, $sessions, workspaceCwdForNewSession } from '@/store/session'
@ -26,6 +29,24 @@ export const $activeProjectId = atom<null | string>(null)
export const $projectTree = atom<SidebarProjectTree[]>([])
export const $projectTreeLoading = atom(false)
// False when the connected backend predates the projects.* JSON-RPC surface
// (same semver label, older install). Null until the first probe.
export const $projectsRpcAvailable = atom<boolean | null>(null)
function markProjectsRpcSuccess(): void {
$projectsRpcAvailable.set(true)
}
function markProjectsRpcFailure(err: unknown): void {
if (isMissingRpcMethod(err)) {
$projectsRpcAvailable.set(false)
}
}
function projectsStaleBackendError(): Error {
return new Error(translateNow('sidebar.projects.staleBackend'))
}
// Client-side cache eviction (Apollo-style optimistic layer): ids the user just
// deleted/archived. The backend tree is a snapshot that still lists them until
// its next refresh, so the render-time overlay strips these so the tree matches
@ -216,7 +237,9 @@ function applyPayload(payload: ProjectsPayload): void {
export async function refreshProjects(): Promise<void> {
try {
applyPayload(await gatewayRequest<ProjectsPayload>('projects.list'))
} catch {
markProjectsRpcSuccess()
} catch (err) {
markProjectsRpcFailure(err)
// Backend may not be ready; keep the last known list.
}
}
@ -254,7 +277,10 @@ export async function refreshProjectTree(): Promise<void> {
$removedSessionIds.set(pending)
}
}
} catch {
markProjectsRpcSuccess()
} catch (err) {
markProjectsRpcFailure(err)
// Backend may not be ready; keep the last known tree.
} finally {
$projectTreeLoading.set(false)
@ -410,17 +436,34 @@ function projectInfoToTreeNode(project: ProjectInfo): SidebarProjectTree {
}
export async function createProject(input: CreateProjectInput): Promise<ProjectInfo | null> {
const res = await gatewayRequest<{ project: ProjectInfo | null }>('projects.create', {
name: input.name,
folders: input.folders ?? [],
primary_path: input.primaryPath,
slug: input.slug,
description: input.description,
icon: input.icon,
color: input.color,
board_slug: input.boardSlug,
use: input.use ?? false
})
if ($projectsRpcAvailable.get() === false) {
throw projectsStaleBackendError()
}
let res: { project: ProjectInfo | null }
try {
res = await gatewayRequest<{ project: ProjectInfo | null }>('projects.create', {
name: input.name,
folders: input.folders ?? [],
primary_path: input.primaryPath,
slug: input.slug,
description: input.description,
icon: input.icon,
color: input.color,
board_slug: input.boardSlug,
use: input.use ?? false
})
} catch (err) {
if (isMissingRpcMethod(err)) {
$projectsRpcAvailable.set(false)
throw projectsStaleBackendError()
}
throw err
}
markProjectsRpcSuccess()
// Not optimistic (the create awaits the RPC first, so there's nothing to roll
// back): apply the server's row into the cached list + tree at once, so it
@ -593,6 +636,15 @@ export interface ProjectDialogState {
export const $projectDialog = atom<null | ProjectDialogState>(null)
export function openProjectCreate(): void {
if ($projectsRpcAvailable.get() === false) {
notify({
kind: 'warning',
message: translateNow('sidebar.projects.staleBackend')
})
return
}
$projectDialog.set({ mode: 'create' })
}