# Engine

The editing session of the desktop app: the project in memory, one write queue, incremental validation, other writers, batches, media jobs and the message protocol. Part of the [architecture](../architecture.md), the contract every change follows.

`@elabora/engine` keeps one project in memory for the desktop app: `openProject(dir, options)` returns a `Session` holding the files snapshot (what the journal knows of the folder), the project loaded from it, a validation context, the findings per screen and of the project checks, the journal's entries and the open AI batch. Files on disk stay the source of truth: agents keep editing them through the `elabora` command, the MCP server or their own file tools. The session owns the write queue and project state; `session-canvas.ts` owns the canvas host and render cache, and `session-voice.ts` owns voice, alignment and transcription jobs; `session-monitor.ts` owns watcher observation and retry scheduling. Journal transactions and reconciliation stay in the session. Their writes go through the session's queue.

## Writes and reads
- **One write queue.** Commands (`run`, the journaled names of the CLI and MCP: `add_block`, `update_screen`…), `undo`, `undoBatch`, `mark`, restores, migrations, builds, what other writers did and media results run one at a time. Reads (`summary`, `readFile`, `listFiles`, `readScreen`, `screenParts`, `findText`, `conversions`, `translationStatus`, `translationTable`, `screenTranslation`, `diagnostics`, `history`, `renderScreen`) answer from memory and never wait for writes.
- **A write**: a human or system write closes the open AI batch first (its `after-ai` version); an `auto` version is taken when the newest is older than 5 minutes; the project lock is taken asynchronously (waiting for a CLI never blocks the event loop); the journal's new entries and the files the watcher reported are taken in; the edit of `@elabora/node` runs with the session's snapshot (`files`, `validate: false`, `autoVersion: false`); what it wrote (`written`) updates the snapshot without reading the folder; the session reads its own journal entry back, releases the lock, pushes the canvas, validates, and emits `changed` then `diagnostics`.
- An edit that finds a file it touches changed on disk unseen asks the session for its snapshot again, with the stale paths: the session journals the difference as `external` first, and the edit is computed again on it.
- **Publishing** (`build`: `mediaQuality`, `release`, `profiles`, `name`, all optional): the package as `elabora build` makes it (`<project>/dist`), in the write queue. The open AI batch closes, and what other writers left is journaled as theirs; the build then runs outside the project lock (encoding renditions can take minutes, and the CLI waits for the lock 40 s at most), with the session's validation options by default; renditions it encodes are reported as `mediaJob` events. The state keys it adds to `elabora.lock.json` are journaled under the lock as one `system` change (command `build`), so the watcher never reports them as an agent's `external` edit; a successful build takes a `publish` version, named after the day in the course language (« Publié le 7 octobre 2026 », "Published on 7 October 2026") unless `name` is given. The result: `ok`, `zip`, `size`, `report`, `summary`, `errors` (what stopped it, waived findings left out), `warnings` (what did not: a package too large for some LMSs while none is chosen…), `change`, `version`. Media jobs still running stop the build (`E_ASSET_MISSING`): the app waits for their `mediaJob` events. The queue waits for the build (1 to 1.5 s on an 87-screen course, longer the first time a quality's renditions are encoded).
- **A language's package** (`buildTranslation`: `language`, `allowMissing`, `mediaQuality`, `release`, `profiles`): the translated course generated as `elabora translate build` writes it, from the files on disk, into `<data>/translations/<lang>` (the project's data folder: never copied, never watched), then built as `build` does into `<project>/dist` (`<course>-<lang>-<version>-scorm12.zip`), in the write queue. Nothing of the source is written or journaled: the copy is generated again every time. Texts to translate, to review or stale, and word anchors whose marker the translation has not placed (D28), stop it (`refused`) unless `allowMissing` (a draft: texts stay in the source language, anchors go to their line's start); translations that break the text always do. The result: `ok`, `zip`, `size`, `report`, `units`, `untranslated`, `unplaced`, `plan` (the translated plan in the package: `approved`, or `draft` until a person approves it: command `approve_translation`), `summary`, `errors`, `warnings` (as `build`'s: the package's findings for its LMS among them).

## Incremental validation
Every check of `@elabora/core/validate` is declared, in the order of the report, with its scope (`FILE_CHECKS` run on any project, `COURSE_CHECKS` once course.json loads):
- a **screen** check depends on one screen and on what screens share and rarely changes (course.json, plan, components, objects, theme, variables). It runs on the screens course.json lists, or with `screens: 'all'` on every screen file, listed or not (schema, declared id, backdrop variant). `readsOtherScreens` marks the few that also read blocks of other screens (events: `visited` conditions; figures and decoration objects: the first block of a duplicate id);
- a **project** check compares screens with each other or with the plan (ids, coverage, usage, exposition streak, suspend data budget), or reads what no screen holds (object code, component definitions, theme, generators, media). `readsScreens` marks those an edited screen can change;
- `createValidationContext(project, options)` builds what checks share once (registry, plan index, listed screens, blocks by id); `validateScreen(context, screenId)` gives the findings of one screen, `validateProjectChecks(context)` those of the project checks, and together they give the findings of `validateProject`;
- loading, validating and compiling never throw on a project that parses but breaks its schema (a test runs seeded mutations through every reader): the validator reports it.

The session (`project-state.ts`) keeps the findings per screen and per project check and re-runs what a change can affect:
- **a screen file**: that screen's checks; the screens that mention an id whose element (block, layer, event, the screen itself) changed in it, as a JSON string in their file; the project checks marked `readsScreens` (the others keep their findings: object code and generators are not parsed again). When only screen files changed, the loaded project is the previous one with those screens replaced: the components, so the registry and the analyses of templates, stay the same objects;
- **an asset**, or a change of the media status (a job under way, an unreadable file): the screens that name it and every project check;
- **any other file**: everything. A test checks, edit after edit on two sample courses, that the findings equal those of a full load and validation.

## Other writers
- **The journal first.** New entries (the CLI, the MCP server, in other processes) are applied to the snapshot and never journaled again: each touched file is read back when its hash is the one the journal recorded after the change; a file written again since gets the journal's version (the operations applied), and the difference is journaled as an external change.
- **External edits.** The folder is watched (recursively) and listed every 15 s for what a watcher missed. `openProject` resolves once the folder's watcher reports (`onReady`; 3 s at most), and its first listing comes after: a file written once the project is open is reported at once. On macOS the watchers of a thread share one FSEvents stream, built again whenever one of them starts or stops, and what is written meanwhile is never reported, for every watcher of the thread (measured: writes from about 16 ms before to 10 ms after). So `FsStore` watches each folder from a worker thread of its own: a preview started or stopped, or another project opened or closed, in the same process takes nothing from an open project (`packages/engine/bench/watch-rebuild.ts`, an agent writing a file every millisecond: 467 writes of 5786 lost over 10 previews started and stopped before, none since). The journal's watcher stays in the engine's thread; it is a shortcut: a change another Elabora tool journals also writes files, which the folder's watcher reports, and the journal is read before them. Reported paths wait for a quiet moment (60 ms, at most 600 ms during a burst: an agent writing 50 files makes one change); then, under the project lock, the files whose content differs from the snapshot are diffed (`diffFiles`) and journaled as one `external` change. A binary file modified less than 400 ms ago is still being written: the burst waits for it, up to 2 s, so that a video copied with the screen that shows it is one change.
- **The session's own writes** come back from the watcher; they are recognised by content, not by time: a reported file that holds what the snapshot holds is settled without the lock.
- **A JSON file that does not parse** (an agent mid-write) keeps its last valid content in the snapshot and the loaded project, with `E_JSON_PARSE` in its screen's findings (or the project's); nothing is journaled until it parses, and it is then diffed against its last valid content. A new file waits outside the snapshot until it parses (its creation is one change). A file broken since the session opened has no valid content to keep: its new text is taken without a journal entry.
- Conflict copies of sync services are set apart (`W_SYNC_CONFLICT_COPY`); tool folders and temporary files are ignored. A project folder moved or deleted while open is reported (`error` event) and nothing is written there any more.

