mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(codex): import and resume sessions from runners (#1088)
* fix codex import resume flow * fix hub restart session active state * fix codex transcript workspace scoping * Address Codex import review findings * Fix Codex import machine selection * Update Codex sessions error test * Address Codex import review findings * Preserve forked Codex session id on sync * Make Codex duplicate cleanup source-aware * Handle Codex archive failures * Limit existing session flag to Codex * Preserve Codex import machine binding * fix: rebase runner Codex import onto current main * fix: preserve runner-scoped Codex import behavior --------- Co-authored-by: syy <815728149@qq.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
setSessionModel,
|
||||
setSessionModelReasoningEffort,
|
||||
setSessionServiceTier,
|
||||
setSessionActive,
|
||||
setSessionTeamState,
|
||||
setSessionTodos,
|
||||
touchSessionUpdatedAt,
|
||||
@@ -87,6 +88,10 @@ export class SessionStore {
|
||||
return setSessionServiceTier(this.db, id, serviceTier, namespace, options)
|
||||
}
|
||||
|
||||
setSessionActive(id: string, active: boolean, activeAt: number, namespace: string): boolean {
|
||||
return setSessionActive(this.db, id, active, activeAt, namespace)
|
||||
}
|
||||
|
||||
touchSessionUpdatedAt(id: string, updatedAt: number, namespace: string): boolean {
|
||||
return touchSessionUpdatedAt(this.db, id, updatedAt, namespace)
|
||||
}
|
||||
|
||||
@@ -539,6 +539,38 @@ export function setSessionEffort(
|
||||
}
|
||||
}
|
||||
|
||||
export function setSessionActive(
|
||||
db: Database,
|
||||
id: string,
|
||||
active: boolean,
|
||||
activeAt: number,
|
||||
namespace: string
|
||||
): boolean {
|
||||
try {
|
||||
const result = db.prepare(`
|
||||
UPDATE sessions
|
||||
SET active = @active,
|
||||
active_at = CASE
|
||||
WHEN active_at IS NULL OR active_at < @active_at THEN @active_at
|
||||
ELSE active_at
|
||||
END,
|
||||
seq = seq + 1
|
||||
WHERE id = @id
|
||||
AND namespace = @namespace
|
||||
AND (active IS NOT @active OR active_at IS NULL OR active_at < @active_at)
|
||||
`).run({
|
||||
id,
|
||||
namespace,
|
||||
active: active ? 1 : 0,
|
||||
active_at: activeAt
|
||||
})
|
||||
|
||||
return result.changes === 1
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function touchSessionUpdatedAt(
|
||||
db: Database,
|
||||
id: string,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { AgentFlavor, CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types'
|
||||
import { RPC_METHODS } from '@hapi/protocol/rpcMethods'
|
||||
import { CursorChatStoreStatusSchema } from '@hapi/protocol/apiTypes'
|
||||
import {
|
||||
ArchiveCodexSessionRpcResponseSchema,
|
||||
CursorChatStoreStatusSchema,
|
||||
ListCodexSessionsRpcResponseSchema
|
||||
} from '@hapi/protocol/apiTypes'
|
||||
import type {
|
||||
CodexModelSummary,
|
||||
CodexModelsResponse,
|
||||
@@ -15,6 +19,8 @@ import type {
|
||||
GrokModelsResponse,
|
||||
GrokReasoningEffortResponse,
|
||||
ListDirectoryResponse,
|
||||
ListCodexSessionsRpcResponse,
|
||||
ArchiveCodexSessionRpcResponse,
|
||||
OpencodeModelsResponse,
|
||||
OpencodeModelSummary,
|
||||
OpencodeReasoningEffortResponse,
|
||||
@@ -59,6 +65,8 @@ export type RpcListDirectoryResponse = ListDirectoryResponse
|
||||
export type RpcPathExistsResponse = PathExistsResponse
|
||||
export type RpcCodexModel = CodexModelSummary
|
||||
export type RpcListCodexModelsResponse = CodexModelsResponse
|
||||
export type RpcListCodexSessionsResponse = ListCodexSessionsRpcResponse
|
||||
export type RpcArchiveCodexSessionResponse = ArchiveCodexSessionRpcResponse
|
||||
export type RpcCursorModel = CursorModelSummary
|
||||
export type RpcListCursorModelsResponse = CursorModelsResponse
|
||||
export type RpcCursorChatStoreStatus = CursorChatStoreStatus
|
||||
@@ -146,13 +154,14 @@ export class RpcGateway {
|
||||
resumeSessionId?: string,
|
||||
effort?: string,
|
||||
permissionMode?: PermissionMode,
|
||||
serviceTier?: string
|
||||
serviceTier?: string,
|
||||
existingSessionId?: string
|
||||
): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> {
|
||||
try {
|
||||
const result = await this.machineRpc(
|
||||
machineId,
|
||||
RPC_METHODS.SpawnHappySession,
|
||||
{ type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode, serviceTier }
|
||||
{ type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode, serviceTier, existingSessionId, sessionId: existingSessionId }
|
||||
)
|
||||
if (result && typeof result === 'object') {
|
||||
const obj = result as Record<string, unknown>
|
||||
@@ -287,6 +296,16 @@ export class RpcGateway {
|
||||
return await this.machineRpc(machineId, RPC_METHODS.ListCodexModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCodexModelsResponse
|
||||
}
|
||||
|
||||
async listCodexSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]): Promise<RpcListCodexSessionsResponse> {
|
||||
const result = await this.machineRpc(machineId, RPC_METHODS.ListCodexSessions, { cwd: cwd ?? null, sessionIds }, MODEL_LIST_RPC_TIMEOUT_MS)
|
||||
return ListCodexSessionsRpcResponseSchema.parse(result)
|
||||
}
|
||||
|
||||
async archiveCodexSessionForMachine(machineId: string, sessionId: string): Promise<RpcArchiveCodexSessionResponse> {
|
||||
const result = await this.machineRpc(machineId, RPC_METHODS.ArchiveCodexSession, { sessionId }, MODEL_LIST_RPC_TIMEOUT_MS)
|
||||
return ArchiveCodexSessionRpcResponseSchema.parse(result)
|
||||
}
|
||||
|
||||
async listCursorModelsForSession(sessionId: string): Promise<RpcListCursorModelsResponse> {
|
||||
return await this.sessionRpc(sessionId, RPC_METHODS.ListCursorModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCursorModelsResponse
|
||||
}
|
||||
|
||||
@@ -186,6 +186,32 @@ export class SessionCache {
|
||||
}
|
||||
}
|
||||
|
||||
markSessionActive(sessionId: string, time: number = Date.now()): void {
|
||||
const t = clampAliveTime(time) ?? Date.now()
|
||||
const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId)
|
||||
if (!session) return
|
||||
|
||||
const wasActive = session.active
|
||||
session.active = true
|
||||
session.activeAt = Math.max(session.activeAt, t)
|
||||
|
||||
this.lastBroadcastAtBySessionId.set(session.id, Date.now())
|
||||
this.publisher.emit({
|
||||
type: 'session-updated',
|
||||
sessionId: session.id,
|
||||
namespace: session.namespace,
|
||||
data: {
|
||||
active: true,
|
||||
activeAt: session.activeAt,
|
||||
thinking: session.thinking
|
||||
} satisfies SessionPatch
|
||||
})
|
||||
|
||||
if (!wasActive) {
|
||||
this.refreshSession(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
handleSessionAlive(payload: {
|
||||
sid: string
|
||||
time: number
|
||||
@@ -401,6 +427,7 @@ export class SessionCache {
|
||||
}
|
||||
|
||||
session.active = false
|
||||
this.store.sessions.setSessionActive(session.id, false, t, session.namespace)
|
||||
session.thinking = false
|
||||
session.thinkingAt = t
|
||||
session.backgroundTaskCount = 0
|
||||
@@ -421,6 +448,7 @@ export class SessionCache {
|
||||
if (!session.active) continue
|
||||
if (now - session.activeAt <= sessionTimeoutMs) continue
|
||||
session.active = false
|
||||
this.store.sessions.setSessionActive(session.id, false, now, session.namespace)
|
||||
session.thinking = false
|
||||
this.pendingThinkingUntilBySessionId.delete(session.id)
|
||||
expired.push(session.id)
|
||||
|
||||
@@ -715,6 +715,58 @@ describe('session model', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('marks a resumed session active in hub cache before returning success without persisting runtime active state', async () => {
|
||||
const store = new Store(':memory:')
|
||||
const events: unknown[] = []
|
||||
const engine = new SyncEngine(
|
||||
store,
|
||||
{} as never,
|
||||
new RpcRegistry(),
|
||||
{ broadcast(event: unknown) { events.push(event) } } as never
|
||||
)
|
||||
|
||||
try {
|
||||
const session = engine.getOrCreateSession(
|
||||
'session-resume-active-state',
|
||||
{
|
||||
path: '/tmp/project',
|
||||
host: 'localhost',
|
||||
machineId: 'machine-1',
|
||||
flavor: 'codex',
|
||||
codexSessionId: 'codex-thread-1'
|
||||
},
|
||||
null,
|
||||
'default',
|
||||
'gpt-5.4'
|
||||
)
|
||||
engine.getOrCreateMachine(
|
||||
'machine-1',
|
||||
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
|
||||
null,
|
||||
'default'
|
||||
)
|
||||
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
|
||||
|
||||
;(engine as any).rpcGateway.spawnSession = async () => ({ type: 'success', sessionId: session.id })
|
||||
;(engine as any).waitForSessionActive = async () => true
|
||||
|
||||
const result = await engine.resumeSession(session.id, 'default')
|
||||
|
||||
expect(result).toEqual({ type: 'success', sessionId: session.id })
|
||||
expect(engine.getSession(session.id)?.active).toBe(true)
|
||||
// 中文注释:active=true 是运行时状态,不能跨 Hub 重启持久化;否则旧会话会在重启后假在线。
|
||||
expect(store.sessions.getSession(session.id)?.active).toBe(false)
|
||||
expect(events.some((event) => {
|
||||
const record = event as { type?: string; sessionId?: string; data?: { active?: boolean } }
|
||||
return record.type === 'session-updated'
|
||||
&& record.sessionId === session.id
|
||||
&& record.data?.active === true
|
||||
})).toBe(true)
|
||||
} finally {
|
||||
engine.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('passes resume session ID to rpc gateway when resuming claude session', async () => {
|
||||
const store = new Store(':memory:')
|
||||
const engine = new SyncEngine(
|
||||
@@ -965,6 +1017,66 @@ describe('session model', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let stale default resume option override persisted Codex yolo', async () => {
|
||||
const store = new Store(':memory:')
|
||||
const engine = new SyncEngine(
|
||||
store,
|
||||
{} as never,
|
||||
new RpcRegistry(),
|
||||
{ broadcast() {} } as never
|
||||
)
|
||||
|
||||
try {
|
||||
const session = engine.getOrCreateSession(
|
||||
'session-codex-yolo-resume',
|
||||
{
|
||||
path: '/tmp/project',
|
||||
host: 'localhost',
|
||||
machineId: 'machine-1',
|
||||
flavor: 'codex',
|
||||
codexSessionId: 'codex-thread-1',
|
||||
preferredPermissionMode: 'yolo'
|
||||
},
|
||||
null,
|
||||
'default',
|
||||
'gpt-5'
|
||||
)
|
||||
engine.getOrCreateMachine(
|
||||
'machine-1',
|
||||
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
|
||||
null,
|
||||
'default'
|
||||
)
|
||||
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
|
||||
|
||||
let capturedPermissionMode: string | undefined
|
||||
;(engine as any).rpcGateway.spawnSession = async (
|
||||
_machineId: string,
|
||||
_directory: string,
|
||||
_agent: string,
|
||||
_model?: string,
|
||||
_modelReasoningEffort?: string,
|
||||
_yolo?: boolean,
|
||||
_sessionType?: string,
|
||||
_worktreeName?: string,
|
||||
_resumeSessionId?: string,
|
||||
_effort?: string,
|
||||
permissionMode?: string
|
||||
) => {
|
||||
capturedPermissionMode = permissionMode
|
||||
return { type: 'success', sessionId: session.id }
|
||||
}
|
||||
;(engine as any).waitForSessionActive = async () => true
|
||||
|
||||
const result = await engine.resumeSession(session.id, 'default', { permissionMode: 'default' })
|
||||
|
||||
expect(result).toEqual({ type: 'success', sessionId: session.id })
|
||||
expect(capturedPermissionMode).toBe('yolo')
|
||||
} finally {
|
||||
engine.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('passes the cached permissionMode when respawning a resumed session', async () => {
|
||||
const store = new Store(':memory:')
|
||||
const engine = new SyncEngine(
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type RpcGeneratedImageResponse,
|
||||
type RpcListDirectoryResponse,
|
||||
type RpcListCodexModelsResponse,
|
||||
type RpcArchiveCodexSessionResponse,
|
||||
type RpcListCursorModelsResponse,
|
||||
type RpcListOpencodeModelsResponse,
|
||||
type RpcListGrokModelsResponse,
|
||||
@@ -807,7 +808,8 @@ export class SyncEngine {
|
||||
resumeSessionId?: string,
|
||||
effort?: string,
|
||||
permissionMode?: PermissionMode,
|
||||
serviceTier?: string
|
||||
serviceTier?: string,
|
||||
existingSessionId?: string
|
||||
): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> {
|
||||
return await this.rpcGateway.spawnSession(
|
||||
machineId,
|
||||
@@ -821,7 +823,8 @@ export class SyncEngine {
|
||||
resumeSessionId,
|
||||
effort,
|
||||
permissionMode,
|
||||
serviceTier
|
||||
serviceTier,
|
||||
existingSessionId
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1274,9 +1277,12 @@ export class SyncEngine {
|
||||
}
|
||||
}
|
||||
|
||||
const preferredPermissionMode = opts?.permissionMode
|
||||
?? session.permissionMode
|
||||
?? session.metadata?.preferredPermissionMode
|
||||
const metadataPermissionMode = session.metadata?.preferredPermissionMode
|
||||
const preferredPermissionMode = metadataPermissionMode === 'yolo' && opts?.permissionMode === 'default'
|
||||
? metadataPermissionMode
|
||||
: opts?.permissionMode
|
||||
?? session.permissionMode
|
||||
?? metadataPermissionMode
|
||||
const spawnResult = await this.rpcGateway.spawnSession(
|
||||
targetMachine.id,
|
||||
directory,
|
||||
@@ -1289,7 +1295,8 @@ export class SyncEngine {
|
||||
resumeToken,
|
||||
session.effort ?? undefined,
|
||||
preferredPermissionMode,
|
||||
session.serviceTier ?? undefined
|
||||
session.serviceTier ?? undefined,
|
||||
access.sessionId
|
||||
)
|
||||
|
||||
if (spawnResult.type !== 'success') {
|
||||
@@ -1332,6 +1339,7 @@ export class SyncEngine {
|
||||
}
|
||||
}
|
||||
|
||||
this.sessionCache.markSessionActive(spawnResult.sessionId)
|
||||
return { type: 'success', sessionId: spawnResult.sessionId }
|
||||
}
|
||||
|
||||
@@ -1690,6 +1698,14 @@ export class SyncEngine {
|
||||
return await this.rpcGateway.listCodexModelsForMachine(machineId)
|
||||
}
|
||||
|
||||
async listCodexSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]) {
|
||||
return await this.rpcGateway.listCodexSessionsForMachine(machineId, cwd, sessionIds)
|
||||
}
|
||||
|
||||
async archiveCodexSessionForMachine(machineId: string, sessionId: string): Promise<RpcArchiveCodexSessionResponse> {
|
||||
return await this.rpcGateway.archiveCodexSessionForMachine(machineId, sessionId)
|
||||
}
|
||||
|
||||
async listCursorModelsForSession(sessionId: string): Promise<RpcListCursorModelsResponse> {
|
||||
return await this.rpcGateway.listCursorModelsForSession(sessionId)
|
||||
}
|
||||
|
||||
@@ -433,6 +433,51 @@ describe('Codex Desktop import routes', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('updates an existing forked import when syncing the original Codex session id', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-source-test-'))
|
||||
const store = new Store(':memory:')
|
||||
const codexSessionId = '12121212-1212-4121-8121-121212121212'
|
||||
process.env.CODEX_HOME = codexHome
|
||||
|
||||
try {
|
||||
createTranscript(codexHome, codexSessionId)
|
||||
|
||||
const first = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
namespace: 'default',
|
||||
getSyncEngine: () => null
|
||||
})
|
||||
expect(first.success).toBe(true)
|
||||
|
||||
const imported = store.sessions.getSessionsByNamespace('default')[0]
|
||||
expect(imported).toBeDefined()
|
||||
store.sessions.updateSessionMetadata(imported.id, {
|
||||
...(imported.metadata ?? {}),
|
||||
codexSessionId: 'fork-session-id',
|
||||
codexSourceSessionId: codexSessionId
|
||||
}, imported.metadataVersion, 'default')
|
||||
|
||||
const second = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
namespace: 'default',
|
||||
getSyncEngine: () => null
|
||||
})
|
||||
|
||||
expect(second.success).toBe(true)
|
||||
const sessions = store.sessions.getSessionsByNamespace('default')
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]?.metadata).toMatchObject({
|
||||
codexSessionId: 'fork-session-id',
|
||||
codexSourceSessionId: codexSessionId
|
||||
})
|
||||
} finally {
|
||||
store.close()
|
||||
rmSync(codexHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('deduplicates mirrored event_msg and response_item user messages', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-mirror-test-'))
|
||||
const store = new Store(':memory:')
|
||||
@@ -727,12 +772,6 @@ describe('Codex Desktop import routes', () => {
|
||||
}
|
||||
])
|
||||
|
||||
const app = createRoutesApp('default')
|
||||
const response = await app.request('/api/codex/sessions')
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json() as { sessions: Array<{ id: string; title: string }> }
|
||||
expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('new thread title')
|
||||
|
||||
const result = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
@@ -762,12 +801,6 @@ describe('Codex Desktop import routes', () => {
|
||||
try {
|
||||
createTranscript(codexHome, codexSessionId)
|
||||
|
||||
const app = createRoutesApp('default')
|
||||
const response = await app.request('/api/codex/sessions')
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json() as { sessions: Array<{ id: string; title: string }> }
|
||||
expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('normal user message')
|
||||
|
||||
const result = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
@@ -804,12 +837,6 @@ describe('Codex Desktop import routes', () => {
|
||||
}
|
||||
])
|
||||
|
||||
const app = createRoutesApp('default')
|
||||
const response = await app.request('/api/codex/sessions')
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json() as { sessions: Array<{ id: string; title: string }> }
|
||||
expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('normal user message')
|
||||
|
||||
const result = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
@@ -925,7 +952,7 @@ describe('Codex Desktop import routes', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an existing machineId when updating an imported transcript', async () => {
|
||||
it('does not append a Runner transcript to a session bound to another machine', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-machine-existing-test-'))
|
||||
const store = new Store(':memory:')
|
||||
const codexSessionId = '55555555-5555-4555-8555-555555555555'
|
||||
@@ -947,15 +974,21 @@ describe('Codex Desktop import routes', () => {
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
namespace: 'default',
|
||||
getSyncEngine: () => engine
|
||||
getSyncEngine: () => engine,
|
||||
machineId: 'machine-new'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const session = store.sessions.getSessionsByNamespace('default')[0]
|
||||
expect(session.metadata).toMatchObject({
|
||||
path: '/home/user/workspace/project',
|
||||
machineId: 'machine-existing'
|
||||
})
|
||||
const sessions = store.sessions.getSessionsByNamespace('default')
|
||||
expect(sessions).toHaveLength(2)
|
||||
expect(sessions.some((session) => (
|
||||
(session.metadata as Record<string, unknown> | null)?.path === '/home/user/workspace/project'
|
||||
&& (session.metadata as Record<string, unknown> | null)?.machineId === 'machine-new'
|
||||
))).toBe(true)
|
||||
expect(sessions.some((session) => (
|
||||
(session.metadata as Record<string, unknown> | null)?.path === '/home/user/workspace/project'
|
||||
&& (session.metadata as Record<string, unknown> | null)?.machineId === 'machine-existing'
|
||||
))).toBe(true)
|
||||
} finally {
|
||||
store.close()
|
||||
rmSync(codexHome, { recursive: true, force: true })
|
||||
@@ -981,13 +1014,68 @@ describe('Codex Desktop import routes', () => {
|
||||
const app = createRoutesApp('default')
|
||||
const response = await app.request('/api/codex/sessions')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.status).toBe(503)
|
||||
expect(await response.json()).toEqual({
|
||||
success: true,
|
||||
success: false,
|
||||
error: 'No online machine available for Codex history import',
|
||||
sessions: []
|
||||
})
|
||||
} finally {
|
||||
rmSync(codexHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fall back to another Runner when the requested machine is offline', async () => {
|
||||
const store = new Store(':memory:')
|
||||
let listCalls = 0
|
||||
const engine = {
|
||||
getOnlineMachinesByNamespace: () => [createMachine('online-machine', ['/tmp'])],
|
||||
listCodexSessionsForMachine: async () => {
|
||||
listCalls += 1
|
||||
return { success: true, sessions: [] }
|
||||
}
|
||||
} as unknown as SyncEngine
|
||||
const app = new Hono<WebAppEnv>()
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('namespace', 'default')
|
||||
await next()
|
||||
})
|
||||
app.route('/api', createCodexDesktopRoutes({ store, getSyncEngine: () => engine }))
|
||||
|
||||
try {
|
||||
const response = await app.request('/api/codex/sessions?machineId=offline-machine')
|
||||
expect(response.status).toBe(503)
|
||||
expect(listCalls).toBe(0)
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('treats source and fork ids as the same duplicate-sessions group', async () => {
|
||||
const app = new Hono<WebAppEnv>()
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('namespace', 'default')
|
||||
await next()
|
||||
})
|
||||
const store = new Store(':memory:')
|
||||
const forkSession = store.sessions.getOrCreateSession('fork-session-id', { codexSessionId: 'fork-session-id', codexSourceSessionId: 'original-session-id' }, {}, 'default')
|
||||
const dupSession = store.sessions.getOrCreateSession('dup-session-id', { codexSessionId: 'original-session-id' }, {}, 'default')
|
||||
app.route('/api', createCodexDesktopRoutes({
|
||||
store,
|
||||
getSyncEngine: () => null
|
||||
}))
|
||||
|
||||
const response = await app.request('/api/codex/duplicate-sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ sessionIds: ['original-session-id'] })
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json() as { success: true; duplicates: Array<{ codexSessionId: string; hapiSessionIds: string[] }> }
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.duplicates).toHaveLength(1)
|
||||
expect(body.duplicates[0]?.codexSessionId).toBe('original-session-id')
|
||||
expect(body.duplicates[0]?.hapiSessionIds.sort()).toEqual([dupSession.id, forkSession.id].sort())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ type ScriptLaunchResponse = {
|
||||
codexClientAvailable?: boolean
|
||||
syncedCount?: number
|
||||
sessionIds?: string[]
|
||||
hapiSessionIds?: string[]
|
||||
} | {
|
||||
success: false
|
||||
error: string
|
||||
@@ -39,6 +40,7 @@ type ScriptLaunchResponse = {
|
||||
codexClientAvailable?: boolean
|
||||
syncedCount?: number
|
||||
sessionIds?: string[]
|
||||
hapiSessionIds?: string[]
|
||||
}
|
||||
|
||||
type CodexDesktopStatus = {
|
||||
@@ -66,6 +68,12 @@ type CodexLocalSessionSummary = {
|
||||
type CodexLocalSessionsResponse = {
|
||||
success: true
|
||||
sessions: CodexLocalSessionSummary[]
|
||||
machineId?: string
|
||||
} | {
|
||||
success: false
|
||||
error: string
|
||||
sessions: []
|
||||
machineId?: string
|
||||
}
|
||||
|
||||
type CodexImportedMessageContent = {
|
||||
@@ -102,6 +110,7 @@ type CodexSessionIndexTitle = {
|
||||
threadName: string
|
||||
updatedAt: string
|
||||
}
|
||||
type RemoteCodexSession = CodexTranscriptImportData
|
||||
|
||||
type ImportCandidate = {
|
||||
sessionId: string
|
||||
@@ -117,6 +126,11 @@ type ImportTargetSelection = {
|
||||
|
||||
type SyncSessionRequestParseResult = {
|
||||
sessionIds: string[]
|
||||
cwd?: string | null
|
||||
machineId?: string | null
|
||||
model?: string | null
|
||||
modelReasoningEffort?: string | null
|
||||
yolo?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -894,10 +908,65 @@ function resolveImportMachineId(
|
||||
return machineIds.length === 1 ? machineIds[0] : undefined
|
||||
}
|
||||
|
||||
|
||||
function resolveCodexImportMachineId(
|
||||
cwd: string | null | undefined,
|
||||
namespace: string,
|
||||
engine: SyncEngine | null,
|
||||
requestedMachineId?: string | null
|
||||
): string | null {
|
||||
if (!engine) return null
|
||||
const onlineMachines = engine.getOnlineMachinesByNamespace(namespace)
|
||||
if (requestedMachineId) {
|
||||
return onlineMachines.some((machine) => machine.id === requestedMachineId)
|
||||
? requestedMachineId
|
||||
: null
|
||||
}
|
||||
if (cwd) {
|
||||
const resolved = resolveImportMachineId(cwd, namespace, engine)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
return onlineMachines.length === 1 ? onlineMachines[0].id : null
|
||||
}
|
||||
|
||||
function asRemoteCodexSessions(value: unknown, requireMessages: boolean): RemoteCodexSession[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((session): session is RemoteCodexSession => {
|
||||
const record = asRecord(session)
|
||||
return typeof record?.id === 'string'
|
||||
&& typeof record.title === 'string'
|
||||
&& typeof record.file === 'string'
|
||||
&& typeof record.modifiedAt === 'number'
|
||||
&& (!requireMessages || Array.isArray(record.messages))
|
||||
})
|
||||
}
|
||||
|
||||
async function listCodexSessionsViaMachine(options: {
|
||||
engine: SyncEngine | null
|
||||
namespace: string
|
||||
cwd?: string | null
|
||||
machineId?: string | null
|
||||
sessionIds?: string[]
|
||||
}): Promise<{ sessions: RemoteCodexSession[]; machineId?: string; error?: string }> {
|
||||
const machineId = resolveCodexImportMachineId(options.cwd, options.namespace, options.engine, options.machineId)
|
||||
if (!machineId || !options.engine) {
|
||||
return { sessions: [], error: 'No online machine available for Codex history import' }
|
||||
}
|
||||
const result = await options.engine.listCodexSessionsForMachine(machineId, options.cwd, options.sessionIds)
|
||||
if (!result || typeof result !== 'object') {
|
||||
return { sessions: [], machineId, error: 'Unexpected Codex sessions RPC response' }
|
||||
}
|
||||
if ((result as { success?: unknown }).success !== true) {
|
||||
return { sessions: [], machineId, error: typeof (result as { error?: unknown }).error === 'string' ? (result as { error: string }).error : 'Failed to list local Codex sessions' }
|
||||
}
|
||||
return { sessions: asRemoteCodexSessions((result as { sessions?: unknown }).sessions, Boolean(options.sessionIds?.length)), machineId }
|
||||
}
|
||||
|
||||
function buildImportedSessionMetadata(
|
||||
data: CodexTranscriptImportData,
|
||||
existingMetadata?: Record<string, unknown> | null,
|
||||
resolvedMachineId?: string
|
||||
resolvedMachineId?: string,
|
||||
permissionMode?: string
|
||||
): Record<string, unknown> {
|
||||
const now = Date.now()
|
||||
const path = data.cwd ?? (typeof existingMetadata?.path === 'string' ? existingMetadata.path : dirname(data.file))
|
||||
@@ -907,6 +976,9 @@ function buildImportedSessionMetadata(
|
||||
const machineId = typeof existingMetadata?.machineId === 'string'
|
||||
? existingMetadata.machineId
|
||||
: resolvedMachineId
|
||||
const currentCodexSessionId = typeof existingMetadata?.codexSessionId === 'string'
|
||||
? existingMetadata.codexSessionId
|
||||
: data.id
|
||||
|
||||
return {
|
||||
...(existingMetadata ?? {}),
|
||||
@@ -921,7 +993,11 @@ function buildImportedSessionMetadata(
|
||||
}
|
||||
: existingMetadata?.summary,
|
||||
flavor: 'codex',
|
||||
codexSessionId: data.id,
|
||||
codexSessionId: currentCodexSessionId,
|
||||
codexSourceSessionId: typeof existingMetadata?.codexSourceSessionId === 'string'
|
||||
? existingMetadata.codexSourceSessionId
|
||||
: data.id,
|
||||
...(permissionMode ? { preferredPermissionMode: permissionMode } : {}),
|
||||
...(machineId ? { machineId } : {}),
|
||||
lifecycleState: typeof existingMetadata?.lifecycleState === 'string'
|
||||
? existingMetadata.lifecycleState
|
||||
@@ -953,6 +1029,10 @@ function stableSerialize(value: unknown): string {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function normalizeComparableText(value: string): string {
|
||||
return value.replace(/\s+$/u, '')
|
||||
}
|
||||
|
||||
function normalizeComparableAgentData(value: unknown): unknown {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
@@ -979,7 +1059,7 @@ function normalizeComparableContent(content: unknown): string | null {
|
||||
}
|
||||
return stableSerialize({
|
||||
role: 'user',
|
||||
text: body.text
|
||||
text: normalizeComparableText(body.text)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1025,20 +1105,30 @@ function collectImportCandidates(
|
||||
}))
|
||||
}
|
||||
|
||||
function getCodexImportIds(metadata: Record<string, unknown> | null | undefined): string[] {
|
||||
return [metadata?.codexSessionId, metadata?.codexSourceSessionId]
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
}
|
||||
|
||||
function selectImportTargetSession(
|
||||
store: Store,
|
||||
candidates: ImportCandidate[],
|
||||
codexSessionId: string,
|
||||
importedComparableMessages: string[]
|
||||
importedComparableMessages: string[],
|
||||
sourceMachineId?: string | null
|
||||
): ImportTargetSelection {
|
||||
const relatedCandidates = candidates
|
||||
.filter((candidate) => candidate.metadata?.codexSessionId === codexSessionId)
|
||||
.filter((candidate) => (
|
||||
candidate.metadata?.codexSessionId === codexSessionId
|
||||
|| candidate.metadata?.codexSourceSessionId === codexSessionId
|
||||
))
|
||||
.filter((candidate) => (
|
||||
!sourceMachineId
|
||||
|| typeof candidate.metadata?.machineId !== 'string'
|
||||
|| candidate.metadata.machineId === sourceMachineId
|
||||
))
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
|
||||
if (relatedCandidates.some((candidate) => candidate.active)) {
|
||||
throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试')
|
||||
}
|
||||
|
||||
let bestSessionId: string | null = null
|
||||
let bestPrefixCount = -1
|
||||
|
||||
@@ -1088,18 +1178,17 @@ function listDuplicateCodexSessionGroups(
|
||||
|
||||
const groups = new Map<string, ImportCandidate[]>()
|
||||
for (const candidate of collectImportCandidates(store, namespace, getSyncEngine)) {
|
||||
const codexSessionId = typeof candidate.metadata?.codexSessionId === 'string'
|
||||
? candidate.metadata.codexSessionId
|
||||
: null
|
||||
if (!codexSessionId || !requestedSessionIds.has(codexSessionId)) {
|
||||
continue
|
||||
}
|
||||
for (const codexSessionId of getCodexImportIds(candidate.metadata)) {
|
||||
if (!requestedSessionIds.has(codexSessionId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = groups.get(codexSessionId)
|
||||
if (existing) {
|
||||
existing.push(candidate)
|
||||
} else {
|
||||
groups.set(codexSessionId, [candidate])
|
||||
const existing = groups.get(codexSessionId)
|
||||
if (existing) {
|
||||
existing.push(candidate)
|
||||
} else {
|
||||
groups.set(codexSessionId, [candidate])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1590,7 +1679,8 @@ function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult {
|
||||
return { sessionIds: [] }
|
||||
}
|
||||
|
||||
const rawSessionIds = (body as { sessionIds?: unknown }).sessionIds
|
||||
const bodyRecord = body as { sessionIds?: unknown; cwd?: unknown; machineId?: unknown; model?: unknown; modelReasoningEffort?: unknown; yolo?: unknown }
|
||||
const rawSessionIds = bodyRecord.sessionIds
|
||||
if (!Array.isArray(rawSessionIds)) {
|
||||
return { sessionIds: [], error: 'Invalid sessionIds' }
|
||||
}
|
||||
@@ -1606,8 +1696,18 @@ function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult {
|
||||
}
|
||||
}
|
||||
|
||||
const hasModel = Object.prototype.hasOwnProperty.call(bodyRecord, 'model')
|
||||
const hasModelReasoningEffort = Object.prototype.hasOwnProperty.call(bodyRecord, 'modelReasoningEffort')
|
||||
|
||||
// 中文注释:前端允许多选,这里按 Codex thread 去重,避免重复导入同一条本地 transcript。
|
||||
return { sessionIds: Array.from(new Set(sessionIds)) }
|
||||
return {
|
||||
sessionIds: Array.from(new Set(sessionIds)),
|
||||
cwd: typeof bodyRecord.cwd === 'string' && bodyRecord.cwd.trim() ? bodyRecord.cwd.trim() : null,
|
||||
machineId: typeof bodyRecord.machineId === 'string' && bodyRecord.machineId.trim() ? bodyRecord.machineId.trim() : null,
|
||||
model: hasModel ? (typeof bodyRecord.model === 'string' && bodyRecord.model.trim() ? bodyRecord.model.trim() : null) : undefined,
|
||||
modelReasoningEffort: hasModelReasoningEffort ? (typeof bodyRecord.modelReasoningEffort === 'string' && bodyRecord.modelReasoningEffort.trim() ? bodyRecord.modelReasoningEffort.trim() : null) : undefined,
|
||||
yolo: bodyRecord.yolo === true
|
||||
}
|
||||
}
|
||||
|
||||
function combineSyncOutputs(results: ScriptLaunchResponse[]): string | undefined {
|
||||
@@ -1645,6 +1745,12 @@ function createImportErrorResponse(
|
||||
}
|
||||
}
|
||||
|
||||
function parseImportedHapiSessionId(output?: string): string | null {
|
||||
if (!output) return null
|
||||
const match = /^Hapi session:\s*(.+)$/m.exec(output)
|
||||
return match?.[1]?.trim() || null
|
||||
}
|
||||
|
||||
function createImportSuccessResponse(
|
||||
codexSessionIds: string[],
|
||||
results: ScriptLaunchResponse[]
|
||||
@@ -1663,16 +1769,21 @@ function createImportSuccessResponse(
|
||||
cwd: workspace,
|
||||
output: combineSyncOutputs(results),
|
||||
sessionIds: codexSessionIds,
|
||||
hapiSessionIds: results.map((result) => parseImportedHapiSessionId(result.output)).filter((id): id is string => Boolean(id)),
|
||||
syncedCount: results.length
|
||||
}
|
||||
}
|
||||
|
||||
function importSingleCodexSession(options: {
|
||||
codexSessionId: string
|
||||
localSessionsById: Map<string, CodexLocalSessionSummary>
|
||||
localSessionsById: Map<string, CodexLocalSessionSummary | RemoteCodexSession>
|
||||
store: Store
|
||||
namespace: string
|
||||
getSyncEngine?: () => SyncEngine | null
|
||||
model?: string | null
|
||||
modelReasoningEffort?: string | null
|
||||
yolo?: boolean
|
||||
machineId?: string | null
|
||||
}): ScriptLaunchResponse {
|
||||
const summary = options.localSessionsById.get(options.codexSessionId)
|
||||
if (!summary) {
|
||||
@@ -1682,7 +1793,9 @@ function importSingleCodexSession(options: {
|
||||
}
|
||||
}
|
||||
|
||||
const transcript = parseCodexTranscriptImportData(summary)
|
||||
const transcript = 'messages' in summary && Array.isArray((summary as RemoteCodexSession).messages)
|
||||
? summary as RemoteCodexSession
|
||||
: parseCodexTranscriptImportData(summary)
|
||||
if (!transcript) {
|
||||
return {
|
||||
...createImportErrorResponse([options.codexSessionId], `Failed to parse Codex transcript: ${summary.file}`),
|
||||
@@ -1707,14 +1820,16 @@ function importSingleCodexSession(options: {
|
||||
options.store,
|
||||
candidates,
|
||||
options.codexSessionId,
|
||||
importedComparableMessages
|
||||
importedComparableMessages,
|
||||
options.machineId
|
||||
)
|
||||
const engine = options.getSyncEngine?.() ?? null
|
||||
const existingStored = target.sessionId ? options.store.sessions.getSessionByNamespace(target.sessionId, options.namespace) : null
|
||||
const metadata = buildImportedSessionMetadata(
|
||||
transcript,
|
||||
asRecord(existingStored?.metadata),
|
||||
resolveImportMachineId(transcript.cwd, options.namespace, engine)
|
||||
options.machineId ?? resolveImportMachineId(transcript.cwd, options.namespace, engine) ?? undefined,
|
||||
options.yolo ? 'yolo' : undefined
|
||||
)
|
||||
|
||||
let sessionId = existingStored?.id ?? null
|
||||
@@ -1725,8 +1840,11 @@ function importSingleCodexSession(options: {
|
||||
randomUUID(),
|
||||
metadata,
|
||||
{},
|
||||
options.namespace
|
||||
) ?? options.store.sessions.getOrCreateSession(randomUUID(), metadata, {}, options.namespace)
|
||||
options.namespace,
|
||||
options.model ?? undefined,
|
||||
undefined,
|
||||
options.modelReasoningEffort ?? undefined
|
||||
) ?? options.store.sessions.getOrCreateSession(randomUUID(), metadata, {}, options.namespace, options.model ?? undefined, undefined, options.modelReasoningEffort ?? undefined)
|
||||
sessionId = createdSession.id
|
||||
created = true
|
||||
} else if (existingStored) {
|
||||
@@ -1739,6 +1857,12 @@ function importSingleCodexSession(options: {
|
||||
if (updatedMetadata.result !== 'success') {
|
||||
throw new Error(`Failed to update metadata for Hapi session: ${existingStored.id}`)
|
||||
}
|
||||
if (options.model !== undefined) {
|
||||
options.store.sessions.setSessionModel(existingStored.id, options.model, options.namespace, { touchUpdatedAt: false })
|
||||
}
|
||||
if (options.modelReasoningEffort !== undefined) {
|
||||
options.store.sessions.setSessionModelReasoningEffort(existingStored.id, options.modelReasoningEffort, options.namespace, { touchUpdatedAt: false })
|
||||
}
|
||||
engine?.handleRealtimeEvent({ type: 'session-updated', sessionId: existingStored.id })
|
||||
}
|
||||
|
||||
@@ -1748,6 +1872,10 @@ function importSingleCodexSession(options: {
|
||||
|
||||
const comparablePrefixCount = sessionId ? target.comparablePrefixCount : 0
|
||||
const messagesToAppend = transcript.messages.slice(comparablePrefixCount)
|
||||
const targetIsActive = Boolean(candidates.find((candidate) => candidate.sessionId === sessionId)?.active)
|
||||
if (targetIsActive && messagesToAppend.length > 0) {
|
||||
throw new Error('当前会话正在运行且 Codex transcript 有新消息,停止或归档后再同步,避免消息顺序错乱')
|
||||
}
|
||||
const appendedMessages = messagesToAppend.map((message) => options.store.messages.addMessage(sessionId!, message))
|
||||
|
||||
// 中文注释:更新 Hapi 会话的 updatedAt,并在已有会话追加时广播新增消息,让当前打开的聊天页立刻显示客户端新增内容。
|
||||
@@ -1782,6 +1910,7 @@ function importSingleCodexSession(options: {
|
||||
cwd: getDirectImportRouteContext().workspace,
|
||||
output,
|
||||
sessionIds: [options.codexSessionId],
|
||||
hapiSessionIds: [sessionId],
|
||||
syncedCount: 1
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1798,13 +1927,18 @@ export async function importSelectedCodexSessions(options: {
|
||||
store: Store
|
||||
namespace: string
|
||||
getSyncEngine?: () => SyncEngine | null
|
||||
localSessions?: RemoteCodexSession[]
|
||||
model?: string | null
|
||||
modelReasoningEffort?: string | null
|
||||
yolo?: boolean
|
||||
machineId?: string | null
|
||||
}): Promise<ScriptLaunchResponse> {
|
||||
const codexSessionIds = options.codexSessionIds
|
||||
if (codexSessionIds.length === 0) {
|
||||
return createImportErrorResponse(codexSessionIds, NO_SYNC_SESSION_SELECTED_ERROR)
|
||||
}
|
||||
|
||||
const localSessionsById = new Map(listLocalCodexSessions().map((session) => [session.id, session]))
|
||||
const localSessionsById = new Map((options.localSessions ?? listLocalCodexSessions()).map((session) => [session.id, session]))
|
||||
const results: ScriptLaunchResponse[] = []
|
||||
for (const codexSessionId of codexSessionIds) {
|
||||
const result = importSingleCodexSession({
|
||||
@@ -1812,7 +1946,11 @@ export async function importSelectedCodexSessions(options: {
|
||||
localSessionsById,
|
||||
store: options.store,
|
||||
namespace: options.namespace,
|
||||
getSyncEngine: options.getSyncEngine
|
||||
getSyncEngine: options.getSyncEngine,
|
||||
model: options.model,
|
||||
modelReasoningEffort: options.modelReasoningEffort,
|
||||
yolo: options.yolo,
|
||||
machineId: options.machineId
|
||||
})
|
||||
results.push(result)
|
||||
|
||||
@@ -1854,13 +1992,59 @@ export function createCodexDesktopRoutes(options: {
|
||||
} satisfies CodexDesktopStatusResponse)
|
||||
})
|
||||
|
||||
app.get('/codex/sessions', (c) => {
|
||||
app.get('/codex/sessions', async (c) => {
|
||||
const cwd = c.req.query('cwd')?.trim() || null
|
||||
const machineId = c.req.query('machineId')?.trim() || null
|
||||
const remote = await listCodexSessionsViaMachine({
|
||||
engine: options.getSyncEngine(),
|
||||
namespace: c.get('namespace'),
|
||||
cwd,
|
||||
machineId
|
||||
})
|
||||
if (remote.error) {
|
||||
return c.json({
|
||||
success: false,
|
||||
error: remote.error,
|
||||
sessions: [],
|
||||
...(remote.machineId ? { machineId: remote.machineId } : {})
|
||||
} satisfies CodexLocalSessionsResponse, 503)
|
||||
}
|
||||
return c.json({
|
||||
success: true,
|
||||
sessions: listLocalCodexSessions()
|
||||
sessions: remote.sessions.map(({ messages: _messages, ...summary }) => summary),
|
||||
...(remote.machineId ? { machineId: remote.machineId } : {})
|
||||
} satisfies CodexLocalSessionsResponse)
|
||||
})
|
||||
|
||||
|
||||
app.post('/codex/archive-session', async (c) => {
|
||||
const body = await c.req.json().catch(() => null)
|
||||
const record = asRecord(body)
|
||||
const sessionId = typeof record?.sessionId === 'string' ? record.sessionId.trim() : ''
|
||||
const requestedMachineId = typeof record?.machineId === 'string' ? record.machineId.trim() : null
|
||||
if (!sessionId) {
|
||||
return c.json({ success: false, error: 'sessionId is required' }, 400)
|
||||
}
|
||||
|
||||
const engine = options.getSyncEngine()
|
||||
const machineId = resolveCodexImportMachineId(null, c.get('namespace'), engine, requestedMachineId)
|
||||
if (!engine || !machineId) {
|
||||
return c.json({ success: false, error: 'No online machine available for Codex history archive' }, 503)
|
||||
}
|
||||
|
||||
const result = await engine.archiveCodexSessionForMachine(machineId, sessionId)
|
||||
if (!result || typeof result !== 'object') {
|
||||
return c.json({ success: false, error: 'Unexpected Codex archive RPC response', machineId }, 500)
|
||||
}
|
||||
if ((result as { success?: unknown }).success !== true) {
|
||||
const error = typeof (result as { error?: unknown }).error === 'string'
|
||||
? (result as { error: string }).error
|
||||
: 'Failed to archive Codex session'
|
||||
return c.json({ success: false, error, machineId }, 500)
|
||||
}
|
||||
return c.json({ success: true, archivedPath: (result as { archivedPath: string }).archivedPath, machineId })
|
||||
})
|
||||
|
||||
app.post('/codex/sync-session', async (c) => {
|
||||
const codexStatus = getCodexDesktopStatus()
|
||||
const body = await c.req.json().catch(() => null)
|
||||
@@ -1877,12 +2061,34 @@ export function createCodexDesktopRoutes(options: {
|
||||
})
|
||||
}
|
||||
|
||||
// 中文注释:这里直接读取本地 transcript 写入 Hapi store,不再启动隐藏 codex resume 进程,避免漏导入客户端新增内容。
|
||||
// 中文注释:hub 可能运行在服务器上;Codex transcript 必须通过用户本机 runner RPC 读取,不能扫描服务器磁盘。
|
||||
const remote = await listCodexSessionsViaMachine({
|
||||
engine: options.getSyncEngine(),
|
||||
namespace: c.get('namespace'),
|
||||
cwd: parsed.cwd,
|
||||
machineId: parsed.machineId,
|
||||
sessionIds: parsed.sessionIds
|
||||
})
|
||||
if (remote.error) {
|
||||
const { workspace } = getDirectImportRouteContext()
|
||||
return c.json({
|
||||
success: false,
|
||||
error: remote.error,
|
||||
cwd: workspace,
|
||||
codexDesktopRunning: codexStatus.running,
|
||||
codexClientAvailable: codexStatus.clientAvailable
|
||||
})
|
||||
}
|
||||
const result = await importSelectedCodexSessions({
|
||||
codexSessionIds: parsed.sessionIds,
|
||||
store: options.store,
|
||||
namespace: c.get('namespace'),
|
||||
getSyncEngine: options.getSyncEngine
|
||||
getSyncEngine: options.getSyncEngine,
|
||||
localSessions: remote.sessions,
|
||||
machineId: remote.machineId ?? null,
|
||||
model: parsed.model,
|
||||
modelReasoningEffort: parsed.modelReasoningEffort,
|
||||
yolo: parsed.yolo
|
||||
})
|
||||
return c.json({
|
||||
...result,
|
||||
|
||||
Reference in New Issue
Block a user