Real architectural gap found and fixed during Phase 2A testing, not part of
the original plan: scenario validate/answer requests proxied through
/lab/<token>/api/scenarios/:id/{validate,answer} were writing progress into
that ephemeral Lab container's own SQLite — which is destroyed on every
reset/reap. Confirmed by testing: solving a scenario never showed up on the
dashboard's curriculum view, and would have been silently lost on lab
recreate, directly contradicting "academic progress must survive lab
reset/recreate."
Root tension: scenario validation must run against the student's own live
cluster (only exists inside their Lab container) — it can't be re-run
anywhere else. But the canonical progress record needs to live on the
persistent identity instance, which never has the student's actual cluster
state applied to it.
Fix: this endpoint (POST /api/progress/record) records an outcome WITHOUT
re-running validation — it exists purely so the Gateway can relay a
validate/answer result it already obtained from the real Lab. The Gateway
forwards the real request to the Lab, returns that real response to the
browser unchanged, and separately calls this endpoint with the outcome. See
platform/gateway/src/server.js's relayScenarioOutcome().
Verified end-to-end: a scenario solved through a student's Lab shows up in
their curriculum immediately, survives their lab being destroyed and reset,
and is correctly attributed only to that student (not visible to a second
student's own curriculum view).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
1038 lines
39 KiB
JavaScript
1038 lines
39 KiB
JavaScript
const express = require('express');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { exec } = require('child_process');
|
|
const { promisify } = require('util');
|
|
const execAsync = promisify(exec);
|
|
const Database = require('better-sqlite3');
|
|
|
|
const app = express();
|
|
const PORT = 4000;
|
|
|
|
app.use(express.json());
|
|
app.use(express.static(path.join(__dirname, '../frontend/dist')));
|
|
|
|
const SCENARIOS_DIR = path.join(__dirname, '../scenarios/data');
|
|
const BUNDLES_DIR = path.join(__dirname, '../scenarios/bundles');
|
|
const CURRICULUM_PATH_FILE = path.join(__dirname, '../scenarios/rootnode-path.json');
|
|
const DB_FILE = process.env.PROGRESS_DB || '/data/progress.db';
|
|
|
|
// ── SQLite progress store ─────────────────────────────────────────────────────
|
|
|
|
let _db = null;
|
|
function getDb() {
|
|
if (_db) return _db;
|
|
const dir = path.dirname(DB_FILE);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
_db = new Database(DB_FILE);
|
|
_db.exec(`
|
|
CREATE TABLE IF NOT EXISTS progress (
|
|
scenario_id TEXT PRIMARY KEY,
|
|
status TEXT,
|
|
attempts INTEGER DEFAULT 0,
|
|
last_validated TEXT,
|
|
completed_at TEXT
|
|
)
|
|
`);
|
|
|
|
// Run dynamic schema migrations to add missing columns to the progress table
|
|
try {
|
|
const tableInfo = _db.prepare("PRAGMA table_info(progress)").all();
|
|
const existingColumns = tableInfo.map(col => col.name);
|
|
|
|
const requiredColumns = [
|
|
{ name: 'started_at', type: 'TEXT' },
|
|
{ name: 'notes', type: 'TEXT' },
|
|
{ name: 'time_spent_seconds', type: 'INTEGER' }
|
|
];
|
|
|
|
for (const col of requiredColumns) {
|
|
if (!existingColumns.includes(col.name)) {
|
|
console.log(`Migrating progress database schema: Adding column '${col.name}' (${col.type})`);
|
|
_db.exec(`ALTER TABLE progress ADD COLUMN ${col.name} ${col.type}`);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to run schema migrations on progress table:', e.message);
|
|
}
|
|
|
|
// ── Students / roster ────────────────────────────────────────────────────
|
|
// ponytail: pick-your-name login, no password — instructor adds students to the
|
|
// roster, student just selects their name. Trust model is "supervised classroom
|
|
// device", same as Clever/ClassLink instant login. Upgrade to per-student PIN
|
|
// if remote/unsupervised access is ever needed.
|
|
_db.exec(`
|
|
CREATE TABLE IF NOT EXISTS students (
|
|
student_id TEXT PRIMARY KEY,
|
|
display_name TEXT NOT NULL,
|
|
cohort TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
last_active_at TEXT
|
|
)
|
|
`);
|
|
|
|
// Migrate progress table to a per-student composite key (student_id, scenario_id).
|
|
// SQLite can't ALTER a primary key, so this rebuilds the table. Rows that predate
|
|
// student accounts are kept under student_id='unassigned' rather than dropped.
|
|
try {
|
|
const progressCols = _db.prepare("PRAGMA table_info(progress)").all().map(c => c.name);
|
|
if (!progressCols.includes('student_id')) {
|
|
console.log('Migrating progress table to per-student schema...');
|
|
_db.exec(`
|
|
CREATE TABLE progress_new (
|
|
student_id TEXT NOT NULL DEFAULT 'unassigned',
|
|
scenario_id TEXT NOT NULL,
|
|
status TEXT,
|
|
attempts INTEGER DEFAULT 0,
|
|
last_validated TEXT,
|
|
completed_at TEXT,
|
|
started_at TEXT,
|
|
notes TEXT,
|
|
time_spent_seconds INTEGER,
|
|
PRIMARY KEY (student_id, scenario_id)
|
|
);
|
|
INSERT INTO progress_new (student_id, scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds)
|
|
SELECT 'unassigned', scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds FROM progress;
|
|
DROP TABLE progress;
|
|
ALTER TABLE progress_new RENAME TO progress;
|
|
`);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to migrate progress table to per-student schema:', e.message);
|
|
}
|
|
|
|
_db.exec(`
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY,
|
|
bundle_id TEXT NOT NULL,
|
|
started_at TEXT NOT NULL,
|
|
submitted_at TEXT,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
exam_minutes INTEGER NOT NULL DEFAULT 120,
|
|
duration_secs INTEGER,
|
|
snapshot TEXT,
|
|
scenario_ids TEXT
|
|
)
|
|
`);
|
|
|
|
// Migrate: add scenario_ids column if missing
|
|
try {
|
|
const cols = _db.prepare("PRAGMA table_info(sessions)").all().map(c => c.name);
|
|
if (!cols.includes('scenario_ids')) {
|
|
_db.exec(`ALTER TABLE sessions ADD COLUMN scenario_ids TEXT`);
|
|
}
|
|
if (!cols.includes('student_id')) {
|
|
_db.exec(`ALTER TABLE sessions ADD COLUMN student_id TEXT`);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to migrate sessions table:', e.message);
|
|
}
|
|
|
|
// Separate exam-session progress table — tracks completions per session
|
|
_db.exec(`
|
|
CREATE TABLE IF NOT EXISTS exam_progress (
|
|
session_id TEXT NOT NULL,
|
|
scenario_id TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'in_progress',
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
completed_at TEXT,
|
|
PRIMARY KEY (session_id, scenario_id),
|
|
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
|
)
|
|
`);
|
|
return _db;
|
|
}
|
|
|
|
// Resolves the acting student for a request. No session tokens yet (see students
|
|
// table comment) — frontend just sends back the student_id it got from /api/login.
|
|
// Falls back to 'unassigned' so the app still works before any student logs in
|
|
// (dev/test use, and pre-migration progress lives there too).
|
|
function getStudentId(req) {
|
|
return req.get('x-student-id') || req.query.studentId || 'unassigned';
|
|
}
|
|
|
|
function touchStudent(studentId) {
|
|
if (!studentId || studentId === 'unassigned') return;
|
|
try {
|
|
getDb().prepare(`UPDATE students SET last_active_at = datetime('now') WHERE student_id = ?`).run(studentId);
|
|
} catch (e) {
|
|
console.error('Failed to update student last_active_at:', e.message);
|
|
}
|
|
}
|
|
|
|
function loadProgress(studentId = 'unassigned') {
|
|
try {
|
|
const rows = getDb().prepare('SELECT * FROM progress WHERE student_id = ?').all(studentId);
|
|
return Object.fromEntries(rows.map(r => [r.scenario_id, {
|
|
status: r.status,
|
|
attempts: r.attempts,
|
|
last_validated: r.last_validated,
|
|
completed_at: r.completed_at,
|
|
started_at: r.started_at || null,
|
|
notes: r.notes || null,
|
|
time_spent_seconds: r.time_spent_seconds || 0
|
|
}]));
|
|
} catch { return {}; }
|
|
}
|
|
|
|
function saveProgress(studentId, progress) {
|
|
try {
|
|
const db = getDb();
|
|
const upsert = db.prepare(`
|
|
INSERT INTO progress (student_id, scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(student_id, scenario_id) DO UPDATE SET
|
|
status = excluded.status,
|
|
attempts = excluded.attempts,
|
|
last_validated = excluded.last_validated,
|
|
completed_at = excluded.completed_at,
|
|
started_at = excluded.started_at,
|
|
notes = excluded.notes,
|
|
time_spent_seconds = excluded.time_spent_seconds
|
|
`);
|
|
const tx = db.transaction((entries) => {
|
|
for (const [id, p] of entries) {
|
|
upsert.run(
|
|
studentId,
|
|
id,
|
|
p.status || null,
|
|
p.attempts || 0,
|
|
p.last_validated || null,
|
|
p.completed_at || null,
|
|
p.started_at || null,
|
|
p.notes || null,
|
|
p.time_spent_seconds || 0
|
|
);
|
|
}
|
|
});
|
|
tx(Object.entries(progress));
|
|
} catch (e) {
|
|
console.error('Failed to save progress:', e.message);
|
|
}
|
|
}
|
|
|
|
|
|
function loadJsonDir(dir) {
|
|
return fs.readdirSync(dir)
|
|
.filter(f => f.endsWith('.json'))
|
|
.map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')));
|
|
}
|
|
|
|
let scenariosCache = [];
|
|
let bundlesCache = [];
|
|
let curriculumPathCache = null;
|
|
|
|
function reloadCache() {
|
|
try {
|
|
scenariosCache = loadJsonDir(SCENARIOS_DIR);
|
|
bundlesCache = loadJsonDir(BUNDLES_DIR);
|
|
curriculumPathCache = fs.existsSync(CURRICULUM_PATH_FILE)
|
|
? JSON.parse(fs.readFileSync(CURRICULUM_PATH_FILE, 'utf8'))
|
|
: null;
|
|
console.log(`Loaded ${scenariosCache.length} scenarios and ${bundlesCache.length} bundles into cache.`);
|
|
} catch (e) {
|
|
console.error('Failed to reload cache:', e.message);
|
|
}
|
|
}
|
|
|
|
// Initial cache populate
|
|
reloadCache();
|
|
|
|
function loadScenarios() {
|
|
return scenariosCache;
|
|
}
|
|
|
|
function loadBundles() {
|
|
return bundlesCache;
|
|
}
|
|
|
|
function loadCurriculumPath() {
|
|
return curriculumPathCache;
|
|
}
|
|
|
|
async function runCommand(cmd, timeoutMs = 15000) {
|
|
try {
|
|
const { stdout } = await execAsync(cmd, {
|
|
timeout: timeoutMs,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, KUBECONFIG: process.env.KUBECONFIG || '/root/.kube/config' }
|
|
});
|
|
return { success: true, output: stdout.trim() };
|
|
} catch (e) {
|
|
return { success: false, output: (e.stdout || '').trim(), error: (e.stderr || e.message || '').trim() };
|
|
}
|
|
}
|
|
|
|
function checkMatch(actual, expected, matchType) {
|
|
const a = String(actual).trim();
|
|
const e = String(expected).trim();
|
|
if (matchType === 'exact') return a === e;
|
|
if (matchType === 'contains') return a.includes(e);
|
|
if (matchType === 'not_contains') return !a.includes(e);
|
|
if (matchType === 'regex') return new RegExp(e).test(a);
|
|
return a === e;
|
|
}
|
|
|
|
// Active WebSocket terminal clients — write output directly (NOT as shell input)
|
|
const activeWsClients = new Set()
|
|
// Active PTY shells — used only to write '\r' and trigger PS1 prompt repaint
|
|
const activeShells = new Set()
|
|
|
|
// Inject text directly into all terminals as output (never touches shell stdin)
|
|
function injectToTerminal(text) {
|
|
for (const ws of activeWsClients) {
|
|
try {
|
|
if (ws.readyState === WebSocket.OPEN) ws.send(text)
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
// Write a carriage-return to every active shell so bash repaints its PS1 prompt
|
|
function refreshPrompt(delayMs = 80) {
|
|
setTimeout(() => {
|
|
for (const shell of activeShells) {
|
|
try { shell.write('\r') } catch (_) {}
|
|
}
|
|
}, delayMs)
|
|
}
|
|
|
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
|
|
|
// ── Roster / student login ──────────────────────────────────────────────────
|
|
|
|
// POST /api/roster — instructor adds a student to a cohort
|
|
app.post('/api/roster', (req, res) => {
|
|
const { display_name, cohort } = req.body;
|
|
if (!display_name || !cohort) {
|
|
return res.status(400).json({ error: 'display_name and cohort are required' });
|
|
}
|
|
try {
|
|
const db = getDb();
|
|
const count = db.prepare('SELECT COUNT(*) AS n FROM students').get().n;
|
|
const studentId = `student-${String(count + 1).padStart(3, '0')}`;
|
|
db.prepare(`INSERT INTO students (student_id, display_name, cohort, created_at) VALUES (?, ?, ?, datetime('now'))`)
|
|
.run(studentId, display_name.trim(), cohort.trim());
|
|
res.json({ student_id: studentId, display_name: display_name.trim(), cohort: cohort.trim() });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// GET /api/roster/cohorts — distinct cohort names, for the login cohort picker
|
|
app.get('/api/roster/cohorts', (req, res) => {
|
|
const rows = getDb().prepare('SELECT DISTINCT cohort FROM students ORDER BY cohort').all();
|
|
res.json(rows.map(r => r.cohort));
|
|
});
|
|
|
|
// GET /api/roster?cohort=root-fall-2026 — student names for the pick-your-name dropdown
|
|
app.get('/api/roster', (req, res) => {
|
|
const { cohort } = req.query;
|
|
const db = getDb();
|
|
const rows = cohort
|
|
? db.prepare('SELECT student_id, display_name, cohort, last_active_at FROM students WHERE cohort = ? ORDER BY display_name').all(cohort)
|
|
: db.prepare('SELECT student_id, display_name, cohort, last_active_at FROM students ORDER BY cohort, display_name').all();
|
|
res.json(rows);
|
|
});
|
|
|
|
// POST /api/login — student picks their name; no password (see students table comment)
|
|
app.post('/api/login', (req, res) => {
|
|
const { student_id } = req.body;
|
|
const student = getDb().prepare('SELECT student_id, display_name, cohort FROM students WHERE student_id = ?').get(student_id);
|
|
if (!student) return res.status(404).json({ error: 'Student not found' });
|
|
touchStudent(student.student_id);
|
|
res.json(student);
|
|
});
|
|
|
|
// POST /api/progress/reset — reset progress for scenario | category | bundle
|
|
app.post('/api/progress/reset', (req, res) => {
|
|
const { scope, scenarioId, category, bundleId } = req.body;
|
|
const studentId = getStudentId(req);
|
|
const db = getDb();
|
|
const resetProgressStmt = db.prepare(`
|
|
UPDATE progress
|
|
SET status = 'not_started',
|
|
attempts = 0,
|
|
completed_at = NULL,
|
|
last_validated = NULL
|
|
WHERE scenario_id = ? AND student_id = ?
|
|
`);
|
|
|
|
try {
|
|
if (scope === 'scenario') {
|
|
resetProgressStmt.run(scenarioId, studentId);
|
|
} else if (scope === 'category') {
|
|
const scenarios = loadScenarios();
|
|
const ids = scenarios.filter(s => s.category === category).map(s => s.id);
|
|
const tx = db.transaction(ids => ids.forEach(id => resetProgressStmt.run(id, studentId)));
|
|
tx(ids);
|
|
} else if (scope === 'bundle') {
|
|
const bundle = loadBundles().find(b => b.id === bundleId);
|
|
if (!bundle) return res.status(404).json({ error: 'Bundle not found' });
|
|
const tx = db.transaction(ids => ids.forEach(id => resetProgressStmt.run(id, studentId)));
|
|
tx(bundle.scenario_ids);
|
|
} else {
|
|
return res.status(400).json({ error: 'Invalid scope' });
|
|
}
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// ── Exam sessions ─────────────────────────────────────────────────────────────
|
|
|
|
// Fisher-Yates shuffle (returns a new array)
|
|
function shuffle(arr) {
|
|
const a = [...arr];
|
|
for (let i = a.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[a[i], a[j]] = [a[j], a[i]];
|
|
}
|
|
return a;
|
|
}
|
|
|
|
// POST /api/sessions — start a new exam session
|
|
app.post('/api/sessions', (req, res) => {
|
|
const { bundleId, examMinutes, scenarioCount } = req.body;
|
|
const bundle = loadBundles().find(b => b.id === bundleId);
|
|
if (!bundle) return res.status(404).json({ error: 'Bundle not found' });
|
|
const db = getDb();
|
|
const mins = Math.max(5, Math.min(300, Number(examMinutes) || bundle.exam_minutes || 120));
|
|
// Shuffle and optionally slice scenario IDs
|
|
const allIds = bundle.scenario_ids || [];
|
|
const count = Math.max(1, Math.min(allIds.length, Number(scenarioCount) || allIds.length));
|
|
const sessionScenarioIds = shuffle(allIds).slice(0, count);
|
|
// Abandon any existing active session
|
|
db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now')
|
|
WHERE status='active'`).run();
|
|
const id = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
const studentId = getStudentId(req);
|
|
db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes, scenario_ids, student_id)
|
|
VALUES (?, ?, datetime('now'), 'active', ?, ?, ?)`)
|
|
.run(id, bundleId, mins, JSON.stringify(sessionScenarioIds), studentId);
|
|
res.json({ id, bundleId, status: 'active', exam_minutes: mins, scenario_ids: sessionScenarioIds });
|
|
});
|
|
|
|
// GET /api/sessions/active — get the current active session
|
|
app.get('/api/sessions/active', (req, res) => {
|
|
const db = getDb();
|
|
const session = db.prepare(`SELECT * FROM sessions WHERE status='active' ORDER BY started_at DESC LIMIT 1`).get();
|
|
if (!session) return res.json(null);
|
|
// Use session-specific scenario_ids (shuffled/sliced at start time)
|
|
let scenarioIds = [];
|
|
try { scenarioIds = session.scenario_ids ? JSON.parse(session.scenario_ids) : []; } catch (_) {}
|
|
if (!scenarioIds.length) {
|
|
// Fallback for sessions created before this feature
|
|
const bundle = loadBundles().find(b => b.id === session.bundle_id);
|
|
scenarioIds = bundle?.scenario_ids || [];
|
|
}
|
|
const completed = db.prepare(
|
|
`SELECT COUNT(*) as cnt FROM exam_progress WHERE session_id=? AND status='completed'`
|
|
).get(session.id)?.cnt || 0;
|
|
res.json({ ...session, scenario_ids: scenarioIds, scenarioCount: scenarioIds.length, completedCount: completed });
|
|
});
|
|
|
|
// GET /api/sessions/:id/exam-progress — return per-scenario progress for an exam session
|
|
app.get('/api/sessions/:id/exam-progress', (req, res) => {
|
|
const db = getDb();
|
|
const rows = db.prepare(`SELECT * FROM exam_progress WHERE session_id=?`).all(req.params.id);
|
|
// Return as a map: scenario_id -> { status, attempts, completed_at }
|
|
const result = {};
|
|
for (const r of rows) {
|
|
result[r.scenario_id] = { status: r.status, attempts: r.attempts, completed_at: r.completed_at };
|
|
}
|
|
res.json(result);
|
|
});
|
|
|
|
// GET /api/sessions/history — list all completed/abandoned exam sessions (newest first)
|
|
app.get('/api/sessions/history', (req, res) => {
|
|
const db = getDb();
|
|
const sessions = db.prepare(
|
|
`SELECT * FROM sessions WHERE status != 'active' ORDER BY started_at DESC`
|
|
).all();
|
|
const bundles = loadBundles();
|
|
|
|
const result = sessions.map(s => {
|
|
const bundle = bundles.find(b => b.id === s.bundle_id);
|
|
let snapshot = [];
|
|
try { snapshot = s.snapshot ? JSON.parse(s.snapshot) : []; } catch (_) {}
|
|
const completed = snapshot.filter(x => x.status === 'completed');
|
|
const totalWeight = snapshot.reduce((a, x) => a + (x.weight || 0), 0);
|
|
const earnedWeight = completed.reduce((a, x) => a + (x.weight || 0), 0);
|
|
const pct = totalWeight > 0 ? Math.round((earnedWeight / totalWeight) * 100) : 0;
|
|
return {
|
|
id: s.id,
|
|
bundle_id: s.bundle_id,
|
|
bundle_name: bundle?.name || s.bundle_id,
|
|
bundle_icon: bundle?.icon || '🎓',
|
|
status: s.status,
|
|
started_at: s.started_at,
|
|
submitted_at: s.submitted_at,
|
|
exam_minutes: s.exam_minutes,
|
|
duration_secs: s.duration_secs,
|
|
scenarioCount: snapshot.length,
|
|
completedCount: completed.length,
|
|
totalWeight,
|
|
earnedWeight,
|
|
pct,
|
|
passed: pct >= 66,
|
|
snapshot,
|
|
};
|
|
});
|
|
|
|
res.json(result);
|
|
});
|
|
|
|
// POST /api/sessions/:id/submit — submit the exam session
|
|
app.post('/api/sessions/:id/submit', (req, res) => {
|
|
const db = getDb();
|
|
const session = db.prepare(`SELECT * FROM sessions WHERE id=?`).get(req.params.id);
|
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
const bundle = loadBundles().find(b => b.id === session.bundle_id);
|
|
const scenarios = loadScenarios();
|
|
// Use session-specific scenario IDs (preserves shuffle order)
|
|
let sessionIds = [];
|
|
try { sessionIds = session.scenario_ids ? JSON.parse(session.scenario_ids) : []; } catch (_) {}
|
|
if (!sessionIds.length) sessionIds = bundle?.scenario_ids || [];
|
|
const bundleScenarios = sessionIds.map(id => scenarios.find(s => s.id === id)).filter(Boolean);
|
|
|
|
// Build snapshot from exam_progress (exam-specific), falling back to 'not_started'
|
|
const examProgressRows = db.prepare(`SELECT * FROM exam_progress WHERE session_id=?`).all(req.params.id);
|
|
const examProgressMap = {};
|
|
for (const r of examProgressRows) examProgressMap[r.scenario_id] = r;
|
|
|
|
// Also pull time_spent_seconds from global progress (tracked per scenario during the exam)
|
|
// Use the session's own student, not whoever's request is submitting it.
|
|
const globalProgress = loadProgress(session.student_id || 'unassigned');
|
|
|
|
const snapshot = bundleScenarios.map(s => ({
|
|
id: s.id, title: s.title, weight: s.weight,
|
|
category: s.category, type: s.type, difficulty: s.difficulty,
|
|
status: examProgressMap[s.id]?.status || 'not_started',
|
|
completed_at: examProgressMap[s.id]?.completed_at || null,
|
|
attempts: examProgressMap[s.id]?.attempts || 0,
|
|
time_spent_seconds: globalProgress[s.id]?.time_spent_seconds || 0,
|
|
}));
|
|
const startedAt = new Date(session.started_at + 'Z');
|
|
const durationSecs = Math.round((Date.now() - startedAt.getTime()) / 1000);
|
|
db.prepare(`UPDATE sessions SET status='submitted', submitted_at=datetime('now'),
|
|
duration_secs=?, snapshot=? WHERE id=?`)
|
|
.run(durationSecs, JSON.stringify(snapshot), req.params.id);
|
|
|
|
// Reset timers of all scenarios in this bundle, for this session's student only
|
|
const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ? AND student_id = ?`);
|
|
const sid = session.student_id || 'unassigned';
|
|
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id, sid)));
|
|
tx(bundle.scenario_ids);
|
|
|
|
res.json({ ok: true, snapshot, durationSecs });
|
|
});
|
|
|
|
// POST /api/sessions/:id/abandon — forfeit the exam without a score report
|
|
app.post('/api/sessions/:id/abandon', (req, res) => {
|
|
const db = getDb();
|
|
const session = db.prepare('SELECT * FROM sessions WHERE id = ?').get(req.params.id);
|
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
const bundles = loadBundles();
|
|
const bundle = bundles.find(b => b.id === session.bundle_id);
|
|
|
|
db.prepare(`UPDATE sessions SET status='abandoned', submitted_at=datetime('now')
|
|
WHERE id=? AND status='active'`).run(req.params.id);
|
|
|
|
if (bundle) {
|
|
const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ? AND student_id = ?`);
|
|
const sid = session.student_id || 'unassigned';
|
|
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id, sid)));
|
|
tx(bundle.scenario_ids);
|
|
}
|
|
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
|
|
// POST /api/scenarios/:id/teardown — run teardown_commands
|
|
app.post('/api/scenarios/:id/teardown', async (req, res) => {
|
|
const scenarios = loadScenarios();
|
|
const scenario = scenarios.find(s => s.id === req.params.id);
|
|
if (!scenario) return res.status(404).json({ error: 'Scenario not found' });
|
|
const results = [];
|
|
for (const item of (scenario.teardown_commands || [])) {
|
|
const cmd = item.command;
|
|
const result = await runCommand(cmd, 30000);
|
|
results.push({ command: cmd, ...result });
|
|
}
|
|
res.json({ ok: true, results });
|
|
});
|
|
|
|
// ── Context sync (Feature 3) ──────────────────────────────────────────────────
|
|
|
|
// POST /api/scenarios/:id/context — inject namespace + banner into active terminals
|
|
app.post('/api/scenarios/:id/context', async (req, res) => {
|
|
const scenarios = loadScenarios();
|
|
const scenario = scenarios.find(s => s.id === req.params.id);
|
|
if (!scenario) return res.status(404).json({ error: 'Scenario not found' });
|
|
const ns = scenario.default_namespace || 'default';
|
|
|
|
// VT sequences: clear visible screen + scrollback, then move cursor to top-left
|
|
const clearScreen = '\x1b[2J\x1b[3J\x1b[H';
|
|
const line = '\u2500'.repeat(54);
|
|
const banner = [
|
|
`\x1b[2m# ${line}\x1b[0m\r\n`,
|
|
`\x1b[1m\x1b[36m\u2388 Scenario : \x1b[0m\x1b[1m\x1b[97m${scenario.title}\x1b[0m\r\n`,
|
|
`\x1b[2m Namespace: \x1b[0m\x1b[33m${ns}\x1b[0m`,
|
|
` \x1b[2mDifficulty: \x1b[0m${scenario.difficulty === 'Easy' ? '\x1b[32m' : scenario.difficulty === 'Hard' ? '\x1b[31m' : '\x1b[33m'}${scenario.difficulty}\x1b[0m\r\n`,
|
|
`\x1b[2m# ${line}\x1b[0m\r\n`,
|
|
].join('');
|
|
|
|
// Set kubectl context namespace silently
|
|
await runCommand(`kubectl config set-context --current --namespace=${ns}`, 5000);
|
|
|
|
// 1) Clear screen and write banner as terminal output (never touches shell stdin)
|
|
injectToTerminal(clearScreen + banner);
|
|
|
|
// 2) After banner renders, write \r to each PTY so bash repaints its PS1 prompt
|
|
refreshPrompt(80);
|
|
|
|
res.json({ ok: true, namespace: ns });
|
|
});
|
|
|
|
// GET /api/bundles — list bundles with per-bundle progress stats
|
|
app.get('/api/bundles', (req, res) => {
|
|
const bundles = loadBundles();
|
|
const scenarios = loadScenarios();
|
|
const progress = loadProgress(getStudentId(req));
|
|
const result = bundles.map(b => {
|
|
const total = b.scenario_ids.length;
|
|
const completed = b.scenario_ids.filter(id => progress[id]?.status === 'completed').length;
|
|
return { ...b, stats: { total, completed } };
|
|
});
|
|
res.json(result);
|
|
});
|
|
|
|
// GET /api/curriculum/path — the guided RootNode learning path, staged with completion status
|
|
app.get('/api/curriculum/path', (req, res) => {
|
|
const path = loadCurriculumPath();
|
|
if (!path) return res.status(404).json({ error: 'Curriculum path not found' });
|
|
const scenarios = loadScenarios();
|
|
const progress = loadProgress(getStudentId(req));
|
|
|
|
let firstIncompleteStage = null;
|
|
const stages = path.stages.map(stage => {
|
|
const scenarioList = stage.scenario_ids.map(id => {
|
|
const s = scenarios.find(x => x.id === id);
|
|
return {
|
|
id,
|
|
title: s?.title || id,
|
|
difficulty: s?.difficulty || null,
|
|
status: progress[id]?.status || 'not_started'
|
|
};
|
|
});
|
|
const total = scenarioList.length;
|
|
const completed = scenarioList.filter(s => s.status === 'completed').length;
|
|
const isComplete = total > 0 && completed === total;
|
|
if (!isComplete && !stage.locked && firstIncompleteStage === null) {
|
|
firstIncompleteStage = stage.id;
|
|
}
|
|
return {
|
|
id: stage.id,
|
|
name: stage.name,
|
|
icon: stage.icon,
|
|
locked: !!stage.locked,
|
|
scenarios: scenarioList,
|
|
stats: { total, completed }
|
|
};
|
|
});
|
|
|
|
res.json({ id: path.id, name: path.name, tagline: path.tagline, stages, next_stage: firstIncompleteStage });
|
|
});
|
|
|
|
// GET /api/scenarios — list scenarios; optional ?bundle=<id> and ?session=<id> filter
|
|
app.get('/api/scenarios', (req, res) => {
|
|
const scenarios = loadScenarios()
|
|
const progress = loadProgress(getStudentId(req))
|
|
const { bundle, session: sessionId } = req.query
|
|
|
|
// Session-scoped: return only session scenario_ids in their shuffled order
|
|
if (sessionId) {
|
|
const db = getDb()
|
|
const session = db.prepare(`SELECT scenario_ids FROM sessions WHERE id=?`).get(sessionId)
|
|
let sessionIds = []
|
|
try { sessionIds = session?.scenario_ids ? JSON.parse(session.scenario_ids) : [] } catch (_) {}
|
|
const list = sessionIds
|
|
.map(id => scenarios.find(s => s.id === id))
|
|
.filter(Boolean)
|
|
.map(s => ({
|
|
id: s.id, title: s.title, category: s.category,
|
|
difficulty: s.difficulty, type: s.type, weight: s.weight,
|
|
progress: progress[s.id] || { status: 'not_started', attempts: 0 }
|
|
}))
|
|
return res.json(list)
|
|
}
|
|
|
|
let filtered = scenarios
|
|
if (bundle) {
|
|
const bundles = loadBundles()
|
|
const b = bundles.find(x => x.id === bundle)
|
|
if (b) filtered = scenarios.filter(s => b.scenario_ids.includes(s.id))
|
|
}
|
|
|
|
const list = filtered.map(s => ({
|
|
id: s.id,
|
|
title: s.title,
|
|
category: s.category,
|
|
difficulty: s.difficulty,
|
|
type: s.type,
|
|
weight: s.weight,
|
|
progress: progress[s.id] || { status: 'not_started', attempts: 0 }
|
|
}))
|
|
res.json(list)
|
|
})
|
|
|
|
// GET /api/scenarios/:id — full scenario detail
|
|
app.get('/api/scenarios/:id', (req, res) => {
|
|
const scenarios = loadScenarios();
|
|
const scenario = scenarios.find(s => s.id === req.params.id);
|
|
if (!scenario) return res.status(404).json({ error: 'Scenario not found' });
|
|
const progress = loadProgress(getStudentId(req));
|
|
res.json({
|
|
...scenario,
|
|
progress: progress[scenario.id] || { status: 'not_started', attempts: 0 }
|
|
});
|
|
});
|
|
|
|
// POST /api/scenarios/:id/setup — run setup_commands for a scenario
|
|
app.post('/api/scenarios/:id/setup', async (req, res) => {
|
|
const scenarios = loadScenarios();
|
|
const scenario = scenarios.find(s => s.id === req.params.id);
|
|
if (!scenario) return res.status(404).json({ error: 'Scenario not found' });
|
|
|
|
const results = [];
|
|
for (const item of (scenario.setup_commands || [])) {
|
|
const cmd = item.command;
|
|
const result = await runCommand(cmd, 30000);
|
|
results.push({ command: cmd, ...result });
|
|
if (!result.success) {
|
|
// Non-fatal: setup commands like "kubectl create namespace" fail if already exists
|
|
console.warn(`Setup command warning: ${cmd} -> ${result.error}`);
|
|
}
|
|
}
|
|
|
|
// Mark scenario as in-progress
|
|
const studentId = getStudentId(req);
|
|
const progress = loadProgress(studentId);
|
|
if (!progress[scenario.id] || progress[scenario.id].status === 'not_started') {
|
|
progress[scenario.id] = {
|
|
...progress[scenario.id],
|
|
status: 'in_progress',
|
|
attempts: (progress[scenario.id]?.attempts || 0),
|
|
started_at: progress[scenario.id]?.started_at || new Date().toISOString()
|
|
};
|
|
saveProgress(studentId, progress);
|
|
}
|
|
|
|
res.json({ setup_results: results });
|
|
});
|
|
|
|
// POST /api/scenarios/:id/validate — validate task-based scenario
|
|
app.post('/api/scenarios/:id/validate', async (req, res) => {
|
|
const scenarios = loadScenarios();
|
|
const scenario = scenarios.find(s => s.id === req.params.id);
|
|
if (!scenario) return res.status(404).json({ error: 'Scenario not found' });
|
|
if (scenario.type !== 'task') return res.status(400).json({ error: 'Not a task scenario' });
|
|
|
|
const checks = [];
|
|
let allPassed = true;
|
|
|
|
for (const check of (scenario.validation?.commands || [])) {
|
|
const result = await runCommand(check.command, 5000);
|
|
// Prefer stdout. Only fall back to stderr for non-API errors:
|
|
// - kubectl auth can-i prints "yes"/"no" to stdout → already in result.output.
|
|
// - kubectl get <missing-resource> prints "Error from server (NotFound):" to stderr
|
|
// and nothing to stdout → suppress, return '' so the check fails cleanly.
|
|
const isKubectlApiError = result.error &&
|
|
/^Error from server|^error:|^Error:/i.test(result.error.trim());
|
|
const actual = result.output || (isKubectlApiError ? '' : result.error) || '';
|
|
const passed = checkMatch(actual, check.expected_output, check.match);
|
|
|
|
checks.push({
|
|
description: check.description,
|
|
command: check.command,
|
|
expected: check.expected_output,
|
|
actual,
|
|
passed
|
|
});
|
|
if (!passed) allPassed = false;
|
|
}
|
|
|
|
// Update global practice progress
|
|
const studentId = getStudentId(req);
|
|
const progress = loadProgress(studentId);
|
|
const prev = progress[scenario.id] || { attempts: 0 };
|
|
progress[scenario.id] = {
|
|
...prev,
|
|
status: allPassed ? 'completed' : 'in_progress',
|
|
attempts: (prev.attempts || 0) + 1,
|
|
last_validated: new Date().toISOString(),
|
|
completed_at: allPassed ? new Date().toISOString() : prev.completed_at
|
|
};
|
|
saveProgress(studentId, progress);
|
|
touchStudent(studentId);
|
|
|
|
// Also update exam_progress if there's an active session
|
|
const db = getDb();
|
|
const activeSession = db.prepare(`SELECT id FROM sessions WHERE status='active' LIMIT 1`).get();
|
|
if (activeSession) {
|
|
const ep = db.prepare(`SELECT * FROM exam_progress WHERE session_id=? AND scenario_id=?`)
|
|
.get(activeSession.id, scenario.id);
|
|
const epAttempts = (ep?.attempts || 0) + 1;
|
|
db.prepare(`
|
|
INSERT INTO exam_progress (session_id, scenario_id, status, attempts, completed_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(session_id, scenario_id) DO UPDATE SET
|
|
status = excluded.status,
|
|
attempts = excluded.attempts,
|
|
completed_at = COALESCE(excluded.completed_at, exam_progress.completed_at)
|
|
`).run(
|
|
activeSession.id, scenario.id,
|
|
allPassed ? 'completed' : 'in_progress',
|
|
epAttempts,
|
|
allPassed ? new Date().toISOString() : null
|
|
);
|
|
}
|
|
|
|
res.json({ passed: allPassed, checks, attempts: progress[scenario.id].attempts });
|
|
});
|
|
|
|
// POST /api/scenarios/:id/answer — submit MCQ answer
|
|
app.post('/api/scenarios/:id/answer', (req, res) => {
|
|
const { selected } = req.body;
|
|
const scenarios = loadScenarios();
|
|
const scenario = scenarios.find(s => s.id === req.params.id);
|
|
if (!scenario) return res.status(404).json({ error: 'Scenario not found' });
|
|
if (scenario.type !== 'mcq') return res.status(400).json({ error: 'Not an MCQ scenario' });
|
|
|
|
const correct = selected === scenario.correct_option;
|
|
|
|
// Update global practice progress
|
|
const studentId = getStudentId(req);
|
|
const progress = loadProgress(studentId);
|
|
const prev = progress[scenario.id] || { attempts: 0 };
|
|
progress[scenario.id] = {
|
|
...prev,
|
|
status: correct ? 'completed' : 'in_progress',
|
|
attempts: (prev.attempts || 0) + 1,
|
|
last_answer: selected,
|
|
last_validated: new Date().toISOString(),
|
|
completed_at: correct ? new Date().toISOString() : prev.completed_at
|
|
};
|
|
saveProgress(studentId, progress);
|
|
touchStudent(studentId);
|
|
|
|
// Also update exam_progress if there's an active session
|
|
const db = getDb();
|
|
const activeSession = db.prepare(`SELECT id FROM sessions WHERE status='active' LIMIT 1`).get();
|
|
if (activeSession) {
|
|
const ep = db.prepare(`SELECT * FROM exam_progress WHERE session_id=? AND scenario_id=?`)
|
|
.get(activeSession.id, scenario.id);
|
|
const epAttempts = (ep?.attempts || 0) + 1;
|
|
db.prepare(`
|
|
INSERT INTO exam_progress (session_id, scenario_id, status, attempts, completed_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(session_id, scenario_id) DO UPDATE SET
|
|
status = excluded.status,
|
|
attempts = excluded.attempts,
|
|
completed_at = COALESCE(excluded.completed_at, exam_progress.completed_at)
|
|
`).run(
|
|
activeSession.id, scenario.id,
|
|
correct ? 'completed' : 'in_progress',
|
|
epAttempts,
|
|
correct ? new Date().toISOString() : null
|
|
);
|
|
}
|
|
|
|
res.json({
|
|
correct,
|
|
correct_option: scenario.correct_option,
|
|
explanation: scenario.explanation,
|
|
attempts: progress[scenario.id].attempts
|
|
});
|
|
});
|
|
|
|
// POST /api/scenarios/:id/time — update time spent on a scenario
|
|
app.post('/api/scenarios/:id/time', (req, res) => {
|
|
const { time_spent_seconds } = req.body;
|
|
if (typeof time_spent_seconds !== 'number') {
|
|
return res.status(400).json({ error: 'Invalid time_spent_seconds' });
|
|
}
|
|
const db = getDb();
|
|
const activeSession = db.prepare("SELECT 1 FROM sessions WHERE status='active'").get();
|
|
if (!activeSession) {
|
|
return res.json({ ok: true, message: 'No active session' });
|
|
}
|
|
const studentId = getStudentId(req);
|
|
const progress = loadProgress(studentId);
|
|
if (!progress[req.params.id]) {
|
|
progress[req.params.id] = {
|
|
status: 'in_progress',
|
|
attempts: 0,
|
|
started_at: new Date().toISOString(),
|
|
time_spent_seconds: 0
|
|
};
|
|
} else if (!progress[req.params.id].started_at) {
|
|
progress[req.params.id].started_at = new Date().toISOString();
|
|
}
|
|
progress[req.params.id].time_spent_seconds = time_spent_seconds;
|
|
saveProgress(studentId, progress);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// POST /api/progress/record — Phase 2A addition. Records a scenario outcome WITHOUT
|
|
// re-running validation. Exists because scenario validation itself must run against the
|
|
// student's own live cluster (inside their ephemeral Lab container), but the canonical
|
|
// progress record needs to live here, on the persistent identity instance — the Gateway
|
|
// calls this after a real validate/answer already succeeded against the student's Lab, to
|
|
// relay the outcome. See docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md "Progress relay."
|
|
app.post('/api/progress/record', (req, res) => {
|
|
const { scenario_id, status } = req.body || {};
|
|
if (!scenario_id || !['completed', 'in_progress'].includes(status)) {
|
|
return res.status(400).json({ error: 'scenario_id and a valid status are required' });
|
|
}
|
|
const studentId = getStudentId(req);
|
|
const progress = loadProgress(studentId);
|
|
const prev = progress[scenario_id] || { attempts: 0 };
|
|
progress[scenario_id] = {
|
|
...prev,
|
|
status,
|
|
attempts: (prev.attempts || 0) + 1,
|
|
last_validated: new Date().toISOString(),
|
|
completed_at: status === 'completed' ? new Date().toISOString() : prev.completed_at,
|
|
};
|
|
saveProgress(studentId, progress);
|
|
touchStudent(studentId);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// GET /api/progress — full progress summary
|
|
app.get('/api/progress', (req, res) => {
|
|
const scenarios = loadScenarios();
|
|
const progress = loadProgress(getStudentId(req));
|
|
const total = scenarios.length;
|
|
const completed = Object.values(progress).filter(p => p.status === 'completed').length;
|
|
const totalWeight = scenarios.reduce((sum, s) => sum + (s.weight || 0), 0);
|
|
const earnedWeight = scenarios
|
|
.filter(s => progress[s.id]?.status === 'completed')
|
|
.reduce((sum, s) => sum + (s.weight || 0), 0);
|
|
res.json({
|
|
total, completed,
|
|
score_pct: totalWeight > 0 ? Math.round((earnedWeight / totalWeight) * 100) : 0,
|
|
details: progress
|
|
});
|
|
});
|
|
|
|
// POST /api/progress/reset/:id — reset a scenario
|
|
app.post('/api/progress/reset/:id', (req, res) => {
|
|
try {
|
|
const db = getDb();
|
|
db.prepare(`
|
|
UPDATE progress
|
|
SET status = 'not_started',
|
|
attempts = 0,
|
|
completed_at = NULL,
|
|
last_validated = NULL
|
|
WHERE scenario_id = ? AND student_id = ?
|
|
`).run(req.params.id, getStudentId(req));
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// POST /api/cache/reload — reload scenarios and bundles cache
|
|
app.post('/api/cache/reload', (req, res) => {
|
|
reloadCache();
|
|
res.json({
|
|
ok: true,
|
|
message: 'Cache reloaded successfully',
|
|
scenarios_count: loadScenarios().length,
|
|
bundles_count: loadBundles().length
|
|
});
|
|
});
|
|
|
|
// GET /api/health
|
|
app.get('/api/health', async (req, res) => {
|
|
const kube = await runCommand('kubectl cluster-info --request-timeout=3s 2>&1 | head -1');
|
|
res.json({ api: 'ok', cluster: kube.success ? 'ready' : 'not_ready', cluster_info: kube.output });
|
|
});
|
|
|
|
// Fallback to frontend SPA
|
|
app.get('*', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
|
|
});
|
|
|
|
// ── WebSocket PTY terminal ────────────────────────────────────────────────────
|
|
const http = require('http');
|
|
const WebSocket = require('ws');
|
|
const pty = require('node-pty');
|
|
|
|
const server = http.createServer(app);
|
|
const wss = new WebSocket.Server({ noServer: true });
|
|
|
|
wss.on('connection', (ws) => {
|
|
activeWsClients.add(ws);
|
|
|
|
const shell = pty.spawn('/bin/bash', [], {
|
|
name: 'xterm-256color',
|
|
cols: 80,
|
|
rows: 24,
|
|
cwd: '/root',
|
|
env: {
|
|
...process.env,
|
|
KUBECONFIG: '/root/.kube/config',
|
|
HOME: '/root',
|
|
TERM: 'xterm-256color',
|
|
},
|
|
});
|
|
|
|
activeShells.add(shell);
|
|
|
|
// Forward PTY output → browser
|
|
shell.onData((data) => {
|
|
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
|
});
|
|
|
|
shell.onExit(() => {
|
|
activeWsClients.delete(ws);
|
|
activeShells.delete(shell);
|
|
if (ws.readyState === WebSocket.OPEN) ws.close();
|
|
});
|
|
|
|
// Forward browser input → PTY
|
|
ws.on('message', (msg) => {
|
|
try {
|
|
const parsed = JSON.parse(msg);
|
|
if (parsed.type === 'resize') {
|
|
shell.resize(Number(parsed.cols) || 80, Number(parsed.rows) || 24);
|
|
return;
|
|
}
|
|
} catch (_) { /* not JSON → raw input */ }
|
|
shell.write(typeof msg === 'string' ? msg : msg.toString());
|
|
});
|
|
|
|
ws.on('close', () => { activeWsClients.delete(ws); activeShells.delete(shell); try { shell.kill(); } catch (_) {} });
|
|
ws.on('error', () => { activeWsClients.delete(ws); activeShells.delete(shell); try { shell.kill(); } catch (_) {} });
|
|
});
|
|
|
|
// Handle WebSocket upgrades only for /shell-ws
|
|
server.on('upgrade', (req, socket, head) => {
|
|
if (req.url === '/shell-ws') {
|
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
wss.emit('connection', ws, req);
|
|
});
|
|
} else {
|
|
socket.destroy();
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, () => console.log(`API server running on :${PORT}`));
|
|
|