## Batches and versions
- An AI batch is open while AI changes (`agent`, `external`) come in. It closes after 5 minutes without one, at a human or system change, at a mark, or when the session closes: the session takes an `after-ai` version and emits `batchClosed`. `mark(request)` (« Demander à l'IA ») closes the batch, writes the mark that labels the next one, and takes a `before-ai` version (the newest version when nothing changed since).
- Elabora's chat: `mark(request, author, chat)` opens a turn (the mark's id is the turn's batch; a mark another process appends with its chat opens one too). A turn's batch stays open through human changes, idle time and another AI's work; `endTurn({ turn })` closes it (`batchClosed` with reason `turn`, an `after-ai` version) and returns `{ batch, changes }` (`batch` null when the agent wrote nothing). The next mark of the conversation or the session closing also close it. A session opened during a turn (no `turn-end` in the conversation's transcript since its mark, less than a day ago) takes it up.
- Versions: `auto` on open and before a write when the newest is older than 5 minutes, and 5 minutes after activity resumes; `close` when the session closes; `migration` before a format migration; `publish` after a successful build (named, so kept until the user deletes it); a restore (`restoreBackup`) is journaled as one `system` change, after the `restore` version, so it can be undone. The project's data is pruned once per session (60 s after opening, or at close), under the project lock.

