@portel/photon 1.11.0 → 1.12.0

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.
Files changed (56) hide show
  1. package/README.md +81 -72
  2. package/dist/auto-ui/beam/photon-management.d.ts.map +1 -1
  3. package/dist/auto-ui/beam/photon-management.js +5 -0
  4. package/dist/auto-ui/beam/photon-management.js.map +1 -1
  5. package/dist/auto-ui/beam/routes/api-browse.d.ts +1 -2
  6. package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
  7. package/dist/auto-ui/beam/routes/api-browse.js +140 -191
  8. package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
  9. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
  10. package/dist/auto-ui/beam/routes/api-config.js +44 -1
  11. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  12. package/dist/auto-ui/beam.d.ts.map +1 -1
  13. package/dist/auto-ui/beam.js +874 -20
  14. package/dist/auto-ui/beam.js.map +1 -1
  15. package/dist/auto-ui/frontend/index.html +83 -60
  16. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  17. package/dist/auto-ui/streamable-http-transport.js +16 -2
  18. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  19. package/dist/auto-ui/types.d.ts +1 -1
  20. package/dist/auto-ui/types.d.ts.map +1 -1
  21. package/dist/auto-ui/types.js.map +1 -1
  22. package/dist/beam.bundle.js +2794 -322
  23. package/dist/beam.bundle.js.map +4 -4
  24. package/dist/cli/commands/package-app.d.ts.map +1 -1
  25. package/dist/cli/commands/package-app.js +116 -35
  26. package/dist/cli/commands/package-app.js.map +1 -1
  27. package/dist/context-store.d.ts +5 -0
  28. package/dist/context-store.d.ts.map +1 -1
  29. package/dist/context-store.js +9 -0
  30. package/dist/context-store.js.map +1 -1
  31. package/dist/daemon/server.js +303 -6
  32. package/dist/daemon/server.js.map +1 -1
  33. package/dist/loader.d.ts +4 -0
  34. package/dist/loader.d.ts.map +1 -1
  35. package/dist/loader.js +186 -1
  36. package/dist/loader.js.map +1 -1
  37. package/dist/photon-cli-runner.d.ts.map +1 -1
  38. package/dist/photon-cli-runner.js +21 -1
  39. package/dist/photon-cli-runner.js.map +1 -1
  40. package/dist/photon-doc-extractor.d.ts +6 -0
  41. package/dist/photon-doc-extractor.d.ts.map +1 -1
  42. package/dist/photon-doc-extractor.js +22 -0
  43. package/dist/photon-doc-extractor.js.map +1 -1
  44. package/dist/photons/tunnel.photon.d.ts +5 -9
  45. package/dist/photons/tunnel.photon.d.ts.map +1 -1
  46. package/dist/photons/tunnel.photon.js +36 -96
  47. package/dist/photons/tunnel.photon.js.map +1 -1
  48. package/dist/photons/tunnel.photon.ts +40 -112
  49. package/dist/server.d.ts.map +1 -1
  50. package/dist/server.js +27 -2
  51. package/dist/server.js.map +1 -1
  52. package/dist/test-runner.d.ts +13 -1
  53. package/dist/test-runner.d.ts.map +1 -1
  54. package/dist/test-runner.js +529 -122
  55. package/dist/test-runner.js.map +1 -1
  56. package/package.json +22 -6
@@ -21,6 +21,9 @@ import { createLogger } from '../shared/logger.js';
21
21
  import { getErrorMessage } from '../shared/error-handler.js';
22
22
  import { timingSafeEqual, readBody, SimpleRateLimiter } from '../shared/security.js';
23
23
  import { audit } from '../shared/audit.js';
24
+ import fastJsonPatch from 'fast-json-patch';
25
+ // eslint-disable-next-line @typescript-eslint/unbound-method
26
+ const jsonPatchCompare = fastJsonPatch.compare;
24
27
  // Command line args: socketPath (global daemon only needs socket path)
25
28
  const socketPath = process.argv[2];
