@playcademy/sandbox 0.8.1-beta.6 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.js +251 -392
  2. package/dist/server.js +251 -392
  3. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -1127,7 +1127,7 @@ var package_default;
1127
1127
  var init_package = __esm(() => {
1128
1128
  package_default = {
1129
1129
  name: "@playcademy/sandbox",
1130
- version: "0.8.1-beta.6",
1130
+ version: "0.9.0",
1131
1131
  description: "Local development server for Playcademy game development",
1132
1132
  type: "module",
1133
1133
  exports: {
@@ -10747,12 +10747,10 @@ function selectAssessmentCourseCandidate(candidates, subjectSpecified) {
10747
10747
  if (candidates.length === 0) {
10748
10748
  return { outcome: "none" };
10749
10749
  }
10750
- const serviceable = candidates.filter((candidate) => candidate.hasLiveTests || candidate.hasOpenAttempt);
10751
- if (serviceable.length === 0) {
10750
+ const eligible = candidates.filter((candidate) => candidate.hasLiveTests);
10751
+ if (eligible.length === 0) {
10752
10752
  return { outcome: "no_eligible_tests" };
10753
10753
  }
10754
- const withOpenAttempt = serviceable.filter((candidate) => candidate.hasOpenAttempt);
10755
- const eligible = withOpenAttempt.length > 0 ? withOpenAttempt : serviceable;
10756
10754
  const subjects = new Set(eligible.map((candidate) => candidate.subject)).size;
10757
10755
  if (!subjectSpecified && subjects > 1) {
10758
10756
  return { outcome: "ambiguous", subjects };
@@ -10764,12 +10762,9 @@ function assessmentAttemptTimestamp(value) {
10764
10762
  const parsed = Date.parse(value);
10765
10763
  return Number.isNaN(parsed) ? 0 : parsed;
10766
10764
  }
10767
- function newestAttempt(left, right, field) {
10768
- const dateDifference = assessmentAttemptTimestamp(right[field]) - assessmentAttemptTimestamp(left[field]);
10769
- return dateDifference || left.attemptId.localeCompare(right.attemptId);
10770
- }
10771
10765
  function compareNewestCompletedAssessmentAttempt(left, right) {
10772
- return newestAttempt(left, right, "scoreDate");
10766
+ const dateDifference = assessmentAttemptTimestamp(right.scoreDate) - assessmentAttemptTimestamp(left.scoreDate);
10767
+ return dateDifference || left.attemptId.localeCompare(right.attemptId);
10773
10768
  }
10774
10769
  function compareAssessmentCatalogOrder(left, right) {
10775
10770
  if (left.assessmentKey !== null && right.assessmentKey !== null) {
@@ -10792,31 +10787,22 @@ function orderRuntimeAssessmentTests(tests) {
10792
10787
  return [...tests].toSorted(compareAssessmentCatalogOrder);
10793
10788
  }
10794
10789
  function selectRuntimeAssessment(liveTests, attempts) {
10795
- const resumable = attempts.filter((attempt) => attempt.resumable !== false && (isAssessmentAttemptOpen(attempt) || isAssessmentAttemptAwaitingAward(attempt))).toSorted((left, right) => newestAttempt(left, right, "updatedAt"));
10796
- if (resumable.length > 0) {
10797
- return {
10798
- kind: "resume",
10799
- attempt: resumable[0],
10800
- additionalResumableAttemptIds: resumable.slice(1).map((candidate) => candidate.attemptId)
10801
- };
10802
- }
10803
10790
  const orderedTests = orderRuntimeAssessmentTests(liveTests);
10804
10791
  if (orderedTests.length === 0) {
10805
10792
  return null;
10806
10793
  }
10807
- const completed = attempts.filter(isAssessmentAttemptCompleted);
10808
- const exposedAssessmentKeys = new Set(attempts.filter((attempt) => isAssessmentAttemptOpen(attempt) || isAssessmentAttemptAwaitingAward(attempt) || isAssessmentAttemptCompleted(attempt)).map((attempt) => attempt.selectedAssessmentKey));
10794
+ const history = attempts.filter((attempt) => isAssessmentAttemptOpen(attempt) || isAssessmentAttemptAwaitingAward(attempt) || isAssessmentAttemptCompleted(attempt) || isAssessmentAttemptSuperseded(attempt));
10795
+ const exposedAssessmentKeys = new Set(history.map((attempt) => attempt.selectedAssessmentKey));
10809
10796
  const unattempted = orderedTests.find((test) => !exposedAssessmentKeys.has(test.assessmentKey));
10810
10797
  if (unattempted) {
10811
- return { kind: "start", test: unattempted, reason: "unattempted" };
10798
+ return { test: unattempted, reason: "unattempted" };
10812
10799
  }
10813
- const latestCompleted = completed.toSorted(compareNewestCompletedAssessmentAttempt)[0];
10814
- const latestTestIndex = latestCompleted ? orderedTests.findIndex((test) => test.assessmentKey === latestCompleted.selectedAssessmentKey) : -1;
10800
+ const latest = history.toSorted(compareNewestCompletedAssessmentAttempt)[0];
10801
+ const latestTestIndex = latest ? orderedTests.findIndex((test) => test.assessmentKey === latest.selectedAssessmentKey) : -1;
10815
10802
  if (latestTestIndex === -1) {
10816
- return { kind: "start", test: orderedTests[0], reason: "first_live" };
10803
+ return { test: orderedTests[0], reason: "first_live" };
10817
10804
  }
10818
10805
  return {
10819
- kind: "start",
10820
10806
  test: orderedTests[(latestTestIndex + 1) % orderedTests.length],
10821
10807
  reason: "round_robin"
10822
10808
  };
@@ -100351,13 +100337,6 @@ function buildAssessmentAwardRecord(input, mastery) {
100351
100337
  ...mastery.warnings ? { warnings: mastery.warnings } : {}
100352
100338
  };
100353
100339
  }
100354
- function stageAssessmentAttemptSupersession(attempt) {
100355
- Object.assign(attempt.result, ASSESSMENT_ATTEMPT_SUPERSEDED);
100356
- Object.assign(attempt.selection, ASSESSMENT_ATTEMPT_SUPERSEDED);
100357
- }
100358
- function shouldConfirmAssessmentVersionUnavailable(details) {
100359
- return typeof details === "object" && details !== null && "expectedRevision" in details && typeof details.expectedRevision === "string" && !("currentRevision" in details);
100360
- }
100361
100340
  function buildAssessmentAttemptSupersessionUpdate(result) {
100362
100341
  if (result.status !== "active" || !isAssessmentAttemptOpen({
100363
100342
  inProgress: result.inProgress ?? "",
@@ -100375,17 +100354,6 @@ function buildAssessmentAttemptSupersessionUpdate(result) {
100375
100354
  metadata: result.metadata ?? {}
100376
100355
  };
100377
100356
  }
100378
- async function resolveAvailableAssessmentAttempt(initialDecision, select3, resume) {
100379
- let decision = initialDecision;
100380
- while (decision?.kind === "resume") {
100381
- const snapshot = await resume(decision);
100382
- if (snapshot !== null) {
100383
- return { kind: "resumed", snapshot };
100384
- }
100385
- decision = select3();
100386
- }
100387
- return { kind: "continue", decision };
100388
- }
100389
100357
  function diagnosticQuestionLineItemId(parentLineItemId, itemIdentifier) {
100390
100358
  return deterministicUUID(["playcademy:diagnostic-question-line-item:v1", parentLineItemId, itemIdentifier].join("\x00"));
100391
100359
  }
@@ -100460,6 +100428,20 @@ function assessmentAttemptId(input) {
100460
100428
  String(input.attemptNumber)
100461
100429
  ].join("\x00"));
100462
100430
  }
100431
+ function buildAssessmentAttemptResultWhere(scope) {
100432
+ return {
100433
+ "student.sourcedId": scope.studentId,
100434
+ status: ONEROSTER_STATUS2.active,
100435
+ [`metadata.${PLAYCADEMY_ASSESSMENT_RESULT_METADATA_KEY}.activityId`]: scope.activityId
100436
+ };
100437
+ }
100438
+ function buildAssessmentChildResultWhere(scope) {
100439
+ return {
100440
+ "student.sourcedId": scope.studentId,
100441
+ status: ONEROSTER_STATUS2.active,
100442
+ [`metadata.${PLAYCADEMY_ASSESSMENT_ITEM_RESULT_METADATA_KEY}.payload.attempt.activityId`]: scope.activityId
100443
+ };
100444
+ }
100463
100445
  async function findLatestAssessmentResult(results, match) {
100464
100446
  let latest = null;
100465
100447
  let latestTimestamp = 0;
@@ -100899,6 +100881,7 @@ var PLAYABLE_SHAPE_SET;
100899
100881
  var init_timeback_assessment_runtime_util = __esm(() => {
100900
100882
  init_src();
100901
100883
  init_assessment_runtime2();
100884
+ init_constants3();
100902
100885
  init_qti();
100903
100886
  init_types2();
100904
100887
  init_utils6();
@@ -102686,21 +102669,6 @@ function assessmentPreparationFlightKey(input) {
102686
102669
  async function assessmentPreparationReceiptMatches(plan, request) {
102687
102670
  return plan.gameId === request.gameId && plan.studentId === request.studentId && plan.activityId === request.input.activityId && plan.purpose === request.input.purpose && plan.requestHash === await assessmentPreparationRequestHash(request.input);
102688
102671
  }
102689
- function resolveAssessmentPreparationSelection(decision, candidateCourseCount) {
102690
- if (decision?.kind === "resume") {
102691
- return {
102692
- status: "blocked",
102693
- result: { status: "canonical_required", reason: "resumable_attempt" }
102694
- };
102695
- }
102696
- if (candidateCourseCount !== 1) {
102697
- return {
102698
- status: "blocked",
102699
- result: { status: "unprepared", reason: "prerequisite_unavailable" }
102700
- };
102701
- }
102702
- return decision ? { status: "ready", decision } : { status: "unavailable" };
102703
- }
102704
102672
  function preparedAssessmentIntegration(plan, candidates) {
102705
102673
  if (candidates.rows.length !== 1) {
102706
102674
  return null;
@@ -103446,15 +103414,16 @@ function assessmentReviewTranscriptExposures(input) {
103446
103414
  for (const { result, metadata: metadata2 } of input.attempts) {
103447
103415
  const state = { ...result, inProgress: result.inProgress ?? "" };
103448
103416
  const settled = isAssessmentAttemptAwaitingAward(state) || isAssessmentAttemptCompleted(state);
103449
- if (metadata2.purpose === "review" && (metadata2.transcript !== undefined || settled)) {
103417
+ const useTranscript = !settled && metadata2.transcript !== undefined;
103418
+ if (metadata2.purpose === "review" && (useTranscript || settled)) {
103450
103419
  authoritative.add(result.sourcedId);
103451
103420
  if (metadata2.review.bankRevision === input.bankRevision) {
103452
- const accepted = settled ? metadata2.itemSubmissions.map((submission) => ({
103453
- itemIdentifier: submission.itemIdentifier,
103454
- deliveredAt: submission.submittedAt
103455
- })) : metadata2.transcript.administrations.map((entry) => ({
103421
+ const accepted = useTranscript ? metadata2.transcript.administrations.map((entry) => ({
103456
103422
  itemIdentifier: entry.itemIdentifier,
103457
103423
  deliveredAt: metadata2.updatedAt
103424
+ })) : metadata2.itemSubmissions.map((submission) => ({
103425
+ itemIdentifier: submission.itemIdentifier,
103426
+ deliveredAt: submission.submittedAt
103458
103427
  }));
103459
103428
  exposures.push(...accepted);
103460
103429
  }
@@ -103588,7 +103557,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
103588
103557
  static PREPARATION_IN_FLIGHT_LIMIT = 32;
103589
103558
  static ASSESSMENT_CACHE_TTL_MS = 60000;
103590
103559
  static EXPORT_CONCURRENCY = 4;
103591
- static ASSESSMENT_VERSION_CONFIRMATION_DELAY_MS = 25;
103592
103560
  static SCORING_CONCURRENCY = 4;
103593
103561
  static STALE_AWARD_AGE_MS = 15 * 60 * 1000;
103594
103562
  deps;
@@ -103678,8 +103646,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
103678
103646
  return this.startReview({ gameId, studentId, input, user });
103679
103647
  }
103680
103648
  const masteryStandard = input.purpose === "mastery" ? normalizeMasteryStandard(input.standard) : undefined;
103681
- const requestFingerprint = this.startRequestFingerprint(input);
103682
- const pendingSupersessions = new Set;
103683
103649
  const candidates = await this.candidateIntegrations(gameId, studentId, input);
103684
103650
  const lockKeys = candidates.rows.map((row) => assessmentRuntimeStartLockKey({
103685
103651
  studentId,
@@ -103688,7 +103654,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
103688
103654
  courseId: row.courseId
103689
103655
  }));
103690
103656
  return this.deps.assessmentRuntimeLock(this.deps.db, lockKeys, async (db2) => {
103691
- const context2 = await this.selectIntegration(candidates, studentId, input);
103657
+ const context2 = await this.selectIntegration(candidates, input);
103692
103658
  const attemptScope = {
103693
103659
  studentId,
103694
103660
  activityId: input.activityId,
@@ -103701,39 +103667,28 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
103701
103667
  this.liveTests(context2.integration.id, input.purpose, db2, masteryStandard),
103702
103668
  this.listAttempts(attemptScope)
103703
103669
  ]);
103704
- const attempts = listing.attempts;
103705
- const initialResolution = await this.resumeAvailableAttempt(tests, attempts, requestFingerprint, pendingSupersessions, (resumeDecision) => this.resumeSnapshot(resumeDecision, attempts, context2.integration));
103706
- if (initialResolution.kind === "resumed") {
103707
- await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
103708
- return initialResolution.snapshot;
103709
- }
103710
- let decision = this.requireDecision(initialResolution.decision, input.purpose);
103711
- let assessment = await this.loadAssessment(decision.test.qtiTestIdentifier);
103712
- const refreshedListing = await this.listAttempts(attemptScope);
103713
- const refreshed = refreshedListing.attempts;
103714
- const refreshedResolution = await this.resumeAvailableAttempt(tests, refreshed, requestFingerprint, pendingSupersessions, (resumeDecision) => this.resumeSnapshot(resumeDecision, refreshed, context2.integration));
103715
- if (refreshedResolution.kind === "resumed") {
103716
- await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
103717
- return refreshedResolution.snapshot;
103718
- }
103719
- decision = this.requireDecision(refreshedResolution.decision, input.purpose);
103720
- if (assessment.identifier !== decision.test.qtiTestIdentifier) {
103721
- assessment = await this.loadAssessment(decision.test.qtiTestIdentifier);
103722
- }
103723
- const classId = await this.resolveClassId(context2.integration.courseId, context2.enrollment, studentId);
103670
+ const pending = await this.pendingAwardSnapshot(listing, context2);
103671
+ if (pending) {
103672
+ return pending;
103673
+ }
103674
+ const decision = this.selectFreshStart(tests, listing, input.purpose);
103675
+ const [assessment, classId, { attemptNumber, attemptId }] = await Promise.all([
103676
+ this.loadAssessment(decision.test.qtiTestIdentifier),
103677
+ this.resolveClassId(context2.integration.courseId, context2.enrollment, studentId),
103678
+ this.claimAttemptId({
103679
+ gameId,
103680
+ studentId,
103681
+ courseId: context2.integration.courseId,
103682
+ activityId: input.activityId,
103683
+ purpose: input.purpose
103684
+ }, nextAssessmentAttemptNumber(listing.attempts.values()))
103685
+ ]);
103724
103686
  const lineItemId = await this.ensureLineItem({
103725
103687
  classId,
103726
103688
  integration: context2.integration,
103727
103689
  purpose: input.purpose,
103728
103690
  assessment
103729
103691
  });
103730
- const { attemptNumber, attemptId } = await this.claimAttemptId({
103731
- gameId,
103732
- studentId,
103733
- courseId: context2.integration.courseId,
103734
- activityId: input.activityId,
103735
- purpose: input.purpose
103736
- }, Math.max(0, ...[...refreshed.values()].map((entry) => entry.metadata.attemptNumber)) + 1);
103737
103692
  const timestamp6 = new Date().toISOString();
103738
103693
  const metadataBase = {
103739
103694
  version: PLAYCADEMY_ASSESSMENT_RESULT_METADATA_VERSION,
@@ -103758,7 +103713,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
103758
103713
  ...metadataBase,
103759
103714
  purpose: input.purpose,
103760
103715
  mastery: {
103761
- requestFingerprint,
103716
+ requestFingerprint: masteryRequestFingerprint(input),
103762
103717
  standard: masteryStandard
103763
103718
  }
103764
103719
  } : { ...metadataBase, purpose: input.purpose };
@@ -103776,17 +103731,24 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
103776
103731
  grade: context2.integration.grade
103777
103732
  })
103778
103733
  });
103779
- await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
103780
- return this.snapshot(result, metadata2, assessment, context2.integration);
103734
+ const [snapshot] = await Promise.all([
103735
+ this.snapshot(result, metadata2, assessment, context2.integration),
103736
+ this.supersedeReplacedAttempts(db2, listing.attempts, context2.enrollment.id)
103737
+ ]);
103738
+ return snapshot;
103781
103739
  });
103782
103740
  }
103783
103741
  async prepareNewAttempt(params, requestHash) {
103784
103742
  const startedAt = Date.now();
103785
103743
  const [candidates, results] = await Promise.all([
103786
103744
  this.candidateIntegrations(params.gameId, params.studentId, params.input),
103787
- this.studentAssessmentResults(params.studentId)
103745
+ this.activityAssessmentResults({
103746
+ studentId: params.studentId,
103747
+ activityId: params.input.activityId,
103748
+ purpose: params.input.purpose
103749
+ })
103788
103750
  ]);
103789
- const context2 = await this.selectIntegration(candidates, params.studentId, params.input, results);
103751
+ const context2 = await this.selectIntegration(candidates, params.input);
103790
103752
  const attemptScope = {
103791
103753
  studentId: params.studentId,
103792
103754
  activityId: params.input.activityId,
@@ -103797,40 +103759,30 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
103797
103759
  };
103798
103760
  const tests = await this.liveTests(context2.integration.id, params.input.purpose, this.deps.db, params.input.purpose === "mastery" ? params.input.standard : undefined, params.input.purpose === "diagnostic" ? params.input.diagnosticKey : undefined);
103799
103761
  const listing = await this.attemptListing(results, attemptScope);
103800
- const decision = selectRuntimeAssessment(tests, [
103801
- ...this.attemptsMatchingRequest(listing.attempts, this.startRequestFingerprint(params.input)).values()
103802
- ].map((entry) => entry.selection));
103803
- const selection = resolveAssessmentPreparationSelection(decision, candidates.rows.length);
103804
- if (selection.status === "blocked") {
103805
- this.recordPreparation(selection.result.status, params.input.purpose, startedAt, {
103806
- reason: selection.result.status === "canonical_required" ? selection.result.reason : "multiple_candidate_courses"
103762
+ if (this.pendingAwardAttempt(listing, context2.enrollment.id)) {
103763
+ this.recordPreparation("unprepared", params.input.purpose, startedAt, {
103764
+ reason: "pending_award"
103807
103765
  });
103808
- return selection.result;
103766
+ return { status: "unprepared", reason: "prerequisite_unavailable" };
103809
103767
  }
103810
- if (selection.status === "unavailable") {
103811
- throw AssessmentRuntimeError.from(assessmentNoEligibleTests(params.input.purpose));
103768
+ if (candidates.rows.length !== 1) {
103769
+ this.recordPreparation("unprepared", params.input.purpose, startedAt, {
103770
+ reason: "multiple_candidate_courses"
103771
+ });
103772
+ return { status: "unprepared", reason: "prerequisite_unavailable" };
103812
103773
  }
103813
103774
  const preparation = {
103814
103775
  context: context2,
103815
103776
  tests,
103816
103777
  listing,
103817
- decision: selection.decision
103778
+ decision: this.selectFreshStart(tests, listing, params.input.purpose)
103818
103779
  };
103819
103780
  let prepared;
103820
103781
  try {
103821
103782
  prepared = await this.buildPreparationPlan(params, requestHash, preparation);
103822
- const assessment = await this.loadPreparedPlanAssessment(prepared.plan);
103823
- if (!assessment) {
103783
+ if (!await this.loadPreparedPlanAssessment(prepared.plan)) {
103824
103784
  throw new Error("Prepared assessment artifact is unavailable");
103825
103785
  }
103826
- await this.provisionPreparationPrerequisites(() => this.ensureTranscriptLineItems({
103827
- version: 1,
103828
- gameId: prepared.plan.gameId,
103829
- studentId: prepared.plan.studentId,
103830
- attemptId: prepared.plan.attemptId,
103831
- parentLineItemId: prepared.plan.parentLineItemId,
103832
- metadata: buildPreparedAssessmentMetadata(prepared.plan, new Date().toISOString(), assessment.title)
103833
- }, assessment));
103834
103786
  } catch (error88) {
103835
103787
  if (error88 instanceof ValidationError || error88 instanceof AssessmentRuntimeError) {
103836
103788
  throw error88;
@@ -103919,6 +103871,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
103919
103871
  courseName: context2.enrollment.course.title,
103920
103872
  ...attempt,
103921
103873
  parentLineItemId,
103874
+ supersedes: [...this.replacedAttemptIds(listing.attempts, context2.enrollment.id)],
103922
103875
  selectedTest: {
103923
103876
  assessmentKey: decision.test.assessmentKey,
103924
103877
  identifier: assessment.identifier,
@@ -103970,26 +103923,12 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
103970
103923
  throw AssessmentRuntimeError.from(assessmentReviewBankShortage(selected.fulfillment));
103971
103924
  }
103972
103925
  const artifact = await this.loadOrCompileReviewBankArtifact(catalog);
103973
- const assessment = projectReviewAssessment(artifact.assessment, catalog.bank, selected.selections);
103974
103926
  const source = { assessment: artifact.assessment, bank: catalog.bank };
103975
103927
  const parentLineItemId = await this.provisionPreparationPrerequisites(() => this.ensureReviewBankLineItem({
103976
103928
  integration: context2.integration,
103977
103929
  activityId: params.input.activityId,
103978
103930
  source
103979
103931
  }));
103980
- await this.provisionPreparationPrerequisites(() => runWithConcurrency(assessment.items, TimebackAssessmentRuntimeService.SCORING_CONCURRENCY, (item) => {
103981
- const indexed = catalog.bank.items.find((candidate) => candidate.itemIdentifier === item.identifier);
103982
- if (!indexed) {
103983
- throw new Error(`Review-bank index is missing item ${item.identifier}`);
103984
- }
103985
- return this.ensureReviewQuestionLineItem({
103986
- parentLineItemId,
103987
- integration: context2.integration,
103988
- bankRevision: catalog.bank.bankRevision,
103989
- standards: [...indexed.standards],
103990
- item
103991
- });
103992
- }));
103993
103932
  const attempt = await this.provisionPreparationPrerequisites(() => this.claimAttemptId({
103994
103933
  gameId: params.gameId,
103995
103934
  studentId: params.studentId,
@@ -104011,6 +103950,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104011
103950
  courseName: context2.enrollment.course.title,
104012
103951
  ...attempt,
104013
103952
  parentLineItemId,
103953
+ supersedes: [...this.replacedAttemptIds(listing.attempts, context2.enrollment.id)],
104014
103954
  selectedTest: {
104015
103955
  assessmentKey: decision.test.assessmentKey,
104016
103956
  identifier: artifact.assessment.identifier,
@@ -104059,10 +103999,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104059
103999
  if (!integration || !await this.preparedPlanTestIsLive(verified, db2)) {
104060
104000
  return null;
104061
104001
  }
104062
- if (await this.attemptIdOccupied(verified.attemptId)) {
104063
- return null;
104064
- }
104065
- return this.createPreparedAttempt(verified, assessment, integration);
104002
+ return this.claimPreparedAttempt(db2, verified, assessment, integration);
104066
104003
  });
104067
104004
  if (!outcome) {
104068
104005
  this.recordPreparedStart("fallback", params.input.purpose, startedAt, {
@@ -104087,8 +104024,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104087
104024
  studentId,
104088
104025
  input
104089
104026
  }) {
104090
- const requestFingerprint = this.startRequestFingerprint(input);
104091
- const pendingSupersessions = new Set;
104092
104027
  const candidates = await this.candidateIntegrations(gameId, studentId, input);
104093
104028
  const lockKeys = candidates.rows.map((row) => assessmentRuntimeStartLockKey({
104094
104029
  studentId,
@@ -104097,7 +104032,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104097
104032
  courseId: row.courseId
104098
104033
  }));
104099
104034
  return this.deps.assessmentRuntimeLock(this.deps.db, lockKeys, async (db2) => {
104100
- const context2 = await this.selectIntegration(candidates, studentId, input);
104035
+ const context2 = await this.selectIntegration(candidates, input);
104101
104036
  const attemptScope = {
104102
104037
  studentId,
104103
104038
  activityId: input.activityId,
@@ -104110,32 +104045,25 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104110
104045
  this.liveTests(context2.integration.id, input.purpose, db2, undefined, input.diagnosticKey),
104111
104046
  this.listAttempts(attemptScope)
104112
104047
  ]);
104113
- const initialResolution = await this.resumeAvailableAttempt(tests, listing.attempts, requestFingerprint, pendingSupersessions, (resumeDecision) => this.resumeDiagnosticSnapshot(resumeDecision, listing.attempts, context2.integration, db2));
104114
- if (initialResolution.kind === "resumed") {
104115
- await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
104116
- return initialResolution.snapshot;
104048
+ const pending = await this.pendingAwardSnapshot(listing, context2);
104049
+ if (pending) {
104050
+ return pending;
104117
104051
  }
104118
- let decision = this.requireDecision(initialResolution.decision, input.purpose);
104052
+ const decision = this.selectFreshStart(tests, listing, input.purpose);
104119
104053
  let definition = await this.requireDiagnosticDefinition(decision.test.id, context2.integration.id, db2, true);
104120
- let assessment = await this.loadAssessment(definition.qtiTestIdentifier);
104121
- let routing = await this.initializeHostedDiagnostic(definition, assessment);
104122
- const refreshedListing = await this.listAttempts(attemptScope);
104123
- const refreshed = refreshedListing.attempts;
104124
- const refreshedResolution = await this.resumeAvailableAttempt(tests, refreshed, requestFingerprint, pendingSupersessions, (resumeDecision) => this.resumeDiagnosticSnapshot(resumeDecision, refreshed, context2.integration, db2));
104125
- if (refreshedResolution.kind === "resumed") {
104126
- await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
104127
- return refreshedResolution.snapshot;
104128
- }
104129
- decision = this.requireDecision(refreshedResolution.decision, input.purpose);
104130
- if (definition.id !== decision.test.id) {
104131
- definition = await this.requireDiagnosticDefinition(decision.test.id, context2.integration.id, db2, true);
104132
- assessment = await this.loadAssessment(definition.qtiTestIdentifier);
104133
- routing = await this.initializeHostedDiagnostic(definition, assessment);
104134
- } else {
104135
- definition = await this.requireDiagnosticDefinition(definition.id, context2.integration.id, db2, true);
104136
- routing = await this.initializeHostedDiagnostic(definition, assessment);
104137
- }
104138
- const classId = await this.resolveClassId(context2.integration.courseId, context2.enrollment, studentId);
104054
+ const [assessment, classId, { attemptNumber, attemptId }] = await Promise.all([
104055
+ this.loadAssessment(definition.qtiTestIdentifier),
104056
+ this.resolveClassId(context2.integration.courseId, context2.enrollment, studentId),
104057
+ this.claimAttemptId({
104058
+ gameId,
104059
+ studentId,
104060
+ courseId: context2.integration.courseId,
104061
+ activityId: input.activityId,
104062
+ purpose: input.purpose
104063
+ }, nextAssessmentAttemptNumber(listing.attempts.values()))
104064
+ ]);
104065
+ definition = await this.requireDiagnosticDefinition(definition.id, context2.integration.id, db2, true);
104066
+ const routing = await this.initializeHostedDiagnostic(definition, assessment);
104139
104067
  const lineItemId = await this.ensureLineItem({
104140
104068
  classId,
104141
104069
  integration: context2.integration,
@@ -104147,13 +104075,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104147
104075
  routingRevision: routing.routingRevision
104148
104076
  }
104149
104077
  });
104150
- const { attemptNumber, attemptId } = await this.claimAttemptId({
104151
- gameId,
104152
- studentId,
104153
- courseId: context2.integration.courseId,
104154
- activityId: input.activityId,
104155
- purpose: input.purpose
104156
- }, Math.max(0, ...[...refreshed.values()].map((entry) => entry.metadata.attemptNumber)) + 1);
104157
104078
  const timestamp6 = new Date().toISOString();
104158
104079
  const metadata2 = {
104159
104080
  version: PLAYCADEMY_ASSESSMENT_RESULT_METADATA_VERSION,
@@ -104197,10 +104118,13 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104197
104118
  grade: context2.integration.grade
104198
104119
  })
104199
104120
  });
104200
- await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
104201
- return this.diagnosticSnapshot(result, metadata2, assessment, context2.integration, {
104202
- state: routing.state
104203
- });
104121
+ const [snapshot] = await Promise.all([
104122
+ this.diagnosticSnapshot(result, metadata2, assessment, context2.integration, {
104123
+ state: routing.state
104124
+ }),
104125
+ this.supersedeReplacedAttempts(db2, listing.attempts, context2.enrollment.id)
104126
+ ]);
104127
+ return snapshot;
104204
104128
  });
104205
104129
  }
104206
104130
  async startReview({
@@ -104210,7 +104134,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104210
104134
  }) {
104211
104135
  const preparationStartedAt = Date.now();
104212
104136
  const requestFingerprint = reviewRequestFingerprint(input, DEFAULT_REVIEW_SELECTION_POLICY);
104213
- const pendingSupersessions = new Set;
104214
104137
  const normalizedRequest = normalizeReviewRequest(input);
104215
104138
  const candidates = await this.candidateIntegrations(gameId, studentId, input);
104216
104139
  const lockKeys = candidates.rows.map((row) => assessmentRuntimeStartLockKey({
@@ -104220,7 +104143,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104220
104143
  courseId: row.courseId
104221
104144
  }));
104222
104145
  return this.deps.assessmentRuntimeLock(this.deps.db, lockKeys, async (db2) => {
104223
- const context2 = await this.selectIntegration(candidates, studentId, input);
104146
+ const context2 = await this.selectIntegration(candidates, input);
104224
104147
  const attemptScope = {
104225
104148
  studentId,
104226
104149
  activityId: input.activityId,
@@ -104233,42 +104156,22 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104233
104156
  this.liveTests(context2.integration.id, input.purpose, db2),
104234
104157
  this.listAttempts(attemptScope)
104235
104158
  ]);
104236
- const attempts = listing.attempts;
104237
- const initialResolution = await this.resumeAvailableAttempt(tests, attempts, requestFingerprint, pendingSupersessions, (resumeDecision) => this.resumeReviewSnapshot(resumeDecision, attempts, context2));
104238
- if (initialResolution.kind === "resumed") {
104239
- await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
104240
- return initialResolution.snapshot;
104241
- }
104242
- let decision = initialResolution.decision;
104243
- if (tests.length === 0 || !decision) {
104244
- throw AssessmentRuntimeError.from(assessmentNoEligibleTests(input.purpose));
104159
+ const pending = await this.pendingAwardSnapshot(listing, context2);
104160
+ if (pending) {
104161
+ return pending;
104245
104162
  }
104163
+ const decision = this.selectFreshStart(tests, listing, input.purpose);
104246
104164
  if (tests.length > 1) {
104247
104165
  throw new ValidationError("Standards review requires exactly one live review-bank assessment.");
104248
104166
  }
104249
104167
  const manifestStartedAt = Date.now();
104250
- let catalog = await this.loadReviewBankCatalog(decision.test.qtiTestIdentifier);
104168
+ const catalog = await this.loadReviewBankCatalog(decision.test.qtiTestIdentifier);
104251
104169
  this.recordReviewPreparationPhase("manifest_load", manifestStartedAt, {
104252
104170
  candidateCount: catalog.bank.items.length
104253
104171
  });
104254
- const refreshedListing = await this.listAttempts(attemptScope);
104255
- const refreshed = refreshedListing.attempts;
104256
- const refreshedResolution = await this.resumeAvailableAttempt(tests, refreshed, requestFingerprint, pendingSupersessions, (resumeDecision) => this.resumeReviewSnapshot(resumeDecision, refreshed, context2));
104257
- if (refreshedResolution.kind === "resumed") {
104258
- await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
104259
- return refreshedResolution.snapshot;
104260
- }
104261
- const refreshedDecision = refreshedResolution.decision;
104262
- if (!refreshedDecision || refreshedDecision.kind !== "start") {
104263
- throw AssessmentRuntimeError.from(assessmentNoEligibleTests(input.purpose));
104264
- }
104265
- decision = refreshedDecision;
104266
- if (catalog.test.identifier !== decision.test.qtiTestIdentifier) {
104267
- catalog = await this.loadReviewBankCatalog(decision.test.qtiTestIdentifier);
104268
- }
104269
104172
  const exposures = assessmentReviewTranscriptExposures({
104270
- attempts: refreshedListing.attempts.values(),
104271
- children: refreshedListing.reviewChildren.values(),
104173
+ attempts: listing.attempts.values(),
104174
+ children: listing.reviewChildren.values(),
104272
104175
  bankRevision: catalog.bank.bankRevision
104273
104176
  });
104274
104177
  const selected = selectReviewItems({
@@ -104289,18 +104192,20 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104289
104192
  });
104290
104193
  const assessment = projectReviewAssessment(source.assessment, source.bank, selected.selections);
104291
104194
  const provisioningStartedAt = Date.now();
104292
- const parentLineItemId = await this.ensureReviewBankLineItem({
104293
- integration: context2.integration,
104294
- activityId: input.activityId,
104295
- source
104296
- });
104297
- const { attemptNumber, attemptId } = await this.claimAttemptId({
104298
- gameId,
104299
- studentId,
104300
- courseId: context2.integration.courseId,
104301
- activityId: input.activityId,
104302
- purpose: input.purpose
104303
- }, Math.max(0, ...[...refreshed.values()].map((entry) => entry.metadata.attemptNumber)) + 1);
104195
+ const [parentLineItemId, { attemptNumber, attemptId }] = await Promise.all([
104196
+ this.ensureReviewBankLineItem({
104197
+ integration: context2.integration,
104198
+ activityId: input.activityId,
104199
+ source
104200
+ }),
104201
+ this.claimAttemptId({
104202
+ gameId,
104203
+ studentId,
104204
+ courseId: context2.integration.courseId,
104205
+ activityId: input.activityId,
104206
+ purpose: input.purpose
104207
+ }, nextAssessmentAttemptNumber(listing.attempts.values()))
104208
+ ]);
104304
104209
  const timestamp6 = new Date().toISOString();
104305
104210
  const metadata2 = {
104306
104211
  version: PLAYCADEMY_ASSESSMENT_RESULT_METADATA_VERSION,
@@ -104344,13 +104249,16 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104344
104249
  grade: context2.integration.grade
104345
104250
  })
104346
104251
  });
104347
- await this.persistPendingSupersessionsBestEffort(db2, pendingSupersessions);
104252
+ const [snapshot] = await Promise.all([
104253
+ this.snapshot(result, metadata2, assessment, context2.integration),
104254
+ this.supersedeReplacedAttempts(db2, listing.attempts, context2.enrollment.id)
104255
+ ]);
104348
104256
  this.recordReviewPreparationPhase("provisioning", provisioningStartedAt);
104349
104257
  this.recordReviewPreparationPhase("total", preparationStartedAt, {
104350
104258
  candidateCount: catalog.bank.items.length,
104351
104259
  selectedCount: selected.selections.length
104352
104260
  });
104353
- return this.snapshot(result, metadata2, assessment, context2.integration);
104261
+ return snapshot;
104354
104262
  });
104355
104263
  }
104356
104264
  async get(params) {
@@ -104868,28 +104776,11 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104868
104776
  liveTestIntegrationIds: integrationIdsWithLiveTests
104869
104777
  };
104870
104778
  }
104871
- async selectIntegration(candidates, studentId, input, results) {
104872
- const {
104873
- rows: candidateRows,
104874
- enrollmentByCourse,
104875
- liveTestIntegrationIds: integrationIdsWithLiveTests
104876
- } = candidates;
104877
- const needsOpenAnnotation = candidateRows.length > 1 || candidateRows.some((row) => !integrationIdsWithLiveTests.has(row.id));
104878
- const openAttemptScope = {
104879
- studentId,
104880
- activityId: input.activityId,
104881
- purpose: input.purpose,
104882
- enrollmentIdByCourse: new Map(candidateRows.map((row) => [row.courseId, enrollmentByCourse.get(row.courseId).id])),
104883
- requestFingerprint: this.startRequestFingerprint(input)
104884
- };
104885
- let openAttemptCourseIds = new Set;
104886
- if (needsOpenAnnotation) {
104887
- openAttemptCourseIds = results ? this.openAttemptCourseIdsFromResults(results, openAttemptScope) : await this.openAttemptCourseIds(openAttemptScope);
104888
- }
104779
+ async selectIntegration(candidates, input) {
104780
+ const { rows: candidateRows, enrollmentByCourse, liveTestIntegrationIds } = candidates;
104889
104781
  const annotated = candidateRows.map((row) => ({
104890
104782
  ...row,
104891
- hasLiveTests: integrationIdsWithLiveTests.has(row.id),
104892
- hasOpenAttempt: openAttemptCourseIds.has(row.courseId)
104783
+ hasLiveTests: liveTestIntegrationIds.has(row.id)
104893
104784
  }));
104894
104785
  const selection = selectAssessmentCourseCandidate(annotated, Boolean(input.subject));
104895
104786
  if (selection.outcome === "none") {
@@ -104918,15 +104809,19 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104918
104809
  standard: assessmentStandardForRow(row)
104919
104810
  })).filter((test) => !matchesMasteryStandard || matchesMasteryStandard(test.standard));
104920
104811
  }
104921
- async openAttemptCourseIds(scope) {
104922
- return this.openAttemptCourseIdsFromResults(await this.studentAssessmentResults(scope.studentId), scope);
104812
+ async activityAssessmentResults(scope) {
104813
+ const [parents, children] = await Promise.all([
104814
+ this.collectAssessmentResults(buildAssessmentAttemptResultWhere(scope)),
104815
+ scope.purpose === "review" ? this.activityChildResults(scope) : []
104816
+ ]);
104817
+ return [...parents, ...children];
104818
+ }
104819
+ activityChildResults(scope) {
104820
+ return this.collectAssessmentResults(buildAssessmentChildResultWhere(scope));
104923
104821
  }
104924
- async studentAssessmentResults(studentId) {
104822
+ async collectAssessmentResults(where) {
104925
104823
  const stream = this.requireClient().api.oneroster.assessmentResults.stream({
104926
- where: {
104927
- "student.sourcedId": studentId,
104928
- status: ONEROSTER_STATUS2.active
104929
- }
104824
+ where
104930
104825
  });
104931
104826
  const results = [];
104932
104827
  for await (const result of stream) {
@@ -104934,18 +104829,8 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104934
104829
  }
104935
104830
  return results;
104936
104831
  }
104937
- openAttemptCourseIdsFromResults(results, scope) {
104938
- const courseIds = new Set;
104939
- for (const result of results) {
104940
- const metadata2 = playcademyAssessmentResultMetadata(result.metadata);
104941
- if (metadata2 && metadata2.activityId === scope.activityId && metadata2.purpose === scope.purpose && this.attemptMatchesRequestFingerprint(metadata2, scope.requestFingerprint) && result.student.sourcedId === scope.studentId && metadata2.enrollmentId === scope.enrollmentIdByCourse.get(metadata2.courseId) && (this.isOpen(result) || this.isAwaitingAward(result))) {
104942
- courseIds.add(metadata2.courseId);
104943
- }
104944
- }
104945
- return courseIds;
104946
- }
104947
104832
  async listAttempts(scope) {
104948
- return this.attemptListing(await this.studentAssessmentResults(scope.studentId), scope);
104833
+ return this.attemptListing(await this.activityAssessmentResults(scope), scope);
104949
104834
  }
104950
104835
  async attemptListing(results, scope) {
104951
104836
  const attempts = new Map;
@@ -104974,7 +104859,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104974
104859
  }
104975
104860
  }
104976
104861
  }
104977
- if (metadata2 && metadata2.activityId === scope.activityId && metadata2.purpose === scope.purpose && metadata2.courseId === scope.courseId && metadata2.integrationId === scope.integrationId && result.student.sourcedId === scope.studentId) {
104862
+ if (metadata2 && this.attemptInScope(result, metadata2, scope)) {
104978
104863
  const selectedAssessmentKey = metadata2.selectedTest.assessmentKey ?? assessmentKeyFromManagedQtiIdentifier(metadata2.selectedTest.identifier);
104979
104864
  if (selectedAssessmentKey) {
104980
104865
  attempts.set(result.sourcedId, {
@@ -104983,12 +104868,9 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104983
104868
  selection: {
104984
104869
  attemptId: result.sourcedId,
104985
104870
  selectedAssessmentKey,
104986
- selectedTestIdentifier: metadata2.selectedTest.identifier,
104987
104871
  inProgress: result.inProgress ?? "",
104988
104872
  scoreStatus: result.scoreStatus,
104989
- scoreDate: result.scoreDate,
104990
- updatedAt: result.dateLastModified ?? metadata2.updatedAt,
104991
- resumable: metadata2.enrollmentId === scope.enrollmentId
104873
+ scoreDate: result.scoreDate
104992
104874
  }
104993
104875
  });
104994
104876
  }
@@ -104996,114 +104878,61 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
104996
104878
  }
104997
104879
  return { attempts, reviewChildren };
104998
104880
  }
104999
- attemptsMatchingRequest(attempts, requestFingerprint) {
105000
- if (requestFingerprint === undefined) {
105001
- return attempts;
105002
- }
105003
- return new Map([...attempts].filter(([, attempt]) => this.attemptMatchesRequestFingerprint(attempt.metadata, requestFingerprint)));
105004
- }
105005
- startRequestFingerprint(input) {
105006
- if (input.purpose === "review") {
105007
- return reviewRequestFingerprint(input, DEFAULT_REVIEW_SELECTION_POLICY);
105008
- }
105009
- if (input.purpose === "mastery") {
105010
- return masteryRequestFingerprint(input);
105011
- }
105012
- return input.purpose === "diagnostic" ? input.diagnosticKey : undefined;
105013
- }
105014
- attemptMatchesRequestFingerprint(metadata2, requestFingerprint) {
105015
- if (metadata2.purpose === "review") {
105016
- return metadata2.review.requestFingerprint === requestFingerprint;
105017
- }
105018
- if (metadata2.purpose === "mastery") {
105019
- return metadata2.mastery.requestFingerprint === requestFingerprint;
105020
- }
105021
- if (metadata2.purpose === "diagnostic" && requestFingerprint !== undefined) {
105022
- return this.isPlatformRoutedDiagnosticMetadata(metadata2) && metadata2.diagnostic.diagnosticKey === requestFingerprint;
105023
- }
105024
- return true;
105025
- }
105026
- requireDecision(decision, purpose) {
104881
+ selectFreshStart(tests, listing, purpose) {
104882
+ const decision = selectRuntimeAssessment(tests, [...listing.attempts.values()].map((entry) => entry.selection));
105027
104883
  if (!decision) {
105028
104884
  throw AssessmentRuntimeError.from(assessmentNoEligibleTests(purpose));
105029
104885
  }
105030
104886
  return decision;
105031
104887
  }
105032
- resumeSnapshot(decision, attempts, integration) {
105033
- if (decision.additionalResumableAttemptIds.length > 0) {
105034
- addEvent("assessment.multiple_resumable_attempts", {
105035
- "app.assessment.attempt_id": decision.attempt.attemptId,
105036
- "app.assessment.additional_attempt_ids": decision.additionalResumableAttemptIds.join(",")
105037
- });
105038
- }
105039
- const resumed = attempts.get(decision.attempt.attemptId);
105040
- return this.snapshotForResult(resumed.result, resumed.metadata, integration);
105041
- }
105042
- async resumeDiagnosticSnapshot(decision, attempts, integration, _db = this.deps.db) {
105043
- const resumed = attempts.get(decision.attempt.attemptId);
105044
- return this.snapshotForResult(resumed.result, resumed.metadata, integration);
104888
+ pendingAwardAttempt(listing, enrollmentId) {
104889
+ const pending = [...listing.attempts.values()].filter((attempt) => attempt.metadata.enrollmentId === enrollmentId && this.isAwaitingAward(attempt.result));
104890
+ return pending.toSorted((left, right) => compareNewestCompletedAssessmentAttempt(left.selection, right.selection))[0] ?? null;
105045
104891
  }
105046
- async resumeAvailableAttempt(tests, attempts, requestFingerprint, pendingSupersessions, resume) {
105047
- const select3 = () => selectRuntimeAssessment(tests, [...this.attemptsMatchingRequest(attempts, requestFingerprint).values()].map((entry) => entry.selection));
105048
- return resolveAvailableAssessmentAttempt(select3(), select3, (decision) => this.resumeSnapshotOrStageSupersession(decision, attempts, pendingSupersessions, () => resume(decision)));
104892
+ async pendingAwardSnapshot(listing, context2) {
104893
+ const pending = this.pendingAwardAttempt(listing, context2.enrollment.id);
104894
+ if (!pending) {
104895
+ return null;
104896
+ }
104897
+ addEvent("assessment.pending_award_returned", {
104898
+ "app.assessment.attempt_id": pending.result.sourcedId
104899
+ });
104900
+ return this.snapshotForResult(pending.result, pending.metadata, context2.integration);
105049
104901
  }
105050
- async resumeSnapshotOrStageSupersession(decision, attempts, pendingSupersessions, resume) {
105051
- try {
105052
- return await resume();
105053
- } catch (error88) {
105054
- if (!(error88 instanceof AssessmentRuntimeError) || error88.runtimeCode !== "SELECTED_TEST_VERSION_UNAVAILABLE") {
105055
- throw error88;
105056
- }
105057
- if (shouldConfirmAssessmentVersionUnavailable(error88.details)) {
105058
- await sleep(TimebackAssessmentRuntimeService.ASSESSMENT_VERSION_CONFIRMATION_DELAY_MS);
105059
- try {
105060
- return await resume();
105061
- } catch (confirmationError) {
105062
- if (!(confirmationError instanceof AssessmentRuntimeError) || confirmationError.runtimeCode !== "SELECTED_TEST_VERSION_UNAVAILABLE") {
105063
- throw confirmationError;
105064
- }
105065
- }
104902
+ replacedAttemptIds(attempts, enrollmentId) {
104903
+ const replaced = new Set;
104904
+ for (const attempt of attempts.values()) {
104905
+ if (attempt.metadata.enrollmentId === enrollmentId && this.isOpen(attempt.result)) {
104906
+ replaced.add(attempt.result.sourcedId);
105066
104907
  }
105067
- const staged = attempts.get(decision.attempt.attemptId);
105068
- pendingSupersessions.add(staged.result.sourcedId);
105069
- stageAssessmentAttemptSupersession(staged);
105070
- addEvent("assessment.attempt_supersession_staged", {
105071
- "app.assessment.attempt_id": decision.attempt.attemptId,
105072
- "app.assessment.reason": "selected_test_version_unavailable"
105073
- });
105074
- return null;
105075
104908
  }
104909
+ return replaced;
104910
+ }
104911
+ supersedeReplacedAttempts(db2, attempts, enrollmentId) {
104912
+ return this.supersedeAttemptsBestEffort(db2, this.replacedAttemptIds(attempts, enrollmentId));
105076
104913
  }
105077
- async persistPendingSupersessions(db2, attemptIds) {
104914
+ async supersedeAttempts(db2, attemptIds) {
105078
104915
  for (const attemptId of [...attemptIds].toSorted()) {
105079
104916
  await this.deps.assessmentRuntimeLock(db2, [assessmentRuntimeAttemptLockKey(attemptId)], async () => {
105080
- let result;
105081
- try {
105082
- result = await this.requireClient().api.oneroster.assessmentResults.get(attemptId);
105083
- } catch (error88) {
105084
- if (isApiError(error88) && error88.statusCode === 404) {
105085
- return;
105086
- }
105087
- throw error88;
105088
- }
105089
- const update2 = buildAssessmentAttemptSupersessionUpdate(result);
104917
+ const result = await this.findAttemptResult(attemptId);
104918
+ const update2 = result && buildAssessmentAttemptSupersessionUpdate(result);
105090
104919
  if (!update2) {
105091
104920
  return;
105092
104921
  }
105093
104922
  await this.requireClient().api.oneroster.assessmentResults.upsert(attemptId, update2);
105094
104923
  addEvent("assessment.attempt_superseded", {
105095
104924
  "app.assessment.attempt_id": attemptId,
105096
- "app.assessment.reason": "selected_test_version_unavailable"
104925
+ "app.assessment.reason": "replaced_by_fresh_start"
105097
104926
  });
105098
104927
  });
105099
104928
  }
105100
104929
  }
105101
- async persistPendingSupersessionsBestEffort(db2, attemptIds) {
104930
+ async supersedeAttemptsBestEffort(db2, attemptIds) {
105102
104931
  if (attemptIds.size === 0) {
105103
104932
  return;
105104
104933
  }
105105
104934
  try {
105106
- await this.persistPendingSupersessions(db2, attemptIds);
104935
+ await this.supersedeAttempts(db2, attemptIds);
105107
104936
  } catch (error88) {
105108
104937
  addEvent("assessment.attempt_supersession_persist_failed", {
105109
104938
  "app.assessment.attempt_ids": [...attemptIds].toSorted().join(","),
@@ -105112,10 +104941,6 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
105112
104941
  });
105113
104942
  }
105114
104943
  }
105115
- async resumeReviewSnapshot(decision, attempts, context2) {
105116
- const resumed = attempts.get(decision.attempt.attemptId);
105117
- return this.snapshotForResult(resumed.result, resumed.metadata, context2.integration);
105118
- }
105119
104944
  recordReviewPreparationPhase(phase, startedAt, counts = {}) {
105120
104945
  addEvent("assessment.review_preparation_phase", {
105121
104946
  "app.assessment.review_phase": phase,
@@ -105466,12 +105291,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
105466
105291
  }
105467
105292
  });
105468
105293
  } catch (error88) {
105469
- if (!isApiError(error88) || error88.statusCode !== 409) {
105470
- throw error88;
105471
- }
105472
- addEvent("assessment.line_item_create_conflict", {
105473
- "app.assessment.line_item_id": lineItemId
105474
- });
105294
+ await this.confirmLineItemAfterRefusedCreate(lineItemId, error88, "assessment.line_item_create_conflict", () => client2.api.oneroster.assessmentLineItems.get(lineItemId));
105475
105295
  }
105476
105296
  return lineItemId;
105477
105297
  }
@@ -105513,15 +105333,26 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
105513
105333
  }
105514
105334
  });
105515
105335
  } catch (error88) {
105516
- if (!isApiError(error88) || error88.statusCode !== 409) {
105517
- throw error88;
105518
- }
105519
- addEvent("assessment.review_bank_line_item_create_conflict", {
105520
- "app.assessment.line_item_id": lineItemId
105521
- });
105336
+ await this.confirmLineItemAfterRefusedCreate(lineItemId, error88, "assessment.review_bank_line_item_create_conflict", () => client2.api.oneroster.assessmentLineItems.get(lineItemId));
105522
105337
  }
105523
105338
  return lineItemId;
105524
105339
  }
105340
+ async confirmLineItemAfterRefusedCreate(lineItemId, createError, event, read) {
105341
+ let stored;
105342
+ try {
105343
+ stored = await read();
105344
+ } catch (readError) {
105345
+ if (isApiError(readError) && readError.statusCode === 404) {
105346
+ throw createError;
105347
+ }
105348
+ throw readError;
105349
+ }
105350
+ addEvent(event, {
105351
+ "app.assessment.line_item_id": lineItemId,
105352
+ ...isApiError(createError) ? { "app.timeback.status": createError.statusCode } : {}
105353
+ });
105354
+ return stored;
105355
+ }
105525
105356
  tokenContext(attempt) {
105526
105357
  return {
105527
105358
  version: 1,
@@ -105575,10 +105406,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
105575
105406
  try {
105576
105407
  await store.create({ sourcedId: id, title: item.title, ...expected });
105577
105408
  } catch (error88) {
105578
- if (!isApiError(error88) || error88.statusCode !== 409) {
105579
- throw error88;
105580
- }
105581
- this.assertAssessmentChildLineItem(id, await store.get(id), expected);
105409
+ this.assertAssessmentChildLineItem(id, await this.confirmLineItemAfterRefusedCreate(id, error88, "assessment.question_line_item_create_conflict", () => store.get(id)), expected);
105582
105410
  }
105583
105411
  }
105584
105412
  this.childLineItemCache.set(id, true);
@@ -105644,14 +105472,8 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
105644
105472
  ...expected
105645
105473
  });
105646
105474
  } catch (error88) {
105647
- if (!isApiError(error88) || error88.statusCode !== 409) {
105648
- throw error88;
105649
- }
105650
- const stored = await client2.api.oneroster.assessmentLineItems.get(lineItemId);
105475
+ const stored = await this.confirmLineItemAfterRefusedCreate(lineItemId, error88, "assessment.diagnostic_question_line_item_create_conflict", () => client2.api.oneroster.assessmentLineItems.get(lineItemId));
105651
105476
  this.assertAssessmentChildLineItem(lineItemId, stored, expected);
105652
- addEvent("assessment.diagnostic_question_line_item_create_conflict", {
105653
- "app.assessment.line_item_id": lineItemId
105654
- });
105655
105477
  }
105656
105478
  return lineItemId;
105657
105479
  }
@@ -105690,14 +105512,8 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
105690
105512
  ...expected
105691
105513
  });
105692
105514
  } catch (error88) {
105693
- if (!isApiError(error88) || error88.statusCode !== 409) {
105694
- throw error88;
105695
- }
105696
- const stored = await client2.api.oneroster.assessmentLineItems.get(lineItemId);
105515
+ const stored = await this.confirmLineItemAfterRefusedCreate(lineItemId, error88, "assessment.review_question_line_item_create_conflict", () => client2.api.oneroster.assessmentLineItems.get(lineItemId));
105697
105516
  this.assertAssessmentChildLineItem(lineItemId, stored, expected);
105698
- addEvent("assessment.review_question_line_item_create_conflict", {
105699
- "app.assessment.line_item_id": lineItemId
105700
- });
105701
105517
  }
105702
105518
  return lineItemId;
105703
105519
  }
@@ -105779,7 +105595,10 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
105779
105595
  const graded = await resumeAssessmentTranscript({
105780
105596
  suppliedTranscript,
105781
105597
  checkpointTranscript: metadata2.transcript,
105782
- recover: async () => recoverAssessmentTranscript(context2, await this.studentAssessmentResults(context2.studentId)),
105598
+ recover: async () => recoverAssessmentTranscript(context2, await this.activityChildResults({
105599
+ studentId: context2.studentId,
105600
+ activityId: context2.metadata.activityId
105601
+ })),
105783
105602
  grade: (transcript) => this.gradeTranscript(context2, transcript)
105784
105603
  });
105785
105604
  const session2 = await this.assessmentSession(context2, graded.transcript);
@@ -105938,7 +105757,22 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
105938
105757
  }
105939
105758
  return projectReviewAssessment(playable, preparedReviewBankIndex(plan), plan.review.selections);
105940
105759
  }
105941
- async createPreparedAttempt(plan, assessment, integration) {
105760
+ async claimPreparedAttempt(db2, plan, assessment, integration) {
105761
+ const occupant = await this.findAttemptResult(plan.attemptId);
105762
+ if (occupant) {
105763
+ const metadata3 = playcademyAssessmentResultMetadata(occupant.metadata);
105764
+ if (occupant.status !== ONEROSTER_STATUS2.active || !metadata3 || !this.attemptInScope(occupant, metadata3, plan) || metadata3.enrollmentId !== plan.enrollmentId || !this.isOpen(occupant) || !this.attemptMatchesPlan(metadata3, plan)) {
105765
+ return null;
105766
+ }
105767
+ addEvent("assessment.prepared_attempt_recovered", {
105768
+ "app.assessment.attempt_id": plan.attemptId
105769
+ });
105770
+ const [snapshot2] = await Promise.all([
105771
+ this.snapshotForResult(occupant, metadata3, integration),
105772
+ this.supersedeAttemptsBestEffort(db2, new Set(plan.supersedes))
105773
+ ]);
105774
+ return snapshot2;
105775
+ }
105942
105776
  const timestamp6 = new Date().toISOString();
105943
105777
  const metadata2 = buildPreparedAssessmentMetadata(plan, timestamp6, assessment.title);
105944
105778
  const result = await this.requireClient().api.oneroster.assessmentResults.upsert(plan.attemptId, {
@@ -105955,9 +105789,42 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
105955
105789
  grade: integration.grade
105956
105790
  })
105957
105791
  });
105958
- return this.isPlatformRoutedDiagnosticMetadata(metadata2) ? this.diagnosticSnapshot(result, metadata2, assessment, integration, {
105959
- state: metadata2.diagnostic.state
105960
- }) : this.snapshot(result, metadata2, assessment, integration);
105792
+ const [snapshot] = await Promise.all([
105793
+ this.isPlatformRoutedDiagnosticMetadata(metadata2) ? this.diagnosticSnapshot(result, metadata2, assessment, integration, {
105794
+ state: metadata2.diagnostic.state
105795
+ }) : this.snapshot(result, metadata2, assessment, integration),
105796
+ this.supersedeAttemptsBestEffort(db2, new Set(plan.supersedes))
105797
+ ]);
105798
+ return snapshot;
105799
+ }
105800
+ attemptMatchesPlan(metadata2, plan) {
105801
+ const planned = buildPreparedAssessmentMetadata(plan, metadata2.startedAt, "");
105802
+ if (metadata2.selectedTest.assessmentKey !== planned.selectedTest.assessmentKey || metadata2.selectedTest.identifier !== planned.selectedTest.identifier || metadata2.selectedTest.contentRevision !== planned.selectedTest.contentRevision) {
105803
+ return false;
105804
+ }
105805
+ if (metadata2.purpose === "review" && planned.purpose === "review") {
105806
+ return metadata2.review.requestFingerprint === planned.review.requestFingerprint && metadata2.review.bankRevision === planned.review.bankRevision && metadata2.review.selections.length === planned.review.selections.length && metadata2.review.selections.every((selection, index2) => selection.itemIdentifier === planned.review.selections[index2].itemIdentifier);
105807
+ }
105808
+ if (metadata2.purpose === "mastery" && planned.purpose === "mastery") {
105809
+ return metadata2.mastery.requestFingerprint === planned.mastery.requestFingerprint;
105810
+ }
105811
+ if (metadata2.purpose === "diagnostic" && plan.purpose === "diagnostic") {
105812
+ return this.isPlatformRoutedDiagnosticMetadata(metadata2) && metadata2.diagnostic.definitionId === plan.diagnostic.definitionId && metadata2.diagnostic.routingRevision === plan.diagnostic.routingRevision;
105813
+ }
105814
+ return metadata2.purpose === planned.purpose;
105815
+ }
105816
+ async findAttemptResult(attemptId) {
105817
+ try {
105818
+ return await this.requireClient().api.oneroster.assessmentResults.get(attemptId);
105819
+ } catch (error88) {
105820
+ if (isApiError(error88) && error88.statusCode === 404) {
105821
+ return null;
105822
+ }
105823
+ throw error88;
105824
+ }
105825
+ }
105826
+ attemptInScope(result, metadata2, scope) {
105827
+ return metadata2.activityId === scope.activityId && metadata2.purpose === scope.purpose && metadata2.courseId === scope.courseId && metadata2.integrationId === scope.integrationId && result.student.sourcedId === scope.studentId;
105961
105828
  }
105962
105829
  async claimAttemptId(scope, from) {
105963
105830
  for (let attemptNumber = from;attemptNumber < from + 20; attemptNumber += 1) {
@@ -105973,15 +105840,7 @@ var init_timeback_assessment_runtime_service = __esm(async () => {
105973
105840
  throw new Error(`Assessment attempt numbers ${from} through ${from + 19} are all occupied by records this code cannot read; refusing to overwrite them.`);
105974
105841
  }
105975
105842
  async attemptIdOccupied(attemptId) {
105976
- try {
105977
- await this.requireClient().api.oneroster.assessmentResults.get(attemptId);
105978
- } catch (error88) {
105979
- if (isApiError(error88) && error88.statusCode === 404) {
105980
- return false;
105981
- }
105982
- throw error88;
105983
- }
105984
- return true;
105843
+ return await this.findAttemptResult(attemptId) !== null;
105985
105844
  }
105986
105845
  replayCompletion(attempt, submissionId, params, game2) {
105987
105846
  const { score, completion } = attempt.metadata;