mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-26 22:30:50 +00:00
* fix(hub): govern runner capabilities so Cursor reopen soft-fails on skew Hub↔runner protocol drift was reported as missing Cursor chat data when cursor-chat-store-status was unregistered. Soft-fail reopen on probe errors, advertise required machine capabilities, surface an unmissable upgrade banner, and stop-runner when a newer CLI binary is already on disk. Fixes #1084 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web,hub): make runner skew banner dismissible; gate auto-upgrade Compact the out-of-date banner (minimize + 1h snooze + per-host Restart) so it no longer blocks the session list. Auto stop-runner on skew stays opt-in via HAPI_AUTO_UPGRADE_RUNNERS / autoUpgradeRunners (default off). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): tolerate full sessionStorage on skew banner minimize QuotaExceededError from setItem aborted minimize before React state updated, leaving the banner stuck over the session list. Persist to memory when storage fails; only enable Restart when a newer CLI is already on disk; clarify opt-in is stop-runner only, not package push. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): drop redundant autoUpgradeRunners; runners already self-restart CLI version handoff already reloads the runner when the on-disk binary mtime changes. Hub-driven stop-runner on skew duplicated that. Keep the skew banner and manual Restart only as a stuck/disabled-handoff escape. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli,hub,web): runner-only caps ads; gate Restart on supervisor Address #1108 bot Majors on the thin tip: terminal/lazy bootstraps no longer merge CURRENT_MACHINE_CAPABILITIES into the machine row (only asRunner registration does). Banner Restart refuses unsupervised hosts so stop-runner cannot leave a detached laptop offline; supervised runners advertise supervisedRestart via HAPI_RUNNER_SUPERVISED=1. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub,cli,web): clear sticky runner ads; docs SUPERVISED; i18n skew label Omit-means-clear on runner registration so rollback cannot leave supervisedRestart/capabilities sticky; always advertise boolean supervisedRestart from asRunner. Document HAPI_RUNNER_SUPERVISED=1 and localize MachineSelector UPDATE REQUIRED. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse> Co-authored-by: Cursor <cursoragent@cursor.com>
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { Session } from '@/api/types'
|
|
|
|
const {
|
|
getSessionMock,
|
|
getOrCreateSessionMock,
|
|
getOrCreateMachineMock,
|
|
sessionSyncClientMock,
|
|
notifyRunnerSessionStartedMock,
|
|
readSettingsMock
|
|
} = vi.hoisted(() => ({
|
|
getSessionMock: vi.fn(),
|
|
getOrCreateSessionMock: vi.fn(),
|
|
getOrCreateMachineMock: vi.fn(),
|
|
sessionSyncClientMock: vi.fn(),
|
|
notifyRunnerSessionStartedMock: vi.fn(async () => ({})),
|
|
readSettingsMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('@/api/api', () => ({
|
|
ApiClient: {
|
|
create: async () => ({
|
|
getSession: getSessionMock,
|
|
getOrCreateSession: getOrCreateSessionMock,
|
|
getOrCreateMachine: getOrCreateMachineMock,
|
|
sessionSyncClient: sessionSyncClientMock
|
|
})
|
|
}
|
|
}))
|
|
|
|
vi.mock('@/runner/controlClient', () => ({
|
|
notifyRunnerSessionStarted: notifyRunnerSessionStartedMock,
|
|
getInstalledCliMtimeMs: () => 1_700_000_000_000,
|
|
}))
|
|
|
|
vi.mock('@/persistence', () => ({
|
|
readSettings: readSettingsMock
|
|
}))
|
|
|
|
vi.mock('@/configuration', () => ({
|
|
configuration: {
|
|
happyHomeDir: '/tmp/.hapi',
|
|
logsDir: '/tmp/.hapi/logs',
|
|
isRunnerProcess: false
|
|
}
|
|
}))
|
|
|
|
vi.mock('@/ui/logger', () => ({
|
|
logger: {
|
|
debug: vi.fn()
|
|
}
|
|
}))
|
|
|
|
import {
|
|
HAPI_SESSION_ID_ENV,
|
|
bootstrapExistingSession,
|
|
bootstrapLazySession,
|
|
bootstrapSession,
|
|
buildMachineMetadata,
|
|
buildSessionMetadata
|
|
} from './sessionFactory'
|
|
|
|
function createSession(): Session {
|
|
return {
|
|
id: 'hapi-session-1',
|
|
namespace: 'default',
|
|
seq: 1,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
active: false,
|
|
activeAt: 1,
|
|
metadata: {
|
|
path: '/tmp/project',
|
|
host: 'localhost',
|
|
machineId: 'machine-1',
|
|
flavor: 'codex',
|
|
codexSessionId: 'codex-thread-1'
|
|
},
|
|
metadataVersion: 1,
|
|
agentState: { controlledByUser: false },
|
|
agentStateVersion: 1,
|
|
thinking: false,
|
|
thinkingAt: 1,
|
|
todos: [],
|
|
model: null,
|
|
modelReasoningEffort: null,
|
|
effort: null,
|
|
serviceTier: null,
|
|
permissionMode: undefined,
|
|
collaborationMode: undefined
|
|
}
|
|
}
|
|
|
|
describe('bootstrapExistingSession', () => {
|
|
beforeEach(() => {
|
|
getSessionMock.mockReset()
|
|
getOrCreateSessionMock.mockReset()
|
|
getOrCreateMachineMock.mockReset()
|
|
sessionSyncClientMock.mockReset()
|
|
notifyRunnerSessionStartedMock.mockClear()
|
|
readSettingsMock.mockReset()
|
|
delete process.env[HAPI_SESSION_ID_ENV]
|
|
})
|
|
|
|
it('loads an existing HAPI session and reports it to the runner', async () => {
|
|
const session = createSession()
|
|
const sessionClient = {
|
|
updateMetadata: vi.fn()
|
|
}
|
|
getSessionMock.mockResolvedValue(session)
|
|
getOrCreateMachineMock.mockResolvedValue({ id: 'machine-1' })
|
|
sessionSyncClientMock.mockReturnValue(sessionClient)
|
|
readSettingsMock.mockResolvedValue({ machineId: 'machine-1' })
|
|
|
|
const result = await bootstrapExistingSession({
|
|
sessionId: 'hapi-session-1',
|
|
flavor: 'codex',
|
|
workingDirectory: '/tmp/project'
|
|
})
|
|
|
|
expect(result.sessionInfo.id).toBe('hapi-session-1')
|
|
expect(process.env[HAPI_SESSION_ID_ENV]).toBe('hapi-session-1')
|
|
expect(result.workingDirectory).toBe('/tmp/project')
|
|
expect(sessionSyncClientMock).toHaveBeenCalledWith(session)
|
|
expect(sessionClient.updateMetadata).toHaveBeenCalledOnce()
|
|
expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith(
|
|
'hapi-session-1',
|
|
expect.objectContaining({
|
|
path: '/tmp/project',
|
|
flavor: 'codex',
|
|
startedBy: 'terminal',
|
|
startedFromRunner: false,
|
|
machineId: 'machine-1'
|
|
})
|
|
)
|
|
})
|
|
|
|
it('preserves existing native resume metadata when reactivating a session', async () => {
|
|
const session = createSession()
|
|
const existingMetadata = session.metadata
|
|
if (!existingMetadata) throw new Error('expected test session metadata')
|
|
|
|
session.metadata = {
|
|
...existingMetadata,
|
|
claudeSessionId: 'claude-thread-1',
|
|
codexSessionId: 'codex-thread-1',
|
|
geminiSessionId: 'gemini-thread-1',
|
|
opencodeSessionId: 'opencode-thread-1',
|
|
grokSessionId: 'grok-thread-1',
|
|
cursorSessionId: 'cursor-thread-1',
|
|
cursorSessionProtocol: 'acp',
|
|
piSessionId: 'pi-thread-1',
|
|
piResumeAttempt: {
|
|
state: 'resuming',
|
|
machineId: 'machine-1',
|
|
startedAt: 123,
|
|
},
|
|
ptyResumeAttempt: {
|
|
state: 'quarantined',
|
|
machineId: 'machine-1',
|
|
startedAt: 456,
|
|
},
|
|
summary: {
|
|
text: 'resume me',
|
|
updatedAt: 100
|
|
},
|
|
tools: ['read_file'],
|
|
slashCommands: ['/compact'],
|
|
conversationHistoryPoints: { 'local-user-1': true },
|
|
conversationHistoryEntryIds: { 'local-user-1': 'pi-entry-1' },
|
|
capabilities: {
|
|
terminal: true,
|
|
conversationHistory: { forkCurrent: true }
|
|
}
|
|
}
|
|
const sessionClient = {
|
|
updateMetadata: vi.fn()
|
|
}
|
|
getSessionMock.mockResolvedValue(session)
|
|
getOrCreateMachineMock.mockResolvedValue({ id: 'machine-1' })
|
|
sessionSyncClientMock.mockReturnValue(sessionClient)
|
|
readSettingsMock.mockResolvedValue({ machineId: 'machine-1' })
|
|
|
|
const result = await bootstrapExistingSession({
|
|
sessionId: 'hapi-session-1',
|
|
flavor: 'codex',
|
|
workingDirectory: '/tmp/project'
|
|
})
|
|
|
|
expect(result.metadata).toEqual(expect.objectContaining({
|
|
claudeSessionId: 'claude-thread-1',
|
|
codexSessionId: 'codex-thread-1',
|
|
geminiSessionId: 'gemini-thread-1',
|
|
opencodeSessionId: 'opencode-thread-1',
|
|
grokSessionId: 'grok-thread-1',
|
|
cursorSessionId: 'cursor-thread-1',
|
|
cursorSessionProtocol: 'acp',
|
|
piSessionId: 'pi-thread-1',
|
|
piResumeAttempt: {
|
|
state: 'resuming',
|
|
machineId: 'machine-1',
|
|
startedAt: 123,
|
|
},
|
|
ptyResumeAttempt: {
|
|
state: 'quarantined',
|
|
machineId: 'machine-1',
|
|
startedAt: 456,
|
|
},
|
|
summary: {
|
|
text: 'resume me',
|
|
updatedAt: 100
|
|
},
|
|
tools: ['read_file'],
|
|
slashCommands: ['/compact'],
|
|
conversationHistoryPoints: { 'local-user-1': true },
|
|
conversationHistoryEntryIds: { 'local-user-1': 'pi-entry-1' },
|
|
capabilities: {
|
|
terminal: true,
|
|
conversationHistory: { forkCurrent: true }
|
|
}
|
|
}))
|
|
expect(sessionClient.updateMetadata).toHaveBeenCalledOnce()
|
|
const updateHandler = sessionClient.updateMetadata.mock.calls[0][0]
|
|
expect(updateHandler(session.metadata)).toEqual(expect.objectContaining({
|
|
codexSessionId: 'codex-thread-1',
|
|
grokSessionId: 'grok-thread-1',
|
|
conversationHistoryEntryIds: { 'local-user-1': 'pi-entry-1' }
|
|
}))
|
|
expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith(
|
|
'hapi-session-1',
|
|
expect.objectContaining({
|
|
codexSessionId: 'codex-thread-1',
|
|
grokSessionId: 'grok-thread-1',
|
|
conversationHistoryEntryIds: { 'local-user-1': 'pi-entry-1' }
|
|
})
|
|
)
|
|
})
|
|
|
|
it('advertises remote terminal capability in session metadata', () => {
|
|
const metadata = buildSessionMetadata({
|
|
flavor: 'codex',
|
|
startedBy: 'terminal',
|
|
workingDirectory: '/tmp/project',
|
|
machineId: 'machine-1',
|
|
now: 123
|
|
})
|
|
|
|
expect(metadata.capabilities?.terminal).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('bootstrapLazySession', () => {
|
|
beforeEach(() => {
|
|
getOrCreateSessionMock.mockReset()
|
|
getOrCreateMachineMock.mockReset()
|
|
sessionSyncClientMock.mockReset()
|
|
notifyRunnerSessionStartedMock.mockClear()
|
|
readSettingsMock.mockReset()
|
|
delete process.env[HAPI_SESSION_ID_ENV]
|
|
})
|
|
|
|
it('does not export HAPI_SESSION_ID until the hub row is materialized', async () => {
|
|
const pendingClient = { isPending: () => true }
|
|
sessionSyncClientMock.mockReturnValue(pendingClient)
|
|
readSettingsMock.mockResolvedValue({ machineId: 'machine-1' })
|
|
|
|
const result = await bootstrapLazySession({
|
|
flavor: 'codex',
|
|
startedBy: 'terminal',
|
|
workingDirectory: '/tmp/project',
|
|
agentState: { controlledByUser: false }
|
|
})
|
|
|
|
expect(process.env[HAPI_SESSION_ID_ENV]).toBeUndefined()
|
|
expect(result.sessionInfo.id).toMatch(/^[0-9a-f-]{36}$/)
|
|
|
|
const [, options] = sessionSyncClientMock.mock.calls[0]
|
|
const materialized = createSession()
|
|
materialized.id = result.sessionInfo.id
|
|
options.onMaterialized(materialized, {
|
|
metadata: result.metadata,
|
|
agentState: { controlledByUser: false }
|
|
})
|
|
|
|
expect(process.env[HAPI_SESSION_ID_ENV]).toBe(result.sessionInfo.id)
|
|
})
|
|
|
|
it('does not persist a machine or session until materialization', async () => {
|
|
const pendingClient = { isPending: () => true }
|
|
sessionSyncClientMock.mockReturnValue(pendingClient)
|
|
readSettingsMock.mockResolvedValue({ machineId: 'machine-1' })
|
|
|
|
const result = await bootstrapLazySession({
|
|
flavor: 'codex',
|
|
startedBy: 'terminal',
|
|
workingDirectory: '/tmp/project',
|
|
agentState: { controlledByUser: false }
|
|
})
|
|
|
|
expect(result.session).toBe(pendingClient)
|
|
expect(getOrCreateMachineMock).not.toHaveBeenCalled()
|
|
expect(getOrCreateSessionMock).not.toHaveBeenCalled()
|
|
expect(notifyRunnerSessionStartedMock).not.toHaveBeenCalled()
|
|
|
|
const [provisional, options] = sessionSyncClientMock.mock.calls[0]
|
|
expect(provisional.id).toMatch(/^[0-9a-f-]{36}$/)
|
|
expect(provisional.metadata).toEqual(expect.objectContaining({
|
|
machineId: 'machine-1',
|
|
path: '/tmp/project',
|
|
flavor: 'codex'
|
|
}))
|
|
|
|
const materialized = createSession()
|
|
materialized.id = provisional.id
|
|
getOrCreateSessionMock.mockResolvedValue(materialized)
|
|
const snapshot = {
|
|
metadata: {
|
|
...provisional.metadata,
|
|
codexSessionId: 'codex-thread-1'
|
|
},
|
|
agentState: { controlledByUser: true }
|
|
}
|
|
await options.materialize(snapshot, new AbortController().signal)
|
|
|
|
expect(getOrCreateSessionMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
id: provisional.id,
|
|
metadata: snapshot.metadata,
|
|
state: snapshot.agentState,
|
|
timeoutMs: 10_000,
|
|
machine: expect.objectContaining({ id: 'machine-1' })
|
|
}))
|
|
|
|
options.onMaterialized(materialized, snapshot)
|
|
expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith(
|
|
provisional.id,
|
|
expect.objectContaining({ codexSessionId: 'codex-thread-1' })
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('bootstrapSession HAPI_SESSION_ID export', () => {
|
|
beforeEach(() => {
|
|
getOrCreateSessionMock.mockReset()
|
|
getOrCreateMachineMock.mockReset()
|
|
sessionSyncClientMock.mockReset()
|
|
notifyRunnerSessionStartedMock.mockClear()
|
|
readSettingsMock.mockReset()
|
|
delete process.env[HAPI_SESSION_ID_ENV]
|
|
})
|
|
|
|
it('exports the hub session id so spawned agents inherit it', async () => {
|
|
const session = createSession()
|
|
session.id = 'hub-session-42'
|
|
getOrCreateSessionMock.mockResolvedValue(session)
|
|
getOrCreateMachineMock.mockResolvedValue({ id: 'machine-1' })
|
|
sessionSyncClientMock.mockReturnValue({ isPending: () => false })
|
|
readSettingsMock.mockResolvedValue({ machineId: 'machine-1' })
|
|
|
|
const result = await bootstrapSession({
|
|
flavor: 'claude',
|
|
workingDirectory: '/tmp/project'
|
|
})
|
|
|
|
expect(result.sessionInfo.id).toBe('hub-session-42')
|
|
expect(process.env[HAPI_SESSION_ID_ENV]).toBe('hub-session-42')
|
|
})
|
|
})
|
|
|
|
describe('buildMachineMetadata runner-only capabilities', () => {
|
|
const originalSupervised = process.env.HAPI_RUNNER_SUPERVISED
|
|
|
|
afterEach(() => {
|
|
if (originalSupervised === undefined) {
|
|
delete process.env.HAPI_RUNNER_SUPERVISED
|
|
} else {
|
|
process.env.HAPI_RUNNER_SUPERVISED = originalSupervised
|
|
}
|
|
})
|
|
|
|
it('omits machine RPC capabilities for terminal bootstrap metadata', () => {
|
|
delete process.env.HAPI_RUNNER_SUPERVISED
|
|
const metadata = buildMachineMetadata()
|
|
expect(metadata.capabilities).toBeUndefined()
|
|
expect(metadata.startedCliMtimeMs).toBeUndefined()
|
|
expect(metadata.installedCliMtimeMs).toBeUndefined()
|
|
expect(metadata.supervisedRestart).toBeUndefined()
|
|
})
|
|
|
|
it('advertises capabilities and supervisedRestart only for asRunner', () => {
|
|
process.env.HAPI_RUNNER_SUPERVISED = '1'
|
|
const metadata = buildMachineMetadata({ asRunner: true, startedCliMtimeMs: 42 })
|
|
expect(metadata.capabilities).toEqual(expect.arrayContaining(['cursor-chat-store-status', 'stop-runner']))
|
|
expect(metadata.startedCliMtimeMs).toBe(42)
|
|
expect(metadata.installedCliMtimeMs).toBe(1_700_000_000_000)
|
|
expect(metadata.supervisedRestart).toBe(true)
|
|
})
|
|
|
|
it('always sends supervisedRestart boolean for asRunner so sticky true can clear', () => {
|
|
delete process.env.HAPI_RUNNER_SUPERVISED
|
|
const metadata = buildMachineMetadata({ asRunner: true })
|
|
expect(metadata.capabilities).toEqual(expect.arrayContaining(['stop-runner']))
|
|
expect(metadata.supervisedRestart).toBe(false)
|
|
})
|
|
})
|