26
29
  const logger = createLogger({
@@ -592,6 +595,10 @@ async function getOrCreateSessionManager(photonName, photonPath, workingDir) {
592
595
  manager = new SessionManager(pathToUse, photonName, idleTimeout, logger.child({ scope: photonName }), workingDir);
593
596
  sessionManagers.set(key, manager);
594
597
  photonPaths.set(key, pathToUse);
598
+ // Also store under bare photonName so snapshotState/persistInstanceState can find it
599
+ if (!photonPaths.has(photonName)) {
600
+ photonPaths.set(photonName, pathToUse);
601
+ }
595
602
  if (workingDir) {
596
603
  workingDirs.set(key, workingDir);
597
604
  watchWorkingDir(workingDir);
@@ -1034,6 +1041,68 @@ async function handleRequest(request, socket) {
1034
1041
  data: { instances, current, autoInstance, metadata },
1035
1042
  };
1036
1043
  }
1044
+ // ── Undo / Redo ──────────────────────────────────────────────
1045
+ if (request.method === '_undo' || request.method === '_redo') {
1046
+ const isUndo = request.method === '_undo';
1047
+ const instanceLabel = session.instanceName || 'default';
1048
+ const key = undoKey(photonName, instanceLabel);
1049
+ const source = isUndo ? undoPast.get(key) : undoFuture.get(key);
1050
+ const dest = isUndo ? undoFuture : undoPast;
1051
+ if (!source || source.length === 0) {
1052
+ return {
1053
+ type: 'result',
1054
+ id: request.id,
1055
+ success: true,
1056
+ data: { error: `Nothing to ${isUndo ? 'undo' : 'redo'}` },
1057
+ };
1058
+ }
1059
+ const entry = source.pop();
1060
+ const opsToApply = isUndo ? entry.inversePatch : entry.patch;
1061
+ // Apply patch to live instance
1062
+ await applyPatchToInstance(session.instance, photonName, opsToApply);
1063
+ // Persist the new state
1064
+ await persistInstanceState(session.instance, photonName, session.instanceName, request.workingDir);
1065
+ // Push to opposite stack
1066
+ let destStack = dest.get(key);
1067
+ if (!destStack) {
1068
+ destStack = [];
1069
+ dest.set(key, destStack);
1070
+ }
1071
+ destStack.push(entry);
1072
+ // Generate the resulting patch for this undo/redo action
1073
+ const actionPatch = isUndo ? entry.inversePatch : entry.patch;
1074
+ const actionInverse = isUndo ? entry.patch : entry.inversePatch;
1075
+ const stateChangedPayload = {
1076
+ event: 'state-changed',
1077
+ method: request.method,
1078
+ params: { undoneMethod: entry.method },
1079
+ instance: instanceLabel,
1080
+ data: { method: entry.method, action: isUndo ? 'undo' : 'redo' },
1081
+ ...(actionPatch.length > 0 && { patch: actionPatch }),
1082
+ ...(actionInverse.length > 0 && { inversePatch: actionInverse }),
1083
+ uri: `photon://${photonName}/${instanceLabel}`,
1084
+ };
1085
+ // Publish to instance-specific channel for multi-instance isolation
1086
+ publishToChannel(`${photonName}:${instanceLabel}:state-changed`, stateChangedPayload, socket);
1087
+ // Log undo/redo to event log
1088
+ await appendEventLog(photonName, instanceLabel, {
1089
+ method: request.method,
1090
+ params: { undoneMethod: entry.method },
1091
+ instance: instanceLabel,
1092
+ patch: actionPatch,
1093
+ inversePatch: actionInverse,
1094
+ }, request.workingDir);
1095
+ return {
1096
+ type: 'result',
1097
+ id: request.id,
1098
+ success: true,
1099
+ data: {
1100
+ action: isUndo ? 'undo' : 'redo',
1101
+ method: entry.method,
1102
+ message: `${isUndo ? 'Undid' : 'Redid'} "${entry.method}"`,
1103
+ },
1104
+ };
1105
+ }
1037
1106
  // ─────────────────────────────────────────────────────────────
1038
1107
  // ── Instance-scoped execution (one-shot, no session mutation) ──
1039
1108
  // If targetInstance is set, load that instance, execute on it,
@@ -1053,6 +1122,8 @@ async function handleRequest(request, socket) {
1053
1122
  publishToChannel(emit.channel, emit, socket);
1054
1123
  }
1055
1124
  };
1125
+ // Snapshot state before execution for JSON Patch diffing
1126
+ const preSnapshot = await snapshotState(targetInst, photonName);
1056
1127
  const startTime = Date.now();
1057
1128
  const result = await sessionManager.loader.executeTool(targetInst, request.method, request.args || {}, { outputHandler });
1058
1129
  const durationMs = Date.now() - startTime;
@@ -1076,12 +1147,41 @@ async function handleRequest(request, socket) {
1076
1147
  durationMs,
1077
1148
  });
1078
1149
  await persistInstanceState(targetInst, photonName, targetName, request.workingDir);
1079
- publishToChannel(`${photonName}:state-changed`, {
1150
+ // Generate JSON Patch (RFC 6902) forward + inverse ops
1151
+ const postSnapshot = preSnapshot ? await snapshotState(targetInst, photonName) : null;
1152
+ const patch = preSnapshot && postSnapshot ? jsonPatchCompare(preSnapshot, postSnapshot) : [];
1153
+ const inversePatch = preSnapshot && postSnapshot ? jsonPatchCompare(postSnapshot, preSnapshot) : [];
1154
+ const instanceLabel = targetName || 'default';
1155
+ const stateChangedPayload = {
1080
1156
  event: 'state-changed',
1081
1157
  method: request.method,
1082
- instance: targetName,
1158
+ params: request.args || {},
1159
+ instance: instanceLabel,
1083
1160
  data: result,
1084
- }, socket);
1161
+ ...(patch.length > 0 && { patch }),
1162
+ ...(inversePatch.length > 0 && { inversePatch }),
1163
+ uri: `photon://${photonName}/${instanceLabel}`,
1164
+ };
1165
+ // Publish to instance-specific channel for multi-instance isolation
1166
+ publishToChannel(`${photonName}:${instanceLabel}:state-changed`, stateChangedPayload, socket);
1167
+ // Append to event log
1168
+ if (patch.length > 0) {
1169
+ await appendEventLog(photonName, instanceLabel, {
1170
+ method: request.method,
1171
+ params: request.args || {},
1172
+ instance: instanceLabel,
1173
+ patch,
1174
+ inversePatch,
1175
+ }, request.workingDir);
1176
+ }
1177
+ // Track for undo/redo
1178
+ if (patch.length > 0) {
1179
+ pushUndoEntry(photonName, instanceLabel, {
1180
+ method: request.method,
1181
+ patch,
1182
+ inversePatch,
1183
+ });
1184
+ }
1085
1185
  return { type: 'result', id: request.id, success: true, data: result, durationMs };
1086
1186
  }
