fix: enable added week deletion and preserve learning concepts when cloning courses

This commit is contained in:
2026-09-22 15:32:22 +09:00
parent 1d959e2c51
commit 33aac68f16
18 changed files with 633 additions and 61 deletions
+91 -32
View File
@@ -1,6 +1,8 @@
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
import { checkDockerStatus } from './dockerStatus'
import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime'
import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress'
import { moduleProgress, reportModuleProgress, createModuleProgressParser } from './moduleProgress'
import { bridgeArtifact, jetsonBridgeImage, localBridgeImage } from './bridgeArtifacts'
import type { WebContents } from 'electron'
import { spawn } from 'child_process'
@@ -1359,6 +1361,8 @@ function resolveNav2RuntimeImage(
folderName?: string | null,
rosParams = '',
): string {
const yahboomImage = yahboomRuntimeImage(folderName)
if (yahboomImage) return yahboomImage
// NAV2-MKMini normally targets the real platform image. Gazebo is an
// explicit local opt-in, so existing platform launches keep their image.
const mkminiGazebo = /lms-nav2-mkmini/i.test(folderName ?? '')
@@ -1699,7 +1703,24 @@ function remoteRepositoryUrl(repositoryUrl: string): string {
return repositoryUrl
}
function handleModuleOperation(channel: string, handler: Parameters<typeof ipcMain.handle>[1]): void {
ipcMain.handle(channel, (event, ...args) => {
const remote = channel.startsWith('remote-')
const folderName = String(args[2] || args[remote ? 1 : 0])
const send = (message: string): void => {
if (!event.sender.isDestroyed()) event.sender.send('module:progress', folderName, message)
}
return moduleProgress.run(send, () => visualizationProgress.run((message) => {
if (!message.startsWith('시각화')) send(message)
}, async () => {
send('모듈 작업 준비 중...')
try { return await handler(event, ...args) } finally { send('') }
}))
})
}
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000): Promise<string> {
const readProgress = createModuleProgressParser()
return new Promise<string>((resolvePromise, rejectPromise) => {
let output = ''
let terminated = false
@@ -1729,8 +1750,10 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
rejectPromise(new Error(`${command} ${args.join(' ')} timed out`))
}, timeoutMs)
child.stdout.on('data', (data: Buffer) => {
child.stdout.setEncoding('utf8')
child.stdout.on('data', (data: string) => {
output += data.toString()
readProgress(data.toString())
})
child.stderr.on('data', (data: Buffer) => {
output += data.toString()
@@ -2331,18 +2354,18 @@ async function syncRemoteSavedMapArtifacts(
'set -e',
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
`MAPS_DIRECTORY=${quoteShellSingle(mapsDirectory)}`,
'if [ ! -d "$MAPS_DIRECTORY" ] || [ ! -w "$MAPS_DIRECTORY" ]; then',
' if docker image inspect future-nav2:humble-jetson_orin >/dev/null 2>&1; then',
' IMAGE="future-nav2:humble-jetson_orin"',
' elif docker image inspect future-nav2:humble >/dev/null 2>&1; then',
' IMAGE="future-nav2:humble"',
' else',
' IMAGE=""',
' fi',
// A missing directory is normal on first install, not a permission failure.
'mkdir -p "$MAPS_DIRECTORY" 2>/dev/null || true',
'if [ ! -d "$MAPS_DIRECTORY" ] || [ ! -w "$MAPS_DIRECTORY" ] || [ ! -x "$MAPS_DIRECTORY" ]; then',
' IMAGE=""',
' for CANDIDATE in future-nav2:humble-yahboom future-nav2:humble-yahboom-agent future-nav2:humble-jetson_orin future-nav2:humble; do',
' if docker image inspect "$CANDIDATE" >/dev/null 2>&1; then IMAGE="$CANDIDATE"; break; fi',
' done',
' if [ -z "$IMAGE" ]; then echo "저장된 맵 디렉터리에 쓸 권한이 없고 권한 복구용 NAV2 이미지를 찾을 수 없습니다." >&2; exit 1; fi',
' docker run --rm --user 0:0 -v "$ROOT:/workspace" --entrypoint /bin/sh "$IMAGE" -lc "mkdir -p /workspace/maps && chown -R $(id -u):$(id -g) /workspace/maps"',
'fi',
'mkdir -p "$MAPS_DIRECTORY"',
'test -w "$MAPS_DIRECTORY" && test -x "$MAPS_DIRECTORY" || { echo "저장된 맵 디렉터리 권한 복구에 실패했습니다: $MAPS_DIRECTORY" >&2; exit 1; }',
...downloads.map((artifact) => (
`curl -fsSL --retry 2 ${quoteShellSingle(artifact.url)} -o ${quoteShellSingle(`${mapsDirectory}/${artifact.fileName}`)}`
)),
@@ -4573,6 +4596,7 @@ function buildNav2DockerRunScript(): string {
' )',
'else',
'case "$MODULE_FOLDER" in',
...yahboomModuleCases(),
' *lms-nav2-mkmini*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;',
' *lms-nav2-gazebo-simulator*) PACKAGE=nav2_turtlebot_simulator; LAUNCH=simulator.launch.py ;;',
' *lms-nav2-gazebo-vehicle*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;',
@@ -4609,6 +4633,7 @@ function buildNav2DockerRunScript(): string {
'fi',
'',
'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }',
...yahboomDevicePreflight(),
'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true',
'',
'args=(',
@@ -4625,6 +4650,7 @@ function buildNav2DockerRunScript(): string {
' -v "$(dirname "$0"):/runtime/nav2/docker:ro"',
' -v /etc/localtime:/etc/localtime:ro',
')',
'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi',
'if [[ "$GPU_ENABLED" == "true" || "$GPU_ENABLED" == "1" ]]; then',
' args+=(--gpus all -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics)',
'fi',
@@ -4667,6 +4693,7 @@ function buildNav2DockerBuildScript(): string {
' )',
'else',
'case "$MODULE_FOLDER" in',
...yahboomModuleCases(true),
' *lms-nav2-mkmini*) TARGET=vehicle_pkg ;;',
' *lms-nav2-gazebo-simulator*) TARGET=nav2_turtlebot_simulator ;;',
' *lms-nav2-gazebo-vehicle*) TARGET=vehicle_pkg ;;',
@@ -4897,7 +4924,7 @@ async function findNav2SimulatorRobotModel(containerName: string): Promise<strin
// Query Gazebo directly first. The ROS GetModelList service can be slow or
// unavailable while DDS participants are still discovering each other, even
// though the simulator itself is already able to accept a pose update.
const knownModels = ['mkmini_gazebo', 'turtlebot3_burger_3d_lidar', 'turtlebot3_burger']
const knownModels = ['mkmini_gazebo', 'turtlebot3_burger_3d_lidar', 'turtlebot3_burger', 'yahboom_gazebo']
for (const modelName of knownModels) {
const pose = await runCommandOutput(
'docker',
@@ -4923,7 +4950,7 @@ async function findNav2SimulatorRobotModel(containerName: string): Promise<strin
const modelList = output.match(/model_names=\[([^\]]*)\]/)?.[1] ?? ''
const modelNames = Array.from(modelList.matchAll(/'([^']+)'/g), (match) => match[1])
const robotModel = modelNames.find((modelName) => (
/turtlebot|burger|mkmini|mk-mini|robot/i.test(modelName) && !/world/i.test(modelName)
/turtlebot|burger|mkmini|mk-mini|yahboom|robot/i.test(modelName) && !/world/i.test(modelName)
))
if (!robotModel) throw new Error('Gazebo 로봇 모델을 찾을 수 없습니다.')
return robotModel
@@ -5017,6 +5044,29 @@ async function clearNav2RuntimeObstacles(): Promise<{ ok: boolean; message: stri
return { ok: true, message: '장애물을 제거했습니다.' }
}
async function resolveLocalModuleRuntime(
moduleRoot: string,
folderName: string,
rosParams: string,
runtimeImages?: RuntimeImageArtifact[] | null,
): Promise<{ image: string; artifacts: RuntimeImageArtifact[] | null | undefined }> {
if (yahboomSimulation(folderName, rosParams)) {
const artifact = runtimeImages?.find((item) =>
item.image?.trim() === defaultNav2GazeboRuntimeImage.image && item.url?.trim())
?? defaultNav2GazeboRuntimeImage
return { image: 'future-nav2-gazebo:humble', artifacts: [artifact] }
}
const image = await resolveCustomModuleRuntimeImage(
moduleRoot, resolveNav2RuntimeImage(runtimeImages, folderName, rosParams),
)
return {
image,
artifacts: yahboomRuntimeImage(folderName)
? yahboomRuntimeArtifacts(folderName, image, runtimeImages)
: requiredRuntimeImages(runtimeImages, image),
}
}
async function startWorkspaceModule(
moduleName: string,
workspaceRoot?: string | null,
@@ -5044,11 +5094,10 @@ async function startWorkspaceModule(
const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, rosDomainId)
const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot)
const scriptPath = resolveNav2DockerRunScript()
const runtimeImage = await resolveCustomModuleRuntimeImage(
moduleRoot,
resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams),
)
await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage))
const runtime = await resolveLocalModuleRuntime(moduleRoot, targetFolderName, rosParams, runtimeImages)
const runtimeImage = runtime.image
await prepareRuntimeImages(runtime.artifacts)
reportModuleProgress("모듈 실행 중...")
await runCommand(
'bash',
[
@@ -5072,6 +5121,7 @@ async function startWorkspaceModule(
}
await prepareRuntimeImages(runtimeImages)
reportModuleProgress("모듈 실행 중...")
await ensureStudioRuntimeAssets()
const scriptPath = resolveAutowareDockerRunScript()
if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`)
@@ -5129,11 +5179,10 @@ async function buildWorkspaceModule(
if (isNav2ModuleFolder(targetFolderName)) {
await ensureNav2RuntimeAssets()
const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot)
const runtimeImage = await resolveCustomModuleRuntimeImage(
moduleRoot,
resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams),
)
await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage))
const runtime = await resolveLocalModuleRuntime(moduleRoot, targetFolderName, rosParams, runtimeImages)
const runtimeImage = runtime.image
await prepareRuntimeImages(runtime.artifacts)
reportModuleProgress("모듈 빌드 중...")
await runCommand(
'bash',
[
@@ -5151,6 +5200,7 @@ async function buildWorkspaceModule(
}
await prepareRuntimeImages(runtimeImages)
reportModuleProgress("모듈 빌드 중...")
await runCommand(
'docker',
[
@@ -5173,6 +5223,7 @@ async function prepareRemoteRuntimeImages(
target: RemoteWorkspaceTarget,
runtimeImages?: RuntimeImageArtifact[] | null,
): Promise<void> {
reportModuleProgress('이미지 준비 대기 중...')
const artifacts = (runtimeImages ?? [])
.filter((image) => image?.image?.trim())
.map((image) => ({
@@ -5208,12 +5259,18 @@ async function prepareRemoteRuntimeImages(
' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "runtime-image.tar"',
' path = os.path.join(cache, name)',
' if os.path.exists(path): os.unlink(path)',
' print("LMS_MODULE_PROGRESS:이미지 다운로드 중...", flush=True)',
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
' print("LMS_MODULE_PROGRESS:이미지 파일 확인 중...", flush=True)',
' expected = (item.get("checksumSha256") or "").strip().lower()',
' if expected:',
' import hashlib',
' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()',
' digest = hashlib.sha256()',
' with open(path, "rb") as fp:',
' for chunk in iter(lambda: fp.read(1024 * 1024), b""): digest.update(chunk)',
' actual = digest.hexdigest().lower()',
' if actual != expected: raise SystemExit(f"runtime image checksum mismatch: {name}")',
' print("LMS_MODULE_PROGRESS:이미지 불러오는 중...", flush=True)',
' if path.lower().endswith(".tar.zst") or path.lower().endswith(".tzst"):',
' import shlex',
' subprocess.run(f"zstd -dc {shlex.quote(path)} | docker load", shell=True, check=True)',
@@ -5255,14 +5312,15 @@ async function startRemoteWorkspaceModule(
? await resolveRemoteCustomModuleRuntimeImage(
target,
targetFolderName,
await resolveRemoteNav2RuntimeImage(runtimeImages),
yahboomRuntimeImage(targetFolderName) ?? await resolveRemoteNav2RuntimeImage(runtimeImages),
)
: null
const runtimeRoot = nav2Module
? await ensureRemoteNav2RuntimeAssets(target)
: await ensureRemoteStudioRuntimeAssets(target)
const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId)
await prepareRemoteRuntimeImages(target, runtimeImages)
await prepareRemoteRuntimeImages(target, yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages))
reportModuleProgress("모듈 실행 중...")
const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000)
if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' }
if (currentStatus.trim()) {
@@ -5352,9 +5410,10 @@ async function buildRemoteWorkspaceModule(
const runtimeImage = await resolveRemoteCustomModuleRuntimeImage(
target,
targetFolderName,
await resolveRemoteNav2RuntimeImage(runtimeImages),
yahboomRuntimeImage(targetFolderName) ?? await resolveRemoteNav2RuntimeImage(runtimeImages),
)
await prepareRemoteRuntimeImages(target, runtimeImages)
await prepareRemoteRuntimeImages(target, yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages))
reportModuleProgress("모듈 빌드 중...")
await runSsh(target, [
'set -e',
`RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`,
@@ -6286,8 +6345,8 @@ function registerLocalWorkspaceHandlers(): void {
return importCustomWorkspaceModule(workspaceRoot, input)
})
ipcMain.handle('local-workspace:modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName))
ipcMain.handle('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
ipcMain.handle('local-workspace:modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams))
handleModuleOperation('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
handleModuleOperation('local-workspace:modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams))
ipcMain.handle('local-workspace:modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName))
ipcMain.handle('local-workspace:modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot))
ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers())
@@ -6349,8 +6408,8 @@ function registerLocalWorkspaceHandlers(): void {
if (!moduleName?.trim()) throw new Error('전송할 모듈명이 없습니다.')
return transferLocalWorkspaceModuleToRemote(target, sourceWorkspaceRoot, moduleName, folderName)
})
ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
ipcMain.handle('remote-workspace:modules:build', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildRemoteWorkspaceModule(target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages))
handleModuleOperation('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
handleModuleOperation('remote-workspace:modules:build', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildRemoteWorkspaceModule(target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages))
ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, moduleName, folderName))
ipcMain.handle('remote-workspace:modules:stop-many', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => stopRemoteWorkspaceModules(target, modules))
ipcMain.handle('remote-workspace:modules:reset-session', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => resetRemoteWorkspaceSession(target, modules))
@@ -6535,8 +6594,8 @@ app.whenReady().then(() => {
ipcMain.handle('modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules))
ipcMain.handle('modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
ipcMain.handle('modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName))
ipcMain.handle('modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
ipcMain.handle('modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams))
handleModuleOperation('modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
handleModuleOperation('modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams))
ipcMain.handle('modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName))
ipcMain.handle('modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot))
ipcMain.handle('modules:stop-running', () => stopRunningWorkspaceContainers())
+28
View File
@@ -0,0 +1,28 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { createRequire } from 'node:module'
const { moduleProgress, createModuleProgressParser } = createRequire(import.meta.url)('./moduleProgress.ts') as typeof import('./moduleProgress')
test('progress parser handles split lines and ignores normal command output', () => {
const messages: string[] = []
const parse = moduleProgress.run((message) => messages.push(message), createModuleProgressParser)
parse('ordinary output\nLMS_MODULE_PRO')
parse('GRESS:download\nLMS_MODULE_PROGRESS:load\n')
assert.deepEqual(messages, ['download', 'load'])
})
test('concurrent operations retain their own progress callbacks', () => {
const first: string[] = []
const second: string[] = []
const a = moduleProgress.run((message) => first.push(message), createModuleProgressParser)
const b = moduleProgress.run((message) => second.push(message), createModuleProgressParser)
b('LMS_MODULE_PROGRESS:build\n')
a('LMS_MODULE_PROGRESS:download\n')
assert.deepEqual(first, ['download'])
assert.deepEqual(second, ['build'])
})
test('commands outside a module operation do not report progress', () => {
assert.doesNotThrow(() => createModuleProgressParser()('LMS_MODULE_PROGRESS:ignored\n'))
})
+21
View File
@@ -0,0 +1,21 @@
import { AsyncLocalStorage } from 'node:async_hooks'
export const moduleProgress = new AsyncLocalStorage<(message: string) => void>()
export function reportModuleProgress(message: string): void {
moduleProgress.getStore()?.(message)
}
export function createModuleProgressParser(): (chunk: string) => void {
const report = moduleProgress.getStore()
let pending = ''
return (chunk) => {
if (!report) return
pending += chunk
const lines = pending.split('\n')
pending = lines.pop()!.slice(-4096)
for (const line of lines) {
if (line.startsWith('LMS_MODULE_PROGRESS:')) report(line.slice('LMS_MODULE_PROGRESS:'.length).trim())
}
}
}
+115
View File
@@ -0,0 +1,115 @@
import { strict as assert } from 'node:assert'
import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { test } from 'node:test'
import { createRequire } from 'node:module'
import ts from 'typescript'
const { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } = createRequire(import.meta.url)('./yahboomRuntime.ts') as typeof import('./yahboomRuntime')
test('simulation is an explicit opt-in and excludes physical device modules', () => {
for (const role of ['vehicle', 'slam', 'map', 'localization', 'planning', 'control']) {
const folder = `[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws`
assert.equal(yahboomSimulation(folder, ''), false)
assert.equal(yahboomSimulation(folder, 'sim_mode:=false'), false)
assert.equal(yahboomSimulation(folder, 'foo:=bar sim_mode:=true'), true)
}
for (const role of ['agent', 'camera']) {
assert.equal(yahboomSimulation(`[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws`, 'sim_mode:=true'), false)
}
assert.equal(yahboomSimulation('[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', 'sim_mode:=true'), false)
})
test('local simulation overrides ARM metadata and downloads only the PC image', async () => {
const source = ts.createSourceFile('index.ts', readFileSync(new URL('./index.ts', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true)
const declaration = source.statements.find((item): item is ts.FunctionDeclaration => ts.isFunctionDeclaration(item) && item.name?.text === 'resolveLocalModuleRuntime')
assert.ok(declaration)
const artifact = { image: 'future-nav2-gazebo:humble', url: 'https://example.com/gazebo.tar.zst' }
const resolver = new Function('yahboomSimulation', 'defaultNav2GazeboRuntimeImage', 'resolveCustomModuleRuntimeImage',
ts.transpile(declaration.getText(source), { target: ts.ScriptTarget.ES2022 }) + '; return resolveLocalModuleRuntime;'
)(yahboomSimulation, artifact, () => { throw new Error('ARM metadata must not override simulation') })
const result = await resolver('/module', '[lms-nav2-yahboom-vehicle]yahboom_vehicle_v1.0.0_ws', 'sim_mode:=true',
[{ image: 'future-nav2:humble-jetson_orin', url: 'https://example.com/arm.tar.zst' }])
assert.deepEqual(result, { image: artifact.image, artifacts: [artifact] })
})
test('remote Yahboom downloads only its own image and preserves explicit overrides', () => {
const configured = [{ image: 'future-nav2:humble-jetson_orin', url: 'https://example.com/jetson.tar.zst' }]
assert.equal(yahboomRuntimeArtifacts('[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', configured[0].image, configured), configured)
const folder = '[lms-nav2-yahboom-agent]yahboom_agent_v1.0.0_ws'
const image = 'future-nav2:humble-yahboom-agent'
const selected = yahboomRuntimeArtifacts(folder, image, configured)
assert.equal(selected?.length, 1)
assert.equal(selected?.[0].image, image)
assert.match(selected?.[0].checksumSha256 ?? '', /^[a-f0-9]{64}$/)
const override = { image, url: 'https://example.com/override.tar.zst' }
assert.deepEqual(yahboomRuntimeArtifacts(folder, image, [...configured, override]), [override])
assert.deepEqual(yahboomRuntimeArtifacts(folder, 'custom:tag', configured), [])
})
function runtimeScript(name: string): string {
const source = ts.createSourceFile('index.ts', readFileSync(new URL('./index.ts', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true)
const declaration = source.statements.find((item): item is ts.FunctionDeclaration => ts.isFunctionDeclaration(item) && item.name?.text === name)
assert.ok(declaration)
const code = ts.transpile(declaration.getText(source), { target: ts.ScriptTarget.ES2022 })
return new Function('yahboomModuleCases', 'yahboomDevicePreflight', `${code}; return ${name}();`)(yahboomModuleCases, yahboomDevicePreflight)
}
test('Yahboom image routing does not change MKMini, Gazebo or custom modules', () => {
assert.equal(yahboomRuntimeImage('[lms-nav2-yahboom-agent]yahboom_agent_v1.0.0_ws'), 'future-nav2:humble-yahboom-agent')
assert.equal(yahboomRuntimeImage('[lms-nav2-yahboom-slam]yahboom_slam_v1.0.0_ws'), 'future-nav2:humble-yahboom')
for (const name of ['[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', '[lms-nav2-gazebo-map]nav2-map_v1.0.0_ws', '[lms-nav2-custom]yahboom_agent_v1.0.0_ws', '[lms-nav2-yahboom-agent]other_v1.0.0_ws']) {
assert.equal(yahboomRuntimeImage(name), null)
}
})
test('all eight templates map to their own package in both runtime scripts', () => {
for (const build of [false, true]) {
for (const role of ['agent', 'vehicle', 'slam', 'map', 'localization', 'planning', 'control', 'camera']) {
const script = ['case "$1" in', ...yahboomModuleCases(build), 'esac', build ? 'printf "%s" "$TARGET"' : 'printf "%s %s" "$PACKAGE" "$LAUNCH"'].join('\n')
const result = spawnSync('bash', ['-c', script, '_', `[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws`], { encoding: 'utf8' })
assert.equal(result.status, 0, result.stderr)
assert.equal(result.stdout, build ? `yahboom_${role}` : `yahboom_${role} ${role}.launch.py`)
}
}
})
test('generated build and run shell scripts are syntactically valid', () => {
for (const name of ['buildNav2DockerRunScript', 'buildNav2DockerBuildScript']) {
const result = spawnSync('bash', ['-n'], { input: runtimeScript(name), encoding: 'utf8' })
assert.equal(result.status, 0, result.stderr)
}
})
test('invalid hardware path fails before Docker is called; MKMini still launches without extra device', () => {
const root = mkdtempSync(join(tmpdir(), 'yahboom-runtime-'))
try {
const bin = join(root, 'bin')
mkdirSync(bin)
writeFileSync(join(bin, 'docker'), '#!/bin/bash\nprintf "%s\\n" "$@"\n', { mode: 0o755 })
const script = join(root, 'docker_run_nav2.sh')
writeFileSync(script, runtimeScript('buildNav2DockerRunScript'))
for (const [folder, parameter] of [
['[lms-nav2-yahboom-agent]yahboom_agent_v1.0.0_ws', 'serial_device:=/etc/passwd'],
['[lms-nav2-yahboom-camera]yahboom_camera_v1.0.0_ws', 'video_device:=/dev/../etc/passwd'],
['[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', ''],
]) {
mkdirSync(join(root, folder, 'install'), { recursive: true })
writeFileSync(join(root, folder, 'install/setup.bash'), '')
const result = spawnSync('bash', [script, 'test-container', root, folder, 'test-image', '77', 'false', parameter], {
encoding: 'utf8', env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, DISPLAY: '', FASTDDS_CONFIG_PATH: '' },
})
if (parameter) {
assert.equal(result.status, 64)
assert.equal(result.stdout, '')
} else {
assert.equal(result.status, 0, result.stderr)
assert.match(result.stdout, /vehicle_pkg\nvehicle.launch.py/)
assert.doesNotMatch(result.stdout, /ttyUSB|video0|privileged/)
}
}
} finally {
rmSync(root, { recursive: true, force: true })
}
})
+70
View File
@@ -0,0 +1,70 @@
const roles = ['agent', 'vehicle', 'slam', 'map', 'localization', 'planning', 'control', 'camera'] as const
export function yahboomSimulation(folderName: string | null | undefined, rosParams: string): boolean {
return Boolean(yahboomRuntimeImage(folderName))
&& !/yahboom-(agent|camera)\]/.test(folderName ?? '')
&& /(?:^|\s)sim_mode:=true(?:\s|$)/.test(rosParams)
}
type RuntimeArtifact = {
image?: string | null
url?: string | null
fileName?: string | null
checksumSha256?: string | null
required?: boolean | null
}
const artifactRoot = 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/nav2/yahboom/arm64'
const artifacts: RuntimeArtifact[] = [
['future-nav2:humble-yahboom', 'future-nav2-humble-yahboom.tar.zst', 'b2f168c45b961ab96badeeb7df68d1c997996c75dcb5e0cc677b0b6669b1ffd2'],
['future-nav2:humble-yahboom-agent', 'future-nav2-humble-yahboom-agent.tar.zst', 'e30404506c320af1d53c8733177eaf27997dcb2864019b8fcc6888874f02d191'],
].map(([image, fileName, checksumSha256]) => ({
image, fileName, checksumSha256, required: true,
url: `${artifactRoot}/${checksumSha256.slice(0, 16)}/${fileName}`,
}))
export function yahboomRuntimeArtifacts(
folderName: string,
runtimeImage: string | null,
configured?: RuntimeArtifact[] | null,
): RuntimeArtifact[] | null | undefined {
if (!yahboomRuntimeImage(folderName)) return configured
const selected = configured?.find((item) => item.image?.trim() === runtimeImage && item.url?.trim())
?? artifacts.find((item) => item.image === runtimeImage)
// Yahboom must not download unrelated Jetson images from an existing profile.
return selected ? [selected] : []
}
export function yahboomRuntimeImage(folderName?: string | null): string | null {
const role = folderName?.match(/^\[lms-nav2-yahboom-([a-z]+)\]yahboom_\1_v[^/]+_ws$/)?.[1]
if (!roles.some((value) => value === role)) return null
return role === 'agent' ? 'future-nav2:humble-yahboom-agent' : 'future-nav2:humble-yahboom'
}
export function yahboomModuleCases(build = false): string[] {
return roles.map((role) => (
` "[lms-nav2-yahboom-${role}]yahboom_${role}_v"*_ws) ${build ? `TARGET=yahboom_${role}` : `PACKAGE=yahboom_${role}; LAUNCH=${role}.launch.py`} ;;`
))
}
// Keep hardware exposure limited to the two hardware-facing Yahboom templates.
export function yahboomDevicePreflight(): string[] {
return [
'YAHBOOM_DEVICE=""',
'case "$MODULE_FOLDER" in',
' "[lms-nav2-yahboom-agent]yahboom_agent_v"*_ws)',
' YAHBOOM_DEVICE=/dev/ttyUSB0',
' for arg in "$@"; do case "$arg" in serial_device:=*) YAHBOOM_DEVICE=${arg#serial_device:=} ;; esac; done',
' case "$YAHBOOM_DEVICE" in /dev/ttyUSB*|/dev/ttyACM*|/dev/serial/by-id/*) ;; *) echo "Invalid Yahboom serial device" >&2; exit 64 ;; esac',
' ;;',
' "[lms-nav2-yahboom-camera]yahboom_camera_v"*_ws)',
' YAHBOOM_DEVICE=/dev/video0',
' for arg in "$@"; do case "$arg" in video_device:=*) YAHBOOM_DEVICE=${arg#video_device:=} ;; esac; done',
' [[ "$YAHBOOM_DEVICE" =~ ^/dev/video[0-9]+$ ]] || { echo "Invalid Yahboom camera device" >&2; exit 64; }',
' ;;',
'esac',
'if [ -n "$YAHBOOM_DEVICE" ]; then',
' [[ "$YAHBOOM_DEVICE" != *".."* ]] && [ -c "$YAHBOOM_DEVICE" ] || { echo "Yahboom device not found: $YAHBOOM_DEVICE" >&2; exit 1; }',
'fi',
]
}