feat(L04): add RV32I decoder and register file card
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
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', ['<CR>']).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', ['<CR>']).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;
|
||||
}
|
||||
Reference in New Issue
Block a user