Files
openmaic/OpenMAIC/tests/pbl/v2/hydration.test.ts
2026-08-16 14:58:47 +08:00

1306 lines
40 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { IDBFactory, IDBKeyRange } from 'fake-indexeddb';
import type {
RuntimePayload,
RuntimeRecord,
RuntimeRecordInit,
RuntimeSession,
} from '@openmaic/dsl';
import { BrowserRuntimeStore, type RuntimeSessionInit, type RuntimeStore } from '@openmaic/storage';
import { applyInstructorEvent } from '@/components/scene-renderers/pbl/v2/apply-instructor-event';
import { recordEvent } from '@/lib/pbl/v2/operations/kernel/engagement';
import { addEvaluation } from '@/lib/pbl/v2/operations/runtime/evaluation';
import {
advanceMicrotask,
continueAfterHandover,
resetProjectProgress,
startMicrotask,
} from '@/lib/pbl/v2/operations/kernel/progress';
import { transitionProjectUiPhase } from '@/lib/pbl/v2/operations/kernel/runtime-events';
import { addSubmission } from '@/lib/pbl/v2/operations/runtime/submission';
import {
clearPendingTaskCompletion,
setPendingTaskCompletion,
} from '@/lib/pbl/v2/operations/kernel/task-completion';
import { drainProjectRuntime } from '@/lib/pbl/v2/runtime/drain';
import { foldPBLRuntime } from '@/lib/pbl/v2/runtime/fold';
import {
appendPBLRuntimeSnapshotIfChanged,
documentContainsLearnerState,
hydratePBLProjectFromRuntime,
hydratePBLScenesFromRuntime,
synchronizePBLProjectRuntime,
} from '@/lib/pbl/v2/runtime/hydration';
import { extractLearnerState, stripToDesignTemplate } from '@/lib/pbl/v2/runtime/learner-state';
import {
pblSnapshotRecordPayload,
type PBLRuntimeStorePayload,
} from '@/lib/pbl/v2/runtime/record-payloads';
import { makeScene, type Scene } from '@/lib/types/stage';
import type { KVScope, KVStore } from '@openmaic/storage';
import type { PBLProjectV2, PBLRuntimeEvent } from '@/lib/pbl/v2/types';
import { withRuntimeStorageExclusiveLock } from '@/lib/utils/chat-storage-lock';
import { legacyPBLSceneFixture } from '@/tests/fixtures/pbl-v1-scene';
if (!('IDBKeyRange' in globalThis)) {
Object.defineProperty(globalThis, 'IDBKeyRange', { value: IDBKeyRange, configurable: true });
}
const STAGE_ID = 'stage-1';
const SCENE_ID = 'scene-1';
const LEARNER_KEY = 'anon:test-device';
function readWriteLockManager(onAcquire?: (mode: LockMode) => void): Pick<LockManager, 'request'> {
type Waiter = {
mode: LockMode;
run(): void;
};
const states = new Map<string, { readers: number; writer: boolean; waiters: Waiter[] }>();
const stateFor = (name: string) => {
let state = states.get(name);
if (!state) {
state = { readers: 0, writer: false, waiters: [] };
states.set(name, state);
}
return state;
};
const pump = (name: string) => {
const state = stateFor(name);
if (state.writer || state.waiters.length === 0) return;
if (state.waiters[0]!.mode === 'exclusive') {
if (state.readers === 0) state.waiters.shift()!.run();
return;
}
while (state.waiters[0]?.mode === 'shared' && !state.writer) {
state.waiters.shift()!.run();
}
};
return {
request<T>(
name: string,
optionsOrCallback: LockOptions | (() => Promise<T> | T),
maybeCallback?: () => Promise<T> | T,
): Promise<T> {
const options = typeof optionsOrCallback === 'function' ? undefined : optionsOrCallback;
const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback!;
const mode = options?.mode ?? 'exclusive';
const state = stateFor(name);
return new Promise<T>((resolve, reject) => {
state.waiters.push({
mode,
run() {
if (mode === 'shared') state.readers += 1;
else state.writer = true;
onAcquire?.(mode);
void Promise.resolve()
.then(callback)
.then(resolve, reject)
.finally(() => {
if (mode === 'shared') state.readers -= 1;
else state.writer = false;
pump(name);
});
},
});
pump(name);
});
},
} as Pick<LockManager, 'request'>;
}
afterEach(() => {
vi.unstubAllGlobals();
});
class MemoryKVStore implements KVStore {
private readonly values = new Map<string, unknown>();
async get<T>(key: string, scope: KVScope = 'account'): Promise<T | null> {
return (this.values.get(`${scope}:${key}`) as T | undefined) ?? null;
}
async set<T>(key: string, value: T, scope: KVScope = 'account'): Promise<void> {
this.values.set(`${scope}:${key}`, value);
}
async remove(key: string, scope: KVScope = 'account'): Promise<void> {
this.values.delete(`${scope}:${key}`);
}
async keys(prefix = '', scope: KVScope = 'account'): Promise<string[]> {
const scopedPrefix = `${scope}:`;
return [...this.values.keys()]
.filter((key) => key.startsWith(scopedPrefix))
.map((key) => key.slice(scopedPrefix.length))
.filter((key) => key.startsWith(prefix));
}
}
class MemoryRuntimeStore implements RuntimeStore {
readonly sessions: RuntimeSession[] = [];
readonly records: RuntimeRecord[] = [];
async createSession(init: RuntimeSessionInit): Promise<RuntimeSession> {
const existing = this.sessions.find((session) => session.id === init.id);
if (existing) throw new Error(`session ${init.id} already exists`);
const session: RuntimeSession = { ...init, runtimeDslVersion: 'test' };
this.sessions.push(session);
return session;
}
async getSession(sessionId: string): Promise<RuntimeSession | undefined> {
return this.sessions.find((session) => session.id === sessionId);
}
async listSessions(stageId: string, learnerKey: string): Promise<RuntimeSession[]> {
return this.sessions.filter(
(session) => session.stageId === stageId && session.learnerKey === learnerKey,
);
}
async setSessionStatus(): Promise<void> {}
async deleteSession(): Promise<void> {}
async appendRecord<TPayload extends RuntimePayload>(
init: RuntimeRecordInit<TPayload>,
): Promise<RuntimeRecord<TPayload>> {
const seq = this.records.filter((record) => record.sessionId === init.sessionId).length;
const record: RuntimeRecord<TPayload> = { ...init, seq };
this.records.push(record);
return record;
}
async listRecords(sessionId: string, opts?: { sceneId?: string }): Promise<RuntimeRecord[]> {
return this.records.filter(
(record) =>
record.sessionId === sessionId && (opts?.sceneId ? record.sceneId === opts.sceneId : true),
);
}
async mergeLearner(): Promise<number> {
return 0;
}
async deleteLearnerRuntime(): Promise<void> {}
async deleteStageRuntime(): Promise<void> {}
async deleteAllRuntime(): Promise<void> {}
}
class ThrowingRuntimeStore extends MemoryRuntimeStore {
async listSessions(): Promise<RuntimeSession[]> {
throw new Error('runtime unavailable');
}
async listRecords(): Promise<RuntimeRecord[]> {
throw new Error('runtime unavailable');
}
async appendRecord<TPayload extends RuntimePayload>(
init: RuntimeRecordInit<TPayload>,
): Promise<RuntimeRecord<TPayload>> {
void init;
throw new Error('runtime unavailable');
}
}
class SlowFirstAppendRuntimeStore extends MemoryRuntimeStore {
private releaseAppend!: () => void;
private readonly appendGate = new Promise<void>((resolve) => {
this.releaseAppend = resolve;
});
private markStarted!: () => void;
readonly appendStarted = new Promise<void>((resolve) => {
this.markStarted = resolve;
});
constructor(private readonly slowRecordId: string) {
super();
}
override async appendRecord<TPayload extends RuntimePayload>(
init: RuntimeRecordInit<TPayload>,
): Promise<RuntimeRecord<TPayload>> {
if (init.id === this.slowRecordId) {
this.markStarted();
await this.appendGate;
}
return super.appendRecord(init);
}
release(): void {
this.releaseAppend();
}
}
class BlockFirstRecordListRuntimeStore extends MemoryRuntimeStore {
private recordListCount = 0;
private releaseFirstList!: () => void;
private readonly firstListGate = new Promise<void>((resolve) => {
this.releaseFirstList = resolve;
});
private markFirstListStarted!: () => void;
readonly firstListStarted = new Promise<void>((resolve) => {
this.markFirstListStarted = resolve;
});
override async listRecords(
sessionId: string,
opts?: { sceneId?: string },
): Promise<RuntimeRecord[]> {
this.recordListCount += 1;
if (this.recordListCount === 1) {
this.markFirstListStarted();
await this.firstListGate;
}
return super.listRecords(sessionId, opts);
}
release(): void {
this.releaseFirstList();
}
}
function makeProject(overrides: Partial<PBLProjectV2> = {}): PBLProjectV2 {
return {
uiPhase: 'hero',
title: 'Hydration project',
description: 'Build something',
proficiency: 'intermediate',
language: 'en-US',
tags: [],
status: 'active',
roles: [{ id: 'role-i', type: 'instructor', name: 'Instructor' }],
milestones: [
{
id: 'ms-1',
title: 'Milestone 1',
status: 'active',
order: 0,
microtasks: [
{
id: 'mt-1',
title: 'Task 1',
status: 'todo',
assignee: 'user',
hints: [],
order: 0,
},
{
id: 'mt-2',
title: 'Task 2',
status: 'todo',
assignee: 'user',
hints: [],
order: 1,
},
],
},
],
submissions: [],
evaluations: [],
threads: [{ agentId: 'role-i', messages: [] }],
engagementEvents: [],
runtimeEvents: [],
createdAt: '2026-05-29T00:00:00.000Z',
updatedAt: '2026-05-29T00:00:00.000Z',
...overrides,
};
}
function makeTwoMilestoneProject(overrides: Partial<PBLProjectV2> = {}): PBLProjectV2 {
return makeProject({
milestones: [
{
id: 'ms-1',
title: 'Milestone 1',
status: 'active',
order: 0,
microtasks: [
{
id: 'mt-1',
title: 'Task 1',
status: 'todo',
assignee: 'user',
hints: [],
order: 0,
},
],
},
{
id: 'ms-2',
title: 'Milestone 2',
status: 'locked',
order: 1,
microtasks: [
{
id: 'mt-2',
title: 'Task 2',
status: 'todo',
assignee: 'user',
hints: [],
order: 0,
},
],
},
],
...overrides,
});
}
function makePBLScene(project: PBLProjectV2): Scene {
return makeScene(
{
id: SCENE_ID,
stageId: STAGE_ID,
title: 'PBL scene',
order: 0,
},
{
type: 'pbl',
projectV2: project,
},
);
}
async function hydrate(project: PBLProjectV2, store: RuntimeStore = new MemoryRuntimeStore()) {
const kv = new MemoryKVStore();
return hydratePBLProjectFromRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project,
store,
kv,
learnerKey: LEARNER_KEY,
});
}
async function listRecords(store: RuntimeStore): Promise<RuntimeRecord[]> {
const sessions = await store.listSessions(STAGE_ID, LEARNER_KEY);
if (!sessions[0]) return [];
return store.listRecords(sessions[0].id, { sceneId: SCENE_ID });
}
async function ensureSession(store: MemoryRuntimeStore): Promise<RuntimeSession> {
const existing = store.sessions.find(
(session) => session.stageId === STAGE_ID && session.learnerKey === LEARNER_KEY,
);
if (existing) return existing;
return store.createSession({
id: `pbl-${STAGE_ID}-${LEARNER_KEY}`,
kind: 'pbl',
stageId: STAGE_ID,
learnerKey: LEARNER_KEY,
status: 'active',
createdAt: '2026-05-29T00:00:00.000Z',
updatedAt: '2026-05-29T00:00:00.000Z',
});
}
describe('PBL runtime hydration', () => {
it('skips a damaged hybrid without touching runtime or learner-state hydration', async () => {
const damagedHybrid = structuredClone(legacyPBLSceneFixture) as Scene;
if (damagedHybrid.content.type !== 'pbl') throw new Error('expected PBL scene');
Reflect.set(damagedHybrid.content, 'projectV2', { title: 'broken' });
const hydrated = await hydratePBLScenesFromRuntime(STAGE_ID, [damagedHybrid], {
store: new ThrowingRuntimeStore(),
kv: new MemoryKVStore(),
learnerKey: LEARNER_KEY,
});
expect(hydrated).toEqual([damagedHybrid]);
expect(hydrated[0]).toBe(damagedHybrid);
expect(documentContainsLearnerState({ title: 'broken' })).toBe(false);
});
it('skips a container-valid v2 project with no runnable milestones', async () => {
const project = makeProject();
project.milestones = [];
const scene = makePBLScene(project);
const hydrated = await hydratePBLScenesFromRuntime(STAGE_ID, [scene], {
store: new ThrowingRuntimeStore(),
kv: new MemoryKVStore(),
learnerKey: LEARNER_KEY,
});
expect(hydrated).toEqual([scene]);
expect(hydrated[0]).toBe(scene);
});
it('returns unchanged scenes when runtime hydration throws for a scene', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const project = makeProject({ uiPhase: 'workspace' });
const scenes = [makePBLScene(project)];
const hydrated = await hydratePBLScenesFromRuntime(STAGE_ID, scenes, {
store: new ThrowingRuntimeStore(),
kv: new MemoryKVStore(),
learnerKey: LEARNER_KEY,
});
expect(hydrated).toEqual(scenes);
expect(hydrated[0]).toBe(scenes[0]);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('does not silently replace runtime-authoritative state with a design-only document', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const scenes = [makePBLScene(stripToDesignTemplate(makeProject()))];
await expect(
hydratePBLScenesFromRuntime(STAGE_ID, scenes, {
store: new ThrowingRuntimeStore(),
kv: new MemoryKVStore(),
learnerKey: LEARNER_KEY,
}),
).rejects.toThrow('runtime unavailable');
expect(warn).not.toHaveBeenCalledWith(
expect.stringContaining('using document state'),
expect.anything(),
);
warn.mockRestore();
});
it('keeps document state and appends a snapshot for a pre-runtime learner', async () => {
const store = new MemoryRuntimeStore();
const project = makeProject({ uiPhase: 'workspace' });
startMicrotask(project, 'mt-1');
project.threads[0]!.messages.push({
id: 'legacy-msg-1',
roleType: 'user',
content: 'Legacy answer',
ts: '2026-05-29T00:00:01.000Z',
microtaskId: 'mt-1',
});
const hydrated = await hydrate(project, store);
expect(hydrated.source).toBe('document');
expect(extractLearnerState(hydrated.project)).toEqual(extractLearnerState(project));
const records = await listRecords(store);
expect((records.at(-1)?.payload as PBLRuntimeStorePayload).kind).toBe('pbl_snapshot');
});
it('synchronizes the complete learner state before document persistence', async () => {
const store = new MemoryRuntimeStore();
const kv = new MemoryKVStore();
const project = makeProject({ uiPhase: 'workspace', runtimeEvents: [] });
project.threads[0]!.messages.push({
id: 'legacy-msg-1',
roleType: 'user',
content: 'State that must survive the write cutover',
ts: '2026-05-29T00:00:01.000Z',
microtaskId: 'mt-1',
});
await synchronizePBLProjectRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project,
store,
kv,
learnerKey: LEARNER_KEY,
});
const records = await listRecords(store);
const folded = foldPBLRuntime({ designTemplate: stripToDesignTemplate(project), records });
expect(folded.diagnostics.gaps).toEqual([]);
expect(folded.learnerState).toEqual(extractLearnerState(project));
expect(
records.filter(
(record) => (record.payload as PBLRuntimeStorePayload).kind === 'pbl_snapshot',
),
).toHaveLength(1);
});
it('serializes the full synchronization so an overlapping stale save cannot win last', async () => {
const store = new BlockFirstRecordListRuntimeStore();
const kv = new MemoryKVStore();
await ensureSession(store);
const staleProject = makeProject({ uiPhase: 'workspace' });
const latestProject = makeProject({ uiPhase: 'completed', status: 'completed' });
const staleSave = synchronizePBLProjectRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project: staleProject,
store,
kv,
learnerKey: LEARNER_KEY,
});
await store.firstListStarted;
const latestSave = synchronizePBLProjectRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project: latestProject,
store,
kv,
learnerKey: LEARNER_KEY,
});
await Promise.resolve();
store.release();
await Promise.all([staleSave, latestSave]);
const records = await listRecords(store);
const folded = foldPBLRuntime({
designTemplate: stripToDesignTemplate(latestProject),
records,
});
expect(folded.learnerState).toEqual(extractLearnerState(latestProject));
});
it('keeps RuntimeStore authoritative when the first stripped document write is interrupted', async () => {
const store = new MemoryRuntimeStore();
const kv = new MemoryKVStore();
const staleDocument = makeProject({ uiPhase: 'workspace' });
const latestProject = makeProject({ uiPhase: 'completed', status: 'completed' });
await synchronizePBLProjectRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project: latestProject,
store,
kv,
learnerKey: LEARNER_KEY,
});
const hydrated = await hydratePBLProjectFromRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project: staleDocument,
store,
kv,
learnerKey: LEARNER_KEY,
});
expect(hydrated.source).toBe('fold');
expect(extractLearnerState(hydrated.project)).toEqual(extractLearnerState(latestProject));
expect(
(await listRecords(store)).some(
(record) =>
(record.payload as Partial<PBLRuntimeStorePayload>).kind === 'pbl_snapshot' &&
(record.payload as { reason?: string }).reason === 'write_cutover',
),
).toBe(true);
});
it('hydrates a fresh learner from the folded baseline without writing a snapshot', async () => {
const store = new MemoryRuntimeStore();
const project = makeProject();
const hydrated = await hydrate(project, store);
expect(hydrated.source).toBe('fold');
expect(extractLearnerState(hydrated.project)).toEqual(extractLearnerState(project));
expect(await listRecords(store)).toEqual([]);
});
it('uses folded runtime history when it matches the document state', async () => {
const store = new MemoryRuntimeStore();
const kv = new MemoryKVStore();
let project = transitionProjectUiPhase(makeProject(), 'workspace');
project = applyInstructorEvent(
{
type: 'project_patch',
patch: {
kind: 'message',
message: {
id: 'msg-1',
roleType: 'user',
content: 'Runtime-backed answer',
ts: '2026-05-29T00:00:01.000Z',
microtaskId: 'mt-1',
},
},
},
project,
() => {},
);
await drainProjectRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project,
store,
kv,
learnerKey: LEARNER_KEY,
});
const hydrated = await hydratePBLProjectFromRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project,
store,
kv,
learnerKey: LEARNER_KEY,
});
expect(hydrated.source).toBe('fold');
expect(extractLearnerState(hydrated.project)).toEqual(extractLearnerState(project));
expect(
(await listRecords(store)).filter(
(record) => (record.payload as PBLRuntimeStorePayload).kind === 'pbl_snapshot',
),
).toHaveLength(0);
});
it('uses runtime history when the persisted document contains only the design template', async () => {
const store = new MemoryRuntimeStore();
const kv = new MemoryKVStore();
const runtimeProject = transitionProjectUiPhase(makeProject(), 'workspace');
await drainProjectRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project: runtimeProject,
store,
kv,
learnerKey: LEARNER_KEY,
});
const designDocument = stripToDesignTemplate(runtimeProject);
const hydrated = await hydratePBLProjectFromRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project: designDocument,
store,
kv,
learnerKey: LEARNER_KEY,
});
expect(hydrated.source).toBe('fold');
expect(extractLearnerState(hydrated.project)).toEqual(extractLearnerState(runtimeProject));
expect(
(await listRecords(store)).filter(
(record) => (record.payload as PBLRuntimeStorePayload).kind === 'pbl_snapshot',
),
).toHaveLength(0);
});
it('waits for the actual drain before reading records or writing a snapshot', async () => {
vi.useFakeTimers();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const project = transitionProjectUiPhase(makeProject(), 'workspace');
const eventId = project.runtimeEvents![0]!.id;
const store = new SlowFirstAppendRuntimeStore(eventId);
try {
const hydrating = hydrate(project, store);
await store.appendStarted;
await vi.advanceTimersByTimeAsync(10_001);
await Promise.resolve();
const recordsAfterCallerTimeout = await listRecords(store);
store.release();
const hydrated = await hydrating;
expect(
recordsAfterCallerTimeout.some(
(record) => (record.payload as Partial<PBLRuntimeStorePayload>).kind === 'pbl_snapshot',
),
).toBe(false);
expect(hydrated.source).toBe('fold');
expect((await listRecords(store)).map((record) => record.id)).toEqual([eventId]);
} finally {
store.release();
warn.mockRestore();
vi.useRealTimers();
}
});
it('enrolls hydration before later maintenance while waiting for an earlier drain', async () => {
let sharedAcquisitions = 0;
vi.stubGlobal('navigator', {
locks: readWriteLockManager((mode) => {
if (mode === 'shared') sharedAcquisitions += 1;
}),
});
const project = transitionProjectUiPhase(makeProject(), 'workspace');
const eventId = project.runtimeEvents![0]!.id;
const store = new SlowFirstAppendRuntimeStore(eventId);
const kv = new MemoryKVStore();
const priorDrain = drainProjectRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project,
store,
kv,
learnerKey: LEARNER_KEY,
});
await store.appendStarted;
const hydrating = hydratePBLProjectFromRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project,
store,
kv,
learnerKey: LEARNER_KEY,
});
await new Promise((resolve) => setTimeout(resolve, 0));
const sharedAcquisitionsBeforeRelease = sharedAcquisitions;
const maintenance = withRuntimeStorageExclusiveLock(async () => {
store.records.splice(0);
store.sessions.splice(0);
});
store.release();
await Promise.all([priorDrain, hydrating, maintenance]);
expect(sharedAcquisitionsBeforeRelease).toBe(2);
expect(store.records).toEqual([]);
expect(store.sessions).toEqual([]);
});
it('falls back to the document without a snapshot when the drain barrier times out', async () => {
vi.useFakeTimers();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const project = transitionProjectUiPhase(makeProject(), 'workspace');
const eventId = project.runtimeEvents![0]!.id;
const store = new SlowFirstAppendRuntimeStore(eventId);
const scenes = [makePBLScene(project)];
const kv = new MemoryKVStore();
try {
const priorDrain = drainProjectRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project,
store,
kv,
learnerKey: LEARNER_KEY,
});
await store.appendStarted;
let settled = false;
const hydrating = hydratePBLScenesFromRuntime(STAGE_ID, scenes, {
store,
kv,
learnerKey: LEARNER_KEY,
}).then((result) => {
settled = true;
return result;
});
await vi.advanceTimersByTimeAsync(20_001);
await Promise.resolve();
const settledAtBarrierTimeout = settled;
const recordsAtBarrierTimeout = await listRecords(store);
store.release();
await priorDrain;
const hydrated = await hydrating;
expect(settledAtBarrierTimeout).toBe(true);
expect(hydrated).toEqual(scenes);
expect(
recordsAtBarrierTimeout.some(
(record) => (record.payload as Partial<PBLRuntimeStorePayload>).kind === 'pbl_snapshot',
),
).toBe(false);
} finally {
store.release();
warn.mockRestore();
vi.useRealTimers();
}
});
it('retains the shared maintenance lock after a timed-out hydration until work settles', async () => {
vi.useFakeTimers();
vi.stubGlobal('navigator', { locks: readWriteLockManager() });
const project = transitionProjectUiPhase(makeProject(), 'workspace');
const eventId = project.runtimeEvents![0]!.id;
const store = new SlowFirstAppendRuntimeStore(eventId);
try {
const hydrating = hydrate(project, store);
let hydrationSettled = false;
const hydrationOutcome = hydrating.then(
() => {
hydrationSettled = true;
},
() => {
hydrationSettled = true;
},
);
await store.appendStarted;
await vi.advanceTimersByTimeAsync(20_001);
await Promise.resolve();
expect(hydrationSettled).toBe(true);
let maintenanceStarted = false;
const maintenance = withRuntimeStorageExclusiveLock(async () => {
maintenanceStarted = true;
});
await Promise.resolve();
expect(maintenanceStarted).toBe(false);
store.release();
await Promise.all([hydrationOutcome, maintenance]);
expect(maintenanceStarted).toBe(true);
} finally {
store.release();
vi.useRealTimers();
}
});
it('keeps document state and self-heals when runtime history is partial', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const store = new MemoryRuntimeStore();
const kv = new MemoryKVStore();
const project = makeProject();
startMicrotask(project, 'mt-1');
addSubmission(project, {
microtaskId: 'mt-1',
milestoneId: 'ms-1',
kind: 'text',
content: 'Answer missing from runtime history',
});
project.runtimeEvents = project.runtimeEvents?.slice(0, 1) ?? [];
const hydrated = await hydratePBLProjectFromRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project,
store,
kv,
learnerKey: LEARNER_KEY,
});
expect(hydrated.source).toBe('document');
expect(extractLearnerState(hydrated.project)).toEqual(extractLearnerState(project));
expect(
(await listRecords(store)).some(
(record) => (record.payload as PBLRuntimeStorePayload).kind === 'pbl_snapshot',
),
).toBe(true);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('flips to fold after a legacy raw-record mismatch has a self-heal snapshot', async () => {
const store = new MemoryRuntimeStore();
const kv = new MemoryKVStore();
const project = makeProject();
project.threads[0]!.messages.push({
id: 'legacy-msg-1',
roleType: 'user',
content: 'Legacy raw record content recovered by snapshot',
ts: '2026-05-29T00:00:01.000Z',
microtaskId: 'mt-1',
});
const session = await ensureSession(store);
const rawEvent: PBLRuntimeEvent = {
id: 'legacy-raw-message',
kind: 'message_created',
actorType: 'user',
messageId: 'legacy-msg-1',
threadId: 'role-i',
ts: '2026-05-29T00:00:01.000Z',
microtaskId: 'mt-1',
milestoneId: 'ms-1',
};
await store.appendRecord({
id: rawEvent.id,
sessionId: session.id,
sceneId: SCENE_ID,
createdAt: rawEvent.ts,
payload: rawEvent as unknown as RuntimePayload,
});
await store.appendRecord({
id: 'self-heal-snapshot',
sessionId: session.id,
sceneId: SCENE_ID,
createdAt: '2026-05-29T00:00:02.000Z',
payload: pblSnapshotRecordPayload({
epoch: 0,
learnerState: extractLearnerState(project),
anchor: { lastRuntimeEventId: rawEvent.id },
reason: 'self_heal',
}),
});
const hydrated = await hydratePBLProjectFromRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project,
store,
kv,
learnerKey: LEARNER_KEY,
});
expect(hydrated.source).toBe('fold');
expect(hydrated.diagnostics.gaps).toEqual([]);
expect(
(await listRecords(store)).filter(
(record) => (record.payload as PBLRuntimeStorePayload).kind === 'pbl_snapshot',
),
).toHaveLength(1);
});
it('skips a duplicate self-heal snapshot when the latest snapshot already matches', async () => {
const store = new MemoryRuntimeStore();
const project = makeProject({ uiPhase: 'workspace' });
const session = await ensureSession(store);
const learnerState = extractLearnerState(project);
const firstAppend = await appendPBLRuntimeSnapshotIfChanged({
store,
stageId: STAGE_ID,
sceneId: SCENE_ID,
learnerKey: LEARNER_KEY,
project,
learnerState,
records: [],
reason: 'self_heal',
});
const snapshotRecord = await store.listRecords(session.id, { sceneId: SCENE_ID });
const snapshotPayload = snapshotRecord[0]!.payload as Extract<
PBLRuntimeStorePayload,
{ kind: 'pbl_snapshot' }
>;
snapshotPayload.learnerState = {
status: learnerState.status,
uiPhase: learnerState.uiPhase,
milestones: learnerState.milestones,
submissions: learnerState.submissions,
evaluations: learnerState.evaluations,
threads: learnerState.threads,
engagementEvents: learnerState.engagementEvents,
};
const secondAppend = await appendPBLRuntimeSnapshotIfChanged({
store,
stageId: STAGE_ID,
sceneId: SCENE_ID,
learnerKey: LEARNER_KEY,
project,
learnerState,
records: snapshotRecord,
reason: 'self_heal',
});
expect(firstAppend).toBe(true);
expect(secondAppend).toBe(false);
expect(
(await listRecords(store)).filter(
(record) => (record.payload as PBLRuntimeStorePayload).kind === 'pbl_snapshot',
),
).toHaveLength(1);
});
it('skips a duplicate snapshot when optional learner-state members were omitted', async () => {
const store = new MemoryRuntimeStore();
const project = makeProject({ uiPhase: 'workspace' });
project.submissions.push({
id: 'sub-undefined-filename',
microtaskId: 'mt-1',
kind: 'text',
content: 'Learner answer',
filename: undefined,
createdAt: '2026-05-29T00:01:00.000Z',
});
const learnerState = extractLearnerState(project);
const firstAppend = await appendPBLRuntimeSnapshotIfChanged({
store,
stageId: STAGE_ID,
sceneId: SCENE_ID,
learnerKey: LEARNER_KEY,
project,
learnerState,
records: [],
reason: 'self_heal',
});
const session = await ensureSession(store);
const records = await store.listRecords(session.id, { sceneId: SCENE_ID });
const secondAppend = await appendPBLRuntimeSnapshotIfChanged({
store,
stageId: STAGE_ID,
sceneId: SCENE_ID,
learnerKey: LEARNER_KEY,
project,
learnerState,
records,
reason: 'self_heal',
});
expect(firstAppend).toBe(true);
expect(secondAppend).toBe(false);
expect(records[0]!.payload).not.toHaveProperty('learnerState.submissions.0.filename');
expect(project.submissions[0]).toHaveProperty('filename', undefined);
});
it('preserves document-only transient fields on a fold-match hydration', async () => {
const store = new MemoryRuntimeStore();
const kv = new MemoryKVStore();
const project = makeProject({
pendingOpenTaskPriorQuizResults: [
{
sceneId: 'quiz-1',
sceneTitle: 'Readiness quiz',
totalQuestions: 2,
correctCount: 1,
incorrectCount: 1,
unscoredCount: 0,
accuracy: 0.5,
},
],
});
const hydrated = await hydratePBLProjectFromRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project,
store,
kv,
learnerKey: LEARNER_KEY,
});
expect(hydrated.source).toBe('fold');
expect(hydrated.project.pendingOpenTaskPriorQuizResults).toEqual(
project.pendingOpenTaskPriorQuizResults,
);
});
it('self-heals when a bounded document outbox cannot cover an older runtime gap', async () => {
const store = new MemoryRuntimeStore();
const project = makeProject({ uiPhase: 'workspace', runtimeEvents: [] });
project.threads[0]!.messages.push({
id: 'evicted-msg',
roleType: 'user',
content: 'This message event was evicted from the document outbox',
ts: '2026-05-29T00:00:01.000Z',
microtaskId: 'mt-1',
});
const hydrated = await hydrate(project, store);
expect(hydrated.source).toBe('document');
expect(
(await listRecords(store)).some(
(record) => (record.payload as PBLRuntimeStorePayload).kind === 'pbl_snapshot',
),
).toBe(true);
});
it('folds duplicate records from overlapping drains exactly once', async () => {
const store = new MemoryRuntimeStore();
const kv = new MemoryKVStore();
let project = transitionProjectUiPhase(makeProject(), 'workspace');
project = applyInstructorEvent(
{
type: 'project_patch',
patch: {
kind: 'message',
message: {
id: 'msg-1',
roleType: 'user',
content: 'Only once',
ts: '2026-05-29T00:00:01.000Z',
microtaskId: 'mt-1',
},
},
},
project,
() => {},
);
await Promise.all([
drainProjectRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project,
store,
kv,
learnerKey: LEARNER_KEY,
}),
drainProjectRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project,
store,
kv,
learnerKey: LEARNER_KEY,
}),
]);
const records = await listRecords(store);
const folded = foldPBLRuntime({ designTemplate: stripToDesignTemplate(project), records });
expect(folded.learnerState.threads[0]?.messages).toHaveLength(1);
expect(folded.learnerState).toEqual(extractLearnerState(project));
});
it('folds staged and consumed handover events drained in one batch', async () => {
const store = new MemoryRuntimeStore();
const kv = new MemoryKVStore();
const project = makeTwoMilestoneProject();
startMicrotask(project, 'mt-1');
const advanced = advanceMicrotask(project, 'mt-1', 'stage complete', {
performance: 'ready for the next milestone',
});
expect(advanced.ok).toBe(true);
expect(project.pendingHandover?.consumed).toBe(false);
const consumed = continueAfterHandover(project);
expect(consumed.ok).toBe(true);
expect(project.pendingHandover?.consumed).toBe(true);
await drainProjectRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project,
store,
kv,
learnerKey: LEARNER_KEY,
});
const records = await listRecords(store);
const folded = foldPBLRuntime({ designTemplate: stripToDesignTemplate(project), records });
expect(folded.diagnostics.gaps).toEqual([]);
expect(folded.learnerState).toEqual(extractLearnerState(project));
});
it('keeps microtask engagement and assessment attached to the completing status event', async () => {
const store = new MemoryRuntimeStore();
const kv = new MemoryKVStore();
const project = makeProject();
startMicrotask(project, 'mt-1');
recordEvent(project, 'learner_turn', {
microtaskId: 'mt-1',
milestoneId: 'ms-1',
payload: { chars: 24 },
});
const advanced = advanceMicrotask(project, 'mt-1', 'learner completed task', {
performance: 'specific evidence',
});
expect(advanced.ok).toBe(true);
await drainProjectRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project,
store,
kv,
learnerKey: LEARNER_KEY,
});
const records = await listRecords(store);
const completedStatus = records
.map((record) => record.payload as PBLRuntimeStorePayload)
.find(
(payload) =>
payload.kind === 'pbl_runtime_event' &&
payload.event.kind === 'status_changed' &&
payload.event.entityType === 'microtask' &&
payload.event.entityId === 'mt-1' &&
payload.event.to === 'completed',
);
const folded = foldPBLRuntime({ designTemplate: stripToDesignTemplate(project), records });
expect(completedStatus).toMatchObject({
kind: 'pbl_runtime_event',
attachment: {
kind: 'status',
microtask: {
completionReason: 'learner completed task',
internalAssessment: { performance: 'specific evidence' },
engagement: { learnerTurnCount: 1 },
},
},
});
expect(folded.diagnostics.gaps).toEqual([]);
expect(folded.learnerState).toEqual(extractLearnerState(project));
});
it('property-checks randomized reducer sequences against BrowserRuntimeStore folds', async () => {
const store = new BrowserRuntimeStore({ indexedDB: new IDBFactory() });
const kv = new MemoryKVStore();
let project = makeProject({ uiPhase: 'workspace' });
let seed = 869;
let messageIndex = 0;
const nextRandom = () => {
seed = (seed * 1103515245 + 12345) % 0x80000000;
return seed / 0x80000000;
};
for (let step = 0; step < 24; step++) {
const active = project.milestones
.find((milestone) => milestone.status === 'active')
?.microtasks.find((task) => task.status === 'todo' || task.status === 'in_progress');
const roll = nextRandom();
if (!active || project.status === 'completed') {
project = resetProjectProgress(project);
} else if (roll < 0.2) {
startMicrotask(project, active.id);
} else if (roll < 0.4) {
messageIndex += 1;
project = applyInstructorEvent(
{
type: 'project_patch',
patch: {
kind: 'message',
message: {
id: `msg-${messageIndex}`,
roleType: messageIndex % 2 === 0 ? 'instructor' : 'user',
agentId: messageIndex % 2 === 0 ? 'role-i' : undefined,
content: `Message ${messageIndex}`,
ts: `2026-05-29T00:${String(step).padStart(2, '0')}:00.000Z`,
microtaskId: active.id,
},
},
},
project,
() => {},
);
} else if (roll < 0.55) {
addSubmission(project, {
microtaskId: active.id,
milestoneId: 'ms-1',
kind: 'text',
content: `Submission ${step}`,
});
} else if (roll < 0.68) {
setPendingTaskCompletion(project, {
microtaskId: active.id,
milestoneId: 'ms-1',
reason: `ready ${step}`,
assessment: { performance: 'ready' },
});
} else if (roll < 0.78) {
clearPendingTaskCompletion(project, active.id);
} else if (roll < 0.9) {
const result = advanceMicrotask(project, active.id, `completed ${step}`, {
performance: `performance ${step}`,
});
expect(result.ok).toBe(true);
} else {
addEvaluation(project, {
kind: 'task',
microtaskId: active.id,
milestoneId: 'ms-1',
feedback: `Feedback ${step}`,
score: 80,
});
}
if (step % 7 === 0) {
project = transitionProjectUiPhase(
project,
project.uiPhase === 'workspace' ? 'hero' : 'workspace',
);
}
await drainProjectRuntime({
stageId: STAGE_ID,
sceneId: SCENE_ID,
project,
store,
kv,
learnerKey: LEARNER_KEY,
});
const records = await listRecords(store);
const folded = foldPBLRuntime({ designTemplate: stripToDesignTemplate(project), records });
expect(folded.diagnostics.gaps).toEqual([]);
expect(folded.learnerState).toEqual(extractLearnerState(project));
}
});
});