План рефакторинга Browser Evaluate CDP

Контекст

act:evaluate выполняет предоставленный пользователем JavaScript на странице. Сегодня он работает через Playwright (page.evaluate или locator.evaluate). Playwright сериализует команды CDP для каждой страницы, поэтому зависший или долго выполняющийся evaluate может заблокировать очередь команд страницы и сделать каждое последующее действие на этой вкладке выглядящим "зависшим".

PR #13498 добавляет прагматичную защитную сеть (ограниченный evaluate, распространение прерывания и best-effort восстановление). Этот документ описывает более крупный рефакторинг, который делает act:evaluate изначально изолированным от Playwright, чтобы зависший evaluate не мог заклинить нормальные операции Playwright.

Цели

  • act:evaluate не может навсегда заблокировать последующие действия браузера на той же вкладке.
  • Таймауты - единственный источник истины end-to-end, чтобы вызывающая сторона могла полагаться на бюджет.
  • Прерывание и таймаут обрабатываются одинаково для HTTP и in-process диспетчеризации.
  • Таргетинг элементов для evaluate поддерживается без переключения всего с Playwright.
  • Поддержка обратной совместимости для существующих вызывающих и полезных нагрузок.

Не-цели

  • Замена всех действий браузера (click, type, wait и т.д.) реализациями CDP.
  • Удаление существующей защитной сети, введенной в PR #13498 (она остается полезным откатом).
  • Введение новых небезопасных возможностей за пределами существующего шлюза browser.evaluateEnabled.
  • Добавление изоляции процесса (рабочий процесс/поток) для evaluate. Если мы все еще видим трудно восстанавливаемые зависшие состояния после этого рефакторинга, это идея для продолжения.

Текущая архитектура (Почему она зависает)

На высоком уровне:

  • Вызывающие отправляют act:evaluate в сервис управления браузером.
  • Обработчик маршрута вызывает Playwright для выполнения JavaScript.
  • Playwright сериализует команды страницы, поэтому evaluate, который никогда не заканчивается, блокирует очередь.
  • Зависшая очередь означает, что последующие операции click/type/wait на вкладке могут показаться зависшими.

Предлагаемая архитектура

1. Распространение дедлайна

Введите единую концепцию бюджета и выводите все из нее:

  • Вызывающая сторона устанавливает timeoutMs (или дедлайн в будущем).
  • Внешний таймаут запроса, логика обработчика маршрута и бюджет выполнения внутри страницы все используют один и тот же бюджет, с небольшим запасом, где необходимо, для накладных расходов сериализации.
  • Прерывание распространяется как AbortSignal везде, чтобы отмена была последовательной.

Направление реализации:

  • Добавить небольшой помощник (например, createBudget({ timeoutMs, signal })), который возвращает:
    • signal: связанный AbortSignal
    • deadlineAtMs: абсолютный дедлайн
    • remainingMs(): оставшийся бюджет для дочерних операций
  • Использовать этот помощник в:
    • src/browser/client-fetch.ts (HTTP и in-process диспетчеризация)
    • src/node-host/runner.ts (путь прокси)
    • реализации действий браузера (Playwright и CDP)

2. Отдельный движок Evaluate (путь CDP)

Добавить реализацию evaluate на основе CDP, которая не разделяет очередь команд Playwright для каждой страницы. Ключевое свойство в том, что транспорт evaluate - это отдельное WebSocket соединение и отдельная CDP сессия, прикрепленная к цели.

Направление реализации:

  • Новый модуль, например src/browser/cdp-evaluate.ts, который:
    • Подключается к настроенному эндпоинту CDP (сокет уровня браузера).
    • Использует Target.attachToTarget({ targetId, flatten: true }) для получения sessionId.
    • Выполняет либо:
      • Runtime.evaluate для evaluate уровня страницы, или
      • DOM.resolveNode плюс Runtime.callFunctionOn для evaluate элемента.
    • При таймауте или прерывании:
      • Отправляет Runtime.terminateExecution best-effort для сессии.
      • Закрывает WebSocket и возвращает четкую ошибку.

Примечания:

  • Это все еще выполняет JavaScript на странице, поэтому завершение может иметь побочные эффекты. Выигрыш в том, что это не заклинивает очередь Playwright, и его можно отменить на уровне транспорта путем убийства CDP сессии.

3. История Ref (таргетинг элементов без полной переписки)

Сложная часть - таргетинг элементов. CDP нужен дескриптор DOM или backendDOMNodeId, в то время как сегодня большинство действий браузера используют локаторы Playwright на основе ref из снимков.

Рекомендуемый подход: сохранить существующие ref, но прикрепить опциональный CDP разрешаемый id.

3.1 Расширить информацию сохраненного Ref

Расширить метаданные сохраненного role ref, чтобы опционально включить CDP id:

  • Сегодня: { role, name, nth }
  • Предлагается: { role, name, nth, backendDOMNodeId?: number }

Это сохраняет все существующие действия на основе Playwright работающими и позволяет CDP evaluate принимать то же значение ref, когда доступен backendDOMNodeId.

3.2 Заполнение backendDOMNodeId во время снимка

При создании снимка role:

  1. Сгенерировать существующую карту role ref как сегодня (role, name, nth).
  2. Получить дерево AX через CDP (Accessibility.getFullAXTree) и вычислить параллельную карту (role, name, nth) -> backendDOMNodeId, используя те же правила обработки дубликатов.
  3. Объединить id обратно в сохраненную информацию ref для текущей вкладки.

Если маппинг не удается для ref, оставить backendDOMNodeId неопределенным. Это делает функцию best-effort и безопасной для развертывания.

3.3 Поведение Evaluate с Ref

