import { createHash, randomUUID } from 'node:crypto'; import { readFile, realpath } from 'node:fs/promises'; import path from 'node:path'; import { callSelectedNvim } from './nvim_ui_bridge.mjs'; const POLL_MS = 100; const state = { wantedGeneration: 0, active: null, activeControl: null, last: null, queue: Promise.resolve() }; function delay(milliseconds) { return new Promise(resolve => setTimeout(resolve, milliseconds)); } function errorText(error) { return error instanceof Error ? error.message : String(error); } async function readJson(file) { return JSON.parse(await readFile(file, 'utf8')); } function runtimeRoot() { return process.env.XDG_RUNTIME_DIR ?? '/run/user/1000'; } function selectionRoot() { return process.env.MCP_CONTAINER_SELECTION_ROOT ?? path.join(runtimeRoot(), 'stem/mcp-selected'); } async function selectedBinding() { const selectedDirectory = await realpath(path.join(selectionRoot(), 'current')); const [metadata, socketPath] = await Promise.all([ readJson(path.join(selectedDirectory, 'selection.json')), realpath(path.join(selectedDirectory, 'n.sock')) ]); return { metadata, socketPath, epoch: `${metadata.container_id ?? 'unknown'}:${socketPath}` }; } async function assertBindingCurrent(expected) { const current = await selectedBinding(); if (current.epoch !== expected.epoch) { throw new Error('Cel MCP zmienił się podczas replay; wynik starego kontenera został odrzucony.'); } } export function assertExpectedBinding(binding, expected) { // Browser activation follows the currently selected teaching session and // remains backward compatible. CLI/orchestrator callers pin all fields. if (expected == null) return; if (typeof expected !== 'object') throw new Error('expected_binding musi być obiektem.'); for (const field of ['container_id', 'instance', 'profile', 'target']) { const wanted = typeof expected[field] === 'string' ? expected[field] : ''; const actual = typeof binding.metadata[field] === 'string' ? binding.metadata[field] : ''; if (!wanted || wanted !== actual) { throw new Error(`Cel MCP zmienił się przed replay (${field}: ${actual || '?'} != ${wanted || '?'}).`); } } } export function assertExpectedIdentity(actual, expected) { // Browser-only calls may omit the pin. Orchestrators send all fields so a // card hot reload cannot silently replay a recipe from another revision. if (expected == null) return; if (typeof expected !== 'object') throw new Error('expected_identity musi być obiektem.'); for (const field of ['id', 'uuid', 'version', 'source_sha256']) { const wanted = typeof expected[field] === 'string' ? expected[field] : ''; const observed = typeof actual[field] === 'string' ? actual[field] : ''; if (!wanted || wanted !== observed) { throw new Error(`Karta zmieniła się przed replay (${field}: ${observed || '?'} != ${wanted || '?'}).`); } } } async function readCardSnapshot(cardFile) { const source = await readFile(cardFile); const document = JSON.parse(source.toString('utf8')); const card = document.card ?? {}; return { document, identity: { id: String(card.id ?? ''), uuid: String(card.uuid ?? ''), version: String(card.version ?? ''), source_sha256: createHash('sha256').update(source).digest('hex') } }; } async function sha256(file) { const hash = createHash('sha256'); hash.update(await readFile(file)); return hash.digest('hex'); } async function gitBlobSha1(file) { const content = await readFile(file); const hash = createHash('sha1'); hash.update(`blob ${content.length}\0`); hash.update(content); return hash.digest('hex'); } function hostPath(repositoryRoot, containerPath) { if (!path.isAbsolute(containerPath)) { const resolved = path.resolve(repositoryRoot, containerPath); if (resolved === repositoryRoot || resolved.startsWith(`${repositoryRoot}${path.sep}`)) { return resolved; } } if (containerPath === '/workspace') return repositoryRoot; if (containerPath.startsWith('/workspace/')) { return path.join(repositoryRoot, containerPath.slice('/workspace/'.length)); } throw new Error(`Ścieżka spoza /workspace nie jest dozwolona: ${containerPath}`); } async function nvimLua(source, args = [], binding = null) { return callSelectedNvim( 'nvim_exec_lua', [source, args], binding ? { socketPath: binding.socketPath } : {} ); } async function callPinnedNvim(binding, method, parameters = []) { return callSelectedNvim(method, parameters, { socketPath: binding.socketPath }); } async function sendGdb(command, binding) { const available = await callPinnedNvim(binding, 'nvim_call_function', [ 'exists', ['*TermDebugSendCommand'] ]); if (available !== 1) { throw new Error('Termdebug nie udostępnia TermDebugSendCommand'); } return callPinnedNvim(binding, 'nvim_call_function', [ 'TermDebugSendCommand', [command] ]); } async function interruptGdb(binding) { return nvimLua(` for _, buffer in ipairs(vim.api.nvim_list_bufs()) do local name = vim.api.nvim_buf_get_name(buffer) if vim.bo[buffer].buftype == 'terminal' and name:find('gdb%-multiarch') then local channel = vim.bo[buffer].channel if channel and channel > 0 then vim.api.nvim_chan_send(channel, string.char(3)) return true end end end return false `, [], binding); } async function gdbTerminalRunning(binding) { return nvimLua(` for _, buffer in ipairs(vim.api.nvim_list_bufs()) do local name = vim.api.nvim_buf_get_name(buffer) if vim.bo[buffer].buftype == 'terminal' and name:find('gdb%-multiarch') then local channel = vim.bo[buffer].channel if channel and channel > 0 and vim.fn.jobwait({ channel }, 0)[1] == -1 then return true end end end return false `, [], binding); } async function cleanupDeadGdbBuffers(binding) { return nvimLua(` local dead = {} for _, buffer in ipairs(vim.api.nvim_list_bufs()) do local name = vim.api.nvim_buf_get_name(buffer) if vim.bo[buffer].buftype == 'terminal' and name:find('gdb%-multiarch') then local channel = vim.bo[buffer].channel if not channel or channel <= 0 or vim.fn.jobwait({ channel }, 0)[1] ~= -1 then table.insert(dead, buffer) end end end for _, buffer in ipairs(dead) do for _, window in ipairs(vim.fn.win_findbuf(buffer)) do if vim.api.nvim_win_is_valid(window) and #vim.api.nvim_list_wins() > 1 then pcall(vim.api.nvim_win_close, window, true) end end if vim.api.nvim_buf_is_valid(buffer) then pcall(vim.api.nvim_buf_delete, buffer, { force = true }) end end return #dead `, [], binding); } async function ensureTermdebug(binding) { const probe = gdbTerminalRunning(binding); let running; try { running = await Promise.race([ probe, delay(500).then(() => { throw new Error('timeout'); }) ]); } catch { // Termdebug cleanup sometimes leaves a hit-enter prompt which blocks an // RPC expression while Neovim still accepts input events. await callPinnedNvim(binding, 'nvim_input', ['']).catch(() => undefined); running = await probe; } if (running) { await cleanupDeadGdbBuffers(binding); await callPinnedNvim(binding, 'nvim_command', ['silent! StudentLayoutFit']).catch(() => undefined); return; } // A failed Termdebug job can leave Neovim at a hit-enter prompt. Feeding // Enter only in the dead-GDB branch clears it before the recovery command. await callPinnedNvim(binding, 'nvim_input', ['']).catch(() => undefined); await cleanupDeadGdbBuffers(binding); await callPinnedNvim(binding, 'nvim_command', ['silent! StudentTermdebug']); const deadline = Date.now() + 8000; while (Date.now() < deadline) { if (await gdbTerminalRunning(binding)) { // TermdebugStartPost queues target/directory/dashboard setup. await delay(1200); await cleanupDeadGdbBuffers(binding); await callPinnedNvim(binding, 'nvim_command', ['silent! StudentLayoutFit']).catch(() => undefined); return; } await delay(100); } throw new Error('Nie udało się automatycznie odtworzyć procesu GDB w Termdebug.'); } async function assertEditorReady(repositoryRoot, artifact, binding) { const modified = await nvimLua(` local result = {} for _, buffer in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_loaded(buffer) and vim.bo[buffer].modified then table.insert(result, vim.api.nvim_buf_get_name(buffer)) end end return result `, [], binding); if (Array.isArray(modified) && modified.some(name => name.endsWith(artifact.source))) { throw new Error('Bufor Task04 ma niezapisane zmiany; zapis lub przebudowa są wymagane przed replay.'); } const elf = await nvimLua('return vim.env.ELF_FILE or ""', [], binding); if (typeof elf !== 'string' || !elf) throw new Error('Neovim nie ma ELF_FILE bieżącej sesji.'); const elfOnHost = hostPath(repositoryRoot, elf); if (artifact.source_git_blob) { const sourceOnHost = hostPath(repositoryRoot, artifact.source); const actualSourceBlob = await gitBlobSha1(sourceOnHost); if (actualSourceBlob !== artifact.source_git_blob) { throw new Error('Źródło Task04 różni się od wersji użytej do pomiaru karty; przebuduj kartę i snapshoty.'); } } if (artifact.hazard3_image_sha256 && binding.metadata.profile === 'hazard3-sim') { const imageOnHost = hostPath(repositoryRoot, artifact.hazard3_image); const actual = await sha256(imageOnHost); if (actual !== artifact.hazard3_image_sha256) { throw new Error('Obraz wykonywalny Hazard3 różni się od obrazu użytego do pomiaru karty; zregeneruj snapshoty.'); } } return { elf, elfOnHost }; } async function waitForResult(resultFile, operationId, timeoutMs, binding) { const deadline = Date.now() + timeoutMs; let latest = null; while (Date.now() < deadline) { try { const current = await readJson(resultFile); if (current.operation_id === operationId) { latest = current; state.active = current; if (current.status === 'ready' || current.status === 'failed') return current; } } catch { // GDB writes atomically; absence before the first phase is expected. } await delay(POLL_MS); } await interruptGdb(binding).catch(() => undefined); await delay(200); throw new Error(`Timeout replay; ostatni stan: ${latest?.status ?? 'brak odpowiedzi GDB'}`); } async function runActivation({ repositoryRoot, cardFile, snapshotRef, generation, expectedBinding, expectedIdentity }) { if (generation !== state.wantedGeneration) { return { status: 'cancelled', generation, snapshot_ref: snapshotRef, message: 'Zastąpione nowszym krokiem.' }; } const [cardSnapshot, binding] = await Promise.all([readCardSnapshot(cardFile), selectedBinding()]); const card = cardSnapshot.document; assertExpectedIdentity(cardSnapshot.identity, expectedIdentity); assertExpectedBinding(binding, expectedBinding); const selection = binding.metadata; const registry = card.debug_checkpoints; const recipe = registry?.items?.[snapshotRef]; if (!recipe) throw new Error(`Brak recepty checkpointu: ${snapshotRef}`); if (!registry.targets?.[selection.profile]) { throw new Error(`Karta nie ma adaptera replay dla profilu ${selection.profile ?? '?'}.`); } await assertBindingCurrent(binding); await ensureTermdebug(binding); await assertEditorReady(repositoryRoot, registry.artifact, binding); const operationId = randomUUID(); const payload = { operation_id: operationId, generation, snapshot_ref: snapshotRef, profile: selection.profile, semantics: registry.semantics, stop: recipe.stop, verify: recipe.verify ?? { expressions: [] } }; state.active = { status: 'replaying', phase: 'dispatch', operation_id: operationId, generation, snapshot_ref: snapshotRef, profile: selection.profile }; state.activeControl = { generation, binding }; await sendGdb('source /workspace/tools/gdb/stem_checkpoint.py', binding); const token = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); await sendGdb(`stem-checkpoint-activate ${token}`, binding); const resultFile = path.join( repositoryRoot, '.stem/instances', selection.instance, 'gdb-sync.json.checkpoint.json' ); const result = await waitForResult( resultFile, operationId, selection.profile === 'rp2350' ? 45000 : 20000, binding ); if (result.status !== 'ready') { throw new Error(result.message ?? `Replay ${snapshotRef} nie osiągnął stanu ready.`); } await assertBindingCurrent(binding); state.last = result; state.active = null; state.activeControl = null; await callPinnedNvim(binding, 'nvim_command', ['silent! StudentViewReset']).catch(() => undefined); await callPinnedNvim(binding, 'nvim_command', ['redraw!']).catch(() => undefined); return result; } export function checkpointStatus() { return { active: state.active, last: state.last, wanted_generation: state.wantedGeneration }; } export function activateCheckpoint({ repositoryRoot, cardFile, snapshotRef, generation, expectedBinding, expectedIdentity }) { if (typeof snapshotRef !== 'string' || !/^task04\.[a-z0-9.-]+$/.test(snapshotRef)) { return Promise.reject(new Error('Niepoprawny snapshot_ref.')); } if (generation != null && (!Number.isSafeInteger(generation) || generation <= 0)) { return Promise.reject(new Error('generation musi być dodatnią, bezpieczną liczbą całkowitą.')); } const nextGeneration = Number.isSafeInteger(generation) && generation > 0 ? generation : state.wantedGeneration + 1; state.wantedGeneration = Math.max(state.wantedGeneration + 1, nextGeneration); const activeControl = state.activeControl; if ( activeControl && activeControl.generation < state.wantedGeneration && state.active?.phase === 'run-to-stop' ) { void interruptGdb(activeControl.binding).catch(() => undefined); } const request = { repositoryRoot, cardFile, snapshotRef, generation: state.wantedGeneration, expectedBinding, expectedIdentity }; const operation = state.queue.then(() => runActivation(request)); state.queue = operation.catch(error => { state.last = { status: 'failed', snapshot_ref: snapshotRef, generation: request.generation, message: errorText(error) }; state.active = null; if (state.activeControl?.generation === request.generation) state.activeControl = null; }); return operation; }