BxDolAIToolMysqlUndo.php 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * @defgroup UnaCore UNA Core
  7. * @{
  8. */
  9. use NeuronAI\Tools\PropertyType;
  10. use NeuronAI\Tools\ToolProperty;
  11. class BxDolAIToolMysqlUndo extends BxDolAITool
  12. {
  13. public function __construct()
  14. {
  15. parent::__construct(
  16. 'mysql_undo',
  17. 'Roll back mysql_write changes from sys_agents_sql_log. scope=last undoes the latest snapshot in this chat; scope=session undoes all remaining snapshots in reverse order. INSERT is undone by deleting that PK internally. UPDATE restores the before snapshot. Do not use mysql_write DELETE.',
  18. );
  19. }
  20. protected function properties(): array
  21. {
  22. return [
  23. new ToolProperty(
  24. name: 'scope',
  25. type: PropertyType::STRING,
  26. description: 'last = one newest change in this chat. session = all not-yet-undone changes in this chat, newest first.',
  27. required: true
  28. ),
  29. new ToolProperty(
  30. name: 'log_id',
  31. type: PropertyType::NUMBER,
  32. description: 'Optional sys_agents_sql_log.id. If set, undo that snapshot only (must belong to this chat).',
  33. required: false
  34. ),
  35. ];
  36. }
  37. public function __invoke(string $scope = 'last', $log_id = 0): array
  38. {
  39. $oDb = BxDolDb::getInstance();
  40. $sScope = strtolower(trim($scope));
  41. if (!in_array($sScope, ['last', 'session'], true))
  42. throw new Exception('scope must be last or session.');
  43. $iLogId = (int)$log_id;
  44. $aCtx = BxDolAIToolMysqlWrite::currentLogContext();
  45. $sThread = $aCtx['thread_id'];
  46. $iProfile = (int)$aCtx['profile_id'];
  47. $aRows = $this->_pickRows($oDb, $sScope, $iLogId, $sThread, $iProfile);
  48. if (!$aRows)
  49. throw new Exception('Nothing to undo in this chat.');
  50. $aDone = [];
  51. foreach ($aRows as $aRow) {
  52. $aDone[] = $this->_undoRow($oDb, $aRow);
  53. }
  54. return [
  55. 'ok' => 1,
  56. 'scope' => $iLogId > 0 ? 'log_id' : $sScope,
  57. 'undone' => count($aDone),
  58. 'items' => $aDone,
  59. ];
  60. }
  61. protected function _pickRows(BxDolDb $oDb, string $sScope, int $iLogId, string $sThread, int $iProfile): array
  62. {
  63. if ($iLogId > 0) {
  64. $aRow = $oDb->getRow("SELECT * FROM `sys_agents_sql_log` WHERE `id` = :id LIMIT 1", ['id' => $iLogId]);
  65. if (!$aRow)
  66. throw new Exception("log_id {$iLogId} not found.");
  67. if ((int)$aRow['undone'] > 0)
  68. throw new Exception("log_id {$iLogId} is already undone.");
  69. if ($sThread !== '') {
  70. if ((string)$aRow['thread_id'] !== $sThread)
  71. throw new Exception("log_id {$iLogId} belongs to another chat.");
  72. }
  73. else if ($iProfile <= 0 || (int)$aRow['profile_id'] !== $iProfile) {
  74. throw new Exception("log_id {$iLogId} belongs to another chat.");
  75. }
  76. return [$aRow];
  77. }
  78. if ($sThread !== '') {
  79. $sSql = "SELECT * FROM `sys_agents_sql_log` WHERE `thread_id` = :t AND `undone` = 0 ORDER BY `id` DESC";
  80. $aBind = ['t' => $sThread];
  81. }
  82. else if ($iProfile > 0) {
  83. $sSql = "SELECT * FROM `sys_agents_sql_log` WHERE `profile_id` = :p AND `undone` = 0 AND `added` > :since ORDER BY `id` DESC";
  84. $aBind = ['p' => $iProfile, 'since' => time() - 7200];
  85. }
  86. else {
  87. return [];
  88. }
  89. if ($sScope === 'last')
  90. $sSql .= " LIMIT 1";
  91. else
  92. $sSql .= " LIMIT 40";
  93. $aRows = $oDb->getAll($sSql, $aBind);
  94. return is_array($aRows) ? $aRows : [];
  95. }
  96. protected function _undoRow(BxDolDb $oDb, array $aRow): array
  97. {
  98. $sOp = strtolower((string)$aRow['op']);
  99. $sTable = str_replace('`', '', (string)$aRow['table_name']);
  100. $sPk = str_replace('`', '', (string)$aRow['pk_name']);
  101. $sPkValue = (string)$aRow['pk_value'];
  102. if ($sTable === '' || $sPk === '' || $sPkValue === '')
  103. throw new Exception("log_id {$aRow['id']} has incomplete snapshot.");
  104. if (!$oDb->isValidFieldName($sTable) || !$oDb->isValidFieldName($sPk))
  105. throw new Exception("log_id {$aRow['id']} has invalid table/pk.");
  106. if ($sOp === 'insert')
  107. $this->_undoInsert($oDb, $sTable, $sPk, $sPkValue);
  108. else if ($sOp === 'update')
  109. $this->_undoUpdate($oDb, $aRow, $sTable, $sPk, $sPkValue);
  110. else
  111. throw new Exception("Cannot undo op {$sOp}.");
  112. $oDb->query("UPDATE `sys_agents_sql_log` SET `undone` = :ts WHERE `id` = :id", [
  113. 'ts' => time(),
  114. 'id' => (int)$aRow['id'],
  115. ]);
  116. return [
  117. 'log_id' => (int)$aRow['id'],
  118. 'op' => $sOp,
  119. 'table' => $sTable,
  120. 'pk' => $sPk,
  121. 'pk_value' => $sPkValue,
  122. ];
  123. }
  124. protected function _undoInsert(BxDolDb $oDb, string $sTable, string $sPk, string $sPkValue): void
  125. {
  126. $aBind = ['v' => preg_match('/^\d+$/', $sPkValue) ? (int)$sPkValue : $sPkValue];
  127. $oDb->query("DELETE FROM `{$sTable}` WHERE `{$sPk}` = :v LIMIT 1", $aBind);
  128. }
  129. protected function _undoUpdate(BxDolDb $oDb, array $aRow, string $sTable, string $sPk, string $sPkValue): void
  130. {
  131. $aBefore = json_decode((string)$aRow['before_json'], true);
  132. if (!is_array($aBefore) || !$aBefore)
  133. throw new Exception("log_id {$aRow['id']} has no before snapshot.");
  134. $aCurrent = $this->_fetchRow($oDb, $sTable, $sPk, $sPkValue);
  135. if (!$aCurrent)
  136. throw new Exception("Row {$sTable}.{$sPk}={$sPkValue} is gone; cannot restore.");
  137. $aAfter = json_decode((string)($aRow['after_json'] ?? ''), true);
  138. if (is_array($aAfter) && $aAfter && !$this->_rowsClose($aCurrent, $aAfter) && !$this->_rowsClose($aCurrent, $aBefore))
  139. throw new Exception("Row {$sTable}.{$sPk}={$sPkValue} changed after the write; refuse undo.");
  140. $aSet = [];
  141. foreach ($aBefore as $sField => $mixedVal) {
  142. if (!$oDb->isValidFieldName($sField))
  143. throw new Exception("Invalid field {$sField} in snapshot.");
  144. if ($sField === $sPk)
  145. continue;
  146. $aSet[$sField] = $mixedVal;
  147. }
  148. if (!$aSet)
  149. throw new Exception("log_id {$aRow['id']} snapshot has nothing to restore.");
  150. $sSet = '';
  151. foreach ($aSet as $sField => $mixedVal) {
  152. $sSet .= ($sSet === '' ? '' : ', ') . '`' . $sField . '` = ' . ($mixedVal === null ? 'NULL' : $oDb->escape($mixedVal));
  153. }
  154. $sPkSql = preg_match('/^\d+$/', $sPkValue) ? (int)$sPkValue : $oDb->escape($sPkValue);
  155. $oDb->query("UPDATE `{$sTable}` SET {$sSet} WHERE `{$sPk}` = {$sPkSql} LIMIT 1");
  156. }
  157. protected function _fetchRow(BxDolDb $oDb, string $sTable, string $sPk, string $sPkValue): array|false
  158. {
  159. $aBind = ['v' => preg_match('/^\d+$/', $sPkValue) ? (int)$sPkValue : $sPkValue];
  160. return $oDb->getRow("SELECT * FROM `{$sTable}` WHERE `{$sPk}` = :v LIMIT 1", $aBind);
  161. }
  162. protected function _rowsClose(array $aA, array $aB): bool
  163. {
  164. foreach ($aB as $sKey => $mixedB) {
  165. if (!array_key_exists($sKey, $aA))
  166. return false;
  167. if ((string)($aA[$sKey] ?? '') !== (string)($mixedB ?? ''))
  168. return false;
  169. }
  170. return true;
  171. }
  172. }