${html(position)}
${evidence}import { createHash, randomUUID } from 'node:crypto';
import { execFile } from 'node:child_process';
import { createServer } from 'node:http';
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import Fastify from 'fastify';
import websocket from '@fastify/websocket';
import {
STATUSES,
catalogFromCard,
normaliseProgress,
progressSummary,
readJson,
statusLabel,
teamsMarkdown,
updateItem,
writeJsonAtomically
} from './lib/card_progress.mjs';
import {
attachNvimUi,
callSelectedNvim,
resizeSelectedNvimUi,
selectedNvimTarget
} from './lib/nvim_ui_bridge.mjs';
import { activateCheckpoint, checkpointStatus } from './lib/checkpoint_controller.mjs';
import { CardStateDatabase } from './lib/card_state_db.mjs';
import {
activateNavigation,
KEYBOARD_SHORTCUTS,
moveNavigation,
navigationCatalog,
normalizeViewerState,
patchViewerState,
selectNavigation
} from './lib/card_control.mjs';
const repositoryRoot = path.resolve(process.cwd());
const cardFile = path.join(repositoryRoot, 'json/card_source.json');
const progressFile = path.resolve(process.env.CARD_PROGRESS_FILE ?? path.join(repositoryRoot, 'json/lesson_progress.json'));
const stateDatabaseFile = path.resolve(
process.env.CARD_STATE_DATABASE ?? path.join(repositoryRoot, '.stem/card-state.sqlite3')
);
const apiSocket = path.resolve(
process.env.CARD_API_SOCKET ?? path.join(repositoryRoot, '.stem/card-api.sock')
);
const evidenceRoot = path.resolve(
process.env.CARD_EVIDENCE_ROOT ?? path.join(repositoryRoot, '.stem/evidence')
);
const webRoot = path.join(repositoryRoot, 'web');
const host = process.env.CARD_HOST ?? '127.0.0.1';
const port = Number(process.env.CARD_PORT ?? 8080);
const app = Fastify({ logger: true });
const execFileAsync = promisify(execFile);
const stateDatabase = new CardStateDatabase(stateDatabaseFile);
await app.register(websocket, {
options: {
maxPayload: 64 * 1024
}
});
const contentTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.puml': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml; charset=utf-8',
'.webp': 'image/webp'
};
let writeQueue = Promise.resolve();
let currentNavigation = stateDatabase.read('navigation');
let navigationRevision = Number.isSafeInteger(Number(currentNavigation?.revision))
? Number(currentNavigation.revision)
: 0;
const persistedViewer = stateDatabase.read('viewer');
let currentViewer = normalizeViewerState(persistedViewer);
if (persistedViewer && JSON.stringify(persistedViewer) !== JSON.stringify(currentViewer)) {
stateDatabase.write('viewer', currentViewer);
}
let viewerRevision = Number.isSafeInteger(Number(currentViewer?.revision))
? Number(currentViewer.revision)
: 0;
function actor(value, fallback = 'system') {
return typeof value === 'string' && value.trim()
? value.trim().slice(0, 160)
: fallback;
}
async function zoomSelectedHostPane() {
const selected = await selectedNvimTarget();
const instance = String(selected.metadata?.instance ?? '');
const result = await execFileAsync('tmux', [
'list-panes', '-a', '-F',
'#{pane_id}\t#{@stem_instance}\t#{@stem_role}\t#{window_zoomed_flag}'
]);
const panes = result.stdout.trim().split('\n').filter(Boolean).map(line => {
const [id, stemInstance, role, zoomed] = line.split('\t');
return { id, stemInstance, role, zoomed };
});
const exact = panes.find(pane => pane.stemInstance === instance && pane.role === 'debugger');
const debuggerPanes = panes.filter(pane => pane.role === 'debugger');
const pane = exact ?? (debuggerPanes.length === 1 ? debuggerPanes[0] : null);
if (!pane) return { found: false, instance };
if (pane.zoomed !== '1') {
await execFileAsync('tmux', ['resize-pane', '-Z', '-t', pane.id]);
}
const dimensions = await execFileAsync('tmux', [
'display-message', '-p', '-t', pane.id,
'#{pane_width}x#{pane_height}\t#{window_zoomed_flag}'
]);
const [size, zoomed] = dimensions.stdout.trim().split('\t');
return { found: true, pane: pane.id, instance, size, zoomed: zoomed === '1' };
}
function expectedNvimGrid(hostPane) {
const match = hostPane?.found && typeof hostPane.size === 'string'
? /^(\d+)x(\d+)$/.exec(hostPane.size)
: null;
if (!match) return null;
return {
width: Number(match[1]),
// The nested tmux status line occupies one row below the Neovim pane.
height: Math.max(1, Number(match[2]) - 1)
};
}
async function settleSelectedNvimTerminalUi(hostPane) {
const target = expectedNvimGrid(hostPane);
let innerTmux = null;
try {
innerTmux = await callSelectedNvim('nvim_exec_lua', [`
if not vim.env.TMUX or vim.env.TMUX == '' then
return { attempted = false }
end
local result = vim.system(
{ 'tmux', 'resize-window', '-A' },
{ text = true }
):wait()
return {
attempted = true,
code = result.code,
stderr = vim.trim(result.stderr or '')
}
`, []]);
} catch (error) {
innerTmux = { attempted: true, error: error instanceof Error ? error.message : String(error) };
}
const deadline = Date.now() + 1800;
let terminal = null;
do {
const uis = await callSelectedNvim('nvim_list_uis', []);
terminal = Array.isArray(uis)
? uis.find(ui => ui?.stdin_tty && ui?.stdout_tty) ?? null
: null;
if (
terminal
&& (!target || (terminal.width >= target.width && terminal.height >= target.height))
) break;
await new Promise(resolve => setTimeout(resolve, 80));
} while (Date.now() < deadline);
return {
target,
terminal: terminal
? { width: terminal.width, height: terminal.height }
: null,
inner_tmux: innerTmux,
settled: Boolean(
terminal
&& (!target || (terminal.width >= target.width && terminal.height >= target.height))
)
};
}
function audit(eventType, eventActor, payload = {}, options = {}) {
return stateDatabase.appendEvent({
eventType,
actor: actor(eventActor),
navigation: options.navigation === undefined ? currentNavigation : options.navigation,
evidenceRef: options.evidenceRef ?? null,
payload
});
}
function html(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"');
}
function auditReportHtml(events) {
const rows = events.map(event => {
const position = [event.task_id, event.block_id, event.phase_id, event.step_id]
.filter(Boolean)
.join(' / ');
const evidence = event.evidence_ref
? ` ${html(position)}
Wpisy i timestampy pochodzą z backendu; zrzuty są dowodami przypiętymi do zdarzeń.