Files
lab-console-git-basics-2/scripts/card_server.mjs
T

919 lines
34 KiB
JavaScript

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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
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
? `<figure><img src="${html(event.evidence_ref)}" alt="${html(event.payload?.caption ?? event.event_type)}"><figcaption>${html(event.payload?.caption ?? '')}</figcaption></figure>`
: '';
return `<article><time>${html(event.occurred_at)}</time><strong>${html(event.event_type)}</strong><span>${html(event.actor)}</span><p>${html(position)}</p>${evidence}</article>`;
}).join('\n');
return `<!doctype html><html lang="pl"><meta charset="utf-8"><title>Ślad lekcji</title><style>body{max-width:1100px;margin:2rem auto;font:16px/1.45 system-ui;color:#222}header{border-bottom:2px solid;padding-bottom:1rem}article{display:grid;grid-template-columns:14rem 13rem 1fr;gap:.35rem 1rem;padding:1rem 0;border-bottom:1px solid #bbb}article p{grid-column:1/-1;margin:0;color:#555}figure{grid-column:1/-1;margin:.7rem 0}img{max-width:100%;border:1px solid #777}figcaption{font-size:.9rem;color:#555}@media print{body{max-width:none;margin:12mm}}</style><header><h1>Chronologiczny ślad lekcji</h1><p>Wpisy i timestampy pochodzą z backendu; zrzuty są dowodami przypiętymi do zdarzeń.</p></header><main>${rows}</main></html>`;
}
async function restoreNavigation() {
if (!currentNavigation) return;
try {
const catalog = await currentNavigationCatalog();
currentNavigation = selectNavigation(
catalog,
currentNavigation,
navigationRevision,
currentNavigation.selected_at
);
} catch {
currentNavigation = null;
navigationRevision = 0;
}
}
async function currentNavigationCatalog() {
return navigationCatalog(await readJson(cardFile));
}
async function currentCardIdentity() {
const source = await readFile(cardFile);
const card = JSON.parse(source.toString('utf8')).card ?? {};
return {
schema: 'stem-card-identity.v1',
id: String(card.id ?? ''),
uuid: String(card.uuid ?? ''),
version: String(card.version ?? ''),
source_sha256: createHash('sha256').update(source).digest('hex')
};
}
async function selectedNvimState() {
return callSelectedNvim('nvim_exec_lua', [`
local win = vim.api.nvim_get_current_win()
local buf = vim.api.nvim_win_get_buf(win)
local cursor = vim.api.nvim_win_get_cursor(win)
local mode = vim.api.nvim_get_mode()
return {
window = win,
buffer = buf,
path = vim.api.nvim_buf_get_name(buf),
cursor = { row = cursor[1], column = cursor[2] },
mode = mode.mode,
blocking = mode.blocking,
changedtick = vim.api.nvim_buf_get_changedtick(buf),
columns = vim.o.columns,
lines = vim.o.lines
}
`, []]);
}
async function webRevision() {
const files = ['index.html', 'app.js', 'app.css', 'style.css', 'card-data.json'];
for (const directory of ['figures', 'debug-snapshots']) {
try {
for (const name of await readdir(path.join(webRoot, directory))) {
files.push(path.join(directory, name));
}
} catch {
// A card without optional generated assets is valid.
}
}
const revisions = await Promise.all(files.sort().map(async name => {
const info = await stat(path.join(webRoot, name));
return `${name}:${info.mtimeMs}:${info.size}`;
}));
return revisions.join('|');
}
async function cardAndProgress() {
const card = await readJson(cardFile);
let saved;
try {
saved = await readJson(progressFile);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
saved = {};
}
return {
card,
progress: normaliseProgress(card, saved, {
repository: process.env.CARD_SOURCE_REPOSITORY,
revision: process.env.CARD_SOURCE_REVISION
})
};
}
async function updateProgress(itemId, change) {
const operation = writeQueue.then(async () => {
const { card, progress } = await cardAndProgress();
updateItem(progress, itemId, change);
await writeJsonAtomically(progressFile, progress);
return { card, progress };
});
writeQueue = operation.catch(() => undefined);
return operation;
}
function trackerAssets() {
const css = `<style id="lesson-progress-style">
.lesson-progress-control{display:inline-flex;align-items:center;gap:.35rem;margin:0 .45rem .25rem 0;padding:.18rem .48rem;border:1px solid #8c8c8c;border-radius:1rem;background:#fff;color:#222;font:600 .76rem/1.25 system-ui,sans-serif;cursor:pointer}.lesson-progress-control:hover{border-color:#005a9c}.lesson-progress-control[data-status="approved"]{border-color:#18794e;background:#e7f6ed}.step-row.lesson-progress-completed{border-left:4px solid #18794e;padding-left:.45rem}.lesson-progress-dock{position:fixed;z-index:20;right:1rem;bottom:1rem;width:min(24rem,calc(100vw - 2rem));padding:.85rem 1rem;border:1px solid #555;border-radius:.6rem;background:#fff;box-shadow:0 5px 24px #0004;font:14px/1.35 system-ui,sans-serif}.lesson-progress-dock strong{display:block}.lesson-progress-dock p{margin:.35rem 0}.lesson-progress-dock a{color:#005a9c}.lesson-progress-dock button{margin-top:.25rem;padding:.3rem .55rem;border:1px solid #555;border-radius:.25rem;background:#f6f6f6;cursor:pointer}@media print{.lesson-progress-control,.lesson-progress-dock{display:none!important}}</style>`;
const script = `<script id="lesson-progress-script">
(() => {
const next = {pending:'approved', approved:'pending'};
const labels = {pending:'○ niezatwierdzony', approved:'● zatwierdzony'};
let catalog = [];
let progress = null;
const api = async (url, options) => {
const response = await fetch(url, options);
if (!response.ok) throw new Error(await response.text());
return response.json();
};
const render = () => {
const rows = [...document.querySelectorAll('.step-row')];
rows.forEach((row, index) => {
const item = catalog[index];
if (!item) return;
let control = row.querySelector('.lesson-progress-control');
if (!control) {
control = document.createElement('button');
control.type = 'button'; control.className = 'lesson-progress-control';
row.querySelector('.step-title')?.before(control);
control.addEventListener('click', async () => {
const current = progress.items[item.id].status;
control.disabled = true;
try {
const data = await api('/api/progress/' + encodeURIComponent(item.id), {method:'PUT', headers:{'content-type':'application/json'}, body:JSON.stringify({status:next[current]})});
progress = data.progress; render();
} catch (error) { window.alert('Nie zapisano postępu: ' + error.message); }
finally { control.disabled = false; }
});
}
const state = progress.items[item.id];
control.dataset.status = state.status;
control.textContent = labels[state.status];
control.title = 'Kliknij, aby zatwierdzić lub cofnąć zatwierdzenie. Zapis zawiera timestamp.';
row.classList.toggle('lesson-progress-completed', state.status === 'approved');
});
const summary = progress.summary;
document.getElementById('lesson-progress-summary').textContent = 'Zatwierdzone: ' + summary.counts.approved + '/' + summary.total;
};
const start = async () => {
const data = await api('/api/progress');
catalog = data.catalog; progress = data.progress; render();
const dock = document.createElement('aside'); dock.className = 'lesson-progress-dock';
dock.innerHTML = '<strong>Przebieg zajęć</strong><p id="lesson-progress-summary"></p><p>Kliknij znacznik przy kroku, aby zatwierdzić lub cofnąć zatwierdzenie. Każda zmiana trafia do wersjonowanego JSON-a z czasem.</p><a href="/api/report/teams.md" target="_blank" rel="noopener">Otwórz raport do Teams (Markdown)</a>';
document.body.append(dock); render();
};
start().catch(error => console.error('Nie można wczytać postępu zajęć:', error));
})();
</script>`;
return { css, script };
}
function injectTracker(html, revision) {
const usesReactRenderer = html.includes('<meta name="card-renderer" content="react">');
const { css, script } = usesReactRenderer ? { css: '', script: '' } : trackerAssets();
const reload = `<script id="card-live-reload">
(() => {
let revision = ${JSON.stringify(revision)};
const check = async () => {
try {
const response = await fetch('/api/card-revision', { cache: 'no-store' });
if (!response.ok) return;
const current = (await response.json()).revision;
if (current !== revision) window.location.reload();
} catch {
// Keep the current page usable while the local server is restarting.
}
};
window.setInterval(check, 1200);
})();
</script>`;
return html
.replace('</head>', `${css}</head>`)
.replace('</body>', `${script}${reload}</body>`);
}
app.get('/api/progress', async () => {
const { card, progress } = await cardAndProgress();
return {
card: progress.card,
session: progress.session,
catalog: catalogFromCard(card),
progress: { ...progress, summary: progressSummary(progress) },
statuses: Object.fromEntries(STATUSES.map(status => [status, statusLabel[status]]))
};
});
app.put('/api/progress/:itemId', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
if (!STATUSES.includes(body.status)) {
return reply.code(400).send({ error: `status musi być jednym z: ${STATUSES.join(', ')}` });
}
try {
const progressActor = actor(body.actor, 'teacher');
const { progress } = await updateProgress(request.params.itemId, {
status: body.status,
note: body.note,
actor: progressActor
});
audit('progress.status', progressActor, {
item_id: request.params.itemId,
status: body.status,
note: typeof body.note === 'string' ? body.note.slice(0, 2000) : null
});
return { progress: { ...progress, summary: progressSummary(progress) } };
} catch (error) {
return reply.code(error.message.startsWith('Nieznany element') ? 404 : 500).send({ error: error.message });
}
});
app.get('/api/report/teams.md', async (_request, reply) => {
const { card, progress } = await cardAndProgress();
return reply
.type('text/markdown; charset=utf-8')
.header('content-disposition', `inline; filename="${progress.session.id}-teams.md"`)
.send(teamsMarkdown(card, progress));
});
app.get('/api/report/lesson.html', async (_request, reply) => reply
.type('text/html; charset=utf-8')
.send(auditReportHtml(stateDatabase.listEvents({ limit: 5000 }))));
app.get('/api/events', async request => {
const query = request.query && typeof request.query === 'object' ? request.query : {};
const afterId = Math.max(0, Number.isSafeInteger(Number(query.after_id)) ? Number(query.after_id) : 0);
const limit = Math.max(1, Math.min(1000, Number.isSafeInteger(Number(query.limit)) ? Number(query.limit) : 200));
return {
schema: 'stem-card-event-log.v1',
events: stateDatabase.listEvents({ afterId, limit })
};
});
app.post('/api/events', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const eventType = typeof body.event_type === 'string' ? body.event_type.trim() : '';
if (!/^[a-z][a-z0-9_.-]{1,79}$/.test(eventType)) {
return reply.code(400).send({ error: 'event_type ma niepoprawny format.' });
}
const payload = body.payload && typeof body.payload === 'object' ? body.payload : {};
if (JSON.stringify(payload).length > 16 * 1024) {
return reply.code(413).send({ error: 'payload zdarzenia jest zbyt duży.' });
}
return {
schema: 'stem-card-event.v1',
event: audit(eventType, actor(body.actor, 'api'), payload)
};
});
app.post('/api/evidence', { bodyLimit: 12 * 1024 * 1024 }, async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const match = typeof body.data_url === 'string'
? /^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/=]+)$/.exec(body.data_url)
: null;
if (!match) return reply.code(400).send({ error: 'Wymagany jest data_url obrazu PNG, JPEG albo WebP.' });
const bytes = Buffer.from(match[2], 'base64');
if (!bytes.length || bytes.length > 8 * 1024 * 1024) {
return reply.code(413).send({ error: 'Zrzut musi mieć od 1 B do 8 MiB.' });
}
const extension = match[1] === 'jpeg' ? 'jpg' : match[1];
const name = `${new Date().toISOString().replaceAll(':', '-')}-${randomUUID()}.${extension}`;
await mkdir(evidenceRoot, { recursive: true });
await writeFile(path.join(evidenceRoot, name), bytes, { mode: 0o600 });
const evidenceRef = `/api/evidence/${name}`;
const event = stateDatabase.appendEvent({
eventType: 'checkpoint.screenshot',
actor: actor(body.actor, 'browser'),
navigation: currentNavigation,
evidenceRef,
payload: {
caption: typeof body.caption === 'string' ? body.caption.slice(0, 500) : '',
mime: `image/${match[1]}`,
size_bytes: bytes.length,
snapshot_ref: typeof body.snapshot_ref === 'string' ? body.snapshot_ref : currentNavigation?.snapshot_ref
}
});
return reply.code(201).send({ schema: 'stem-card-evidence.v1', event });
});
app.get('/api/evidence/:name', async (request, reply) => {
const name = String(request.params.name ?? '');
if (!/^[A-Za-z0-9_.+-]+\.(png|jpg|webp)$/.test(name)) return reply.code(404).send('Not found');
try {
const file = path.join(evidenceRoot, name);
const info = await stat(file);
if (!info.isFile()) return reply.code(404).send('Not found');
return reply
.type(contentTypes[path.extname(file).toLowerCase()] ?? 'application/octet-stream')
.send(await readFile(file));
} catch {
return reply.code(404).send('Not found');
}
});
app.get('/api/card-revision', async () => ({ revision: await webRevision() }));
app.get('/api/identity', async () => currentCardIdentity());
app.get('/api/checkpoint/status', async () => checkpointStatus());
app.get('/api/keyboard', async () => ({
schema: 'stem-card-keyboard.v1',
hierarchy: ['card', 'task', 'block', 'phase', 'step', 'snapshot'],
shortcuts: KEYBOARD_SHORTCUTS
}));
app.get('/api/navigation/catalog', async () => currentNavigationCatalog());
app.get('/api/navigation/current', async () => ({
current: currentNavigation,
checkpoint: checkpointStatus()
}));
app.put('/api/navigation/current', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
try {
const catalog = await currentNavigationCatalog();
navigationRevision += 1;
currentNavigation = selectNavigation(catalog, body, navigationRevision);
stateDatabase.write('navigation', currentNavigation);
audit('navigation.select', currentNavigation.actor, {
focus_level: currentNavigation.focus_level,
sync_requested: currentNavigation.sync_requested
});
return { current: currentNavigation };
} catch (error) {
return reply.code(400).send({
error: error instanceof Error ? error.message : String(error)
});
}
});
app.post('/api/navigation/move', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
try {
const catalog = await currentNavigationCatalog();
navigationRevision += 1;
currentNavigation = moveNavigation(
catalog,
currentNavigation,
body,
navigationRevision
);
stateDatabase.write('navigation', currentNavigation);
audit('navigation.move', currentNavigation.actor, {
level: body.level ?? currentNavigation.focus_level,
delta: Number(body.delta),
sync_requested: false
});
return { current: currentNavigation };
} catch (error) {
return reply.code(400).send({
error: error instanceof Error ? error.message : String(error)
});
}
});
app.get('/api/viewer/state', async () => ({ viewer: currentViewer }));
app.put('/api/viewer/state', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
try {
const requestedViewer = body.viewer && typeof body.viewer === 'object' ? body.viewer : body;
const requestedNvim = requestedViewer.nvim && typeof requestedViewer.nvim === 'object'
? requestedViewer.nvim
: null;
viewerRevision += 1;
currentViewer = patchViewerState(currentViewer, body, viewerRevision);
stateDatabase.write('viewer', currentViewer);
if (requestedNvim && ['visible', 'sync', 'control', 'expanded', 'fit'].some(key => key in requestedNvim)) {
audit('viewer.nvim', actor(body.actor, 'browser'), {
visible: currentViewer.nvim.visible,
sync: currentViewer.nvim.sync,
control: currentViewer.nvim.control,
expanded: currentViewer.nvim.expanded,
fit: currentViewer.nvim.fit
});
}
if (requestedViewer.allocator && typeof requestedViewer.allocator === 'object'
&& 'visible' in requestedViewer.allocator) {
audit('viewer.allocator', actor(body.actor, 'browser'), {
visible: currentViewer.allocator.visible
});
}
return { viewer: currentViewer };
} catch (error) {
return reply.code(400).send({
error: error instanceof Error ? error.message : String(error)
});
}
});
app.get('/api/control/state', async () => ({
schema: 'stem-card-control-state.v1',
revision: `${navigationRevision}:${viewerRevision}`,
navigation: currentNavigation,
viewer: currentViewer,
checkpoint: checkpointStatus()
}));
app.get('/api/nvim/state', async (_request, reply) => {
try {
return {
schema: 'stem-card-nvim-state.v1',
state: await selectedNvimState()
};
} catch (error) {
return reply.code(503).send({
error: error instanceof Error ? error.message : String(error)
});
}
});
app.put('/api/nvim/cursor', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const row = Number(body.row);
const column = Number(body.column);
const requestedWindow = body.window == null ? null : Number(body.window);
if (!Number.isSafeInteger(row) || row < 1 || !Number.isSafeInteger(column) || column < 0) {
return reply.code(400).send({ error: 'Kursor wymaga row >= 1 i column >= 0.' });
}
if (requestedWindow != null && (!Number.isSafeInteger(requestedWindow) || requestedWindow < 1)) {
return reply.code(400).send({ error: 'window musi być poprawnym identyfikatorem Neovima.' });
}
try {
const window = requestedWindow ?? await callSelectedNvim('nvim_get_current_win');
await callSelectedNvim('nvim_win_set_cursor', [window, [row, column]]);
return {
schema: 'stem-card-nvim-state.v1',
state: await selectedNvimState()
};
} catch (error) {
return reply.code(503).send({
error: error instanceof Error ? error.message : String(error)
});
}
});
app.post('/api/nvim/input', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const keys = typeof body.keys === 'string' ? body.keys : '';
if (!keys || keys.length > 256) {
return reply.code(400).send({ error: 'keys musi zawierać od 1 do 256 znaków.' });
}
try {
const accepted = await callSelectedNvim('nvim_input', [keys]);
return { accepted, state: await selectedNvimState() };
} catch (error) {
return reply.code(503).send({
error: error instanceof Error ? error.message : String(error)
});
}
});
app.get('/api/state', async () => {
let nvim;
try {
nvim = { available: true, state: await selectedNvimState() };
} catch (error) {
nvim = { available: false, error: error instanceof Error ? error.message : String(error) };
}
return {
schema: 'stem-card-state.v1',
navigation: currentNavigation,
viewer: currentViewer,
checkpoint: checkpointStatus(),
nvim,
endpoints: {
keyboard: '/api/keyboard',
navigation_catalog: '/api/navigation/catalog',
navigation_current: '/api/navigation/current',
navigation_move: '/api/navigation/move',
navigation_sync: '/api/navigation/sync',
events: '/api/events',
evidence: '/api/evidence',
lesson_report: '/api/report/lesson.html',
identity: '/api/identity',
viewer: '/api/viewer/state',
nvim_reset: '/api/nvim/reset',
nvim_redraw: '/api/nvim/redraw',
nvim_cursor: '/api/nvim/cursor',
nvim_input: '/api/nvim/input'
}
};
});
app.post('/api/checkpoint/activate', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const eventActor = actor(body.actor, 'browser');
audit('checkpoint.request', eventActor, {
snapshot_ref: body.snapshot_ref,
generation: Number(body.generation)
});
try {
const result = await activateCheckpoint({
repositoryRoot,
cardFile,
snapshotRef: body.snapshot_ref,
generation: Number(body.generation),
expectedBinding: body.expected_binding,
expectedIdentity: body.expected_identity
});
audit('checkpoint.ready', eventActor, {
snapshot_ref: body.snapshot_ref,
generation: Number(body.generation),
status: result.status,
message: result.message
});
return result;
} catch (error) {
audit('checkpoint.failed', eventActor, {
snapshot_ref: body.snapshot_ref,
generation: Number(body.generation),
message: error instanceof Error ? error.message : String(error)
});
return reply.code(409).send({
status: 'failed',
snapshot_ref: body.snapshot_ref,
message: error instanceof Error ? error.message : String(error)
});
}
});
app.post('/api/nvim/reset', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const eventActor = actor(body.actor, 'browser');
audit('nvim.reset.request', eventActor, { source: 'web.F1' });
try {
await callSelectedNvim('nvim_command', ['CpuReset']);
return {
schema: 'stem-card-nvim-reset.v1',
status: 'accepted',
actor: eventActor,
state: await selectedNvimState()
};
} catch (error) {
audit('nvim.reset.failed', eventActor, {
source: 'web.F1',
message: error instanceof Error ? error.message : String(error)
});
return reply.code(503).send({
status: 'failed',
message: error instanceof Error ? error.message : String(error)
});
}
});
app.post('/api/nvim/redraw', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const eventActor = actor(body.actor, 'nvim');
let hostPane;
try {
hostPane = await zoomSelectedHostPane();
} catch (error) {
hostPane = { found: false, error: error instanceof Error ? error.message : String(error) };
}
await new Promise(resolve => setTimeout(resolve, 120));
try {
const terminalUi = await settleSelectedNvimTerminalUi(hostPane);
const externalUi = await resizeSelectedNvimUi();
await callSelectedNvim('nvim_command', ['silent! StudentLayoutFit | redraw!']);
const state = await selectedNvimState();
audit('nvim.redraw', eventActor, {
source: 'api',
host_pane: hostPane,
terminal_ui: terminalUi,
external_ui: externalUi,
columns: state.columns,
lines: state.lines
});
return {
schema: 'stem-card-nvim-redraw.v1',
status: 'ready',
host_pane: hostPane,
terminal_ui: terminalUi,
external_ui: externalUi,
state
};
} catch (error) {
return reply.code(503).send({
status: 'failed',
host_pane: hostPane,
message: error instanceof Error ? error.message : String(error)
});
}
});
app.post('/api/navigation/sync', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const eventActor = actor(body.actor, 'nvim');
const resolvedFrom = currentNavigation?.focus_level ?? null;
let activatedNavigation;
try {
const catalog = await currentNavigationCatalog();
activatedNavigation = activateNavigation(
catalog,
currentNavigation,
eventActor,
navigationRevision + 1
);
} catch (error) {
return reply.code(409).send({
status: 'failed',
message: error instanceof Error ? error.message : String(error)
});
}
navigationRevision += 1;
currentNavigation = activatedNavigation;
stateDatabase.write('navigation', currentNavigation);
audit('navigation.activate', eventActor, {
source: 'navigation.sync',
resolved_from: resolvedFrom,
focus_level: currentNavigation.focus_level,
snapshot_ref: currentNavigation.snapshot_ref
});
const snapshotRef = currentNavigation.snapshot_ref;
audit('checkpoint.request', eventActor, {
source: 'navigation.sync',
snapshot_ref: snapshotRef
});
try {
const checkpoint = await activateCheckpoint({
repositoryRoot,
cardFile,
snapshotRef,
generation: Date.now()
});
audit('checkpoint.ready', eventActor, {
source: 'navigation.sync',
snapshot_ref: snapshotRef,
status: checkpoint.status,
message: checkpoint.message
});
return {
schema: 'stem-card-navigation-sync.v1',
actor: eventActor,
current: currentNavigation,
checkpoint
};
} catch (error) {
audit('checkpoint.failed', eventActor, {
source: 'navigation.sync',
snapshot_ref: snapshotRef,
message: error instanceof Error ? error.message : String(error)
});
return reply.code(409).send({
status: 'failed',
snapshot_ref: snapshotRef,
message: error instanceof Error ? error.message : String(error)
});
}
});
app.get('/api/nvim-ui', { websocket: true }, (socket, request) => {
const origin = request.headers.origin;
const expectedOrigin = `http://${request.headers.host}`;
if (origin && origin !== expectedOrigin) {
socket.close(1008, 'Origin not allowed');
return;
}
attachNvimUi(socket);
});
app.get('/*', async (request, reply) => {
const requested = request.params['*'] || 'index.html';
const relative = requested === '' ? 'index.html' : requested;
const target = path.resolve(webRoot, relative);
if (target !== webRoot && !target.startsWith(`${webRoot}${path.sep}`)) return reply.code(403).send('Forbidden');
try {
const info = await stat(target);
if (!info.isFile()) return reply.code(404).send('Not found');
if (path.basename(target) === 'index.html') {
return reply
.type('text/html; charset=utf-8')
.send(injectTracker(await readFile(target, 'utf8'), await webRevision()));
}
return reply
.type(contentTypes[path.extname(target).toLowerCase()] ?? 'application/octet-stream')
.send(await readFile(target));
} catch {
return reply.code(404).send('Not found');
}
});
let socketServer = null;
app.addHook('onClose', async () => {
if (socketServer) {
await new Promise(resolve => socketServer.close(resolve));
}
await rm(apiSocket, { force: true });
stateDatabase.close();
});
await restoreNavigation();
await app.listen({ host, port });
await rm(apiSocket, { force: true });
socketServer = createServer(app.routing);
await new Promise((resolve, reject) => {
socketServer.once('error', reject);
socketServer.listen(apiSocket, () => {
socketServer.off('error', reject);
resolve();
});
});