(fix) bug remote workspace ssh..

This commit is contained in:
2026-09-03 15:39:19 +09:00
parent b051ac75cf
commit cb1106f319
11 changed files with 271 additions and 179 deletions
+65 -10
View File
@@ -22,6 +22,8 @@ const terminalStopTimers = new Map<string, NodeJS.Timeout>()
const intentionalTerminalStops = new Set<string>()
const nav2RuntimeObstacleNames = new Set<string>()
const remoteSshQueues = new Map<string, Promise<void>>()
const remoteGitModuleRoots = new Map<string, string>()
const remoteSshControlDirectory = resolve(homedir(), '.autonomy-studio', 'ssh', 'control')
const moduleCacheRoot = resolve(homedir(), '.caches')
const artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts')
const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime')
@@ -367,6 +369,19 @@ function normalizeWorldLayout(value: unknown): WorldLayout {
return { version: 1, bounds: { width: size(bounds.width, 10), depth: size(bounds.depth, 10) }, objects }
}
function isSavedMapCollisionObject(object: WorldLayoutObject): boolean {
return /^saved-(?:map-obstacle|3d-map-voxel)-/i.test(object.id)
}
// Saved-map collision layouts used to be copied into world-layout.json. Keep
// that file exclusively for the custom map and discard only those old generated
// objects while preserving any user-created objects that may coexist there.
function normalizeCustomWorldLayout(value: unknown): WorldLayout {
const layout = normalizeWorldLayout(value)
const objects = layout.objects.filter((object) => !isSavedMapCollisionObject(object))
return objects.length === layout.objects.length ? layout : { ...layout, objects }
}
function worldLayoutPath(workspaceRoot?: string | null): string {
const root = expandUserPath(workspaceRoot)
if (!root) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
@@ -527,7 +542,6 @@ async function generateWorkspaceMapCollisionLayout(workspaceRoot: string | null
const layout = createMapCollisionLayout(await readFile(yamlFilePath, 'utf8'), await readFile(pgmFilePath))
await mkdir(mapsDirectory, { recursive: true })
await writeJsonAtomically(resolve(mapsDirectory, savedMapCollisionLayoutFileName(yamlFileName)), layout)
await writeJsonAtomically(worldLayoutPath(root), layout)
return layout
}
@@ -681,7 +695,6 @@ async function generateWorkspacePointCloudCollisionLayout(
&& cached.source.modifiedAtMs === sourceStat.mtimeMs
&& cached.layout) {
const layout = normalizeWorldLayout(cached.layout)
await writeJsonAtomically(worldLayoutPath(root), layout)
return layout
}
} catch {
@@ -697,7 +710,6 @@ async function generateWorkspacePointCloudCollisionLayout(
source: { fileName: databaseFileName, size: sourceStat.size, modifiedAtMs: sourceStat.mtimeMs },
layout,
} satisfies PointCloudLayoutCache)
await writeJsonAtomically(worldLayoutPath(root), layout)
return layout
}
@@ -1365,7 +1377,7 @@ async function collectRemoteAvailableModules(
}
})
.sort((a, b) => a.sortOrder - b.sortOrder)
const output = await runSshOutput(targetInput ?? target, buildRemoteModuleProbeScript(target.workspaceRoot, moduleInputs), 15000)
const output = await runSshOutput(targetInput ?? target, buildRemoteModuleProbeScript(target.workspaceRoot, moduleInputs), 30_000)
const dockerStatuses = new Map<string, { running: boolean; exists: boolean; status: string }>()
const dockerStats = new Map<string, { cpu: number; memory: number }>()
const installedModules = new Map<string, { path: string; parameters: string[] }>()
@@ -1843,6 +1855,7 @@ async function queueRemoteSsh<T>(target: ReturnType<typeof normalizeRemoteTarget
async function runSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs = 120_000): Promise<string> {
const normalized = normalizeRemoteTarget(target)
await mkdir(remoteSshControlDirectory, { recursive: true })
return queueRemoteSsh(normalized, () => runCommandOutput(
'ssh',
[
@@ -1854,6 +1867,18 @@ async function runSshOutput(target: RemoteWorkspaceTarget, command: string, time
'StrictHostKeyChecking=accept-new',
'-o',
'ConnectTimeout=5',
'-o',
'PreferredAuthentications=publickey',
'-o',
'PasswordAuthentication=no',
'-o',
'KbdInteractiveAuthentication=no',
'-o',
'ControlMaster=auto',
'-o',
'ControlPersist=120',
'-o',
`ControlPath=${join(remoteSshControlDirectory, 'cm-%C')}`,
'-i',
normalized.identityFile,
`${normalized.user}@${normalized.host}`,
@@ -2123,10 +2148,10 @@ function buildRemoteModuleProbeScript(workspaceRoot: string, modules: CourseModu
'set +e',
`ROOT=${quoteShellSingle(workspaceRoot)}`,
...expandRemotePathVar('ROOT'),
'timeout 12s docker ps -a --format "DOCKER\\t{{.Names}}\\t{{.Status}}" 2>/dev/null || true',
'timeout 5s docker ps -a --format "DOCKER\\t{{.Names}}\\t{{.Status}}" 2>/dev/null || true',
// docker stats can stall on a remote Jetson. Module state must not wait
// behind a long statistics sample.
'timeout 3s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true',
'timeout 2s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true',
...moduleFolders.map((folderName) => {
const modulePath = remotePathJoin('$ROOT', folderName)
return [
@@ -2344,6 +2369,9 @@ async function resolveRemoteGitModuleRoot(
): Promise<{ target: ReturnType<typeof normalizeRemoteTarget>; moduleRoot: string }> {
const target = normalizeRemoteTarget(targetInput)
const targetFolderName = folderName?.trim() || moduleName
const cacheKey = `${target.user}@${target.host}:${target.port}:${target.workspaceRoot}:${targetFolderName}`
const cachedModuleRoot = remoteGitModuleRoots.get(cacheKey)
if (cachedModuleRoot) return { target, moduleRoot: cachedModuleRoot }
const output = await runSshOutput(target, [
'set -e',
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
@@ -2356,6 +2384,7 @@ async function resolveRemoteGitModuleRoot(
].join('\n'), 8000)
const moduleRoot = output.trim()
if (!moduleRoot) throw new Error('원격 Git 저장소를 찾을 수 없습니다.')
remoteGitModuleRoots.set(cacheKey, moduleRoot)
return { target, moduleRoot }
}
@@ -3347,6 +3376,7 @@ async function startVisualizationBridge(
environmentProfile?: EnvironmentProfileConfig | null,
rosNetwork?: RosNetworkConfig | null,
savedMapDatabasePath?: string | null,
savedMapWorldLayoutPath?: string | null,
): Promise<VisualizationBridgeStatus> {
const stack = normalizeVisualizationStack(stackInput ?? environmentProfile?.type?.toLowerCase())
const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack)
@@ -3358,6 +3388,25 @@ async function startVisualizationBridge(
const usesSlamBridge = stack === 'slam' || stack === 'slam3d' || stack === 'nav2'
const layoutWorkspaceRoot = usesSlamBridge ? expandUserPath(workspaceRoot) : null
if (layoutWorkspaceRoot) await mkdir(resolve(layoutWorkspaceRoot, 'maps'), { recursive: true })
const savedMapWorldLayoutFileName = savedMapWorldLayoutPath
? safeSavedMapFileName(basename(savedMapWorldLayoutPath))
: null
const requestedWorldLayoutPath = savedMapWorldLayoutFileName
? `/workspace/maps/${savedMapWorldLayoutFileName}`
: '/workspace/world-layout.json'
if (layoutWorkspaceRoot && !savedMapWorldLayoutFileName) {
const customLayoutPath = worldLayoutPath(layoutWorkspaceRoot)
try {
const currentLayout = await readFile(customLayoutPath, 'utf8')
const parsedLayout = JSON.parse(currentLayout)
const normalizedLayout = normalizeCustomWorldLayout(parsedLayout)
if (JSON.stringify(normalizedLayout) !== JSON.stringify(parsedLayout)) {
await writeJsonAtomically(customLayoutPath, normalizedLayout)
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
}
const requestedRosDomainId = String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0'
const requestedRosNetworkKey = rosNetworkEnabled(rosNetwork)
? `peer:${rosNetwork?.peer?.host?.trim() ?? ''}`
@@ -3384,10 +3433,16 @@ async function startVisualizationBridge(
const runningWorkspaceRoot = layoutWorkspaceRoot
? await readContainerMountSource(containerName, '/workspace')
: null
const runningWorldLayoutPath = layoutWorkspaceRoot
? await readContainerEnvironmentValue(containerName, 'WORLD_LAYOUT_PATH')
: null
if (response?.ok
&& (!runningRosDomainId || runningRosDomainId === requestedRosDomainId)
&& runningRosNetworkKey === requestedRosNetworkKey
&& (!layoutWorkspaceRoot || runningWorkspaceRoot === resolve(layoutWorkspaceRoot))) {
&& (!layoutWorkspaceRoot || (
runningWorkspaceRoot === resolve(layoutWorkspaceRoot)
&& runningWorldLayoutPath === requestedWorldLayoutPath
))) {
return readVisualizationBridgeStatus()
}
await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined)
@@ -3448,7 +3503,7 @@ async function startVisualizationBridge(
if (layoutWorkspaceRoot) {
args.push(
'-v', `${layoutWorkspaceRoot}:/workspace`,
'-e', 'WORLD_LAYOUT_PATH=/workspace/world-layout.json',
'-e', `WORLD_LAYOUT_PATH=${requestedWorldLayoutPath}`,
'-e', 'SAVED_MAPS_PATH=/workspace/maps',
'-e', 'RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db',
)
@@ -5804,7 +5859,7 @@ function registerLocalWorkspaceHandlers(): void {
ipcMain.handle('local-workspace:world-layout:read', async (_event, workspaceRoot?: string | null) => {
const targetPath = worldLayoutPath(workspaceRoot)
try {
return normalizeWorldLayout(JSON.parse(await readFile(targetPath, 'utf8')))
return normalizeCustomWorldLayout(JSON.parse(await readFile(targetPath, 'utf8')))
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') return defaultWorldLayout()
@@ -5902,7 +5957,7 @@ function registerLocalWorkspaceHandlers(): void {
return { ok: true }
})
ipcMain.handle('local-workspace:visualization:status', () => readVisualizationBridgeStatus())
ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath))
ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath, savedMapWorldLayoutPath))
ipcMain.handle('local-workspace:visualization:stop', (_event, environmentProfile?: EnvironmentProfileConfig | null) => stopVisualizationBridge(environmentProfile))
ipcMain.handle('local-workspace:git:status', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null) => readWorkspaceGitStatus(moduleName, workspaceRoot, folderName, selectedFilePath))
ipcMain.handle('local-workspace:git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl))