Files
3eea9dc542 feat(runtime): complete the #869 learner-data cutover — quiz + playback onto RuntimeStore (#955)
* feat(runtime): persist quiz attempts in RuntimeStore

* fix(runtime): coalesce quiz draft snapshots

* fix(runtime): recover concurrent quiz attempts

* fix(runtime): handle quiz completion races

* fix(runtime): commit quiz review atomically

* fix(runtime): dedupe concurrent quiz writes

* fix(runtime): drop stale quiz drafts

* fix: harden quiz runtime review recovery

* fix: serialize quiz attempt identity

* feat: read quiz state from runtime store

* fix: persist quiz retries before resetting

* fix: preserve authoritative quiz outcomes

* fix: preserve legacy quiz retries during cutover

* fix: reconcile legacy quiz snapshots safely

* fix: drain rollover quiz write queues

* fix: drain completed quiz retry queues

* fix: reuse concurrent quiz retries

* fix(quiz): preserve drafts across abrupt reloads

* fix(quiz): recover empty retry sessions

* fix(chat): abort stalled runtime state reads

* fix(quiz): expose queued phases to readers

* fix(quiz): retain concurrent writer tails

* fix(quiz): canonicalize retry branches

* fix(quiz): keep retry rollovers monotonic

* fix(quiz): validate skipped retry siblings

* fix(quiz): close read cutover races

* test(classroom): cover legacy quiz summaries

* fix(quiz): reset async consumers on scene changes

* fix(quiz): close scene transition windows

* test(pbl): cover launch freshness guards

* fix(quiz): close cutover concurrency gaps

* fix(quiz): harden retry and context freshness

* fix(quiz): reject malformed legacy answers

* fix(quiz): validate legacy answer values

* test(quiz): cover legacy multi-answer migration

* fix(merge): retire dead ChatRequestTemplate.storeState after quiz read cutover

Main's three call sites built static storeState blocks that runAgentLoopFn
never consumed (it always rebuilds fresh state via getStoreState); the quiz
read cutover replaced that callback with the async two-phase RuntimeStore
read, leaving the template field with zero consumers. Drop it.

* feat(storage): conform HTTP/PG backends and reference server to RuntimeAppendOptions

The quiz write path's expectedLastSeq / sessionTransition / RuntimeAppendConflictError
semantics existed only in the browser backend; server-backed deployments would
silently accept conflicting appends and leave completed sessions active. Forward
the options over the wire, detect conflicts atomically under the PG transaction,
map them to HTTP 409 RUNTIME_APPEND_CONFLICT, and rematerialize the typed error
client-side so quiz retry logic works across every backend.

Co-authored-by: Codex <codex@openai.com>

* fix(chat): Pi single requests build storeState via the async runtime quiz read

Pi bypasses runAgentLoop's per-iteration getStoreState and serializes the
request template straight to /api/chat/pi, which rejects bodies without
storeState. Extract the fresh-snapshot builder (async RuntimeStore quiz read
with the scene-transition guard) and call it on the Pi path too.

* ci: whitelist the runtime-data-cutover integration trunk for PR checks

* feat(runtime): playback cutover — cursor in KV, discussion facts in RuntimeStore (#956)

* feat(runtime): cut playback over to the runtime layer — cursor in KV, facts in RuntimeStore (#869)

The fourth and last runtime family. Consumed-discussion facts become
append-only 'playback' records folded into a set at read (at-least-once
appends, no conflict machinery); the resume cursor is device-scoped
last-write-wins KV per the amended #779/#869 split. sessionStorage keeps
same-tab priority; KV takes over on fresh tabs/reloads. The dead Dexie
playbackState machinery is retired, with a one-time lazy migration of any
legacy row (cursor half + facts half) before deletion, and stage deletion
now clears both the KV cursor and any unmigrated legacy row.

Co-authored-by: Codex <codex@openai.com>

* test(runtime): include playbackState in the stage-delete db mock

---------

Co-authored-by: Codex <codex@openai.com>

* fix(playback): persist discussion facts on every consumption path (#957)

* fix(playback): persist discussion facts on every consumption path (final-review P0+P1s)

- The engine now publishes a progress snapshot the moment a discussion is
  consumed (join / skip / unselected-agent auto-skip). onProgress otherwise
  fires before the discussion action executes and a discussion is the scene's
  last action, so the fact never reached persistence.
- Reads fold records across ALL playback sessions in the learner partition
  (mergeLearner deliberately preserves same-kind sessions from both keys).
- Legacy migration appends only not-yet-durable facts, so an interrupted
  migration resumes instead of dropping the tail.
- recordConsumedDiscussion reports durability; the component drops failed ids
  from its observed set so a later progress tick retries (at-least-once).

* test(e2e): live verification of the playback persistence chain

Seeds a deterministic stage straight into the Dexie DB, starts the lecture
via the canvas overlay, and asserts the full chain: discussion auto-skip
appends a discussionConsumed record to maic-runtime, the device cursor lands
in KV, and both survive a fresh browsing context (empty sessionStorage).

* refactor(playback): consumed-discussion state is volatile by decision — cursor-only persistence (#959)

Product ruling on #869's fourth family: playback learner state is front-end
ephemeral UX, not learner data. A re-shown proactive card auto-skips, joined
discussions' content already lives in chat runtime records, and no replay
export / analytics consumer exists — so durable facts bought nothing over
in-memory + same-tab sessionStorage. Drop lib/playback/runtime.ts and the
RuntimeStore facts wiring; keep the device-scoped KV resume cursor (the half
with real UX value), the engine's consumption-time progress snapshot (cursor
freshness), and the legacy Dexie retirement (cursor half migrates, row
deletes, consumed ids are dropped).

* fix(review): P3 pair from cross-review — scene-id boundary + sessionTransition 400 (#966)

* fix(review): scene-id boundary for quiz context + 4xx for malformed sessionTransition (P3 pair)

Review findings on #955: didActiveSceneRemainUnchanged compared the active
scene by object identity, so a store update reallocating the scene during
the async quiz read dropped the learner's graded answers from that turn's
request — the scene id is the real boundary. The records route now
classifies a malformed sessionTransition as a validation failure instead of
letting the store's throw surface as a 500.

* fix(playback): superseded-engine cursor guard + migration write-window recheck

Second-vendor review of the #959 shrink (requested after the cross-review
noted it had single-vendor coverage) found: an engine orphaned by a scene
switch during async lecture resume could pass the idle-only recheck, be
resurrected, and publish its old scene's progress over the new scene's
debounced cursor — the resume continuation now requires identity with the
installed engine, and onProgress drops snapshots from superseded engines.
The legacy cursor migration also rechecks KV immediately before its write
so a concurrent tab's newer cursor cannot be overwritten and orphaned by
the legacy-row delete.

Co-authored-by: Codex <codex@openai.com>

---------

Co-authored-by: Codex <codex@openai.com>

* fix(review): approval follow-up P3 nits (#967)

* release: v0.3.1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): approval P3 nits — ISO gate on sessionTransition, dead mocks, corrupt-timestamp guard

- The records route's sessionTransition guard now requires an ISO
  updatedAt (isIsoTimestamp), matching the sibling PATCH /status route
- Dead vi.mock factories for the deleted playback-storage module dropped
- A corrupt legacy playback timestamp falls back to 'now' instead of
  wedging migration into a permanent re-throw that disabled resume

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:28:04 +08:00

66 lines
1.9 KiB
TypeScript

/**
* Playback Types - Types for lecture playback and live discussion engine
*/
export interface PlaybackSnapshot {
sceneIndex: number;
actionIndex: number;
consumedDiscussions: string[];
sceneId?: string;
}
/** Visual effects (for onEffectFire callback) */
export type Effect =
| { kind: 'spotlight'; targetId: string; dimOpacity?: number }
| { kind: 'laser'; targetId: string; color?: string };
/** Engine mode state machine */
export type EngineMode = 'idle' | 'playing' | 'paused' | 'live';
/** Discussion topic state */
export type TopicState = 'active' | 'pending' | 'closed';
/** Trigger event (for proactive discussion card) */
export interface TriggerEvent {
id: string;
question: string;
prompt?: string;
agentId?: string;
}
/** Playback engine callbacks */
export interface PlaybackEngineCallbacks {
onModeChange?: (mode: EngineMode) => void;
onSceneChange?: (sceneId: string) => void;
onSpeechStart?: (text: string) => void;
onSpeechEnd?: () => void;
onTextDelta?: (content: string) => void;
onSpeakerChange?: (role: string) => void;
onEffectFire?: (effect: Effect) => void;
// Proactive discussion
onProactiveShow?: (trigger: TriggerEvent) => void;
onProactiveHide?: () => void;
// Discussion lifecycle
onDiscussionConfirmed?: (topic: string, prompt?: string, agentId?: string) => void;
onDiscussionEnd?: () => void;
onUserInterrupt?: (text: string) => void;
// Topic / Transcript
onTopicStart?: (type: 'lecture' | 'discussion', title: string) => void;
onTopicAppend?: (role: string, text: string) => void;
onTopicEnd?: () => void;
// Progress tracking (for persistence)
onProgress?: (snapshot: PlaybackSnapshot) => void;
/** Check if a given agent is in the user's selected list (for skipping discussion actions) */
isAgentSelected?: (agentId: string) => boolean;
/** Get current playback speed multiplier (e.g. 1, 1.5, 2) */
getPlaybackSpeed?: () => number;
onComplete?: () => void;
}