mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 04:08:28 +08:00
Compare commits
16 Commits
fix/photon
...
feat/deskt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30ad77daeb | ||
|
|
ae8c1fe209 | ||
|
|
5b1dd05994 | ||
|
|
c5715827ae | ||
|
|
bb430904dd | ||
|
|
a91a2daaf5 | ||
|
|
a5e5f28b46 | ||
|
|
7c3b703123 | ||
|
|
bd3a7bf81b | ||
|
|
57c6d0cc95 | ||
|
|
c40c4136fc | ||
|
|
c54f30b1fd | ||
|
|
8473d7a575 | ||
|
|
148fa87677 | ||
|
|
ecb4fc3762 | ||
|
|
518d2768c1 |
@@ -3899,10 +3899,12 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
|
||||
const scoped = key ? config.profiles?.[key] || null : null
|
||||
const block = key ? scoped || {} : config.remote || {}
|
||||
|
||||
const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
||||
|
||||
const remoteToken = decryptDesktopSecret(block.token)
|
||||
const authMode = normAuthMode(block.authMode)
|
||||
const remoteUrl = String(block.url || '')
|
||||
const mode = (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
|
||||
const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '')
|
||||
const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
|
||||
|
||||
let remoteOauthConnected = false
|
||||
if (authMode === 'oauth' && remoteUrl) {
|
||||
@@ -3928,7 +3930,7 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
|
||||
remoteTokenSet: Boolean(remoteToken),
|
||||
// The env override only forces the global/primary connection; a per-profile
|
||||
// scope is never overridden by HERMES_DESKTOP_REMOTE_URL.
|
||||
envOverride: key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
||||
envOverride
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { $previewServerRestartStatus } from '@/store/preview'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$busy,
|
||||
$connection,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
@@ -40,7 +41,14 @@ import {
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
|
||||
import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates'
|
||||
import {
|
||||
$backendUpdateApply,
|
||||
$backendUpdateStatus,
|
||||
$desktopVersion,
|
||||
$updateApply,
|
||||
$updateStatus,
|
||||
openUpdateOverlayFor
|
||||
} from '@/store/updates'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
import { CRON_ROUTE } from '../../routes'
|
||||
@@ -97,7 +105,10 @@ export function useStatusbarItems({
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
const updateStatus = useStore($updateStatus)
|
||||
const updateApply = useStore($updateApply)
|
||||
const backendUpdateStatus = useStore($backendUpdateStatus)
|
||||
const backendUpdateApply = useStore($backendUpdateApply)
|
||||
const desktopVersion = useStore($desktopVersion)
|
||||
const connection = useStore($connection)
|
||||
|
||||
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
|
||||
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
|
||||
@@ -194,18 +205,19 @@ export function useStatusbarItems({
|
||||
? 'text-amber-600 hover:text-amber-600'
|
||||
: 'text-destructive hover:text-destructive'
|
||||
|
||||
const versionItem = useMemo<StatusbarItem>(() => {
|
||||
const clientVersionItem = useMemo<StatusbarItem>(() => {
|
||||
const appVersion = desktopVersion?.appVersion
|
||||
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
|
||||
const behind = updateStatus?.behind ?? 0
|
||||
const applying = updateApply.applying || updateApply.stage === 'restart'
|
||||
const base = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
|
||||
const remote = connection?.mode === 'remote'
|
||||
|
||||
const version = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
|
||||
const base = remote ? copy.clientLabel(appVersion ?? sha ?? copy.unknown) : version
|
||||
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
|
||||
|
||||
const label = applying
|
||||
? updateApply.stage === 'restart'
|
||||
? `${base} · ${copy.restart}`
|
||||
: `${base} · ${copy.update}`
|
||||
? `${base} · ${updateApply.stage === 'restart' ? copy.restart : copy.update}`
|
||||
: `${base}${behindHint}`
|
||||
|
||||
const tooltip = [
|
||||
@@ -220,17 +232,18 @@ export function useStatusbarItems({
|
||||
|
||||
return {
|
||||
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
|
||||
detail: appVersion && sha && !applying ? sha : undefined,
|
||||
detail: appVersion && sha && !applying && !remote ? sha : undefined,
|
||||
hidden: !appVersion && !sha,
|
||||
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
|
||||
id: 'version',
|
||||
id: 'version-client',
|
||||
label,
|
||||
onSelect: () => setUpdateOverlayOpen(true),
|
||||
onSelect: () => openUpdateOverlayFor('client'),
|
||||
title: tooltip || undefined,
|
||||
variant: 'action'
|
||||
}
|
||||
}, [
|
||||
desktopVersion?.appVersion,
|
||||
connection?.mode,
|
||||
copy,
|
||||
updateApply.applying,
|
||||
updateApply.message,
|
||||
@@ -240,6 +253,50 @@ export function useStatusbarItems({
|
||||
updateStatus?.currentSha
|
||||
])
|
||||
|
||||
const backendVersionItem = useMemo<StatusbarItem | null>(() => {
|
||||
if (connection?.mode !== 'remote') {
|
||||
return null
|
||||
}
|
||||
|
||||
const backendVersion = statusSnapshot?.version
|
||||
const behind = backendUpdateStatus?.behind ?? 0
|
||||
const applying = backendUpdateApply.applying || backendUpdateApply.stage === 'restart'
|
||||
|
||||
const base = copy.backendLabel(backendVersion ?? copy.unknown)
|
||||
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
|
||||
|
||||
const label = applying
|
||||
? `${base} · ${backendUpdateApply.stage === 'restart' ? copy.restart : copy.update}`
|
||||
: `${base}${behindHint}`
|
||||
|
||||
const tooltip = [
|
||||
applying ? backendUpdateApply.message || copy.updateInProgress : null,
|
||||
!applying && behind > 0 && copy.commitsBehind(behind, 'main'),
|
||||
backendVersion && copy.backendVersion(backendVersion)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return {
|
||||
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
|
||||
hidden: !backendVersion,
|
||||
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
|
||||
id: 'version-backend',
|
||||
label,
|
||||
onSelect: () => openUpdateOverlayFor('backend'),
|
||||
title: tooltip || undefined,
|
||||
variant: 'action'
|
||||
}
|
||||
}, [
|
||||
connection?.mode,
|
||||
statusSnapshot?.version,
|
||||
backendUpdateStatus?.behind,
|
||||
backendUpdateApply.applying,
|
||||
backendUpdateApply.message,
|
||||
backendUpdateApply.stage,
|
||||
copy
|
||||
])
|
||||
|
||||
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -385,7 +442,8 @@ export function useStatusbarItems({
|
||||
variant: 'action' as const
|
||||
})
|
||||
},
|
||||
versionItem
|
||||
clientVersionItem,
|
||||
...(backendVersionItem ? [backendVersionItem] : [])
|
||||
],
|
||||
[
|
||||
busy,
|
||||
@@ -401,7 +459,8 @@ export function useStatusbarItems({
|
||||
showYoloToggle,
|
||||
toggleYolo,
|
||||
turnStartedAt,
|
||||
versionItem,
|
||||
clientVersionItem,
|
||||
backendVersionItem,
|
||||
yoloActive
|
||||
]
|
||||
)
|
||||
|
||||
@@ -12,12 +12,19 @@ import { useI18n } from '@/i18n'
|
||||
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
|
||||
import { AlertCircle, Check, CheckCircle2, Copy, Terminal } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { resolveUpdateCopy, type UpdateTarget } from '@/lib/update-copy'
|
||||
import {
|
||||
$backendUpdateApply,
|
||||
$backendUpdateChecking,
|
||||
$backendUpdateStatus,
|
||||
$updateApply,
|
||||
$updateChecking,
|
||||
$updateOverlayOpen,
|
||||
$updateOverlayTarget,
|
||||
$updateStatus,
|
||||
applyBackendUpdate,
|
||||
applyUpdates,
|
||||
checkBackendUpdates,
|
||||
checkUpdates,
|
||||
resetUpdateApplyState,
|
||||
setUpdateOverlayOpen,
|
||||
@@ -30,15 +37,27 @@ function totalItems(groups: readonly CommitGroup[]) {
|
||||
|
||||
export function UpdatesOverlay() {
|
||||
const open = useStore($updateOverlayOpen)
|
||||
const status = useStore($updateStatus)
|
||||
const checking = useStore($updateChecking)
|
||||
const apply = useStore($updateApply)
|
||||
const target = useStore($updateOverlayTarget)
|
||||
|
||||
const clientStatus = useStore($updateStatus)
|
||||
const clientChecking = useStore($updateChecking)
|
||||
const clientApply = useStore($updateApply)
|
||||
const backendStatus = useStore($backendUpdateStatus)
|
||||
const backendChecking = useStore($backendUpdateChecking)
|
||||
const backendApply = useStore($backendUpdateApply)
|
||||
|
||||
const isBackend = target === 'backend'
|
||||
const status = isBackend ? backendStatus : clientStatus
|
||||
const checking = isBackend ? backendChecking : clientChecking
|
||||
const apply = isBackend ? backendApply : clientApply
|
||||
const check = isBackend ? checkBackendUpdates : checkUpdates
|
||||
const install = isBackend ? applyBackendUpdate : applyUpdates
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !status && !checking) {
|
||||
void checkUpdates()
|
||||
void check()
|
||||
}
|
||||
}, [checking, open, status])
|
||||
}, [check, checking, open, status])
|
||||
|
||||
const behind = status?.behind ?? 0
|
||||
|
||||
@@ -64,7 +83,7 @@ export function UpdatesOverlay() {
|
||||
}
|
||||
|
||||
const handleInstall = () => {
|
||||
void applyUpdates()
|
||||
void install()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -73,7 +92,7 @@ export function UpdatesOverlay() {
|
||||
className="max-w-sm overflow-hidden border-border/70 p-0 gap-0"
|
||||
showCloseButton={phase !== 'applying'}
|
||||
>
|
||||
{phase === 'applying' && <ApplyingView apply={apply} />}
|
||||
{phase === 'applying' && <ApplyingView apply={apply} isBackend={isBackend} />}
|
||||
|
||||
{phase === 'manual' && (
|
||||
<ManualView command={apply.command ?? 'hermes update'} onDone={() => handleClose(false)} />
|
||||
@@ -90,8 +109,9 @@ export function UpdatesOverlay() {
|
||||
commits={status?.commits ?? []}
|
||||
onInstall={handleInstall}
|
||||
onLater={() => handleClose(false)}
|
||||
onRetryCheck={() => void checkUpdates()}
|
||||
onRetryCheck={() => void check()}
|
||||
status={status}
|
||||
target={target}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
@@ -106,7 +126,8 @@ function IdleView({
|
||||
onInstall,
|
||||
onLater,
|
||||
onRetryCheck,
|
||||
status
|
||||
status,
|
||||
target
|
||||
}: {
|
||||
behind: number
|
||||
checking: boolean
|
||||
@@ -115,6 +136,7 @@ function IdleView({
|
||||
onLater: () => void
|
||||
onRetryCheck: () => void
|
||||
status: DesktopUpdateStatus | null
|
||||
target: UpdateTarget
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const u = t.updates
|
||||
@@ -167,7 +189,7 @@ function IdleView({
|
||||
if (behind === 0) {
|
||||
return (
|
||||
<CenteredStatus
|
||||
body={u.latestBody}
|
||||
body={target === 'backend' ? u.latestBodyBackend : u.latestBody}
|
||||
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
|
||||
title={u.allSetTitle}
|
||||
/>
|
||||
@@ -178,14 +200,20 @@ function IdleView({
|
||||
const shownItems = totalItems(groups)
|
||||
const remaining = Math.max(0, behind - shownItems)
|
||||
|
||||
// Name what's being updated. In remote mode the overlay acts on the connected
|
||||
// backend, not the local client — say so. When there are no commit rows to
|
||||
// show (e.g. pip/non-git backend), degrade to honest "no release notes" copy
|
||||
// instead of generic filler.
|
||||
const { title, body } = resolveUpdateCopy({ target, shownItems, copy: u })
|
||||
|
||||
return (
|
||||
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<BrandMark className="size-16" />
|
||||
|
||||
<DialogTitle className="text-center text-xl">{u.availableTitle}</DialogTitle>
|
||||
<DialogTitle className="text-center text-xl">{title}</DialogTitle>
|
||||
<DialogDescription className="text-center text-sm">
|
||||
{u.availableBody}
|
||||
{body}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
@@ -281,10 +309,11 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
|
||||
)
|
||||
}
|
||||
|
||||
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
|
||||
function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend: boolean }) {
|
||||
const { t } = useI18n()
|
||||
const u = t.updates
|
||||
const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle
|
||||
const body = isBackend ? u.applyingBodyBackend : u.applyingBody
|
||||
|
||||
const percent =
|
||||
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
|
||||
@@ -298,7 +327,7 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
|
||||
|
||||
<DialogTitle className="text-center text-xl">{label}</DialogTitle>
|
||||
<DialogDescription className="text-center text-sm">
|
||||
{u.applyingBody}
|
||||
{body}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
BackendUpdateCheckResponse,
|
||||
ConfigSchemaResponse,
|
||||
CronJob,
|
||||
CronJobCreatePayload,
|
||||
@@ -53,6 +54,7 @@ export type {
|
||||
AnalyticsSkillEntry,
|
||||
AnalyticsSkillsSummary,
|
||||
AnalyticsTotals,
|
||||
BackendUpdateCheckResponse,
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
@@ -686,6 +688,15 @@ export function updateHermes(): Promise<ActionResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
/** Query the connected backend's own update state. In remote mode this is the
|
||||
* authoritative source for the backend's behind-count + "what's changed",
|
||||
* distinct from the Electron client clone's git state. */
|
||||
export function checkHermesUpdate(force = false): Promise<BackendUpdateCheckResponse> {
|
||||
return window.hermesDesktop.api<BackendUpdateCheckResponse>({
|
||||
path: `/api/hermes/update/check${force ? '?force=true' : ''}`
|
||||
})
|
||||
}
|
||||
|
||||
export function getActionStatus(name: string, lines = 200): Promise<ActionStatusResponse> {
|
||||
return window.hermesDesktop.api<ActionStatusResponse>({
|
||||
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`
|
||||
|
||||
@@ -1237,9 +1237,13 @@ export const en: Translations = {
|
||||
unsupportedMessage: 'This version of Hermes can’t update itself from inside the app.',
|
||||
connectionRetry: 'Check your connection and try again.',
|
||||
latestBody: 'You’re running the latest version.',
|
||||
latestBodyBackend: 'The backend is running the latest version.',
|
||||
allSetTitle: 'You’re all set',
|
||||
availableTitle: 'New update available',
|
||||
availableBody: 'A new version of Hermes is ready to install.',
|
||||
availableTitleBackend: 'Backend update available',
|
||||
availableBodyBackend: 'A newer version of the connected Hermes backend is ready to install.',
|
||||
availableBodyNoChangelog: 'A newer version is ready. Release notes aren’t available for this install type.',
|
||||
updateNow: 'Update now',
|
||||
maybeLater: 'Maybe later',
|
||||
moreChanges: count => `+ ${count} more change${count === 1 ? '' : 's'} included.`,
|
||||
@@ -1250,10 +1254,19 @@ export const en: Translations = {
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
applyingBody: 'The Hermes updater will take over in its own window and reopen Hermes when it’s done.',
|
||||
applyingBodyBackend: 'The remote backend is applying the update and will restart. Hermes reconnects automatically when it’s back.',
|
||||
applyingClose: 'Hermes will close to apply the update.',
|
||||
errorTitle: 'Update didn’t finish',
|
||||
errorBody: 'No worries — nothing was lost. You can try again now.',
|
||||
notNow: 'Not now'
|
||||
notNow: 'Not now',
|
||||
applyStatus: {
|
||||
preparing: 'Updating backend…',
|
||||
pulling: 'Backend updating…',
|
||||
restarting: 'Backend restarting to load the update…',
|
||||
notAvailable: 'Update not available for this backend.',
|
||||
failed: 'Backend update failed.',
|
||||
noReturn: 'Backend didn’t come back online. The update may not have completed — check the backend host.'
|
||||
}
|
||||
},
|
||||
|
||||
install: {
|
||||
@@ -1439,6 +1452,9 @@ export const en: Translations = {
|
||||
updateInProgress: 'Update in progress',
|
||||
commitsBehind: (count, branch) => `${count} commit${count === 1 ? '' : 's'} behind ${branch}`,
|
||||
desktopVersion: version => `Hermes Desktop v${version}`,
|
||||
backendVersion: version => `Backend v${version}`,
|
||||
clientLabel: version => `client v${version}`,
|
||||
backendLabel: version => `backend v${version}`,
|
||||
commit: sha => `commit ${sha}`,
|
||||
branch: branch => `branch ${branch}`,
|
||||
closeCommandCenter: 'Close Command Center',
|
||||
|
||||
@@ -1378,9 +1378,13 @@ export const ja = defineLocale({
|
||||
unsupportedMessage: 'このバージョンの Hermes はアプリ内から自分を更新できません。',
|
||||
connectionRetry: '接続を確認してもう一度試してください。',
|
||||
latestBody: '最新バージョンを実行しています。',
|
||||
latestBodyBackend: 'バックエンドは最新バージョンを実行しています。',
|
||||
allSetTitle: '準備完了',
|
||||
availableTitle: '新しい更新が利用可能',
|
||||
availableBody: '新しいバージョンの Hermes をインストールする準備ができています。',
|
||||
availableTitleBackend: 'バックエンドの更新があります',
|
||||
availableBodyBackend: '接続中の Hermes バックエンドの新しいバージョンをインストールできます。',
|
||||
availableBodyNoChangelog: '新しいバージョンを利用できます。このインストール形式ではリリースノートは表示できません。',
|
||||
updateNow: '今すぐ更新',
|
||||
maybeLater: '後で',
|
||||
moreChanges: count => `さらに ${count} 件の変更が含まれています。`,
|
||||
@@ -1392,10 +1396,19 @@ export const ja = defineLocale({
|
||||
copied: 'コピーしました',
|
||||
done: '完了',
|
||||
applyingBody: 'Hermes アップデーターが独自のウィンドウで引き継ぎ、完了後に Hermes を再度開きます。',
|
||||
applyingBodyBackend: 'リモートバックエンドが更新を適用して再起動します。復帰すると Hermes が自動的に再接続します。',
|
||||
applyingClose: 'Hermes は更新を適用するために閉じます。',
|
||||
errorTitle: '更新が完了しませんでした',
|
||||
errorBody: 'ご安心ください。何も失われていません。今すぐ再試行できます。',
|
||||
notNow: '今は後で'
|
||||
notNow: '今は後で',
|
||||
applyStatus: {
|
||||
preparing: 'バックエンドを更新しています…',
|
||||
pulling: 'バックエンドを更新中…',
|
||||
restarting: 'バックエンドが更新を読み込むため再起動しています…',
|
||||
notAvailable: 'このバックエンドでは更新を利用できません。',
|
||||
failed: 'バックエンドの更新に失敗しました。',
|
||||
noReturn: 'バックエンドがオンラインに戻りませんでした。更新が完了していない可能性があります。バックエンドホストを確認してください。'
|
||||
}
|
||||
},
|
||||
|
||||
install: {
|
||||
@@ -1582,6 +1595,9 @@ export const ja = defineLocale({
|
||||
updateInProgress: '更新中',
|
||||
commitsBehind: (count, branch) => `${branch} より ${count} コミット遅れています`,
|
||||
desktopVersion: version => `Hermes Desktop v${version}`,
|
||||
backendVersion: version => `バックエンド v${version}`,
|
||||
clientLabel: version => `クライアント v${version}`,
|
||||
backendLabel: version => `バックエンド v${version}`,
|
||||
commit: sha => `コミット ${sha}`,
|
||||
branch: branch => `ブランチ ${branch}`,
|
||||
closeCommandCenter: 'コマンドセンターを閉じる',
|
||||
|
||||
@@ -937,9 +937,13 @@ export interface Translations {
|
||||
unsupportedMessage: string
|
||||
connectionRetry: string
|
||||
latestBody: string
|
||||
latestBodyBackend: string
|
||||
allSetTitle: string
|
||||
availableTitle: string
|
||||
availableBody: string
|
||||
availableTitleBackend: string
|
||||
availableBodyBackend: string
|
||||
availableBodyNoChangelog: string
|
||||
updateNow: string
|
||||
maybeLater: string
|
||||
moreChanges: (count: number) => string
|
||||
@@ -950,10 +954,19 @@ export interface Translations {
|
||||
copied: string
|
||||
done: string
|
||||
applyingBody: string
|
||||
applyingBodyBackend: string
|
||||
applyingClose: string
|
||||
errorTitle: string
|
||||
errorBody: string
|
||||
notNow: string
|
||||
applyStatus: {
|
||||
preparing: string
|
||||
pulling: string
|
||||
restarting: string
|
||||
notAvailable: string
|
||||
failed: string
|
||||
noReturn: string
|
||||
}
|
||||
}
|
||||
|
||||
install: {
|
||||
@@ -1111,6 +1124,9 @@ export interface Translations {
|
||||
updateInProgress: string
|
||||
commitsBehind: (count: number, branch: string) => string
|
||||
desktopVersion: (version: string) => string
|
||||
backendVersion: (version: string) => string
|
||||
clientLabel: (version: string) => string
|
||||
backendLabel: (version: string) => string
|
||||
commit: (sha: string) => string
|
||||
branch: (branch: string) => string
|
||||
closeCommandCenter: string
|
||||
|
||||
@@ -1344,9 +1344,13 @@ export const zhHant = defineLocale({
|
||||
unsupportedMessage: '此版本的 Hermes 無法在應用程式內自行更新。',
|
||||
connectionRetry: '請檢查網路連線後重試。',
|
||||
latestBody: '您正在執行最新版本。',
|
||||
latestBodyBackend: '後端正在執行最新版本。',
|
||||
allSetTitle: '已是最新版本',
|
||||
availableTitle: '有可用更新',
|
||||
availableBody: '新版 Hermes 已可安裝。',
|
||||
availableTitleBackend: '後端有可用更新',
|
||||
availableBodyBackend: '已連接的 Hermes 後端有新版本可安裝。',
|
||||
availableBodyNoChangelog: '已有新版本可用。此安裝方式無法顯示更新日誌。',
|
||||
updateNow: '立即更新',
|
||||
maybeLater: '稍後再說',
|
||||
moreChanges: count => `另有 ${count} 項變更。`,
|
||||
@@ -1357,10 +1361,19 @@ export const zhHant = defineLocale({
|
||||
copied: '已複製',
|
||||
done: '完成',
|
||||
applyingBody: 'Hermes 更新程式會在自己的視窗中接管,並在完成後重新開啟 Hermes。',
|
||||
applyingBodyBackend: '遠端後端正在套用更新並將重新啟動。恢復後 Hermes 會自動重新連線。',
|
||||
applyingClose: 'Hermes 將關閉以套用更新。',
|
||||
errorTitle: '更新未完成',
|
||||
errorBody: '沒有資料遺失。您可以現在重試。',
|
||||
notNow: '暫不'
|
||||
notNow: '暫不',
|
||||
applyStatus: {
|
||||
preparing: '正在更新後端…',
|
||||
pulling: '後端更新中…',
|
||||
restarting: '後端正在重新啟動以載入更新…',
|
||||
notAvailable: '此後端無法更新。',
|
||||
failed: '後端更新失敗。',
|
||||
noReturn: '後端未恢復連線。更新可能未完成——請檢查後端主機。'
|
||||
}
|
||||
},
|
||||
|
||||
install: {
|
||||
@@ -1543,6 +1556,9 @@ export const zhHant = defineLocale({
|
||||
updateInProgress: '更新中',
|
||||
commitsBehind: (count, branch) => `落後 ${branch} ${count} 個提交`,
|
||||
desktopVersion: version => `Hermes Desktop v${version}`,
|
||||
backendVersion: version => `後端 v${version}`,
|
||||
clientLabel: version => `用戶端 v${version}`,
|
||||
backendLabel: version => `後端 v${version}`,
|
||||
commit: sha => `提交 ${sha}`,
|
||||
branch: branch => `分支 ${branch}`,
|
||||
closeCommandCenter: '關閉命令中心',
|
||||
|
||||
@@ -1424,9 +1424,13 @@ export const zh: Translations = {
|
||||
unsupportedMessage: '此版本的 Hermes 无法在应用内自行更新。',
|
||||
connectionRetry: '请检查网络连接后重试。',
|
||||
latestBody: '你正在运行最新版本。',
|
||||
latestBodyBackend: '后端正在运行最新版本。',
|
||||
allSetTitle: '已是最新',
|
||||
availableTitle: '有可用更新',
|
||||
availableBody: '新版 Hermes 已可安装。',
|
||||
availableTitleBackend: '后端有可用更新',
|
||||
availableBodyBackend: '已连接的 Hermes 后端有新版本可安装。',
|
||||
availableBodyNoChangelog: '已有新版本可用。此安装方式无法显示更新日志。',
|
||||
updateNow: '立即更新',
|
||||
maybeLater: '稍后再说',
|
||||
moreChanges: count => `另有 ${count} 项更改。`,
|
||||
@@ -1437,10 +1441,19 @@ export const zh: Translations = {
|
||||
copied: '已复制',
|
||||
done: '完成',
|
||||
applyingBody: 'Hermes 更新器会在自己的窗口中接管,并在完成后重新打开 Hermes。',
|
||||
applyingBodyBackend: '远程后端正在应用更新并将重启。恢复后 Hermes 会自动重新连接。',
|
||||
applyingClose: 'Hermes 将关闭以应用更新。',
|
||||
errorTitle: '更新未完成',
|
||||
errorBody: '没有数据丢失。你可以现在重试。',
|
||||
notNow: '暂不'
|
||||
notNow: '暂不',
|
||||
applyStatus: {
|
||||
preparing: '正在更新后端…',
|
||||
pulling: '后端更新中…',
|
||||
restarting: '后端正在重启以加载更新…',
|
||||
notAvailable: '此后端无法更新。',
|
||||
failed: '后端更新失败。',
|
||||
noReturn: '后端未恢复在线。更新可能未完成——请检查后端主机。'
|
||||
}
|
||||
},
|
||||
|
||||
install: {
|
||||
@@ -1620,6 +1633,9 @@ export const zh: Translations = {
|
||||
updateInProgress: '正在更新',
|
||||
commitsBehind: (count, branch) => `落后 ${branch} ${count} 个提交`,
|
||||
desktopVersion: version => `Hermes Desktop v${version}`,
|
||||
backendVersion: version => `后端 v${version}`,
|
||||
clientLabel: version => `客户端 v${version}`,
|
||||
backendLabel: version => `后端 v${version}`,
|
||||
commit: sha => `提交 ${sha}`,
|
||||
branch: branch => `分支 ${branch}`,
|
||||
closeCommandCenter: '关闭命令中心',
|
||||
|
||||
38
apps/desktop/src/lib/update-copy.test.ts
Normal file
38
apps/desktop/src/lib/update-copy.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveUpdateCopy } from './update-copy'
|
||||
|
||||
const copy = {
|
||||
availableTitle: 'New update available',
|
||||
availableBody: 'A new version of Hermes is ready to install.',
|
||||
availableTitleBackend: 'Backend update available',
|
||||
availableBodyBackend: 'A newer version of the connected Hermes backend is ready to install.',
|
||||
availableBodyNoChangelog: 'A newer version is ready. Release notes aren’t available for this install type.'
|
||||
}
|
||||
|
||||
describe('resolveUpdateCopy', () => {
|
||||
it('client target with commits: client title + client body', () => {
|
||||
const r = resolveUpdateCopy({ target: 'client', shownItems: 5, copy })
|
||||
expect(r.title).toBe('New update available')
|
||||
expect(r.body).toBe('A new version of Hermes is ready to install.')
|
||||
})
|
||||
|
||||
it('backend target with commits: names the backend in title and body', () => {
|
||||
const r = resolveUpdateCopy({ target: 'backend', shownItems: 5, copy })
|
||||
expect(r.title).toBe('Backend update available')
|
||||
expect(r.body).toContain('backend')
|
||||
})
|
||||
|
||||
it('no changelog (pip/non-git backend): degrades honestly, still names backend target in title', () => {
|
||||
const r = resolveUpdateCopy({ target: 'backend', shownItems: 0, copy })
|
||||
expect(r.title).toBe('Backend update available')
|
||||
// Body must NOT pretend there are notes — it states they're unavailable.
|
||||
expect(r.body).toBe(copy.availableBodyNoChangelog)
|
||||
})
|
||||
|
||||
it('no changelog on client: same honest degrade', () => {
|
||||
const r = resolveUpdateCopy({ target: 'client', shownItems: 0, copy })
|
||||
expect(r.title).toBe('New update available')
|
||||
expect(r.body).toBe(copy.availableBodyNoChangelog)
|
||||
})
|
||||
})
|
||||
44
apps/desktop/src/lib/update-copy.ts
Normal file
44
apps/desktop/src/lib/update-copy.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Pure copy-selection for the updates overlay's "available" state.
|
||||
*
|
||||
* Names the update target (client vs the connected backend in remote mode) and
|
||||
* degrades honestly when there's no commit changelog to show (e.g. a pip /
|
||||
* non-git backend where `git log` yields nothing) instead of generic filler.
|
||||
*
|
||||
* Extracted from updates-overlay.tsx so the wording logic is unit-testable.
|
||||
*/
|
||||
|
||||
export type UpdateTarget = 'client' | 'backend'
|
||||
|
||||
export interface UpdateCopyStrings {
|
||||
availableTitle: string
|
||||
availableBody: string
|
||||
availableTitleBackend: string
|
||||
availableBodyBackend: string
|
||||
availableBodyNoChangelog: string
|
||||
}
|
||||
|
||||
export interface ResolveUpdateCopyInput {
|
||||
target: UpdateTarget
|
||||
/** Number of commit rows actually shown in the changelog. 0 → no notes. */
|
||||
shownItems: number
|
||||
copy: UpdateCopyStrings
|
||||
}
|
||||
|
||||
export interface UpdateCopyResult {
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export function resolveUpdateCopy({ target, shownItems, copy }: ResolveUpdateCopyInput): UpdateCopyResult {
|
||||
const title = target === 'backend' ? copy.availableTitleBackend : copy.availableTitle
|
||||
|
||||
const body =
|
||||
shownItems === 0
|
||||
? copy.availableBodyNoChangelog
|
||||
: target === 'backend'
|
||||
? copy.availableBodyBackend
|
||||
: copy.availableBody
|
||||
|
||||
return { title, body }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DesktopUpdateStatus } from '@/global'
|
||||
|
||||
@@ -23,7 +23,18 @@ vi.mock('@/store/notifications', () => ({
|
||||
dismissNotification: (...args: unknown[]) => dismissSpy(...args)
|
||||
}))
|
||||
|
||||
const { maybeNotifyUpdateAvailable } = await import('./updates')
|
||||
const checkHermesUpdateSpy = vi.fn()
|
||||
const updateHermesSpy = vi.fn()
|
||||
const getActionStatusSpy = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
checkHermesUpdate: (...args: unknown[]) => checkHermesUpdateSpy(...args),
|
||||
updateHermes: (...args: unknown[]) => updateHermesSpy(...args),
|
||||
getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args)
|
||||
}))
|
||||
|
||||
const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply } = await import('./updates')
|
||||
const { setConnection } = await import('./session')
|
||||
|
||||
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
|
||||
supported: true,
|
||||
@@ -75,3 +86,114 @@ describe('maybeNotifyUpdateAvailable', () => {
|
||||
expect(notifySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkBackendUpdates', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
notifySpy.mockClear()
|
||||
checkHermesUpdateSpy.mockReset()
|
||||
$backendUpdateStatus.set(null)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const setRemote = (on: boolean) =>
|
||||
setConnection({
|
||||
baseUrl: 'http://box:9119',
|
||||
isFullscreen: false,
|
||||
mode: on ? 'remote' : 'local',
|
||||
nativeOverlayWidth: 0,
|
||||
token: 't',
|
||||
wsUrl: 'ws://box:9119',
|
||||
logs: [],
|
||||
windowButtonPosition: null
|
||||
})
|
||||
|
||||
it('maps the backend /update/check onto the backend status, including commits', async () => {
|
||||
setRemote(true)
|
||||
checkHermesUpdateSpy.mockResolvedValue({
|
||||
install_method: 'git',
|
||||
current_version: '0.16.0',
|
||||
behind: 2,
|
||||
update_available: true,
|
||||
can_apply: true,
|
||||
update_command: 'hermes update',
|
||||
message: null,
|
||||
commits: [{ sha: 'abc1234', summary: 'feat: x', author: 'a', at: 1 }]
|
||||
})
|
||||
|
||||
const result = await checkBackendUpdates()
|
||||
|
||||
expect(checkHermesUpdateSpy).toHaveBeenCalled()
|
||||
expect(result?.behind).toBe(2)
|
||||
expect(result?.commits?.[0]?.sha).toBe('abc1234')
|
||||
expect(result?.supported).toBe(true)
|
||||
expect($backendUpdateStatus.get()?.commits?.[0]?.summary).toBe('feat: x')
|
||||
})
|
||||
|
||||
it('honours can_apply=false (docker/nix): not supported, carries message', async () => {
|
||||
setRemote(true)
|
||||
checkHermesUpdateSpy.mockResolvedValue({
|
||||
install_method: 'docker',
|
||||
current_version: '0.16.0',
|
||||
behind: null,
|
||||
update_available: false,
|
||||
can_apply: false,
|
||||
update_command: 'docker pull ...',
|
||||
message: 'Docker images are immutable.'
|
||||
})
|
||||
|
||||
const result = await checkBackendUpdates()
|
||||
|
||||
expect(result?.supported).toBe(false)
|
||||
expect(result?.message).toBe('Docker images are immutable.')
|
||||
})
|
||||
|
||||
it('is a no-op in local mode (backend check only runs when remote)', async () => {
|
||||
setRemote(false)
|
||||
await checkBackendUpdates()
|
||||
expect(checkHermesUpdateSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyBackendUpdate recovery', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
checkHermesUpdateSpy.mockReset()
|
||||
updateHermesSpy.mockReset()
|
||||
getActionStatusSpy.mockReset()
|
||||
$backendUpdateApply.set({ applying: false, stage: 'idle', message: '', percent: null, error: null, command: null, log: [] })
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('waits for the backend to return after the restart drops the connection, then clears the overlay', async () => {
|
||||
updateHermesSpy.mockResolvedValue({ ok: true, name: 'update', pid: 1 })
|
||||
getActionStatusSpy.mockRejectedValue(new Error('ECONNREFUSED'))
|
||||
checkHermesUpdateSpy.mockResolvedValue({ install_method: 'git', current_version: '0.16.0', behind: 0, update_available: false, can_apply: true, update_command: 'hermes update', message: null })
|
||||
|
||||
const promise = applyBackendUpdate()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
const result = await promise
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect($backendUpdateApply.get().stage).toBe('idle')
|
||||
expect($backendUpdateApply.get().applying).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces an error when the backend never comes back after the restart', async () => {
|
||||
updateHermesSpy.mockResolvedValue({ ok: true, name: 'update', pid: 1 })
|
||||
getActionStatusSpy.mockRejectedValue(new Error('ECONNREFUSED'))
|
||||
checkHermesUpdateSpy.mockRejectedValue(new Error('ECONNREFUSED'))
|
||||
|
||||
const promise = applyBackendUpdate()
|
||||
await vi.advanceTimersByTimeAsync(70000)
|
||||
const result = await promise
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect($backendUpdateApply.get().stage).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,9 +13,12 @@ import type {
|
||||
DesktopUpdateStatus,
|
||||
DesktopVersionInfo
|
||||
} from '@/global'
|
||||
import { checkHermesUpdate, getActionStatus, updateHermes } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import { dismissNotification, notify } from '@/store/notifications'
|
||||
import { $connection } from '@/store/session'
|
||||
import type { BackendUpdateCheckResponse } from '@/types/hermes'
|
||||
|
||||
export interface UpdateApplyState {
|
||||
applying: boolean
|
||||
@@ -45,8 +48,24 @@ export const $updateChecking = atom<boolean>(false)
|
||||
export const $updateOverlayOpen = atom<boolean>(false)
|
||||
export const $updateStatus = atom<DesktopUpdateStatus | null>(null)
|
||||
|
||||
// Client and backend are independently updatable; each keeps its own state.
|
||||
export const $backendUpdateStatus = atom<DesktopUpdateStatus | null>(null)
|
||||
export const $backendUpdateApply = atom<UpdateApplyState>(IDLE)
|
||||
export const $backendUpdateChecking = atom<boolean>(false)
|
||||
|
||||
export type UpdateTarget = 'client' | 'backend'
|
||||
export const $updateOverlayTarget = atom<UpdateTarget>('client')
|
||||
|
||||
export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(open)
|
||||
export const resetUpdateApplyState = () => $updateApply.set(IDLE)
|
||||
export const openUpdateOverlayFor = (target: UpdateTarget) => {
|
||||
$updateOverlayTarget.set(target)
|
||||
$updateOverlayOpen.set(true)
|
||||
void (target === 'backend' ? checkBackendUpdates() : checkUpdates())
|
||||
}
|
||||
export const resetUpdateApplyState = () => {
|
||||
$updateApply.set(IDLE)
|
||||
$backendUpdateApply.set(IDLE)
|
||||
}
|
||||
|
||||
const UPDATE_TOAST_ID = 'desktop-update-available'
|
||||
// Time-based snooze instead of per-sha dismissal: this repo lands ~100 commits
|
||||
@@ -86,7 +105,7 @@ export function reportBackendContract(contract: number | undefined): void {
|
||||
}
|
||||
|
||||
notify({
|
||||
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyUpdates() },
|
||||
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyBackendUpdate() },
|
||||
durationMs: 0,
|
||||
id: SKEW_TOAST_ID,
|
||||
kind: 'warning',
|
||||
@@ -137,13 +156,8 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the updates dialog and kicks off a fresh check so the user always
|
||||
* sees current state, even if a stale status is cached from earlier.
|
||||
*/
|
||||
export function openUpdatesWindow(): void {
|
||||
$updateOverlayOpen.set(true)
|
||||
void checkUpdates()
|
||||
openUpdateOverlayFor(isRemoteMode() ? 'backend' : 'client')
|
||||
}
|
||||
|
||||
/** Re-read the running app's version from the Electron main process and
|
||||
@@ -174,6 +188,52 @@ export async function refreshDesktopVersion(): Promise<DesktopVersionInfo | null
|
||||
}
|
||||
}
|
||||
|
||||
function isRemoteMode(): boolean {
|
||||
return $connection.get()?.mode === 'remote'
|
||||
}
|
||||
|
||||
function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus {
|
||||
const behind = res.behind ?? 0
|
||||
|
||||
return {
|
||||
supported: res.can_apply,
|
||||
message: res.message ?? undefined,
|
||||
behind: behind > 0 ? behind : 0,
|
||||
targetSha: res.update_available ? `backend:${res.current_version}` : undefined,
|
||||
commits: res.commits,
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkBackendUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
if (!isRemoteMode() || $backendUpdateChecking.get()) {
|
||||
return $backendUpdateStatus.get()
|
||||
}
|
||||
|
||||
$backendUpdateChecking.set(true)
|
||||
|
||||
try {
|
||||
const status = mapBackendCheck(await checkHermesUpdate(true))
|
||||
$backendUpdateStatus.set(status)
|
||||
maybeNotifyUpdateAvailable(status)
|
||||
|
||||
return status
|
||||
} catch (error) {
|
||||
const fallback: DesktopUpdateStatus = {
|
||||
supported: $backendUpdateStatus.get()?.supported ?? true,
|
||||
error: 'check-failed',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
|
||||
$backendUpdateStatus.set(fallback)
|
||||
|
||||
return fallback
|
||||
} finally {
|
||||
$backendUpdateChecking.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
const bridge = window.hermesDesktop?.updates
|
||||
|
||||
@@ -187,9 +247,6 @@ export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
const status = await bridge.check()
|
||||
$updateStatus.set(status)
|
||||
maybeNotifyUpdateAvailable(status)
|
||||
// The update check pulls the latest hermes_cli + bundled package metadata
|
||||
// into place. Re-read the running version so About reflects the now-fresh
|
||||
// checkout rather than the one captured at process start.
|
||||
void refreshDesktopVersion()
|
||||
|
||||
return status
|
||||
@@ -247,6 +304,107 @@ export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promis
|
||||
}
|
||||
}
|
||||
|
||||
const BACKEND_RETURN_POLL_MS = 1500
|
||||
const BACKEND_RETURN_MAX_ATTEMPTS = 40
|
||||
|
||||
async function waitForBackendReturn(): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < BACKEND_RETURN_MAX_ATTEMPTS; attempt += 1) {
|
||||
await new Promise(resolve => globalThis.setTimeout(resolve, BACKEND_RETURN_POLL_MS))
|
||||
try {
|
||||
await checkHermesUpdate()
|
||||
|
||||
return true
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function finishBackendApply(returned: boolean): DesktopUpdateApplyResult {
|
||||
if (returned) {
|
||||
$backendUpdateApply.set(IDLE)
|
||||
setUpdateOverlayOpen(false)
|
||||
void checkBackendUpdates()
|
||||
|
||||
return { ok: true, message: 'Backend update applied.' }
|
||||
}
|
||||
|
||||
$backendUpdateApply.set({
|
||||
...$backendUpdateApply.get(),
|
||||
applying: false,
|
||||
stage: 'error',
|
||||
error: 'apply-failed',
|
||||
message: translateNow('updates.applyStatus.noReturn')
|
||||
})
|
||||
|
||||
return { ok: false, error: 'apply-failed', message: 'Backend did not come back online.' }
|
||||
}
|
||||
|
||||
export async function applyBackendUpdate(): Promise<DesktopUpdateApplyResult> {
|
||||
dismissNotification(UPDATE_TOAST_ID)
|
||||
$backendUpdateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: translateNow('updates.applyStatus.preparing') })
|
||||
|
||||
try {
|
||||
const started = await updateHermes()
|
||||
|
||||
if (!started.ok) {
|
||||
const message = (started as { message?: string }).message || translateNow('updates.applyStatus.notAvailable')
|
||||
const command = (started as { update_command?: string }).update_command || 'hermes update'
|
||||
$backendUpdateApply.set({ ...IDLE, applying: false, stage: 'manual', message, command })
|
||||
|
||||
return { ok: false, error: 'manual', manual: true, message, command }
|
||||
}
|
||||
|
||||
$backendUpdateApply.set({ ...IDLE, applying: true, stage: 'pull', message: translateNow('updates.applyStatus.pulling') })
|
||||
|
||||
let last: Awaited<ReturnType<typeof getActionStatus>> | null = null
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await new Promise(resolve => globalThis.setTimeout(resolve, 1500))
|
||||
try {
|
||||
last = await getActionStatus(started.name, 200)
|
||||
} catch {
|
||||
// The dashboard restarts mid-update, dropping this connection — expected, not a failure.
|
||||
$backendUpdateApply.set({
|
||||
...$backendUpdateApply.get(),
|
||||
applying: true,
|
||||
stage: 'restart',
|
||||
message: translateNow('updates.applyStatus.restarting')
|
||||
})
|
||||
|
||||
return finishBackendApply(await waitForBackendReturn())
|
||||
}
|
||||
|
||||
if (last && !last.running) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const ok = !!last && (last.exit_code ?? 1) === 0
|
||||
if (ok) {
|
||||
$backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: true, stage: 'restart', message: translateNow('updates.applyStatus.restarting') })
|
||||
|
||||
return finishBackendApply(await waitForBackendReturn())
|
||||
}
|
||||
|
||||
$backendUpdateApply.set({
|
||||
...$backendUpdateApply.get(),
|
||||
applying: false,
|
||||
stage: 'error',
|
||||
error: 'apply-failed',
|
||||
message: translateNow('updates.applyStatus.failed')
|
||||
})
|
||||
|
||||
return { ok: false, error: 'apply-failed', message: 'Backend update failed.' }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
$backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message })
|
||||
|
||||
return { ok: false, error: 'apply-failed', message }
|
||||
}
|
||||
}
|
||||
|
||||
function ingestProgress(payload: DesktopUpdateProgress): void {
|
||||
const current = $updateApply.get()
|
||||
const log = [...current.log, { stage: payload.stage, message: payload.message, at: payload.at }].slice(-50)
|
||||
@@ -267,6 +425,8 @@ function ingestProgress(payload: DesktopUpdateProgress): void {
|
||||
let pollerStarted = false
|
||||
let backgroundTimer: ReturnType<typeof setInterval> | null = null
|
||||
let lastFocusAt = 0
|
||||
let connectionUnsub: (() => void) | null = null
|
||||
let lastConnectionMode: string | undefined
|
||||
|
||||
/** Wire up background polling + progress streaming. Idempotent. */
|
||||
export function startUpdatePoller(): void {
|
||||
@@ -282,11 +442,28 @@ export function startUpdatePoller(): void {
|
||||
|
||||
pollerStarted = true
|
||||
void checkUpdates()
|
||||
void checkBackendUpdates()
|
||||
void refreshDesktopVersion()
|
||||
bridge.onProgress(ingestProgress)
|
||||
|
||||
// The poller starts at mount, before the gateway connects — so the first
|
||||
// backend check above sees mode≠remote and no-ops. Re-check once the
|
||||
// connection resolves to remote.
|
||||
connectionUnsub = $connection.subscribe(conn => {
|
||||
if (conn?.mode === lastConnectionMode) {
|
||||
return
|
||||
}
|
||||
lastConnectionMode = conn?.mode
|
||||
if (conn?.mode === 'remote') {
|
||||
void checkBackendUpdates()
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('focus', onFocus)
|
||||
backgroundTimer = setInterval(() => void checkUpdates(), 30 * 60 * 1000)
|
||||
backgroundTimer = setInterval(() => {
|
||||
void checkUpdates()
|
||||
void checkBackendUpdates()
|
||||
}, 30 * 60 * 1000)
|
||||
}
|
||||
|
||||
export function stopUpdatePoller(): void {
|
||||
@@ -295,6 +472,9 @@ export function stopUpdatePoller(): void {
|
||||
backgroundTimer = null
|
||||
}
|
||||
|
||||
connectionUnsub?.()
|
||||
connectionUnsub = null
|
||||
lastConnectionMode = undefined
|
||||
window.removeEventListener('focus', onFocus)
|
||||
pollerStarted = false
|
||||
}
|
||||
@@ -308,8 +488,6 @@ function onFocus() {
|
||||
|
||||
lastFocusAt = now
|
||||
void checkUpdates()
|
||||
// Cheap and safe to re-read on every (throttled) focus: the user may have
|
||||
// updated Hermes from another window/CLI between focuses, and About should
|
||||
// catch up without forcing a restart.
|
||||
void checkBackendUpdates()
|
||||
void refreshDesktopVersion()
|
||||
}
|
||||
|
||||
@@ -596,6 +596,27 @@ export interface ActionStatusResponse {
|
||||
running: boolean
|
||||
}
|
||||
|
||||
export interface BackendUpdateCommit {
|
||||
sha: string
|
||||
summary: string
|
||||
author: string
|
||||
at: number
|
||||
}
|
||||
|
||||
/** Shape of `GET /api/hermes/update/check` — the backend's own update state.
|
||||
* Used by the desktop's remote update overlay so the backend version (not the
|
||||
* Electron client clone) drives "what's changed + Install" in remote mode. */
|
||||
export interface BackendUpdateCheckResponse {
|
||||
install_method: string
|
||||
current_version: string
|
||||
behind: number | null
|
||||
update_available: boolean
|
||||
can_apply: boolean
|
||||
update_command: string | null
|
||||
message: string | null
|
||||
commits?: BackendUpdateCommit[]
|
||||
}
|
||||
|
||||
export interface AuxiliaryTaskAssignment {
|
||||
base_url: string
|
||||
model: string
|
||||
|
||||
@@ -1405,6 +1405,54 @@ async def update_hermes():
|
||||
}
|
||||
|
||||
|
||||
def _recent_upstream_commits(n: int = 20) -> List[Dict[str, Any]]:
|
||||
"""Commits the local checkout is behind ``origin/main`` by, newest first.
|
||||
|
||||
Logs the SAME range the behind-count uses (``HEAD..origin/main`` — see
|
||||
``banner._check_via_local_git``), NOT the branch's ``@{upstream}``. On a
|
||||
feature-branch checkout ``@{upstream}`` is the branch's own tip (zero
|
||||
commits), which would leave the changelog empty even though the count is
|
||||
non-zero. Pinning to ``origin/main`` keeps count and changelog consistent.
|
||||
|
||||
Best-effort: returns [] if not a git checkout, origin/main is unreachable,
|
||||
or git is unavailable. Never raises into the request path.
|
||||
"""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(PROJECT_ROOT),
|
||||
"log",
|
||||
"--format=%H%x1f%s%x1f%an%x1f%ct",
|
||||
"HEAD..origin/main",
|
||||
f"-n{int(n)}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
return []
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for line in out.stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = (line.split("\x1f") + ["", "", "", "0"])[:4]
|
||||
sha, summary, author, at = parts
|
||||
rows.append(
|
||||
{
|
||||
"sha": sha[:7],
|
||||
"summary": summary,
|
||||
"author": author,
|
||||
"at": int(at or 0),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@app.get("/api/hermes/update/check")
|
||||
async def check_hermes_update(force: bool = False):
|
||||
"""Report whether a Hermes update is available, without applying it.
|
||||
@@ -1425,6 +1473,11 @@ async def check_hermes_update(force: bool = False):
|
||||
user must update out-of-band
|
||||
update_command: the recommended command for this install method
|
||||
message: human-readable guidance for non-applyable methods
|
||||
commits: for git/pip installs that are behind, a list of the commits
|
||||
the local checkout is behind upstream by — each
|
||||
{sha, summary, author, at}. Absent/empty otherwise. The
|
||||
desktop's remote update overlay renders this as "what's
|
||||
changed". Additive: existing consumers ignore it.
|
||||
"""
|
||||
install_method = detect_install_method(PROJECT_ROOT)
|
||||
update_command = recommended_update_command_for_method(install_method)
|
||||
@@ -1467,6 +1520,11 @@ async def check_hermes_update(force: bool = False):
|
||||
payload["message"] = "You're on the latest version."
|
||||
else:
|
||||
payload["update_available"] = True
|
||||
# Enrich with the actual commits we're behind by, so the desktop's
|
||||
# remote update overlay can show "what's changed". git/pip only;
|
||||
# best-effort (empty list on any failure).
|
||||
if install_method in ("git", "pip"):
|
||||
payload["commits"] = await asyncio.to_thread(_recent_upstream_commits)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
@@ -701,6 +701,37 @@ class TestUpdateCheckEndpoint:
|
||||
assert body["update_available"] is False
|
||||
assert body["message"]
|
||||
|
||||
def test_git_behind_includes_commits(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
|
||||
monkeypatch.setattr(banner, "check_for_updates", lambda: 3)
|
||||
monkeypatch.setattr(
|
||||
ws,
|
||||
"_recent_upstream_commits",
|
||||
lambda n=20: [
|
||||
{"sha": "abc1234", "summary": "feat: x", "author": "a", "at": 1},
|
||||
],
|
||||
)
|
||||
|
||||
body = self.client.get("/api/hermes/update/check").json()
|
||||
# The desktop overlay renders this as the "what's changed" list.
|
||||
assert isinstance(body["commits"], list)
|
||||
assert body["commits"][0]["sha"] == "abc1234"
|
||||
assert body["commits"][0]["summary"] == "feat: x"
|
||||
|
||||
def test_up_to_date_omits_commits(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
import hermes_cli.banner as banner
|
||||
|
||||
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
|
||||
monkeypatch.setattr(banner, "check_for_updates", lambda: 0)
|
||||
|
||||
body = self.client.get("/api/hermes/update/check").json()
|
||||
# No commits list when there's nothing to show (additive, non-breaking).
|
||||
assert body.get("commits", []) == []
|
||||
|
||||
|
||||
class TestDebugShareEndpoint:
|
||||
"""POST /api/ops/debug-share returns the paste URLs synchronously so the
|
||||
|
||||
@@ -481,7 +481,7 @@ same auth gate as the rest of `/api/`.
|
||||
| `GET /api/ops/checkpoints` · `POST .../prune` | Inspect / prune the `/rollback` store |
|
||||
| `POST /api/ops/hooks` · `DELETE /api/ops/hooks` | Create / remove a shell hook (consent-gated) |
|
||||
| `GET /api/system/stats` | Host stats — OS, CPU, memory, disk, uptime |
|
||||
| `GET /api/hermes/update/check` | Report update availability (commits behind, install method) without applying. `?force=1` busts the 6h cache |
|
||||
| `GET /api/hermes/update/check` | Report update availability (commits behind, install method) without applying. For git/pip installs that are behind, also returns a `commits` list (`sha`, `summary`, `author`, `at`) of what's changed. `?force=1` busts the 6h cache |
|
||||
| `GET /api/curator` · `PUT .../paused` · `POST .../run` | Skill-curator status + pause/resume + run |
|
||||
| `GET /api/portal` | Nous Portal auth + Tool Gateway routing (read-only) |
|
||||
| `POST /api/ops/prompt-size` · `/dump` · `/config-migrate` | Diagnostics (backgrounded) |
|
||||
|
||||
Reference in New Issue
Block a user