@dfosco/storyboard 0.6.11 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,8 @@
12
12
  * GET /list — list all canvases
13
13
  * GET /folders — list canvas folders
14
14
  * PUT /update — append update events (widgets, sources, settings)
15
+ * POST /undo — append the inverse of a previously-applied event
16
+ * POST /redo — re-apply an undone event (mirror of /undo)
15
17
  * PUT /rename-page — rename a canvas page file
16
18
  * PUT /reorder-pages — save page order for a canvas folder
17
19
  * GET /page-order — read page order for a folder
@@ -38,7 +40,9 @@ import fs from 'node:fs'
38
40
  import path from 'node:path'
39
41
  import { Buffer } from 'node:buffer'
40
42
  import { createHash } from 'node:crypto'
41
- import { materializeFromText, serializeEvent } from './materializer.js'
43
+ import { materializeFromText, parseCanvasJsonl, serializeEvent } from './materializer.js'
44
+ import { buildInverseEvent } from './undoRedo.js'
45
+ import { compactCanvas } from './compact.js'
42
46
  import { toCanvasId, parseCanvasId } from './identity.js'
43
47
  import {
44
48
  GH_INSTALL_URL,
@@ -240,6 +244,53 @@ function appendEventRaw(filePath, event) {
240
244
  fs.appendFileSync(filePath, serializeEvent(event) + '\n', 'utf-8')
241
245
  }
242
246
 
247
+ /**
248
+ * Generate a short random event id. Stamped on every canvas event so
249
+ * undo/redo can reference the inverse target by id alone.
250
+ */
251
+ function generateEventId() {
252
+ return `evt_${Math.random().toString(36).slice(2, 10)}`
253
+ }
254
+
255
+ /**
256
+ * Hard ceiling for runaway sessions: when a JSONL file grows past this size,
257
+ * compact it once (collapses history into a single `canvas_created` baseline).
258
+ * Set above the normal startup-compaction threshold (500 KB) so most files
259
+ * never hit it — only marathon sessions that don't restart the dev server.
260
+ */
261
+ const SOFT_COMPACT_THRESHOLD_BYTES = 2 * 1024 * 1024
262
+
263
+ /** Per-file lock to avoid compacting the same file repeatedly on bursts. */
264
+ const inFlightCompactions = new Set()
265
+
266
+ /**
267
+ * Check the file size after a write; if past the runaway threshold, kick off
268
+ * a one-shot compaction. Best-effort — failures are swallowed and logged.
269
+ */
270
+ function maybeCompactRunawayFile(filePath) {
271
+ if (inFlightCompactions.has(filePath)) return
272
+ let size
273
+ try { size = fs.statSync(filePath).size } catch { return }
274
+ if (size < SOFT_COMPACT_THRESHOLD_BYTES) return
275
+ inFlightCompactions.add(filePath)
276
+ // Run on the next tick so this append's HMR push fires first and the
277
+ // compaction's HMR push lands after — the client treats both consistently
278
+ // (compaction's push is a single canvas_created event that reconciles).
279
+ setImmediate(() => {
280
+ try {
281
+ const result = compactCanvas(filePath, { force: true })
282
+ devLog().logEvent('info', `[canvas] runaway compaction ${path.basename(filePath)}`, {
283
+ before: result.before,
284
+ after: result.after,
285
+ })
286
+ } catch (err) {
287
+ devLog().logEvent('warn', `[canvas] runaway compaction failed for ${filePath}`, { error: err.message })
288
+ } finally {
289
+ inFlightCompactions.delete(filePath)
290
+ }
291
+ })
292
+ }
293
+
243
294
  /**
244
295
  * Generate a unique widget ID.
245
296
  */
@@ -702,13 +753,25 @@ export function createCanvasHandler(ctx) {
702
753
  // Append an event to an existing canvas file.
703
754
  // Marks the file in the write guard so the data plugin's watcher handler
704
755
  // skips sending a duplicate HMR event (the server pushes its own via
705
- // pushCanvasUpdate after the write).
756
+ // pushCanvasUpdate after the write). Stamps a stable random `id` so
757
+ // undo/redo markers can target events by id.
758
+ //
759
+ // Also runs the runaway-session hard ceiling: if the file grows past
760
+ // SOFT_COMPACT_THRESHOLD after writing, kick off a one-shot compaction
761
+ // to keep the JSONL → HMR → React pipeline responsive. The user's per-tab
762
+ // undo stack will lose pre-compaction targets (post-compaction undo calls
763
+ // return 404 which the client interprets as "clear the stack").
706
764
  function appendEvent(filePath, event) {
765
+ if (event && typeof event === 'object' && !event.id) {
766
+ event.id = generateEventId()
767
+ }
707
768
  markCanvasWrite(filePath)
708
769
  appendEventRaw(filePath, event)
770
+ maybeCompactRunawayFile(filePath)
709
771
  // Unmark after enough time for the watcher to fire and be suppressed.
710
772
  // macOS FSEvents latency is typically 100-500ms; 1s covers edge cases.
711
773
  setTimeout(() => unmarkCanvasWrite(filePath), 1000)
774
+ return event.id
712
775
  }
713
776
 
714
777
  /**
@@ -1007,6 +1070,9 @@ export function createCanvasHandler(ctx) {
1007
1070
 
1008
1071
  try {
1009
1072
  const ts = new Date().toISOString()
1073
+ // Snapshot of pre-update state so we can stamp prev* payloads on
1074
+ // source_updated and settings_updated events without re-reading.
1075
+ const current = readCanvas(filePath)
1010
1076
 
1011
1077
  if (widgets) {
1012
1078
  // Guard against accidental canvas wipes. Any widget IDs present in
@@ -1015,7 +1081,6 @@ export function createCanvasHandler(ctx) {
1015
1081
  // This protects against stale-state writers (e.g. a debounced client
1016
1082
  // save fired before an HMR push containing newly-added widgets was
1017
1083
  // reconciled) silently wiping freshly-created widgets.
1018
- const current = readCanvas(filePath)
1019
1084
  const currentWidgets = current.widgets || []
1020
1085
  if (body.replaceAll !== true) {
1021
1086
  const incomingIds = new Set(widgets.map((w) => w && w.id).filter(Boolean))
@@ -1032,14 +1097,23 @@ export function createCanvasHandler(ctx) {
1032
1097
  }
1033
1098
  }
1034
1099
  const stamped = stampBoundsAll(widgets)
1100
+ // widgets_replaced is not undoable — emitted only by compaction or
1101
+ // explicit replaceAll. The client should use PATCH/POST/DELETE for
1102
+ // granular, undoable changes.
1035
1103
  appendEvent(filePath, { event: 'widgets_replaced', timestamp: ts, widgets: stamped })
1036
1104
  }
1037
1105
 
1038
1106
  if (sources) {
1039
- appendEvent(filePath, { event: 'source_updated', timestamp: ts, sources })
1107
+ appendEvent(filePath, {
1108
+ event: 'source_updated',
1109
+ timestamp: ts,
1110
+ sources,
1111
+ prevSources: Array.isArray(current.sources) ? current.sources : [],
1112
+ })
1040
1113
  }
1041
1114
 
1042
1115
  if (connectors) {
1116
+ // connectors_replaced is not undoable — emitted only by compaction.
1043
1117
  appendEvent(filePath, { event: 'connectors_replaced', timestamp: ts, connectors })
1044
1118
  }
1045
1119
 
@@ -1051,7 +1125,17 @@ export function createCanvasHandler(ctx) {
1051
1125
  }
1052
1126
  }
1053
1127
  if (Object.keys(filtered).length > 0) {
1054
- appendEvent(filePath, { event: 'settings_updated', timestamp: ts, settings: filtered })
1128
+ // Capture prior values for the keys being changed.
1129
+ const prevSettings = {}
1130
+ for (const key of Object.keys(filtered)) {
1131
+ prevSettings[key] = current[key]
1132
+ }
1133
+ appendEvent(filePath, {
1134
+ event: 'settings_updated',
1135
+ timestamp: ts,
1136
+ settings: filtered,
1137
+ prevSettings,
1138
+ })
1055
1139
  }
1056
1140
  }
1057
1141
 
@@ -1063,6 +1147,61 @@ export function createCanvasHandler(ctx) {
1063
1147
  return
1064
1148
  }
1065
1149
 
1150
+ // POST /undo — append the inverse of a previously-applied event, tagged
1151
+ // with meta.kind = 'undo' / meta.of = <targetEventId>. Body:
1152
+ // { name, eventId }
1153
+ // Returns { success, eventId, inverseEvent: {...} }.
1154
+ //
1155
+ // POST /redo behaves identically but stamps meta.kind = 'redo'. Per
1156
+ // convention the client passes the id of the *undo* event when redoing
1157
+ // (so the redo cancels the undo and brings back the original change).
1158
+ if ((routePath === '/undo' || routePath === '/redo') && method === 'POST') {
1159
+ const { name, eventId } = body
1160
+ const kind = routePath === '/undo' ? 'undo' : 'redo'
1161
+
1162
+ if (!name || !eventId) {
1163
+ sendJson(res, 400, { error: 'Canvas name and eventId are required' })
1164
+ return
1165
+ }
1166
+
1167
+ const filePath = findCanvasPath(root, name)
1168
+ if (!filePath) {
1169
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
1170
+ return
1171
+ }
1172
+
1173
+ try {
1174
+ const text = fs.readFileSync(filePath, 'utf-8')
1175
+ const events = parseCanvasJsonl(text)
1176
+ const target = events.find((evt) => evt && evt.id === eventId)
1177
+ if (!target) {
1178
+ sendJson(res, 404, { error: `Event "${eventId}" not found in canvas "${name}"` })
1179
+ return
1180
+ }
1181
+
1182
+ const inverse = buildInverseEvent(target, kind)
1183
+ if (!inverse) {
1184
+ sendJson(res, 400, {
1185
+ error: `Event "${eventId}" (type "${target.event}") cannot be ${kind === 'undo' ? 'undone' : 'redone'}: missing inverse payload or unsupported event type`,
1186
+ })
1187
+ return
1188
+ }
1189
+
1190
+ inverse.timestamp = new Date().toISOString()
1191
+ const inverseId = appendEvent(filePath, inverse)
1192
+
1193
+ sendJson(res, 200, {
1194
+ success: true,
1195
+ eventId: inverseId,
1196
+ inverseEvent: { ...inverse, id: inverseId },
1197
+ })
1198
+ pushCanvasUpdate(name, filePath, __viteWs)
1199
+ } catch (err) {
1200
+ sendJson(res, 500, { error: `Failed to ${kind} event: ${err.message}` })
1201
+ }
1202
+ return
1203
+ }
1204
+
1066
1205
  // POST /widget — append a widget_added event
1067
1206
  if (routePath === '/widget' && method === 'POST') {
1068
1207
  const { name, type, props = {}, pool, near, direction, resolve, source, gap } = body
@@ -1150,13 +1289,13 @@ export function createCanvasHandler(ctx) {
1150
1289
 
1151
1290
  const widget = stampBounds({ id: widgetId, type, position, props })
1152
1291
 
1153
- appendEvent(filePath, {
1292
+ const eventId = appendEvent(filePath, {
1154
1293
  event: 'widget_added',
1155
1294
  timestamp: new Date().toISOString(),
1156
1295
  widget,
1157
1296
  })
1158
1297
 
1159
- const response = { success: true, widget }
1298
+ const response = { success: true, widget, eventId }
1160
1299
  if (hotProbe.ready) response.hotSession = { id: null, tmuxName: null, webglReady: hotProbe.webglReady }
1161
1300
  sendJson(res, 201, response)
1162
1301
  pushCanvasUpdate(name, filePath, __viteWs)
@@ -1190,10 +1329,13 @@ export function createCanvasHandler(ctx) {
1190
1329
  return
1191
1330
  }
1192
1331
 
1193
- appendEvent(filePath, {
1332
+ const eventId = appendEvent(filePath, {
1194
1333
  event: 'widget_removed',
1195
1334
  timestamp: new Date().toISOString(),
1196
1335
  widgetId,
1336
+ // Carry the full removed widget so undo can re-add it without
1337
+ // walking history. Symmetric to widget_added.widget.
1338
+ widget,
1197
1339
  })
1198
1340
 
1199
1341
  // Orphan terminal session when a terminal widget is deleted (not killed)
@@ -1206,7 +1348,7 @@ export function createCanvasHandler(ctx) {
1206
1348
  }
1207
1349
  }
1208
1350
 
1209
- sendJson(res, 200, { success: true, removed: 1 })
1351
+ sendJson(res, 200, { success: true, removed: 1, eventId })
1210
1352
  pushCanvasUpdate(name, filePath, __viteWs)
1211
1353
  } catch (err) {
1212
1354
  sendJson(res, 500, { error: `Failed to remove widget: ${err.message}` })
@@ -1242,25 +1384,34 @@ export function createCanvasHandler(ctx) {
1242
1384
  }
1243
1385
 
1244
1386
  const ts = new Date().toISOString()
1387
+ const eventIds = []
1245
1388
 
1246
1389
  if (props) {
1247
- appendEvent(filePath, {
1390
+ // Capture only the keys being changed so undo restores them
1391
+ // precisely. Missing keys mean "this prop didn't exist before".
1392
+ const prevProps = {}
1393
+ for (const key of Object.keys(props)) {
1394
+ prevProps[key] = widget.props ? widget.props[key] : undefined
1395
+ }
1396
+ eventIds.push(appendEvent(filePath, {
1248
1397
  event: 'widget_updated',
1249
1398
  timestamp: ts,
1250
1399
  widgetId,
1251
1400
  props,
1252
- })
1401
+ prevProps,
1402
+ }))
1253
1403
  }
1254
1404
 
1255
1405
  if (position) {
1256
1406
  // Merge with existing position so partial updates (only --x or --y) are safe
1257
1407
  const mergedPosition = { ...widget.position, ...position }
1258
- appendEvent(filePath, {
1408
+ eventIds.push(appendEvent(filePath, {
1259
1409
  event: 'widget_moved',
1260
1410
  timestamp: ts,
1261
1411
  widgetId,
1262
1412
  position: mergedPosition,
1263
- })
1413
+ prevPosition: widget.position || { x: 0, y: 0 },
1414
+ }))
1264
1415
  }
1265
1416
 
1266
1417
  // Return the merged widget for convenience
@@ -1269,7 +1420,7 @@ export function createCanvasHandler(ctx) {
1269
1420
  props: { ...widget.props, ...(props || {}) },
1270
1421
  position: position ? { ...widget.position, ...position } : widget.position,
1271
1422
  }
1272
- sendJson(res, 200, { success: true, widget: merged })
1423
+ sendJson(res, 200, { success: true, widget: merged, eventIds })
1273
1424
  pushCanvasUpdate(name, filePath, __viteWs)
1274
1425
  } catch (err) {
1275
1426
  sendJson(res, 500, { error: `Failed to update widget: ${err.message}` })
@@ -1417,13 +1568,13 @@ export function createCanvasHandler(ctx) {
1417
1568
  meta: meta && typeof meta === 'object' ? { ...meta } : {},
1418
1569
  }
1419
1570
 
1420
- appendEvent(filePath, {
1571
+ const eventId = appendEvent(filePath, {
1421
1572
  event: 'connector_added',
1422
1573
  timestamp: new Date().toISOString(),
1423
1574
  connector,
1424
1575
  })
1425
1576
 
1426
- sendJson(res, 201, { success: true, connector })
1577
+ sendJson(res, 201, { success: true, connector, eventId })
1427
1578
  pushCanvasUpdate(name, filePath, __viteWs)
1428
1579
  } catch (err) {
1429
1580
  sendJson(res, 500, { error: `Failed to add connector: ${err.message}` })
@@ -1469,14 +1620,28 @@ export function createCanvasHandler(ctx) {
1469
1620
  if (startAnchor) updates.startAnchor = startAnchor
1470
1621
  if (endAnchor) updates.endAnchor = endAnchor
1471
1622
 
1472
- appendEvent(filePath, {
1623
+ // Capture the previous values for the keys we're changing so undo
1624
+ // can restore them precisely.
1625
+ const prevUpdates = {}
1626
+ if (meta) {
1627
+ const prevMeta = {}
1628
+ for (const key of Object.keys(meta)) {
1629
+ prevMeta[key] = connector.meta ? connector.meta[key] : undefined
1630
+ }
1631
+ prevUpdates.meta = prevMeta
1632
+ }
1633
+ if (startAnchor) prevUpdates.startAnchor = connector.start?.anchor
1634
+ if (endAnchor) prevUpdates.endAnchor = connector.end?.anchor
1635
+
1636
+ const eventId = appendEvent(filePath, {
1473
1637
  event: 'connector_updated',
1474
1638
  timestamp: new Date().toISOString(),
1475
1639
  connectorId,
1476
1640
  updates,
1641
+ prevUpdates,
1477
1642
  })
1478
1643
 
1479
- sendJson(res, 200, { success: true })
1644
+ sendJson(res, 200, { success: true, eventId })
1480
1645
  pushCanvasUpdate(name, filePath, __viteWs)
1481
1646
 
1482
1647
  // Inject messaging skill into both terminals when mode changes
@@ -1562,8 +1727,8 @@ export function createCanvasHandler(ctx) {
1562
1727
 
1563
1728
  try {
1564
1729
  const data = readCanvas(filePath)
1565
- const exists = (data.connectors || []).some((c) => c.id === connectorId)
1566
- if (!exists) {
1730
+ const existing = (data.connectors || []).find((c) => c.id === connectorId)
1731
+ if (!existing) {
1567
1732
  sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
1568
1733
  return
1569
1734
  }
@@ -1576,14 +1741,17 @@ export function createCanvasHandler(ctx) {
1576
1741
  return out
1577
1742
  })
1578
1743
 
1579
- appendEvent(filePath, {
1744
+ const eventId = appendEvent(filePath, {
1580
1745
  event: 'connector_waypoints_set',
1581
1746
  timestamp: new Date().toISOString(),
1582
1747
  connectorId,
1583
1748
  waypoints: normalized,
1749
+ // null prevWaypoints means "this connector had no manual routing
1750
+ // before"; undo emits connector_waypoints_cleared in that case.
1751
+ prevWaypoints: Array.isArray(existing.waypoints) ? existing.waypoints : null,
1584
1752
  })
1585
1753
 
1586
- sendJson(res, 200, { success: true, waypoints: normalized })
1754
+ sendJson(res, 200, { success: true, waypoints: normalized, eventId })
1587
1755
  pushCanvasUpdate(name, filePath, __viteWs)
1588
1756
  } catch (err) {
1589
1757
  sendJson(res, 500, { error: `Failed to set waypoints: ${err.message}` })
@@ -1608,19 +1776,20 @@ export function createCanvasHandler(ctx) {
1608
1776
 
1609
1777
  try {
1610
1778
  const data = readCanvas(filePath)
1611
- const exists = (data.connectors || []).some((c) => c.id === connectorId)
1612
- if (!exists) {
1779
+ const existing = (data.connectors || []).find((c) => c.id === connectorId)
1780
+ if (!existing) {
1613
1781
  sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
1614
1782
  return
1615
1783
  }
1616
1784
 
1617
- appendEvent(filePath, {
1785
+ const eventId = appendEvent(filePath, {
1618
1786
  event: 'connector_waypoints_cleared',
1619
1787
  timestamp: new Date().toISOString(),
1620
1788
  connectorId,
1789
+ prevWaypoints: Array.isArray(existing.waypoints) ? existing.waypoints : null,
1621
1790
  })
1622
1791
 
1623
- sendJson(res, 200, { success: true })
1792
+ sendJson(res, 200, { success: true, eventId })
1624
1793
  pushCanvasUpdate(name, filePath, __viteWs)
1625
1794
  } catch (err) {
1626
1795
  sendJson(res, 500, { error: `Failed to clear waypoints: ${err.message}` })
@@ -1645,19 +1814,22 @@ export function createCanvasHandler(ctx) {
1645
1814
 
1646
1815
  try {
1647
1816
  const data = readCanvas(filePath)
1648
- const exists = (data.connectors || []).some((c) => c.id === connectorId)
1649
- if (!exists) {
1817
+ const connector = (data.connectors || []).find((c) => c.id === connectorId)
1818
+ if (!connector) {
1650
1819
  sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
1651
1820
  return
1652
1821
  }
1653
1822
 
1654
- appendEvent(filePath, {
1823
+ const eventId = appendEvent(filePath, {
1655
1824
  event: 'connector_removed',
1656
1825
  timestamp: new Date().toISOString(),
1657
1826
  connectorId,
1827
+ // Carry the full removed connector so undo can re-add it without
1828
+ // walking history. Symmetric to connector_added.connector.
1829
+ connector,
1658
1830
  })
1659
1831
 
1660
- sendJson(res, 200, { success: true, removed: 1 })
1832
+ sendJson(res, 200, { success: true, removed: 1, eventId })
1661
1833
  pushCanvasUpdate(name, filePath, __viteWs)
1662
1834
  } catch (err) {
1663
1835
  sendJson(res, 500, { error: `Failed to remove connector: ${err.message}` })
@@ -1901,14 +2073,14 @@ export function createCanvasHandler(ctx) {
1901
2073
 
1902
2074
  const widget = stampBounds({ id: widgetId, type, position, props })
1903
2075
 
1904
- appendEvent(filePath, { event: 'widget_added', timestamp: ts, widget })
2076
+ const eventId = appendEvent(filePath, { event: 'widget_added', timestamp: ts, widget })
1905
2077
 
1906
2078
  widgetIds.add(widgetId)
1907
2079
  widgetMap.set(widgetId, widget)
1908
2080
  refs[String(i)] = widgetId
1909
2081
  if (ref) refs[ref] = widgetId
1910
2082
 
1911
- const result = { index: i, op: 'create-widget', ref: ref || undefined, widgetId, widget }
2083
+ const result = { index: i, op: 'create-widget', ref: ref || undefined, widgetId, widget, eventId }
1912
2084
  if (hotProbe.ready) result.hotSession = { id: null, tmuxName: null, webglReady: hotProbe.webglReady }
1913
2085
  results.push(result)
1914
2086
  break
@@ -1921,12 +2093,16 @@ export function createCanvasHandler(ctx) {
1921
2093
  if (!props) throw new Error('props is required')
1922
2094
  if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
1923
2095
 
1924
- appendEvent(filePath, { event: 'widget_updated', timestamp: ts, widgetId, props })
1925
-
1926
2096
  const existing = widgetMap.get(widgetId)
2097
+ const prevProps = {}
2098
+ for (const key of Object.keys(props)) {
2099
+ prevProps[key] = existing?.props ? existing.props[key] : undefined
2100
+ }
2101
+ const eventId = appendEvent(filePath, { event: 'widget_updated', timestamp: ts, widgetId, props, prevProps })
2102
+
1927
2103
  if (existing) existing.props = { ...existing.props, ...props }
1928
2104
 
1929
- results.push({ index: i, op: 'update-widget', widgetId, success: true })
2105
+ results.push({ index: i, op: 'update-widget', widgetId, success: true, eventId })
1930
2106
  break
1931
2107
  }
1932
2108
 
@@ -1939,12 +2115,13 @@ export function createCanvasHandler(ctx) {
1939
2115
 
1940
2116
  const existing = widgetMap.get(widgetId)
1941
2117
  const mergedPosition = { ...(existing?.position || {}), ...position }
2118
+ const prevPosition = existing?.position || { x: 0, y: 0 }
1942
2119
 
1943
- appendEvent(filePath, { event: 'widget_moved', timestamp: ts, widgetId, position: mergedPosition })
2120
+ const eventId = appendEvent(filePath, { event: 'widget_moved', timestamp: ts, widgetId, position: mergedPosition, prevPosition })
1944
2121
 
1945
2122
  if (existing) existing.position = mergedPosition
1946
2123
 
1947
- results.push({ index: i, op: 'move-widget', widgetId, success: true })
2124
+ results.push({ index: i, op: 'move-widget', widgetId, success: true, eventId })
1948
2125
  break
1949
2126
  }
1950
2127
 
@@ -1953,12 +2130,18 @@ export function createCanvasHandler(ctx) {
1953
2130
  if (!widgetId) throw new Error('widgetId is required')
1954
2131
  if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
1955
2132
 
1956
- appendEvent(filePath, { event: 'widget_removed', timestamp: ts, widgetId })
2133
+ const existing = widgetMap.get(widgetId)
2134
+ const eventId = appendEvent(filePath, {
2135
+ event: 'widget_removed',
2136
+ timestamp: ts,
2137
+ widgetId,
2138
+ widget: existing,
2139
+ })
1957
2140
 
1958
2141
  widgetIds.delete(widgetId)
1959
2142
  widgetMap.delete(widgetId)
1960
2143
 
1961
- results.push({ index: i, op: 'delete-widget', widgetId, success: true })
2144
+ results.push({ index: i, op: 'delete-widget', widgetId, success: true, eventId })
1962
2145
  break
1963
2146
  }
1964
2147
 
@@ -1993,14 +2176,14 @@ export function createCanvasHandler(ctx) {
1993
2176
  meta: op.meta && typeof op.meta === 'object' ? { ...op.meta } : {},
1994
2177
  }
1995
2178
 
1996
- appendEvent(filePath, { event: 'connector_added', timestamp: ts, connector })
2179
+ const eventId = appendEvent(filePath, { event: 'connector_added', timestamp: ts, connector })
1997
2180
 
1998
2181
  connectorIds.add(connectorId)
1999
2182
  connectorMap.set(connectorId, connector)
2000
2183
  refs[String(i)] = connectorId
2001
2184
  if (ref) refs[ref] = connectorId
2002
2185
 
2003
- results.push({ index: i, op: 'create-connector', ref: ref || undefined, connectorId, success: true })
2186
+ results.push({ index: i, op: 'create-connector', ref: ref || undefined, connectorId, success: true, eventId })
2004
2187
  break
2005
2188
  }
2006
2189
 
@@ -2009,11 +2192,17 @@ export function createCanvasHandler(ctx) {
2009
2192
  if (!connectorId) throw new Error('connectorId is required')
2010
2193
  if (!connectorIds.has(connectorId)) throw new Error(`Connector "${connectorId}" not found`)
2011
2194
 
2012
- appendEvent(filePath, { event: 'connector_removed', timestamp: ts, connectorId })
2195
+ const existing = connectorMap.get(connectorId)
2196
+ const eventId = appendEvent(filePath, {
2197
+ event: 'connector_removed',
2198
+ timestamp: ts,
2199
+ connectorId,
2200
+ connector: existing,
2201
+ })
2013
2202
  connectorIds.delete(connectorId)
2014
2203
  connectorMap.delete(connectorId)
2015
2204
 
2016
- results.push({ index: i, op: 'delete-connector', connectorId, success: true })
2205
+ results.push({ index: i, op: 'delete-connector', connectorId, success: true, eventId })
2017
2206
  break
2018
2207
  }
2019
2208
 
@@ -2024,12 +2213,22 @@ export function createCanvasHandler(ctx) {
2024
2213
  if (!meta) throw new Error('meta is required')
2025
2214
  if (!connectorIds.has(connectorId)) throw new Error(`Connector "${connectorId}" not found`)
2026
2215
 
2027
- appendEvent(filePath, { event: 'connector_updated', timestamp: ts, connectorId, updates: { meta } })
2028
-
2029
2216
  const existing = connectorMap.get(connectorId)
2217
+ const prevMeta = {}
2218
+ for (const key of Object.keys(meta)) {
2219
+ prevMeta[key] = existing?.meta ? existing.meta[key] : undefined
2220
+ }
2221
+ const eventId = appendEvent(filePath, {
2222
+ event: 'connector_updated',
2223
+ timestamp: ts,
2224
+ connectorId,
2225
+ updates: { meta },
2226
+ prevUpdates: { meta: prevMeta },
2227
+ })
2228
+
2030
2229
  if (existing) existing.meta = { ...(existing.meta || {}), ...meta }
2031
2230
 
2032
- results.push({ index: i, op: 'update-connector', connectorId, success: true })
2231
+ results.push({ index: i, op: 'update-connector', connectorId, success: true, eventId })
2033
2232
  break
2034
2233
  }
2035
2234