feat(curriculum): add RootNode learning path, student roster, and scenario isolation metadata

Pre-Phase-1 platform work, approved and tested earlier but not yet committed:

- Terminal banner: "RootNode Academy Lab — node: k8s-lab", upstream KubeKosh
  attribution left untouched in footer/README/LICENSE.
- RootNode curriculum mode: scenarios/rootnode-path.json (Foundation -> Workloads
  -> Networking -> Configuration -> Troubleshooting -> Real Ops -> Capstone),
  built from existing scenario content grouped by category, no new scenarios
  invented. New GET /api/curriculum/path endpoint.
- Student roster + per-student progress: instructor-roster, pick-your-name login
  (no password), students/environments-adjacent progress table migrated to a
  (student_id, scenario_id) composite key so practice progress and exam sessions
  are isolated per student instead of one global bucket. New /api/roster,
  /api/login endpoints.
- scenarios/data/*.json: added isolation_type metadata (shared_namespace vs
  ephemeral_cluster) from an audit of which scenarios execute cluster-scoped or
  node-level commands. This was Tier-1-isolation groundwork that got superseded
  same-day by the full-ephemeral-per-student architecture (see
  docs/LAB_ARCHITECTURE.md) — the field is kept as descriptive metadata only,
  per the note now in scenarios/SCHEMA.md. Not wasted: it's an accurate audit of
  which scenarios touch cluster-scoped resources, useful independent of isolation
  model.
- .gitignore added (node_modules/, frontend/dist/, *.db, /data/, .env) — none
  existed before; without it, npm installs during testing would have landed in
  a commit.

Known limitation still open: no frontend UI for any of this yet (login screen,
roster-add form, curriculum view) — API-only. No auth on the roster-add endpoint.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-18 01:21:16 -05:00
co-authored by Claude Sonnet 5
parent 6ddfb1cbde
commit 60fbf0c385
105 changed files with 4643 additions and 55 deletions
+1184
View File
File diff suppressed because it is too large Load Diff
+211 -43
View File
@@ -14,6 +14,7 @@ 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 ─────────────────────────────────────────────────────
@@ -55,6 +56,51 @@ function getDb() {
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,
@@ -75,6 +121,9 @@ function getDb() {
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);
}
@@ -94,9 +143,26 @@ function getDb() {
return _db;
}
function loadProgress() {
// 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 {
const rows = getDb().prepare('SELECT * FROM progress').all();
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,
@@ -109,13 +175,13 @@ function loadProgress() {
} catch { return {}; }
}
function saveProgress(progress) {
function saveProgress(studentId, progress) {
try {
const db = getDb();
const upsert = db.prepare(`
INSERT INTO progress (scenario_id, status, attempts, last_validated, completed_at, started_at, notes, time_spent_seconds)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(scenario_id) DO UPDATE SET
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,
@@ -127,6 +193,7 @@ function saveProgress(progress) {
const tx = db.transaction((entries) => {
for (const [id, p] of entries) {
upsert.run(
studentId,
id,
p.status || null,
p.attempts || 0,
@@ -153,11 +220,15 @@ function loadJsonDir(dir) {
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);
@@ -175,6 +246,10 @@ function loadBundles() {
return bundlesCache;
}
function loadCurriculumPath() {
return curriculumPathCache;
}
async function runCommand(cmd, timeoutMs = 15000) {
try {
const { stdout } = await execAsync(cmd, {
@@ -223,31 +298,77 @@ function refreshPrompt(delayMs = 80) {
// ── 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 = ?
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);
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)));
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)));
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' });
@@ -285,9 +406,10 @@ app.post('/api/sessions', (req, res) => {
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)}`;
db.prepare(`INSERT INTO sessions (id, bundle_id, started_at, status, exam_minutes, scenario_ids)
VALUES (?, ?, datetime('now'), 'active', ?, ?)`)
.run(id, bundleId, mins, JSON.stringify(sessionScenarioIds));
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 });
});
@@ -380,7 +502,8 @@ app.post('/api/sessions/:id/submit', (req, res) => {
for (const r of examProgressRows) examProgressMap[r.scenario_id] = r;
// Also pull time_spent_seconds from global progress (tracked per scenario during the exam)
const globalProgress = loadProgress();
// 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,
@@ -396,9 +519,10 @@ app.post('/api/sessions/:id/submit', (req, res) => {
duration_secs=?, snapshot=? WHERE id=?`)
.run(durationSecs, JSON.stringify(snapshot), req.params.id);
// Reset timers of all scenarios in this bundle
const clearTimer = db.prepare(`UPDATE progress SET started_at = NULL, time_spent_seconds = 0 WHERE scenario_id = ?`);
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(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 });
@@ -416,8 +540,9 @@ app.post('/api/sessions/:id/abandon', (req, res) => {
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 = ?`);
const tx = db.transaction(ids => ids.forEach(id => clearTimer.run(id)));
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);
}
@@ -475,7 +600,7 @@ app.post('/api/scenarios/:id/context', async (req, res) => {
app.get('/api/bundles', (req, res) => {
const bundles = loadBundles();
const scenarios = loadScenarios();
const progress = loadProgress();
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;
@@ -484,10 +609,47 @@ app.get('/api/bundles', (req, res) => {
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()
const progress = loadProgress(getStudentId(req))
const { bundle, session: sessionId } = req.query
// Session-scoped: return only session scenario_ids in their shuffled order
@@ -531,7 +693,7 @@ 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();
const progress = loadProgress(getStudentId(req));
res.json({
...scenario,
progress: progress[scenario.id] || { status: 'not_started', attempts: 0 }
@@ -556,7 +718,8 @@ app.post('/api/scenarios/:id/setup', async (req, res) => {
}
// Mark scenario as in-progress
const progress = loadProgress();
const studentId = getStudentId(req);
const progress = loadProgress(studentId);
if (!progress[scenario.id] || progress[scenario.id].status === 'not_started') {
progress[scenario.id] = {
...progress[scenario.id],
@@ -564,7 +727,7 @@ app.post('/api/scenarios/:id/setup', async (req, res) => {
attempts: (progress[scenario.id]?.attempts || 0),
started_at: progress[scenario.id]?.started_at || new Date().toISOString()
};
saveProgress(progress);
saveProgress(studentId, progress);
}
res.json({ setup_results: results });
@@ -602,7 +765,8 @@ app.post('/api/scenarios/:id/validate', async (req, res) => {
}
// Update global practice progress
const progress = loadProgress();
const studentId = getStudentId(req);
const progress = loadProgress(studentId);
const prev = progress[scenario.id] || { attempts: 0 };
progress[scenario.id] = {
...prev,
@@ -611,7 +775,8 @@ app.post('/api/scenarios/:id/validate', async (req, res) => {
last_validated: new Date().toISOString(),
completed_at: allPassed ? new Date().toISOString() : prev.completed_at
};
saveProgress(progress);
saveProgress(studentId, progress);
touchStudent(studentId);
// Also update exam_progress if there's an active session
const db = getDb();
@@ -649,7 +814,8 @@ app.post('/api/scenarios/:id/answer', (req, res) => {
const correct = selected === scenario.correct_option;
// Update global practice progress
const progress = loadProgress();
const studentId = getStudentId(req);
const progress = loadProgress(studentId);
const prev = progress[scenario.id] || { attempts: 0 };
progress[scenario.id] = {
...prev,
@@ -659,7 +825,8 @@ app.post('/api/scenarios/:id/answer', (req, res) => {
last_validated: new Date().toISOString(),
completed_at: correct ? new Date().toISOString() : prev.completed_at
};
saveProgress(progress);
saveProgress(studentId, progress);
touchStudent(studentId);
// Also update exam_progress if there's an active session
const db = getDb();
@@ -702,7 +869,8 @@ app.post('/api/scenarios/:id/time', (req, res) => {
if (!activeSession) {
return res.json({ ok: true, message: 'No active session' });
}
const progress = loadProgress();
const studentId = getStudentId(req);
const progress = loadProgress(studentId);
if (!progress[req.params.id]) {
progress[req.params.id] = {
status: 'in_progress',
@@ -714,14 +882,14 @@ app.post('/api/scenarios/:id/time', (req, res) => {
progress[req.params.id].started_at = new Date().toISOString();
}
progress[req.params.id].time_spent_seconds = time_spent_seconds;
saveProgress(progress);
saveProgress(studentId, progress);
res.json({ ok: true });
});
// GET /api/progress — full progress summary
app.get('/api/progress', (req, res) => {
const scenarios = loadScenarios();
const progress = loadProgress();
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);
@@ -740,13 +908,13 @@ 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 = ?
`).run(req.params.id);
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 });