feat(sim): F4.6f1 what a run leaves goes back into the document

The oracle SaveAfterRunRun saves Java's project after a run. The room writes
what a run leaves that Java keeps - files, running servers, virtual hosts,
mail servers' domains and accounts, mail programs' accounts and folders,
firewalls, DHCP clients' addresses - into the document as origin 'sim', at
most once a second, after each action and at the stop; writeRunState writes
only what differs. The command line now goes on in a folder made before a
restart, as Java's does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 17:05:11 +00:00
co-authored by Claude Opus 5.5
parent 11f13bca84
commit 3d6655e736
15 changed files with 705 additions and 22 deletions
+70 -1
View File
@@ -27,6 +27,12 @@
// installed or removed on the desktop (Java's software installation) is
// installed or removed in the running engine at once, as Java starts or ends
// it; any other change comes in at the next start.
//
// What a run leaves that Java's project keeps (files, which servers run,
// accounts, mails, a DHCP client's address …) goes back into the document,
// as `origin: 'sim'`: at most once a second while it runs, after each action
// in a window and when it stops (writeRunState). So the next start, and every
// client, sees it — as Java's project saved after a run holds it.
import * as Y from 'yjs';
import {
@@ -44,7 +50,7 @@ import {
type PageLanguage,
type TopologySnapshot,
} from '@mdg-filius/core';
import { docSnapshot, installedSoftware, ROOTS } from '@mdg-filius/doc';
import { docSnapshot, emailServerAfterRun, fromY, installedSoftware, ROOTS, SIM_ORIGIN, writeRunState, type NodeRunState } from '@mdg-filius/doc';
import {
parseClientMessage,
SIM_PROTOCOL_VERSION,
@@ -64,6 +70,8 @@ import { BrowserWindow } from './browserWindow.js';
import { TerminalWindow } from './terminalWindow.js';
export const TICK_MILLISECONDS = 200;
/** How often a running simulation writes what it left into the document. */
export const WRITE_BACK_MILLISECONDS = 1_000;
export const MAXIMUM_CATCH_UP_MILLISECONDS = 2_000;
export interface RoomOptions {
@@ -71,6 +79,8 @@ export interface RoomOptions {
readonly now: () => number;
/** A seed for each start. */
readonly seed: () => number;
/** How often a running simulation writes what it left into the document; WRITE_BACK_MILLISECONDS by default. */
readonly writeBackMilliseconds?: number;
}
interface Client {
@@ -111,6 +121,10 @@ export class SimulationRoom {
private readonly browserWindows = new Map<Id, BrowserWindow>();
/** The language of the last start (the pages the devices write themselves). */
private language: PageLanguage | undefined;
/** Each node's file system and its revision when it was last written back. */
private writtenFiles = new Map<Id, { readonly files: object; readonly revision: number }>();
/** The wall clock's time of the last write-back. */
private lastWriteBack = 0;
/** Each computer's programs as the running engine has them. */
private software = new Map<Id, readonly InstallableProgram[]>();
@@ -157,6 +171,56 @@ export class SimulationRoom {
this.flush([...this.clients.values()], false);
}
/**
* What the run left of each node that Java's project keeps: its files (when
* they changed since the last write), whether its servers run, a web
* server's virtual hosts, an e-mail server's domain and accounts, an
* e-mail program's account and folders, a firewall, a DHCP client's address.
*/
private runState(): Map<Id, NodeRunState> {
const snapshot = this.engine.state.topologySnapshot;
const states = new Map<Id, NodeRunState>();
const nodes = this.doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
for (const { id } of snapshot.nodes) {
const node = nodes.get(id);
if (node === undefined) continue;
const setting = (key: string) => fromY((node.get('settings') as Y.Map<unknown>).get(key));
const active: NonNullable<NodeRunState['active']> = {};
const settings: Record<string, unknown> = {};
const web = this.engine.webServer(id);
const webSettings = snapshot.webServersByNodeId.get(id);
if (web !== undefined && webSettings !== undefined) {
active.webServer = web.isActive;
settings.webServer = { port: web.port, documentRoot: webSettings.documentRoot, virtualHosts: web.useVirtualHosts };
}
const mailServer = this.engine.emailServer(id);
const mailServerSettings = snapshot.emailServersByNodeId.get(id);
const configured = setting('emailServer');
if (mailServer !== undefined && mailServerSettings !== undefined && configured !== undefined) {
active.emailServer = mailServer.isActive;
settings.emailServer = emailServerAfterRun(configured as never, mailServerSettings);
}
const echo = this.engine.echoServer(id);
if (echo !== undefined) active.echoServer = echo.isActive;
settings.emailClient = snapshot.emailClientsByNodeId.get(id);
settings.firewall = snapshot.firewallConfigurationsByNodeId.get(id);
if (snapshot.dhcpClientConfigurationsByNodeId.get(id)?.isEnabled === true) settings.device = snapshot.deviceConfigurations.get(id);
const files = snapshot.fileSystemsByNodeId.get(id);
const written = this.writtenFiles.get(id);
const changed = files !== undefined && (written?.files !== files || written.revision !== files.revision);
if (files !== undefined && changed) this.writtenFiles.set(id, { files, revision: files.revision });
states.set(id, { ...(changed ? { files } : {}), active, settings });
}
return states;
}
/** Writes what the run left into the document (origin 'sim'). */
private writeBack(): void {
if (this.engine.state.phase !== 'running') return;
this.lastWriteBack = this.options.now();
writeRunState(this.doc, this.runState(), SIM_ORIGIN);
}
/** The document as the engine takes it, in the language of the start. */
private snapshot(): TopologySnapshot {
const snapshot = docSnapshot(this.doc);
@@ -246,6 +310,7 @@ export class SimulationRoom {
case 'input': {
let failure: ErrorCode | undefined = 'engineFailed';
if (!this.guarded(() => (failure = this.act(input)))) return;
if (failure === undefined) this.guarded(() => this.writeBack());
client.send(failure === undefined ? message({ kind: 'ack', reqId: input.reqId }) : message({ kind: 'error', code: failure, reqId: input.reqId }));
this.flush([...this.clients.values()], false);
return;
@@ -272,12 +337,15 @@ export class SimulationRoom {
this.syncWindows();
this.mode = 'simulation';
this.lastWall = this.options.now();
this.lastWriteBack = this.lastWall;
this.broadcast(this.state());
this.flush([...this.clients.values()], false);
}
stop(): void {
if (this.mode === 'design') return;
// A failure already stopped the engine and went back to the design.
if (!this.guarded(() => this.writeBack())) return;
this.engine.handle({ type: 'stop' });
this.mode = 'design';
this.lastWall = undefined;
@@ -295,6 +363,7 @@ export class SimulationRoom {
this.lastWall = behind > MAXIMUM_CATCH_UP_MILLISECONDS ? now : (this.lastWall ?? now) + elapsed;
if (!this.guarded(() => this.engine.handle({ type: 'advance', toMilliseconds: this.engine.state.currentTimeMilliseconds + elapsed }))) return;
this.flush([...this.clients.values()], false);
if (now - this.lastWriteBack >= (this.options.writeBackMilliseconds ?? WRITE_BACK_MILLISECONDS)) this.guarded(() => this.writeBack());
}
/**