1087
1187
  logger.info('Executing request', {
@@ -1097,6 +1197,8 @@ async function handleRequest(request, socket) {
1097
1197
  logger.debug('Published to channel', { channel: emit.channel });
1098
1198
  }
1099
1199
  };
1200
+ // Snapshot state before execution for JSON Patch diffing
1201
+ const preSnapshot = await snapshotState(session.instance, photonName);
1100
1202
  const startTime = Date.now();
1101
1203
  const result = await sessionManager.loader.executeTool(session.instance, request.method, request.args || {}, { outputHandler });
1102
1204
  const durationMs = Date.now() - startTime;
@@ -1121,12 +1223,42 @@ async function handleRequest(request, socket) {
1121
1223
  });
1122
1224
  // Persist reactive state after each tool call
1123
1225
  await persistInstanceState(session.instance, photonName, session.instanceName, request.workingDir);
1124
- // Notify subscribers that state may have changed
1125
- publishToChannel(`${photonName}:state-changed`, {
1226
+ // Generate JSON Patch (RFC 6902) forward + inverse ops
1227
+ const postSnapshot = preSnapshot ? await snapshotState(session.instance, photonName) : null;
1228
+ const patch = preSnapshot && postSnapshot ? jsonPatchCompare(preSnapshot, postSnapshot) : [];
1229
+ const inversePatch = preSnapshot && postSnapshot ? jsonPatchCompare(postSnapshot, preSnapshot) : [];
1230
+ const instanceLabel = session.instanceName || 'default';
1231
+ const stateChangedPayload = {
1126
1232
  event: 'state-changed',
1127
1233
  method: request.method,
1234
+ params: request.args || {},
1235
+ instance: instanceLabel,
1128
1236
  data: result,
1129
- }, socket);
1237
+ ...(patch.length > 0 && { patch }),
1238
+ ...(inversePatch.length > 0 && { inversePatch }),
1239
+ uri: `photon://${photonName}/${instanceLabel}`,
1240
+ };
1241
+ // Notify subscribers that state may have changed (instance-specific channel)
1242
+ const targetLabel = session.instanceName || 'default';
1243
+ publishToChannel(`${photonName}:${targetLabel}:state-changed`, stateChangedPayload, socket);
1244
+ // Append to event log
1245
+ if (patch.length > 0) {
1246
+ await appendEventLog(photonName, instanceLabel, {
1247
+ method: request.method,
1248
+ params: request.args || {},
1249
+ instance: instanceLabel,
1250
+ patch,
1251
+ inversePatch,
1252
+ }, request.workingDir);
1253
+ }
1254
+ // Track for undo/redo
1255
+ if (patch.length > 0) {
1256
+ pushUndoEntry(photonName, instanceLabel, {
1257
+ method: request.method,
1258
+ patch,
1259
+ inversePatch,
1260
+ });
1261
+ }
1130
1262
  return { type: 'result', id: request.id, success: true, data: result, durationMs };