В act:evaluate:

  • Если ref присутствует и имеет backendDOMNodeId, выполнить evaluate элемента через CDP.
  • Если ref присутствует, но нет backendDOMNodeId, откатиться к пути Playwright (с защитной сетью).

Опциональный escape hatch:

  • Расширить форму запроса для прямого приема backendDOMNodeId для продвинутых вызывающих (и для отладки), сохраняя ref как основной интерфейс.

4. Сохранить путь восстановления последней надежды

Даже с CDP evaluate есть другие способы заклинить вкладку или соединение. Сохранить существующие механизмы восстановления (завершить выполнение + отключить Playwright) как последнее средство для:

  • устаревших вызывающих
  • окружений, где прикрепление CDP заблокировано
  • неожиданных граничных случаев Playwright

План реализации (одна итерация)

Результаты

  • Движок evaluate на основе CDP, который работает вне очереди команд Playwright для каждой страницы.
  • Единый end-to-end бюджет таймаута/прерывания, используемый последовательно вызывающими и обработчиками.
  • Метаданные Ref, которые могут опционально нести backendDOMNodeId для evaluate элемента.
  • act:evaluate предпочитает движок CDP, когда возможно, и откатывается к Playwright, когда нет.
  • Тесты, доказывающие, что зависший evaluate не заклинивает последующие действия.
  • Логи/метрики, делающие сбои и откаты видимыми.

Контрольный список реализации

  1. Добавить общий помощник "budget" для связывания timeoutMs + upstream AbortSignal в:
    • единый AbortSignal
    • абсолютный дедлайн
    • помощник remainingMs() для downstream операций
  2. Обновить все пути вызывающих для использования этого помощника, чтобы timeoutMs означал одно и то же везде:
    • src/browser/client-fetch.ts (HTTP и in-process диспетчеризация)
    • src/node-host/runner.ts (путь прокси узла)
    • CLI обертки, вызывающие /act (добавить --timeout-ms к browser evaluate)
  3. Реализовать src/browser/cdp-evaluate.ts:
    • подключиться к сокету CDP уровня браузера
    • Target.attachToTarget для получения sessionId
    • выполнить Runtime.evaluate для evaluate страницы
    • выполнить DOM.resolveNode + Runtime.callFunctionOn для evaluate элемента
    • при таймауте/прерывании: best-effort Runtime.terminateExecution, затем закрыть сокет
  4. Расширить метаданные сохраненного role ref для опционального включения backendDOMNodeId:
    • сохранить существующее поведение { role, name, nth } для действий Playwright
    • добавить backendDOMNodeId?: number для таргетинга элементов CDP
  5. Заполнить backendDOMNodeId во время создания снимка (best-effort):
    • получить дерево AX через CDP (Accessibility.getFullAXTree)
    • вычислить (role, name, nth) -> backendDOMNodeId и объединить в сохраненную карту ref
    • если маппинг неоднозначен или отсутствует, оставить id неопределенным
  6. Обновить маршрутизацию act:evaluate:
    • если нет ref: всегда использовать CDP evaluate
    • если ref разрешается в backendDOMNodeId: использовать CDP evaluate элемента
    • иначе: откатиться к Playwright evaluate (все еще ограниченный и прерываемый)
  7. Сохранить существующий путь восстановления "последней надежды" как откат, не как путь по умолчанию.
  8. Добавить тесты:
    • зависший evaluate таймаутит в пределах бюджета, и следующий click/type успешен
    • прерывание отменяет evaluate (отключение клиента или таймаут) и разблокирует последующие действия
    • сбои маппинга чисто откатываются к Playwright
  9. Добавить наблюдаемость:
    • счетчики длительности evaluate и таймаута
    • использование terminateExecution
    • частота отката (CDP -> Playwright) и причины

Критерии приемки

  • Намеренно зависший act:evaluate возвращается в пределах бюджета вызывающей стороны и не заклинивает вкладку для последующих действий.
  • timeoutMs ведет себя последовательно в CLI, инструменте агента, прокси узла и вызовах in-process.
  • Если ref может быть отображен в backendDOMNodeId, evaluate элемента использует CDP; иначе путь отката все еще ограничен и восстанавливаем.

План тестирования

  • Юнит-тесты:
    • Логика сопоставления (role, name, nth) между role ref и узлами дерева AX.
    • Поведение помощника Budget (запас, математика оставшегося времени).
  • Интеграционные тесты:
    • Таймаут CDP evaluate возвращается в пределах бюджета и не блокирует следующее действие.
    • Прерывание отменяет evaluate и запускает завершение best-effort.
  • Контрактные тесты:
    • Убедиться, что BrowserActRequest и BrowserActResponse остаются совместимыми.

Риски и митигации

  • Маппинг несовершенен:
    • Митигация: маппинг best-effort, откат к Playwright evaluate и добавить инструменты отладки.
  • Runtime.terminateExecution имеет побочные эффекты:
    • Митигация: использовать только при таймауте/прерывании и документировать поведение в ошибках.
  • Дополнительные накладные расходы:
    • Митигация: получать дерево AX только когда запрашиваются снимки, кэшировать для каждой цели и держать CDP сессию короткоживущей.
  • Ограничения relay расширения:
    • Митигация: использовать API прикрепления уровня браузера, когда сокеты для каждой страницы недоступны, и сохранить текущий путь Playwright как откат.

Открытые вопросы

  • Должен ли новый движок быть настраиваемым как playwright, cdp или auto?
  • Хотим ли мы предоставить новый формат "nodeRef" для продвинутых пользователей, или сохранить только ref?
  • Как должны участвовать снимки фреймов и снимки с областью селектора в маппинге AX?