Skip to content

Commit 8526a94

Browse files
authored
fix: bind imported Codex sessions to matching machine (tiann#886)
Co-authored-by: dzshzx <22311806+dzshzx@users.noreply.github.com>
1 parent 93d0041 commit 8526a94

2 files changed

Lines changed: 244 additions & 6 deletions

File tree

hub/src/web/routes/codexDesktop.test.ts

Lines changed: 180 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import { afterEach, describe, expect, it } from 'bun:test'
2+
import { randomUUID } from 'node:crypto'
23
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
34
import { tmpdir } from 'node:os'
45
import { join } from 'node:path'
56
import { Hono } from 'hono'
67
import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol'
78
import { Store } from '../../store'
8-
import type { SyncEngine } from '../../sync/syncEngine'
9+
import type { Machine, SyncEngine } from '../../sync/syncEngine'
910
import type { WebAppEnv } from '../middleware/auth'
1011
import { createCodexDesktopRoutes, importSelectedCodexSessions } from './codexDesktop'
1112

1213
const originalCodexHome = process.env.CODEX_HOME
1314

14-
function createTranscript(codexHome: string, sessionId: string): void {
15+
function createTranscript(codexHome: string, sessionId: string, cwd = 'C:\\work\\project'): void {
1516
const sessionDir = join(codexHome, 'sessions', '2026', '06', '04')
1617
mkdirSync(sessionDir, { recursive: true })
1718
const transcriptPath = join(sessionDir, `rollout-${sessionId}.jsonl`)
@@ -20,7 +21,7 @@ function createTranscript(codexHome: string, sessionId: string): void {
2021
type: 'session_meta',
2122
payload: {
2223
id: sessionId,
23-
cwd: 'C:\\work\\project',
24+
cwd,
2425
originator: 'codex_cli_rs',
2526
cli_version: '0.0.0-test'
2627
}
@@ -45,6 +46,50 @@ function createTranscript(codexHome: string, sessionId: string): void {
4546
writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8')
4647
}
4748

49+
function createMachine(id: string, workspaceRoots: string[], namespace = 'default'): Machine {
50+
return {
51+
id,
52+
namespace,
53+
seq: 0,
54+
createdAt: 0,
55+
updatedAt: 0,
56+
active: true,
57+
activeAt: 0,
58+
metadata: {
59+
host: id,
60+
platform: 'linux',
61+
happyCliVersion: '0.0.0-test',
62+
workspaceRoots
63+
},
64+
metadataVersion: 1,
65+
runnerState: null,
66+
runnerStateVersion: 1
67+
}
68+
}
69+
70+
function createImportSyncEngine(store: Store, machines: Machine[]): SyncEngine {
71+
return {
72+
getOnlineMachinesByNamespace: (namespace: string) => machines.filter((machine) => (
73+
machine.namespace === namespace && machine.active
74+
)),
75+
getSessionsByNamespace: (namespace: string) => (
76+
store.sessions.getSessionsByNamespace(namespace) as unknown as ReturnType<SyncEngine['getSessionsByNamespace']>
77+
),
78+
getOrCreateSession: (
79+
tag: string,
80+
metadata: unknown,
81+
agentState: unknown,
82+
namespace: string
83+
) => (
84+
store.sessions.getOrCreateSession(tag, metadata, agentState, namespace) as unknown as ReturnType<SyncEngine['getOrCreateSession']>
85+
),
86+
handleRealtimeEvent: () => {},
87+
recordSessionActivity: (sessionId: string, updatedAt: number) => {
88+
store.sessions.touchSessionUpdatedAt(sessionId, updatedAt, 'default')
89+
}
90+
} as unknown as SyncEngine
91+
}
92+
4893
function createRoutesApp(namespace: string): Hono<WebAppEnv> {
4994
const app = new Hono<WebAppEnv>()
5095
app.use('*', async (c, next) => {
@@ -118,6 +163,138 @@ describe('Codex Desktop import routes', () => {
118163
}
119164
})
120165

166+
it('binds imported transcripts to the unique online machine that owns the cwd', async () => {
167+
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-machine-test-'))
168+
const store = new Store(':memory:')
169+
const codexSessionId = '22222222-2222-4222-8222-222222222222'
170+
process.env.CODEX_HOME = codexHome
171+
172+
try {
173+
createTranscript(codexHome, codexSessionId, '/home/user/workspace/project')
174+
const engine = createImportSyncEngine(store, [
175+
createMachine('machine-1', ['/home/user/workspace']),
176+
createMachine('machine-2', ['/other/workspace'])
177+
])
178+
179+
const result = await importSelectedCodexSessions({
180+
codexSessionIds: [codexSessionId],
181+
store,
182+
namespace: 'default',
183+
getSyncEngine: () => engine
184+
})
185+
186+
expect(result.success).toBe(true)
187+
const session = store.sessions.getSessionsByNamespace('default')[0]
188+
expect(session.metadata).toMatchObject({
189+
path: '/home/user/workspace/project',
190+
machineId: 'machine-1'
191+
})
192+
} finally {
193+
store.close()
194+
rmSync(codexHome, { recursive: true, force: true })
195+
}
196+
})
197+
198+
it('does not bind imported transcripts when multiple online machines own the cwd', async () => {
199+
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-machine-ambiguous-test-'))
200+
const store = new Store(':memory:')
201+
const codexSessionId = '33333333-3333-4333-8333-333333333333'
202+
process.env.CODEX_HOME = codexHome
203+
204+
try {
205+
createTranscript(codexHome, codexSessionId, '/home/user/workspace/project')
206+
const engine = createImportSyncEngine(store, [
207+
createMachine('machine-1', ['/home/user/workspace']),
208+
createMachine('machine-2', ['/home/user/workspace/project'])
209+
])
210+
211+
const result = await importSelectedCodexSessions({
212+
codexSessionIds: [codexSessionId],
213+
store,
214+
namespace: 'default',
215+
getSyncEngine: () => engine
216+
})
217+
218+
expect(result.success).toBe(true)
219+
const session = store.sessions.getSessionsByNamespace('default')[0]
220+
expect(session.metadata).toMatchObject({
221+
path: '/home/user/workspace/project'
222+
})
223+
expect(session.metadata).not.toHaveProperty('machineId')
224+
} finally {
225+
store.close()
226+
rmSync(codexHome, { recursive: true, force: true })
227+
}
228+
})
229+
230+
it('does not bind imported transcripts when no online machine owns the cwd', async () => {
231+
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-machine-miss-test-'))
232+
const store = new Store(':memory:')
233+
const codexSessionId = '44444444-4444-4444-8444-444444444444'
234+
process.env.CODEX_HOME = codexHome
235+
236+
try {
237+
createTranscript(codexHome, codexSessionId, '/home/user/workspace/project')
238+
const engine = createImportSyncEngine(store, [
239+
createMachine('machine-1', ['/home/user/other'])
240+
])
241+
242+
const result = await importSelectedCodexSessions({
243+
codexSessionIds: [codexSessionId],
244+
store,
245+
namespace: 'default',
246+
getSyncEngine: () => engine
247+
})
248+
249+
expect(result.success).toBe(true)
250+
const session = store.sessions.getSessionsByNamespace('default')[0]
251+
expect(session.metadata).toMatchObject({
252+
path: '/home/user/workspace/project'
253+
})
254+
expect(session.metadata).not.toHaveProperty('machineId')
255+
} finally {
256+
store.close()
257+
rmSync(codexHome, { recursive: true, force: true })
258+
}
259+
})
260+
261+
it('keeps an existing machineId when updating an imported transcript', async () => {
262+
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-machine-existing-test-'))
263+
const store = new Store(':memory:')
264+
const codexSessionId = '55555555-5555-4555-8555-555555555555'
265+
process.env.CODEX_HOME = codexHome
266+
267+
try {
268+
createTranscript(codexHome, codexSessionId, '/home/user/workspace/project')
269+
store.sessions.getOrCreateSession(randomUUID(), {
270+
path: '/home/user/workspace/project',
271+
flavor: 'codex',
272+
codexSessionId,
273+
machineId: 'machine-existing'
274+
}, {}, 'default')
275+
const engine = createImportSyncEngine(store, [
276+
createMachine('machine-new', ['/home/user/workspace'])
277+
])
278+
279+
const result = await importSelectedCodexSessions({
280+
codexSessionIds: [codexSessionId],
281+
store,
282+
namespace: 'default',
283+
getSyncEngine: () => engine
284+
})
285+
286+
expect(result.success).toBe(true)
287+
const session = store.sessions.getSessionsByNamespace('default')[0]
288+
expect(session.metadata).toMatchObject({
289+
path: '/home/user/workspace/project',
290+
machineId: 'machine-existing'
291+
})
292+
} finally {
293+
store.close()
294+
rmSync(codexHome, { recursive: true, force: true })
295+
}
296+
})
297+
121298
it('rejects Codex transcript endpoints outside the default namespace', async () => {
122299
const app = createRoutesApp('team-a')
123300
const response = await app.request('/api/codex/sessions')

hub/src/web/routes/codexDesktop.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path'
55
import { homedir, hostname, platform } from 'node:os'
66
import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol'
77
import { Hono } from 'hono'
8-
import type { SyncEngine } from '../../sync/syncEngine'
8+
import type { Machine, SyncEngine } from '../../sync/syncEngine'
99
import type { Store, StoredMessage } from '../../store'
1010
import type { WebAppEnv } from '../middleware/auth'
1111

@@ -672,15 +672,71 @@ function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): Code
672672
}
673673
}
674674

675+
function normalizeComparablePath(pathValue: string, options?: { caseInsensitive?: boolean }): string {
676+
let normalized = pathValue.trim().replace(/\\/g, '/').replace(/\/+/g, '/')
677+
if (normalized.length > 1) {
678+
normalized = normalized.replace(/\/+$/, '')
679+
}
680+
return options?.caseInsensitive ? normalized.toLowerCase() : normalized
681+
}
682+
683+
function shouldCompareCaseInsensitive(...pathValues: string[]): boolean {
684+
return pathValues.some((pathValue) => /^[a-z]:[\\/]/i.test(pathValue) || pathValue.includes('\\'))
685+
}
686+
687+
function isPathInsideWorkspaceRoot(pathValue: string, rootValue: string): boolean {
688+
if (!pathValue.trim() || !rootValue.trim()) {
689+
return false
690+
}
691+
692+
const caseInsensitive = shouldCompareCaseInsensitive(pathValue, rootValue)
693+
const path = normalizeComparablePath(pathValue, { caseInsensitive })
694+
const root = normalizeComparablePath(rootValue, { caseInsensitive })
695+
if (!path || !root) {
696+
return false
697+
}
698+
if (path === root) {
699+
return true
700+
}
701+
if (root === '/') {
702+
return path.startsWith('/')
703+
}
704+
return path.startsWith(`${root}/`)
705+
}
706+
707+
function machineOwnsCodexCwd(machine: Machine, cwd: string): boolean {
708+
const workspaceRoots = machine.metadata?.workspaceRoots ?? []
709+
return workspaceRoots.some((workspaceRoot) => isPathInsideWorkspaceRoot(cwd, workspaceRoot))
710+
}
711+
712+
function resolveImportMachineId(
713+
cwd: string | null | undefined,
714+
namespace: string,
715+
engine: SyncEngine | null
716+
): string | undefined {
717+
if (!cwd || !engine) {
718+
return undefined
719+
}
720+
721+
const matches = engine.getOnlineMachinesByNamespace(namespace)
722+
.filter((machine) => machineOwnsCodexCwd(machine, cwd))
723+
const machineIds = Array.from(new Set(matches.map((machine) => machine.id)))
724+
return machineIds.length === 1 ? machineIds[0] : undefined
725+
}
726+
675727
function buildImportedSessionMetadata(
676728
data: CodexTranscriptImportData,
677-
existingMetadata?: Record<string, unknown> | null
729+
existingMetadata?: Record<string, unknown> | null,
730+
resolvedMachineId?: string
678731
): Record<string, unknown> {
679732
const now = Date.now()
680733
const path = data.cwd ?? (typeof existingMetadata?.path === 'string' ? existingMetadata.path : dirname(data.file))
681734
const host = typeof existingMetadata?.host === 'string' ? existingMetadata.host : (process.env.HAPI_HOSTNAME || hostname())
682735
const osValue = typeof existingMetadata?.os === 'string' ? existingMetadata.os : platform()
683736
const summaryText = data.lastUserMessage ?? data.title
737+
const machineId = typeof existingMetadata?.machineId === 'string'
738+
? existingMetadata.machineId
739+
: resolvedMachineId
684740

685741
return {
686742
...(existingMetadata ?? {}),
@@ -696,6 +752,7 @@ function buildImportedSessionMetadata(
696752
: existingMetadata?.summary,
697753
flavor: 'codex',
698754
codexSessionId: data.id,
755+
...(machineId ? { machineId } : {}),
699756
lifecycleState: typeof existingMetadata?.lifecycleState === 'string'
700757
? existingMetadata.lifecycleState
701758
: 'imported',
@@ -1484,7 +1541,11 @@ function importSingleCodexSession(options: {
14841541
)
14851542
const engine = options.getSyncEngine?.() ?? null
14861543
const existingStored = target.sessionId ? options.store.sessions.getSessionByNamespace(target.sessionId, options.namespace) : null
1487-
const metadata = buildImportedSessionMetadata(transcript, asRecord(existingStored?.metadata))
1544+
const metadata = buildImportedSessionMetadata(
1545+
transcript,
1546+
asRecord(existingStored?.metadata),
1547+
resolveImportMachineId(transcript.cwd, options.namespace, engine)
1548+
)
14881549

14891550
let sessionId = existingStored?.id ?? null
14901551
let created = false

0 commit comments

Comments
 (0)