feat: implement CPP03 C++20 card
This commit is contained in:
@@ -0,0 +1,918 @@
|
||||
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
|
||||
? `<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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user