Skip to content

Commit 5b9604f

Browse files
authored
Merge pull request #265 from pathsim/feature/structured-mutations
Replace raw Python string mutations with structured JSON command protocol
2 parents 74149d0 + ad7e5ed commit 5b9604f

2 files changed

Lines changed: 128 additions & 53 deletions

File tree

src/lib/pyodide/mutationQueue.ts

Lines changed: 70 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Mutation Queue
33
* Collects graph changes (add/remove blocks, connections, parameter/setting changes)
4-
* as Python code strings. Changes are NOT applied automatically — the user
4+
* as structured command objects. Changes are NOT applied automatically — the user
55
* explicitly stages them via a "Stage Changes" action.
66
*
77
* On "Run": queue is cleared, mappings initialized from code generation result.
@@ -11,7 +11,8 @@
1111
* Design:
1212
* - Structural mutations (add/remove block/connection) are queued in order.
1313
* - Parameter and setting updates are coalesced: only the latest value per key.
14-
* - Each mutation is wrapped in try/except for error isolation on flush.
14+
* - Mutations are serialized as JSON and dispatched by a Python-side handler
15+
* (_apply_mutations) which handles per-mutation error isolation.
1516
* - pendingMutationCount is a Svelte store for UI reactivity (badge on stage button).
1617
*/
1718

@@ -21,6 +22,16 @@ import { nodeRegistry } from '$lib/nodes/registry';
2122
import { isSubsystem } from '$lib/nodes/shapes';
2223
import { sanitizeName } from './codeBuilder';
2324

25+
// --- Command types ---
26+
27+
type MutationCommand =
28+
| { type: 'set_param'; var: string; param: string; value: string }
29+
| { type: 'set_setting'; code: string }
30+
| { type: 'add_block'; var: string; blockClass: string; params: Record<string, string>; nodeId: string; nodeName: string }
31+
| { type: 'remove_block'; var: string; nodeId: string }
32+
| { type: 'add_connection'; var: string; sourceVar: string; sourcePort: number; targetVar: string; targetPort: number }
33+
| { type: 'remove_connection'; var: string };
34+
2435
// --- Internal state ---
2536

2637
/** Active variable name mappings from the last run */
@@ -31,13 +42,13 @@ let activeConnVars = new Map<string, string>(); // connectionId → Python va
3142
let dynamicVarCounter = 0;
3243

3344
/** Ordered structural mutations (add/remove block/connection) */
34-
const structuralQueue: string[] = [];
45+
const structuralQueue: MutationCommand[] = [];
3546

36-
/** Coalesced parameter updates: "nodeId:paramName" → Python assignment */
37-
const paramUpdates = new Map<string, string>();
47+
/** Coalesced parameter updates: "nodeId:paramName" → command */
48+
const paramUpdates = new Map<string, MutationCommand>();
3849

39-
/** Coalesced setting updates: key → Python code */
40-
const settingUpdates = new Map<string, string>();
50+
/** Coalesced setting updates: key → command */
51+
const settingUpdates = new Map<string, MutationCommand>();
4152

