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:
@@ -4,6 +4,7 @@ export * from './fields.js';
|
||||
export * from './janitor.js';
|
||||
export * from './projectDoc.js';
|
||||
export * from './settings.js';
|
||||
export * from './simulation.js';
|
||||
export * from './sheet.js';
|
||||
export * from './snapshot.js';
|
||||
export * from './values.js';
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// What a run leaves in the project, written back into the document (plan
|
||||
// §6.1: the room writes it as `origin: 'sim'`), as Java's project holds it
|
||||
// when it is saved after a run (measured with tools/java-oracle's
|
||||
// SaveAfterRunRun): each computer's files as its programs left them, which
|
||||
// servers run, and the settings a run changes — a mail server's domain and
|
||||
// accounts, a mail program's account and folders, a web server's virtual
|
||||
// hosts, a firewall changed through a router's page, the address a DHCP
|
||||
// client got. Only what differs is written, so that a write changes nothing
|
||||
// the second time and a text edited meanwhile keeps what it did not touch.
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import type { Id, InstallableProgram, VirtualFileContent, VirtualFileSystem } from '@mdg-filius/core';
|
||||
import { fileEntry, NODE_SETTINGS, ROOTS } from './projectDoc.js';
|
||||
|
||||
export { emailServerAfterRun } from '@mdg-filius/fls';
|
||||
import { fromY, toY, type PlainValue } from './values.js';
|
||||
|
||||
/** The origin of the room's writes (plan §6.1); a person's undo never takes them. */
|
||||
export const SIM_ORIGIN = 'sim';
|
||||
|
||||
/** A node's settings key in the document (NODE_SETTINGS' values). */
|
||||
export type NodeSettingsKey = (typeof NODE_SETTINGS)[keyof typeof NODE_SETTINGS];
|
||||
|
||||
/** What a run left of a node. */
|
||||
export interface NodeRunState {
|
||||
readonly files?: VirtualFileSystem;
|
||||
readonly active?: Partial<Record<InstallableProgram, boolean>>;
|
||||
readonly settings?: Partial<Record<NodeSettingsKey, unknown>>;
|
||||
}
|
||||
|
||||
/** Plain values alike, whatever the order of an object's fields; a field that is undefined is none. */
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
if (left instanceof Uint8Array || right instanceof Uint8Array) return left instanceof Uint8Array && right instanceof Uint8Array && sameBytes(left, right);
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((each, index) => same(each, right[index]));
|
||||
}
|
||||
if (typeof left === 'object' && left !== null && typeof right === 'object' && right !== null) {
|
||||
const fields = (value: object) => Object.entries(value).filter(([, field]) => field !== undefined);
|
||||
const leftFields = fields(left);
|
||||
const rightFields = new Map(fields(right));
|
||||
return leftFields.length === rightFields.size && leftFields.every(([key, field]) => rightFields.has(key) && same(field, rightFields.get(key)));
|
||||
}
|
||||
return left === right;
|
||||
}
|
||||
|
||||
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
||||
return left.length === right.length && left.every((byte, index) => byte === right[index]);
|
||||
}
|
||||
|
||||
/** A text changed where it changed: the common start and end stay (and with them what another person typed there). */
|
||||
function replaceText(text: Y.Text, next: string): void {
|
||||
const current = text.toString();
|
||||
if (current === next) return;
|
||||
let start = 0;
|
||||
while (start < current.length && start < next.length && current[start] === next[start]) start += 1;
|
||||
let end = 0;
|
||||
while (end < current.length - start && end < next.length - start && current[current.length - 1 - end] === next[next.length - 1 - end]) end += 1;
|
||||
if (current.length - start - end > 0) text.delete(start, current.length - start - end);
|
||||
if (next.length - start - end > 0) text.insert(start, next.slice(start, next.length - end));
|
||||
}
|
||||
|
||||
/** Whether a document entry holds the content. */
|
||||
function holds(entry: Y.Map<unknown>, content: VirtualFileContent): boolean {
|
||||
if (entry.get('kind') !== content.kind) return false;
|
||||
if (content.kind === 'directory' || content.kind === 'text') return true;
|
||||
return sameBytes(entry.get('data') as Uint8Array, content.data) && entry.get('mediaType') === content.mediaType;
|
||||
}
|
||||
|
||||
/** A node's files as the run left them: new ones after the others, changed ones in their place, gone ones removed. */
|
||||
function writeFiles(node: Y.Map<unknown>, fileSystem: VirtualFileSystem): void {
|
||||
let files = node.get('files') as Y.Map<Y.Map<unknown>> | undefined;
|
||||
const wanted: [string, VirtualFileContent][] = [];
|
||||
const walk = (folder: string): void => {
|
||||
for (const entry of fileSystem.entriesInCreationOrder(folder)) {
|
||||
wanted.push([entry.path, entry.content]);
|
||||
if (entry.content.kind === 'directory') walk(entry.path);
|
||||
}
|
||||
};
|
||||
walk('/');
|
||||
if (files === undefined) {
|
||||
if (wanted.length === 0) return;
|
||||
files = new Y.Map<Y.Map<unknown>>();
|
||||
node.set('files', files);
|
||||
}
|
||||
const paths = new Set(wanted.map(([path]) => path));
|
||||
for (const path of [...files.keys()]) if (!paths.has(path)) files.delete(path);
|
||||
let order = Math.max(-1, ...[...files.values()].map((entry) => entry.get('order') as number)) + 1;
|
||||
for (const [path, content] of wanted) {
|
||||
const entry = files.get(path);
|
||||
if (entry === undefined) {
|
||||
files.set(path, fileEntry(order++, content));
|
||||
} else if (!holds(entry, content)) {
|
||||
files.set(path, fileEntry(entry.get('order') as number, content));
|
||||
} else if (content.kind === 'text') {
|
||||
replaceText(entry.get('text') as Y.Text, content.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes what the runs left of the nodes, what differs only, in one transaction; nodes the document no longer has are left out. */
|
||||
export function writeRunState(doc: Y.Doc, states: ReadonlyMap<Id, NodeRunState>, origin: unknown = SIM_ORIGIN): void {
|
||||
const nodes = doc.getMap<Y.Map<unknown>>(ROOTS.nodes);
|
||||
doc.transact(() => {
|
||||
for (const [id, state] of states) {
|
||||
const node = nodes.get(id);
|
||||
if (node === undefined) continue;
|
||||
if (state.files !== undefined) writeFiles(node, state.files);
|
||||
const programs = node.get('programs') as Y.Map<Y.Map<unknown>>;
|
||||
for (const [program, active] of Object.entries(state.active ?? {})) {
|
||||
const entry = programs.get(program);
|
||||
if (entry !== undefined && entry.get('active') !== active) entry.set('active', active);
|
||||
}
|
||||
const settings = node.get('settings') as Y.Map<unknown>;
|
||||
// Only a setting the node has (a router's own web server has none).
|
||||
for (const [key, value] of Object.entries(state.settings ?? {})) {
|
||||
const current = settings.get(key);
|
||||
if (value === undefined || current === undefined) continue;
|
||||
if (!same(fromY(current), value)) settings.set(key, toY(value as PlainValue));
|
||||
}
|
||||
}
|
||||
}, origin);
|
||||
}
|
||||
Reference in New Issue
Block a user