## Media
The session runs the project's media queue from opening. Files that unfinished jobs will write or replace are left to the job's result: when a job is done its files are journaled (an import as its requester's change; a conversion as a `system` change holding the master, the removal of the original and the references rewritten by `replaceMediaReferences`, the original stored first so that undo brings it back). Media files of `assets/` carry their probe and verdict in the snapshot (new or changed files are probed, through the probe cache); the media status follows the jobs (the session's own, and other processes' at each listing), so `I_MEDIA_PROCESSING` replaces `E_ASSET_MISSING` while a job writes a file. Media that are not conformant masters are converted (option `media.autoOptimize`, on by default). At close, running conversions are cancelled (queued again at the next open, since the files are still not masters) unless `close({ media: 'wait' })`.

## Format
A course in an older format is read migrated in memory (`I_FORMAT_OUTDATED`; `summary().format` says what the migration changes). Option `migrate`: `ask` (default: the `migrate` request writes the migration, and an edit through the session migrates first, as the CLI does), `auto` (migrated on open, after the open version), `never` (writes are refused). A migration is one `system` change after a `migration` version (`migrateIfOutdated`, given the session's snapshot). A newer format is refused (`E_FORMAT_NEWER`).

## Rendering and the canvas
`renderScreen` renders through one render context per language, kept while what screens share is unchanged (course.json, plan.json, elabora.lock.json, `components/`, object manifests, `theme/`, translations, the files its stylesheets name); renders are memoised per screen and view and forgotten when the screen file, a file they list (assets by hash, an object's folder) or a shared file changes; what the player's bars show that depends on every screen (the outline, the items that show, « IA et crédits », the languages) is kept apart per language, made again after a change to any screen or asset, and pushed with the screen shown when it changed. `openCanvas` starts the canvas host of the project (its page goes in a frame of the app); `canvasView` shows a screen or a language (a new render), or changes the state, step or layer shown (a view message: the page keeps its nodes); after every change that affects the screen shown the session pushes it again, with the change's sequence number as render id; `closeCanvas`. The app's « Lecteur » tab adds `&chrome=live` to the page URL: the player's bars working around the screen, which speak `elabora-player-preview/1` with the app ([Rendering](rendering.md), *The player's preview*); a change of `player`, the resources, the glossary or the credits reaches them as any change of `course.json` does.

## Protocol
Transport-neutral and plain data (`protocol.ts`, types only plus a small client), carried by the app over Electron message ports and by a server over WebSockets:
- a request `{ kind: 'request', id, type, params }` gets one response `{ kind: 'response', id, type, ok: true, result }` or `{ …, ok: false, error: { kind, message, code?, diagnostics? } }`; kinds: `refused` (with the catalogue code: `E_LOCKED`, `E_CONFLICT`, `E_SCHEMA`, `I_FORMAT_OUTDATED`…), `not-found`, `bad-request`, `not-available`, `closed`, `internal`;
- requests: `summary` (with each screen's estimated `seconds`, `project/duration.ts`, D50), `readFile`, `listFiles`, `readScreen`, `screenParts` (what the events of a screen can name: its blocks with their controls, a decision's options, a free object's events and settings, its layers and steps, its narration's lines, the course's screens and variables), `narration` (a narrated screen as an editor shows it: its settings as they apply and its own, each line in a language with its audio's state and its place on the timeline, the steps' segments: `@elabora/core` `narration/view.ts`), `findText` (the course's words that hold a text, where each is: not the plan's validated texts), `conversions` (« Transformer en… », D63: what a block may become, `{ block, language }` → `{ supported, from, targets: [{ to: { type, presentation? }, kept, lost, added, progress }], refused: [{ to, reason }] }`, the sentences in `language`, fr or en; `{ probe: true }` → `{ supported: true }` with no targets; the change itself is `run` `convert_block` `{ block, to }`), `translationStatus`, `translationTable` (a language as a bilingual table: each unit, its translation, what it asks for; a narration line's word anchors with the words each is placed on, its texts with their markers), `screenTranslation` (one screen's units in a language, with the block and field the canvas shows each at; `canvasView { language }` shows the screen in it), `buildTranslation` (a language as its own package: the translated course generated in the project's data folder, built into `dist/`), `diagnostics`, `history`, `run`, `undo`, `undoBatch`, `mark` (with `chat`: a turn of the chat), `endTurn`, `migrate`, `build`, `packageWeights` (what a package would weigh at each media quality as the project is in memory, `[{ quality, bytes, estimated }]`: nothing encoded, compiled or written, `@elabora/node` `packaging/weights.ts`, docs/architecture/media.md), `importMedia`, `mediaJobs`, `cancelMediaJob`, `backups`, `createBackup`, `restoreBackup`, `renderScreen`, `openCanvas`, `canvasView`, `closeCanvas`, `voices`, `installVoices`, `setSpeakerVoice`, `generateVoice` (`voiceProgress` events), `voiceTakes`, `cancelVoice` (stops a screen's generation, or every one and the downloads), `voiceSample` (a voice heard before it is chosen: WAV bytes), `aligners` (the word aligners and whether they are on this computer), `alignNarration` (voiced lines timed by forced alignment, D27: `alignProgress` events, one `set_narration_words` change a line), `timelineMarks` (a timed screen's marks, the Revue's storyboard: `@elabora/core` `timelineMarks`), `narrationPeaks` (the waveform of a screen's lines, 0 to 255 per slice, cached in `<data>/cache/peaks/`), `importNarrationFiles` (recordings of several lines, each to the line its name gives or, with the word aligner, the line it says), `planVoiceover` (the voice-over the plan gives a screen), `planVersions` (the plan's versions, its state, the changes since the validated version and in no version, what an export would show, D52), `planDiff` (`from`, `to`: a version, `validated`, `latest`, `current`), `planDocument` (the plan's document as data, a version or `current`, the Document view's model, D48), `planDocumentPage` (the same as one HTML page with its fonts, for the app to print with `printToPDF`, and the version it shows, for `record_plan_send`), `exportPlanDocument` (the document written as `.docx`, `.html` or `.pdf`, the send recorded on its version), `dialogue` (a dialogue block for its editor: its data, script, map and paths, and its voice lines with their audio's state, D61), `dialoguePlay` (« Jouer un chemin »: the path of the choices given, its score, ending and duration), `dialoguePage` (its script and map as one HTML page, for the app to print), `exportDialogue` (the same written as `.html` or `.pdf`), `effects` (the effects a timing can take: presets and the theme's, default durations, directions), `transcribers` (what can transcribe a language here: the local model, installed or its size, and ElevenLabs when a key is given), `transcribeRecording` (what a recording says, its words timed: on this computer, or ElevenLabs with the share approved, D46), `transcriptionEstimate` (what ElevenLabs would take of the quota for a file or a line's recording), `transcribeLine` (a recorded line's text now and what its recording says, nothing written), `close`;
- events `{ kind: 'event', event }`: `changed` (source `engine`, `journal`, `watcher` or `media`; the change with its author, batch, files and operations), `diagnostics` (the screens whose findings changed, removed screens, the project's findings when they changed, the summary), `mediaJob`, `backupTaken`, `batchClosed`, `error` (a background task), `closed`.

```ts
// engine process
const session = await openProject(dir);
session.subscribe((message) => port.postMessage(message));
port.on('message', async ({ data }) => port.postMessage(await session.handle(data)));
// renderer
const client = new ProtocolClient((message) => port.postMessage(message));
port.onmessage = ({ data }) => client.receive(data);
const summary = await client.request('summary', {});
```

## Measured
On the 87-screen course of `scripts/make-large-course.ts` (288 MB of video), on a busy machine (`packages/engine/bench/measure.ts`, `test/e2e/engine-canvas.test.ts`): open 30 to 90 ms (250 to 850 ms the first time, when every video is hashed), plus about 40 ms on macOS until the folder's watcher reports (its thread starts, about 20 ms, then its probe is reported); a command to its `changed` event about 2 ms, to its `diagnostics` event 5 to 8 ms (median); a `course.json` edit (everything validated) about 50 ms; an agent's own file write to its events about 75 ms (the debounce); a CLI edit is seen as soon as it is journaled; a command to the canvas DOM about 10 ms in Chrome; heap used after 150 commands +4 MB.
