feat(learning): add scenario attempt telemetry and curriculum completion rules

Evolves scenario_attempts (Phase 2B: one row per validation click) into a real attempt
lifecycle — one row per continuous working session, with live counters (validation
attempts/passes/fails, hint reveals, duration) updated in place, not re-inserted. Backward
compatible: every new column is nullable/defaulted, existing rows backfilled under the new
semantics rather than dropped. hint_count (Phase 2B, always NULL — 'not measurable') finally
gets a real DEFAULT 0 and increments, since this is the phase that builds real hint tracking.

curriculum-rules.js computes rule-driven stage lock/complete/percent/next-scenario state from
scenarios/rootnode-path.json's new per-stage completion_rule (all_required/threshold/
prerequisites) against a student's progress. The capstone stage is no longer a hardcoded
locked placeholder — it carries an unlock_rule and a pointer at the assessment definition that
actually defines its scenario draw (see the assessment-engine commit).
This commit is contained in:
2026-08-18 14:35:11 -05:00
parent e793d9476b
commit 01a862e007
4 changed files with 231 additions and 1 deletions
@@ -0,0 +1,55 @@
-- Evolves scenario_attempts (Phase 2B) from "one row per validation click" into a real attempt
-- lifecycle: one row per continuous working session on a scenario, with aggregated telemetry
-- counters, updated in place as the student works — not a new table (Phase 3B explicitly asks
-- to extend/refine rather than duplicate). See platform/api/src/attempts.js.
--
-- Backward compatible: every new column is nullable or has a DEFAULT, so Phase 2B's existing
-- rows (real historical data from testing) stay valid without being dropped or rewritten in
-- shape — the backfill UPDATE below just gives them sane values under the new semantics.
-- Phase 2B required `result` on every row (it was always written at completion time, atomically).
-- Under the new attempt lifecycle a row can exist mid-attempt (status='in_progress') with no
-- result yet — result is only ever set once the attempt reaches a terminal state.
ALTER TABLE scenario_attempts ALTER COLUMN result DROP NOT NULL;
-- hint_count (migration 005) was deliberately left NULL / undefaulted — Phase 2B's note was
-- "not measurable today." It's measurable now (this is the phase that builds real hint
-- tracking), so it needs a DEFAULT 0 for new rows to actually increment from, and existing NULL
-- rows backfilled to 0 (same "not measured" meaning either way, just now a real zero).
ALTER TABLE scenario_attempts ALTER COLUMN hint_count SET DEFAULT 0;
UPDATE scenario_attempts SET hint_count = 0 WHERE hint_count IS NULL;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS cohort_id TEXT;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS curriculum_stage TEXT;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'completed';
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS validation_attempt_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS validation_pass_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS validation_fail_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS highest_hint_level INTEGER NOT NULL DEFAULT 0;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS duration_seconds INTEGER;
ALTER TABLE scenario_attempts ADD COLUMN IF NOT EXISTS assessment_session_id UUID;
DO $$ BEGIN
ALTER TABLE scenario_attempts ADD CONSTRAINT scenario_attempts_status_check
CHECK (status IN ('in_progress', 'completed', 'abandoned'));
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- Backfill: Phase 2B rows were written atomically at completion time (one INSERT per validate
-- call, already carrying completed_at + result) — under the new semantics that's a completed
-- attempt with exactly one validation performed.
UPDATE scenario_attempts SET
status = 'completed',
validation_attempt_count = 1,
validation_pass_count = CASE WHEN result = 'passed' THEN 1 ELSE 0 END,
validation_fail_count = CASE WHEN result = 'failed' THEN 1 ELSE 0 END
WHERE validation_attempt_count = 0 AND completed_at IS NOT NULL;
-- "Find the open attempt for this student+scenario+environment" is the hot lookup path (every
-- hint reveal and validate call needs it) — environment-scoped so a Lab recreate doesn't
-- silently resume an attempt tied to a destroyed environment.
CREATE INDEX IF NOT EXISTS idx_attempts_open_lookup
ON scenario_attempts(student_id, scenario_id, environment_id, status);
CREATE INDEX IF NOT EXISTS idx_attempts_cohort ON scenario_attempts(cohort_id);
CREATE INDEX IF NOT EXISTS idx_attempts_assessment_session ON scenario_attempts(assessment_session_id);
+83
View File
@@ -0,0 +1,83 @@
// Attempt lifecycle — the Phase 3B core. One scenario_attempts row per continuous working
// session on a scenario (not per validation click, see migrations/012). Every mutation here
// updates that one row in place; nothing is ever inserted twice for the same open attempt.
//
// Deliberately does NOT try to detect "abandoned" attempts automatically (e.g. on Lab destroy)
// — that would need a reaper-style background job wired to Provisioner environment_events,
// which is more machinery than the instructional-visibility goal here needs. An attempt just
// stays 'in_progress' if the student never returns to finish it; instructors can already see
// "in_progress, N failed validations" as a real signal. Documented as a known limitation.
async function getOrCreateOpenAttempt(pool, { studentId, scenarioId, environmentId, cohortId, curriculumStage, assessmentSessionId }) {
const { rows: existing } = await pool.query(
`SELECT * FROM scenario_attempts
WHERE student_id = $1 AND scenario_id = $2 AND environment_id = $3 AND status = 'in_progress'
ORDER BY started_at DESC LIMIT 1`,
[studentId, scenarioId, environmentId]
);
if (existing[0]) return existing[0];
const { rows } = await pool.query(
`INSERT INTO scenario_attempts
(student_id, scenario_id, environment_id, cohort_id, curriculum_stage, started_at, status, assessment_session_id)
VALUES ($1, $2, $3, $4, $5, now(), 'in_progress', $6)
RETURNING *`,
[studentId, scenarioId, environmentId, cohortId || null, curriculumStage || null, assessmentSessionId || null]
);
return rows[0];
}
// Records one validation outcome against the currently open attempt (creating one first if
// none exists — a student can hit Validate without an explicit "start" call ever having fired,
// e.g. if the frontend missed it; this stays correct either way). Closes the attempt on a pass.
async function recordValidation(pool, ctx, passed) {
const attempt = await getOrCreateOpenAttempt(pool, ctx);
const now = new Date();
if (passed) {
const durationSeconds = attempt.started_at ? Math.max(0, Math.round((now - new Date(attempt.started_at)) / 1000)) : null;
const { rows } = await pool.query(
`UPDATE scenario_attempts SET
validation_attempt_count = validation_attempt_count + 1,
validation_pass_count = validation_pass_count + 1,
status = 'completed', result = 'passed', completed_at = now(), duration_seconds = $2
WHERE attempt_id = $1 RETURNING *`,
[attempt.attempt_id, durationSeconds]
);
return rows[0];
}
const { rows } = await pool.query(
`UPDATE scenario_attempts SET
validation_attempt_count = validation_attempt_count + 1,
validation_fail_count = validation_fail_count + 1
WHERE attempt_id = $1 RETURNING *`,
[attempt.attempt_id]
);
return rows[0];
}
// Reveals the next hint, sequentially — level N can only be revealed once level N-1 has been.
// Returns null if the requested reveal is out of order (caller treats that as a 409/400) or if
// there are no more hints left.
async function recordHintReveal(pool, ctx, totalHints) {
const attempt = await getOrCreateOpenAttempt(pool, ctx);
const nextLevel = attempt.highest_hint_level + 1;
if (nextLevel > totalHints) return { ok: false, reason: 'no_more_hints', attempt };
const { rows } = await pool.query(
`UPDATE scenario_attempts SET hint_count = hint_count + 1, highest_hint_level = $2
WHERE attempt_id = $1 RETURNING *`,
[attempt.attempt_id, nextLevel]
);
return { ok: true, level: nextLevel, attempt: rows[0] };
}
async function getAttemptSummary(pool, { studentId, scenarioId, environmentId }) {
const { rows } = await pool.query(
`SELECT * FROM scenario_attempts
WHERE student_id = $1 AND scenario_id = $2 AND ($3::uuid IS NULL OR environment_id = $3)
ORDER BY started_at DESC LIMIT 1`,
[studentId, scenarioId, environmentId || null]
);
return rows[0] || null;
}
module.exports = { getOrCreateOpenAttempt, recordValidation, recordHintReveal, getAttemptSummary };
+83
View File
@@ -0,0 +1,83 @@
// Curriculum progression rules — reads scenarios/rootnode-path.json's per-stage
// completion_rule (and the capstone stage's unlock_rule) against a student's progress map to
// answer: current stage, completed/locked stages, next required scenario, percent complete,
// and what's blocking advancement. Rule-driven, not adaptive — see
// docs/PHASE3B_LEARNING_AND_ASSESSMENT.md §4 for why that's a deliberate scope line.
//
// Capstone completeness itself isn't computed here (a capstone stage has no scenario_ids to
// check against progress) — the caller supplies it after checking for a 'capstone_completed'
// achievement, since that's Postgres-backed state this module has no access to.
function isStageComplete(stage, progress) {
const ids = stage.scenario_ids || [];
if (!ids.length) return false;
const rule = stage.completion_rule || { type: 'all_required' };
const completedIds = ids.filter((id) => progress[id]?.status === 'completed');
switch (rule.type) {
case 'threshold':
return completedIds.length / ids.length >= (rule.percent || 100) / 100;
case 'prerequisites': {
const required = rule.scenario_ids && rule.scenario_ids.length ? rule.scenario_ids : ids;
return required.every((id) => progress[id]?.status === 'completed');
}
case 'all_required':
default:
return completedIds.length === ids.length;
}
}
function nextIncompleteScenario(stage, progress) {
return (stage.scenario_ids || []).find((id) => progress[id]?.status !== 'completed') || null;
}
// capstoneStatus: 'locked' | 'unlocked' | 'completed' — supplied by the caller (server route),
// since it depends on Postgres state (achievements) this module can't see.
function computeCurriculumState(path, progress, capstoneStatus) {
const contentStages = path.stages.filter((s) => s.type !== 'capstone');
const capstoneStage = path.stages.find((s) => s.type === 'capstone');
let priorComplete = true; // the first content stage is always unlocked
const stageCompletion = {}; // stage id -> bool, used by the capstone's stages_complete rule
const stages = contentStages.map((stage) => {
const complete = isStageComplete(stage, progress);
stageCompletion[stage.id] = complete;
const locked = !priorComplete;
if (!complete) priorComplete = false; // next stage stays locked until this one is done
const ids = stage.scenario_ids || [];
const completedCount = ids.filter((id) => progress[id]?.status === 'completed').length;
return {
id: stage.id, name: stage.name, icon: stage.icon, locked, complete,
total: ids.length, completed: completedCount,
pct: ids.length ? Math.round((completedCount / ids.length) * 100) : 0,
next_scenario: locked || complete ? null : nextIncompleteScenario(stage, progress),
};
});
const currentStage = stages.find((s) => !s.locked && !s.complete) || null;
const overallTotal = contentStages.reduce((n, s) => n + (s.scenario_ids || []).length, 0);
const overallCompleted = contentStages.reduce(
(n, s) => n + (s.scenario_ids || []).filter((id) => progress[id]?.status === 'completed').length, 0
);
let capstone = null;
if (capstoneStage) {
const rule = capstoneStage.unlock_rule || { type: 'stages_complete', stage_ids: contentStages.map((s) => s.id) };
const unlockedByRule = rule.type === 'stages_complete' && rule.stage_ids.every((id) => stageCompletion[id]);
const blocking = rule.type === 'stages_complete' ? rule.stage_ids.filter((id) => !stageCompletion[id]) : [];
capstone = {
id: capstoneStage.id, name: capstoneStage.name, icon: capstoneStage.icon,
assessment_definition_id: capstoneStage.capstone_assessment_id,
status: capstoneStatus || (unlockedByRule ? 'unlocked' : 'locked'),
blocking_stage_ids: blocking,
};
}
return {
stages, current_stage: currentStage?.id || null,
percent_complete: overallTotal ? Math.round((overallCompleted / overallTotal) * 100) : 0,
capstone,
};
}
module.exports = { computeCurriculumState, isStageComplete };
+10 -1
View File
@@ -2,11 +2,13 @@
"id": "rootnode-path",
"name": "RootNode Learning Path",
"tagline": "The guided route through RootNode Academy — one stage at a time.",
"version": 1,
"stages": [
{
"id": "foundation",
"name": "Foundation",
"icon": "🌱",
"completion_rule": { "type": "all_required" },
"scenario_ids": [
"pod-basics-mcq",
"kubectl-essentials-mcq",
@@ -34,6 +36,7 @@
"id": "workloads",
"name": "Workloads",
"icon": "📦",
"completion_rule": { "type": "all_required" },
"scenario_ids": [
"deploy-nginx",
"scale-deployment",
@@ -49,6 +52,7 @@
"id": "networking",
"name": "Networking",
"icon": "🔌",
"completion_rule": { "type": "all_required" },
"scenario_ids": [
"services-mcq",
"expose-service-basics"
@@ -58,6 +62,7 @@
"id": "configuration",
"name": "Configuration",
"icon": "⚙️",
"completion_rule": { "type": "all_required" },
"scenario_ids": [
"configmap-volume",
"resource-limits-task",
@@ -70,6 +75,7 @@
"id": "troubleshooting",
"name": "Troubleshooting",
"icon": "🔍",
"completion_rule": { "type": "all_required" },
"scenario_ids": [
"extract-logs-basics",
"kubectl-cp-basics",
@@ -83,6 +89,7 @@
"name": "RootNode: Real Ops",
"icon": "🌳",
"bundle_id": "rootnode-real-ops",
"completion_rule": { "type": "all_required" },
"scenario_ids": [
"rootnode-runaway-monitor",
"broken-deployment",
@@ -94,7 +101,9 @@
"id": "capstone",
"name": "Capstone",
"icon": "🏁",
"locked": true,
"type": "capstone",
"capstone_assessment_id": "rootnode-capstone",
"unlock_rule": { "type": "stages_complete", "stage_ids": ["foundation", "workloads", "networking", "configuration", "troubleshooting", "real-ops"] },
"scenario_ids": []
}
]