919 lines
34 KiB
JavaScript
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('&', '&')
|
|
.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();
|
|
});
|
|
});
|