4253
/** Reactive store: number of pending mutations */
4354
export const pendingMutationCount = writable(0);
@@ -83,34 +94,38 @@ export function clearQueue(): void {
8394

8495
/**
8596
* Get all pending mutations as a Python code string and clear the queue.
86-
* Each mutation is wrapped in try/except for error isolation.
97+
* Mutations are serialized as JSON and dispatched via _apply_mutations().
8798
* Order: settings first, then structural mutations, then parameter updates.
8899
*/
89100
export function flushQueue(): string | null {
90-
const allCode: string[] = [];
101+
const allCommands: MutationCommand[] = [];
91102

92103
// 1. Settings (apply before structural changes)
93-
for (const code of settingUpdates.values()) {
94-
allCode.push(wrapTryExcept(code));
104+
for (const cmd of settingUpdates.values()) {
105+
allCommands.push(cmd);
95106
}
96107

97108
// 2. Structural mutations (add/remove in order)
98-
for (const code of structuralQueue) {
99-
allCode.push(wrapTryExcept(code));
109+
for (const cmd of structuralQueue) {
110+
allCommands.push(cmd);
100111
}
101112

102113
// 3. Parameter updates (apply after blocks exist)
103-
for (const code of paramUpdates.values()) {
104-
allCode.push(wrapTryExcept(code));
114+
for (const cmd of paramUpdates.values()) {
115+
allCommands.push(cmd);
105116
}
106117

107118
structuralQueue.length = 0;
108119
paramUpdates.clear();
109120
settingUpdates.clear();
110121
updateCount();
111122

112-
if (allCode.length === 0) return null;
113-
return allCode.join('\n');
123+
if (allCommands.length === 0) return null;
124+
125+
// Double stringify: inner produces the JSON array,
126+
// outer wraps it as a Python string literal with proper escaping
127+
const jsonPayload = JSON.stringify(JSON.stringify(allCommands));
128+
return `_apply_mutations(${jsonPayload})`;
114129
}
115130

116131
/**
@@ -141,23 +156,22 @@ export function queueAddBlock(node: NodeInstance): void {
141156
activeNodeVars.set(node.id, varName);
142157

143158
const validParamNames = new Set(typeDef.params.map(p => p.name));
144-
const paramParts: string[] = [];
159+
const params: Record<string, string> = {};
145160
for (const [name, value] of Object.entries(node.params)) {
146161
if (value === null || value === undefined || value === '') continue;
147162
if (name.startsWith('_')) continue;
148163
if (!validParamNames.has(name)) continue;
149-
paramParts.push(`${name}=${value}`);
164+
params[name] = String(value);
150165
}
151-
const params = paramParts.join(', ');
152-
const constructor = params ? `${typeDef.blockClass}(${params})` : `${typeDef.blockClass}()`;
153-
154-
structuralQueue.push([
155-
`${varName} = ${constructor}`,
156-
`sim.add_block(${varName})`,
157-
`blocks.append(${varName})`,
158-
`_node_id_map[id(${varName})] = "${node.id}"`,
159-
`_node_name_map["${node.id}"] = "${node.name.replace(/"/g, '\\"')}"`
160-
].join('\n'));
166+
167+
structuralQueue.push({
168+
type: 'add_block',
169+
var: varName,
170+
blockClass: typeDef.blockClass,
171+
params,
172+
nodeId: node.id,
173+
nodeName: node.name
174+
});
161175
updateCount();
162176
}
163177

@@ -168,12 +182,11 @@ export function queueRemoveBlock(nodeId: string): void {
168182
const varName = activeNodeVars.get(nodeId);
169183
if (!varName) return;
170184

171-
structuralQueue.push([
172-
`sim.remove_block(${varName})`,
173-
`blocks.remove(${varName})`,
174-
`_node_id_map.pop(id(${varName}), None)`,
175-
`_node_name_map.pop("${nodeId}", None)`
176-
].join('\n'));
185+
structuralQueue.push({
186+
type: 'remove_block',
187+
var: varName,
188+
nodeId
189+
});
177190
activeNodeVars.delete(nodeId);
178191

179192
// Remove any coalesced param updates for this block
@@ -198,11 +211,14 @@ export function queueAddConnection(conn: Connection): void {
198211
const varName = `conn_dyn_${dynamicVarCounter++}`;
199212
activeConnVars.set(conn.id, varName);
200213

201-
structuralQueue.push([
202-
`${varName} = Connection(${sourceVar}[${conn.sourcePortIndex}], ${targetVar}[${conn.targetPortIndex}])`,
203-
`sim.add_connection(${varName})`,
204-
`connections.append(${varName})`
205-
].join('\n'));
214+
structuralQueue.push({
215+
type: 'add_connection',
216+
var: varName,
217+
sourceVar,
218+
sourcePort: conn.sourcePortIndex,
219+
targetVar,
220+
targetPort: conn.targetPortIndex
221+
});
206222
updateCount();
207223
}
208224

@@ -213,10 +229,10 @@ export function queueRemoveConnection(connId: string): void {
213229
const varName = activeConnVars.get(connId);
214230
if (!varName) return;
215231

216-
structuralQueue.push([
217-
`sim.remove_connection(${varName})`,
218-
`connections.remove(${varName})`
219-
].join('\n'));
232+
structuralQueue.push({
233+
type: 'remove_connection',
234+
var: varName
235+
});
220236
activeConnVars.delete(connId);
221237
updateCount();
222238
}
@@ -229,7 +245,12 @@ export function queueUpdateParam(nodeId: string, paramName: string, value: strin
229245
const varName = activeNodeVars.get(nodeId);
230246
if (!varName) return;
231247

232-
paramUpdates.set(`${nodeId}:${paramName}`, `${varName}.${paramName} = ${value}`);
248+
paramUpdates.set(`${nodeId}:${paramName}`, {
249+
type: 'set_param',
250+
var: varName,
251+
param: paramName,
252+
value
253+
});
233254
updateCount();
234255
}
235256

@@ -242,7 +263,10 @@ export function queueUpdateParam(nodeId: string, paramName: string, value: strin
242263
export function queueUpdateSetting(key: string, code: string): void {
243264
if (!isActive()) return;
244265

245-
settingUpdates.set(key, code);
266+
settingUpdates.set(key, {
267+
type: 'set_setting',
268+
code
269+
});
246270
updateCount();
247271
}
248272

@@ -255,10 +279,3 @@ export function getNodeVar(nodeId: string): string | undefined {
255279
export function getConnVar(connId: string): string | undefined {
256280
return activeConnVars.get(connId);
257281
}
258-
259-
// --- Internal helpers ---
260-
261-
function wrapTryExcept(code: string): string {
262-
const indented = code.split('\n').map(line => ` ${line}`).join('\n');
263-
return `try:\n${indented}\nexcept Exception as _e:\n print(f"Mutation error: {_e}", file=__import__('sys').stderr)`;
264-
}

src/lib/pyodide/pythonHelpers.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,64 @@ def _step_streaming_gen():
3434
_sim_streaming = False
3535
return {'done': True, 'result': None}
3636
37+
def _apply_mutations(json_str):
38+
"""Apply a batch of structured mutation commands.
39+
Each mutation is isolated — errors in one do not prevent others from running.
40+
"""
41+
import json as _json
42+
mutations = _json.loads(json_str)
43+
for mut in mutations:
44+
try:
45+
_apply_single_mutation(mut)
46+
except Exception as _e:
47+
print(f"Mutation error ({mut.get('type', '?')}): {_e}", file=__import__('sys').stderr)
48+
49+
def _apply_single_mutation(mut):
50+
"""Dispatch a single mutation command by type."""
51+
g = globals()
52+
t = mut['type']
53+
54+
if t == 'set_param':
55+
block = g[mut['var']]
56+
setattr(block, mut['param'], eval(mut['value'], g))
57+
58+
elif t == 'set_setting':
59+
exec(mut['code'], g)
60+
61+
elif t == 'add_block':
62+
block_class = eval(mut['blockClass'], g)
63+
params = {k: eval(v, g) for k, v in mut['params'].items()}
64+
block = block_class(**params)
65+
g[mut['var']] = block
66+
sim.add_block(block)
67+
blocks.append(block)
68+
_node_id_map[id(block)] = mut['nodeId']
69+
_node_name_map[mut['nodeId']] = mut['nodeName']
70+
71+
elif t == 'remove_block':
72+
block = g[mut['var']]
73+
sim.remove_block(block)
74+
blocks.remove(block)
75+
_node_id_map.pop(id(block), None)
76+
_node_name_map.pop(mut['nodeId'], None)
77+
78+
elif t == 'add_connection':
79+
source = g[mut['sourceVar']]
80+
target = g[mut['targetVar']]
81+
conn = Connection(source[mut['sourcePort']], target[mut['targetPort']])
82+
g[mut['var']] = conn
83+
sim.add_connection(conn)
84+
connections.append(conn)
85+
86+
elif t == 'remove_connection':
87+
conn = g[mut['var']]
88+
sim.remove_connection(conn)
89+
connections.remove(conn)
90+
91+
else:
92+
raise ValueError(f"Unknown mutation type: {t}")
93+
94+
3795
def _extract_scope_data(blocks, node_id_map, incremental=False):
3896
"""Extract data from Scope blocks recursively.
3997

0 commit comments

Comments
 (0)