1131
1263
  }
1132
1264
  catch (error) {
@@ -1193,6 +1325,34 @@ async function getStateKeys(photonName, photonPath) {
1193
1325
  return [];
1194
1326
  }
1195
1327
  }
1328
+ /**
1329
+ * Capture a JSON-serializable snapshot of a photon instance's state.
1330
+ * Returns null for non-stateful photons (no state keys).
1331
+ * Used for JSON Patch diffing (pre/post execution).
1332
+ */
1333
+ async function snapshotState(instance, photonName) {
1334
+ const photonPath = photonPaths.get(photonName);
1335
+ if (!photonPath)
1336
+ return null;
1337
+ const keys = await getStateKeys(photonName, photonPath);
1338
+ if (keys.length === 0)
1339
+ return null;
1340
+ const target = instance?.instance ?? instance;
1341
+ const snapshot = {};
1342
+ for (const key of keys) {
1343
+ const value = target[key];
1344
+ if (value === undefined)
1345
+ continue;
1346
+ if (value && typeof value === 'object' && value._propertyName) {
1347
+ snapshot[key] = value.toJSON ? value.toJSON() : globalThis.Array.from(value);
1348
+ }
1349
+ else {
1350
+ snapshot[key] = value;
1351
+ }
1352
+ }
1353
+ // Deep-clone to freeze the snapshot (avoid reference sharing with live state)
1354
+ return JSON.parse(JSON.stringify(snapshot));
1355
+ }
1196
1356
  /**
1197
1357
  * Persist reactive collection state to disk after each tool call.
1198
1358
  * Only persists properties identified as 'state' injection type
@@ -1240,6 +1400,143 @@ async function persistInstanceState(instance, photonName, instanceName, workingD
1240
1400
  }
1241
1401
  }
1242
1402
  // ════════════════════════════════════════════════════════════════════════════════
1403
+ // EVENT LOG (JSONL append-only per instance)
1404
+ // ════════════════════════════════════════════════════════════════════════════════
1405
+ /** Monotonically increasing sequence number per photon:instance */
1406
+ const eventLogSeq = new Map();
1407
+ const EVENT_LOG_MAX_SIZE = parseInt(process.env.PHOTON_EVENT_LOG_MAX_SIZE || '', 10) || 10 * 1024 * 1024; // 10MB default
1408
+ /**
1409
+ * Get the event log path for a photon instance.
1410
+ * Co-located with state file: ~/.photon/state/{photon}/{instance}.log
1411
+ */
1412
+ function getInstanceLogPath(photonName, instanceName, baseDir) {
1413
+ const name = instanceName || 'default';
1414
+ const dir = baseDir || getDefaultContext().baseDir;
1415
+ return path.join(dir, 'state', photonName, `${name}.log`);
1416
+ }
1417
+ /**
1418
+ * Append an event entry to the JSONL event log.
1419
+ * Handles log rotation when file exceeds EVENT_LOG_MAX_SIZE.
1420
+ */
1421
+ async function appendEventLog(photonName, instanceName, entry, workingDir) {
1422
+ try {
1423
+ const logPath = getInstanceLogPath(photonName, instanceName, workingDir);
1424
+ const fsPromises = await import('fs/promises');
1425
+ await fsPromises.mkdir(path.dirname(logPath), { recursive: true });
1426
+ // Get next sequence number
1427
+ const seqKey = `${photonName}:${instanceName}`;
1428
+ const seq = (eventLogSeq.get(seqKey) || 0) + 1;
1429
+ eventLogSeq.set(seqKey, seq);
1430
+ const logEntry = {
1431
+ seq,
1432
+ timestamp: new Date().toISOString(),
1433
+ method: entry.method,
1434
+ params: entry.params,
1435
+ instance: entry.instance,
1436
+ patch: entry.patch,
1437
+ inversePatch: entry.inversePatch,
1438
+ };
1439
+ const line = JSON.stringify(logEntry) + '\n';
1440
+ // Check rotation before appending
1441
+ try {
1442
+ const stat = await fsPromises.stat(logPath);
1443
+ if (stat.size >= EVENT_LOG_MAX_SIZE) {
1444
+ const rotatedPath = logPath + '.1';
1445
+ await fsPromises.rename(logPath, rotatedPath).catch(() => { });
1446
+ logger.debug('Rotated event log', { photon: photonName, instance: instanceName });
1447
+ }
1448
+ }
1449
+ catch {
1450
+ // File doesn't exist yet — that's fine
1451
+ }
1452
+ await fsPromises.appendFile(logPath, line);
1453
+ logger.debug('Appended event log', { photon: photonName, instance: instanceName, seq });
1454
+ }
1455
+ catch (error) {
1456
+ logger.error('Failed to append event log', {
1457
+ photon: photonName,
1458
+ error: getErrorMessage(error),
1459
+ });
1460
+ }
1461
+ }
1462
+ /** past stack per photon:instance */
1463
+ const undoPast = new Map();
1464
+ /** future stack per photon:instance */
1465
+ const undoFuture = new Map();
1466
+ const UNDO_STACK_LIMIT = 100;
1467
+ function undoKey(photonName, instance) {
1468
+ return `${photonName}:${instance || 'default'}`;
1469
+ }
1470
+ function pushUndoEntry(photonName, instance, entry) {
1471
+ const key = undoKey(photonName, instance);
1472
+ let past = undoPast.get(key);
1473
+ if (!past) {
1474
+ past = [];
1475
+ undoPast.set(key, past);
1476
+ }
1477
+ past.push(entry);
1478
+ if (past.length > UNDO_STACK_LIMIT)
1479
+ past.shift();
1480
+ // New mutation clears the redo future
1481
+ undoFuture.set(key, []);
1482
+ }
1483
+ /**
1484
+ * Apply a JSON Patch to a live photon instance's state.
1485
+ * Snapshots state as plain JSON, applies patch, then rehydrates instance properties.
1486
+ */
1487
+ async function applyPatchToInstance(instance, photonName, ops) {
1488
+ // Use the already-imported fastJsonPatch module
1489
+ const applyPatch = fastJsonPatch.applyPatch.bind(fastJsonPatch);
1490
+ const photonPath = photonPaths.get(photonName);
1491
+ if (!photonPath)
1492
+ return;
1493
+ const keys = await getStateKeys(photonName, photonPath);
1494
+ if (keys.length === 0)
1495
+ return;
1496
+ // Get current state as plain JSON
1497
+ const snapshot = await snapshotState(instance, photonName);
1498
+ if (!snapshot)
1499
+ return;
1500
+ // Apply patch operations
1501
+ applyPatch(snapshot, ops, true, true);
1502
+ // Rehydrate instance properties from patched snapshot
1503
+ const target = instance?.instance ?? instance;
1504
+ for (const key of keys) {
1505
+ if (!(key in snapshot))
1506
+ continue;
1507
+ const current = target[key];
1508
+ const patched = snapshot[key];
1509
+ if (current && typeof current === 'object' && current._propertyName) {
1510
+ // ReactiveArray — clear and replace contents
1511
+ if (typeof current.splice === 'function') {
1512
+ current.splice(0, current.length, ...patched);
1513
+ }
1514
+ else if (current instanceof Map || (current.clear && current.set)) {
1515
+ current.clear();
1516
+ for (const [k, v] of Object.entries(patched))
1517
+ current.set(k, v);
1518
+ }
1519
+ else if (current instanceof Set || (current.clear && current.add)) {
1520
+ current.clear();
1521
+ for (const v of patched)
1522
+ current.add(v);
1523
+ }
1524
+ }
1525
+ else if (globalThis.Array.isArray(current)) {
1526
+ current.splice(0, current.length, ...patched);
1527
+ }
1528
+ else if (typeof current === 'object' && current !== null) {
1529
+ // Plain object — replace properties
1530
+ for (const k of Object.keys(current))
1531
+ delete current[k];
1532
+ Object.assign(current, patched);
1533
+ }
1534
+ else {
1535
+ target[key] = patched;
1536
+ }
1537
+ }
1538
+ }
1539
+ // ════════════════════════════════════════════════════════════════════════════════
1243
1540
  // FILE WATCHING (Auto Hot-Reload)
1244
1541
  // ════════════════════════════════════════════════════════════════════════════════
1245
1542
  function watchPhotonFile(photonName, photonPath) {