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
+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 };