Files
OpenMAIC/lib/api/stage-api.ts
T
be3d313bc3 feat(document): dirty-set incremental saves + operation-level flush (#983)
* feat(document): dirty-set incremental saves + operation-level flush + unload protection

Every store mutation used to trigger a debounced FULL aggregate rewrite —
editing one word rewrote the whole document, scene switches during playback
rewrote it for nothing, and concurrent editors clobbered each other at
document granularity. Mutations now mark precise dirt (scene:<id> /
structure / stage / outline / currentScene / chats): scene edits flush as
version-gated putScene, stage metadata as putStage, structure and outline
changes keep the full-save path, and currentScene/chats no longer touch
the document at all. Dirt entries are revisioned so a mutation landing
during a write is never accidentally cleared, and failed writes retain
their dirt for retry. flushStageSave() drains the debounce single-flight;
agent apply operations await it so each AI edit is durable before its tool
result returns; hidden-page/unload kick a best-effort immediate flush.
The PendingChange type stays neutral about the change unit so a future
operation-log backend can replace the flush internals at one seam.

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

* fix(document): incremental-persistence review round — bypass-proofing, true drain, no stranded dirt

Structural mutations carry the cursor they changed (and cursor resolution
validates membership on load, so a stale device cursor can never resurrect
a deleted scene). The Stage API now wraps the real store in a guard that
diffs pre/post state and centrally classifies every raw write — whiteboard,
element, canvas, and action-engine mutations can no longer bypass dirty
tracking, and new API namespaces inherit the guard by construction (with a
lint-like inventory test). flushStageSave is a bounded true drain: an
awaiter whose mutation landed mid-flight loops until its own dirt is
durable. setStage hands the old document's dirt to a departing flush
instead of discarding it, and a failed flush always reschedules. Mixed
dirty batches (scene+stage/outline) use the atomic full save — the
incremental fast path is homogeneous-only. Chat-half failures keep the
chats dirt for retry without changing the tolerated-split outer semantics.

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

* fix(document): reviewer round — drain covers caller dirt, full-path chat retry, stamping unification

flushStageSave's early-return now only fires when the failing round's
snapshot actually covered the caller's dirt — an agent edit landing beside
an in-flight failing round is written before the promise resolves, keeping
the tool-result durability guarantee unconditional. The full-save path
reports chat failures the same way the incremental path does (chats dirt
survives and reschedules; the tolerated-split semantics are unchanged).
The departing-stage flush retries once and its doc-comment states the
best-effort semantics honestly. Stage and scene stamping are extracted and
shared — fixing the incremental putScene branch silently dropping the
order ?? index normalization the full path applies — and the dead outline
mark in setGenerationComplete is gone.

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

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: 杨慎 <117187635+cosarah@users.noreply.github.com>
2026-07-24 00:00:14 -04:00

140 lines
4.4 KiB
TypeScript

/**
* Stage API - AI Agent Toolkit
*
* Provides a complete Stage operation interface for AI Agents to create and manage course content
*
* Design Principles:
* 1. Type Safety: Fully leverage TypeScript's type system
* 2. Ease of Use: Provide high-level abstractions with clear, intuitive API naming
* 3. Extensibility: Support adding new scene types in the future
* 4. Idempotency: Multiple calls with the same parameters produce the same result
* 5. Error Handling: Return explicit success/failure status and error messages
*
* @example
* ```typescript
* const api = createStageAPI(stageStore);
*
* // Create a new scene
* const sceneId = api.scene.create({
* type: 'slide',
* title: 'Introduction',
* // speech is now in actions
* });
*
* // Add an element
* const elementId = api.element.add(sceneId, {
* type: 'text',
* content: 'Hello World',
* left: 100,
* top: 100
* });
*
* // Highlight an element (teaching feature)
* api.canvas.highlight(sceneId, elementId, 3000);
* ```
*/
// Re-export all types
export type {
APIResult,
CreateSceneParams,
CreateElementParams,
HighlightOptions,
SpotlightOptions,
StageStore,
} from './stage-api-types';
// Re-export utility functions that were previously accessible
export {
generateId,
validateSceneId,
getScene,
createDefaultContent,
createDefaultSlideContent,
createDefaultQuizContent,
createDefaultInteractiveContent,
createDefaultPBLContent,
} from './stage-api-defaults';
// Import sub-API factories
import { createSceneAPI } from './stage-api-scene';
import { createElementAPI } from './stage-api-element';
import { createCanvasAPI } from './stage-api-canvas';
import { createNavigationAPI } from './stage-api-navigation';
import { createWhiteboardAPI } from './stage-api-whiteboard';
import { createModeAPI, createStageMetaAPI } from './stage-api-mode';
import type { StageStore } from './stage-api-types';
import { markStagePersistenceDirty, useStageStore } from '@/lib/store/stage';
import type { PendingChange } from '@/lib/utils/stage-storage';
function persistenceChangesForSetState(
before: ReturnType<StageStore['getState']>,
after: ReturnType<StageStore['getState']>,
): PendingChange[] {
const changes: PendingChange[] = [];
if (before.stage !== after.stage) changes.push({ kind: 'stage' });
if (before.currentSceneId !== after.currentSceneId) changes.push({ kind: 'currentScene' });
if (before.scenes !== after.scenes) {
const beforeStructure = before.scenes.map(({ id, order }) => [id, order] as const);
const afterStructure = after.scenes.map(({ id, order }) => [id, order] as const);
const structureChanged =
beforeStructure.length !== afterStructure.length ||
beforeStructure.some(
([id, order], index) =>
afterStructure[index]?.[0] !== id || afterStructure[index]?.[1] !== order,
);
if (structureChanged) {
changes.push({ kind: 'structure' });
} else {
before.scenes.forEach((scene, index) => {
if (scene !== after.scenes[index]) changes.push({ kind: 'scene', sceneId: scene.id });
});
}
}
return changes;
}
function withProductionPersistence(store: StageStore): StageStore {
if (store !== useStageStore) return store;
return {
...store,
setState(partial) {
const before = store.getState();
store.setState(partial);
const changes = persistenceChangesForSetState(before, store.getState());
if (changes.length > 0) markStagePersistenceDirty(changes);
},
};
}
// ==================== Stage API Implementation ====================
/**
* Create a Stage API instance
*
* @param store - Zustand store instance
* @returns Stage API object
*/
export function createStageAPI(store: StageStore) {
// All namespaces receive the same guarded injection boundary. New API
// modules cannot bypass persistence by adding another raw setState call.
const persistenceStore = withProductionPersistence(store);
return {
scene: createSceneAPI(persistenceStore),
navigation: createNavigationAPI(persistenceStore),
element: createElementAPI(persistenceStore),
canvas: createCanvasAPI(persistenceStore),
whiteboard: createWhiteboardAPI(persistenceStore),
mode: createModeAPI(persistenceStore),
stage: createStageMetaAPI(persistenceStore),
};
}
// ==================== Type Exports ====================
export type StageAPI = ReturnType<typeof createStageAPI>;