diff --git a/platform/api/migrations/012_scenario_attempts_learning.sql b/platform/api/migrations/012_scenario_attempts_learning.sql new file mode 100644 index 0000000..4646e78 --- /dev/null +++ b/platform/api/migrations/012_scenario_attempts_learning.sql @@ -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); diff --git a/platform/api/src/attempts.js b/platform/api/src/attempts.js new file mode 100644 index 0000000..036ba4a --- /dev/null +++ b/platform/api/src/attempts.js @@ -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 }; diff --git a/platform/api/src/curriculum-rules.js b/platform/api/src/curriculum-rules.js new file mode 100644 index 0000000..b932510 --- /dev/null +++ b/platform/api/src/curriculum-rules.js @@ -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 }; diff --git a/scenarios/rootnode-path.json b/scenarios/rootnode-path.json index f084c4d..561b3f4 100644 --- a/scenarios/rootnode-path.json +++ b/scenarios/rootnode-path.json @@ -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": [] } ]