chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
*.onnx filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -0,0 +1,72 @@
|
||||
# name: Build and Release Chrome Extension
|
||||
|
||||
# on:
|
||||
# push:
|
||||
# branches: [ master, develop ]
|
||||
# paths:
|
||||
# - 'app/chrome-extension/**'
|
||||
# pull_request:
|
||||
# branches: [ master ]
|
||||
# paths:
|
||||
# - 'app/chrome-extension/**'
|
||||
# workflow_dispatch:
|
||||
|
||||
# jobs:
|
||||
# build-extension:
|
||||
# runs-on: ubuntu-latest
|
||||
|
||||
# steps:
|
||||
# - name: Checkout code
|
||||
# uses: actions/checkout@v4
|
||||
|
||||
# - name: Setup Node.js
|
||||
# uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: '18'
|
||||
# cache: 'npm'
|
||||
# cache-dependency-path: 'app/chrome-extension/package-lock.json'
|
||||
|
||||
# - name: Install dependencies
|
||||
# run: |
|
||||
# cd app/chrome-extension
|
||||
# npm ci
|
||||
|
||||
# - name: Build extension
|
||||
# run: |
|
||||
# cd app/chrome-extension
|
||||
# npm run build
|
||||
|
||||
# - name: Create zip package
|
||||
# run: |
|
||||
# cd app/chrome-extension
|
||||
# npm run zip
|
||||
|
||||
# - name: Prepare release directory
|
||||
# run: |
|
||||
# mkdir -p releases/chrome-extension/latest
|
||||
# mkdir -p releases/chrome-extension/$(date +%Y%m%d-%H%M%S)
|
||||
|
||||
# - name: Copy release files
|
||||
# run: |
|
||||
# # Copy to latest
|
||||
# cp app/chrome-extension/.output/chrome-mv3-prod.zip releases/chrome-extension/latest/chrome-mcp-server-latest.zip
|
||||
|
||||
# # Copy to timestamped version
|
||||
# TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
# cp app/chrome-extension/.output/chrome-mv3-prod.zip releases/chrome-extension/$TIMESTAMP/chrome-mcp-server-$TIMESTAMP.zip
|
||||
|
||||
# - name: Upload build artifacts
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: chrome-extension-build
|
||||
# path: releases/chrome-extension/
|
||||
# retention-days: 30
|
||||
|
||||
# - name: Commit and push releases (if on main branch)
|
||||
# if: github.ref == 'refs/heads/main'
|
||||
# run: |
|
||||
# git config --local user.email "action@github.com"
|
||||
# git config --local user.name "GitHub Action"
|
||||
# git add releases/
|
||||
# git diff --staged --quiet || git commit -m "Auto-build: Update Chrome extension release [skip ci]"
|
||||
# git push
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.output
|
||||
stats.html
|
||||
stats-*.json
|
||||
.wxt
|
||||
web-ext.config.ts
|
||||
dist
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.onnx
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Prevent npm metadata pollution
|
||||
false/
|
||||
metadata-v1.3/
|
||||
registry.npmmirror.com/
|
||||
registry.npmjs.com/
|
||||
|
||||
other/
|
||||
tools_optimize.md
|
||||
Agents.md
|
||||
CLAUDE.md
|
||||
|
||||
**/*/coverage/*
|
||||
|
||||
.docs/
|
||||
.claude/
|
||||
@@ -0,0 +1 @@
|
||||
npx --no -- commitlint --edit "$1"
|
||||
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -0,0 +1,35 @@
|
||||
# 构建输出目录
|
||||
dist
|
||||
.output
|
||||
.wxt
|
||||
|
||||
# 依赖
|
||||
node_modules
|
||||
|
||||
# 日志
|
||||
logs
|
||||
*.log
|
||||
|
||||
# 缓存
|
||||
.cache
|
||||
.temp
|
||||
|
||||
# 编辑器配置
|
||||
.vscode
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
|
||||
# 系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 打包文件
|
||||
*.zip
|
||||
*.tar.gz
|
||||
|
||||
# 统计文件
|
||||
stats.html
|
||||
stats-*.json
|
||||
|
||||
# 锁文件
|
||||
pnpm-lock.yaml
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"endOfLine": "auto",
|
||||
"proseWrap": "preserve",
|
||||
"htmlWhitespaceSensitivity": "strict"
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 hangye
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,305 @@
|
||||
# Chrome MCP Server 🚀
|
||||
|
||||
[](https://img.shields.io/github/stars/hangwin/mcp-chrome)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://developer.chrome.com/docs/extensions/)
|
||||
[](https://img.shields.io/github/v/release/hangwin/mcp-chrome.svg)
|
||||
|
||||
> 🌟 **Turn your Chrome browser into your intelligent assistant** - Let AI take control of your browser, transforming it into a powerful AI-controlled automation tool.
|
||||
|
||||
**📖 Documentation**: [English](README.md) | [中文](README_zh.md)
|
||||
|
||||
> The project is still in its early stages and is under intensive development. More features, stability improvements, and other enhancements will follow.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 What is Chrome MCP Server?
|
||||
|
||||
Chrome MCP Server is a Chrome extension-based **Model Context Protocol (MCP) server** that exposes your Chrome browser functionality to AI assistants like Claude, enabling complex browser automation, content analysis, and semantic search. Unlike traditional browser automation tools (like Playwright), **Chrome MCP Server** directly uses your daily Chrome browser, leveraging existing user habits, configurations, and login states, allowing various large models or chatbots to take control of your browser and truly become your everyday assistant.
|
||||
|
||||
## ✨ New Features(2025/12/30)
|
||||
|
||||
- **A New Visual Editor for Claude Code & Codex**, for more detail here: [VisualEditor](docs/VisualEditor.md)
|
||||
|
||||
## ✨ Core Features
|
||||
|
||||
- 😁 **Chatbot/Model Agnostic**: Let any LLM or chatbot client or agent you prefer automate your browser
|
||||
- ⭐️ **Use Your Original Browser**: Seamlessly integrate with your existing browser environment (your configurations, login states, etc.)
|
||||
- 💻 **Fully Local**: Pure local MCP server ensuring user privacy
|
||||
- 🚄 **Streamable HTTP**: Streamable HTTP connection method
|
||||
- 🏎 **Cross-Tab**: Cross-tab context
|
||||
- 🧠 **Semantic Search**: Built-in vector database for intelligent browser tab content discovery
|
||||
- 🔍 **Smart Content Analysis**: AI-powered text extraction and similarity matching
|
||||
- 🌐 **20+ Tools**: Support for screenshots, network monitoring, interactive operations, bookmark management, browsing history, and 20+ other tools
|
||||
- 🚀 **SIMD-Accelerated AI**: Custom WebAssembly SIMD optimization for 4-8x faster vector operations
|
||||
|
||||
## 🆚 Comparison with Similar Projects
|
||||
|
||||
| Comparison Dimension | Playwright-based MCP Server | Chrome Extension-based MCP Server |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| **Resource Usage** | ❌ Requires launching independent browser process, installing Playwright dependencies, downloading browser binaries, etc. | ✅ No need to launch independent browser process, directly utilizes user's already open Chrome browser |
|
||||
| **User Session Reuse** | ❌ Requires re-login | ✅ Automatically uses existing login state |
|
||||
| **Browser Environment** | ❌ Clean environment lacks user settings | ✅ Fully preserves user environment |
|
||||
| **API Access** | ⚠️ Limited to Playwright API | ✅ Full access to Chrome native APIs |
|
||||
| **Startup Speed** | ❌ Requires launching browser process | ✅ Only needs to activate extension |
|
||||
| **Response Speed** | 50-200ms inter-process communication | ✅ Faster |
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 20.0.0 and pnpm/npm
|
||||
- Chrome/Chromium browser
|
||||
|
||||
### Installation Steps
|
||||
|
||||
1. **Download the latest Chrome extension from GitHub**
|
||||
|
||||
Download link: https://github.com/hangwin/mcp-chrome/releases
|
||||
|
||||
2. **Install mcp-chrome-bridge globally**
|
||||
|
||||
npm
|
||||
|
||||
```bash
|
||||
npm install -g mcp-chrome-bridge
|
||||
```
|
||||
|
||||
pnpm
|
||||
|
||||
```bash
|
||||
# Method 1: Enable scripts globally (recommended)
|
||||
pnpm config set enable-pre-post-scripts true
|
||||
pnpm install -g mcp-chrome-bridge
|
||||
|
||||
# Method 2: Manual registration (if postinstall doesn't run)
|
||||
pnpm install -g mcp-chrome-bridge
|
||||
mcp-chrome-bridge register
|
||||
```
|
||||
|
||||
> Note: pnpm v7+ disables postinstall scripts by default for security. The `enable-pre-post-scripts` setting controls whether pre/post install scripts run. If automatic registration fails, use the manual registration command above.
|
||||
|
||||
3. **Load Chrome Extension**
|
||||
- Open Chrome and go to `chrome://extensions/`
|
||||
- Enable "Developer mode"
|
||||
- Click "Load unpacked" and select `your/dowloaded/extension/folder`
|
||||
- Click the extension icon to open the plugin, then click connect to see the MCP configuration
|
||||
<img width="475" alt="Screenshot 2025-06-09 15 52 06" src="https://github.com/user-attachments/assets/241e57b8-c55f-41a4-9188-0367293dc5bc" />
|
||||
|
||||
### Usage with MCP Protocol Clients
|
||||
|
||||
#### Using Streamable HTTP Connection (👍🏻 Recommended)
|
||||
|
||||
Add the following configuration to your MCP client configuration (using CherryStudio as an example):
|
||||
|
||||
> Streamable HTTP connection method is recommended
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-mcp-server": {
|
||||
"type": "streamableHttp",
|
||||
"url": "http://127.0.0.1:12306/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Using STDIO Connection (Alternative)
|
||||
|
||||
If your client only supports stdio connection method, please use the following approach:
|
||||
|
||||
1. First, check the installation location of the npm package you just installed
|
||||
|
||||
```sh
|
||||
# npm check method
|
||||
npm list -g mcp-chrome-bridge
|
||||
# pnpm check method
|
||||
pnpm list -g mcp-chrome-bridge
|
||||
```
|
||||
|
||||
Assuming the command above outputs the path: /Users/xxx/Library/pnpm/global/5
|
||||
Then your final path would be: /Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js
|
||||
|
||||
2. Replace the configuration below with the final path you just obtained
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-mcp-stdio": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"node",
|
||||
"/Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
eg:config in augment:
|
||||
|
||||
<img width="494" alt="截屏2025-06-22 22 11 25" src="https://github.com/user-attachments/assets/48eefc0c-a257-4d3b-8bbe-d7ff716de2bf" />
|
||||
|
||||
## 🛠️ Available Tools
|
||||
|
||||
Complete tool list: [Complete Tool List](docs/TOOLS.md)
|
||||
|
||||
<details>
|
||||
<summary><strong>📊 Browser Management (6 tools)</strong></summary>
|
||||
|
||||
- `get_windows_and_tabs` - List all browser windows and tabs
|
||||
- `chrome_navigate` - Navigate to URLs and control viewport
|
||||
- `chrome_switch_tab` - Switch the current active tab
|
||||
- `chrome_close_tabs` - Close specific tabs or windows
|
||||
- `chrome_go_back_or_forward` - Browser navigation control
|
||||
- `chrome_inject_script` - Inject content scripts into web pages
|
||||
- `chrome_send_command_to_inject_script` - Send commands to injected content scripts
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>📸 Screenshots & Visual (1 tool)</strong></summary>
|
||||
|
||||
- `chrome_screenshot` - Advanced screenshot capture with element targeting, full-page support, and custom dimensions
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🌐 Network Monitoring (4 tools)</strong></summary>
|
||||
|
||||
- `chrome_network_capture_start/stop` - webRequest API network capture
|
||||
- `chrome_network_debugger_start/stop` - Debugger API with response bodies
|
||||
- `chrome_network_request` - Send custom HTTP requests
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🔍 Content Analysis (4 tools)</strong></summary>
|
||||
|
||||
- `search_tabs_content` - AI-powered semantic search across browser tabs
|
||||
- `chrome_get_web_content` - Extract HTML/text content from pages
|
||||
- `chrome_get_interactive_elements` - Find clickable elements
|
||||
- `chrome_console` - Capture and retrieve console output from browser tabs
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🎯 Interaction (3 tools)</strong></summary>
|
||||
|
||||
- `chrome_click_element` - Click elements using CSS selectors
|
||||
- `chrome_fill_or_select` - Fill forms and select options
|
||||
- `chrome_keyboard` - Simulate keyboard input and shortcuts
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>📚 Data Management (5 tools)</strong></summary>
|
||||
|
||||
- `chrome_history` - Search browser history with time filters
|
||||
- `chrome_bookmark_search` - Find bookmarks by keywords
|
||||
- `chrome_bookmark_add` - Add new bookmarks with folder support
|
||||
- `chrome_bookmark_delete` - Delete bookmarks
|
||||
</details>
|
||||
|
||||
## 🧪 Usage Examples
|
||||
|
||||
### AI helps you summarize webpage content and automatically control Excalidraw for drawing
|
||||
|
||||
prompt: [excalidraw-prompt](prompt/excalidraw-prompt.md)
|
||||
Instruction: Help me summarize the current page content, then draw a diagram to aid my understanding.
|
||||
https://www.youtube.com/watch?v=3fBPdUBWVz0
|
||||
|
||||
https://github.com/user-attachments/assets/fd17209b-303d-48db-9e5e-3717141df183
|
||||
|
||||
### After analyzing the content of the image, the LLM automatically controls Excalidraw to replicate the image
|
||||
|
||||
prompt: [excalidraw-prompt](prompt/excalidraw-prompt.md)|[content-analize](prompt/content-analize.md)
|
||||
Instruction: First, analyze the content of the image, and then replicate the image by combining the analysis with the content of the image.
|
||||
https://www.youtube.com/watch?v=tEPdHZBzbZk
|
||||
|
||||
https://github.com/user-attachments/assets/60d12b1a-9b74-40f4-994c-95e8fa1fc8d3
|
||||
|
||||
### AI automatically injects scripts and modifies webpage styles
|
||||
|
||||
prompt: [modify-web-prompt](prompt/modify-web.md)
|
||||
Instruction: Help me modify the current page's style and remove advertisements.
|
||||
https://youtu.be/twI6apRKHsk
|
||||
|
||||
https://github.com/user-attachments/assets/69cb561c-2e1e-4665-9411-4a3185f9643e
|
||||
|
||||
### AI automatically captures network requests for you
|
||||
|
||||
query: I want to know what the search API for Xiaohongshu is and what the response structure looks like
|
||||
|
||||
https://youtu.be/1hHKr7XKqnQ
|
||||
|
||||
https://github.com/user-attachments/assets/dc7e5cab-b9af-4b9a-97ce-18e4837318d9
|
||||
|
||||
### AI helps analyze your browsing history
|
||||
|
||||
query: Analyze my browsing history from the past month
|
||||
|
||||
https://youtu.be/jf2UZfrR2Vk
|
||||
|
||||
https://github.com/user-attachments/assets/31b2e064-88c6-4adb-96d7-50748b826eae
|
||||
|
||||
### Web page conversation
|
||||
|
||||
query: Translate and summarize the current web page
|
||||
https://youtu.be/FlJKS9UQyC8
|
||||
|
||||
https://github.com/user-attachments/assets/aa8ef2a1-2310-47e6-897a-769d85489396
|
||||
|
||||
### AI automatically takes screenshots for you (web page screenshots)
|
||||
|
||||
query: Take a screenshot of Hugging Face's homepage
|
||||
https://youtu.be/7ycK6iksWi4
|
||||
|
||||
https://github.com/user-attachments/assets/65c6eee2-6366-493d-a3bd-2b27529ff5b3
|
||||
|
||||
### AI automatically takes screenshots for you (element screenshots)
|
||||
|
||||
query: Capture the icon from Hugging Face's homepage
|
||||
https://youtu.be/ev8VivANIrk
|
||||
|
||||
https://github.com/user-attachments/assets/d0cf9785-c2fe-4729-a3c5-7f2b8b96fe0c
|
||||
|
||||
### AI helps manage bookmarks
|
||||
|
||||
query: Add the current page to bookmarks and put it in an appropriate folder
|
||||
|
||||
https://youtu.be/R_83arKmFTo
|
||||
|
||||
https://github.com/user-attachments/assets/15a7d04c-0196-4b40-84c2-bafb5c26dfe0
|
||||
|
||||
### Automatically close web pages
|
||||
|
||||
query: Close all shadcn-related web pages
|
||||
|
||||
https://youtu.be/2wzUT6eNVg4
|
||||
|
||||
https://github.com/user-attachments/assets/83de4008-bb7e-494d-9b0f-98325cfea592
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Please see [CONTRIBUTING.md](docs/CONTRIBUTING.md) for detailed guidelines.
|
||||
|
||||
## 🚧 Future Roadmap
|
||||
|
||||
We have exciting plans for the future development of Chrome MCP Server:
|
||||
|
||||
- [ ] Authentication
|
||||
- [ ] Recording and Playback
|
||||
- [ ] Workflow Automation
|
||||
- [ ] Enhanced Browser Support (Firefox Extension)
|
||||
|
||||
---
|
||||
|
||||
**Want to contribute to any of these features?** Check out our [Contributing Guide](docs/CONTRIBUTING.md) and join our development community!
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 📚 More Documentation
|
||||
|
||||
- [Architecture Design](docs/ARCHITECTURE.md) - Detailed technical architecture documentation
|
||||
- [TOOLS API](docs/TOOLS.md) - Complete tool API documentation
|
||||
- [Troubleshooting](docs/TROUBLESHOOTING.md) - Common issue solutions
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`hangwin/mcp-chrome`
|
||||
- 原始仓库:https://github.com/hangwin/mcp-chrome
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
# Chrome MCP Server 🚀
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://developer.chrome.com/docs/extensions/)
|
||||
|
||||
> 🌟 **让chrome浏览器变成你的智能助手** - 让AI接管你的浏览器,将您的浏览器转变为强大的 AI 控制自动化工具。
|
||||
|
||||
**📖 文档**: [English](README.md) | [中文](README_zh.md)
|
||||
|
||||
> 项目仍处于早期阶段,正在紧锣密鼓开发中,后续将有更多新功能,以及稳定性等的提升,如遇bug,请轻喷
|
||||
|
||||
---
|
||||
|
||||
## 🎯 什么是 Chrome MCP Server?
|
||||
|
||||
Chrome MCP Server 是一个基于chrome插件的 **模型上下文协议 (MCP) 服务器**,它将您的 Chrome 浏览器功能暴露给 Claude 等 AI 助手,实现复杂的浏览器自动化、内容分析和语义搜索等。与传统的浏览器自动化工具(如playwright)不同,**Chrome MCP server**直接使用您日常使用的chrome浏览器,基于现有的用户习惯和配置、登录态,让各种大模型或者各种chatbot都可以接管你的浏览器,真正成为你的日常助手
|
||||
|
||||
## ✨ 船新的功能(2025/12/30)
|
||||
|
||||
- **让Claude Code/Codex也能使用的可视化编辑器**, 更多详情请看: [VisualEditor](docs/VisualEditor_zh.md)
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
- 😁 **chatbot/模型无关**:让任意你喜欢的llm或chatbot客户端或agent来自动化操作你的浏览器
|
||||
- ⭐️ **使用你原本的浏览器**:无缝集成用户本身的浏览器环境(你的配置、登录态等)
|
||||
- 💻 **完全本地运行**:纯本地运行的mcp server,保证用户隐私
|
||||
- 🚄 **Streamable http**:Streamable http的连接方式
|
||||
- 🏎 **跨标签页** 跨标签页的上下文
|
||||
- 🧠 **语义搜索**:内置向量数据库和本地小模型,智能发现浏览器标签页内容
|
||||
- 🔍 **智能内容分析**:AI 驱动的文本提取和相似度匹配
|
||||
- 🌐 **20+ 工具**:支持截图、网络监控、交互操作、书签管理、浏览历史等20多种工具
|
||||
- 🚀 **SIMD 加速 AI**:自定义 WebAssembly SIMD 优化,向量运算速度提升 4-8 倍
|
||||
|
||||
## 🆚 与同类项目对比
|
||||
|
||||
| 对比维度 | 基于Playwright的MCP Server | 基于Chrome插件的MCP Server |
|
||||
| ------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| **资源占用** | ❌ 需启动独立浏览器进程,需要安装Playwright依赖,下载浏览器二进制等 | ✅ 无需启动独立的浏览器进程,直接利用用户已打开的Chrome浏览器 |
|
||||
| **用户会话复用** | ❌ 需重新登录 | ✅ 自动使用已登录状态 |
|
||||
| **浏览器环境保持** | ❌ 干净环境缺少用户设置 | ✅ 完整保留用户环境 |
|
||||
| **API访问权限** | ⚠️ 受限于Playwright API | ✅ Chrome原生API全访问 |
|
||||
| **启动速度** | ❌ 需启动浏览器进程 | ✅ 只需激活插件 |
|
||||
| **响应速度** | 50-200ms进程间通信 | ✅ 更快 |
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 20.0.0 和 (npm 或 pnpm)
|
||||
- Chrome/Chromium 浏览器
|
||||
|
||||
### 安装步骤
|
||||
|
||||
1. **从github上下载最新的chrome扩展**
|
||||
|
||||
下载地址:https://github.com/hangwin/mcp-chrome/releases
|
||||
|
||||
2. **全局安装mcp-chrome-bridge**
|
||||
|
||||
npm
|
||||
|
||||
```bash
|
||||
npm install -g mcp-chrome-bridge
|
||||
```
|
||||
|
||||
pnpm
|
||||
|
||||
```bash
|
||||
# 方法1:全局启用脚本(推荐)
|
||||
pnpm config set enable-pre-post-scripts true
|
||||
pnpm install -g mcp-chrome-bridge
|
||||
|
||||
# 方法2:如果 postinstall 没有运行,手动注册
|
||||
pnpm install -g mcp-chrome-bridge
|
||||
mcp-chrome-bridge register
|
||||
```
|
||||
|
||||
> 注意:pnpm v7+ 默认禁用 postinstall 脚本以提高安全性。`enable-pre-post-scripts` 设置控制是否运行 pre/post 安装脚本。如果自动注册失败,请使用上述手动注册命令。
|
||||
|
||||
3. **加载 Chrome 扩展**
|
||||
- 打开 Chrome 并访问 `chrome://extensions/`
|
||||
- 启用"开发者模式"
|
||||
- 点击"加载已解压的扩展程序",选择 `your/dowloaded/extension/folder`
|
||||
- 点击插件图标打开插件,点击连接即可看到mcp的配置
|
||||
<img width="475" alt="截屏2025-06-09 15 52 06" src="https://github.com/user-attachments/assets/241e57b8-c55f-41a4-9188-0367293dc5bc" />
|
||||
|
||||
### 在支持MCP协议的客户端中使用
|
||||
|
||||
#### 使用streamable http的方式连接(👍🏻推荐)
|
||||
|
||||
将以下配置添加到客户端的 MCP 配置中以cherryStudio为例:
|
||||
|
||||
> 推荐用streamable http的连接方式
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-mcp-server": {
|
||||
"type": "streamableHttp",
|
||||
"url": "http://127.0.0.1:12306/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 使用stdio的方式连接(备选)
|
||||
|
||||
假设你的客户端仅支持stdio的连接方式,那么请使用下面的方法:
|
||||
|
||||
1. 先查看你刚刚安装的npm包的安装位置
|
||||
|
||||
```sh
|
||||
# npm 查看方式
|
||||
npm list -g mcp-chrome-bridge
|
||||
# pnpm 查看方式
|
||||
pnpm list -g mcp-chrome-bridge
|
||||
```
|
||||
|
||||
假设上面的命令输出的路径是:/Users/xxx/Library/pnpm/global/5
|
||||
那么你的最终路径就是:/Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js
|
||||
|
||||
2. 把下面的配置替换成你刚刚得到的最终路径
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"chrome-mcp-stdio": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"node",
|
||||
"/Users/xxx/Library/pnpm/global/5/node_modules/mcp-chrome-bridge/dist/mcp/mcp-server-stdio.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
比如:在augment中的配置如下:
|
||||
|
||||
<img width="494" alt="截屏2025-06-22 22 11 25" src="https://github.com/user-attachments/assets/07c0b090-622b-433d-be70-44e8cb8980a5" />
|
||||
|
||||
## 🛠️ 可用工具
|
||||
|
||||
完整工具列表:[完整工具列表](docs/TOOLS_zh.md)
|
||||
|
||||
<details>
|
||||
<summary><strong>📊 浏览器管理 (6个工具)</strong></summary>
|
||||
|
||||
- `get_windows_and_tabs` - 列出所有浏览器窗口和标签页
|
||||
- `chrome_navigate` - 导航到 URL 并控制视口
|
||||
- `chrome_switch_tab` - 切换当前显示的标签页
|
||||
- `chrome_close_tabs` - 关闭特定标签页或窗口
|
||||
- `chrome_go_back_or_forward` - 浏览器导航控制
|
||||
- `chrome_inject_script` - 向网页注入内容脚本
|
||||
- `chrome_send_command_to_inject_script` - 向已注入的内容脚本发送指令
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>📸 截图和视觉 (1个工具)</strong></summary>
|
||||
|
||||
- `chrome_screenshot` - 高级截图捕获,支持元素定位、全页面和自定义尺寸
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🌐 网络监控 (4个工具)</strong></summary>
|
||||
|
||||
- `chrome_network_capture_start/stop` - webRequest API 网络捕获
|
||||
- `chrome_network_debugger_start/stop` - Debugger API 包含响应体
|
||||
- `chrome_network_request` - 发送自定义 HTTP 请求
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🔍 内容分析 (4个工具)</strong></summary>
|
||||
|
||||
- `search_tabs_content` - AI 驱动的浏览器标签页语义搜索
|
||||
- `chrome_get_web_content` - 从页面提取 HTML/文本内容
|
||||
- `chrome_get_interactive_elements` - 查找可点击元素
|
||||
- `chrome_console` - 捕获和获取浏览器标签页的控制台输出
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>🎯 交互操作 (3个工具)</strong></summary>
|
||||
|
||||
- `chrome_click_element` - 使用 CSS 选择器点击元素
|
||||
- `chrome_fill_or_select` - 填充表单和选择选项
|
||||
- `chrome_keyboard` - 模拟键盘输入和快捷键
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>📚 数据管理 (5个工具)</strong></summary>
|
||||
|
||||
- `chrome_history` - 搜索浏览器历史记录,支持时间过滤
|
||||
- `chrome_bookmark_search` - 按关键词查找书签
|
||||
- `chrome_bookmark_add` - 添加新书签,支持文件夹
|
||||
- `chrome_bookmark_delete` - 删除书签
|
||||
</details>
|
||||
|
||||
## 🧪 使用示例
|
||||
|
||||
### ai帮你总结网页内容然后自动控制excalidraw画图
|
||||
|
||||
prompt: [excalidraw-prompt](prompt/excalidraw-prompt.md)
|
||||
指令:帮我总结当前页面内容,然后画个图帮我理解
|
||||
https://www.youtube.com/watch?v=3fBPdUBWVz0
|
||||
|
||||
https://github.com/user-attachments/assets/f14f79a6-9390-4821-8296-06d020bcfc07
|
||||
|
||||
### ai先分析图片的内容元素,然后再自动控制excalidraw把图片模仿出来
|
||||
|
||||
prompt: [excalidraw-prompt](prompt/excalidraw-prompt.md)|[content-analize](prompt/content-analize.md)
|
||||
指令:先看下图片是否能用excalidraw画出来,如果则列出所需的步骤和元素,然后画出来
|
||||
https://www.youtube.com/watch?v=tEPdHZBzbZk
|
||||
|
||||
https://github.com/user-attachments/assets/4f0600c1-bb1e-4b57-85ab-36c8bdf71c68
|
||||
|
||||
### ai自动帮你注入脚本并修改网页的样式
|
||||
|
||||
prompt: [modify-web-prompt](prompt/modify-web.md)
|
||||
指令:帮我修改当前页面的样式,去掉广告
|
||||
https://youtu.be/twI6apRKHsk
|
||||
|
||||
https://github.com/user-attachments/assets/aedbe98d-e90c-4a58-a4a5-d888f7293d8e
|
||||
|
||||
### ai自动帮你捕获网络请求
|
||||
|
||||
指令:我想知道小红书的搜索接口是哪个,响应体结构是什么样的
|
||||
https://youtu.be/1hHKr7XKqnQ
|
||||
|
||||
https://github.com/user-attachments/assets/dc7e5cab-b9af-4b9a-97ce-18e4837318d9
|
||||
|
||||
### ai帮你分析你的浏览记录
|
||||
|
||||
指令:分析一下我近一个月的浏览记录
|
||||
https://youtu.be/jf2UZfrR2Vk
|
||||
|
||||
https://github.com/user-attachments/assets/31b2e064-88c6-4adb-96d7-50748b826eae
|
||||
|
||||
### 网页对话
|
||||
|
||||
指令:翻译并总结当前网页
|
||||
https://youtu.be/FlJKS9UQyC8
|
||||
|
||||
https://github.com/user-attachments/assets/aa8ef2a1-2310-47e6-897a-769d85489396
|
||||
|
||||
### ai帮你自动截图(网页截图)
|
||||
|
||||
指令:把huggingface的首页截个图
|
||||
https://youtu.be/7ycK6iksWi4
|
||||
|
||||
https://github.com/user-attachments/assets/65c6eee2-6366-493d-a3bd-2b27529ff5b3
|
||||
|
||||
### ai帮你自动截图(元素截图)
|
||||
|
||||
指令:把huggingface首页的图标截取下来
|
||||
https://youtu.be/ev8VivANIrk
|
||||
|
||||
https://github.com/user-attachments/assets/d0cf9785-c2fe-4729-a3c5-7f2b8b96fe0c
|
||||
|
||||
### ai帮你管理书签
|
||||
|
||||
指令:将当前页面添加到书签中,放到合适的文件夹
|
||||
https://youtu.be/R_83arKmFTo
|
||||
|
||||
https://github.com/user-attachments/assets/15a7d04c-0196-4b40-84c2-bafb5c26dfe0
|
||||
|
||||
### 自动关闭网页
|
||||
|
||||
指令:关闭所有shadcn相关的网页
|
||||
https://youtu.be/2wzUT6eNVg4
|
||||
|
||||
https://github.com/user-attachments/assets/83de4008-bb7e-494d-9b0f-98325cfea592
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
我们欢迎贡献!请查看 [CONTRIBUTING_zh.md](docs/CONTRIBUTING_zh.md) 了解详细指南。
|
||||
|
||||
## 🚧 未来发展路线图
|
||||
|
||||
我们对 Chrome MCP Server 的未来发展有着激动人心的计划:
|
||||
|
||||
- [ ] 身份认证
|
||||
|
||||
- [ ] 录制与回放
|
||||
|
||||
- [ ] 工作流自动化
|
||||
|
||||
- [ ] 增强浏览器支持(Firefox 扩展)
|
||||
|
||||
---
|
||||
|
||||
**想要为这些功能中的任何一个做贡献?** 查看我们的[贡献指南](docs/CONTRIBUTING_zh.md)并加入我们的开发社区!
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 MIT 许可证 - 详见 [LICENSE](LICENSE) 文件。
|
||||
|
||||
## 📚 更多文档
|
||||
|
||||
- [架构设计](docs/ARCHITECTURE_zh.md) - 详细的技术架构说明
|
||||
- [工具列表](docs/TOOLS_zh.md) - 完整的工具 API 文档
|
||||
- [故障排除](docs/TROUBLESHOOTING_zh.md) - 常见问题解决方案
|
||||
|
||||
## 微信交流群
|
||||
|
||||
拉群的目的是让踩过坑的大佬们互相帮忙解答问题,因本人平时要忙着搬砖,不一定能及时解答
|
||||
|
||||

|
||||
@@ -0,0 +1,4 @@
|
||||
# Chrome Extension Private Key
|
||||
# Copy this file to .env and replace with your actual private key
|
||||
# This key is used for Chrome extension packaging and should be kept secure
|
||||
CHROME_EXTENSION_KEY=YOUR_PRIVATE_KEY_HERE
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 hangwin
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,7 @@
|
||||
# WXT + Vue 3
|
||||
|
||||
This template should help get you started developing with Vue 3 in WXT.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar).
|
||||
@@ -0,0 +1,446 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "chrome-mcp-server",
|
||||
"description": "Erweiterungsname"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Stellt Browser-Funktionen mit Ihrem eigenen Chrome zur Verfügung",
|
||||
"description": "Erweiterungsbeschreibung"
|
||||
},
|
||||
"nativeServerConfigLabel": {
|
||||
"message": "Native Server-Konfiguration",
|
||||
"description": "Hauptabschnittstitel für Native Server-Einstellungen"
|
||||
},
|
||||
"semanticEngineLabel": {
|
||||
"message": "Semantische Engine",
|
||||
"description": "Hauptabschnittstitel für semantische Engine"
|
||||
},
|
||||
"embeddingModelLabel": {
|
||||
"message": "Embedding-Modell",
|
||||
"description": "Hauptabschnittstitel für Modellauswahl"
|
||||
},
|
||||
"indexDataManagementLabel": {
|
||||
"message": "Index-Datenverwaltung",
|
||||
"description": "Hauptabschnittstitel für Datenverwaltung"
|
||||
},
|
||||
"modelCacheManagementLabel": {
|
||||
"message": "Modell-Cache-Verwaltung",
|
||||
"description": "Hauptabschnittstitel für Cache-Verwaltung"
|
||||
},
|
||||
"statusLabel": {
|
||||
"message": "Status",
|
||||
"description": "Allgemeines Statuslabel"
|
||||
},
|
||||
"runningStatusLabel": {
|
||||
"message": "Betriebsstatus",
|
||||
"description": "Server-Betriebsstatuslabel"
|
||||
},
|
||||
"connectionStatusLabel": {
|
||||
"message": "Verbindungsstatus",
|
||||
"description": "Verbindungsstatuslabel"
|
||||
},
|
||||
"lastUpdatedLabel": {
|
||||
"message": "Zuletzt aktualisiert:",
|
||||
"description": "Zeitstempel der letzten Aktualisierung"
|
||||
},
|
||||
"connectButton": {
|
||||
"message": "Verbinden",
|
||||
"description": "Verbinden-Schaltflächentext"
|
||||
},
|
||||
"disconnectButton": {
|
||||
"message": "Trennen",
|
||||
"description": "Trennen-Schaltflächentext"
|
||||
},
|
||||
"connectingStatus": {
|
||||
"message": "Verbindung wird hergestellt...",
|
||||
"description": "Verbindungsstatusmeldung"
|
||||
},
|
||||
"connectedStatus": {
|
||||
"message": "Verbunden",
|
||||
"description": "Verbunden-Statusmeldung"
|
||||
},
|
||||
"disconnectedStatus": {
|
||||
"message": "Getrennt",
|
||||
"description": "Getrennt-Statusmeldung"
|
||||
},
|
||||
"detectingStatus": {
|
||||
"message": "Erkennung läuft...",
|
||||
"description": "Erkennungsstatusmeldung"
|
||||
},
|
||||
"serviceRunningStatus": {
|
||||
"message": "Service läuft (Port: $PORT$)",
|
||||
"description": "Service läuft mit Portnummer",
|
||||
"placeholders": {
|
||||
"port": {
|
||||
"content": "$1",
|
||||
"example": "12306"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serviceNotConnectedStatus": {
|
||||
"message": "Service nicht verbunden",
|
||||
"description": "Service nicht verbunden Status"
|
||||
},
|
||||
"connectedServiceNotStartedStatus": {
|
||||
"message": "Verbunden, Service nicht gestartet",
|
||||
"description": "Verbunden aber Service nicht gestartet Status"
|
||||
},
|
||||
"mcpServerConfigLabel": {
|
||||
"message": "MCP Server-Konfiguration",
|
||||
"description": "MCP Server-Konfigurationsabschnittslabel"
|
||||
},
|
||||
"connectionPortLabel": {
|
||||
"message": "Verbindungsport",
|
||||
"description": "Verbindungsport-Eingabelabel"
|
||||
},
|
||||
"refreshStatusButton": {
|
||||
"message": "Status aktualisieren",
|
||||
"description": "Status aktualisieren Schaltflächen-Tooltip"
|
||||
},
|
||||
"copyConfigButton": {
|
||||
"message": "Konfiguration kopieren",
|
||||
"description": "Konfiguration kopieren Schaltflächentext"
|
||||
},
|
||||
"retryButton": {
|
||||
"message": "Wiederholen",
|
||||
"description": "Wiederholen-Schaltflächentext"
|
||||
},
|
||||
"cancelButton": {
|
||||
"message": "Abbrechen",
|
||||
"description": "Abbrechen-Schaltflächentext"
|
||||
},
|
||||
"confirmButton": {
|
||||
"message": "Bestätigen",
|
||||
"description": "Bestätigen-Schaltflächentext"
|
||||
},
|
||||
"saveButton": {
|
||||
"message": "Speichern",
|
||||
"description": "Speichern-Schaltflächentext"
|
||||
},
|
||||
"closeButton": {
|
||||
"message": "Schließen",
|
||||
"description": "Schließen-Schaltflächentext"
|
||||
},
|
||||
"resetButton": {
|
||||
"message": "Zurücksetzen",
|
||||
"description": "Zurücksetzen-Schaltflächentext"
|
||||
},
|
||||
"initializingStatus": {
|
||||
"message": "Initialisierung...",
|
||||
"description": "Initialisierung-Fortschrittsmeldung"
|
||||
},
|
||||
"processingStatus": {
|
||||
"message": "Verarbeitung...",
|
||||
"description": "Verarbeitung-Fortschrittsmeldung"
|
||||
},
|
||||
"loadingStatus": {
|
||||
"message": "Wird geladen...",
|
||||
"description": "Ladefortschrittsmeldung"
|
||||
},
|
||||
"clearingStatus": {
|
||||
"message": "Wird geleert...",
|
||||
"description": "Leerungsfortschrittsmeldung"
|
||||
},
|
||||
"cleaningStatus": {
|
||||
"message": "Wird bereinigt...",
|
||||
"description": "Bereinigungsfortschrittsmeldung"
|
||||
},
|
||||
"downloadingStatus": {
|
||||
"message": "Wird heruntergeladen...",
|
||||
"description": "Download-Fortschrittsmeldung"
|
||||
},
|
||||
"semanticEngineReadyStatus": {
|
||||
"message": "Semantische Engine bereit",
|
||||
"description": "Semantische Engine bereit Status"
|
||||
},
|
||||
"semanticEngineInitializingStatus": {
|
||||
"message": "Semantische Engine wird initialisiert...",
|
||||
"description": "Semantische Engine Initialisierungsstatus"
|
||||
},
|
||||
"semanticEngineInitFailedStatus": {
|
||||
"message": "Initialisierung der semantischen Engine fehlgeschlagen",
|
||||
"description": "Semantische Engine Initialisierung fehlgeschlagen Status"
|
||||
},
|
||||
"semanticEngineNotInitStatus": {
|
||||
"message": "Semantische Engine nicht initialisiert",
|
||||
"description": "Semantische Engine nicht initialisiert Status"
|
||||
},
|
||||
"initSemanticEngineButton": {
|
||||
"message": "Semantische Engine initialisieren",
|
||||
"description": "Semantische Engine initialisieren Schaltflächentext"
|
||||
},
|
||||
"reinitializeButton": {
|
||||
"message": "Neu initialisieren",
|
||||
"description": "Neu initialisieren Schaltflächentext"
|
||||
},
|
||||
"downloadingModelStatus": {
|
||||
"message": "Modell wird heruntergeladen... $PROGRESS$%",
|
||||
"description": "Modell-Download-Fortschritt mit Prozentsatz",
|
||||
"placeholders": {
|
||||
"progress": {
|
||||
"content": "$1",
|
||||
"example": "50"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switchingModelStatus": {
|
||||
"message": "Modell wird gewechselt...",
|
||||
"description": "Modellwechsel-Fortschrittsmeldung"
|
||||
},
|
||||
"modelLoadedStatus": {
|
||||
"message": "Modell geladen",
|
||||
"description": "Modell erfolgreich geladen Status"
|
||||
},
|
||||
"modelFailedStatus": {
|
||||
"message": "Modell konnte nicht geladen werden",
|
||||
"description": "Modell-Ladefehler Status"
|
||||
},
|
||||
"lightweightModelDescription": {
|
||||
"message": "Leichtgewichtiges mehrsprachiges Modell",
|
||||
"description": "Beschreibung für leichtgewichtige Modelloption"
|
||||
},
|
||||
"betterThanSmallDescription": {
|
||||
"message": "Etwas größer als e5-small, aber bessere Leistung",
|
||||
"description": "Beschreibung für mittlere Modelloption"
|
||||
},
|
||||
"multilingualModelDescription": {
|
||||
"message": "Mehrsprachiges semantisches Modell",
|
||||
"description": "Beschreibung für mehrsprachige Modelloption"
|
||||
},
|
||||
"fastPerformance": {
|
||||
"message": "Schnell",
|
||||
"description": "Schnelle Leistungsanzeige"
|
||||
},
|
||||
"balancedPerformance": {
|
||||
"message": "Ausgewogen",
|
||||
"description": "Ausgewogene Leistungsanzeige"
|
||||
},
|
||||
"accuratePerformance": {
|
||||
"message": "Genau",
|
||||
"description": "Genaue Leistungsanzeige"
|
||||
},
|
||||
"networkErrorMessage": {
|
||||
"message": "Netzwerkverbindungsfehler, bitte Netzwerk prüfen und erneut versuchen",
|
||||
"description": "Netzwerkverbindungsfehlermeldung"
|
||||
},
|
||||
"modelCorruptedErrorMessage": {
|
||||
"message": "Modelldatei beschädigt oder unvollständig, bitte Download wiederholen",
|
||||
"description": "Modell-Beschädigungsfehlermeldung"
|
||||
},
|
||||
"unknownErrorMessage": {
|
||||
"message": "Unbekannter Fehler, bitte prüfen Sie, ob Ihr Netzwerk auf HuggingFace zugreifen kann",
|
||||
"description": "Unbekannte Fehler-Rückfallmeldung"
|
||||
},
|
||||
"permissionDeniedErrorMessage": {
|
||||
"message": "Zugriff verweigert",
|
||||
"description": "Zugriff verweigert Fehlermeldung"
|
||||
},
|
||||
"timeoutErrorMessage": {
|
||||
"message": "Zeitüberschreitung",
|
||||
"description": "Zeitüberschreitungsfehlermeldung"
|
||||
},
|
||||
"indexedPagesLabel": {
|
||||
"message": "Indizierte Seiten",
|
||||
"description": "Anzahl indizierter Seiten Label"
|
||||
},
|
||||
"indexSizeLabel": {
|
||||
"message": "Indexgröße",
|
||||
"description": "Indexgröße Label"
|
||||
},
|
||||
"activeTabsLabel": {
|
||||
"message": "Aktive Tabs",
|
||||
"description": "Anzahl aktiver Tabs Label"
|
||||
},
|
||||
"vectorDocumentsLabel": {
|
||||
"message": "Vektordokumente",
|
||||
"description": "Anzahl Vektordokumente Label"
|
||||
},
|
||||
"cacheSizeLabel": {
|
||||
"message": "Cache-Größe",
|
||||
"description": "Cache-Größe Label"
|
||||
},
|
||||
"cacheEntriesLabel": {
|
||||
"message": "Cache-Einträge",
|
||||
"description": "Anzahl Cache-Einträge Label"
|
||||
},
|
||||
"clearAllDataButton": {
|
||||
"message": "Alle Daten löschen",
|
||||
"description": "Alle Daten löschen Schaltflächentext"
|
||||
},
|
||||
"clearAllCacheButton": {
|
||||
"message": "Gesamten Cache löschen",
|
||||
"description": "Gesamten Cache löschen Schaltflächentext"
|
||||
},
|
||||
"cleanExpiredCacheButton": {
|
||||
"message": "Abgelaufenen Cache bereinigen",
|
||||
"description": "Abgelaufenen Cache bereinigen Schaltflächentext"
|
||||
},
|
||||
"exportDataButton": {
|
||||
"message": "Daten exportieren",
|
||||
"description": "Daten exportieren Schaltflächentext"
|
||||
},
|
||||
"importDataButton": {
|
||||
"message": "Daten importieren",
|
||||
"description": "Daten importieren Schaltflächentext"
|
||||
},
|
||||
"confirmClearDataTitle": {
|
||||
"message": "Datenlöschung bestätigen",
|
||||
"description": "Datenlöschung bestätigen Dialogtitel"
|
||||
},
|
||||
"settingsTitle": {
|
||||
"message": "Einstellungen",
|
||||
"description": "Einstellungen Dialogtitel"
|
||||
},
|
||||
"aboutTitle": {
|
||||
"message": "Über",
|
||||
"description": "Über Dialogtitel"
|
||||
},
|
||||
"helpTitle": {
|
||||
"message": "Hilfe",
|
||||
"description": "Hilfe Dialogtitel"
|
||||
},
|
||||
"clearDataWarningMessage": {
|
||||
"message": "Diese Aktion löscht alle indizierten Webseiteninhalte und Vektordaten, einschließlich:",
|
||||
"description": "Datenlöschung Warnmeldung"
|
||||
},
|
||||
"clearDataList1": {
|
||||
"message": "Alle Webseitentextinhaltsindizes",
|
||||
"description": "Erster Punkt in Datenlöschungsliste"
|
||||
},
|
||||
"clearDataList2": {
|
||||
"message": "Vektor-Embedding-Daten",
|
||||
"description": "Zweiter Punkt in Datenlöschungsliste"
|
||||
},
|
||||
"clearDataList3": {
|
||||
"message": "Suchverlauf und Cache",
|
||||
"description": "Dritter Punkt in Datenlöschungsliste"
|
||||
},
|
||||
"clearDataIrreversibleWarning": {
|
||||
"message": "Diese Aktion ist unwiderruflich! Nach dem Löschen müssen Sie Webseiten erneut durchsuchen, um den Index neu aufzubauen.",
|
||||
"description": "Unwiderrufliche Aktion Warnung"
|
||||
},
|
||||
"confirmClearButton": {
|
||||
"message": "Löschung bestätigen",
|
||||
"description": "Löschung bestätigen Aktionsschaltfläche"
|
||||
},
|
||||
"cacheDetailsLabel": {
|
||||
"message": "Cache-Details",
|
||||
"description": "Cache-Details Abschnittslabel"
|
||||
},
|
||||
"noCacheDataMessage": {
|
||||
"message": "Keine Cache-Daten vorhanden",
|
||||
"description": "Keine Cache-Daten verfügbar Meldung"
|
||||
},
|
||||
"loadingCacheInfoStatus": {
|
||||
"message": "Cache-Informationen werden geladen...",
|
||||
"description": "Cache-Informationen laden Status"
|
||||
},
|
||||
"processingCacheStatus": {
|
||||
"message": "Cache wird verarbeitet...",
|
||||
"description": "Cache verarbeiten Status"
|
||||
},
|
||||
"expiredLabel": {
|
||||
"message": "Abgelaufen",
|
||||
"description": "Abgelaufenes Element Label"
|
||||
},
|
||||
"bookmarksBarLabel": {
|
||||
"message": "Lesezeichenleiste",
|
||||
"description": "Lesezeichenleiste Ordnername"
|
||||
},
|
||||
"newTabLabel": {
|
||||
"message": "Neuer Tab",
|
||||
"description": "Neuer Tab Label"
|
||||
},
|
||||
"currentPageLabel": {
|
||||
"message": "Aktuelle Seite",
|
||||
"description": "Aktuelle Seite Label"
|
||||
},
|
||||
"menuLabel": {
|
||||
"message": "Menü",
|
||||
"description": "Menü Barrierefreiheitslabel"
|
||||
},
|
||||
"navigationLabel": {
|
||||
"message": "Navigation",
|
||||
"description": "Navigation Barrierefreiheitslabel"
|
||||
},
|
||||
"mainContentLabel": {
|
||||
"message": "Hauptinhalt",
|
||||
"description": "Hauptinhalt Barrierefreiheitslabel"
|
||||
},
|
||||
"languageSelectorLabel": {
|
||||
"message": "Sprache",
|
||||
"description": "Sprachauswahl Label"
|
||||
},
|
||||
"themeLabel": {
|
||||
"message": "Design",
|
||||
"description": "Design-Auswahl Label"
|
||||
},
|
||||
"lightTheme": {
|
||||
"message": "Hell",
|
||||
"description": "Helles Design Option"
|
||||
},
|
||||
"darkTheme": {
|
||||
"message": "Dunkel",
|
||||
"description": "Dunkles Design Option"
|
||||
},
|
||||
"autoTheme": {
|
||||
"message": "Automatisch",
|
||||
"description": "Automatisches Design Option"
|
||||
},
|
||||
"advancedSettingsLabel": {
|
||||
"message": "Erweiterte Einstellungen",
|
||||
"description": "Erweiterte Einstellungen Abschnittslabel"
|
||||
},
|
||||
"debugModeLabel": {
|
||||
"message": "Debug-Modus",
|
||||
"description": "Debug-Modus Umschalter Label"
|
||||
},
|
||||
"verboseLoggingLabel": {
|
||||
"message": "Ausführliche Protokollierung",
|
||||
"description": "Ausführliche Protokollierung Umschalter Label"
|
||||
},
|
||||
"successNotification": {
|
||||
"message": "Vorgang erfolgreich abgeschlossen",
|
||||
"description": "Allgemeine Erfolgsmeldung"
|
||||
},
|
||||
"warningNotification": {
|
||||
"message": "Warnung: Bitte prüfen Sie vor dem Fortfahren",
|
||||
"description": "Allgemeine Warnmeldung"
|
||||
},
|
||||
"infoNotification": {
|
||||
"message": "Information",
|
||||
"description": "Allgemeine Informationsmeldung"
|
||||
},
|
||||
"configCopiedNotification": {
|
||||
"message": "Konfiguration in Zwischenablage kopiert",
|
||||
"description": "Konfiguration kopiert Erfolgsmeldung"
|
||||
},
|
||||
"dataClearedNotification": {
|
||||
"message": "Daten erfolgreich gelöscht",
|
||||
"description": "Daten gelöscht Erfolgsmeldung"
|
||||
},
|
||||
"bytesUnit": {
|
||||
"message": "Bytes",
|
||||
"description": "Bytes Einheit"
|
||||
},
|
||||
"kilobytesUnit": {
|
||||
"message": "KB",
|
||||
"description": "Kilobytes Einheit"
|
||||
},
|
||||
"megabytesUnit": {
|
||||
"message": "MB",
|
||||
"description": "Megabytes Einheit"
|
||||
},
|
||||
"gigabytesUnit": {
|
||||
"message": "GB",
|
||||
"description": "Gigabytes Einheit"
|
||||
},
|
||||
"itemsUnit": {
|
||||
"message": "Elemente",
|
||||
"description": "Elemente Zähleinheit"
|
||||
},
|
||||
"pagesUnit": {
|
||||
"message": "Seiten",
|
||||
"description": "Seiten Zähleinheit"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "chrome-mcp-server",
|
||||
"description": "Extension name"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "Exposes browser capabilities with your own chrome",
|
||||
"description": "Extension description"
|
||||
},
|
||||
"nativeServerConfigLabel": {
|
||||
"message": "Native Server Configuration",
|
||||
"description": "Main section header for native server settings"
|
||||
},
|
||||
"semanticEngineLabel": {
|
||||
"message": "Semantic Engine",
|
||||
"description": "Main section header for semantic engine"
|
||||
},
|
||||
"embeddingModelLabel": {
|
||||
"message": "Embedding Model",
|
||||
"description": "Main section header for model selection"
|
||||
},
|
||||
"indexDataManagementLabel": {
|
||||
"message": "Index Data Management",
|
||||
"description": "Main section header for data management"
|
||||
},
|
||||
"modelCacheManagementLabel": {
|
||||
"message": "Model Cache Management",
|
||||
"description": "Main section header for cache management"
|
||||
},
|
||||
"statusLabel": {
|
||||
"message": "Status",
|
||||
"description": "Generic status label"
|
||||
},
|
||||
"runningStatusLabel": {
|
||||
"message": "Running Status",
|
||||
"description": "Server running status label"
|
||||
},
|
||||
"connectionStatusLabel": {
|
||||
"message": "Connection Status",
|
||||
"description": "Connection status label"
|
||||
},
|
||||
"lastUpdatedLabel": {
|
||||
"message": "Last Updated:",
|
||||
"description": "Last updated timestamp label"
|
||||
},
|
||||
"connectButton": {
|
||||
"message": "Connect",
|
||||
"description": "Connect button text"
|
||||
},
|
||||
"disconnectButton": {
|
||||
"message": "Disconnect",
|
||||
"description": "Disconnect button text"
|
||||
},
|
||||
"connectingStatus": {
|
||||
"message": "Connecting...",
|
||||
"description": "Connecting status message"
|
||||
},
|
||||
"connectedStatus": {
|
||||
"message": "Connected",
|
||||
"description": "Connected status message"
|
||||
},
|
||||
"disconnectedStatus": {
|
||||
"message": "Disconnected",
|
||||
"description": "Disconnected status message"
|
||||
},
|
||||
"detectingStatus": {
|
||||
"message": "Detecting...",
|
||||
"description": "Detecting status message"
|
||||
},
|
||||
"serviceRunningStatus": {
|
||||
"message": "Service Running (Port: $PORT$)",
|
||||
"description": "Service running with port number",
|
||||
"placeholders": {
|
||||
"port": {
|
||||
"content": "$1",
|
||||
"example": "12306"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serviceNotConnectedStatus": {
|
||||
"message": "Service Not Connected",
|
||||
"description": "Service not connected status"
|
||||
},
|
||||
"connectedServiceNotStartedStatus": {
|
||||
"message": "Connected, Service Not Started",
|
||||
"description": "Connected but service not started status"
|
||||
},
|
||||
"mcpServerConfigLabel": {
|
||||
"message": "MCP Server Configuration",
|
||||
"description": "MCP server configuration section label"
|
||||
},
|
||||
"connectionPortLabel": {
|
||||
"message": "Connection Port",
|
||||
"description": "Connection port input label"
|
||||
},
|
||||
"refreshStatusButton": {
|
||||
"message": "Refresh Status",
|
||||
"description": "Refresh status button tooltip"
|
||||
},
|
||||
"copyConfigButton": {
|
||||
"message": "Copy Configuration",
|
||||
"description": "Copy configuration button text"
|
||||
},
|
||||
"retryButton": {
|
||||
"message": "Retry",
|
||||
"description": "Retry button text"
|
||||
},
|
||||
"cancelButton": {
|
||||
"message": "Cancel",
|
||||
"description": "Cancel button text"
|
||||
},
|
||||
"confirmButton": {
|
||||
"message": "Confirm",
|
||||
"description": "Confirm button text"
|
||||
},
|
||||
"saveButton": {
|
||||
"message": "Save",
|
||||
"description": "Save button text"
|
||||
},
|
||||
"closeButton": {
|
||||
"message": "Close",
|
||||
"description": "Close button text"
|
||||
},
|
||||
"resetButton": {
|
||||
"message": "Reset",
|
||||
"description": "Reset button text"
|
||||
},
|
||||
"initializingStatus": {
|
||||
"message": "Initializing...",
|
||||
"description": "Initializing progress message"
|
||||
},
|
||||
"processingStatus": {
|
||||
"message": "Processing...",
|
||||
"description": "Processing progress message"
|
||||
},
|
||||
"loadingStatus": {
|
||||
"message": "Loading...",
|
||||
"description": "Loading progress message"
|
||||
},
|
||||
"clearingStatus": {
|
||||
"message": "Clearing...",
|
||||
"description": "Clearing progress message"
|
||||
},
|
||||
"cleaningStatus": {
|
||||
"message": "Cleaning...",
|
||||
"description": "Cleaning progress message"
|
||||
},
|
||||
"downloadingStatus": {
|
||||
"message": "Downloading...",
|
||||
"description": "Downloading progress message"
|
||||
},
|
||||
"semanticEngineReadyStatus": {
|
||||
"message": "Semantic Engine Ready",
|
||||
"description": "Semantic engine ready status"
|
||||
},
|
||||
"semanticEngineInitializingStatus": {
|
||||
"message": "Semantic Engine Initializing...",
|
||||
"description": "Semantic engine initializing status"
|
||||
},
|
||||
"semanticEngineInitFailedStatus": {
|
||||
"message": "Semantic Engine Initialization Failed",
|
||||
"description": "Semantic engine initialization failed status"
|
||||
},
|
||||
"semanticEngineNotInitStatus": {
|
||||
"message": "Semantic Engine Not Initialized",
|
||||
"description": "Semantic engine not initialized status"
|
||||
},
|
||||
"initSemanticEngineButton": {
|
||||
"message": "Initialize Semantic Engine",
|
||||
"description": "Initialize semantic engine button text"
|
||||
},
|
||||
"reinitializeButton": {
|
||||
"message": "Reinitialize",
|
||||
"description": "Reinitialize button text"
|
||||
},
|
||||
"downloadingModelStatus": {
|
||||
"message": "Downloading Model... $PROGRESS$%",
|
||||
"description": "Model download progress with percentage",
|
||||
"placeholders": {
|
||||
"progress": {
|
||||
"content": "$1",
|
||||
"example": "50"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switchingModelStatus": {
|
||||
"message": "Switching Model...",
|
||||
"description": "Model switching progress message"
|
||||
},
|
||||
"modelLoadedStatus": {
|
||||
"message": "Model Loaded",
|
||||
"description": "Model successfully loaded status"
|
||||
},
|
||||
"modelFailedStatus": {
|
||||
"message": "Model Failed to Load",
|
||||
"description": "Model failed to load status"
|
||||
},
|
||||
"lightweightModelDescription": {
|
||||
"message": "Lightweight Multilingual Model",
|
||||
"description": "Description for lightweight model option"
|
||||
},
|
||||
"betterThanSmallDescription": {
|
||||
"message": "Slightly larger than e5-small, but better performance",
|
||||
"description": "Description for medium model option"
|
||||
},
|
||||
"multilingualModelDescription": {
|
||||
"message": "Multilingual Semantic Model",
|
||||
"description": "Description for multilingual model option"
|
||||
},
|
||||
"fastPerformance": {
|
||||
"message": "Fast",
|
||||
"description": "Fast performance indicator"
|
||||
},
|
||||
"balancedPerformance": {
|
||||
"message": "Balanced",
|
||||
"description": "Balanced performance indicator"
|
||||
},
|
||||
"accuratePerformance": {
|
||||
"message": "Accurate",
|
||||
"description": "Accurate performance indicator"
|
||||
},
|
||||
"networkErrorMessage": {
|
||||
"message": "Network connection error, please check network and retry",
|
||||
"description": "Network connection error message"
|
||||
},
|
||||
"modelCorruptedErrorMessage": {
|
||||
"message": "Model file corrupted or incomplete, please retry download",
|
||||
"description": "Model corruption error message"
|
||||
},
|
||||
"unknownErrorMessage": {
|
||||
"message": "Unknown error, please check if your network can access HuggingFace",
|
||||
"description": "Unknown error fallback message"
|
||||
},
|
||||
"permissionDeniedErrorMessage": {
|
||||
"message": "Permission denied",
|
||||
"description": "Permission denied error message"
|
||||
},
|
||||
"timeoutErrorMessage": {
|
||||
"message": "Operation timed out",
|
||||
"description": "Timeout error message"
|
||||
},
|
||||
"indexedPagesLabel": {
|
||||
"message": "Indexed Pages",
|
||||
"description": "Number of indexed pages label"
|
||||
},
|
||||
"indexSizeLabel": {
|
||||
"message": "Index Size",
|
||||
"description": "Index size label"
|
||||
},
|
||||
"activeTabsLabel": {
|
||||
"message": "Active Tabs",
|
||||
"description": "Number of active tabs label"
|
||||
},
|
||||
"vectorDocumentsLabel": {
|
||||
"message": "Vector Documents",
|
||||
"description": "Number of vector documents label"
|
||||
},
|
||||
"cacheSizeLabel": {
|
||||
"message": "Cache Size",
|
||||
"description": "Cache size label"
|
||||
},
|
||||
"cacheEntriesLabel": {
|
||||
"message": "Cache Entries",
|
||||
"description": "Number of cache entries label"
|
||||
},
|
||||
"clearAllDataButton": {
|
||||
"message": "Clear All Data",
|
||||
"description": "Clear all data button text"
|
||||
},
|
||||
"clearAllCacheButton": {
|
||||
"message": "Clear All Cache",
|
||||
"description": "Clear all cache button text"
|
||||
},
|
||||
"cleanExpiredCacheButton": {
|
||||
"message": "Clean Expired Cache",
|
||||
"description": "Clean expired cache button text"
|
||||
},
|
||||
"exportDataButton": {
|
||||
"message": "Export Data",
|
||||
"description": "Export data button text"
|
||||
},
|
||||
"importDataButton": {
|
||||
"message": "Import Data",
|
||||
"description": "Import data button text"
|
||||
},
|
||||
"confirmClearDataTitle": {
|
||||
"message": "Confirm Clear Data",
|
||||
"description": "Clear data confirmation dialog title"
|
||||
},
|
||||
"settingsTitle": {
|
||||
"message": "Settings",
|
||||
"description": "Settings dialog title"
|
||||
},
|
||||
"aboutTitle": {
|
||||
"message": "About",
|
||||
"description": "About dialog title"
|
||||
},
|
||||
"helpTitle": {
|
||||
"message": "Help",
|
||||
"description": "Help dialog title"
|
||||
},
|
||||
"clearDataWarningMessage": {
|
||||
"message": "This operation will clear all indexed webpage content and vector data, including:",
|
||||
"description": "Clear data warning message"
|
||||
},
|
||||
"clearDataList1": {
|
||||
"message": "All webpage text content index",
|
||||
"description": "First item in clear data list"
|
||||
},
|
||||
"clearDataList2": {
|
||||
"message": "Vector embedding data",
|
||||
"description": "Second item in clear data list"
|
||||
},
|
||||
"clearDataList3": {
|
||||
"message": "Search history and cache",
|
||||
"description": "Third item in clear data list"
|
||||
},
|
||||
"clearDataIrreversibleWarning": {
|
||||
"message": "This operation is irreversible! After clearing, you need to browse webpages again to rebuild the index.",
|
||||
"description": "Irreversible operation warning"
|
||||
},
|
||||
"confirmClearButton": {
|
||||
"message": "Confirm Clear",
|
||||
"description": "Confirm clear action button"
|
||||
},
|
||||
"cacheDetailsLabel": {
|
||||
"message": "Cache Details",
|
||||
"description": "Cache details section label"
|
||||
},
|
||||
"noCacheDataMessage": {
|
||||
"message": "No cache data",
|
||||
"description": "No cache data available message"
|
||||
},
|
||||
"loadingCacheInfoStatus": {
|
||||
"message": "Loading cache information...",
|
||||
"description": "Loading cache information status"
|
||||
},
|
||||
"processingCacheStatus": {
|
||||
"message": "Processing cache...",
|
||||
"description": "Processing cache status"
|
||||
},
|
||||
"expiredLabel": {
|
||||
"message": "Expired",
|
||||
"description": "Expired item label"
|
||||
},
|
||||
"bookmarksBarLabel": {
|
||||
"message": "Bookmarks Bar",
|
||||
"description": "Bookmarks bar folder name"
|
||||
},
|
||||
"newTabLabel": {
|
||||
"message": "New Tab",
|
||||
"description": "New tab label"
|
||||
},
|
||||
"currentPageLabel": {
|
||||
"message": "Current Page",
|
||||
"description": "Current page label"
|
||||
},
|
||||
"menuLabel": {
|
||||
"message": "Menu",
|
||||
"description": "Menu accessibility label"
|
||||
},
|
||||
"navigationLabel": {
|
||||
"message": "Navigation",
|
||||
"description": "Navigation accessibility label"
|
||||
},
|
||||
"mainContentLabel": {
|
||||
"message": "Main Content",
|
||||
"description": "Main content accessibility label"
|
||||
},
|
||||
"languageSelectorLabel": {
|
||||
"message": "Language",
|
||||
"description": "Language selector label"
|
||||
},
|
||||
"themeLabel": {
|
||||
"message": "Theme",
|
||||
"description": "Theme selector label"
|
||||
},
|
||||
"lightTheme": {
|
||||
"message": "Light",
|
||||
"description": "Light theme option"
|
||||
},
|
||||
"darkTheme": {
|
||||
"message": "Dark",
|
||||
"description": "Dark theme option"
|
||||
},
|
||||
"autoTheme": {
|
||||
"message": "Auto",
|
||||
"description": "Auto theme option"
|
||||
},
|
||||
"advancedSettingsLabel": {
|
||||
"message": "Advanced Settings",
|
||||
"description": "Advanced settings section label"
|
||||
},
|
||||
"debugModeLabel": {
|
||||
"message": "Debug Mode",
|
||||
"description": "Debug mode toggle label"
|
||||
},
|
||||
"verboseLoggingLabel": {
|
||||
"message": "Verbose Logging",
|
||||
"description": "Verbose logging toggle label"
|
||||
},
|
||||
"successNotification": {
|
||||
"message": "Operation completed successfully",
|
||||
"description": "Generic success notification"
|
||||
},
|
||||
"warningNotification": {
|
||||
"message": "Warning: Please review before proceeding",
|
||||
"description": "Generic warning notification"
|
||||
},
|
||||
"infoNotification": {
|
||||
"message": "Information",
|
||||
"description": "Generic info notification"
|
||||
},
|
||||
"configCopiedNotification": {
|
||||
"message": "Configuration copied to clipboard",
|
||||
"description": "Configuration copied success message"
|
||||
},
|
||||
"dataClearedNotification": {
|
||||
"message": "Data cleared successfully",
|
||||
"description": "Data cleared success message"
|
||||
},
|
||||
"bytesUnit": {
|
||||
"message": "bytes",
|
||||
"description": "Bytes unit"
|
||||
},
|
||||
"kilobytesUnit": {
|
||||
"message": "KB",
|
||||
"description": "Kilobytes unit"
|
||||
},
|
||||
"megabytesUnit": {
|
||||
"message": "MB",
|
||||
"description": "Megabytes unit"
|
||||
},
|
||||
"gigabytesUnit": {
|
||||
"message": "GB",
|
||||
"description": "Gigabytes unit"
|
||||
},
|
||||
"itemsUnit": {
|
||||
"message": "items",
|
||||
"description": "Items count unit"
|
||||
},
|
||||
"pagesUnit": {
|
||||
"message": "pages",
|
||||
"description": "Pages count unit"
|
||||
},
|
||||
"userscriptsManagerTitle": {
|
||||
"message": "Userscripts Manager",
|
||||
"description": "Options page title"
|
||||
},
|
||||
"emergencySwitchLabel": { "message": "Emergency Switch", "description": "Global disable switch" },
|
||||
"createRunSectionTitle": {
|
||||
"message": "Create / Run",
|
||||
"description": "Create & run section title"
|
||||
},
|
||||
"nameLabel": { "message": "Name", "description": "Name input label" },
|
||||
"runAtLabel": { "message": "Run At", "description": "runAt select label" },
|
||||
"runAtAuto": { "message": "auto", "description": "runAt auto" },
|
||||
"runAtDocumentStart": { "message": "document_start", "description": "runAt document_start" },
|
||||
"runAtDocumentEnd": { "message": "document_end", "description": "runAt document_end" },
|
||||
"runAtDocumentIdle": { "message": "document_idle", "description": "runAt document_idle" },
|
||||
"worldLabel": { "message": "World", "description": "world select label" },
|
||||
"worldAuto": { "message": "auto", "description": "world auto" },
|
||||
"worldIsolated": { "message": "ISOLATED", "description": "ISOLATED world" },
|
||||
"worldMain": { "message": "MAIN", "description": "MAIN world" },
|
||||
"modeLabel": { "message": "Mode", "description": "mode select label" },
|
||||
"modeAuto": { "message": "auto", "description": "mode auto" },
|
||||
"modePersistent": { "message": "persistent", "description": "mode persistent" },
|
||||
"modeCss": { "message": "css", "description": "mode css" },
|
||||
"modeOnce": { "message": "once", "description": "mode once" },
|
||||
"allFramesLabel": { "message": "All Frames", "description": "allFrames checkbox" },
|
||||
"persistLabel": { "message": "Persist", "description": "persist checkbox" },
|
||||
"dnrFallbackLabel": { "message": "DNR Fallback", "description": "dnr fallback checkbox" },
|
||||
"matchesInputLabel": { "message": "Matches (comma-separated)", "description": "matches input" },
|
||||
"excludesInputLabel": {
|
||||
"message": "Excludes (comma-separated)",
|
||||
"description": "excludes input"
|
||||
},
|
||||
"tagsInputLabel": { "message": "Tags (comma-separated)", "description": "tags input" },
|
||||
"scriptLabel": { "message": "Script", "description": "script textarea label" },
|
||||
"applyButton": { "message": "Apply", "description": "apply button" },
|
||||
"runOnceButton": { "message": "Run Once (CDP)", "description": "run once button" },
|
||||
"listSectionTitle": { "message": "List", "description": "list section title" },
|
||||
"queryLabel": { "message": "Query", "description": "query input label" },
|
||||
"statusAll": { "message": "all", "description": "status all" },
|
||||
"statusEnabled": { "message": "enabled", "description": "status enabled" },
|
||||
"statusDisabled": { "message": "disabled", "description": "status disabled" },
|
||||
"domainLabel": { "message": "Domain", "description": "domain filter label" },
|
||||
"exportAllButton": { "message": "Export All", "description": "export button" },
|
||||
"tableHeaderName": { "message": "Name", "description": "table header name" },
|
||||
"tableHeaderWorld": { "message": "World", "description": "table header world" },
|
||||
"tableHeaderRunAt": { "message": "Run At", "description": "table header runAt" },
|
||||
"tableHeaderUpdated": { "message": "Updated", "description": "table header updated" },
|
||||
"deleteButton": { "message": "Delete", "description": "delete button" },
|
||||
"placeholderOptional": { "message": "optional", "description": "generic optional placeholder" },
|
||||
"placeholderMatchesExample": {
|
||||
"message": "e.g. https://*.example.com/*",
|
||||
"description": "matches example placeholder"
|
||||
},
|
||||
"placeholderScriptHint": {
|
||||
"message": "Paste JS/CSS/TM here",
|
||||
"description": "script textarea placeholder"
|
||||
},
|
||||
"placeholderDomainHint": { "message": "example.com", "description": "domain filter placeholder" }
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "Chrome MCPサーバー"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "自身のChromeブラウザの機能を外部に公開します"
|
||||
},
|
||||
"nativeServerConfigLabel": {
|
||||
"message": "ネイティブサーバー設定"
|
||||
},
|
||||
"semanticEngineLabel": {
|
||||
"message": "セマンティックエンジン"
|
||||
},
|
||||
"embeddingModelLabel": {
|
||||
"message": "埋め込みモデル"
|
||||
},
|
||||
"indexDataManagementLabel": {
|
||||
"message": "インデックスデータ管理"
|
||||
},
|
||||
"modelCacheManagementLabel": {
|
||||
"message": "モデルキャッシュ管理"
|
||||
},
|
||||
"statusLabel": {
|
||||
"message": "ステータス"
|
||||
},
|
||||
"runningStatusLabel": {
|
||||
"message": "実行ステータス"
|
||||
},
|
||||
"connectionStatusLabel": {
|
||||
"message": "接続ステータス"
|
||||
},
|
||||
"lastUpdatedLabel": {
|
||||
"message": "最終更新:"
|
||||
},
|
||||
"connectButton": {
|
||||
"message": "接続"
|
||||
},
|
||||
"disconnectButton": {
|
||||
"message": "切断"
|
||||
},
|
||||
"connectingStatus": {
|
||||
"message": "接続中..."
|
||||
},
|
||||
"connectedStatus": {
|
||||
"message": "接続済み"
|
||||
},
|
||||
"disconnectedStatus": {
|
||||
"message": "未接続"
|
||||
},
|
||||
"detectingStatus": {
|
||||
"message": "検出中..."
|
||||
},
|
||||
"serviceRunningStatus": {
|
||||
"message": "サービス実行中 (ポート: $1)",
|
||||
"placeholders": {
|
||||
"port": {
|
||||
"content": "$1",
|
||||
"example": "12306"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serviceNotConnectedStatus": {
|
||||
"message": "サービス未接続"
|
||||
},
|
||||
"connectedServiceNotStartedStatus": {
|
||||
"message": "接続済み、サービス未起動"
|
||||
},
|
||||
"mcpServerConfigLabel": {
|
||||
"message": "MCPサーバー設定"
|
||||
},
|
||||
"connectionPortLabel": {
|
||||
"message": "接続ポート"
|
||||
},
|
||||
"refreshStatusButton": {
|
||||
"message": "ステータス更新"
|
||||
},
|
||||
"copyConfigButton": {
|
||||
"message": "設定をコピー"
|
||||
},
|
||||
"retryButton": {
|
||||
"message": "再試行"
|
||||
},
|
||||
"cancelButton": {
|
||||
"message": "キャンセル"
|
||||
},
|
||||
"confirmButton": {
|
||||
"message": "確認"
|
||||
},
|
||||
"saveButton": {
|
||||
"message": "保存"
|
||||
},
|
||||
"closeButton": {
|
||||
"message": "閉じる"
|
||||
},
|
||||
"resetButton": {
|
||||
"message": "リセット"
|
||||
},
|
||||
"initializingStatus": {
|
||||
"message": "初期化中..."
|
||||
},
|
||||
"processingStatus": {
|
||||
"message": "処理中..."
|
||||
},
|
||||
"loadingStatus": {
|
||||
"message": "読み込み中..."
|
||||
},
|
||||
"clearingStatus": {
|
||||
"message": "クリア中..."
|
||||
},
|
||||
"cleaningStatus": {
|
||||
"message": "クリーンアップ中..."
|
||||
},
|
||||
"downloadingStatus": {
|
||||
"message": "ダウンロード中..."
|
||||
},
|
||||
"semanticEngineReadyStatus": {
|
||||
"message": "セマンティックエンジン準備完了"
|
||||
},
|
||||
"semanticEngineInitializingStatus": {
|
||||
"message": "セマンティックエンジン初期化中..."
|
||||
},
|
||||
"semanticEngineInitFailedStatus": {
|
||||
"message": "セマンティックエンジンの初期化に失敗しました"
|
||||
},
|
||||
"semanticEngineNotInitStatus": {
|
||||
"message": "セマンティックエンジン未初期化"
|
||||
},
|
||||
"initSemanticEngineButton": {
|
||||
"message": "セマンティックエンジンを初期化"
|
||||
},
|
||||
"reinitializeButton": {
|
||||
"message": "再初期化"
|
||||
},
|
||||
"downloadingModelStatus": {
|
||||
"message": "モデルをダウンロード中... $1%",
|
||||
"placeholders": {
|
||||
"progress": {
|
||||
"content": "$1",
|
||||
"example": "50"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switchingModelStatus": {
|
||||
"message": "モデルを切り替え中..."
|
||||
},
|
||||
"modelLoadedStatus": {
|
||||
"message": "モデル読み込み完了"
|
||||
},
|
||||
"modelFailedStatus": {
|
||||
"message": "モデルの読み込みに失敗しました"
|
||||
},
|
||||
"lightweightModelDescription": {
|
||||
"message": "軽量多言語モデル"
|
||||
},
|
||||
"betterThanSmallDescription": {
|
||||
"message": "e5-smallよりわずかに大きいが、性能は向上"
|
||||
},
|
||||
"multilingualModelDescription": {
|
||||
"message": "多言語対応セマンティックモデル"
|
||||
},
|
||||
"fastPerformance": {
|
||||
"message": "高速"
|
||||
},
|
||||
"balancedPerformance": {
|
||||
"message": "バランス"
|
||||
},
|
||||
"accuratePerformance": {
|
||||
"message": "高精度"
|
||||
},
|
||||
"networkErrorMessage": {
|
||||
"message": "ネットワーク接続エラーです。ネットワークを確認して再試行してください"
|
||||
},
|
||||
"modelCorruptedErrorMessage": {
|
||||
"message": "モデルファイルが破損しているか不完全です。再ダウンロードしてください"
|
||||
},
|
||||
"unknownErrorMessage": {
|
||||
"message": "不明なエラーです。ネットワークがHuggingFaceにアクセスできるか確認してください"
|
||||
},
|
||||
"permissionDeniedErrorMessage": {
|
||||
"message": "権限が拒否されました"
|
||||
},
|
||||
"timeoutErrorMessage": {
|
||||
"message": "操作がタイムアウトしました"
|
||||
},
|
||||
"indexedPagesLabel": {
|
||||
"message": "インデックス化されたページ"
|
||||
},
|
||||
"indexSizeLabel": {
|
||||
"message": "インデックスサイズ"
|
||||
},
|
||||
"activeTabsLabel": {
|
||||
"message": "アクティブなタブ"
|
||||
},
|
||||
"vectorDocumentsLabel": {
|
||||
"message": "ベクトルドキュメント"
|
||||
},
|
||||
"cacheSizeLabel": {
|
||||
"message": "キャッシュサイズ"
|
||||
},
|
||||
"cacheEntriesLabel": {
|
||||
"message": "キャッシュエントリ"
|
||||
},
|
||||
"clearAllDataButton": {
|
||||
"message": "全データをクリア"
|
||||
},
|
||||
"clearAllCacheButton": {
|
||||
"message": "全キャッシュをクリア"
|
||||
},
|
||||
"cleanExpiredCacheButton": {
|
||||
"message": "期限切れキャッシュをクリーンアップ"
|
||||
},
|
||||
"exportDataButton": {
|
||||
"message": "データのエクスポート"
|
||||
},
|
||||
"importDataButton": {
|
||||
"message": "データのインポート"
|
||||
},
|
||||
"confirmClearDataTitle": {
|
||||
"message": "データクリアの確認"
|
||||
},
|
||||
"settingsTitle": {
|
||||
"message": "設定"
|
||||
},
|
||||
"aboutTitle": {
|
||||
"message": "情報"
|
||||
},
|
||||
"helpTitle": {
|
||||
"message": "ヘルプ"
|
||||
},
|
||||
"clearDataWarningMessage": {
|
||||
"message": "この操作は、インデックス化されたすべてのウェブページコンテンツとベクトルデータをクリアします。これには以下が含まれます:"
|
||||
},
|
||||
"clearDataList1": {
|
||||
"message": "すべてのウェブページテキストコンテンツインデックス"
|
||||
},
|
||||
"clearDataList2": {
|
||||
"message": "ベクトル埋め込みデータ"
|
||||
},
|
||||
"clearDataList3": {
|
||||
"message": "検索履歴とキャッシュ"
|
||||
},
|
||||
"clearDataIrreversibleWarning": {
|
||||
"message": "この操作は元に戻せません!クリア後、再度ウェブページを閲覧してインデックスを再構築する必要があります。"
|
||||
},
|
||||
"confirmClearButton": {
|
||||
"message": "クリアを確認"
|
||||
},
|
||||
"cacheDetailsLabel": {
|
||||
"message": "キャッシュ詳細"
|
||||
},
|
||||
"noCacheDataMessage": {
|
||||
"message": "キャッシュデータがありません"
|
||||
},
|
||||
"loadingCacheInfoStatus": {
|
||||
"message": "キャッシュ情報を読み込み中..."
|
||||
},
|
||||
"processingCacheStatus": {
|
||||
"message": "キャッシュを処理中..."
|
||||
},
|
||||
"expiredLabel": {
|
||||
"message": "期限切れ"
|
||||
},
|
||||
"bookmarksBarLabel": {
|
||||
"message": "ブックマークバー"
|
||||
},
|
||||
"newTabLabel": {
|
||||
"message": "新しいタブ"
|
||||
},
|
||||
"currentPageLabel": {
|
||||
"message": "現在のページ"
|
||||
},
|
||||
"menuLabel": {
|
||||
"message": "メニュー"
|
||||
},
|
||||
"navigationLabel": {
|
||||
"message": "ナビゲーション"
|
||||
},
|
||||
"mainContentLabel": {
|
||||
"message": "メインコンテンツ"
|
||||
},
|
||||
"languageSelectorLabel": {
|
||||
"message": "言語"
|
||||
},
|
||||
"themeLabel": {
|
||||
"message": "テーマ"
|
||||
},
|
||||
"lightTheme": {
|
||||
"message": "ライト"
|
||||
},
|
||||
"darkTheme": {
|
||||
"message": "ダーク"
|
||||
},
|
||||
"autoTheme": {
|
||||
"message": "自動"
|
||||
},
|
||||
"advancedSettingsLabel": {
|
||||
"message": "詳細設定"
|
||||
},
|
||||
"debugModeLabel": {
|
||||
"message": "デバッグモード"
|
||||
},
|
||||
"verboseLoggingLabel": {
|
||||
"message": "詳細ロギング"
|
||||
},
|
||||
"successNotification": {
|
||||
"message": "操作が正常に完了しました"
|
||||
},
|
||||
"warningNotification": {
|
||||
"message": "警告:続行する前に確認してください"
|
||||
},
|
||||
"infoNotification": {
|
||||
"message": "情報"
|
||||
},
|
||||
"configCopiedNotification": {
|
||||
"message": "設定がクリップボードにコピーされました"
|
||||
},
|
||||
"dataClearedNotification": {
|
||||
"message": "データが正常にクリアされました"
|
||||
},
|
||||
"bytesUnit": {
|
||||
"message": "バイト"
|
||||
},
|
||||
"kilobytesUnit": {
|
||||
"message": "KB"
|
||||
},
|
||||
"megabytesUnit": {
|
||||
"message": "MB"
|
||||
},
|
||||
"gigabytesUnit": {
|
||||
"message": "GB"
|
||||
},
|
||||
"itemsUnit": {
|
||||
"message": "項目"
|
||||
},
|
||||
"pagesUnit": {
|
||||
"message": "ページ"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "chrome-mcp-server",
|
||||
"description": "확장 프로그램 이름"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "크롬 브라우저와 연동하여 브라우저 기능을 제어하는 MCP 서버입니다.",
|
||||
"description": "확장 프로그램 설명"
|
||||
},
|
||||
"nativeServerConfigLabel": {
|
||||
"message": "네이티브 서버 설정",
|
||||
"description": "네이티브 서버 설정의 주 섹션 제목"
|
||||
},
|
||||
"semanticEngineLabel": {
|
||||
"message": "시맨틱 엔진",
|
||||
"description": "시맨틱 엔진의 주 섹션 제목"
|
||||
},
|
||||
"embeddingModelLabel": {
|
||||
"message": "임베딩 모델",
|
||||
"description": "모델 선택의 주 섹션 제목"
|
||||
},
|
||||
"indexDataManagementLabel": {
|
||||
"message": "인덱스 데이터 관리",
|
||||
"description": "데이터 관리의 주 섹션 제목"
|
||||
},
|
||||
"modelCacheManagementLabel": {
|
||||
"message": "모델 캐시 관리",
|
||||
"description": "캐시 관리의 주 섹션 제목"
|
||||
},
|
||||
"statusLabel": {
|
||||
"message": "상태",
|
||||
"description": "일반 상태 레이블"
|
||||
},
|
||||
"runningStatusLabel": {
|
||||
"message": "실행 상태",
|
||||
"description": "서버 실행 상태 레이블"
|
||||
},
|
||||
"connectionStatusLabel": {
|
||||
"message": "연결 상태",
|
||||
"description": "연결 상태 레이블"
|
||||
},
|
||||
"lastUpdatedLabel": {
|
||||
"message": "마지막 업데이트:",
|
||||
"description": "마지막 업데이트 타임스탬프 레이블"
|
||||
},
|
||||
"connectButton": {
|
||||
"message": "연결",
|
||||
"description": "연결 버튼 텍스트"
|
||||
},
|
||||
"disconnectButton": {
|
||||
"message": "연결 끊기",
|
||||
"description": "연결 끊기 버튼 텍스트"
|
||||
},
|
||||
"connectingStatus": {
|
||||
"message": "연결 중...",
|
||||
"description": "연결 상태 메시지"
|
||||
},
|
||||
"connectedStatus": {
|
||||
"message": "연결됨",
|
||||
"description": "연결된 상태 메시지"
|
||||
},
|
||||
"disconnectedStatus": {
|
||||
"message": "연결 끊김",
|
||||
"description": "연결이 끊긴 상태 메시지"
|
||||
},
|
||||
"detectingStatus": {
|
||||
"message": "감지 중...",
|
||||
"description": "감지 상태 메시지"
|
||||
},
|
||||
"serviceRunningStatus": {
|
||||
"message": "서비스 실행 중 (포트: $PORT$)",
|
||||
"description": "포트 번호와 함께 서비스 실행 중 상태",
|
||||
"placeholders": {
|
||||
"port": {
|
||||
"content": "$1",
|
||||
"example": "12306"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serviceNotConnectedStatus": {
|
||||
"message": "서비스에 연결되지 않음",
|
||||
"description": "서비스가 연결되지 않은 상태"
|
||||
},
|
||||
"connectedServiceNotStartedStatus": {
|
||||
"message": "연결됨, 서비스 시작되지 않음",
|
||||
"description": "연결되었지만 서비스가 시작되지 않은 상태"
|
||||
},
|
||||
"mcpServerConfigLabel": {
|
||||
"message": "MCP 서버 설정",
|
||||
"description": "MCP 서버 설정 섹션 레이블"
|
||||
},
|
||||
"connectionPortLabel": {
|
||||
"message": "연결 포트",
|
||||
"description": "연결 포트 입력 레이블"
|
||||
},
|
||||
"refreshStatusButton": {
|
||||
"message": "상태 새로고침",
|
||||
"description": "상태 새로고침 버튼 툴팁"
|
||||
},
|
||||
"copyConfigButton": {
|
||||
"message": "설정 복사",
|
||||
"description": "설정 복사 버튼 텍스트"
|
||||
},
|
||||
"retryButton": {
|
||||
"message": "재시도",
|
||||
"description": "재시도 버튼 텍스트"
|
||||
},
|
||||
"cancelButton": {
|
||||
"message": "취소",
|
||||
"description": "취소 버튼 텍스트"
|
||||
},
|
||||
"confirmButton": {
|
||||
"message": "확인",
|
||||
"description": "확인 버튼 텍스트"
|
||||
},
|
||||
"saveButton": {
|
||||
"message": "저장",
|
||||
"description": "저장 버튼 텍스트"
|
||||
},
|
||||
"closeButton": {
|
||||
"message": "닫기",
|
||||
"description": "닫기 버튼 텍스트"
|
||||
},
|
||||
"resetButton": {
|
||||
"message": "초기화",
|
||||
"description": "초기화 버튼 텍스트"
|
||||
},
|
||||
"initializingStatus": {
|
||||
"message": "초기화 중...",
|
||||
"description": "초기화 진행 메시지"
|
||||
},
|
||||
"processingStatus": {
|
||||
"message": "처리 중...",
|
||||
"description": "처리 진행 메시지"
|
||||
},
|
||||
"loadingStatus": {
|
||||
"message": "로드 중...",
|
||||
"description": "로드 진행 메시지"
|
||||
},
|
||||
"clearingStatus": {
|
||||
"message": "삭제 중...",
|
||||
"description": "삭제 진행 메시지"
|
||||
},
|
||||
"cleaningStatus": {
|
||||
"message": "정리 중...",
|
||||
"description": "정리 진행 메시지"
|
||||
},
|
||||
"downloadingStatus": {
|
||||
"message": "다운로드 중...",
|
||||
"description": "다운로드 진행 메시지"
|
||||
},
|
||||
"semanticEngineReadyStatus": {
|
||||
"message": "시맨틱 엔진 준비 완료",
|
||||
"description": "시맨틱 엔진 준비 완료 상태"
|
||||
},
|
||||
"semanticEngineInitializingStatus": {
|
||||
"message": "시맨틱 엔진 초기화 중...",
|
||||
"description": "시맨틱 엔진 초기화 상태"
|
||||
},
|
||||
"semanticEngineInitFailedStatus": {
|
||||
"message": "시맨틱 엔진 초기화 실패",
|
||||
"description": "시맨틱 엔진 초기화 실패 상태"
|
||||
},
|
||||
"semanticEngineNotInitStatus": {
|
||||
"message": "시맨틱 엔진이 초기화되지 않음",
|
||||
"description": "시맨틱 엔진이 초기화되지 않은 상태"
|
||||
},
|
||||
"initSemanticEngineButton": {
|
||||
"message": "시맨틱 엔진 초기화",
|
||||
"description": "시맨틱 엔진 초기화 버튼 텍스트"
|
||||
},
|
||||
"reinitializeButton": {
|
||||
"message": "재초기화",
|
||||
"description": "재초기화 버튼 텍스트"
|
||||
},
|
||||
"downloadingModelStatus": {
|
||||
"message": "모델 다운로드 중... $PROGRESS$%",
|
||||
"description": "백분율이 포함된 모델 다운로드 진행 상태",
|
||||
"placeholders": {
|
||||
"progress": {
|
||||
"content": "$1",
|
||||
"example": "50"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switchingModelStatus": {
|
||||
"message": "모델 전환 중...",
|
||||
"description": "모델 전환 진행 메시지"
|
||||
},
|
||||
"modelLoadedStatus": {
|
||||
"message": "모델 로드 완료",
|
||||
"description": "모델 로드 성공 상태"
|
||||
},
|
||||
"modelFailedStatus": {
|
||||
"message": "모델 로드 실패",
|
||||
"description": "모델 로드 실패 상태"
|
||||
},
|
||||
"lightweightModelDescription": {
|
||||
"message": "경량 다국어 모델",
|
||||
"description": "경량 모델 옵션 설명"
|
||||
},
|
||||
"betterThanSmallDescription": {
|
||||
"message": "e5-small보다 약간 크지만 성능이 더 좋습니다",
|
||||
"description": "중간 모델 옵션 설명"
|
||||
},
|
||||
"multilingualModelDescription": {
|
||||
"message": "다국어 시맨틱 모델",
|
||||
"description": "다국어 모델 옵션 설명"
|
||||
},
|
||||
"fastPerformance": {
|
||||
"message": "빠름",
|
||||
"description": "빠른 성능 표시"
|
||||
},
|
||||
"balancedPerformance": {
|
||||
"message": "균형",
|
||||
"description": "균형 잡힌 성능 표시"
|
||||
},
|
||||
"accuratePerformance": {
|
||||
"message": "정확",
|
||||
"description": "정확한 성능 표시"
|
||||
},
|
||||
"networkErrorMessage": {
|
||||
"message": "네트워크 연결 오류, 네트워크를 확인하고 다시 시도하세요",
|
||||
"description": "네트워크 연결 오류 메시지"
|
||||
},
|
||||
"modelCorruptedErrorMessage": {
|
||||
"message": "모델 파일이 손상되었거나 불완전합니다. 다운로드를 다시 시도하세요",
|
||||
"description": "모델 손상 오류 메시지"
|
||||
},
|
||||
"unknownErrorMessage": {
|
||||
"message": "알 수 없는 오류, 네트워크에서 HuggingFace에 접속할 수 있는지 확인하세요",
|
||||
"description": "알 수 없는 오류 대체 메시지"
|
||||
},
|
||||
"permissionDeniedErrorMessage": {
|
||||
"message": "권한이 거부되었습니다",
|
||||
"description": "권한 거부 오류 메시지"
|
||||
},
|
||||
"timeoutErrorMessage": {
|
||||
"message": "작업 시간 초과",
|
||||
"description": "시간 초과 오류 메시지"
|
||||
},
|
||||
"indexedPagesLabel": {
|
||||
"message": "인덱싱된 페이지",
|
||||
"description": "인덱싱된 페이지 수 레이블"
|
||||
},
|
||||
"indexSizeLabel": {
|
||||
"message": "인덱스 크기",
|
||||
"description": "인덱스 크기 레이블"
|
||||
},
|
||||
"activeTabsLabel": {
|
||||
"message": "활성 탭",
|
||||
"description": "활성 탭 수 레이블"
|
||||
},
|
||||
"vectorDocumentsLabel": {
|
||||
"message": "벡터 문서",
|
||||
"description": "벡터 문서 수 레이블"
|
||||
},
|
||||
"cacheSizeLabel": {
|
||||
"message": "캐시 크기",
|
||||
"description": "캐시 크기 레이블"
|
||||
},
|
||||
"cacheEntriesLabel": {
|
||||
"message": "캐시 항목",
|
||||
"description": "캐시 항목 수 레이블"
|
||||
},
|
||||
"clearAllDataButton": {
|
||||
"message": "모든 데이터 지우기",
|
||||
"description": "모든 데이터 지우기 버튼 텍스트"
|
||||
},
|
||||
"clearAllCacheButton": {
|
||||
"message": "모든 캐시 지우기",
|
||||
"description": "모든 캐시 지우기 버튼 텍스트"
|
||||
},
|
||||
"cleanExpiredCacheButton": {
|
||||
"message": "만료된 캐시 정리",
|
||||
"description": "만료된 캐시 정리 버튼 텍스트"
|
||||
},
|
||||
"exportDataButton": {
|
||||
"message": "데이터 내보내기",
|
||||
"description": "데이터 내보내기 버튼 텍스트"
|
||||
},
|
||||
"importDataButton": {
|
||||
"message": "데이터 가져오기",
|
||||
"description": "데이터 가져오기 버튼 텍스트"
|
||||
},
|
||||
"confirmClearDataTitle": {
|
||||
"message": "데이터 지우기 확인",
|
||||
"description": "데이터 지우기 확인 대화상자 제목"
|
||||
},
|
||||
"settingsTitle": {
|
||||
"message": "설정",
|
||||
"description": "설정 대화상자 제목"
|
||||
},
|
||||
"aboutTitle": {
|
||||
"message": "정보",
|
||||
"description": "정보 대화상자 제목"
|
||||
},
|
||||
"helpTitle": {
|
||||
"message": "도움말",
|
||||
"description": "도움말 대화상자 제목"
|
||||
},
|
||||
"clearDataWarningMessage": {
|
||||
"message": "이 작업은 다음을 포함한 모든 인덱싱된 웹페이지 콘텐츠와 벡터 데이터를 지웁니다:",
|
||||
"description": "데이터 지우기 경고 메시지"
|
||||
},
|
||||
"clearDataList1": {
|
||||
"message": "모든 웹페이지 텍스트 콘텐츠 인덱스",
|
||||
"description": "데이터 지우기 목록 첫 번째 항목"
|
||||
},
|
||||
"clearDataList2": {
|
||||
"message": "벡터 임베딩 데이터",
|
||||
"description": "데이터 지우기 목록 두 번째 항목"
|
||||
},
|
||||
"clearDataList3": {
|
||||
"message": "검색 기록 및 캐시",
|
||||
"description": "데이터 지우기 목록 세 번째 항목"
|
||||
},
|
||||
"clearDataIrreversibleWarning": {
|
||||
"message": "이 작업은 되돌릴 수 없습니다! 삭제 후에는 인덱스를 다시 생성하기 위해 웹페이지를 다시 방문해야 합니다.",
|
||||
"description": "되돌릴 수 없는 작업 경고"
|
||||
},
|
||||
"confirmClearButton": {
|
||||
"message": "삭제 확인",
|
||||
"description": "삭제 작업 확인 버튼"
|
||||
},
|
||||
"cacheDetailsLabel": {
|
||||
"message": "캐시 정보",
|
||||
"description": "캐시 정보 섹션 레이블"
|
||||
},
|
||||
"noCacheDataMessage": {
|
||||
"message": "캐시 데이터 없음",
|
||||
"description": "사용 가능한 캐시 데이터 없음 메시지"
|
||||
},
|
||||
"loadingCacheInfoStatus": {
|
||||
"message": "캐시 정보를 불러오는 중...",
|
||||
"description": "캐시 정보 로드 상태"
|
||||
},
|
||||
"processingCacheStatus": {
|
||||
"message": "캐시 처리 중...",
|
||||
"description": "캐시 처리 상태"
|
||||
},
|
||||
"expiredLabel": {
|
||||
"message": "만료됨",
|
||||
"description": "만료된 항목 레이블"
|
||||
},
|
||||
"bookmarksBarLabel": {
|
||||
"message": "북마크바",
|
||||
"description": "북마크바 폴더 이름"
|
||||
},
|
||||
"newTabLabel": {
|
||||
"message": "새 탭",
|
||||
"description": "새 탭 레이블"
|
||||
},
|
||||
"currentPageLabel": {
|
||||
"message": "현재 페이지",
|
||||
"description": "현재 페이지 레이블"
|
||||
},
|
||||
"menuLabel": {
|
||||
"message": "메뉴",
|
||||
"description": "메뉴 접근성 레이블"
|
||||
},
|
||||
"navigationLabel": {
|
||||
"message": "탐색",
|
||||
"description": "탐색 접근성 레이블"
|
||||
},
|
||||
"mainContentLabel": {
|
||||
"message": "주요 콘텐츠",
|
||||
"description": "주요 콘텐츠 접근성 레이블"
|
||||
},
|
||||
"languageSelectorLabel": {
|
||||
"message": "언어",
|
||||
"description": "언어 선택기 레이블"
|
||||
},
|
||||
"themeLabel": {
|
||||
"message": "테마",
|
||||
"description": "테마 선택기 레이블"
|
||||
},
|
||||
"lightTheme": {
|
||||
"message": "라이트",
|
||||
"description": "라이트 테마 옵션"
|
||||
},
|
||||
"darkTheme": {
|
||||
"message": "다크",
|
||||
"description": "다크 테마 옵션"
|
||||
},
|
||||
"autoTheme": {
|
||||
"message": "자동",
|
||||
"description": "자동 테마 옵션"
|
||||
},
|
||||
"advancedSettingsLabel": {
|
||||
"message": "고급 설정",
|
||||
"description": "고급 설정 섹션 레이블"
|
||||
},
|
||||
"debugModeLabel": {
|
||||
"message": "디버그 모드",
|
||||
"description": "디버그 모드 토글 레이블"
|
||||
},
|
||||
"verboseLoggingLabel": {
|
||||
"message": "상세 로깅",
|
||||
"description": "상세 로깅 토글 레이블"
|
||||
},
|
||||
"successNotification": {
|
||||
"message": "작업이 성공적으로 완료되었습니다",
|
||||
"description": "일반 성공 알림"
|
||||
},
|
||||
"warningNotification": {
|
||||
"message": "경고: 계속하기 전에 검토하세요",
|
||||
"description": "일반 경고 알림"
|
||||
},
|
||||
"infoNotification": {
|
||||
"message": "정보",
|
||||
"description": "일반 정보 알림"
|
||||
},
|
||||
"configCopiedNotification": {
|
||||
"message": "설정이 클립보드에 복사되었습니다",
|
||||
"description": "설정 복사 성공 메시지"
|
||||
},
|
||||
"dataClearedNotification": {
|
||||
"message": "데이터가 성공적으로 삭제되었습니다",
|
||||
"description": "데이터 삭제 성공 메시지"
|
||||
},
|
||||
"bytesUnit": {
|
||||
"message": "바이트",
|
||||
"description": "바이트 단위"
|
||||
},
|
||||
"kilobytesUnit": {
|
||||
"message": "KB",
|
||||
"description": "킬로바이트 단위"
|
||||
},
|
||||
"megabytesUnit": {
|
||||
"message": "MB",
|
||||
"description": "메가바이트 단위"
|
||||
},
|
||||
"gigabytesUnit": {
|
||||
"message": "GB",
|
||||
"description": "기가바이트 단위"
|
||||
},
|
||||
"itemsUnit": {
|
||||
"message": "개",
|
||||
"description": "항목 개수 단위"
|
||||
},
|
||||
"pagesUnit": {
|
||||
"message": "페이지",
|
||||
"description": "페이지 수 단위"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "chrome-mcp-server",
|
||||
"description": "扩展名称"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "使用你自己的 Chrome 浏览器暴露浏览器功能",
|
||||
"description": "扩展描述"
|
||||
},
|
||||
"nativeServerConfigLabel": {
|
||||
"message": "Native Server 配置",
|
||||
"description": "本地服务器设置的主要节标题"
|
||||
},
|
||||
"semanticEngineLabel": {
|
||||
"message": "语义引擎",
|
||||
"description": "语义引擎的主要节标题"
|
||||
},
|
||||
"embeddingModelLabel": {
|
||||
"message": "Embedding模型",
|
||||
"description": "模型选择的主要节标题"
|
||||
},
|
||||
"indexDataManagementLabel": {
|
||||
"message": "索引数据管理",
|
||||
"description": "数据管理的主要节标题"
|
||||
},
|
||||
"modelCacheManagementLabel": {
|
||||
"message": "模型缓存管理",
|
||||
"description": "缓存管理的主要节标题"
|
||||
},
|
||||
"statusLabel": {
|
||||
"message": "状态",
|
||||
"description": "通用状态标签"
|
||||
},
|
||||
"runningStatusLabel": {
|
||||
"message": "运行状态",
|
||||
"description": "服务器运行状态标签"
|
||||
},
|
||||
"connectionStatusLabel": {
|
||||
"message": "连接状态",
|
||||
"description": "连接状态标签"
|
||||
},
|
||||
"lastUpdatedLabel": {
|
||||
"message": "最后更新:",
|
||||
"description": "最后更新时间戳标签"
|
||||
},
|
||||
"connectButton": {
|
||||
"message": "连接",
|
||||
"description": "连接按钮文本"
|
||||
},
|
||||
"disconnectButton": {
|
||||
"message": "断开",
|
||||
"description": "断开连接按钮文本"
|
||||
},
|
||||
"connectingStatus": {
|
||||
"message": "连接中...",
|
||||
"description": "连接状态消息"
|
||||
},
|
||||
"connectedStatus": {
|
||||
"message": "已连接",
|
||||
"description": "已连接状态消息"
|
||||
},
|
||||
"disconnectedStatus": {
|
||||
"message": "已断开",
|
||||
"description": "已断开状态消息"
|
||||
},
|
||||
"detectingStatus": {
|
||||
"message": "检测中...",
|
||||
"description": "检测状态消息"
|
||||
},
|
||||
"serviceRunningStatus": {
|
||||
"message": "服务运行中 (端口: $PORT$)",
|
||||
"description": "带端口号的服务运行状态",
|
||||
"placeholders": {
|
||||
"port": {
|
||||
"content": "$1",
|
||||
"example": "12306"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serviceNotConnectedStatus": {
|
||||
"message": "服务未连接",
|
||||
"description": "服务未连接状态"
|
||||
},
|
||||
"connectedServiceNotStartedStatus": {
|
||||
"message": "已连接,服务未启动",
|
||||
"description": "已连接但服务未启动状态"
|
||||
},
|
||||
"mcpServerConfigLabel": {
|
||||
"message": "MCP 服务器配置",
|
||||
"description": "MCP 服务器配置节标签"
|
||||
},
|
||||
"connectionPortLabel": {
|
||||
"message": "连接端口",
|
||||
"description": "连接端口输入标签"
|
||||
},
|
||||
"refreshStatusButton": {
|
||||
"message": "刷新状态",
|
||||
"description": "刷新状态按钮提示"
|
||||
},
|
||||
"copyConfigButton": {
|
||||
"message": "复制配置",
|
||||
"description": "复制配置按钮文本"
|
||||
},
|
||||
"retryButton": {
|
||||
"message": "重试",
|
||||
"description": "重试按钮文本"
|
||||
},
|
||||
"cancelButton": {
|
||||
"message": "取消",
|
||||
"description": "取消按钮文本"
|
||||
},
|
||||
"confirmButton": {
|
||||
"message": "确认",
|
||||
"description": "确认按钮文本"
|
||||
},
|
||||
"saveButton": {
|
||||
"message": "保存",
|
||||
"description": "保存按钮文本"
|
||||
},
|
||||
"closeButton": {
|
||||
"message": "关闭",
|
||||
"description": "关闭按钮文本"
|
||||
},
|
||||
"resetButton": {
|
||||
"message": "重置",
|
||||
"description": "重置按钮文本"
|
||||
},
|
||||
"initializingStatus": {
|
||||
"message": "初始化中...",
|
||||
"description": "初始化进度消息"
|
||||
},
|
||||
"processingStatus": {
|
||||
"message": "处理中...",
|
||||
"description": "处理进度消息"
|
||||
},
|
||||
"loadingStatus": {
|
||||
"message": "加载中...",
|
||||
"description": "加载进度消息"
|
||||
},
|
||||
"clearingStatus": {
|
||||
"message": "清空中...",
|
||||
"description": "清空进度消息"
|
||||
},
|
||||
"cleaningStatus": {
|
||||
"message": "清理中...",
|
||||
"description": "清理进度消息"
|
||||
},
|
||||
"downloadingStatus": {
|
||||
"message": "下载中...",
|
||||
"description": "下载进度消息"
|
||||
},
|
||||
"semanticEngineReadyStatus": {
|
||||
"message": "语义引擎已就绪",
|
||||
"description": "语义引擎就绪状态"
|
||||
},
|
||||
"semanticEngineInitializingStatus": {
|
||||
"message": "语义引擎初始化中...",
|
||||
"description": "语义引擎初始化状态"
|
||||
},
|
||||
"semanticEngineInitFailedStatus": {
|
||||
"message": "语义引擎初始化失败",
|
||||
"description": "语义引擎初始化失败状态"
|
||||
},
|
||||
"semanticEngineNotInitStatus": {
|
||||
"message": "语义引擎未初始化",
|
||||
"description": "语义引擎未初始化状态"
|
||||
},
|
||||
"initSemanticEngineButton": {
|
||||
"message": "初始化语义引擎",
|
||||
"description": "初始化语义引擎按钮文本"
|
||||
},
|
||||
"reinitializeButton": {
|
||||
"message": "重新初始化",
|
||||
"description": "重新初始化按钮文本"
|
||||
},
|
||||
"downloadingModelStatus": {
|
||||
"message": "下载模型中... $PROGRESS$%",
|
||||
"description": "带百分比的模型下载进度",
|
||||
"placeholders": {
|
||||
"progress": {
|
||||
"content": "$1",
|
||||
"example": "50"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switchingModelStatus": {
|
||||
"message": "切换模型中...",
|
||||
"description": "模型切换进度消息"
|
||||
},
|
||||
"modelLoadedStatus": {
|
||||
"message": "模型已加载",
|
||||
"description": "模型成功加载状态"
|
||||
},
|
||||
"modelFailedStatus": {
|
||||
"message": "模型加载失败",
|
||||
"description": "模型加载失败状态"
|
||||
},
|
||||
"lightweightModelDescription": {
|
||||
"message": "轻量级多语言模型",
|
||||
"description": "轻量级模型选项的描述"
|
||||
},
|
||||
"betterThanSmallDescription": {
|
||||
"message": "比e5-small稍大,但效果更好",
|
||||
"description": "中等模型选项的描述"
|
||||
},
|
||||
"multilingualModelDescription": {
|
||||
"message": "多语言语义模型",
|
||||
"description": "多语言模型选项的描述"
|
||||
},
|
||||
"fastPerformance": {
|
||||
"message": "快速",
|
||||
"description": "快速性能指示器"
|
||||
},
|
||||
"balancedPerformance": {
|
||||
"message": "平衡",
|
||||
"description": "平衡性能指示器"
|
||||
},
|
||||
"accuratePerformance": {
|
||||
"message": "精确",
|
||||
"description": "精确性能指示器"
|
||||
},
|
||||
"networkErrorMessage": {
|
||||
"message": "网络连接错误,请检查网络连接后重试",
|
||||
"description": "网络连接错误消息"
|
||||
},
|
||||
"modelCorruptedErrorMessage": {
|
||||
"message": "模型文件损坏或不完整,请重试下载",
|
||||
"description": "模型损坏错误消息"
|
||||
},
|
||||
"unknownErrorMessage": {
|
||||
"message": "未知错误,请检查你的网络是否可以访问HuggingFace",
|
||||
"description": "未知错误回退消息"
|
||||
},
|
||||
"permissionDeniedErrorMessage": {
|
||||
"message": "权限被拒绝",
|
||||
"description": "权限被拒绝错误消息"
|
||||
},
|
||||
"timeoutErrorMessage": {
|
||||
"message": "操作超时",
|
||||
"description": "超时错误消息"
|
||||
},
|
||||
"indexedPagesLabel": {
|
||||
"message": "已索引页面",
|
||||
"description": "已索引页面数量标签"
|
||||
},
|
||||
"indexSizeLabel": {
|
||||
"message": "索引大小",
|
||||
"description": "索引大小标签"
|
||||
},
|
||||
"activeTabsLabel": {
|
||||
"message": "活跃标签页",
|
||||
"description": "活跃标签页数量标签"
|
||||
},
|
||||
"vectorDocumentsLabel": {
|
||||
"message": "向量文档",
|
||||
"description": "向量文档数量标签"
|
||||
},
|
||||
"cacheSizeLabel": {
|
||||
"message": "缓存大小",
|
||||
"description": "缓存大小标签"
|
||||
},
|
||||
"cacheEntriesLabel": {
|
||||
"message": "缓存条目",
|
||||
"description": "缓存条目数量标签"
|
||||
},
|
||||
"clearAllDataButton": {
|
||||
"message": "清空所有数据",
|
||||
"description": "清空所有数据按钮文本"
|
||||
},
|
||||
"clearAllCacheButton": {
|
||||
"message": "清空所有缓存",
|
||||
"description": "清空所有缓存按钮文本"
|
||||
},
|
||||
"cleanExpiredCacheButton": {
|
||||
"message": "清理过期缓存",
|
||||
"description": "清理过期缓存按钮文本"
|
||||
},
|
||||
"exportDataButton": {
|
||||
"message": "导出数据",
|
||||
"description": "导出数据按钮文本"
|
||||
},
|
||||
"importDataButton": {
|
||||
"message": "导入数据",
|
||||
"description": "导入数据按钮文本"
|
||||
},
|
||||
"confirmClearDataTitle": {
|
||||
"message": "确认清空数据",
|
||||
"description": "清空数据确认对话框标题"
|
||||
},
|
||||
"settingsTitle": {
|
||||
"message": "设置",
|
||||
"description": "设置对话框标题"
|
||||
},
|
||||
"aboutTitle": {
|
||||
"message": "关于",
|
||||
"description": "关于对话框标题"
|
||||
},
|
||||
"helpTitle": {
|
||||
"message": "帮助",
|
||||
"description": "帮助对话框标题"
|
||||
},
|
||||
"clearDataWarningMessage": {
|
||||
"message": "此操作将清空所有已索引的网页内容和向量数据,包括:",
|
||||
"description": "清空数据警告消息"
|
||||
},
|
||||
"clearDataList1": {
|
||||
"message": "所有网页的文本内容索引",
|
||||
"description": "清空数据列表第一项"
|
||||
},
|
||||
"clearDataList2": {
|
||||
"message": "向量嵌入数据",
|
||||
"description": "清空数据列表第二项"
|
||||
},
|
||||
"clearDataList3": {
|
||||
"message": "搜索历史和缓存",
|
||||
"description": "清空数据列表第三项"
|
||||
},
|
||||
"clearDataIrreversibleWarning": {
|
||||
"message": "此操作不可撤销!清空后需要重新浏览网页来重建索引。",
|
||||
"description": "不可逆操作警告"
|
||||
},
|
||||
"confirmClearButton": {
|
||||
"message": "确认清空",
|
||||
"description": "确认清空操作按钮"
|
||||
},
|
||||
"cacheDetailsLabel": {
|
||||
"message": "缓存详情",
|
||||
"description": "缓存详情节标签"
|
||||
},
|
||||
"noCacheDataMessage": {
|
||||
"message": "暂无缓存数据",
|
||||
"description": "无缓存数据可用消息"
|
||||
},
|
||||
"loadingCacheInfoStatus": {
|
||||
"message": "正在加载缓存信息...",
|
||||
"description": "加载缓存信息状态"
|
||||
},
|
||||
"processingCacheStatus": {
|
||||
"message": "处理缓存中...",
|
||||
"description": "处理缓存状态"
|
||||
},
|
||||
"expiredLabel": {
|
||||
"message": "已过期",
|
||||
"description": "过期项标签"
|
||||
},
|
||||
"bookmarksBarLabel": {
|
||||
"message": "书签栏",
|
||||
"description": "书签栏文件夹名称"
|
||||
},
|
||||
"newTabLabel": {
|
||||
"message": "新标签页",
|
||||
"description": "新标签页标签"
|
||||
},
|
||||
"currentPageLabel": {
|
||||
"message": "当前页面",
|
||||
"description": "当前页面标签"
|
||||
},
|
||||
"menuLabel": {
|
||||
"message": "菜单",
|
||||
"description": "菜单辅助功能标签"
|
||||
},
|
||||
"navigationLabel": {
|
||||
"message": "导航",
|
||||
"description": "导航辅助功能标签"
|
||||
},
|
||||
"mainContentLabel": {
|
||||
"message": "主要内容",
|
||||
"description": "主要内容辅助功能标签"
|
||||
},
|
||||
"languageSelectorLabel": {
|
||||
"message": "语言",
|
||||
"description": "语言选择器标签"
|
||||
},
|
||||
"themeLabel": {
|
||||
"message": "主题",
|
||||
"description": "主题选择器标签"
|
||||
},
|
||||
"lightTheme": {
|
||||
"message": "浅色",
|
||||
"description": "浅色主题选项"
|
||||
},
|
||||
"darkTheme": {
|
||||
"message": "深色",
|
||||
"description": "深色主题选项"
|
||||
},
|
||||
"autoTheme": {
|
||||
"message": "自动",
|
||||
"description": "自动主题选项"
|
||||
},
|
||||
"advancedSettingsLabel": {
|
||||
"message": "高级设置",
|
||||
"description": "高级设置节标签"
|
||||
},
|
||||
"debugModeLabel": {
|
||||
"message": "调试模式",
|
||||
"description": "调试模式切换标签"
|
||||
},
|
||||
"verboseLoggingLabel": {
|
||||
"message": "详细日志",
|
||||
"description": "详细日志切换标签"
|
||||
},
|
||||
"successNotification": {
|
||||
"message": "操作成功完成",
|
||||
"description": "通用成功通知"
|
||||
},
|
||||
"warningNotification": {
|
||||
"message": "警告:请在继续之前检查",
|
||||
"description": "通用警告通知"
|
||||
},
|
||||
"infoNotification": {
|
||||
"message": "信息",
|
||||
"description": "通用信息通知"
|
||||
},
|
||||
"configCopiedNotification": {
|
||||
"message": "配置已复制到剪贴板",
|
||||
"description": "配置复制成功消息"
|
||||
},
|
||||
"dataClearedNotification": {
|
||||
"message": "数据清空成功",
|
||||
"description": "数据清空成功消息"
|
||||
},
|
||||
"bytesUnit": {
|
||||
"message": "字节",
|
||||
"description": "字节单位"
|
||||
},
|
||||
"kilobytesUnit": {
|
||||
"message": "KB",
|
||||
"description": "千字节单位"
|
||||
},
|
||||
"megabytesUnit": {
|
||||
"message": "MB",
|
||||
"description": "兆字节单位"
|
||||
},
|
||||
"gigabytesUnit": {
|
||||
"message": "GB",
|
||||
"description": "吉字节单位"
|
||||
},
|
||||
"itemsUnit": {
|
||||
"message": "项",
|
||||
"description": "项目计数单位"
|
||||
},
|
||||
"pagesUnit": {
|
||||
"message": "页",
|
||||
"description": "页面计数单位"
|
||||
},
|
||||
"userscriptsManagerTitle": { "message": "脚本管理器", "description": "Options 页标题" },
|
||||
"emergencySwitchLabel": { "message": "紧急开关", "description": "紧急关闭开关" },
|
||||
"createRunSectionTitle": { "message": "创建 / 运行", "description": "创建与运行分区标题" },
|
||||
"nameLabel": { "message": "名称", "description": "名称输入标签" },
|
||||
"runAtLabel": { "message": "运行时机", "description": "runAt 选择标签" },
|
||||
"runAtAuto": { "message": "自动", "description": "runAt auto" },
|
||||
"runAtDocumentStart": { "message": "document_start", "description": "runAt document_start" },
|
||||
"runAtDocumentEnd": { "message": "document_end", "description": "runAt document_end" },
|
||||
"runAtDocumentIdle": { "message": "document_idle", "description": "runAt document_idle" },
|
||||
"worldLabel": { "message": "执行上下文", "description": "world 选择标签" },
|
||||
"worldAuto": { "message": "自动", "description": "world auto" },
|
||||
"worldIsolated": { "message": "隔离 (ISOLATED)", "description": "ISOLATED world" },
|
||||
"worldMain": { "message": "页面 (MAIN)", "description": "MAIN world" },
|
||||
"modeLabel": { "message": "模式", "description": "模式选择标签" },
|
||||
"modeAuto": { "message": "自动", "description": "mode auto" },
|
||||
"modePersistent": { "message": "持久", "description": "mode persistent" },
|
||||
"modeCss": { "message": "仅样式 (CSS)", "description": "mode css" },
|
||||
"modeOnce": { "message": "一次运行 (CDP)", "description": "mode once" },
|
||||
"allFramesLabel": { "message": "全部 frame", "description": "allFrames 复选框" },
|
||||
"persistLabel": { "message": "持久化", "description": "persist 复选框" },
|
||||
"dnrFallbackLabel": { "message": "DNR 回退", "description": "DNR fallback 复选框" },
|
||||
"matchesInputLabel": { "message": "匹配(逗号分隔)", "description": "matches 输入" },
|
||||
"excludesInputLabel": { "message": "排除(逗号分隔)", "description": "excludes 输入" },
|
||||
"tagsInputLabel": { "message": "标签(逗号分隔)", "description": "tags 输入" },
|
||||
"scriptLabel": { "message": "脚本", "description": "脚本文本标签" },
|
||||
"applyButton": { "message": "应用", "description": "应用按钮" },
|
||||
"runOnceButton": { "message": "一次运行(CDP)", "description": "一次运行按钮" },
|
||||
"listSectionTitle": { "message": "脚本列表", "description": "列表分区标题" },
|
||||
"queryLabel": { "message": "搜索", "description": "查询输入标签" },
|
||||
"statusAll": { "message": "全部", "description": "状态-全部" },
|
||||
"statusEnabled": { "message": "启用", "description": "状态-启用" },
|
||||
"statusDisabled": { "message": "禁用", "description": "状态-禁用" },
|
||||
"domainLabel": { "message": "域名", "description": "域名过滤标签" },
|
||||
"exportAllButton": { "message": "导出全部", "description": "导出按钮" },
|
||||
"tableHeaderName": { "message": "名称", "description": "表头-名称" },
|
||||
"tableHeaderWorld": { "message": "执行上下文", "description": "表头-World" },
|
||||
"tableHeaderRunAt": { "message": "运行时机", "description": "表头-RunAt" },
|
||||
"tableHeaderUpdated": { "message": "更新时间", "description": "表头-更新时间" },
|
||||
"deleteButton": { "message": "删除", "description": "删除按钮" },
|
||||
"placeholderOptional": { "message": "可选", "description": "通用可选占位符" },
|
||||
"placeholderMatchesExample": {
|
||||
"message": "例如:https://*.example.com/*",
|
||||
"description": "匹配示例占位符"
|
||||
},
|
||||
"placeholderScriptHint": { "message": "在此粘贴 JS/CSS/TM", "description": "脚本文本域占位符" },
|
||||
"placeholderDomainHint": { "message": "example.com", "description": "域名筛选占位符" }
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
{
|
||||
"extensionName": {
|
||||
"message": "chrome-mcp-server",
|
||||
"description": "擴充功能名稱"
|
||||
},
|
||||
"extensionDescription": {
|
||||
"message": "使用您自己的 Chrome 瀏覽器暴露瀏覽器功能",
|
||||
"description": "擴充功能描述"
|
||||
},
|
||||
"nativeServerConfigLabel": {
|
||||
"message": "原生伺服器設定",
|
||||
"description": "本機伺服器設定的主要區段標題"
|
||||
},
|
||||
"semanticEngineLabel": {
|
||||
"message": "語意引擎",
|
||||
"description": "語意引擎的主要區段標題"
|
||||
},
|
||||
"embeddingModelLabel": {
|
||||
"message": "Embedding 模型",
|
||||
"description": "模型選擇的主要區段標題"
|
||||
},
|
||||
"indexDataManagementLabel": {
|
||||
"message": "索引資料管理",
|
||||
"description": "資料管理主要區段標題"
|
||||
},
|
||||
"modelCacheManagementLabel": {
|
||||
"message": "模型快取管理",
|
||||
"description": "快取管理主要區段標題"
|
||||
},
|
||||
"statusLabel": {
|
||||
"message": "狀態",
|
||||
"description": "通用狀態標籤"
|
||||
},
|
||||
"runningStatusLabel": {
|
||||
"message": "執行狀態",
|
||||
"description": "伺服器執行狀態標籤"
|
||||
},
|
||||
"connectionStatusLabel": {
|
||||
"message": "連線狀態",
|
||||
"description": "連線狀態標籤"
|
||||
},
|
||||
"lastUpdatedLabel": {
|
||||
"message": "最後更新:",
|
||||
"description": "最後更新時間戳標籤"
|
||||
},
|
||||
"connectButton": {
|
||||
"message": "連線",
|
||||
"description": "連線按鈕文字"
|
||||
},
|
||||
"disconnectButton": {
|
||||
"message": "中斷連線",
|
||||
"description": "中斷連線按鈕文字"
|
||||
},
|
||||
"connectingStatus": {
|
||||
"message": "連線中...",
|
||||
"description": "連線狀態訊息"
|
||||
},
|
||||
"connectedStatus": {
|
||||
"message": "已連線",
|
||||
"description": "已連線狀態訊息"
|
||||
},
|
||||
"disconnectedStatus": {
|
||||
"message": "已中斷",
|
||||
"description": "已中斷狀態訊息"
|
||||
},
|
||||
"detectingStatus": {
|
||||
"message": "偵測中...",
|
||||
"description": "偵測狀態訊息"
|
||||
},
|
||||
"serviceRunningStatus": {
|
||||
"message": "服務執行中 (連結埠: $PORT$)",
|
||||
"description": "含連結埠號的服務執行狀態",
|
||||
"placeholders": {
|
||||
"port": {
|
||||
"content": "$1",
|
||||
"example": "12306"
|
||||
}
|
||||
}
|
||||
},
|
||||
"serviceNotConnectedStatus": {
|
||||
"message": "服務未連線",
|
||||
"description": "服務未連線狀態"
|
||||
},
|
||||
"connectedServiceNotStartedStatus": {
|
||||
"message": "已連線,服務未啟動",
|
||||
"description": "已連線但服務未啟動狀態"
|
||||
},
|
||||
"mcpServerConfigLabel": {
|
||||
"message": "MCP 伺服器設定",
|
||||
"description": "MCP 伺服器設定區段標籤"
|
||||
},
|
||||
"connectionPortLabel": {
|
||||
"message": "連結埠",
|
||||
"description": "連結埠輸入標籤"
|
||||
},
|
||||
"refreshStatusButton": {
|
||||
"message": "重新整理狀態",
|
||||
"description": "重新整理狀態按鈕提示"
|
||||
},
|
||||
"copyConfigButton": {
|
||||
"message": "複製設定",
|
||||
"description": "複製設定按鈕文字"
|
||||
},
|
||||
"retryButton": {
|
||||
"message": "重試",
|
||||
"description": "重試按鈕文字"
|
||||
},
|
||||
"cancelButton": {
|
||||
"message": "取消",
|
||||
"description": "取消按鈕文字"
|
||||
},
|
||||
"confirmButton": {
|
||||
"message": "確認",
|
||||
"description": "確認按鈕文字"
|
||||
},
|
||||
"saveButton": {
|
||||
"message": "儲存",
|
||||
"description": "儲存按鈕文字"
|
||||
},
|
||||
"closeButton": {
|
||||
"message": "關閉",
|
||||
"description": "關閉按鈕文字"
|
||||
},
|
||||
"resetButton": {
|
||||
"message": "重設",
|
||||
"description": "重設按鈕文字"
|
||||
},
|
||||
"initializingStatus": {
|
||||
"message": "初始化中...",
|
||||
"description": "初始化進度訊息"
|
||||
},
|
||||
"processingStatus": {
|
||||
"message": "處理中...",
|
||||
"description": "處理進度訊息"
|
||||
},
|
||||
"loadingStatus": {
|
||||
"message": "載入中...",
|
||||
"description": "載入進度訊息"
|
||||
},
|
||||
"clearingStatus": {
|
||||
"message": "清除中...",
|
||||
"description": "清除進度訊息"
|
||||
},
|
||||
"cleaningStatus": {
|
||||
"message": "清理中...",
|
||||
"description": "清理進度訊息"
|
||||
},
|
||||
"downloadingStatus": {
|
||||
"message": "下載中...",
|
||||
"description": "下載進度訊息"
|
||||
},
|
||||
"semanticEngineReadyStatus": {
|
||||
"message": "語意引擎已就緒",
|
||||
"description": "語意引擎就緒狀態"
|
||||
},
|
||||
"semanticEngineInitializingStatus": {
|
||||
"message": "語意引擎初始化中...",
|
||||
"description": "語意引擎初始化狀態"
|
||||
},
|
||||
"semanticEngineInitFailedStatus": {
|
||||
"message": "語意引擎初始化失敗",
|
||||
"description": "語意引擎初始化失敗狀態"
|
||||
},
|
||||
"semanticEngineNotInitStatus": {
|
||||
"message": "語意引擎未初始化",
|
||||
"description": "語意引擎未初始化狀態"
|
||||
},
|
||||
"initSemanticEngineButton": {
|
||||
"message": "初始化語意引擎",
|
||||
"description": "初始化語意引擎按鈕文字"
|
||||
},
|
||||
"reinitializeButton": {
|
||||
"message": "重新初始化",
|
||||
"description": "重新初始化按鈕文字"
|
||||
},
|
||||
"downloadingModelStatus": {
|
||||
"message": "正在下載模型... $PROGRESS$%",
|
||||
"description": "含百分比的模型下載進度",
|
||||
"placeholders": {
|
||||
"progress": {
|
||||
"content": "$1",
|
||||
"example": "50"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switchingModelStatus": {
|
||||
"message": "正在切換模型...",
|
||||
"description": "模型切換進度訊息"
|
||||
},
|
||||
"modelLoadedStatus": {
|
||||
"message": "模型已載入",
|
||||
"description": "模型成功載入狀態"
|
||||
},
|
||||
"modelFailedStatus": {
|
||||
"message": "模型載入失敗",
|
||||
"description": "模型載入失敗狀態"
|
||||
},
|
||||
"lightweightModelDescription": {
|
||||
"message": "輕量級多語言模型",
|
||||
"description": "輕量級模型選項描述"
|
||||
},
|
||||
"betterThanSmallDescription": {
|
||||
"message": "比 e5-small 稍大,但效果更佳",
|
||||
"description": "中等模型選項描述"
|
||||
},
|
||||
"multilingualModelDescription": {
|
||||
"message": "多語言語意模型",
|
||||
"description": "多語言模型選項描述"
|
||||
},
|
||||
"fastPerformance": {
|
||||
"message": "快速",
|
||||
"description": "快速效能指標"
|
||||
},
|
||||
"balancedPerformance": {
|
||||
"message": "平衡",
|
||||
"description": "平衡效能指標"
|
||||
},
|
||||
"accuratePerformance": {
|
||||
"message": "精確",
|
||||
"description": "精確效能指標"
|
||||
},
|
||||
"networkErrorMessage": {
|
||||
"message": "網路連線錯誤,請檢查網路後再試一次",
|
||||
"description": "網路連線錯誤訊息"
|
||||
},
|
||||
"modelCorruptedErrorMessage": {
|
||||
"message": "模型檔案毀損或不完整,請重新下載",
|
||||
"description": "模型毀損錯誤訊息"
|
||||
},
|
||||
"unknownErrorMessage": {
|
||||
"message": "未知錯誤,請檢查您的網路是否可存取 HuggingFace",
|
||||
"description": "未知錯誤回退訊息"
|
||||
},
|
||||
"permissionDeniedErrorMessage": {
|
||||
"message": "權限被拒絕",
|
||||
"description": "權限被拒絕錯誤訊息"
|
||||
},
|
||||
"timeoutErrorMessage": {
|
||||
"message": "操作逾時",
|
||||
"description": "逾時錯誤訊息"
|
||||
},
|
||||
"indexedPagesLabel": {
|
||||
"message": "已索引頁面",
|
||||
"description": "已索引頁面數量標籤"
|
||||
},
|
||||
"indexSizeLabel": {
|
||||
"message": "索引大小",
|
||||
"description": "索引大小標籤"
|
||||
},
|
||||
"activeTabsLabel": {
|
||||
"message": "作用中分頁",
|
||||
"description": "作用中分頁數量標籤"
|
||||
},
|
||||
"vectorDocumentsLabel": {
|
||||
"message": "向量文件",
|
||||
"description": "向量文件數量標籤"
|
||||
},
|
||||
"cacheSizeLabel": {
|
||||
"message": "快取大小",
|
||||
"description": "快取大小標籤"
|
||||
},
|
||||
"cacheEntriesLabel": {
|
||||
"message": "快取項目",
|
||||
"description": "快取項目數量標籤"
|
||||
},
|
||||
"clearAllDataButton": {
|
||||
"message": "清除所有資料",
|
||||
"description": "清除所有資料按鈕文字"
|
||||
},
|
||||
"clearAllCacheButton": {
|
||||
"message": "清除所有快取",
|
||||
"description": "清除所有快取按鈕文字"
|
||||
},
|
||||
"cleanExpiredCacheButton": {
|
||||
"message": "清理過期快取",
|
||||
"description": "清理過期快取按鈕文字"
|
||||
},
|
||||
"exportDataButton": {
|
||||
"message": "匯出資料",
|
||||
"description": "匯出資料按鈕文字"
|
||||
},
|
||||
"importDataButton": {
|
||||
"message": "匯入資料",
|
||||
"description": "匯入資料按鈕文字"
|
||||
},
|
||||
"confirmClearDataTitle": {
|
||||
"message": "確認清除資料",
|
||||
"description": "清除資料確認對話框標題"
|
||||
},
|
||||
"settingsTitle": {
|
||||
"message": "設定",
|
||||
"description": "設定對話框標題"
|
||||
},
|
||||
"aboutTitle": {
|
||||
"message": "關於",
|
||||
"description": "關於對話框標題"
|
||||
},
|
||||
"helpTitle": {
|
||||
"message": "說明",
|
||||
"description": "說明對話框標題"
|
||||
},
|
||||
"clearDataWarningMessage": {
|
||||
"message": "此操作將清除所有已索引的網頁內容與向量資料,包括:",
|
||||
"description": "清除資料警告訊息"
|
||||
},
|
||||
"clearDataList1": {
|
||||
"message": "所有網頁的文字內容索引",
|
||||
"description": "清除資料列表第一項"
|
||||
},
|
||||
"clearDataList2": {
|
||||
"message": "向量嵌入資料",
|
||||
"description": "清除資料列表第二項"
|
||||
},
|
||||
"clearDataList3": {
|
||||
"message": "搜尋歷史與快取",
|
||||
"description": "清除資料列表第三項"
|
||||
},
|
||||
"clearDataIrreversibleWarning": {
|
||||
"message": "此操作無法復原!清除後需重新瀏覽網頁以重建索引。",
|
||||
"description": "不可逆操作警告"
|
||||
},
|
||||
"confirmClearButton": {
|
||||
"message": "確認清除",
|
||||
"description": "確認清除操作按鈕"
|
||||
},
|
||||
"cacheDetailsLabel": {
|
||||
"message": "快取詳細資訊",
|
||||
"description": "快取詳細資訊區段標籤"
|
||||
},
|
||||
"noCacheDataMessage": {
|
||||
"message": "尚無快取資料",
|
||||
"description": "無可用快取資料訊息"
|
||||
},
|
||||
"loadingCacheInfoStatus": {
|
||||
"message": "正在載入快取資訊...",
|
||||
"description": "載入快取資訊狀態"
|
||||
},
|
||||
"processingCacheStatus": {
|
||||
"message": "正在處理快取...",
|
||||
"description": "處理快取狀態"
|
||||
},
|
||||
"expiredLabel": {
|
||||
"message": "已過期",
|
||||
"description": "過期項目標籤"
|
||||
},
|
||||
"bookmarksBarLabel": {
|
||||
"message": "書籤列",
|
||||
"description": "書籤列資料夾名稱"
|
||||
},
|
||||
"newTabLabel": {
|
||||
"message": "新分頁",
|
||||
"description": "新分頁標籤"
|
||||
},
|
||||
"currentPageLabel": {
|
||||
"message": "目前頁面",
|
||||
"description": "目前頁面標籤"
|
||||
},
|
||||
"menuLabel": {
|
||||
"message": "功能表",
|
||||
"description": "功能表無障礙標籤"
|
||||
},
|
||||
"navigationLabel": {
|
||||
"message": "導覽",
|
||||
"description": "導覽無障礙標籤"
|
||||
},
|
||||
"mainContentLabel": {
|
||||
"message": "主要內容",
|
||||
"description": "主要內容無障礙標籤"
|
||||
},
|
||||
"languageSelectorLabel": {
|
||||
"message": "語言",
|
||||
"description": "語言選擇器標籤"
|
||||
},
|
||||
"themeLabel": {
|
||||
"message": "主題",
|
||||
"description": "主題選擇器標籤"
|
||||
},
|
||||
"lightTheme": {
|
||||
"message": "淺色",
|
||||
"description": "淺色主題選項"
|
||||
},
|
||||
"darkTheme": {
|
||||
"message": "深色",
|
||||
"description": "深色主題選項"
|
||||
},
|
||||
"autoTheme": {
|
||||
"message": "自動",
|
||||
"description": "自動主題選項"
|
||||
},
|
||||
"advancedSettingsLabel": {
|
||||
"message": "進階設定",
|
||||
"description": "進階設定區段標籤"
|
||||
},
|
||||
"debugModeLabel": {
|
||||
"message": "偵錯模式",
|
||||
"description": "偵錯模式切換標籤"
|
||||
},
|
||||
"verboseLoggingLabel": {
|
||||
"message": "詳細日誌",
|
||||
"description": "詳細日誌切換標籤"
|
||||
},
|
||||
"successNotification": {
|
||||
"message": "操作已成功完成",
|
||||
"description": "通用成功通知"
|
||||
},
|
||||
"warningNotification": {
|
||||
"message": "警告:請在繼續前檢查",
|
||||
"description": "通用警告通知"
|
||||
},
|
||||
"infoNotification": {
|
||||
"message": "資訊",
|
||||
"description": "通用資訊通知"
|
||||
},
|
||||
"configCopiedNotification": {
|
||||
"message": "設定已複製到剪貼簿",
|
||||
"description": "設定複製成功訊息"
|
||||
},
|
||||
"dataClearedNotification": {
|
||||
"message": "資料清除成功",
|
||||
"description": "資料清除成功訊息"
|
||||
},
|
||||
"bytesUnit": {
|
||||
"message": "bytes",
|
||||
"description": "位元組單位"
|
||||
},
|
||||
"kilobytesUnit": {
|
||||
"message": "KB",
|
||||
"description": "千位元組單位"
|
||||
},
|
||||
"megabytesUnit": {
|
||||
"message": "MB",
|
||||
"description": "百萬位元組單位"
|
||||
},
|
||||
"gigabytesUnit": {
|
||||
"message": "GB",
|
||||
"description": "十億位元組單位"
|
||||
},
|
||||
"itemsUnit": {
|
||||
"message": "項目",
|
||||
"description": "項目計數單位"
|
||||
},
|
||||
"pagesUnit": {
|
||||
"message": "頁面",
|
||||
"description": "頁面計數單位"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 497 B |
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Agent CLI Model Definitions.
|
||||
*
|
||||
* Static model definitions for each CLI type.
|
||||
* Based on the pattern from Claudable (other/cweb).
|
||||
*/
|
||||
|
||||
import type { CodexReasoningEffort } from 'chrome-mcp-shared';
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export interface ModelDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
supportsImages?: boolean;
|
||||
/** Supported reasoning effort levels for Codex models */
|
||||
supportedReasoningEfforts?: readonly CodexReasoningEffort[];
|
||||
}
|
||||
|
||||
export type AgentCliType = 'claude' | 'codex' | 'cursor' | 'qwen' | 'glm';
|
||||
|
||||
// ============================================================
|
||||
// Claude Models
|
||||
// ============================================================
|
||||
|
||||
export const CLAUDE_MODELS: ModelDefinition[] = [
|
||||
{
|
||||
id: 'claude-sonnet-4-5-20250929',
|
||||
name: 'Claude Sonnet 4.5',
|
||||
description: 'Balanced model with large context window',
|
||||
supportsImages: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4-5-20251101',
|
||||
name: 'Claude Opus 4.5',
|
||||
description: 'Strongest reasoning model',
|
||||
supportsImages: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-haiku-4-5-20251001',
|
||||
name: 'Claude Haiku 4.5',
|
||||
description: 'Fast and cost-efficient',
|
||||
supportsImages: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const CLAUDE_DEFAULT_MODEL = 'claude-sonnet-4-5-20250929';
|
||||
|
||||
// ============================================================
|
||||
// Codex Models
|
||||
// ============================================================
|
||||
|
||||
/** Standard reasoning efforts supported by all models */
|
||||
const CODEX_STANDARD_EFFORTS: readonly CodexReasoningEffort[] = ['low', 'medium', 'high'];
|
||||
/** Extended reasoning efforts (includes xhigh) - only for gpt-5.2 and gpt-5.1-codex-max */
|
||||
const CODEX_EXTENDED_EFFORTS: readonly CodexReasoningEffort[] = ['low', 'medium', 'high', 'xhigh'];
|
||||
|
||||
export const CODEX_MODELS: ModelDefinition[] = [
|
||||
{
|
||||
id: 'gpt-5.1',
|
||||
name: 'GPT-5.1',
|
||||
description: 'OpenAI high-quality reasoning model',
|
||||
supportedReasoningEfforts: CODEX_STANDARD_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.2',
|
||||
name: 'GPT-5.2',
|
||||
description: 'OpenAI flagship reasoning model with extended effort support',
|
||||
supportedReasoningEfforts: CODEX_EXTENDED_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex',
|
||||
name: 'GPT-5.1 Codex',
|
||||
description: 'Coding-optimized model for agent workflows',
|
||||
supportedReasoningEfforts: CODEX_STANDARD_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex-max',
|
||||
name: 'GPT-5.1 Codex Max',
|
||||
description: 'Highest quality coding model with extended effort support',
|
||||
supportedReasoningEfforts: CODEX_EXTENDED_EFFORTS,
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex-mini',
|
||||
name: 'GPT-5.1 Codex Mini',
|
||||
description: 'Fast, cost-efficient coding model',
|
||||
supportedReasoningEfforts: CODEX_STANDARD_EFFORTS,
|
||||
},
|
||||
];
|
||||
|
||||
export const CODEX_DEFAULT_MODEL = 'gpt-5.1';
|
||||
|
||||
// Codex model alias normalization
|
||||
const CODEX_ALIAS_MAP: Record<string, string> = {
|
||||
gpt5: 'gpt-5.1',
|
||||
gpt_5: 'gpt-5.1',
|
||||
'gpt-5': 'gpt-5.1',
|
||||
'gpt-5.0': 'gpt-5.1',
|
||||
};
|
||||
|
||||
const CODEX_KNOWN_IDS = new Set(CODEX_MODELS.map((model) => model.id));
|
||||
|
||||
/**
|
||||
* Normalize a Codex model ID, handling aliases and falling back to default.
|
||||
*/
|
||||
export function normalizeCodexModelId(model?: string | null): string {
|
||||
if (!model || typeof model !== 'string') {
|
||||
return CODEX_DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
const trimmed = model.trim();
|
||||
if (!trimmed) {
|
||||
return CODEX_DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (CODEX_ALIAS_MAP[lower]) {
|
||||
return CODEX_ALIAS_MAP[lower];
|
||||
}
|
||||
|
||||
if (CODEX_KNOWN_IDS.has(lower)) {
|
||||
return lower;
|
||||
}
|
||||
|
||||
// If the exact casing exists, allow it
|
||||
if (CODEX_KNOWN_IDS.has(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return CODEX_DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get supported reasoning efforts for a Codex model.
|
||||
* Returns standard efforts (low/medium/high) for unknown models.
|
||||
*/
|
||||
export function getCodexReasoningEfforts(modelId?: string | null): readonly CodexReasoningEffort[] {
|
||||
const normalized = normalizeCodexModelId(modelId);
|
||||
const model = CODEX_MODELS.find((m) => m.id === normalized);
|
||||
return model?.supportedReasoningEfforts ?? CODEX_STANDARD_EFFORTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model supports xhigh reasoning effort.
|
||||
*/
|
||||
export function supportsXhighEffort(modelId?: string | null): boolean {
|
||||
const efforts = getCodexReasoningEfforts(modelId);
|
||||
return efforts.includes('xhigh');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Cursor Models
|
||||
// ============================================================
|
||||
|
||||
export const CURSOR_MODELS: ModelDefinition[] = [
|
||||
{
|
||||
id: 'auto',
|
||||
name: 'Auto',
|
||||
description: 'Cursor auto-selects the best model',
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4-5-20250929',
|
||||
name: 'Claude Sonnet 4.5',
|
||||
description: 'Anthropic Claude via Cursor',
|
||||
supportsImages: true,
|
||||
},
|
||||
{
|
||||
id: 'gpt-4.1',
|
||||
name: 'GPT-4.1',
|
||||
description: 'OpenAI model via Cursor',
|
||||
},
|
||||
];
|
||||
|
||||
export const CURSOR_DEFAULT_MODEL = 'auto';
|
||||
|
||||
// ============================================================
|
||||
// Qwen Models
|
||||
// ============================================================
|
||||
|
||||
export const QWEN_MODELS: ModelDefinition[] = [
|
||||
{
|
||||
id: 'qwen3-coder-plus',
|
||||
name: 'Qwen3 Coder Plus',
|
||||
description: 'Balanced 32k context model for coding',
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder-pro',
|
||||
name: 'Qwen3 Coder Pro',
|
||||
description: 'Larger 128k context with stronger reasoning',
|
||||
},
|
||||
{
|
||||
id: 'qwen3-coder',
|
||||
name: 'Qwen3 Coder',
|
||||
description: 'Fast iteration model',
|
||||
},
|
||||
];
|
||||
|
||||
export const QWEN_DEFAULT_MODEL = 'qwen3-coder-plus';
|
||||
|
||||
// ============================================================
|
||||
// GLM Models
|
||||
// ============================================================
|
||||
|
||||
export const GLM_MODELS: ModelDefinition[] = [
|
||||
{
|
||||
id: 'glm-4.6',
|
||||
name: 'GLM 4.6',
|
||||
description: 'Zhipu GLM 4.6 agent runtime',
|
||||
},
|
||||
];
|
||||
|
||||
export const GLM_DEFAULT_MODEL = 'glm-4.6';
|
||||
|
||||
// ============================================================
|
||||
// Aggregated Definitions
|
||||
// ============================================================
|
||||
|
||||
export const CLI_MODEL_DEFINITIONS: Record<AgentCliType, ModelDefinition[]> = {
|
||||
claude: CLAUDE_MODELS,
|
||||
codex: CODEX_MODELS,
|
||||
cursor: CURSOR_MODELS,
|
||||
qwen: QWEN_MODELS,
|
||||
glm: GLM_MODELS,
|
||||
};
|
||||
|
||||
export const CLI_DEFAULT_MODELS: Record<AgentCliType, string> = {
|
||||
claude: CLAUDE_DEFAULT_MODEL,
|
||||
codex: CODEX_DEFAULT_MODEL,
|
||||
cursor: CURSOR_DEFAULT_MODEL,
|
||||
qwen: QWEN_DEFAULT_MODEL,
|
||||
glm: GLM_DEFAULT_MODEL,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Helper Functions
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get model definitions for a specific CLI type.
|
||||
*/
|
||||
export function getModelsForCli(cli: string | null | undefined): ModelDefinition[] {
|
||||
if (!cli) return [];
|
||||
const key = cli.toLowerCase() as AgentCliType;
|
||||
return CLI_MODEL_DEFINITIONS[key] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default model for a CLI type.
|
||||
*/
|
||||
export function getDefaultModelForCli(cli: string | null | undefined): string {
|
||||
if (!cli) return '';
|
||||
const key = cli.toLowerCase() as AgentCliType;
|
||||
return CLI_DEFAULT_MODELS[key] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a model ID.
|
||||
*/
|
||||
export function getModelDisplayName(
|
||||
cli: string | null | undefined,
|
||||
modelId: string | null | undefined,
|
||||
): string {
|
||||
if (!cli || !modelId) return modelId || '';
|
||||
const models = getModelsForCli(cli);
|
||||
const model = models.find((m) => m.id === modelId);
|
||||
return model?.name || modelId;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Chrome Extension Constants
|
||||
* Centralized configuration values and magic constants
|
||||
*/
|
||||
|
||||
// Native Host Configuration
|
||||
export const NATIVE_HOST = {
|
||||
NAME: 'com.chromemcp.nativehost',
|
||||
DEFAULT_PORT: 12306,
|
||||
} as const;
|
||||
|
||||
// Chrome Extension Icons
|
||||
export const ICONS = {
|
||||
NOTIFICATION: 'icon/48.png',
|
||||
} as const;
|
||||
|
||||
// Timeouts and Delays (in milliseconds)
|
||||
export const TIMEOUTS = {
|
||||
DEFAULT_WAIT: 1000,
|
||||
NETWORK_CAPTURE_MAX: 30000,
|
||||
NETWORK_CAPTURE_IDLE: 3000,
|
||||
SCREENSHOT_DELAY: 100,
|
||||
KEYBOARD_DELAY: 50,
|
||||
CLICK_DELAY: 100,
|
||||
} as const;
|
||||
|
||||
// Limits and Thresholds
|
||||
export const LIMITS = {
|
||||
MAX_NETWORK_REQUESTS: 100,
|
||||
MAX_SEARCH_RESULTS: 50,
|
||||
MAX_BOOKMARK_RESULTS: 100,
|
||||
MAX_HISTORY_RESULTS: 100,
|
||||
SIMILARITY_THRESHOLD: 0.1,
|
||||
VECTOR_DIMENSIONS: 384,
|
||||
} as const;
|
||||
|
||||
// Error Messages
|
||||
export const ERROR_MESSAGES = {
|
||||
NATIVE_CONNECTION_FAILED: 'Failed to connect to native host',
|
||||
NATIVE_DISCONNECTED: 'Native connection disconnected',
|
||||
SERVER_STATUS_LOAD_FAILED: 'Failed to load server status',
|
||||
SERVER_STATUS_SAVE_FAILED: 'Failed to save server status',
|
||||
TOOL_EXECUTION_FAILED: 'Tool execution failed',
|
||||
INVALID_PARAMETERS: 'Invalid parameters provided',
|
||||
PERMISSION_DENIED: 'Permission denied',
|
||||
TAB_NOT_FOUND: 'Tab not found',
|
||||
ELEMENT_NOT_FOUND: 'Element not found',
|
||||
NETWORK_ERROR: 'Network error occurred',
|
||||
} as const;
|
||||
|
||||
// Success Messages
|
||||
export const SUCCESS_MESSAGES = {
|
||||
TOOL_EXECUTED: 'Tool executed successfully',
|
||||
CONNECTION_ESTABLISHED: 'Connection established',
|
||||
SERVER_STARTED: 'Server started successfully',
|
||||
SERVER_STOPPED: 'Server stopped successfully',
|
||||
} as const;
|
||||
|
||||
// External Links
|
||||
export const LINKS = {
|
||||
TROUBLESHOOTING: 'https://github.com/hangwin/mcp-chrome/blob/master/docs/TROUBLESHOOTING.md',
|
||||
} as const;
|
||||
|
||||
// File Extensions and MIME Types
|
||||
export const FILE_TYPES = {
|
||||
STATIC_EXTENSIONS: [
|
||||
'.css',
|
||||
'.js',
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.svg',
|
||||
'.ico',
|
||||
'.woff',
|
||||
'.woff2',
|
||||
'.ttf',
|
||||
],
|
||||
FILTERED_MIME_TYPES: ['text/html', 'text/css', 'text/javascript', 'application/javascript'],
|
||||
IMAGE_FORMATS: ['png', 'jpeg', 'webp'] as const,
|
||||
} as const;
|
||||
|
||||
// Network Filtering
|
||||
export const NETWORK_FILTERS = {
|
||||
// Substring match against full URL (not just hostname) to support patterns like 'facebook.com/tr'
|
||||
EXCLUDED_DOMAINS: [
|
||||
// Google
|
||||
'google-analytics.com',
|
||||
'googletagmanager.com',
|
||||
'analytics.google.com',
|
||||
'doubleclick.net',
|
||||
'googlesyndication.com',
|
||||
'googleads.g.doubleclick.net',
|
||||
'stats.g.doubleclick.net',
|
||||
'adservice.google.com',
|
||||
'pagead2.googlesyndication.com',
|
||||
// Amazon
|
||||
'amazon-adsystem.com',
|
||||
// Microsoft
|
||||
'bat.bing.com',
|
||||
'clarity.ms',
|
||||
// Facebook
|
||||
'connect.facebook.net',
|
||||
'facebook.com/tr',
|
||||
// Twitter
|
||||
'analytics.twitter.com',
|
||||
'ads-twitter.com',
|
||||
// Other ad networks
|
||||
'ads.yahoo.com',
|
||||
'adroll.com',
|
||||
'adnxs.com',
|
||||
'criteo.com',
|
||||
'quantserve.com',
|
||||
'scorecardresearch.com',
|
||||
// Analytics & session recording
|
||||
'segment.io',
|
||||
'amplitude.com',
|
||||
'mixpanel.com',
|
||||
'optimizely.com',
|
||||
'static.hotjar.com',
|
||||
'script.hotjar.com',
|
||||
'crazyegg.com',
|
||||
'clicktale.net',
|
||||
'mouseflow.com',
|
||||
'fullstory.com',
|
||||
// LinkedIn (tracking pixels)
|
||||
'linkedin.com/px',
|
||||
],
|
||||
// Static resource extensions (used when includeStatic=false)
|
||||
STATIC_RESOURCE_EXTENSIONS: [
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.gif',
|
||||
'.svg',
|
||||
'.webp',
|
||||
'.ico',
|
||||
'.bmp',
|
||||
'.cur',
|
||||
'.css',
|
||||
'.scss',
|
||||
'.less',
|
||||
'.js',
|
||||
'.jsx',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
'.map',
|
||||
'.woff',
|
||||
'.woff2',
|
||||
'.ttf',
|
||||
'.eot',
|
||||
'.otf',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.avi',
|
||||
'.mov',
|
||||
'.wmv',
|
||||
'.flv',
|
||||
'.webm',
|
||||
'.ogg',
|
||||
'.wav',
|
||||
'.pdf',
|
||||
'.zip',
|
||||
'.rar',
|
||||
'.7z',
|
||||
'.iso',
|
||||
'.dmg',
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.xls',
|
||||
'.xlsx',
|
||||
'.ppt',
|
||||
'.pptx',
|
||||
],
|
||||
// MIME types treated as static/binary (filtered when includeStatic=false)
|
||||
STATIC_MIME_TYPES_TO_FILTER: [
|
||||
'image/',
|
||||
'font/',
|
||||
'audio/',
|
||||
'video/',
|
||||
'text/css',
|
||||
'text/javascript',
|
||||
'application/javascript',
|
||||
'application/x-javascript',
|
||||
'application/pdf',
|
||||
'application/zip',
|
||||
'application/octet-stream',
|
||||
],
|
||||
// API-like MIME types (never filtered by MIME)
|
||||
API_MIME_TYPES: [
|
||||
'application/json',
|
||||
'application/xml',
|
||||
'text/xml',
|
||||
'text/plain',
|
||||
'text/event-stream',
|
||||
'application/x-www-form-urlencoded',
|
||||
'application/graphql',
|
||||
'application/grpc',
|
||||
'application/protobuf',
|
||||
'application/x-protobuf',
|
||||
'application/x-json',
|
||||
'application/ld+json',
|
||||
'application/problem+json',
|
||||
'application/problem+xml',
|
||||
'application/soap+xml',
|
||||
'application/vnd.api+json',
|
||||
],
|
||||
STATIC_RESOURCE_TYPES: ['stylesheet', 'image', 'font', 'media', 'other'],
|
||||
} as const;
|
||||
|
||||
// Semantic Similarity Configuration
|
||||
export const SEMANTIC_CONFIG = {
|
||||
DEFAULT_MODEL: 'sentence-transformers/all-MiniLM-L6-v2',
|
||||
CHUNK_SIZE: 512,
|
||||
CHUNK_OVERLAP: 50,
|
||||
BATCH_SIZE: 32,
|
||||
CACHE_SIZE: 1000,
|
||||
} as const;
|
||||
|
||||
// Storage Keys
|
||||
export const STORAGE_KEYS = {
|
||||
SERVER_STATUS: 'serverStatus',
|
||||
NATIVE_SERVER_PORT: 'nativeServerPort',
|
||||
NATIVE_AUTO_CONNECT_ENABLED: 'nativeAutoConnectEnabled',
|
||||
SEMANTIC_MODEL: 'selectedModel',
|
||||
USER_PREFERENCES: 'userPreferences',
|
||||
VECTOR_INDEX: 'vectorIndex',
|
||||
USERSCRIPTS: 'userscripts',
|
||||
USERSCRIPTS_DISABLED: 'userscripts_disabled',
|
||||
// Record & Replay storage keys
|
||||
RR_FLOWS: 'rr_flows',
|
||||
RR_RUNS: 'rr_runs',
|
||||
RR_PUBLISHED: 'rr_published_flows',
|
||||
RR_SCHEDULES: 'rr_schedules',
|
||||
RR_TRIGGERS: 'rr_triggers',
|
||||
// Persistent recording state (guards resume across navigations/service worker restarts)
|
||||
RR_RECORDING_STATE: 'rr_recording_state',
|
||||
} as const;
|
||||
|
||||
// Notification Configuration
|
||||
export const NOTIFICATIONS = {
|
||||
PRIORITY: 2,
|
||||
TYPE: 'basic' as const,
|
||||
} as const;
|
||||
|
||||
export enum ExecutionWorld {
|
||||
ISOLATED = 'ISOLATED',
|
||||
MAIN = 'MAIN',
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Element marker types shared across background, content scripts, and popup
|
||||
|
||||
export type UrlMatchType = 'exact' | 'prefix' | 'host';
|
||||
|
||||
export interface ElementMarker {
|
||||
id: string;
|
||||
// Original URL where the marker was created
|
||||
url: string;
|
||||
// Normalized pieces to support matching
|
||||
origin: string; // scheme + host + port
|
||||
host: string; // hostname
|
||||
path: string; // pathname part only
|
||||
matchType: UrlMatchType; // default: 'prefix'
|
||||
|
||||
name: string; // Human-friendly name, e.g., "Login Button"
|
||||
selector: string; // Selector string
|
||||
selectorType?: 'css' | 'xpath'; // Default: css
|
||||
listMode?: boolean; // Whether this marker was created in list mode (allows multiple matches)
|
||||
action?: 'click' | 'fill' | 'custom'; // Intended action hint (optional)
|
||||
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface UpsertMarkerRequest {
|
||||
id?: string;
|
||||
url: string;
|
||||
name: string;
|
||||
selector: string;
|
||||
selectorType?: 'css' | 'xpath';
|
||||
listMode?: boolean;
|
||||
matchType?: UrlMatchType;
|
||||
action?: 'click' | 'fill' | 'custom';
|
||||
}
|
||||
|
||||
// Validation actions for MCP-integrated verification
|
||||
export enum MarkerValidationAction {
|
||||
Hover = 'hover',
|
||||
LeftClick = 'left_click',
|
||||
RightClick = 'right_click',
|
||||
DoubleClick = 'double_click',
|
||||
TypeText = 'type_text',
|
||||
PressKeys = 'press_keys',
|
||||
Scroll = 'scroll',
|
||||
}
|
||||
|
||||
export interface MarkerValidationRequest {
|
||||
selector: string;
|
||||
selectorType?: 'css' | 'xpath';
|
||||
action: MarkerValidationAction;
|
||||
// Optional payload for certain actions
|
||||
text?: string; // for type_text
|
||||
keys?: string; // for press_keys
|
||||
// Event options for click-like actions
|
||||
button?: 'left' | 'right' | 'middle';
|
||||
bubbles?: boolean;
|
||||
cancelable?: boolean;
|
||||
modifiers?: { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean };
|
||||
// Targeting options
|
||||
coordinates?: { x: number; y: number }; // absolute viewport coords
|
||||
offsetX?: number; // relative to element center if relativeTo = 'element'
|
||||
offsetY?: number;
|
||||
relativeTo?: 'element' | 'viewport';
|
||||
// Navigation options for click-like actions
|
||||
waitForNavigation?: boolean;
|
||||
timeoutMs?: number;
|
||||
// Scroll options
|
||||
scrollDirection?: 'up' | 'down' | 'left' | 'right';
|
||||
scrollAmount?: number; // pixels per tick
|
||||
}
|
||||
|
||||
export interface MarkerValidationResponse {
|
||||
success: boolean;
|
||||
resolved?: boolean;
|
||||
ref?: string;
|
||||
center?: { x: number; y: number };
|
||||
tool?: { name: string; ok: boolean; error?: string };
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface MarkerQuery {
|
||||
url?: string; // If present, query by URL match; otherwise list all
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Consolidated message type constants for Chrome extension communication
|
||||
* Note: Native message types are imported from the shared package
|
||||
*/
|
||||
|
||||
import type { RealtimeEvent } from 'chrome-mcp-shared';
|
||||
|
||||
// Message targets for routing
|
||||
export enum MessageTarget {
|
||||
Offscreen = 'offscreen',
|
||||
ContentScript = 'content_script',
|
||||
Background = 'background',
|
||||
}
|
||||
|
||||
// Background script message types
|
||||
export const BACKGROUND_MESSAGE_TYPES = {
|
||||
SWITCH_SEMANTIC_MODEL: 'switch_semantic_model',
|
||||
GET_MODEL_STATUS: 'get_model_status',
|
||||
UPDATE_MODEL_STATUS: 'update_model_status',
|
||||
GET_STORAGE_STATS: 'get_storage_stats',
|
||||
CLEAR_ALL_DATA: 'clear_all_data',
|
||||
GET_SERVER_STATUS: 'get_server_status',
|
||||
REFRESH_SERVER_STATUS: 'refresh_server_status',
|
||||
SERVER_STATUS_CHANGED: 'server_status_changed',
|
||||
INITIALIZE_SEMANTIC_ENGINE: 'initialize_semantic_engine',
|
||||
// Record & Replay background control and queries
|
||||
RR_START_RECORDING: 'rr_start_recording',
|
||||
RR_STOP_RECORDING: 'rr_stop_recording',
|
||||
RR_PAUSE_RECORDING: 'rr_pause_recording',
|
||||
RR_RESUME_RECORDING: 'rr_resume_recording',
|
||||
RR_GET_RECORDING_STATUS: 'rr_get_recording_status',
|
||||
RR_LIST_FLOWS: 'rr_list_flows',
|
||||
RR_FLOWS_CHANGED: 'rr_flows_changed',
|
||||
RR_GET_FLOW: 'rr_get_flow',
|
||||
RR_DELETE_FLOW: 'rr_delete_flow',
|
||||
RR_PUBLISH_FLOW: 'rr_publish_flow',
|
||||
RR_UNPUBLISH_FLOW: 'rr_unpublish_flow',
|
||||
RR_RUN_FLOW: 'rr_run_flow',
|
||||
RR_SAVE_FLOW: 'rr_save_flow',
|
||||
RR_EXPORT_FLOW: 'rr_export_flow',
|
||||
RR_EXPORT_ALL: 'rr_export_all',
|
||||
RR_IMPORT_FLOW: 'rr_import_flow',
|
||||
RR_LIST_RUNS: 'rr_list_runs',
|
||||
// Triggers
|
||||
RR_LIST_TRIGGERS: 'rr_list_triggers',
|
||||
RR_SAVE_TRIGGER: 'rr_save_trigger',
|
||||
RR_DELETE_TRIGGER: 'rr_delete_trigger',
|
||||
RR_REFRESH_TRIGGERS: 'rr_refresh_triggers',
|
||||
// Scheduling
|
||||
RR_SCHEDULE_FLOW: 'rr_schedule_flow',
|
||||
RR_UNSCHEDULE_FLOW: 'rr_unschedule_flow',
|
||||
RR_LIST_SCHEDULES: 'rr_list_schedules',
|
||||
// Element marker management
|
||||
ELEMENT_MARKER_LIST_ALL: 'element_marker_list_all',
|
||||
ELEMENT_MARKER_LIST_FOR_URL: 'element_marker_list_for_url',
|
||||
ELEMENT_MARKER_SAVE: 'element_marker_save',
|
||||
ELEMENT_MARKER_UPDATE: 'element_marker_update',
|
||||
ELEMENT_MARKER_DELETE: 'element_marker_delete',
|
||||
ELEMENT_MARKER_VALIDATE: 'element_marker_validate',
|
||||
ELEMENT_MARKER_START: 'element_marker_start_from_popup',
|
||||
// Element picker (human-in-the-loop element selection)
|
||||
ELEMENT_PICKER_UI_EVENT: 'element_picker_ui_event',
|
||||
ELEMENT_PICKER_FRAME_EVENT: 'element_picker_frame_event',
|
||||
// Web editor (in-page visual editing)
|
||||
WEB_EDITOR_TOGGLE: 'web_editor_toggle',
|
||||
WEB_EDITOR_APPLY: 'web_editor_apply',
|
||||
WEB_EDITOR_STATUS_QUERY: 'web_editor_status_query',
|
||||
// Web editor <-> AgentChat integration (Phase 1.1)
|
||||
WEB_EDITOR_APPLY_BATCH: 'web_editor_apply_batch',
|
||||
WEB_EDITOR_TX_CHANGED: 'web_editor_tx_changed',
|
||||
WEB_EDITOR_HIGHLIGHT_ELEMENT: 'web_editor_highlight_element',
|
||||
// Web editor <-> AgentChat integration (Phase 2 - Revert)
|
||||
WEB_EDITOR_REVERT_ELEMENT: 'web_editor_revert_element',
|
||||
// Web editor <-> AgentChat integration - Selection sync
|
||||
WEB_EDITOR_SELECTION_CHANGED: 'web_editor_selection_changed',
|
||||
// Web editor <-> AgentChat integration - Clear selection (sidepanel -> web-editor)
|
||||
WEB_EDITOR_CLEAR_SELECTION: 'web_editor_clear_selection',
|
||||
// Web editor <-> AgentChat integration - Cancel execution
|
||||
WEB_EDITOR_CANCEL_EXECUTION: 'web_editor_cancel_execution',
|
||||
// Web editor props (Phase 7.1.6 early injection)
|
||||
WEB_EDITOR_PROPS_REGISTER_EARLY_INJECTION: 'web_editor_props_register_early_injection',
|
||||
// Web editor props - open source file in VSCode
|
||||
WEB_EDITOR_OPEN_SOURCE: 'web_editor_open_source',
|
||||
// Quick Panel <-> AgentChat integration
|
||||
QUICK_PANEL_SEND_TO_AI: 'quick_panel_send_to_ai',
|
||||
QUICK_PANEL_CANCEL_AI: 'quick_panel_cancel_ai',
|
||||
// Quick Panel Search - Tabs bridge
|
||||
QUICK_PANEL_TABS_QUERY: 'quick_panel_tabs_query',
|
||||
QUICK_PANEL_TAB_ACTIVATE: 'quick_panel_tab_activate',
|
||||
QUICK_PANEL_TAB_CLOSE: 'quick_panel_tab_close',
|
||||
} as const;
|
||||
|
||||
// Offscreen message types
|
||||
export const OFFSCREEN_MESSAGE_TYPES = {
|
||||
SIMILARITY_ENGINE_INIT: 'similarityEngineInit',
|
||||
SIMILARITY_ENGINE_COMPUTE: 'similarityEngineCompute',
|
||||
SIMILARITY_ENGINE_BATCH_COMPUTE: 'similarityEngineBatchCompute',
|
||||
SIMILARITY_ENGINE_STATUS: 'similarityEngineStatus',
|
||||
// GIF encoding
|
||||
GIF_ADD_FRAME: 'gifAddFrame',
|
||||
GIF_FINISH: 'gifFinish',
|
||||
GIF_RESET: 'gifReset',
|
||||
} as const;
|
||||
|
||||
// Content script message types
|
||||
export const CONTENT_MESSAGE_TYPES = {
|
||||
WEB_FETCHER_GET_TEXT_CONTENT: 'webFetcherGetTextContent',
|
||||
WEB_FETCHER_GET_HTML_CONTENT: 'getHtmlContent',
|
||||
NETWORK_CAPTURE_PING: 'network_capture_ping',
|
||||
CLICK_HELPER_PING: 'click_helper_ping',
|
||||
FILL_HELPER_PING: 'fill_helper_ping',
|
||||
KEYBOARD_HELPER_PING: 'keyboard_helper_ping',
|
||||
SCREENSHOT_HELPER_PING: 'screenshot_helper_ping',
|
||||
INTERACTIVE_ELEMENTS_HELPER_PING: 'interactive_elements_helper_ping',
|
||||
ACCESSIBILITY_TREE_HELPER_PING: 'chrome_read_page_ping',
|
||||
WAIT_HELPER_PING: 'wait_helper_ping',
|
||||
DOM_OBSERVER_PING: 'dom_observer_ping',
|
||||
} as const;
|
||||
|
||||
// Tool action message types (for chrome.runtime.sendMessage)
|
||||
export const TOOL_MESSAGE_TYPES = {
|
||||
// Screenshot related
|
||||
SCREENSHOT_PREPARE_PAGE_FOR_CAPTURE: 'preparePageForCapture',
|
||||
SCREENSHOT_GET_PAGE_DETAILS: 'getPageDetails',
|
||||
SCREENSHOT_GET_ELEMENT_DETAILS: 'getElementDetails',
|
||||
SCREENSHOT_SCROLL_PAGE: 'scrollPage',
|
||||
SCREENSHOT_RESET_PAGE_AFTER_CAPTURE: 'resetPageAfterCapture',
|
||||
|
||||
// Web content fetching
|
||||
WEB_FETCHER_GET_HTML_CONTENT: 'getHtmlContent',
|
||||
WEB_FETCHER_GET_TEXT_CONTENT: 'getTextContent',
|
||||
|
||||
// User interactions
|
||||
CLICK_ELEMENT: 'clickElement',
|
||||
FILL_ELEMENT: 'fillElement',
|
||||
SIMULATE_KEYBOARD: 'simulateKeyboard',
|
||||
|
||||
// Interactive elements
|
||||
GET_INTERACTIVE_ELEMENTS: 'getInteractiveElements',
|
||||
|
||||
// Accessibility tree
|
||||
GENERATE_ACCESSIBILITY_TREE: 'generateAccessibilityTree',
|
||||
RESOLVE_REF: 'resolveRef',
|
||||
ENSURE_REF_FOR_SELECTOR: 'ensureRefForSelector',
|
||||
VERIFY_FINGERPRINT: 'verifyFingerprint',
|
||||
DISPATCH_HOVER_FOR_REF: 'dispatchHoverForRef',
|
||||
|
||||
// Network requests
|
||||
NETWORK_SEND_REQUEST: 'sendPureNetworkRequest',
|
||||
|
||||
// Wait helper
|
||||
WAIT_FOR_TEXT: 'waitForText',
|
||||
|
||||
// Semantic similarity engine
|
||||
SIMILARITY_ENGINE_INIT: 'similarityEngineInit',
|
||||
SIMILARITY_ENGINE_COMPUTE_BATCH: 'similarityEngineComputeBatch',
|
||||
// Record & Replay content script bridge
|
||||
RR_RECORDER_CONTROL: 'rr_recorder_control',
|
||||
RR_RECORDER_EVENT: 'rr_recorder_event',
|
||||
// Record & Replay timeline feed (background -> content overlay)
|
||||
RR_TIMELINE_UPDATE: 'rr_timeline_update',
|
||||
// Quick Panel AI streaming events (background -> content script)
|
||||
QUICK_PANEL_AI_EVENT: 'quick_panel_ai_event',
|
||||
// DOM observer trigger bridge
|
||||
SET_DOM_TRIGGERS: 'set_dom_triggers',
|
||||
DOM_TRIGGER_FIRED: 'dom_trigger_fired',
|
||||
// Record & Replay overlay: variable collection
|
||||
COLLECT_VARIABLES: 'collectVariables',
|
||||
// Element marker overlay control (content-side)
|
||||
ELEMENT_MARKER_START: 'element_marker_start',
|
||||
// Element picker (tool-driven, background <-> content scripts)
|
||||
ELEMENT_PICKER_START: 'elementPickerStart',
|
||||
ELEMENT_PICKER_STOP: 'elementPickerStop',
|
||||
ELEMENT_PICKER_SET_ACTIVE_REQUEST: 'elementPickerSetActiveRequest',
|
||||
ELEMENT_PICKER_UI_PING: 'elementPickerUiPing',
|
||||
ELEMENT_PICKER_UI_SHOW: 'elementPickerUiShow',
|
||||
ELEMENT_PICKER_UI_UPDATE: 'elementPickerUiUpdate',
|
||||
ELEMENT_PICKER_UI_HIDE: 'elementPickerUiHide',
|
||||
} as const;
|
||||
|
||||
// Type unions for type safety
|
||||
export type BackgroundMessageType =
|
||||
(typeof BACKGROUND_MESSAGE_TYPES)[keyof typeof BACKGROUND_MESSAGE_TYPES];
|
||||
export type OffscreenMessageType =
|
||||
(typeof OFFSCREEN_MESSAGE_TYPES)[keyof typeof OFFSCREEN_MESSAGE_TYPES];
|
||||
export type ContentMessageType = (typeof CONTENT_MESSAGE_TYPES)[keyof typeof CONTENT_MESSAGE_TYPES];
|
||||
export type ToolMessageType = (typeof TOOL_MESSAGE_TYPES)[keyof typeof TOOL_MESSAGE_TYPES];
|
||||
|
||||
// Legacy enum for backward compatibility (will be deprecated)
|
||||
export enum SendMessageType {
|
||||
// Screenshot related message types
|
||||
ScreenshotPreparePageForCapture = 'preparePageForCapture',
|
||||
ScreenshotGetPageDetails = 'getPageDetails',
|
||||
ScreenshotGetElementDetails = 'getElementDetails',
|
||||
ScreenshotScrollPage = 'scrollPage',
|
||||
ScreenshotResetPageAfterCapture = 'resetPageAfterCapture',
|
||||
|
||||
// Web content fetching related message types
|
||||
WebFetcherGetHtmlContent = 'getHtmlContent',
|
||||
WebFetcherGetTextContent = 'getTextContent',
|
||||
|
||||
// Click related message types
|
||||
ClickElement = 'clickElement',
|
||||
|
||||
// Input filling related message types
|
||||
FillElement = 'fillElement',
|
||||
|
||||
// Interactive elements related message types
|
||||
GetInteractiveElements = 'getInteractiveElements',
|
||||
|
||||
// Network request capture related message types
|
||||
NetworkSendRequest = 'sendPureNetworkRequest',
|
||||
|
||||
// Keyboard event related message types
|
||||
SimulateKeyboard = 'simulateKeyboard',
|
||||
|
||||
// Semantic similarity engine related message types
|
||||
SimilarityEngineInit = 'similarityEngineInit',
|
||||
SimilarityEngineComputeBatch = 'similarityEngineComputeBatch',
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Quick Panel <-> AgentChat Message Contracts
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Context information that can be attached to a Quick Panel AI request.
|
||||
* Allows passing page-specific data to enhance the AI's understanding.
|
||||
*/
|
||||
export interface QuickPanelAIContext {
|
||||
/** Current page URL */
|
||||
pageUrl?: string;
|
||||
/** User's text selection on the page */
|
||||
selectedText?: string;
|
||||
/**
|
||||
* Optional element metadata from the page.
|
||||
* Kept as unknown to avoid tight coupling with specific element types.
|
||||
*/
|
||||
elementInfo?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for sending a message to AI via Quick Panel.
|
||||
*/
|
||||
export interface QuickPanelSendToAIPayload {
|
||||
/** The user's instruction/question for the AI */
|
||||
instruction: string;
|
||||
/** Optional contextual information from the page */
|
||||
context?: QuickPanelAIContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from QUICK_PANEL_SEND_TO_AI message handler.
|
||||
*/
|
||||
export type QuickPanelSendToAIResponse =
|
||||
| { success: true; requestId: string; sessionId: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Message structure for sending to AI.
|
||||
*/
|
||||
export interface QuickPanelSendToAIMessage {
|
||||
type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_SEND_TO_AI;
|
||||
payload: QuickPanelSendToAIPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for cancelling an active AI request.
|
||||
*/
|
||||
export interface QuickPanelCancelAIPayload {
|
||||
/** The request ID to cancel */
|
||||
requestId: string;
|
||||
/**
|
||||
* Optional session ID for fallback when background state is missing.
|
||||
* This can happen after MV3 Service Worker restarts.
|
||||
*/
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from QUICK_PANEL_CANCEL_AI message handler.
|
||||
*/
|
||||
export type QuickPanelCancelAIResponse = { success: true } | { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Message structure for cancelling AI request.
|
||||
*/
|
||||
export interface QuickPanelCancelAIMessage {
|
||||
type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CANCEL_AI;
|
||||
payload: QuickPanelCancelAIPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message pushed from background to content script with AI streaming events.
|
||||
* Uses the same RealtimeEvent type as AgentChat for consistency.
|
||||
*/
|
||||
export interface QuickPanelAIEventMessage {
|
||||
action: typeof TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT;
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
event: RealtimeEvent;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Quick Panel Search - Tabs Bridge Contracts
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Payload for querying open tabs.
|
||||
*/
|
||||
export interface QuickPanelTabsQueryPayload {
|
||||
/**
|
||||
* When true (default), query tabs across all windows.
|
||||
* When false, restrict results to the sender's window.
|
||||
*/
|
||||
includeAllWindows?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary of a single tab returned from the background.
|
||||
*/
|
||||
export interface QuickPanelTabSummary {
|
||||
tabId: number;
|
||||
windowId: number;
|
||||
title: string;
|
||||
url: string;
|
||||
favIconUrl?: string;
|
||||
active: boolean;
|
||||
pinned: boolean;
|
||||
audible: boolean;
|
||||
muted: boolean;
|
||||
index: number;
|
||||
lastAccessed?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from QUICK_PANEL_TABS_QUERY message handler.
|
||||
*/
|
||||
export type QuickPanelTabsQueryResponse =
|
||||
| {
|
||||
success: true;
|
||||
tabs: QuickPanelTabSummary[];
|
||||
currentTabId: number | null;
|
||||
currentWindowId: number | null;
|
||||
}
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Message structure for querying tabs.
|
||||
*/
|
||||
export interface QuickPanelTabsQueryMessage {
|
||||
type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TABS_QUERY;
|
||||
payload?: QuickPanelTabsQueryPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for activating a tab.
|
||||
*/
|
||||
export interface QuickPanelActivateTabPayload {
|
||||
tabId: number;
|
||||
windowId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from QUICK_PANEL_TAB_ACTIVATE message handler.
|
||||
*/
|
||||
export type QuickPanelActivateTabResponse = { success: true } | { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Message structure for activating a tab.
|
||||
*/
|
||||
export interface QuickPanelActivateTabMessage {
|
||||
type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_ACTIVATE;
|
||||
payload: QuickPanelActivateTabPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for closing a tab.
|
||||
*/
|
||||
export interface QuickPanelCloseTabPayload {
|
||||
tabId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from QUICK_PANEL_TAB_CLOSE message handler.
|
||||
*/
|
||||
export type QuickPanelCloseTabResponse = { success: true } | { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Message structure for closing a tab.
|
||||
*/
|
||||
export interface QuickPanelCloseTabMessage {
|
||||
type: typeof BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_CLOSE;
|
||||
payload: QuickPanelCloseTabPayload;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// node-types.ts — centralized node type constants for Builder/UI layer
|
||||
// Combines all executable Step types with UI-only nodes (e.g., trigger, delay)
|
||||
|
||||
import { STEP_TYPES } from './step-types';
|
||||
|
||||
export const NODE_TYPES = {
|
||||
// Executable step types (spread from STEP_TYPES)
|
||||
...STEP_TYPES,
|
||||
// UI-only nodes
|
||||
TRIGGER: 'trigger',
|
||||
DELAY: 'delay',
|
||||
} as const;
|
||||
|
||||
export type NodeTypeConst = (typeof NODE_TYPES)[keyof typeof NODE_TYPES];
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @fileoverview RR V3 Keepalive Protocol Constants
|
||||
* @description Shared protocol constants for Background-Offscreen keepalive communication
|
||||
*/
|
||||
|
||||
/** Keepalive Port 名称 */
|
||||
export const RR_V3_KEEPALIVE_PORT_NAME = 'rr_v3_keepalive' as const;
|
||||
|
||||
/** Keepalive 消息类型 */
|
||||
export type KeepaliveMessageType =
|
||||
| 'keepalive.ping'
|
||||
| 'keepalive.pong'
|
||||
| 'keepalive.start'
|
||||
| 'keepalive.stop';
|
||||
|
||||
/** Keepalive 消息 */
|
||||
export interface KeepaliveMessage {
|
||||
type: KeepaliveMessageType;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** 默认心跳间隔(毫秒) - Offscreen 每隔这个间隔发送 ping */
|
||||
export const DEFAULT_KEEPALIVE_PING_INTERVAL_MS = 20_000;
|
||||
|
||||
/** 最大心跳间隔(毫秒)- Chrome MV3 SW 约 30s 空闲后终止 */
|
||||
export const MAX_KEEPALIVE_PING_INTERVAL_MS = 25_000;
|
||||
@@ -0,0 +1,4 @@
|
||||
// step-types.ts — re-export shared constants to keep single source of truth
|
||||
export { STEP_TYPES } from 'chrome-mcp-shared';
|
||||
export type StepTypeConst =
|
||||
(typeof import('chrome-mcp-shared'))['STEP_TYPES'][keyof (typeof import('chrome-mcp-shared'))['STEP_TYPES']];
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CallToolResult, TextContent, ImageContent } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export interface ToolResult extends CallToolResult {
|
||||
content: (TextContent | ImageContent)[];
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export interface ToolExecutor {
|
||||
execute(args: any): Promise<ToolResult>;
|
||||
}
|
||||
|
||||
export const createErrorResponse = (
|
||||
message: string = 'Unknown error, please try again',
|
||||
): ToolResult => {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Web Editor V2 - Shared Type Definitions
|
||||
*
|
||||
* This module defines types shared between:
|
||||
* - Background script (injection control)
|
||||
* - Inject script (web-editor-v2.ts)
|
||||
* - Future: UI panels
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Editor State
|
||||
// =============================================================================
|
||||
|
||||
/** Current state of the web editor */
|
||||
export interface WebEditorState {
|
||||
/** Whether the editor is currently active */
|
||||
active: boolean;
|
||||
/** Editor version for compatibility checks */
|
||||
version: 2;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Message Protocol (Background <-> Inject Script)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Action types for web editor V2 messages
|
||||
*
|
||||
* IMPORTANT: V2 uses versioned action names (suffix _v2) to avoid
|
||||
* conflicts with V1 when both scripts might be injected in the same tab.
|
||||
* This prevents double-response race conditions.
|
||||
*
|
||||
* V1 uses: web_editor_ping, web_editor_toggle, etc.
|
||||
* V2 uses: web_editor_ping_v2, web_editor_toggle_v2, etc.
|
||||
*/
|
||||
export const WEB_EDITOR_V2_ACTIONS = {
|
||||
/** Check if V2 editor is injected and get status */
|
||||
PING: 'web_editor_ping_v2',
|
||||
/** Toggle V2 editor on/off */
|
||||
TOGGLE: 'web_editor_toggle_v2',
|
||||
/** Start V2 editor */
|
||||
START: 'web_editor_start_v2',
|
||||
/** Stop V2 editor */
|
||||
STOP: 'web_editor_stop_v2',
|
||||
/** Highlight an element (from sidepanel hover) */
|
||||
HIGHLIGHT_ELEMENT: 'web_editor_highlight_element_v2',
|
||||
/** Revert an element to its original state (Phase 2 - Selective Undo) */
|
||||
REVERT_ELEMENT: 'web_editor_revert_element_v2',
|
||||
/** Clear selection (from sidepanel after send) */
|
||||
CLEAR_SELECTION: 'web_editor_clear_selection_v2',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Legacy V1 action types (for reference and background compatibility)
|
||||
* These are used when USE_WEB_EDITOR_V2 is false
|
||||
*/
|
||||
export const WEB_EDITOR_V1_ACTIONS = {
|
||||
PING: 'web_editor_ping',
|
||||
TOGGLE: 'web_editor_toggle',
|
||||
START: 'web_editor_start',
|
||||
STOP: 'web_editor_stop',
|
||||
APPLY: 'web_editor_apply',
|
||||
} as const;
|
||||
|
||||
export type WebEditorV2Action = (typeof WEB_EDITOR_V2_ACTIONS)[keyof typeof WEB_EDITOR_V2_ACTIONS];
|
||||
export type WebEditorV1Action = (typeof WEB_EDITOR_V1_ACTIONS)[keyof typeof WEB_EDITOR_V1_ACTIONS];
|
||||
|
||||
/** Editor version literal type */
|
||||
export type WebEditorVersion = 1 | 2;
|
||||
|
||||
/** Ping request (V2) */
|
||||
export interface WebEditorV2PingRequest {
|
||||
action: typeof WEB_EDITOR_V2_ACTIONS.PING;
|
||||
}
|
||||
|
||||
/** Ping response (V2) */
|
||||
export interface WebEditorV2PingResponse {
|
||||
status: 'pong';
|
||||
active: boolean;
|
||||
version: 2;
|
||||
}
|
||||
|
||||
/** Toggle request (V2) */
|
||||
export interface WebEditorV2ToggleRequest {
|
||||
action: typeof WEB_EDITOR_V2_ACTIONS.TOGGLE;
|
||||
}
|
||||
|
||||
/** Toggle response (V2) */
|
||||
export interface WebEditorV2ToggleResponse {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** Start request (V2) */
|
||||
export interface WebEditorV2StartRequest {
|
||||
action: typeof WEB_EDITOR_V2_ACTIONS.START;
|
||||
}
|
||||
|
||||
/** Start response (V2) */
|
||||
export interface WebEditorV2StartResponse {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** Stop request (V2) */
|
||||
export interface WebEditorV2StopRequest {
|
||||
action: typeof WEB_EDITOR_V2_ACTIONS.STOP;
|
||||
}
|
||||
|
||||
/** Stop response (V2) */
|
||||
export interface WebEditorV2StopResponse {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** Union types for V2 type-safe message handling */
|
||||
export type WebEditorV2Request =
|
||||
| WebEditorV2PingRequest
|
||||
| WebEditorV2ToggleRequest
|
||||
| WebEditorV2StartRequest
|
||||
| WebEditorV2StopRequest;
|
||||
|
||||
export type WebEditorV2Response =
|
||||
| WebEditorV2PingResponse
|
||||
| WebEditorV2ToggleResponse
|
||||
| WebEditorV2StartResponse
|
||||
| WebEditorV2StopResponse;
|
||||
|
||||
// =============================================================================
|
||||
// Element Locator (Phase 1 - Basic Structure)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Framework debug source information
|
||||
* Extracted from React Fiber or Vue component instance
|
||||
*/
|
||||
export interface DebugSource {
|
||||
/** Source file path */
|
||||
file: string;
|
||||
/** Line number (1-based) */
|
||||
line?: number;
|
||||
/** Column number (1-based) */
|
||||
column?: number;
|
||||
/** Component name (if available) */
|
||||
componentName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Element Locator - Primary key for element identification
|
||||
*
|
||||
* Uses multiple strategies to locate elements, supporting:
|
||||
* - HMR/DOM changes recovery
|
||||
* - Cross-session persistence
|
||||
* - Framework-agnostic identification
|
||||
*/
|
||||
export interface ElementLocator {
|
||||
/** CSS selector candidates (ordered by specificity) */
|
||||
selectors: string[];
|
||||
/** Structural fingerprint for similarity matching */
|
||||
fingerprint: string;
|
||||
/** Framework debug information (React/Vue) */
|
||||
debugSource?: DebugSource;
|
||||
/** DOM tree path (child indices from root) */
|
||||
path: number[];
|
||||
/** iframe selector chain (from top to target frame) - Phase 4 */
|
||||
frameChain?: string[];
|
||||
/** Shadow DOM host selector chain - Phase 2 */
|
||||
shadowHostChain?: string[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Transaction System (Phase 1 - Basic Structure, Low Priority)
|
||||
// =============================================================================
|
||||
|
||||
/** Transaction operation types */
|
||||
export type TransactionType = 'style' | 'text' | 'class' | 'move' | 'structure';
|
||||
|
||||
/**
|
||||
* Transaction snapshot for undo/redo
|
||||
* Captures element state before/after changes
|
||||
*/
|
||||
export interface TransactionSnapshot {
|
||||
/** Element locator for re-identification */
|
||||
locator: ElementLocator;
|
||||
/** innerHTML snapshot (for structure changes) */
|
||||
html?: string;
|
||||
/** Changed style properties */
|
||||
styles?: Record<string, string>;
|
||||
/** Class list tokens (from `class` attribute) */
|
||||
classes?: string[];
|
||||
/** Text content */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move position data
|
||||
* Captures a concrete insertion point under a parent element
|
||||
*/
|
||||
export interface MoveOperationData {
|
||||
/** Target parent element locator */
|
||||
parentLocator: ElementLocator;
|
||||
/** Insert position index (among element children) */
|
||||
insertIndex: number;
|
||||
/** Anchor sibling element locator (for stable positioning) */
|
||||
anchorLocator?: ElementLocator;
|
||||
/** Position relative to anchor */
|
||||
anchorPosition: 'before' | 'after';
|
||||
}
|
||||
|
||||
/**
|
||||
* Move transaction data
|
||||
* Captures both source and destination for undo/redo
|
||||
*/
|
||||
export interface MoveTransactionData {
|
||||
/** Original location before move */
|
||||
from: MoveOperationData;
|
||||
/** Target location after move */
|
||||
to: MoveOperationData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure operation data
|
||||
* For wrap/unwrap/delete/duplicate operations (Phase 5.5)
|
||||
*/
|
||||
export interface StructureOperationData {
|
||||
/** Structure action type */
|
||||
action: 'wrap' | 'unwrap' | 'delete' | 'duplicate';
|
||||
/** Wrapper tag for wrap/unwrap actions */
|
||||
wrapperTag?: string;
|
||||
/** Wrapper inline styles for wrap/unwrap actions */
|
||||
wrapperStyles?: Record<string, string>;
|
||||
/**
|
||||
* Deterministic insertion position for undo/redo.
|
||||
* Required for delete (restore) and duplicate (re-create).
|
||||
*/
|
||||
position?: MoveOperationData;
|
||||
/**
|
||||
* Serialized element HTML for undo/redo.
|
||||
* Must be a single-root element outerHTML string.
|
||||
* Used by delete (restore original) and duplicate (re-create clone).
|
||||
*/
|
||||
html?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transaction record for undo/redo system
|
||||
*/
|
||||
export interface Transaction {
|
||||
/** Unique transaction ID */
|
||||
id: string;
|
||||
/** Operation type */
|
||||
type: TransactionType;
|
||||
/** Target element locator */
|
||||
targetLocator: ElementLocator;
|
||||
/**
|
||||
* Stable element identifier for cross-transaction grouping.
|
||||
* Used by AgentChat integration for element chips aggregation.
|
||||
* Optional for backward compatibility with existing transactions.
|
||||
*/
|
||||
elementKey?: string;
|
||||
/** State before change */
|
||||
before: TransactionSnapshot;
|
||||
/** State after change */
|
||||
after: TransactionSnapshot;
|
||||
/** Move-specific data */
|
||||
moveData?: MoveTransactionData;
|
||||
/** Structure-specific data */
|
||||
structureData?: StructureOperationData;
|
||||
/** Timestamp */
|
||||
timestamp: number;
|
||||
/** Whether merged with previous transaction */
|
||||
merged: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AgentChat Integration Types (Phase 1.1)
|
||||
// =============================================================================
|
||||
|
||||
/** Stable element identifier for aggregating transactions across UI contexts */
|
||||
export type WebEditorElementKey = string;
|
||||
|
||||
/**
|
||||
* Net effect payload for a single element aggregated from the undo stack.
|
||||
* Designed to be directly consumable by prompt builders.
|
||||
*/
|
||||
export interface NetEffectPayload {
|
||||
/** Stable element key */
|
||||
elementKey: WebEditorElementKey;
|
||||
/** Locator snapshot for element re-identification */
|
||||
locator: ElementLocator;
|
||||
/**
|
||||
* Aggregated style changes (first before -> last after).
|
||||
* Contains ONLY the affected properties, not a full style snapshot.
|
||||
* Empty string value means the property was removed/unset.
|
||||
*/
|
||||
styleChanges?: {
|
||||
before: Record<string, string>;
|
||||
after: Record<string, string>;
|
||||
};
|
||||
/** Aggregated text change (first before -> last after) */
|
||||
textChange?: {
|
||||
before: string;
|
||||
after: string;
|
||||
};
|
||||
/** Aggregated class changes (first before -> last after) */
|
||||
classChanges?: {
|
||||
before: string[];
|
||||
after: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/** High-level change category for UI display */
|
||||
export type ElementChangeType = 'style' | 'text' | 'class' | 'mixed';
|
||||
|
||||
/**
|
||||
* Element change summary for Chips rendering in AgentChat.
|
||||
* Aggregates multiple transactions for the same element.
|
||||
*/
|
||||
export interface ElementChangeSummary {
|
||||
/** Stable element identifier */
|
||||
elementKey: WebEditorElementKey;
|
||||
/** Short label for Chips display (e.g., "button#submit") */
|
||||
label: string;
|
||||
/** Full label for tooltips with more context */
|
||||
fullLabel: string;
|
||||
/** Locator snapshot for highlighting and element recovery */
|
||||
locator: ElementLocator;
|
||||
/** High-level change category */
|
||||
type: ElementChangeType;
|
||||
/** Detailed change statistics for UI tooltips */
|
||||
changes: {
|
||||
style?: {
|
||||
/** Number of new style properties added */
|
||||
added: number;
|
||||
/** Number of style properties removed */
|
||||
removed: number;
|
||||
/** Number of style properties modified */
|
||||
modified: number;
|
||||
/** List of affected style property names */
|
||||
details: string[];
|
||||
};
|
||||
text?: {
|
||||
/** Truncated preview of original text */
|
||||
beforePreview: string;
|
||||
/** Truncated preview of new text */
|
||||
afterPreview: string;
|
||||
};
|
||||
class?: {
|
||||
/** Classes added */
|
||||
added: string[];
|
||||
/** Classes removed */
|
||||
removed: string[];
|
||||
};
|
||||
};
|
||||
/** Contributing transaction IDs in chronological order */
|
||||
transactionIds: string[];
|
||||
/** Net effect payload for batch Apply */
|
||||
netEffect: NetEffectPayload;
|
||||
/** Timestamp of the most recent transaction */
|
||||
updatedAt: number;
|
||||
/** Debug source information if available */
|
||||
debugSource?: DebugSource;
|
||||
}
|
||||
|
||||
/** Action types for TX change events */
|
||||
export type WebEditorTxChangeAction = 'push' | 'merge' | 'undo' | 'redo' | 'clear' | 'rollback';
|
||||
|
||||
/**
|
||||
* TX change broadcast payload sent to Sidepanel/AgentChat.
|
||||
* Emitted when the undo stack changes (push, undo, redo, clear).
|
||||
*/
|
||||
export interface WebEditorTxChangedPayload {
|
||||
/** Source tab ID for multi-tab isolation */
|
||||
tabId: number;
|
||||
/** Action that triggered this change (for UI animations/incremental updates) */
|
||||
action: WebEditorTxChangeAction;
|
||||
/** Aggregated element-level summaries from the current undo stack */
|
||||
elements: ElementChangeSummary[];
|
||||
/** Current undo stack size */
|
||||
undoCount: number;
|
||||
/** Current redo stack size */
|
||||
redoCount: number;
|
||||
/** Whether there are applicable changes (style/text/class) */
|
||||
hasApplicableChanges: boolean;
|
||||
/** Page URL for context */
|
||||
pageUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch Apply payload sent from web-editor to background.
|
||||
*/
|
||||
export interface WebEditorApplyBatchPayload {
|
||||
/** Source tab ID */
|
||||
tabId: number;
|
||||
/** Element changes to apply */
|
||||
elements: ElementChangeSummary[];
|
||||
/** Element keys excluded by user */
|
||||
excludedKeys: WebEditorElementKey[];
|
||||
/** Page URL for context */
|
||||
pageUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight element request sent from AgentChat to the active tab.
|
||||
*/
|
||||
export interface WebEditorHighlightElementPayload {
|
||||
/** Target tab ID */
|
||||
tabId: number;
|
||||
/** Element key to highlight */
|
||||
elementKey: WebEditorElementKey;
|
||||
/** Locator for element identification */
|
||||
locator: ElementLocator;
|
||||
/** Highlight mode: 'hover' to show, 'clear' to hide */
|
||||
mode: 'hover' | 'clear';
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert element request sent from AgentChat to the active tab.
|
||||
* Used for Phase 2 - Selective Undo (reverting individual element changes).
|
||||
*/
|
||||
export interface WebEditorRevertElementPayload {
|
||||
/** Target tab ID */
|
||||
tabId: number;
|
||||
/** Element key to revert */
|
||||
elementKey: WebEditorElementKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert element response from content script.
|
||||
*/
|
||||
export interface WebEditorRevertElementResponse {
|
||||
/** Whether the revert was successful */
|
||||
success: boolean;
|
||||
/** What was reverted (for UI feedback) */
|
||||
reverted?: {
|
||||
style?: boolean;
|
||||
text?: boolean;
|
||||
class?: boolean;
|
||||
};
|
||||
/** Error message if revert failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Selection Sync Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Summary of currently selected element.
|
||||
* Lightweight payload for selection sync (no transaction data).
|
||||
*/
|
||||
export interface SelectedElementSummary {
|
||||
/** Stable element identifier */
|
||||
elementKey: WebEditorElementKey;
|
||||
/** Locator for element identification and highlighting */
|
||||
locator: ElementLocator;
|
||||
/** Short display label (e.g., "div#app") */
|
||||
label: string;
|
||||
/** Full label with context (e.g., "body > div#app") */
|
||||
fullLabel: string;
|
||||
/** Tag name of the element */
|
||||
tagName: string;
|
||||
/** Timestamp for deduplication */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection change broadcast payload.
|
||||
* Sent immediately when user selects/deselects elements (no debounce).
|
||||
*/
|
||||
export interface WebEditorSelectionChangedPayload {
|
||||
/** Source tab ID (filled by background from sender.tab.id) */
|
||||
tabId: number;
|
||||
/** Currently selected element, or null if deselected */
|
||||
selected: SelectedElementSummary | null;
|
||||
/** Page URL for context */
|
||||
pageUrl?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Execution Cancel Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Payload for canceling an ongoing Apply execution.
|
||||
* Sent from web-editor toolbar or sidepanel to background.
|
||||
*/
|
||||
export interface WebEditorCancelExecutionPayload {
|
||||
/** Session ID of the execution to cancel */
|
||||
sessionId: string;
|
||||
/** Request ID of the execution to cancel */
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from cancel execution request.
|
||||
*/
|
||||
export interface WebEditorCancelExecutionResponse {
|
||||
/** Whether the cancel request was successful */
|
||||
success: boolean;
|
||||
/** Error message if cancellation failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Public API Interface
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Web Editor V2 Public API
|
||||
* Exposed on window.__MCP_WEB_EDITOR_V2__
|
||||
*/
|
||||
export interface WebEditorV2Api {
|
||||
/** Start the editor */
|
||||
start: () => void;
|
||||
/** Stop the editor */
|
||||
stop: () => void;
|
||||
/** Toggle editor on/off, returns new state */
|
||||
toggle: () => boolean;
|
||||
/** Get current state */
|
||||
getState: () => WebEditorState;
|
||||
/**
|
||||
* Revert a specific element to its original state (Phase 2 - Selective Undo).
|
||||
* Creates a compensating transaction that can be undone.
|
||||
*/
|
||||
revertElement: (elementKey: WebEditorElementKey) => Promise<WebEditorRevertElementResponse>;
|
||||
/**
|
||||
* Clear current selection (called from sidepanel after send).
|
||||
* Triggers deselect and broadcasts null selection.
|
||||
*/
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Global Declaration
|
||||
// =============================================================================
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__MCP_WEB_EDITOR_V2__?: WebEditorV2Api;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// IndexedDB storage for element markers (URL -> marked selectors)
|
||||
// Uses the shared IndexedDbClient for robust transaction handling.
|
||||
|
||||
import { IndexedDbClient } from '@/utils/indexeddb-client';
|
||||
import type { ElementMarker, UpsertMarkerRequest } from '@/common/element-marker-types';
|
||||
|
||||
const DB_NAME = 'element_marker_storage';
|
||||
const DB_VERSION = 1;
|
||||
const STORE = 'markers';
|
||||
|
||||
const idb = new IndexedDbClient(DB_NAME, DB_VERSION, (db, oldVersion) => {
|
||||
switch (oldVersion) {
|
||||
case 0: {
|
||||
const store = db.createObjectStore(STORE, { keyPath: 'id' });
|
||||
// Useful indexes for lookups
|
||||
store.createIndex('by_host', 'host', { unique: false });
|
||||
store.createIndex('by_origin', 'origin', { unique: false });
|
||||
store.createIndex('by_path', 'path', { unique: false });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function normalizeUrl(raw: string): { url: string; origin: string; host: string; path: string } {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
return { url: raw, origin: u.origin, host: u.hostname, path: u.pathname };
|
||||
} catch {
|
||||
return { url: raw, origin: '', host: '', path: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function now(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export async function listAllMarkers(): Promise<ElementMarker[]> {
|
||||
return idb.getAll<ElementMarker>(STORE);
|
||||
}
|
||||
|
||||
export async function listMarkersForUrl(url: string): Promise<ElementMarker[]> {
|
||||
const { origin, path, host } = normalizeUrl(url);
|
||||
const all = await idb.getAll<ElementMarker>(STORE);
|
||||
// Simple matching policy:
|
||||
// - exact: origin + path must match exactly
|
||||
// - prefix: origin matches and marker.path is a prefix of current path
|
||||
// - host: host matches regardless of path
|
||||
return all.filter((m) => {
|
||||
if (!m) return false;
|
||||
if (m.matchType === 'exact') return m.origin === origin && m.path === path;
|
||||
if (m.matchType === 'host') return !!m.host && m.host === host;
|
||||
// default 'prefix'
|
||||
return m.origin === origin && (m.path ? path.startsWith(m.path) : true);
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveMarker(req: UpsertMarkerRequest): Promise<ElementMarker> {
|
||||
const { url: rawUrl, selector } = req;
|
||||
if (!rawUrl || !selector) throw new Error('url and selector are required');
|
||||
const { url, origin, host, path } = normalizeUrl(rawUrl);
|
||||
const ts = now();
|
||||
const marker: ElementMarker = {
|
||||
id: req.id || (globalThis.crypto?.randomUUID?.() ?? `${ts}_${Math.random()}`),
|
||||
url,
|
||||
origin,
|
||||
host,
|
||||
path,
|
||||
matchType: req.matchType || 'prefix',
|
||||
name: req.name || selector,
|
||||
selector,
|
||||
selectorType: req.selectorType || 'css',
|
||||
listMode: req.listMode || false,
|
||||
action: req.action || 'custom',
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
};
|
||||
await idb.put<ElementMarker>(STORE, marker);
|
||||
return marker;
|
||||
}
|
||||
|
||||
export async function updateMarker(marker: ElementMarker): Promise<void> {
|
||||
const existing = await idb.get<ElementMarker>(STORE, marker.id);
|
||||
if (!existing) throw new Error('marker not found');
|
||||
|
||||
// Preserve createdAt from existing record, only update updatedAt
|
||||
const updated: ElementMarker = {
|
||||
...marker,
|
||||
createdAt: existing.createdAt, // Never overwrite createdAt
|
||||
updatedAt: now(),
|
||||
};
|
||||
await idb.put<ElementMarker>(STORE, updated);
|
||||
}
|
||||
|
||||
export async function deleteMarker(id: string): Promise<void> {
|
||||
await idb.delete(STORE, id);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
|
||||
import type {
|
||||
UpsertMarkerRequest,
|
||||
ElementMarker,
|
||||
MarkerValidationRequest,
|
||||
MarkerValidationAction,
|
||||
} from '@/common/element-marker-types';
|
||||
import {
|
||||
deleteMarker,
|
||||
listAllMarkers,
|
||||
listMarkersForUrl,
|
||||
saveMarker,
|
||||
updateMarker,
|
||||
} from './element-marker-storage';
|
||||
import { computerTool } from '@/entrypoints/background/tools/browser/computer';
|
||||
import { clickTool } from '@/entrypoints/background/tools/browser/interaction';
|
||||
import { keyboardTool } from '@/entrypoints/background/tools/browser/keyboard';
|
||||
|
||||
const CONTEXT_MENU_ID = 'element_marker_mark';
|
||||
|
||||
/**
|
||||
* Extract error message from MCP tool result
|
||||
*/
|
||||
function extractToolError(result: any): string | undefined {
|
||||
if (!result) return undefined;
|
||||
|
||||
// Check for error in result content array
|
||||
if (Array.isArray(result.content)) {
|
||||
for (const item of result.content) {
|
||||
if (item?.text) {
|
||||
try {
|
||||
const parsed = JSON.parse(item.text);
|
||||
if (parsed?.error) return parsed.error;
|
||||
if (parsed?.message) return parsed.message;
|
||||
} catch {
|
||||
// Not JSON, use as-is
|
||||
return item.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to direct error field
|
||||
return result.error || (result.isError ? 'unknown tool error' : undefined);
|
||||
}
|
||||
|
||||
async function ensureContextMenu() {
|
||||
try {
|
||||
// Guard: contextMenus permission may be missing
|
||||
if (!(chrome as any).contextMenus?.create) return;
|
||||
// Remove and re-create our single menu to avoid duplication
|
||||
try {
|
||||
await chrome.contextMenus.remove(CONTEXT_MENU_ID);
|
||||
} catch {}
|
||||
await chrome.contextMenus.create({
|
||||
id: CONTEXT_MENU_ID,
|
||||
title: '标注元素',
|
||||
contexts: ['all'],
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('ElementMarker: ensureContextMenu failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element-marker.js is already injected in the tab
|
||||
* Uses a short timeout to avoid hanging on unresponsive tabs
|
||||
*/
|
||||
async function isMarkerInjected(tabId: number): Promise<boolean> {
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
chrome.tabs.sendMessage(tabId, { action: 'element_marker_ping' }),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 300)),
|
||||
]);
|
||||
return response?.status === 'pong';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject element-marker.js into the tab if not already injected
|
||||
*/
|
||||
async function injectMarkerHelper(tabId: number) {
|
||||
// Check if already injected via ping
|
||||
const alreadyInjected = await isMarkerInjected(tabId);
|
||||
|
||||
if (!alreadyInjected) {
|
||||
try {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId, allFrames: true },
|
||||
files: ['inject-scripts/element-marker.js'],
|
||||
world: 'ISOLATED',
|
||||
} as any);
|
||||
} catch (e) {
|
||||
// Script injection may fail on some pages (e.g., chrome:// URLs)
|
||||
console.warn('ElementMarker: script injection failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tabId, { action: 'element_marker_start' } as any);
|
||||
} catch (e) {
|
||||
console.warn('ElementMarker: start overlay failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function initElementMarkerListeners() {
|
||||
// Ensure context menu on startup
|
||||
ensureContextMenu().catch(() => {});
|
||||
|
||||
// Respond to RR triggers refresh by re-ensuring our menu a bit later
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
try {
|
||||
switch (message?.type) {
|
||||
// Handle element marker start from popup
|
||||
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_START: {
|
||||
const tabId = message.tabId;
|
||||
if (typeof tabId !== 'number') {
|
||||
sendResponse({ success: false, error: 'invalid tabId' });
|
||||
return true;
|
||||
}
|
||||
injectMarkerHelper(tabId)
|
||||
.then(() => sendResponse({ success: true }))
|
||||
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
|
||||
return true;
|
||||
}
|
||||
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_ALL: {
|
||||
listAllMarkers()
|
||||
.then((markers) => sendResponse({ success: true, markers }))
|
||||
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
|
||||
return true;
|
||||
}
|
||||
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_LIST_FOR_URL: {
|
||||
const url = String(message.url || '');
|
||||
listMarkersForUrl(url)
|
||||
.then((markers) => sendResponse({ success: true, markers }))
|
||||
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
|
||||
return true;
|
||||
}
|
||||
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_SAVE: {
|
||||
const req = message.marker as UpsertMarkerRequest;
|
||||
saveMarker(req)
|
||||
.then((marker) => sendResponse({ success: true, marker }))
|
||||
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
|
||||
return true;
|
||||
}
|
||||
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_UPDATE: {
|
||||
const marker = message.marker as ElementMarker;
|
||||
updateMarker(marker)
|
||||
.then(() => sendResponse({ success: true }))
|
||||
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
|
||||
return true;
|
||||
}
|
||||
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_DELETE: {
|
||||
const id = String(message.id || '');
|
||||
if (!id) {
|
||||
sendResponse({ success: false, error: 'invalid id' });
|
||||
return true;
|
||||
}
|
||||
deleteMarker(id)
|
||||
.then(() => sendResponse({ success: true }))
|
||||
.catch((e) => sendResponse({ success: false, error: e?.message || String(e) }));
|
||||
return true;
|
||||
}
|
||||
case BACKGROUND_MESSAGE_TYPES.ELEMENT_MARKER_VALIDATE: {
|
||||
// Validate via MCP tool chain
|
||||
(async () => {
|
||||
const req = message as {
|
||||
selector: string;
|
||||
selectorType?: 'css' | 'xpath';
|
||||
action: MarkerValidationAction;
|
||||
listMode?: boolean;
|
||||
text?: string;
|
||||
keys?: string;
|
||||
button?: 'left' | 'right' | 'middle';
|
||||
bubbles?: boolean;
|
||||
cancelable?: boolean;
|
||||
modifiers?: any;
|
||||
coordinates?: { x: number; y: number };
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
relativeTo?: 'element' | 'viewport';
|
||||
};
|
||||
// enrich typing with optional nav + scroll params
|
||||
(req as any).waitForNavigation = (message as any).waitForNavigation;
|
||||
(req as any).timeoutMs = (message as any).timeoutMs;
|
||||
(req as any).scrollDirection = (message as any).scrollDirection;
|
||||
(req as any).scrollAmount = (message as any).scrollAmount;
|
||||
const selector = String(req.selector || '').trim();
|
||||
const selectorType = (req.selectorType || 'css') as 'css' | 'xpath';
|
||||
const action = req.action as MarkerValidationAction;
|
||||
if (!selector) return sendResponse({ success: false, error: 'selector is required' });
|
||||
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
const tab = tabs[0];
|
||||
if (!tab?.id) return sendResponse({ success: false, error: 'active tab not found' });
|
||||
|
||||
// 1) Ensure helper
|
||||
try {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id, allFrames: true },
|
||||
files: ['inject-scripts/accessibility-tree-helper.js'],
|
||||
world: 'ISOLATED',
|
||||
} as any);
|
||||
} catch {}
|
||||
|
||||
// 2) Resolve selector -> ref/center via helper (same as tools)
|
||||
let ensured: any;
|
||||
try {
|
||||
ensured = await chrome.tabs.sendMessage(tab.id, {
|
||||
action: 'ensureRefForSelector',
|
||||
selector,
|
||||
isXPath: selectorType === 'xpath',
|
||||
allowMultiple: !!req.listMode,
|
||||
} as any);
|
||||
} catch (e) {
|
||||
return sendResponse({
|
||||
success: false,
|
||||
error: String(e instanceof Error ? e.message : e),
|
||||
});
|
||||
}
|
||||
if (!ensured || !ensured.success || !ensured.ref) {
|
||||
return sendResponse({
|
||||
success: false,
|
||||
error: ensured?.error || 'failed to resolve selector',
|
||||
});
|
||||
}
|
||||
|
||||
const base = {
|
||||
success: true,
|
||||
resolved: true,
|
||||
ref: ensured.ref,
|
||||
center: ensured.center,
|
||||
} as any;
|
||||
|
||||
// Compute optional coordinates from offsets
|
||||
let coords: { x: number; y: number } | undefined = undefined;
|
||||
if (
|
||||
req.coordinates &&
|
||||
typeof req.coordinates.x === 'number' &&
|
||||
typeof req.coordinates.y === 'number'
|
||||
) {
|
||||
coords = { x: Math.round(req.coordinates.x), y: Math.round(req.coordinates.y) };
|
||||
} else if (
|
||||
req.relativeTo === 'element' &&
|
||||
ensured.center &&
|
||||
(typeof req.offsetX === 'number' || typeof req.offsetY === 'number')
|
||||
) {
|
||||
const dx = Number.isFinite(req.offsetX as any) ? (req.offsetX as number) : 0;
|
||||
const dy = Number.isFinite(req.offsetY as any) ? (req.offsetY as number) : 0;
|
||||
coords = { x: ensured.center.x + dx, y: ensured.center.y + dy };
|
||||
}
|
||||
|
||||
// 3) Dispatch to appropriate tool for end-to-end validation
|
||||
try {
|
||||
switch (action) {
|
||||
case 'hover': {
|
||||
const r = await computerTool.execute(
|
||||
coords
|
||||
? { action: 'hover', coordinates: coords }
|
||||
: ({ action: 'hover', ref: ensured.ref } as any),
|
||||
);
|
||||
const error = r.isError ? extractToolError(r) : undefined;
|
||||
base.tool = { name: 'computer.hover', ok: !r.isError, error };
|
||||
break;
|
||||
}
|
||||
case 'left_click': {
|
||||
const r = await clickTool.execute({
|
||||
...(coords ? { coordinates: coords } : { ref: ensured.ref }),
|
||||
waitForNavigation: !!req.waitForNavigation,
|
||||
timeout: Number.isFinite(req.timeoutMs as any)
|
||||
? (req.timeoutMs as number)
|
||||
: 3000,
|
||||
button: (req.button || 'left') as any,
|
||||
modifiers: req.modifiers || {},
|
||||
} as any);
|
||||
const error = r.isError ? extractToolError(r) : undefined;
|
||||
base.tool = { name: 'interaction.click', ok: !r.isError, error };
|
||||
break;
|
||||
}
|
||||
case 'double_click': {
|
||||
const r = await clickTool.execute({
|
||||
...(coords ? { coordinates: coords } : { ref: ensured.ref }),
|
||||
double: true,
|
||||
waitForNavigation: !!req.waitForNavigation,
|
||||
timeout: Number.isFinite(req.timeoutMs as any)
|
||||
? (req.timeoutMs as number)
|
||||
: 3000,
|
||||
button: (req.button || 'left') as any,
|
||||
modifiers: req.modifiers || {},
|
||||
} as any);
|
||||
const error = r.isError ? extractToolError(r) : undefined;
|
||||
base.tool = { name: 'interaction.click(double)', ok: !r.isError, error };
|
||||
break;
|
||||
}
|
||||
case 'right_click': {
|
||||
const r = await clickTool.execute({
|
||||
...(coords ? { coordinates: coords } : { ref: ensured.ref }),
|
||||
waitForNavigation: !!req.waitForNavigation,
|
||||
timeout: Number.isFinite(req.timeoutMs as any)
|
||||
? (req.timeoutMs as number)
|
||||
: 3000,
|
||||
button: 'right',
|
||||
modifiers: req.modifiers || {},
|
||||
} as any);
|
||||
const error = r.isError ? extractToolError(r) : undefined;
|
||||
base.tool = { name: 'interaction.click(right)', ok: !r.isError, error };
|
||||
break;
|
||||
}
|
||||
case 'scroll': {
|
||||
const direction = (req as any).scrollDirection || 'down';
|
||||
const amount = Number.isFinite((req as any).scrollAmount)
|
||||
? Number((req as any).scrollAmount)
|
||||
: 300;
|
||||
const payload = coords
|
||||
? {
|
||||
action: 'scroll',
|
||||
scrollDirection: direction,
|
||||
scrollAmount: amount,
|
||||
coordinates: coords,
|
||||
}
|
||||
: ({
|
||||
action: 'scroll',
|
||||
scrollDirection: direction,
|
||||
scrollAmount: amount,
|
||||
ref: ensured.ref,
|
||||
} as any);
|
||||
const r = await computerTool.execute(payload as any);
|
||||
const error = r.isError ? extractToolError(r) : undefined;
|
||||
base.tool = { name: 'computer.scroll', ok: !r.isError, error };
|
||||
break;
|
||||
}
|
||||
case 'type_text': {
|
||||
const text = String(req.text || '');
|
||||
const r = await computerTool.execute({ action: 'type', ref: ensured.ref, text });
|
||||
const error = r.isError ? extractToolError(r) : undefined;
|
||||
base.tool = { name: 'computer.type', ok: !r.isError, error };
|
||||
break;
|
||||
}
|
||||
case 'press_keys': {
|
||||
const keys = String(req.keys || '');
|
||||
// Focus first by ref to ensure key target
|
||||
try {
|
||||
await clickTool.execute({
|
||||
ref: ensured.ref,
|
||||
waitForNavigation: false,
|
||||
timeout: 2000,
|
||||
});
|
||||
} catch {}
|
||||
const r = await keyboardTool.execute({ keys, delay: 0 } as any);
|
||||
const error = r.isError ? extractToolError(r) : undefined;
|
||||
base.tool = { name: 'keyboard.simulate', ok: !r.isError, error };
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
base.tool = { name: 'noop', ok: true };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ElementMarker] Validation failed before tool execution', e);
|
||||
base.tool = {
|
||||
name: 'unknown',
|
||||
ok: false,
|
||||
error: String(e instanceof Error ? e.message : e),
|
||||
};
|
||||
}
|
||||
|
||||
// Log tool failures for debugging
|
||||
if (base.tool && base.tool.ok === false) {
|
||||
console.warn('[ElementMarker] Tool validation failure', {
|
||||
action,
|
||||
toolName: base.tool.name,
|
||||
error: base.tool.error,
|
||||
selector,
|
||||
selectorType,
|
||||
});
|
||||
}
|
||||
|
||||
return sendResponse(base);
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
// When RR refresh (or similar) happens, re-add our menu
|
||||
case BACKGROUND_MESSAGE_TYPES.RR_REFRESH_TRIGGERS:
|
||||
case BACKGROUND_MESSAGE_TYPES.RR_SAVE_TRIGGER:
|
||||
case BACKGROUND_MESSAGE_TYPES.RR_DELETE_TRIGGER: {
|
||||
setTimeout(() => ensureContextMenu().catch(() => {}), 300);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
sendResponse({ success: false, error: (e as any)?.message || String(e) });
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Context menu click routing
|
||||
if ((chrome as any).contextMenus?.onClicked?.addListener) {
|
||||
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
||||
try {
|
||||
if (info.menuItemId === CONTEXT_MENU_ID && tab?.id) {
|
||||
await injectMarkerHelper(tab.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('ElementMarker: context menu click failed:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { initNativeHostListener } from './native-host';
|
||||
import {
|
||||
initSemanticSimilarityListener,
|
||||
initializeSemanticEngineIfCached,
|
||||
} from './semantic-similarity';
|
||||
import { initStorageManagerListener } from './storage-manager';
|
||||
import { cleanupModelCache } from '@/utils/semantic-similarity-engine';
|
||||
import { initRecordReplayListeners } from './record-replay';
|
||||
import { initElementMarkerListeners } from './element-marker';
|
||||
import { initWebEditorListeners } from './web-editor';
|
||||
import { initQuickPanelAgentHandler } from './quick-panel/agent-handler';
|
||||
import { initQuickPanelCommands } from './quick-panel/commands';
|
||||
import { initQuickPanelTabsHandler } from './quick-panel/tabs-handler';
|
||||
|
||||
// Record-Replay V3 (feature flag)
|
||||
import { bootstrapV3 } from './record-replay-v3/bootstrap';
|
||||
|
||||
/**
|
||||
* Feature flag for RR-V3
|
||||
* Set to true to enable the new Record-Replay V3 engine
|
||||
*/
|
||||
const ENABLE_RR_V3 = true;
|
||||
|
||||
/**
|
||||
* Background script entry point
|
||||
* Initializes all background services and listeners
|
||||
*/
|
||||
export default defineBackground(() => {
|
||||
// Open welcome page on first install
|
||||
chrome.runtime.onInstalled.addListener((details) => {
|
||||
if (details.reason === 'install') {
|
||||
// Open the welcome/onboarding page for new installations
|
||||
chrome.tabs.create({
|
||||
url: chrome.runtime.getURL('/welcome.html'),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize core services
|
||||
initNativeHostListener();
|
||||
initSemanticSimilarityListener();
|
||||
initStorageManagerListener();
|
||||
// Record & Replay V1/V2 listeners
|
||||
initRecordReplayListeners();
|
||||
|
||||
// Record & Replay V3 (new engine)
|
||||
if (ENABLE_RR_V3) {
|
||||
bootstrapV3()
|
||||
.then((runtime) => {
|
||||
console.log(`[RR-V3] Bootstrap complete, ownerId: ${runtime.ownerId}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[RR-V3] Bootstrap failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Element marker: context menu + CRUD listeners
|
||||
initElementMarkerListeners();
|
||||
// Web editor: toggle edit-mode overlay
|
||||
initWebEditorListeners();
|
||||
// Quick Panel: send messages to AgentChat via background-stream bridge
|
||||
initQuickPanelAgentHandler();
|
||||
// Quick Panel: tabs search bridge for content script UI
|
||||
initQuickPanelTabsHandler();
|
||||
// Quick Panel: keyboard shortcut handler
|
||||
initQuickPanelCommands();
|
||||
|
||||
// Conditionally initialize semantic similarity engine if model cache exists
|
||||
initializeSemanticEngineIfCached()
|
||||
.then((initialized) => {
|
||||
if (initialized) {
|
||||
console.log('Background: Semantic similarity engine initialized from cache');
|
||||
} else {
|
||||
console.log(
|
||||
'Background: Semantic similarity engine initialization skipped (no cache found)',
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Background: Failed to conditionally initialize semantic engine:', error);
|
||||
});
|
||||
|
||||
// Initial cleanup on startup
|
||||
cleanupModelCache().catch((error) => {
|
||||
console.warn('Background: Initial cache cleanup failed:', error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @fileoverview Keepalive Manager
|
||||
* @description Global singleton service for managing Service Worker keepalive.
|
||||
*
|
||||
* This module provides a unified interface for acquiring and releasing keepalive
|
||||
* references. Multiple modules can acquire keepalive independently using tags,
|
||||
* and the underlying keepalive mechanism will remain active as long as at least
|
||||
* one reference is held.
|
||||
*/
|
||||
|
||||
import {
|
||||
createOffscreenKeepaliveController,
|
||||
type KeepaliveController,
|
||||
} from './record-replay-v3/engine/keepalive/offscreen-keepalive';
|
||||
|
||||
const LOG_PREFIX = '[KeepaliveManager]';
|
||||
|
||||
/**
|
||||
* Singleton keepalive controller instance.
|
||||
* Created lazily to avoid initialization issues during module loading.
|
||||
*/
|
||||
let controller: KeepaliveController | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the singleton keepalive controller.
|
||||
*/
|
||||
function getController(): KeepaliveController {
|
||||
if (!controller) {
|
||||
controller = createOffscreenKeepaliveController({ logger: console });
|
||||
console.debug(`${LOG_PREFIX} Controller initialized`);
|
||||
}
|
||||
return controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a keepalive reference with a tag.
|
||||
*
|
||||
* @param tag - Identifier for the reference (e.g., 'native-host', 'rr-engine')
|
||||
* @returns A release function to call when keepalive is no longer needed
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const release = acquireKeepalive('native-host');
|
||||
* // ... do work that needs SW to stay alive ...
|
||||
* release(); // Release when done
|
||||
* ```
|
||||
*/
|
||||
export function acquireKeepalive(tag: string): () => void {
|
||||
try {
|
||||
const release = getController().acquire(tag);
|
||||
console.debug(`${LOG_PREFIX} Acquired keepalive for tag: ${tag}`);
|
||||
return () => {
|
||||
try {
|
||||
release();
|
||||
console.debug(`${LOG_PREFIX} Released keepalive for tag: ${tag}`);
|
||||
} catch (error) {
|
||||
console.warn(`${LOG_PREFIX} Failed to release keepalive for ${tag}:`, error);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`${LOG_PREFIX} Failed to acquire keepalive for ${tag}:`, error);
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if keepalive is currently active (any references held).
|
||||
*/
|
||||
export function isKeepaliveActive(): boolean {
|
||||
try {
|
||||
return getController().isActive();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current keepalive reference count.
|
||||
* Useful for debugging.
|
||||
*/
|
||||
export function getKeepaliveRefCount(): number {
|
||||
try {
|
||||
return getController().getRefCount();
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
import { NativeMessageType } from 'chrome-mcp-shared';
|
||||
import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types';
|
||||
import { NATIVE_HOST, STORAGE_KEYS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '@/common/constants';
|
||||
import { handleCallTool } from './tools';
|
||||
import { listPublished, getFlow } from './record-replay/flow-store';
|
||||
import { acquireKeepalive } from './keepalive-manager';
|
||||
|
||||
const LOG_PREFIX = '[NativeHost]';
|
||||
|
||||
let nativePort: chrome.runtime.Port | null = null;
|
||||
export const HOST_NAME = NATIVE_HOST.NAME;
|
||||
|
||||
// ==================== Reconnect Configuration ====================
|
||||
|
||||
const RECONNECT_BASE_DELAY_MS = 500;
|
||||
const RECONNECT_MAX_DELAY_MS = 60_000;
|
||||
const RECONNECT_MAX_FAST_ATTEMPTS = 8;
|
||||
const RECONNECT_COOLDOWN_DELAY_MS = 5 * 60_000;
|
||||
|
||||
// ==================== Auto-connect State ====================
|
||||
|
||||
let keepaliveRelease: (() => void) | null = null;
|
||||
let autoConnectEnabled = true;
|
||||
let autoConnectLoaded = false;
|
||||
let ensurePromise: Promise<boolean> | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
let manualDisconnect = false;
|
||||
|
||||
/**
|
||||
* Server status management interface
|
||||
*/
|
||||
interface ServerStatus {
|
||||
isRunning: boolean;
|
||||
port?: number;
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
let currentServerStatus: ServerStatus = {
|
||||
isRunning: false,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Save server status to chrome.storage
|
||||
*/
|
||||
async function saveServerStatus(status: ServerStatus): Promise<void> {
|
||||
try {
|
||||
await chrome.storage.local.set({ [STORAGE_KEYS.SERVER_STATUS]: status });
|
||||
} catch (error) {
|
||||
console.error(ERROR_MESSAGES.SERVER_STATUS_SAVE_FAILED, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load server status from chrome.storage
|
||||
*/
|
||||
async function loadServerStatus(): Promise<ServerStatus> {
|
||||
try {
|
||||
const result = await chrome.storage.local.get([STORAGE_KEYS.SERVER_STATUS]);
|
||||
if (result[STORAGE_KEYS.SERVER_STATUS]) {
|
||||
return result[STORAGE_KEYS.SERVER_STATUS];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);
|
||||
}
|
||||
return {
|
||||
isRunning: false,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast server status change to all listeners
|
||||
*/
|
||||
function broadcastServerStatusChange(status: ServerStatus): void {
|
||||
chrome.runtime
|
||||
.sendMessage({
|
||||
type: BACKGROUND_MESSAGE_TYPES.SERVER_STATUS_CHANGED,
|
||||
payload: status,
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore errors if no listeners are present
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Port Normalization ====================
|
||||
|
||||
/**
|
||||
* Normalize a port value to a valid port number or null.
|
||||
*/
|
||||
function normalizePort(value: unknown): number | null {
|
||||
const n =
|
||||
typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;
|
||||
if (!Number.isFinite(n)) return null;
|
||||
const port = Math.floor(n);
|
||||
if (port <= 0 || port > 65535) return null;
|
||||
return port;
|
||||
}
|
||||
|
||||
// ==================== Reconnect Utilities ====================
|
||||
|
||||
/**
|
||||
* Add jitter to a delay value to avoid thundering herd.
|
||||
*/
|
||||
function withJitter(ms: number): number {
|
||||
const ratio = 0.7 + Math.random() * 0.6;
|
||||
return Math.max(0, Math.round(ms * ratio));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate reconnect delay based on attempt number.
|
||||
* Uses exponential backoff with jitter, then switches to cooldown interval.
|
||||
*/
|
||||
function getReconnectDelayMs(attempt: number): number {
|
||||
if (attempt >= RECONNECT_MAX_FAST_ATTEMPTS) {
|
||||
return withJitter(RECONNECT_COOLDOWN_DELAY_MS);
|
||||
}
|
||||
const delay = Math.min(RECONNECT_BASE_DELAY_MS * Math.pow(2, attempt), RECONNECT_MAX_DELAY_MS);
|
||||
return withJitter(delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the reconnect timer if active.
|
||||
*/
|
||||
function clearReconnectTimer(): void {
|
||||
if (!reconnectTimer) return;
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset reconnect state after successful connection.
|
||||
*/
|
||||
function resetReconnectState(): void {
|
||||
reconnectAttempts = 0;
|
||||
clearReconnectTimer();
|
||||
}
|
||||
|
||||
// ==================== Keepalive Management ====================
|
||||
|
||||
/**
|
||||
* Sync keepalive hold based on autoConnectEnabled state.
|
||||
* When auto-connect is enabled, we hold a keepalive reference to keep SW alive.
|
||||
*/
|
||||
function syncKeepaliveHold(): void {
|
||||
if (autoConnectEnabled) {
|
||||
if (!keepaliveRelease) {
|
||||
keepaliveRelease = acquireKeepalive('native-host');
|
||||
console.debug(`${LOG_PREFIX} Acquired keepalive`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (keepaliveRelease) {
|
||||
try {
|
||||
keepaliveRelease();
|
||||
console.debug(`${LOG_PREFIX} Released keepalive`);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
keepaliveRelease = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Auto-connect Settings ====================
|
||||
|
||||
/**
|
||||
* Load the nativeAutoConnectEnabled setting from storage.
|
||||
*/
|
||||
async function loadNativeAutoConnectEnabled(): Promise<boolean> {
|
||||
try {
|
||||
const result = await chrome.storage.local.get([STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED]);
|
||||
const raw = result[STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED];
|
||||
if (typeof raw === 'boolean') return raw;
|
||||
} catch (error) {
|
||||
console.warn(`${LOG_PREFIX} Failed to load nativeAutoConnectEnabled`, error);
|
||||
}
|
||||
return true; // Default to enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the nativeAutoConnectEnabled setting and persist to storage.
|
||||
*/
|
||||
async function setNativeAutoConnectEnabled(enabled: boolean): Promise<void> {
|
||||
autoConnectEnabled = enabled;
|
||||
autoConnectLoaded = true;
|
||||
try {
|
||||
await chrome.storage.local.set({ [STORAGE_KEYS.NATIVE_AUTO_CONNECT_ENABLED]: enabled });
|
||||
console.debug(`${LOG_PREFIX} Set nativeAutoConnectEnabled=${enabled}`);
|
||||
} catch (error) {
|
||||
console.warn(`${LOG_PREFIX} Failed to persist nativeAutoConnectEnabled`, error);
|
||||
}
|
||||
syncKeepaliveHold();
|
||||
}
|
||||
|
||||
// ==================== Port Preference ====================
|
||||
|
||||
/**
|
||||
* Get the preferred port for connecting to native server.
|
||||
* Priority: explicit override > user preference > last known port > default
|
||||
*/
|
||||
async function getPreferredPort(override?: unknown): Promise<number> {
|
||||
const explicit = normalizePort(override);
|
||||
if (explicit) return explicit;
|
||||
|
||||
try {
|
||||
const result = await chrome.storage.local.get([
|
||||
STORAGE_KEYS.NATIVE_SERVER_PORT,
|
||||
STORAGE_KEYS.SERVER_STATUS,
|
||||
]);
|
||||
|
||||
const userPort = normalizePort(result[STORAGE_KEYS.NATIVE_SERVER_PORT]);
|
||||
if (userPort) return userPort;
|
||||
|
||||
const status = result[STORAGE_KEYS.SERVER_STATUS] as Partial<ServerStatus> | undefined;
|
||||
const statusPort = normalizePort(status?.port);
|
||||
if (statusPort) return statusPort;
|
||||
} catch (error) {
|
||||
console.warn(`${LOG_PREFIX} Failed to read preferred port`, error);
|
||||
}
|
||||
|
||||
const inMemoryPort = normalizePort(currentServerStatus.port);
|
||||
if (inMemoryPort) return inMemoryPort;
|
||||
|
||||
return NATIVE_HOST.DEFAULT_PORT;
|
||||
}
|
||||
|
||||
// ==================== Reconnect Scheduling ====================
|
||||
|
||||
/**
|
||||
* Schedule a reconnect attempt with exponential backoff.
|
||||
*/
|
||||
function scheduleReconnect(reason: string): void {
|
||||
if (nativePort) return;
|
||||
if (manualDisconnect) return;
|
||||
if (!autoConnectEnabled) return;
|
||||
if (reconnectTimer) return;
|
||||
|
||||
const delay = getReconnectDelayMs(reconnectAttempts);
|
||||
console.debug(
|
||||
`${LOG_PREFIX} Reconnect scheduled in ${delay}ms (attempt=${reconnectAttempts}, reason=${reason})`,
|
||||
);
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (nativePort) return;
|
||||
if (manualDisconnect || !autoConnectEnabled) return;
|
||||
|
||||
reconnectAttempts += 1;
|
||||
void ensureNativeConnected(`reconnect:${reason}`).catch(() => {});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// ==================== Server Status Update ====================
|
||||
|
||||
/**
|
||||
* Mark server as stopped and broadcast the change.
|
||||
*/
|
||||
async function markServerStopped(reason: string): Promise<void> {
|
||||
currentServerStatus = {
|
||||
isRunning: false,
|
||||
port: currentServerStatus.port,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
try {
|
||||
await saveServerStatus(currentServerStatus);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
broadcastServerStatusChange(currentServerStatus);
|
||||
console.debug(`${LOG_PREFIX} Server marked stopped (${reason})`);
|
||||
}
|
||||
|
||||
// ==================== Core Ensure Function ====================
|
||||
|
||||
/**
|
||||
* Ensure native connection is established.
|
||||
* This is the main entry point for auto-connect logic.
|
||||
*
|
||||
* @param trigger - Description of what triggered this call (for logging)
|
||||
* @param portOverride - Optional explicit port to use
|
||||
* @returns Whether the connection is now established
|
||||
*/
|
||||
async function ensureNativeConnected(trigger: string, portOverride?: unknown): Promise<boolean> {
|
||||
// Concurrency protection: only one ensure flow at a time
|
||||
if (ensurePromise) return ensurePromise;
|
||||
|
||||
ensurePromise = (async () => {
|
||||
// Load auto-connect setting if not yet loaded
|
||||
if (!autoConnectLoaded) {
|
||||
autoConnectEnabled = await loadNativeAutoConnectEnabled();
|
||||
autoConnectLoaded = true;
|
||||
syncKeepaliveHold();
|
||||
}
|
||||
|
||||
// If auto-connect is disabled, do nothing
|
||||
if (!autoConnectEnabled) {
|
||||
console.debug(`${LOG_PREFIX} Auto-connect disabled, skipping ensure (trigger=${trigger})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sync keepalive hold
|
||||
syncKeepaliveHold();
|
||||
|
||||
// Already connected
|
||||
if (nativePort) {
|
||||
console.debug(`${LOG_PREFIX} Already connected (trigger=${trigger})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the port to use
|
||||
const port = await getPreferredPort(portOverride);
|
||||
console.debug(`${LOG_PREFIX} Attempting connection on port ${port} (trigger=${trigger})`);
|
||||
|
||||
// Attempt connection
|
||||
const ok = connectNativeHost(port);
|
||||
if (!ok) {
|
||||
console.warn(`${LOG_PREFIX} Connection failed (trigger=${trigger})`);
|
||||
scheduleReconnect(`connect_failed:${trigger}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.debug(`${LOG_PREFIX} Connection initiated successfully (trigger=${trigger})`);
|
||||
// Note: Don't reset reconnect state here. Wait for SERVER_STARTED confirmation.
|
||||
// Chrome may return a Port but disconnect immediately if native host is missing.
|
||||
return true;
|
||||
})().finally(() => {
|
||||
ensurePromise = null;
|
||||
});
|
||||
|
||||
return ensurePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the native messaging host
|
||||
* @returns Whether the connection was initiated successfully
|
||||
*/
|
||||
export function connectNativeHost(port: number = NATIVE_HOST.DEFAULT_PORT): boolean {
|
||||
if (nativePort) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
nativePort = chrome.runtime.connectNative(HOST_NAME);
|
||||
|
||||
nativePort.onMessage.addListener(async (message) => {
|
||||
if (message.type === NativeMessageType.PROCESS_DATA && message.requestId) {
|
||||
const requestId = message.requestId;
|
||||
const requestPayload = message.payload;
|
||||
|
||||
nativePort?.postMessage({
|
||||
responseToRequestId: requestId,
|
||||
payload: {
|
||||
status: 'success',
|
||||
message: SUCCESS_MESSAGES.TOOL_EXECUTED,
|
||||
data: requestPayload,
|
||||
},
|
||||
});
|
||||
} else if (message.type === NativeMessageType.CALL_TOOL && message.requestId) {
|
||||
const requestId = message.requestId;
|
||||
try {
|
||||
const result = await handleCallTool(message.payload);
|
||||
nativePort?.postMessage({
|
||||
responseToRequestId: requestId,
|
||||
payload: {
|
||||
status: 'success',
|
||||
message: SUCCESS_MESSAGES.TOOL_EXECUTED,
|
||||
data: result,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
nativePort?.postMessage({
|
||||
responseToRequestId: requestId,
|
||||
payload: {
|
||||
status: 'error',
|
||||
message: ERROR_MESSAGES.TOOL_EXECUTION_FAILED,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (message.type === 'rr_list_published_flows' && message.requestId) {
|
||||
const requestId = message.requestId;
|
||||
try {
|
||||
const published = await listPublished();
|
||||
const items = [] as any[];
|
||||
for (const p of published) {
|
||||
const flow = await getFlow(p.id);
|
||||
if (!flow) continue;
|
||||
items.push({
|
||||
id: p.id,
|
||||
slug: p.slug,
|
||||
version: p.version,
|
||||
name: p.name,
|
||||
description: p.description || flow.description || '',
|
||||
variables: flow.variables || [],
|
||||
meta: flow.meta || {},
|
||||
});
|
||||
}
|
||||
nativePort?.postMessage({
|
||||
responseToRequestId: requestId,
|
||||
payload: { status: 'success', items },
|
||||
});
|
||||
} catch (error: any) {
|
||||
nativePort?.postMessage({
|
||||
responseToRequestId: requestId,
|
||||
payload: { status: 'error', error: error?.message || String(error) },
|
||||
});
|
||||
}
|
||||
} else if (message.type === NativeMessageType.SERVER_STARTED) {
|
||||
const port = message.payload?.port;
|
||||
currentServerStatus = {
|
||||
isRunning: true,
|
||||
port: port,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
await saveServerStatus(currentServerStatus);
|
||||
broadcastServerStatusChange(currentServerStatus);
|
||||
// Server is confirmed running - now we can reset reconnect state
|
||||
resetReconnectState();
|
||||
console.log(`${SUCCESS_MESSAGES.SERVER_STARTED} on port ${port}`);
|
||||
} else if (message.type === NativeMessageType.SERVER_STOPPED) {
|
||||
currentServerStatus = {
|
||||
isRunning: false,
|
||||
port: currentServerStatus.port, // Keep last known port for reconnection
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
await saveServerStatus(currentServerStatus);
|
||||
broadcastServerStatusChange(currentServerStatus);
|
||||
console.log(SUCCESS_MESSAGES.SERVER_STOPPED);
|
||||
} else if (message.type === NativeMessageType.ERROR_FROM_NATIVE_HOST) {
|
||||
console.error('Error from native host:', message.payload?.message || 'Unknown error');
|
||||
} else if (message.type === 'file_operation_response') {
|
||||
// Forward file operation response back to the requesting tool
|
||||
chrome.runtime.sendMessage(message).catch(() => {
|
||||
// Ignore if no listeners
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
nativePort.onDisconnect.addListener(() => {
|
||||
console.warn(ERROR_MESSAGES.NATIVE_DISCONNECTED, chrome.runtime.lastError);
|
||||
nativePort = null;
|
||||
|
||||
// Mark server as stopped since native host disconnection means server is down
|
||||
void markServerStopped('native_port_disconnected');
|
||||
|
||||
// Handle reconnection based on disconnect reason
|
||||
if (manualDisconnect) {
|
||||
manualDisconnect = false;
|
||||
return;
|
||||
}
|
||||
if (!autoConnectEnabled) return;
|
||||
scheduleReconnect('native_port_disconnected');
|
||||
});
|
||||
|
||||
nativePort.postMessage({ type: NativeMessageType.START, payload: { port } });
|
||||
// Note: Don't reset reconnect state here. Wait for SERVER_STARTED confirmation.
|
||||
// Chrome may return a Port but disconnect immediately if native host is missing.
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn(ERROR_MESSAGES.NATIVE_CONNECTION_FAILED, error);
|
||||
nativePort = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize native host listeners and load initial state
|
||||
*/
|
||||
export const initNativeHostListener = () => {
|
||||
// Initialize server status from storage
|
||||
loadServerStatus()
|
||||
.then((status) => {
|
||||
currentServerStatus = status;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);
|
||||
});
|
||||
|
||||
// Auto-connect on SW activation (covers SW restart after idle termination)
|
||||
void ensureNativeConnected('sw_startup').catch(() => {});
|
||||
|
||||
// Auto-connect on Chrome browser startup
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
void ensureNativeConnected('onStartup').catch(() => {});
|
||||
});
|
||||
|
||||
// Auto-connect on extension install/update
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
void ensureNativeConnected('onInstalled').catch(() => {});
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
// Allow UI to call tools directly
|
||||
if (message && message.type === 'call_tool' && message.name) {
|
||||
handleCallTool({ name: message.name, args: message.args })
|
||||
.then((res) => sendResponse({ success: true, result: res }))
|
||||
.catch((err) =>
|
||||
sendResponse({ success: false, error: err instanceof Error ? err.message : String(err) }),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
const msgType = typeof message === 'string' ? message : message?.type;
|
||||
|
||||
// ENSURE_NATIVE: Trigger ensure without changing autoConnectEnabled
|
||||
if (msgType === NativeMessageType.ENSURE_NATIVE) {
|
||||
const portOverride = typeof message === 'object' ? message.port : undefined;
|
||||
ensureNativeConnected('ui_ensure', portOverride)
|
||||
.then((connected) => {
|
||||
sendResponse({ success: true, connected, autoConnectEnabled });
|
||||
})
|
||||
.catch((e) => {
|
||||
sendResponse({ success: false, connected: nativePort !== null, error: String(e) });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// CONNECT_NATIVE: Explicit user connect, re-enables auto-connect
|
||||
if (msgType === NativeMessageType.CONNECT_NATIVE) {
|
||||
const portOverride = typeof message === 'object' ? message.port : undefined;
|
||||
const normalized = normalizePort(portOverride);
|
||||
|
||||
(async () => {
|
||||
// Explicit user connect: re-enable auto-connect
|
||||
await setNativeAutoConnectEnabled(true);
|
||||
|
||||
if (normalized) {
|
||||
// Best-effort: persist preferred port
|
||||
try {
|
||||
await chrome.storage.local.set({ [STORAGE_KEYS.NATIVE_SERVER_PORT]: normalized });
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
return ensureNativeConnected('ui_connect', normalized ?? undefined);
|
||||
})()
|
||||
.then((connected) => {
|
||||
sendResponse({ success: true, connected });
|
||||
})
|
||||
.catch((e) => {
|
||||
sendResponse({ success: false, connected: nativePort !== null, error: String(e) });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msgType === NativeMessageType.PING_NATIVE) {
|
||||
const connected = nativePort !== null;
|
||||
sendResponse({ connected, autoConnectEnabled });
|
||||
return true;
|
||||
}
|
||||
|
||||
// DISCONNECT_NATIVE: Explicit user disconnect, disables auto-connect
|
||||
if (msgType === NativeMessageType.DISCONNECT_NATIVE) {
|
||||
(async () => {
|
||||
// Explicit user disconnect: disable auto-connect and stop reconnect loop
|
||||
await setNativeAutoConnectEnabled(false);
|
||||
clearReconnectTimer();
|
||||
reconnectAttempts = 0;
|
||||
syncKeepaliveHold();
|
||||
|
||||
if (nativePort) {
|
||||
// Only set manualDisconnect if we actually have a port to disconnect.
|
||||
// This prevents the flag from persisting when there's no active connection.
|
||||
manualDisconnect = true;
|
||||
try {
|
||||
nativePort.disconnect();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
nativePort = null;
|
||||
}
|
||||
await markServerStopped('manual_disconnect');
|
||||
})()
|
||||
.then(() => {
|
||||
sendResponse({ success: true });
|
||||
})
|
||||
.catch((e) => {
|
||||
sendResponse({ success: false, error: String(e) });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === BACKGROUND_MESSAGE_TYPES.GET_SERVER_STATUS) {
|
||||
sendResponse({
|
||||
success: true,
|
||||
serverStatus: currentServerStatus,
|
||||
connected: nativePort !== null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === BACKGROUND_MESSAGE_TYPES.REFRESH_SERVER_STATUS) {
|
||||
loadServerStatus()
|
||||
.then((storedStatus) => {
|
||||
currentServerStatus = storedStatus;
|
||||
sendResponse({
|
||||
success: true,
|
||||
serverStatus: currentServerStatus,
|
||||
connected: nativePort !== null,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED, error);
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: ERROR_MESSAGES.SERVER_STATUS_LOAD_FAILED,
|
||||
serverStatus: currentServerStatus,
|
||||
connected: nativePort !== null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Forward file operation messages to native host
|
||||
if (message.type === 'forward_to_native' && message.message) {
|
||||
if (nativePort) {
|
||||
nativePort.postMessage(message.message);
|
||||
sendResponse({ success: true });
|
||||
} else {
|
||||
sendResponse({ success: false, error: 'Native host not connected' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,779 @@
|
||||
/**
|
||||
* Quick Panel Agent Handler
|
||||
*
|
||||
* Background service that bridges Quick Panel (content script) with the native-server Agent.
|
||||
* Handles message routing, SSE streaming, and lifecycle management for AI chat requests.
|
||||
*
|
||||
* Architecture:
|
||||
* - Quick Panel sends QUICK_PANEL_SEND_TO_AI via chrome.runtime.sendMessage
|
||||
* - This handler subscribes to SSE first, then fires POST /act
|
||||
* - Incoming RealtimeEvents are filtered by requestId and forwarded to the originating tab
|
||||
* - Keepalive is explicitly managed to prevent MV3 Service Worker suspension during streaming
|
||||
*
|
||||
* @see https://developer.chrome.com/docs/extensions/mv3/service_workers/
|
||||
*/
|
||||
|
||||
import type { AgentActRequest, RealtimeEvent } from 'chrome-mcp-shared';
|
||||
import { NativeMessageType } from 'chrome-mcp-shared';
|
||||
|
||||
import { NATIVE_HOST, STORAGE_KEYS } from '@/common/constants';
|
||||
import {
|
||||
BACKGROUND_MESSAGE_TYPES,
|
||||
TOOL_MESSAGE_TYPES,
|
||||
type QuickPanelAIEventMessage,
|
||||
type QuickPanelCancelAIMessage,
|
||||
type QuickPanelCancelAIResponse,
|
||||
type QuickPanelSendToAIMessage,
|
||||
type QuickPanelSendToAIResponse,
|
||||
} from '@/common/message-types';
|
||||
import { acquireKeepalive } from '../keepalive-manager';
|
||||
import { openAgentChatSidepanel } from '../utils/sidepanel';
|
||||
|
||||
// ============================================================
|
||||
// Constants
|
||||
// ============================================================
|
||||
|
||||
const LOG_PREFIX = '[QuickPanelAgent]';
|
||||
const KEEPALIVE_TAG = 'quick-panel-ai';
|
||||
|
||||
/** Storage key for AgentChat selected session ID (owned by sidepanel composables) */
|
||||
const STORAGE_KEY_SELECTED_SESSION = 'agent-selected-session-id';
|
||||
|
||||
/** Timeout for initial SSE connection establishment */
|
||||
const SSE_CONNECT_TIMEOUT_MS = 3000;
|
||||
|
||||
/** Safety timeout for entire request lifecycle (15 minutes) */
|
||||
const REQUEST_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
/** Flag indicating SSE connection was successful */
|
||||
const SSE_CONNECTED = Symbol('SSE_CONNECTED');
|
||||
|
||||
/** Flag indicating SSE connection timed out but we should continue */
|
||||
const SSE_TIMEOUT = Symbol('SSE_TIMEOUT');
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Represents an active streaming request from Quick Panel.
|
||||
*
|
||||
* Background maintains this state to:
|
||||
* 1. Route SSE events to the correct tab
|
||||
* 2. Manage keepalive lifecycle
|
||||
* 3. Handle cancellation and cleanup
|
||||
*/
|
||||
interface ActiveRequest {
|
||||
readonly requestId: string;
|
||||
readonly sessionId: string;
|
||||
readonly instruction: string;
|
||||
readonly tabId: number;
|
||||
readonly windowId?: number;
|
||||
readonly frameId?: number;
|
||||
readonly port: number;
|
||||
readonly createdAt: number;
|
||||
readonly abortController: AbortController;
|
||||
readonly releaseKeepalive: () => void;
|
||||
readonly timeoutId: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// State
|
||||
// ============================================================
|
||||
|
||||
/** Active streaming requests indexed by requestId */
|
||||
const activeRequests = new Map<string, ActiveRequest>();
|
||||
|
||||
/** Initialization flag to prevent duplicate listeners */
|
||||
let initialized = false;
|
||||
|
||||
// ============================================================
|
||||
// Utility Functions
|
||||
// ============================================================
|
||||
|
||||
function normalizeString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function normalizePort(value: unknown): number | null {
|
||||
const num =
|
||||
typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN;
|
||||
|
||||
if (!Number.isFinite(num)) return null;
|
||||
|
||||
const port = Math.floor(num);
|
||||
if (port <= 0 || port > 65535) return null;
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
function createRequestId(): string {
|
||||
// Prefer crypto.randomUUID for proper UUID format
|
||||
try {
|
||||
const id = crypto?.randomUUID?.();
|
||||
if (id) return id;
|
||||
} catch {
|
||||
// Fallback for environments without crypto.randomUUID
|
||||
}
|
||||
return `req_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isTerminalStatus(status: string): boolean {
|
||||
return status === 'completed' || status === 'error' || status === 'cancelled';
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Factories
|
||||
// ============================================================
|
||||
|
||||
function createErrorEvent(sessionId: string, requestId: string, error: string): RealtimeEvent {
|
||||
return {
|
||||
type: 'error',
|
||||
error: error || 'Unknown error',
|
||||
data: { sessionId, requestId },
|
||||
};
|
||||
}
|
||||
|
||||
function createCancelledStatusEvent(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
message?: string,
|
||||
): RealtimeEvent {
|
||||
return {
|
||||
type: 'status',
|
||||
data: {
|
||||
sessionId,
|
||||
status: 'cancelled',
|
||||
requestId,
|
||||
message: message || 'Cancelled by user',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Forwarding
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Forward a RealtimeEvent to the Quick Panel in the originating tab.
|
||||
* Handles receiver unavailability gracefully by cleaning up the request.
|
||||
*/
|
||||
function forwardEventToQuickPanel(request: ActiveRequest, event: RealtimeEvent): void {
|
||||
const message: QuickPanelAIEventMessage = {
|
||||
action: TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT,
|
||||
requestId: request.requestId,
|
||||
sessionId: request.sessionId,
|
||||
event,
|
||||
};
|
||||
|
||||
const sendOptions =
|
||||
typeof request.frameId === 'number' ? { frameId: request.frameId } : undefined;
|
||||
|
||||
const sendPromise = sendOptions
|
||||
? chrome.tabs.sendMessage(request.tabId, message, sendOptions)
|
||||
: chrome.tabs.sendMessage(request.tabId, message);
|
||||
|
||||
sendPromise.catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
// Detect receiver unavailability (tab closed, navigated, Quick Panel closed)
|
||||
const receiverGone =
|
||||
msg.includes('Receiving end does not exist') ||
|
||||
msg.includes('No tab with id') ||
|
||||
msg.includes('The message port closed');
|
||||
|
||||
if (receiverGone) {
|
||||
cleanupRequest(request.requestId, 'receiver_unavailable');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Request Lifecycle Management
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Clean up an active request and release all associated resources.
|
||||
* Idempotent - safe to call multiple times.
|
||||
*/
|
||||
function cleanupRequest(requestId: string, reason: string): void {
|
||||
const request = activeRequests.get(requestId);
|
||||
if (!request) return;
|
||||
|
||||
activeRequests.delete(requestId);
|
||||
|
||||
// Clear timeout
|
||||
try {
|
||||
clearTimeout(request.timeoutId);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Abort SSE connection
|
||||
try {
|
||||
request.abortController.abort();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Release keepalive
|
||||
try {
|
||||
request.releaseKeepalive();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
console.debug(`${LOG_PREFIX} Cleaned up request ${requestId} (${reason})`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Session Validation
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Validate that the selected session exists on the native server.
|
||||
* Returns false if the session is invalid or server is unreachable.
|
||||
*/
|
||||
async function validateSession(port: number, sessionId: string): Promise<boolean> {
|
||||
const url = `http://127.0.0.1:${port}/agent/sessions/${encodeURIComponent(sessionId)}`;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SSE Event Filtering
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Determine if a RealtimeEvent should be forwarded for a specific requestId.
|
||||
*
|
||||
* Events without requestId (connected, heartbeat) are session-level signals
|
||||
* and are not forwarded to avoid confusion with request-specific events.
|
||||
*/
|
||||
function shouldForwardEvent(event: RealtimeEvent, requestId: string): boolean {
|
||||
switch (event.type) {
|
||||
case 'message':
|
||||
return event.data?.requestId === requestId;
|
||||
case 'status':
|
||||
return event.data?.requestId === requestId;
|
||||
case 'usage':
|
||||
return event.data?.requestId === requestId;
|
||||
case 'error':
|
||||
return event.data?.requestId === requestId;
|
||||
case 'connected':
|
||||
case 'heartbeat':
|
||||
// Session-level signals, not request-scoped
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SSE Subscription
|
||||
// ============================================================
|
||||
|
||||
interface SseSubscription {
|
||||
/**
|
||||
* Resolves with true when SSE connection is established.
|
||||
* Resolves with false if connection failed (request was cleaned up).
|
||||
*/
|
||||
ready: Promise<boolean>;
|
||||
/** Resolves when SSE stream ends (normally or due to error/abort) */
|
||||
done: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SSE subscription for the request's session.
|
||||
*
|
||||
* The subscription:
|
||||
* 1. Connects to the session's /stream endpoint
|
||||
* 2. Filters events by requestId
|
||||
* 3. Forwards matching events to Quick Panel
|
||||
* 4. Triggers cleanup on terminal status
|
||||
*
|
||||
* @returns SseSubscription with ready promise that resolves to:
|
||||
* - true: SSE connected successfully
|
||||
* - false: SSE failed (request was cleaned up, don't send /act)
|
||||
*/
|
||||
function createSseSubscription(request: ActiveRequest): SseSubscription {
|
||||
// Track whether ready has been resolved
|
||||
let readySettled = false;
|
||||
let readyResolve: (connected: boolean) => void;
|
||||
|
||||
const ready = new Promise<boolean>((resolve) => {
|
||||
readyResolve = resolve;
|
||||
});
|
||||
|
||||
// Helper to resolve ready exactly once
|
||||
const settleReady = (connected: boolean): void => {
|
||||
if (readySettled) return;
|
||||
readySettled = true;
|
||||
readyResolve(connected);
|
||||
};
|
||||
|
||||
const done = (async () => {
|
||||
const sseUrl = `http://127.0.0.1:${request.port}/agent/chat/${encodeURIComponent(request.sessionId)}/stream`;
|
||||
|
||||
try {
|
||||
const response = await fetch(sseUrl, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
signal: request.abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`SSE stream unavailable (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
// Signal that SSE is connected successfully
|
||||
settleReady(true);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
// Read and parse SSE stream
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const raw = line.slice(5).trim();
|
||||
if (!raw) continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(raw) as RealtimeEvent;
|
||||
|
||||
// Filter by requestId to prevent cross-request leakage
|
||||
if (!shouldForwardEvent(event, request.requestId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
forwardEventToQuickPanel(request, event);
|
||||
|
||||
// Cleanup on terminal status
|
||||
if (event.type === 'status' && event.data?.requestId === request.requestId) {
|
||||
if (isTerminalStatus(event.data.status)) {
|
||||
cleanupRequest(request.requestId, `terminal_status:${event.data.status}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors (best-effort stream processing)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// AbortError is intentional (cancellation or cleanup)
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
// Signal not connected if aborted before connecting
|
||||
settleReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Surface error to UI and cleanup if request is still active
|
||||
if (activeRequests.has(request.requestId)) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
forwardEventToQuickPanel(
|
||||
request,
|
||||
createErrorEvent(request.sessionId, request.requestId, msg),
|
||||
);
|
||||
cleanupRequest(request.requestId, 'sse_error');
|
||||
}
|
||||
|
||||
// Signal failed connection
|
||||
settleReady(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return { ready, done };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Agent API
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Send the act request to native-server.
|
||||
* The server will emit events via SSE which are already being subscribed.
|
||||
*
|
||||
* @param request - Active request context
|
||||
* @throws Error if request was cancelled/aborted or HTTP request fails
|
||||
*/
|
||||
async function postActRequest(request: ActiveRequest): Promise<void> {
|
||||
// Check if request was cancelled before sending
|
||||
if (request.abortController.signal.aborted) {
|
||||
throw new Error('Request was cancelled');
|
||||
}
|
||||
|
||||
const url = `http://127.0.0.1:${request.port}/agent/chat/${encodeURIComponent(request.sessionId)}/act`;
|
||||
|
||||
const payload: AgentActRequest = {
|
||||
instruction: request.instruction,
|
||||
// Ensures session-level config is loaded (engine, model, options, project binding)
|
||||
dbSessionId: request.sessionId,
|
||||
// Enables SSE-first flow and requestId filtering on session-scoped streams
|
||||
requestId: request.requestId,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: request.abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(text || `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an active request on the native-server.
|
||||
*/
|
||||
async function cancelRequestOnServer(
|
||||
port: number,
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
): Promise<void> {
|
||||
const url = `http://127.0.0.1:${port}/agent/chat/${encodeURIComponent(sessionId)}/cancel/${encodeURIComponent(requestId)}`;
|
||||
try {
|
||||
await fetch(url, { method: 'DELETE' });
|
||||
} catch {
|
||||
// Best-effort: cancellation might still succeed if request already ended
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Request Orchestration
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Check if the request is still active and not cancelled.
|
||||
* Used as a guard before each async operation to handle race conditions.
|
||||
*/
|
||||
function isRequestStillActive(request: ActiveRequest): boolean {
|
||||
return activeRequests.has(request.requestId) && !request.abortController.signal.aborted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main orchestration function for starting a Quick Panel AI request.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Ensure native server is running
|
||||
* 2. Validate session exists
|
||||
* 3. Open sidepanel (best-effort)
|
||||
* 4. Start SSE subscription (wait for connection)
|
||||
* 5. Fire act request
|
||||
* 6. Let SSE handle event forwarding and cleanup
|
||||
*
|
||||
* @remarks
|
||||
* Guards are placed after each async operation to handle cancellation races.
|
||||
*/
|
||||
async function startRequest(request: ActiveRequest): Promise<void> {
|
||||
try {
|
||||
// Best-effort: ensure native server is running
|
||||
await chrome.runtime.sendMessage({ type: NativeMessageType.ENSURE_NATIVE }).catch(() => null);
|
||||
|
||||
// Guard: check if cancelled during ENSURE_NATIVE
|
||||
if (!isRequestStillActive(request)) return;
|
||||
|
||||
// Validate session still exists
|
||||
const sessionValid = await validateSession(request.port, request.sessionId);
|
||||
|
||||
// Guard: check if cancelled during validation
|
||||
if (!isRequestStillActive(request)) return;
|
||||
|
||||
if (!sessionValid) {
|
||||
forwardEventToQuickPanel(
|
||||
request,
|
||||
createErrorEvent(
|
||||
request.sessionId,
|
||||
request.requestId,
|
||||
'Selected Agent session is not available. Please open AgentChat and select a valid session.',
|
||||
),
|
||||
);
|
||||
// Open sidepanel without deep-linking to invalid session
|
||||
openAgentChatSidepanel(request.tabId, request.windowId).catch(() => {});
|
||||
cleanupRequest(request.requestId, 'session_invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
// Best-effort: open sidepanel deep-linked to current session
|
||||
openAgentChatSidepanel(request.tabId, request.windowId, request.sessionId).catch(() => {});
|
||||
|
||||
// Start SSE subscription BEFORE sending act request to avoid missing early events
|
||||
const sse = createSseSubscription(request);
|
||||
|
||||
// Wait for SSE connection with timeout
|
||||
// The race returns either:
|
||||
// - boolean from sse.ready (true=connected, false=failed)
|
||||
// - undefined from timeout (treat as "proceed with caution")
|
||||
const sseResult = await Promise.race([
|
||||
sse.ready,
|
||||
sleep(SSE_CONNECT_TIMEOUT_MS).then(() => SSE_TIMEOUT),
|
||||
]);
|
||||
|
||||
// Guard: check if cancelled during SSE connection
|
||||
if (!isRequestStillActive(request)) return;
|
||||
|
||||
// If SSE explicitly failed (returned false), don't send /act
|
||||
// The SSE subscription already cleaned up and sent error to UI
|
||||
if (sseResult === false) {
|
||||
console.debug(`${LOG_PREFIX} SSE failed for ${request.requestId}, not sending /act`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If SSE timed out, log warning but continue (degraded experience)
|
||||
if (sseResult === SSE_TIMEOUT) {
|
||||
console.warn(
|
||||
`${LOG_PREFIX} SSE connection timed out for ${request.requestId}, proceeding anyway`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fire the act request
|
||||
await postActRequest(request);
|
||||
|
||||
// SSE subscription continues running and will handle cleanup on terminal status
|
||||
void sse.done;
|
||||
} catch (err) {
|
||||
// Abort errors are expected during cancellation
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Request may have been cleaned up already
|
||||
if (!activeRequests.has(request.requestId)) return;
|
||||
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
forwardEventToQuickPanel(request, createErrorEvent(request.sessionId, request.requestId, msg));
|
||||
cleanupRequest(request.requestId, 'start_failed');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Message Handlers
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Handle QUICK_PANEL_SEND_TO_AI message.
|
||||
* Creates a new streaming request and starts the orchestration flow.
|
||||
*/
|
||||
async function handleSendToAI(
|
||||
message: QuickPanelSendToAIMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): Promise<QuickPanelSendToAIResponse> {
|
||||
const tabId = sender?.tab?.id;
|
||||
const windowId = sender?.tab?.windowId;
|
||||
const frameId = typeof sender?.frameId === 'number' ? sender.frameId : undefined;
|
||||
|
||||
if (typeof tabId !== 'number') {
|
||||
return { success: false, error: 'Quick Panel request must originate from a tab.' };
|
||||
}
|
||||
|
||||
const instruction = normalizeString(message?.payload?.instruction).trim();
|
||||
if (!instruction) {
|
||||
return { success: false, error: 'instruction is required' };
|
||||
}
|
||||
|
||||
// Read server port and selected session from storage
|
||||
const stored = await chrome.storage.local.get([
|
||||
STORAGE_KEYS.NATIVE_SERVER_PORT,
|
||||
STORAGE_KEY_SELECTED_SESSION,
|
||||
]);
|
||||
|
||||
const port = normalizePort(stored?.[STORAGE_KEYS.NATIVE_SERVER_PORT]) ?? NATIVE_HOST.DEFAULT_PORT;
|
||||
const sessionId = normalizeString(stored?.[STORAGE_KEY_SELECTED_SESSION]).trim();
|
||||
|
||||
if (!sessionId) {
|
||||
// No session selected: open sidepanel for user to select/create one
|
||||
openAgentChatSidepanel(tabId, windowId).catch(() => {});
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'No Agent session selected. Please open AgentChat, select or create a session, then try again.',
|
||||
};
|
||||
}
|
||||
|
||||
// Create request state
|
||||
const requestId = createRequestId();
|
||||
const releaseKeepalive = acquireKeepalive(KEEPALIVE_TAG);
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Safety timeout to prevent infinite streaming
|
||||
const timeoutId = setTimeout(() => {
|
||||
const activeRequest = activeRequests.get(requestId);
|
||||
if (!activeRequest) return;
|
||||
|
||||
forwardEventToQuickPanel(
|
||||
activeRequest,
|
||||
createErrorEvent(
|
||||
activeRequest.sessionId,
|
||||
activeRequest.requestId,
|
||||
'Quick Panel stream timed out. Please continue in AgentChat sidepanel.',
|
||||
),
|
||||
);
|
||||
cleanupRequest(requestId, 'timeout');
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
|
||||
const request: ActiveRequest = {
|
||||
requestId,
|
||||
sessionId,
|
||||
instruction,
|
||||
tabId,
|
||||
windowId: typeof windowId === 'number' ? windowId : undefined,
|
||||
frameId,
|
||||
port,
|
||||
createdAt: Date.now(),
|
||||
abortController,
|
||||
releaseKeepalive,
|
||||
timeoutId,
|
||||
};
|
||||
|
||||
activeRequests.set(requestId, request);
|
||||
|
||||
// Start the request asynchronously (don't await)
|
||||
void startRequest(request);
|
||||
|
||||
return { success: true, requestId, sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle QUICK_PANEL_CANCEL_AI message.
|
||||
* Cancels an active request both locally and on the server.
|
||||
*/
|
||||
async function handleCancelAI(
|
||||
message: QuickPanelCancelAIMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): Promise<QuickPanelCancelAIResponse> {
|
||||
const tabId = sender?.tab?.id;
|
||||
const frameId = typeof sender?.frameId === 'number' ? sender.frameId : undefined;
|
||||
|
||||
if (typeof tabId !== 'number') {
|
||||
return { success: false, error: 'Cancel request must originate from a tab.' };
|
||||
}
|
||||
|
||||
const requestId = normalizeString(message?.payload?.requestId).trim();
|
||||
const fallbackSessionId = normalizeString(message?.payload?.sessionId).trim();
|
||||
|
||||
if (!requestId) {
|
||||
return { success: false, error: 'requestId is required' };
|
||||
}
|
||||
|
||||
const activeRequest = activeRequests.get(requestId);
|
||||
const sessionId = activeRequest?.sessionId || fallbackSessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unknown sessionId for this request. Please cancel from AgentChat sidepanel.',
|
||||
};
|
||||
}
|
||||
|
||||
// Abort SSE immediately for responsive UX
|
||||
if (activeRequest) {
|
||||
try {
|
||||
activeRequest.abortController.abort();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Determine port
|
||||
let port = activeRequest?.port;
|
||||
if (!port) {
|
||||
const stored = await chrome.storage.local.get([STORAGE_KEYS.NATIVE_SERVER_PORT]);
|
||||
port = normalizePort(stored?.[STORAGE_KEYS.NATIVE_SERVER_PORT]) ?? NATIVE_HOST.DEFAULT_PORT;
|
||||
}
|
||||
|
||||
// Cancel on server (async, don't await)
|
||||
void cancelRequestOnServer(port, sessionId, requestId);
|
||||
|
||||
// Send synthetic cancelled status to UI
|
||||
const cancelledEvent = createCancelledStatusEvent(sessionId, requestId);
|
||||
const eventMessage: QuickPanelAIEventMessage = {
|
||||
action: TOOL_MESSAGE_TYPES.QUICK_PANEL_AI_EVENT,
|
||||
requestId,
|
||||
sessionId,
|
||||
event: cancelledEvent,
|
||||
};
|
||||
|
||||
const sendOptions = typeof frameId === 'number' ? { frameId } : undefined;
|
||||
const sendPromise = sendOptions
|
||||
? chrome.tabs.sendMessage(tabId, eventMessage, sendOptions)
|
||||
: chrome.tabs.sendMessage(tabId, eventMessage);
|
||||
|
||||
sendPromise
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
cleanupRequest(requestId, 'cancelled_by_user');
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Initialization
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Initialize the Quick Panel Agent Handler.
|
||||
* Sets up message listeners and tab cleanup handlers.
|
||||
*/
|
||||
export function initQuickPanelAgentHandler(): void {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
// Message listener for Quick Panel messages
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
// Handle QUICK_PANEL_SEND_TO_AI
|
||||
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_SEND_TO_AI) {
|
||||
handleSendToAI(message as QuickPanelSendToAIMessage, sender)
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
sendResponse({ success: false, error: msg || 'Unknown error' });
|
||||
});
|
||||
return true; // Async response
|
||||
}
|
||||
|
||||
// Handle QUICK_PANEL_CANCEL_AI
|
||||
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_CANCEL_AI) {
|
||||
handleCancelAI(message as QuickPanelCancelAIMessage, sender)
|
||||
.then(sendResponse)
|
||||
.catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
sendResponse({ success: false, error: msg || 'Unknown error' });
|
||||
});
|
||||
return true; // Async response
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Clean up requests when their tab is closed
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
for (const [requestId, request] of activeRequests) {
|
||||
if (request.tabId === tabId) {
|
||||
cleanupRequest(requestId, 'tab_removed');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.debug(`${LOG_PREFIX} Initialized`);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Quick Panel Commands Handler
|
||||
*
|
||||
* Handles keyboard shortcuts for Quick Panel functionality.
|
||||
* Listens for the 'toggle_quick_panel' command and sends toggle message
|
||||
* to the content script in the active tab.
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// Constants
|
||||
// ============================================================
|
||||
|
||||
const COMMAND_KEY = 'toggle_quick_panel';
|
||||
const LOG_PREFIX = '[QuickPanelCommands]';
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get the ID of the currently active tab
|
||||
*/
|
||||
async function getActiveTabId(): Promise<number | null> {
|
||||
try {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
return tab?.id ?? null;
|
||||
} catch (err) {
|
||||
console.warn(`${LOG_PREFIX} Failed to get active tab:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tab can receive content scripts
|
||||
*/
|
||||
function isValidTabUrl(url?: string): boolean {
|
||||
if (!url) return false;
|
||||
|
||||
// Cannot inject into browser internal pages
|
||||
const invalidPrefixes = [
|
||||
'chrome://',
|
||||
'chrome-extension://',
|
||||
'edge://',
|
||||
'about:',
|
||||
'moz-extension://',
|
||||
'devtools://',
|
||||
'view-source:',
|
||||
'data:',
|
||||
// 'file://',
|
||||
];
|
||||
|
||||
return !invalidPrefixes.some((prefix) => url.startsWith(prefix));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main Handler
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Toggle Quick Panel in the active tab
|
||||
*/
|
||||
async function toggleQuickPanelInActiveTab(): Promise<void> {
|
||||
const tabId = await getActiveTabId();
|
||||
if (tabId === null) {
|
||||
console.warn(`${LOG_PREFIX} No active tab found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get tab info to check URL validity
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (!isValidTabUrl(tab.url)) {
|
||||
console.warn(`${LOG_PREFIX} Cannot inject into tab URL: ${tab.url}`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`${LOG_PREFIX} Failed to get tab info:`, err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send toggle message to content script
|
||||
try {
|
||||
const response = await chrome.tabs.sendMessage(tabId, { action: 'toggle_quick_panel' });
|
||||
if (response?.success) {
|
||||
console.log(`${LOG_PREFIX} Quick Panel toggled, visible: ${response.visible}`);
|
||||
} else {
|
||||
console.warn(`${LOG_PREFIX} Toggle failed:`, response?.error);
|
||||
}
|
||||
} catch (err) {
|
||||
// Content script may not be loaded yet; this is expected on some pages
|
||||
console.warn(
|
||||
`${LOG_PREFIX} Failed to send toggle message (content script may not be loaded):`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Initialization
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Initialize Quick Panel keyboard command listener
|
||||
*/
|
||||
export function initQuickPanelCommands(): void {
|
||||
console.log(`${LOG_PREFIX} initQuickPanelCommands called`);
|
||||
chrome.commands.onCommand.addListener(async (command) => {
|
||||
console.log(`${LOG_PREFIX} onCommand received:`, command);
|
||||
if (command !== COMMAND_KEY) {
|
||||
console.log(`${LOG_PREFIX} Command not matched, expected:`, COMMAND_KEY);
|
||||
return;
|
||||
}
|
||||
console.log(`${LOG_PREFIX} Command matched, calling toggleQuickPanelInActiveTab...`);
|
||||
|
||||
try {
|
||||
await toggleQuickPanelInActiveTab();
|
||||
console.log(`${LOG_PREFIX} toggleQuickPanelInActiveTab completed`);
|
||||
} catch (err) {
|
||||
console.error(`${LOG_PREFIX} Command handler error:`, err);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`${LOG_PREFIX} Command listener registered for: ${COMMAND_KEY}`);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Quick Panel Tabs Handler
|
||||
*
|
||||
* Background service worker bridge for Quick Panel (content script) to:
|
||||
* - Enumerate tabs for search suggestions
|
||||
* - Activate a selected tab
|
||||
* - Close a tab
|
||||
*
|
||||
* Note: Content scripts cannot access chrome.tabs.* directly.
|
||||
*/
|
||||
|
||||
import {
|
||||
BACKGROUND_MESSAGE_TYPES,
|
||||
type QuickPanelActivateTabMessage,
|
||||
type QuickPanelActivateTabResponse,
|
||||
type QuickPanelCloseTabMessage,
|
||||
type QuickPanelCloseTabResponse,
|
||||
type QuickPanelTabSummary,
|
||||
type QuickPanelTabsQueryMessage,
|
||||
type QuickPanelTabsQueryResponse,
|
||||
} from '@/common/message-types';
|
||||
|
||||
// ============================================================
|
||||
// Constants
|
||||
// ============================================================
|
||||
|
||||
const LOG_PREFIX = '[QuickPanelTabs]';
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
function isValidTabId(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function isValidWindowId(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown): boolean {
|
||||
return value === true;
|
||||
}
|
||||
|
||||
function getLastAccessed(tab: chrome.tabs.Tab): number | undefined {
|
||||
const anyTab = tab as unknown as { lastAccessed?: unknown };
|
||||
const value = anyTab.lastAccessed;
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function safeErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message || String(err);
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a chrome.tabs.Tab to our summary format.
|
||||
* Returns null if tab is invalid.
|
||||
*/
|
||||
function toTabSummary(tab: chrome.tabs.Tab): QuickPanelTabSummary | null {
|
||||
if (!isValidTabId(tab.id)) return null;
|
||||
|
||||
const windowId = isValidWindowId(tab.windowId) ? tab.windowId : null;
|
||||
if (windowId === null) return null;
|
||||
|
||||
return {
|
||||
tabId: tab.id,
|
||||
windowId,
|
||||
title: tab.title ?? '',
|
||||
url: tab.url ?? '',
|
||||
favIconUrl: tab.favIconUrl ?? undefined,
|
||||
active: normalizeBoolean(tab.active),
|
||||
pinned: normalizeBoolean(tab.pinned),
|
||||
audible: normalizeBoolean(tab.audible),
|
||||
muted: normalizeBoolean(tab.mutedInfo?.muted),
|
||||
index: typeof tab.index === 'number' && Number.isFinite(tab.index) ? tab.index : 0,
|
||||
lastAccessed: getLastAccessed(tab),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Message Handlers
|
||||
// ============================================================
|
||||
|
||||
async function handleTabsQuery(
|
||||
message: QuickPanelTabsQueryMessage,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
): Promise<QuickPanelTabsQueryResponse> {
|
||||
try {
|
||||
const includeAllWindows = message.payload?.includeAllWindows ?? true;
|
||||
|
||||
// Extract current context from sender
|
||||
const currentWindowId = isValidWindowId(sender.tab?.windowId) ? sender.tab!.windowId : null;
|
||||
const currentTabId = isValidTabId(sender.tab?.id) ? sender.tab!.id : null;
|
||||
|
||||
// Quick Panel should only be called from content scripts (which have sender.tab)
|
||||
// Reject requests without valid sender tab context for security
|
||||
if (!includeAllWindows && currentWindowId === null) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid request: sender tab context required for window-scoped queries',
|
||||
};
|
||||
}
|
||||
|
||||
// Build query info based on scope
|
||||
const queryInfo: chrome.tabs.QueryInfo = includeAllWindows
|
||||
? {}
|
||||
: { windowId: currentWindowId! };
|
||||
|
||||
const tabs = await chrome.tabs.query(queryInfo);
|
||||
|
||||
// Convert to summaries, filtering out invalid tabs
|
||||
const summaries: QuickPanelTabSummary[] = [];
|
||||
for (const tab of tabs) {
|
||||
const summary = toTabSummary(tab);
|
||||
if (summary) {
|
||||
summaries.push(summary);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tabs: summaries,
|
||||
currentTabId,
|
||||
currentWindowId,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn(`${LOG_PREFIX} Error querying tabs:`, err);
|
||||
return {
|
||||
success: false,
|
||||
error: safeErrorMessage(err) || 'Failed to query tabs',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActivateTab(
|
||||
message: QuickPanelActivateTabMessage,
|
||||
): Promise<QuickPanelActivateTabResponse> {
|
||||
try {
|
||||
const tabId = message.payload?.tabId;
|
||||
const windowId = message.payload?.windowId;
|
||||
|
||||
if (!isValidTabId(tabId)) {
|
||||
return { success: false, error: 'Invalid tabId' };
|
||||
}
|
||||
|
||||
// Focus the window first if provided
|
||||
if (isValidWindowId(windowId)) {
|
||||
try {
|
||||
await chrome.windows.update(windowId, { focused: true });
|
||||
} catch {
|
||||
// Best-effort: tab activation may still succeed without focusing window.
|
||||
}
|
||||
}
|
||||
|
||||
// Activate the tab
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.warn(`${LOG_PREFIX} Error activating tab:`, err);
|
||||
return {
|
||||
success: false,
|
||||
error: safeErrorMessage(err) || 'Failed to activate tab',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloseTab(
|
||||
message: QuickPanelCloseTabMessage,
|
||||
): Promise<QuickPanelCloseTabResponse> {
|
||||
try {
|
||||
const tabId = message.payload?.tabId;
|
||||
|
||||
if (!isValidTabId(tabId)) {
|
||||
return { success: false, error: 'Invalid tabId' };
|
||||
}
|
||||
|
||||
await chrome.tabs.remove(tabId);
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.warn(`${LOG_PREFIX} Error closing tab:`, err);
|
||||
return {
|
||||
success: false,
|
||||
error: safeErrorMessage(err) || 'Failed to close tab',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Initialization
|
||||
// ============================================================
|
||||
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the Quick Panel Tabs handler.
|
||||
* Safe to call multiple times - subsequent calls are no-ops.
|
||||
*/
|
||||
export function initQuickPanelTabsHandler(): void {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
// Tabs query
|
||||
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TABS_QUERY) {
|
||||
handleTabsQuery(message as QuickPanelTabsQueryMessage, sender).then(sendResponse);
|
||||
return true; // Will respond asynchronously
|
||||
}
|
||||
|
||||
// Tab activate
|
||||
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_ACTIVATE) {
|
||||
handleActivateTab(message as QuickPanelActivateTabMessage).then(sendResponse);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tab close
|
||||
if (message?.type === BACKGROUND_MESSAGE_TYPES.QUICK_PANEL_TAB_CLOSE) {
|
||||
handleCloseTab(message as QuickPanelCloseTabMessage).then(sendResponse);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false; // Not handled by this listener
|
||||
});
|
||||
|
||||
console.debug(`${LOG_PREFIX} Initialized`);
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* @fileoverview Record-Replay V3 composition root (bootstrap)
|
||||
* @description
|
||||
* Wires storage, events, scheduler, triggers and RPC for the MV3 background service worker.
|
||||
*
|
||||
* 设计说明:
|
||||
* - 必须先执行 recoverFromCrash() 再启动 scheduler.start()
|
||||
* - 使用全局单例 keepalive-manager 避免多个控制器冲突
|
||||
* - RunExecutor 使用 RunRunner 执行实际的 Flow
|
||||
*/
|
||||
|
||||
import type { UnixMillis } from './domain/json';
|
||||
import type { RunId } from './domain/ids';
|
||||
import { RR_ERROR_CODES, createRRError, type RRError } from './domain/errors';
|
||||
|
||||
import type { StoragePort } from './engine/storage/storage-port';
|
||||
import { StorageBackedEventsBus, type EventsBus } from './engine/transport/events-bus';
|
||||
|
||||
import { DEFAULT_QUEUE_CONFIG, type RunQueueItem } from './engine/queue/queue';
|
||||
import { createLeaseManager, generateOwnerId, type LeaseManager } from './engine/queue/leasing';
|
||||
import { createRunScheduler, type RunExecutor, type RunScheduler } from './engine/queue/scheduler';
|
||||
import { recoverFromCrash } from './engine/recovery/recovery-coordinator';
|
||||
|
||||
import { RpcServer } from './engine/transport/rpc-server';
|
||||
|
||||
import { createTriggerManager, type TriggerManager } from './engine/triggers/trigger-manager';
|
||||
import { createUrlTriggerHandlerFactory } from './engine/triggers/url-trigger';
|
||||
import { createCommandTriggerHandlerFactory } from './engine/triggers/command-trigger';
|
||||
import { createContextMenuTriggerHandlerFactory } from './engine/triggers/context-menu-trigger';
|
||||
import { createDomTriggerHandlerFactory } from './engine/triggers/dom-trigger';
|
||||
import { createCronTriggerHandlerFactory } from './engine/triggers/cron-trigger';
|
||||
import { createIntervalTriggerHandlerFactory } from './engine/triggers/interval-trigger';
|
||||
import { createOnceTriggerHandlerFactory } from './engine/triggers/once-trigger';
|
||||
import { createManualTriggerHandlerFactory } from './engine/triggers/manual-trigger';
|
||||
|
||||
import { createChromeArtifactService } from './engine/kernel/artifacts';
|
||||
import { createRunRunnerFactory, type RunRunnerFactory } from './engine/kernel/runner';
|
||||
import {
|
||||
createDebugController,
|
||||
createRunnerRegistry,
|
||||
type DebugController,
|
||||
type RunnerRegistry,
|
||||
} from './engine/kernel/debug-controller';
|
||||
|
||||
import { PluginRegistry } from './engine/plugins/registry';
|
||||
import {
|
||||
registerV2ReplayNodesAsV3Nodes,
|
||||
DEFAULT_V2_EXCLUDE_LIST,
|
||||
} from './engine/plugins/register-v2-replay-nodes';
|
||||
|
||||
import { acquireKeepalive } from '../keepalive-manager';
|
||||
import { createStoragePort } from './index';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
type Logger = Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
|
||||
/**
|
||||
* V3 运行时句柄
|
||||
*/
|
||||
export interface V3Runtime {
|
||||
ownerId: string;
|
||||
storage: StoragePort;
|
||||
events: EventsBus;
|
||||
leaseManager: LeaseManager;
|
||||
scheduler: RunScheduler;
|
||||
runners: RunnerRegistry;
|
||||
debugController: DebugController;
|
||||
triggers: TriggerManager;
|
||||
rpcServer: RpcServer;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
// ==================== Singleton State ====================
|
||||
|
||||
let runtime: V3Runtime | null = null;
|
||||
let bootstrapPromise: Promise<V3Runtime> | null = null;
|
||||
|
||||
// ==================== Utilities ====================
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (err && typeof err === 'object' && 'message' in err)
|
||||
return String((err as { message: unknown }).message);
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function isFiniteNumber(v: unknown): v is number {
|
||||
return typeof v === 'number' && Number.isFinite(v);
|
||||
}
|
||||
|
||||
async function tabExists(tabId: number): Promise<boolean> {
|
||||
try {
|
||||
await chrome.tabs.get(tabId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createEphemeralTab(logger: Logger): Promise<number> {
|
||||
const tab = await chrome.tabs.create({ url: 'about:blank', active: false });
|
||||
if (tab.id === undefined) {
|
||||
throw new Error('chrome.tabs.create returned a tab without id');
|
||||
}
|
||||
logger.debug(`[RR-V3] Allocated ephemeral tab ${tab.id}`);
|
||||
return tab.id;
|
||||
}
|
||||
|
||||
async function safeRemoveTab(tabId: number, logger: Logger): Promise<void> {
|
||||
try {
|
||||
await chrome.tabs.remove(tabId);
|
||||
} catch (e) {
|
||||
logger.debug(`[RR-V3] Failed to close tab ${tabId}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析运行 Run 所需的 Tab ID
|
||||
* 优先级: run.tabId > queue.tabId > trigger.sourceTabId > 创建新 Tab
|
||||
*/
|
||||
async function resolveRunTab(input: {
|
||||
runTabId?: number;
|
||||
queueTabId?: number;
|
||||
triggerTabId?: number;
|
||||
logger: Logger;
|
||||
}): Promise<{ tabId: number; shouldClose: boolean }> {
|
||||
const candidates = [input.runTabId, input.queueTabId, input.triggerTabId].filter(
|
||||
(x): x is number => isFiniteNumber(x),
|
||||
);
|
||||
|
||||
for (const tabId of candidates) {
|
||||
if (await tabExists(tabId)) {
|
||||
return { tabId, shouldClose: false };
|
||||
}
|
||||
}
|
||||
|
||||
const tabId = await createEphemeralTab(input.logger);
|
||||
return { tabId, shouldClose: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Run 标记为失败
|
||||
* 注意:会重新读取最新的 RunRecord 以获取正确的 startedAt
|
||||
*/
|
||||
async function failRun(
|
||||
deps: { storage: StoragePort; events: EventsBus; now: () => UnixMillis; logger: Logger },
|
||||
runId: RunId,
|
||||
error: RRError,
|
||||
): Promise<void> {
|
||||
const finishedAt = deps.now();
|
||||
|
||||
// 重新获取最新的 run 记录以获取正确的 startedAt
|
||||
let startedAt = finishedAt;
|
||||
try {
|
||||
const latestRun = await deps.storage.runs.get(runId);
|
||||
if (latestRun?.startedAt !== undefined) {
|
||||
startedAt = latestRun.startedAt;
|
||||
}
|
||||
} catch {
|
||||
// ignore - use finishedAt as startedAt
|
||||
}
|
||||
|
||||
const tookMs = Math.max(0, finishedAt - startedAt);
|
||||
|
||||
try {
|
||||
await deps.storage.runs.patch(runId, {
|
||||
status: 'failed',
|
||||
finishedAt,
|
||||
tookMs,
|
||||
error,
|
||||
});
|
||||
} catch (e) {
|
||||
deps.logger.error(`[RR-V3] Failed to patch run "${runId}" as failed:`, e);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.events.append({ runId, type: 'run.failed', error });
|
||||
} catch (e) {
|
||||
deps.logger.warn(`[RR-V3] Failed to append run.failed for "${runId}":`, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Run Executor ====================
|
||||
|
||||
/**
|
||||
* 创建默认的 RunExecutor
|
||||
* 使用 RunRunner 执行 Flow
|
||||
*/
|
||||
function createDefaultRunExecutor(deps: {
|
||||
storage: StoragePort;
|
||||
events: EventsBus;
|
||||
runnerFactory: RunRunnerFactory;
|
||||
runners: RunnerRegistry;
|
||||
now: () => UnixMillis;
|
||||
logger: Logger;
|
||||
}): RunExecutor {
|
||||
return async (item: RunQueueItem): Promise<void> => {
|
||||
const runId = item.id;
|
||||
|
||||
// 1. 获取 RunRecord
|
||||
const run = await deps.storage.runs.get(runId);
|
||||
if (!run) {
|
||||
deps.logger.warn(`[RR-V3] RunRecord not found for queue item "${runId}", skipping execution`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 获取 Flow
|
||||
const flow = await deps.storage.flows.get(item.flowId);
|
||||
if (!flow) {
|
||||
await failRun(
|
||||
deps,
|
||||
runId,
|
||||
createRRError(RR_ERROR_CODES.VALIDATION_ERROR, `Flow "${item.flowId}" not found`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 解析 Tab ID
|
||||
const { tabId, shouldClose } = await resolveRunTab({
|
||||
runTabId: run.tabId,
|
||||
queueTabId: item.tabId,
|
||||
triggerTabId: item.trigger?.sourceTabId,
|
||||
logger: deps.logger,
|
||||
});
|
||||
|
||||
// 4. 同步 attempt 到 RunRecord
|
||||
try {
|
||||
await deps.storage.runs.patch(runId, {
|
||||
attempt: item.attempt,
|
||||
maxAttempts: item.maxAttempts,
|
||||
tabId,
|
||||
});
|
||||
} catch (e) {
|
||||
deps.logger.debug(`[RR-V3] Failed to patch run "${runId}" attempt/tabId:`, e);
|
||||
}
|
||||
|
||||
// 5. 执行 Run
|
||||
let runner;
|
||||
try {
|
||||
runner = deps.runnerFactory.create(runId, {
|
||||
flow,
|
||||
tabId,
|
||||
args: item.args,
|
||||
startNodeId: run.startNodeId,
|
||||
debug: item.debug,
|
||||
});
|
||||
|
||||
// 注册到 RunnerRegistry,供 DebugController 和 RPC 使用
|
||||
deps.runners.register(runId, runner);
|
||||
|
||||
await runner.start();
|
||||
} catch (e) {
|
||||
await failRun(
|
||||
deps,
|
||||
runId,
|
||||
createRRError(RR_ERROR_CODES.INTERNAL, `Executor crashed: ${errorMessage(e)}`),
|
||||
);
|
||||
} finally {
|
||||
// 6. 注销 Runner
|
||||
if (runner) {
|
||||
deps.runners.unregister(runId);
|
||||
}
|
||||
|
||||
// 7. 清理临时 Tab
|
||||
if (shouldClose) {
|
||||
await safeRemoveTab(tabId, deps.logger);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Bootstrap ====================
|
||||
|
||||
/**
|
||||
* 启动 RR-V3 运行时
|
||||
* @returns 运行时句柄
|
||||
*/
|
||||
export async function bootstrapV3(): Promise<V3Runtime> {
|
||||
if (runtime) return runtime;
|
||||
if (bootstrapPromise) return bootstrapPromise;
|
||||
|
||||
bootstrapPromise = (async () => {
|
||||
const logger: Logger = console;
|
||||
const now = (): UnixMillis => Date.now();
|
||||
|
||||
logger.info('[RR-V3] Bootstrapping...');
|
||||
|
||||
// 1) Storage
|
||||
const storage = createStoragePort();
|
||||
|
||||
// 2) EventsBus
|
||||
const events: EventsBus = new StorageBackedEventsBus(storage.events);
|
||||
|
||||
// 3) Lease owner identity (per SW instance)
|
||||
const ownerId = generateOwnerId();
|
||||
logger.debug(`[RR-V3] Owner ID: ${ownerId}`);
|
||||
|
||||
// 4) LeaseManager
|
||||
const leaseManager = createLeaseManager(storage.queue, DEFAULT_QUEUE_CONFIG);
|
||||
|
||||
// 5) RunnerRegistry + DebugController
|
||||
const runners = createRunnerRegistry();
|
||||
const debugController = createDebugController({ storage, events, runners });
|
||||
|
||||
// 6) Keepalive (reuse global singleton to avoid multiple controllers fighting)
|
||||
const keepalive = {
|
||||
acquire: (tag: string) => acquireKeepalive(`rr_v3:${tag}`),
|
||||
};
|
||||
|
||||
// 7) PluginRegistry - register V2 action handlers as V3 nodes
|
||||
const plugins = new PluginRegistry();
|
||||
const registeredNodes = registerV2ReplayNodesAsV3Nodes(plugins, {
|
||||
// Exclude control directives that V3 runner doesn't support
|
||||
exclude: [...DEFAULT_V2_EXCLUDE_LIST],
|
||||
});
|
||||
logger.debug(`[RR-V3] Registered ${registeredNodes.length} V2 action handlers as V3 nodes`);
|
||||
|
||||
// 8) RunExecutor via RunRunnerFactory
|
||||
const runnerFactory = createRunRunnerFactory({
|
||||
storage,
|
||||
events,
|
||||
plugins,
|
||||
artifactService: createChromeArtifactService(),
|
||||
now,
|
||||
});
|
||||
|
||||
const execute = createDefaultRunExecutor({
|
||||
storage,
|
||||
events,
|
||||
runnerFactory,
|
||||
runners,
|
||||
now,
|
||||
logger,
|
||||
});
|
||||
|
||||
// 7) Scheduler
|
||||
const scheduler = createRunScheduler({
|
||||
queue: storage.queue,
|
||||
leaseManager,
|
||||
keepalive,
|
||||
config: DEFAULT_QUEUE_CONFIG,
|
||||
ownerId,
|
||||
execute,
|
||||
now,
|
||||
logger,
|
||||
});
|
||||
|
||||
// 8) TriggerManager
|
||||
const triggers = createTriggerManager({
|
||||
storage,
|
||||
events,
|
||||
scheduler,
|
||||
handlerFactories: {
|
||||
url: createUrlTriggerHandlerFactory({ logger }),
|
||||
command: createCommandTriggerHandlerFactory({ logger }),
|
||||
contextMenu: createContextMenuTriggerHandlerFactory({ logger }),
|
||||
dom: createDomTriggerHandlerFactory({ logger }),
|
||||
cron: createCronTriggerHandlerFactory({ logger, now }),
|
||||
interval: createIntervalTriggerHandlerFactory({ logger }),
|
||||
once: createOnceTriggerHandlerFactory({ logger }),
|
||||
manual: createManualTriggerHandlerFactory({ logger }),
|
||||
},
|
||||
now,
|
||||
logger,
|
||||
});
|
||||
|
||||
// 10) RpcServer (created but started after recovery)
|
||||
const rpcServer = new RpcServer({
|
||||
storage,
|
||||
events,
|
||||
scheduler,
|
||||
debugController,
|
||||
runners,
|
||||
triggerManager: triggers,
|
||||
now,
|
||||
});
|
||||
|
||||
// Cleanup helper for error recovery
|
||||
const cleanup = async (): Promise<void> => {
|
||||
try {
|
||||
rpcServer.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await triggers.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
scheduler.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
leaseManager.dispose();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
debugController.stop();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// 10) Recovery - MUST run before scheduler.start()
|
||||
logger.info('[RR-V3] Running crash recovery...');
|
||||
await recoverFromCrash({ storage, events, ownerId, now, logger });
|
||||
|
||||
// 11) Start components
|
||||
scheduler.start();
|
||||
await triggers.start();
|
||||
rpcServer.start();
|
||||
|
||||
logger.info('[RR-V3] Bootstrap complete');
|
||||
} catch (e) {
|
||||
await cleanup();
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Build runtime handle
|
||||
runtime = {
|
||||
ownerId,
|
||||
storage,
|
||||
events,
|
||||
leaseManager,
|
||||
scheduler,
|
||||
runners,
|
||||
debugController,
|
||||
triggers,
|
||||
rpcServer,
|
||||
stop: async () => {
|
||||
logger.info('[RR-V3] Stopping...');
|
||||
// Stop order: RPC first (block new requests) -> triggers -> scheduler -> lease -> debug
|
||||
rpcServer.stop();
|
||||
await triggers.stop().catch(() => {});
|
||||
scheduler.stop();
|
||||
leaseManager.dispose();
|
||||
debugController.stop();
|
||||
runtime = null;
|
||||
logger.info('[RR-V3] Stopped');
|
||||
},
|
||||
};
|
||||
|
||||
return runtime;
|
||||
})().finally(() => {
|
||||
bootstrapPromise = null;
|
||||
});
|
||||
|
||||
return bootstrapPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前运行时(如果已启动)
|
||||
*/
|
||||
export function getV3Runtime(): V3Runtime | null {
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 V3 是否已启动
|
||||
*/
|
||||
export function isV3Running(): boolean {
|
||||
return runtime !== null;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @fileoverview 调试器类型定义
|
||||
* @description 定义 Record-Replay V3 中的调试器状态和协议
|
||||
*/
|
||||
|
||||
import type { JsonValue } from './json';
|
||||
import type { NodeId, RunId } from './ids';
|
||||
import type { PauseReason } from './events';
|
||||
|
||||
/**
|
||||
* 断点定义
|
||||
*/
|
||||
export interface Breakpoint {
|
||||
/** 断点所在节点 ID */
|
||||
nodeId: NodeId;
|
||||
/** 是否启用 */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试器状态
|
||||
* @description 描述调试器当前的连接和执行状态
|
||||
*/
|
||||
export interface DebuggerState {
|
||||
/** 关联的 Run ID */
|
||||
runId: RunId;
|
||||
/** 调试器连接状态 */
|
||||
status: 'attached' | 'detached';
|
||||
/** 执行状态 */
|
||||
execution: 'running' | 'paused';
|
||||
/** 暂停原因(仅当 execution='paused' 时有效) */
|
||||
pauseReason?: PauseReason;
|
||||
/** 当前节点 ID */
|
||||
currentNodeId?: NodeId;
|
||||
/** 断点列表 */
|
||||
breakpoints: Breakpoint[];
|
||||
/** 单步模式 */
|
||||
stepMode?: 'none' | 'stepOver';
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试器命令
|
||||
* @description 客户端发送给调试器的命令
|
||||
*/
|
||||
export type DebuggerCommand =
|
||||
// ===== 连接控制 =====
|
||||
| { type: 'debug.attach'; runId: RunId }
|
||||
| { type: 'debug.detach'; runId: RunId }
|
||||
|
||||
// ===== 执行控制 =====
|
||||
| { type: 'debug.pause'; runId: RunId }
|
||||
| { type: 'debug.resume'; runId: RunId }
|
||||
| { type: 'debug.stepOver'; runId: RunId }
|
||||
|
||||
// ===== 断点管理 =====
|
||||
| { type: 'debug.setBreakpoints'; runId: RunId; nodeIds: NodeId[] }
|
||||
| { type: 'debug.addBreakpoint'; runId: RunId; nodeId: NodeId }
|
||||
| { type: 'debug.removeBreakpoint'; runId: RunId; nodeId: NodeId }
|
||||
|
||||
// ===== 状态查询 =====
|
||||
| { type: 'debug.getState'; runId: RunId }
|
||||
|
||||
// ===== 变量操作 =====
|
||||
| { type: 'debug.getVar'; runId: RunId; name: string }
|
||||
| { type: 'debug.setVar'; runId: RunId; name: string; value: JsonValue };
|
||||
|
||||
/** 调试器命令类型(从联合类型提取) */
|
||||
export type DebuggerCommandType = DebuggerCommand['type'];
|
||||
|
||||
/**
|
||||
* 调试器命令响应
|
||||
*/
|
||||
export type DebuggerResponse =
|
||||
| { ok: true; state?: DebuggerState; value?: JsonValue }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* 创建初始调试器状态
|
||||
*/
|
||||
export function createInitialDebuggerState(runId: RunId): DebuggerState {
|
||||
return {
|
||||
runId,
|
||||
status: 'detached',
|
||||
execution: 'running',
|
||||
breakpoints: [],
|
||||
stepMode: 'none',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* @fileoverview 错误类型定义
|
||||
* @description 定义 Record-Replay V3 中使用的错误码和错误类型
|
||||
*/
|
||||
|
||||
import type { JsonValue } from './json';
|
||||
|
||||
/** 错误码常量 */
|
||||
export const RR_ERROR_CODES = {
|
||||
// ===== 验证错误 =====
|
||||
/** 通用验证错误 */
|
||||
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
||||
/** 不支持的节点类型 */
|
||||
UNSUPPORTED_NODE: 'UNSUPPORTED_NODE',
|
||||
/** DAG 结构无效 */
|
||||
DAG_INVALID: 'DAG_INVALID',
|
||||
/** DAG 存在循环 */
|
||||
DAG_CYCLE: 'DAG_CYCLE',
|
||||
|
||||
// ===== 运行时错误 =====
|
||||
/** 操作超时 */
|
||||
TIMEOUT: 'TIMEOUT',
|
||||
/** Tab 未找到 */
|
||||
TAB_NOT_FOUND: 'TAB_NOT_FOUND',
|
||||
/** Frame 未找到 */
|
||||
FRAME_NOT_FOUND: 'FRAME_NOT_FOUND',
|
||||
/** 目标元素未找到 */
|
||||
TARGET_NOT_FOUND: 'TARGET_NOT_FOUND',
|
||||
/** 元素不可见 */
|
||||
ELEMENT_NOT_VISIBLE: 'ELEMENT_NOT_VISIBLE',
|
||||
/** 导航失败 */
|
||||
NAVIGATION_FAILED: 'NAVIGATION_FAILED',
|
||||
/** 网络请求失败 */
|
||||
NETWORK_REQUEST_FAILED: 'NETWORK_REQUEST_FAILED',
|
||||
|
||||
// ===== 脚本/工具错误 =====
|
||||
/** 脚本执行失败 */
|
||||
SCRIPT_FAILED: 'SCRIPT_FAILED',
|
||||
/** 权限被拒绝 */
|
||||
PERMISSION_DENIED: 'PERMISSION_DENIED',
|
||||
/** 工具执行错误 */
|
||||
TOOL_ERROR: 'TOOL_ERROR',
|
||||
|
||||
// ===== 控制错误 =====
|
||||
/** Run 被取消 */
|
||||
RUN_CANCELED: 'RUN_CANCELED',
|
||||
/** Run 被暂停 */
|
||||
RUN_PAUSED: 'RUN_PAUSED',
|
||||
|
||||
// ===== 内部错误 =====
|
||||
/** 内部错误 */
|
||||
INTERNAL: 'INTERNAL',
|
||||
/** 不变量违规 */
|
||||
INVARIANT_VIOLATION: 'INVARIANT_VIOLATION',
|
||||
} as const;
|
||||
|
||||
/** 错误码类型 */
|
||||
export type RRErrorCode = (typeof RR_ERROR_CODES)[keyof typeof RR_ERROR_CODES];
|
||||
|
||||
/**
|
||||
* Record-Replay 错误接口
|
||||
* @description 统一的错误表示,支持错误链和可重试标记
|
||||
*/
|
||||
export interface RRError {
|
||||
/** 错误码 */
|
||||
code: RRErrorCode;
|
||||
/** 错误消息 */
|
||||
message: string;
|
||||
/** 附加数据 */
|
||||
data?: JsonValue;
|
||||
/** 是否可重试 */
|
||||
retryable?: boolean;
|
||||
/** 原因错误(错误链) */
|
||||
cause?: RRError;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 RRError 的工厂函数
|
||||
*/
|
||||
export function createRRError(
|
||||
code: RRErrorCode,
|
||||
message: string,
|
||||
options?: { data?: JsonValue; retryable?: boolean; cause?: RRError },
|
||||
): RRError {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
...(options?.data !== undefined && { data: options.data }),
|
||||
...(options?.retryable !== undefined && { retryable: options.retryable }),
|
||||
...(options?.cause !== undefined && { cause: options.cause }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* @fileoverview 事件类型定义
|
||||
* @description 定义 Record-Replay V3 中的运行事件和状态
|
||||
*/
|
||||
|
||||
import type { JsonObject, JsonValue, UnixMillis } from './json';
|
||||
import type { EdgeLabel, FlowId, NodeId, RunId } from './ids';
|
||||
import type { RRError } from './errors';
|
||||
import type { TriggerFireContext } from './triggers';
|
||||
|
||||
/** 取消订阅函数类型 */
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
/** Run 状态 */
|
||||
export type RunStatus = 'queued' | 'running' | 'paused' | 'succeeded' | 'failed' | 'canceled';
|
||||
|
||||
/**
|
||||
* 事件基础接口
|
||||
* @description 所有事件的公共字段
|
||||
*/
|
||||
export interface EventBase {
|
||||
/** 所属 Run ID */
|
||||
runId: RunId;
|
||||
/** 事件时间戳 */
|
||||
ts: UnixMillis;
|
||||
/** 单调递增序列号 */
|
||||
seq: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停原因
|
||||
* @description 描述 Run 暂停的原因
|
||||
*/
|
||||
export type PauseReason =
|
||||
| { kind: 'breakpoint'; nodeId: NodeId }
|
||||
| { kind: 'step'; nodeId: NodeId }
|
||||
| { kind: 'command' }
|
||||
| { kind: 'policy'; nodeId: NodeId; reason: string };
|
||||
|
||||
/** 恢复原因 */
|
||||
export type RecoveryReason = 'sw_restart' | 'lease_expired';
|
||||
|
||||
/**
|
||||
* Run 事件联合类型
|
||||
* @description 所有可能的运行时事件
|
||||
*/
|
||||
export type RunEvent =
|
||||
// ===== Run 生命周期事件 =====
|
||||
| (EventBase & { type: 'run.queued'; flowId: FlowId })
|
||||
| (EventBase & { type: 'run.started'; flowId: FlowId; tabId: number })
|
||||
| (EventBase & { type: 'run.paused'; reason: PauseReason; nodeId?: NodeId })
|
||||
| (EventBase & { type: 'run.resumed' })
|
||||
| (EventBase & {
|
||||
type: 'run.recovered';
|
||||
/** 恢复原因 */
|
||||
reason: RecoveryReason;
|
||||
/** 恢复前状态 */
|
||||
fromStatus: 'running' | 'paused';
|
||||
/** 恢复后状态 */
|
||||
toStatus: 'queued';
|
||||
/** 原 ownerId(用于审计) */
|
||||
prevOwnerId?: string;
|
||||
})
|
||||
| (EventBase & { type: 'run.canceled'; reason?: string })
|
||||
| (EventBase & { type: 'run.succeeded'; tookMs: number; outputs?: JsonObject })
|
||||
| (EventBase & { type: 'run.failed'; error: RRError; nodeId?: NodeId })
|
||||
|
||||
// ===== Node 执行事件 =====
|
||||
| (EventBase & { type: 'node.queued'; nodeId: NodeId })
|
||||
| (EventBase & { type: 'node.started'; nodeId: NodeId; attempt: number })
|
||||
| (EventBase & {
|
||||
type: 'node.succeeded';
|
||||
nodeId: NodeId;
|
||||
tookMs: number;
|
||||
next?: { kind: 'edgeLabel'; label: EdgeLabel } | { kind: 'end' };
|
||||
})
|
||||
| (EventBase & {
|
||||
type: 'node.failed';
|
||||
nodeId: NodeId;
|
||||
attempt: number;
|
||||
error: RRError;
|
||||
decision: 'retry' | 'continue' | 'stop' | 'goto';
|
||||
})
|
||||
| (EventBase & { type: 'node.skipped'; nodeId: NodeId; reason: 'disabled' | 'unreachable' })
|
||||
|
||||
// ===== 变量和日志事件 =====
|
||||
| (EventBase & {
|
||||
type: 'vars.patch';
|
||||
patch: Array<{ op: 'set' | 'delete'; name: string; value?: JsonValue }>;
|
||||
})
|
||||
| (EventBase & { type: 'artifact.screenshot'; nodeId: NodeId; data: string; savedAs?: string })
|
||||
| (EventBase & {
|
||||
type: 'log';
|
||||
level: 'debug' | 'info' | 'warn' | 'error';
|
||||
message: string;
|
||||
data?: JsonValue;
|
||||
});
|
||||
|
||||
/** Run 事件类型(从联合类型提取) */
|
||||
export type RunEventType = RunEvent['type'];
|
||||
|
||||
/**
|
||||
* 分布式 Omit(保留联合类型)
|
||||
*/
|
||||
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
|
||||
|
||||
/**
|
||||
* Run 事件输入类型
|
||||
* @description seq 必须由 storage 层原子分配(通过 RunRecordV3.nextSeq)
|
||||
* ts 可选,默认为 Date.now()
|
||||
*/
|
||||
export type RunEventInput = DistributiveOmit<RunEvent, 'seq' | 'ts'> & {
|
||||
ts?: UnixMillis;
|
||||
};
|
||||
|
||||
/** Run Schema 版本 */
|
||||
export const RUN_SCHEMA_VERSION = 3 as const;
|
||||
|
||||
/**
|
||||
* Run 记录 V3
|
||||
* @description 存储在 IndexedDB 中的 Run 摘要记录
|
||||
*/
|
||||
export interface RunRecordV3 {
|
||||
/** Schema 版本 */
|
||||
schemaVersion: typeof RUN_SCHEMA_VERSION;
|
||||
/** Run 唯一标识符 */
|
||||
id: RunId;
|
||||
/** 关联的 Flow ID */
|
||||
flowId: FlowId;
|
||||
|
||||
/** 当前状态 */
|
||||
status: RunStatus;
|
||||
/** 创建时间 */
|
||||
createdAt: UnixMillis;
|
||||
/** 最后更新时间 */
|
||||
updatedAt: UnixMillis;
|
||||
|
||||
/** 开始执行时间 */
|
||||
startedAt?: UnixMillis;
|
||||
/** 结束时间 */
|
||||
finishedAt?: UnixMillis;
|
||||
/** 总耗时(毫秒) */
|
||||
tookMs?: number;
|
||||
|
||||
/** 绑定的 Tab ID(每 Run 独占) */
|
||||
tabId?: number;
|
||||
/** 起始节点 ID(如果不是默认入口) */
|
||||
startNodeId?: NodeId;
|
||||
/** 当前执行节点 ID */
|
||||
currentNodeId?: NodeId;
|
||||
|
||||
/** 当前尝试次数 */
|
||||
attempt: number;
|
||||
/** 最大尝试次数 */
|
||||
maxAttempts: number;
|
||||
|
||||
/** 运行参数 */
|
||||
args?: JsonObject;
|
||||
/** 触发器上下文 */
|
||||
trigger?: TriggerFireContext;
|
||||
/** 调试配置 */
|
||||
debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean };
|
||||
|
||||
/** 错误信息(如果失败) */
|
||||
error?: RRError;
|
||||
/** 输出结果 */
|
||||
outputs?: JsonObject;
|
||||
|
||||
/** 下一个事件序列号(缓存字段) */
|
||||
nextSeq: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 Run 是否已终止
|
||||
*/
|
||||
export function isTerminalStatus(status: RunStatus): boolean {
|
||||
return status === 'succeeded' || status === 'failed' || status === 'canceled';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 Run 是否正在执行
|
||||
*/
|
||||
export function isActiveStatus(status: RunStatus): boolean {
|
||||
return status === 'running' || status === 'paused';
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @fileoverview Flow 类型定义
|
||||
* @description 定义 Record-Replay V3 中的 Flow IR(中间表示)
|
||||
*/
|
||||
|
||||
import type { ISODateTimeString, JsonObject } from './json';
|
||||
import type { EdgeId, EdgeLabel, FlowId, NodeId } from './ids';
|
||||
import type { FlowPolicy, NodePolicy } from './policy';
|
||||
import type { VariableDefinition } from './variables';
|
||||
|
||||
/** Flow Schema 版本 */
|
||||
export const FLOW_SCHEMA_VERSION = 3 as const;
|
||||
|
||||
/**
|
||||
* Edge V3
|
||||
* @description DAG 中的边,连接两个节点
|
||||
*/
|
||||
export interface EdgeV3 {
|
||||
/** Edge 唯一标识符 */
|
||||
id: EdgeId;
|
||||
/** 源节点 ID */
|
||||
from: NodeId;
|
||||
/** 目标节点 ID */
|
||||
to: NodeId;
|
||||
/** 边标签(用于条件分支和错误处理) */
|
||||
label?: EdgeLabel;
|
||||
}
|
||||
|
||||
/** 节点类型(可扩展) */
|
||||
export type NodeKind = string;
|
||||
|
||||
/**
|
||||
* Node V3
|
||||
* @description DAG 中的节点,代表一个可执行的操作
|
||||
*/
|
||||
export interface NodeV3 {
|
||||
/** Node 唯一标识符 */
|
||||
id: NodeId;
|
||||
/** 节点类型 */
|
||||
kind: NodeKind;
|
||||
/** 节点名称(用于显示) */
|
||||
name?: string;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 节点级策略 */
|
||||
policy?: NodePolicy;
|
||||
/** 节点配置(类型由 kind 决定) */
|
||||
config: JsonObject;
|
||||
/** UI 布局信息 */
|
||||
ui?: { x: number; y: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow 元数据绑定
|
||||
* @description 定义 Flow 与特定域名/路径/URL 的关联
|
||||
*/
|
||||
export interface FlowBinding {
|
||||
kind: 'domain' | 'path' | 'url';
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow V3
|
||||
* @description 完整的 Flow 定义,包含节点、边和配置
|
||||
*/
|
||||
export interface FlowV3 {
|
||||
/** Schema 版本 */
|
||||
schemaVersion: typeof FLOW_SCHEMA_VERSION;
|
||||
/** Flow 唯一标识符 */
|
||||
id: FlowId;
|
||||
/** Flow 名称 */
|
||||
name: string;
|
||||
/** Flow 描述 */
|
||||
description?: string;
|
||||
/** 创建时间 */
|
||||
createdAt: ISODateTimeString;
|
||||
/** 更新时间 */
|
||||
updatedAt: ISODateTimeString;
|
||||
|
||||
/** 入口节点 ID(显式指定,不依赖入度推断) */
|
||||
entryNodeId: NodeId;
|
||||
/** 节点列表 */
|
||||
nodes: NodeV3[];
|
||||
/** 边列表 */
|
||||
edges: EdgeV3[];
|
||||
|
||||
/** 变量定义 */
|
||||
variables?: VariableDefinition[];
|
||||
/** Flow 级策略 */
|
||||
policy?: FlowPolicy;
|
||||
/** 元数据 */
|
||||
meta?: {
|
||||
/** 标签 */
|
||||
tags?: string[];
|
||||
/** 绑定规则 */
|
||||
bindings?: FlowBinding[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 查找节点
|
||||
*/
|
||||
export function findNodeById(flow: FlowV3, nodeId: NodeId): NodeV3 | undefined {
|
||||
return flow.nodes.find((n) => n.id === nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找从指定节点出发的所有边
|
||||
*/
|
||||
export function findEdgesFrom(flow: FlowV3, nodeId: NodeId): EdgeV3[] {
|
||||
return flow.edges.filter((e) => e.from === nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找指向指定节点的所有边
|
||||
*/
|
||||
export function findEdgesTo(flow: FlowV3, nodeId: NodeId): EdgeV3[] {
|
||||
return flow.edges.filter((e) => e.to === nodeId);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @fileoverview ID 类型定义
|
||||
* @description 定义 Record-Replay V3 中使用的各种 ID 类型
|
||||
*/
|
||||
|
||||
/** Flow 唯一标识符 */
|
||||
export type FlowId = string;
|
||||
|
||||
/** Node 唯一标识符 */
|
||||
export type NodeId = string;
|
||||
|
||||
/** Edge 唯一标识符 */
|
||||
export type EdgeId = string;
|
||||
|
||||
/** Run 唯一标识符 */
|
||||
export type RunId = string;
|
||||
|
||||
/** Trigger 唯一标识符 */
|
||||
export type TriggerId = string;
|
||||
|
||||
/** Edge 标签类型 */
|
||||
export type EdgeLabel = string;
|
||||
|
||||
/** 预定义的 Edge 标签常量 */
|
||||
export const EDGE_LABELS = {
|
||||
/** 默认边 */
|
||||
DEFAULT: 'default',
|
||||
/** 错误处理边 */
|
||||
ON_ERROR: 'onError',
|
||||
/** 条件为真时的边 */
|
||||
TRUE: 'true',
|
||||
/** 条件为假时的边 */
|
||||
FALSE: 'false',
|
||||
} as const;
|
||||
|
||||
/** Edge 标签类型(从常量推导) */
|
||||
export type EdgeLabelValue = (typeof EDGE_LABELS)[keyof typeof EDGE_LABELS];
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @fileoverview Domain 层导出入口
|
||||
* @description 导出所有 Domain 类型定义
|
||||
*/
|
||||
|
||||
// JSON 基础类型
|
||||
export * from './json';
|
||||
|
||||
// ID 类型
|
||||
export * from './ids';
|
||||
|
||||
// 错误类型
|
||||
export * from './errors';
|
||||
|
||||
// 策略类型
|
||||
export * from './policy';
|
||||
|
||||
// 变量类型
|
||||
export * from './variables';
|
||||
|
||||
// Flow 类型
|
||||
export * from './flow';
|
||||
|
||||
// 事件类型
|
||||
export * from './events';
|
||||
|
||||
// 调试器类型
|
||||
export * from './debug';
|
||||
|
||||
// 触发器类型
|
||||
export * from './triggers';
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @fileoverview JSON 基础类型定义
|
||||
* @description 定义 Record-Replay V3 中使用的 JSON 相关类型
|
||||
*/
|
||||
|
||||
/** JSON 原始类型 */
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
|
||||
/** JSON 对象类型 */
|
||||
export interface JsonObject {
|
||||
[key: string]: JsonValue;
|
||||
}
|
||||
|
||||
/** JSON 数组类型 */
|
||||
export type JsonArray = JsonValue[];
|
||||
|
||||
/** 任意 JSON 值类型 */
|
||||
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
|
||||
|
||||
/** ISO 8601 日期时间字符串 */
|
||||
export type ISODateTimeString = string;
|
||||
|
||||
/** Unix 毫秒时间戳 */
|
||||
export type UnixMillis = number;
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @fileoverview 策略类型定义
|
||||
* @description 定义 Record-Replay V3 中使用的超时、重试、错误处理和工件策略
|
||||
*/
|
||||
|
||||
import type { EdgeLabel, NodeId } from './ids';
|
||||
import type { RRErrorCode } from './errors';
|
||||
import type { UnixMillis } from './json';
|
||||
|
||||
/**
|
||||
* 超时策略
|
||||
* @description 定义操作的超时时间和作用范围
|
||||
*/
|
||||
export interface TimeoutPolicy {
|
||||
/** 超时时间(毫秒) */
|
||||
ms: UnixMillis;
|
||||
/** 超时范围:attempt=每次尝试, node=整个节点执行 */
|
||||
scope?: 'attempt' | 'node';
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试策略
|
||||
* @description 定义失败后的重试行为
|
||||
*/
|
||||
export interface RetryPolicy {
|
||||
/** 最大重试次数 */
|
||||
retries: number;
|
||||
/** 重试间隔(毫秒) */
|
||||
intervalMs: UnixMillis;
|
||||
/** 退避策略:none=固定间隔, exp=指数退避, linear=线性增长 */
|
||||
backoff?: 'none' | 'exp' | 'linear';
|
||||
/** 最大重试间隔(毫秒) */
|
||||
maxIntervalMs?: UnixMillis;
|
||||
/** 抖动策略:none=无抖动, full=完全随机 */
|
||||
jitter?: 'none' | 'full';
|
||||
/** 仅在这些错误码时重试 */
|
||||
retryOn?: ReadonlyArray<RRErrorCode>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误处理策略
|
||||
* @description 定义节点执行失败后的处理方式
|
||||
*/
|
||||
export type OnErrorPolicy =
|
||||
| { kind: 'stop' }
|
||||
| { kind: 'continue'; as?: 'warning' | 'error' }
|
||||
| {
|
||||
kind: 'goto';
|
||||
target: { kind: 'edgeLabel'; label: EdgeLabel } | { kind: 'node'; nodeId: NodeId };
|
||||
}
|
||||
| { kind: 'retry'; override?: Partial<RetryPolicy> };
|
||||
|
||||
/**
|
||||
* 工件策略
|
||||
* @description 定义截图和日志收集的行为
|
||||
*/
|
||||
export interface ArtifactPolicy {
|
||||
/** 截图策略:never=从不, onFailure=失败时, always=总是 */
|
||||
screenshot?: 'never' | 'onFailure' | 'always';
|
||||
/** 截图保存路径模板 */
|
||||
saveScreenshotAs?: string;
|
||||
/** 是否包含控制台日志 */
|
||||
includeConsole?: boolean;
|
||||
/** 是否包含网络请求 */
|
||||
includeNetwork?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点级策略
|
||||
* @description 单个节点的执行策略配置
|
||||
*/
|
||||
export interface NodePolicy {
|
||||
/** 超时策略 */
|
||||
timeout?: TimeoutPolicy;
|
||||
/** 重试策略 */
|
||||
retry?: RetryPolicy;
|
||||
/** 错误处理策略 */
|
||||
onError?: OnErrorPolicy;
|
||||
/** 工件策略 */
|
||||
artifacts?: ArtifactPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow 级策略
|
||||
* @description 整个 Flow 的执行策略配置
|
||||
*/
|
||||
export interface FlowPolicy {
|
||||
/** 默认节点策略 */
|
||||
defaultNodePolicy?: NodePolicy;
|
||||
/** 不支持节点的处理策略 */
|
||||
unsupportedNodePolicy?: OnErrorPolicy;
|
||||
/** Run 总超时时间(毫秒) */
|
||||
runTimeoutMs?: UnixMillis;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并节点策略
|
||||
* @description 将 Flow 级默认策略与节点级策略合并
|
||||
*/
|
||||
export function mergeNodePolicy(
|
||||
flowDefault: NodePolicy | undefined,
|
||||
nodePolicy: NodePolicy | undefined,
|
||||
): NodePolicy {
|
||||
if (!flowDefault) return nodePolicy ?? {};
|
||||
if (!nodePolicy) return flowDefault;
|
||||
|
||||
return {
|
||||
timeout: nodePolicy.timeout ?? flowDefault.timeout,
|
||||
retry: nodePolicy.retry ?? flowDefault.retry,
|
||||
onError: nodePolicy.onError ?? flowDefault.onError,
|
||||
artifacts: nodePolicy.artifacts
|
||||
? { ...flowDefault.artifacts, ...nodePolicy.artifacts }
|
||||
: flowDefault.artifacts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @fileoverview 触发器类型定义
|
||||
* @description 定义 Record-Replay V3 中的触发器规范
|
||||
*/
|
||||
|
||||
import type { JsonObject, UnixMillis } from './json';
|
||||
import type { FlowId, TriggerId } from './ids';
|
||||
|
||||
/** 触发器类型 */
|
||||
export type TriggerKind =
|
||||
| 'manual'
|
||||
| 'url'
|
||||
| 'cron'
|
||||
| 'interval'
|
||||
| 'once'
|
||||
| 'command'
|
||||
| 'contextMenu'
|
||||
| 'dom';
|
||||
|
||||
/**
|
||||
* 触发器基础接口
|
||||
*/
|
||||
export interface TriggerSpecBase {
|
||||
/** 触发器 ID */
|
||||
id: TriggerId;
|
||||
/** 触发器类型 */
|
||||
kind: TriggerKind;
|
||||
/** 是否启用 */
|
||||
enabled: boolean;
|
||||
/** 关联的 Flow ID */
|
||||
flowId: FlowId;
|
||||
/** 传递给 Flow 的参数 */
|
||||
args?: JsonObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 匹配规则
|
||||
*/
|
||||
export interface UrlMatchRule {
|
||||
kind: 'url' | 'domain' | 'path';
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发器规范联合类型
|
||||
*/
|
||||
export type TriggerSpec =
|
||||
// 手动触发
|
||||
| (TriggerSpecBase & { kind: 'manual' })
|
||||
|
||||
// URL 触发
|
||||
| (TriggerSpecBase & {
|
||||
kind: 'url';
|
||||
match: UrlMatchRule[];
|
||||
})
|
||||
|
||||
// Cron 定时触发
|
||||
| (TriggerSpecBase & {
|
||||
kind: 'cron';
|
||||
cron: string;
|
||||
timezone?: string;
|
||||
})
|
||||
|
||||
// Interval 定时触发(固定间隔重复)
|
||||
| (TriggerSpecBase & {
|
||||
kind: 'interval';
|
||||
/** 间隔分钟数,最小为 1 */
|
||||
periodMinutes: number;
|
||||
})
|
||||
|
||||
// Once 定时触发(指定时间触发一次后自动禁用)
|
||||
| (TriggerSpecBase & {
|
||||
kind: 'once';
|
||||
/** 触发时间戳 (Unix milliseconds) */
|
||||
whenMs: UnixMillis;
|
||||
})
|
||||
|
||||
// 快捷键触发
|
||||
| (TriggerSpecBase & {
|
||||
kind: 'command';
|
||||
commandKey: string;
|
||||
})
|
||||
|
||||
// 右键菜单触发
|
||||
| (TriggerSpecBase & {
|
||||
kind: 'contextMenu';
|
||||
title: string;
|
||||
contexts?: ReadonlyArray<string>;
|
||||
})
|
||||
|
||||
// DOM 元素出现触发
|
||||
| (TriggerSpecBase & {
|
||||
kind: 'dom';
|
||||
selector: string;
|
||||
appear?: boolean;
|
||||
once?: boolean;
|
||||
debounceMs?: UnixMillis;
|
||||
});
|
||||
|
||||
/**
|
||||
* 触发器触发上下文
|
||||
* @description 描述触发器被触发时的上下文信息
|
||||
*/
|
||||
export interface TriggerFireContext {
|
||||
/** 触发器 ID */
|
||||
triggerId: TriggerId;
|
||||
/** 触发器类型 */
|
||||
kind: TriggerKind;
|
||||
/** 触发时间 */
|
||||
firedAt: UnixMillis;
|
||||
/** 来源 Tab ID */
|
||||
sourceTabId?: number;
|
||||
/** 来源 URL */
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据触发器类型获取类型化的触发器规范
|
||||
*/
|
||||
export type TriggerSpecByKind<K extends TriggerKind> = Extract<TriggerSpec, { kind: K }>;
|
||||
|
||||
/**
|
||||
* 判断触发器是否启用
|
||||
*/
|
||||
export function isTriggerEnabled(trigger: TriggerSpec): boolean {
|
||||
return trigger.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建触发器触发上下文
|
||||
*/
|
||||
export function createTriggerFireContext(
|
||||
trigger: TriggerSpec,
|
||||
options?: { sourceTabId?: number; sourceUrl?: string },
|
||||
): TriggerFireContext {
|
||||
return {
|
||||
triggerId: trigger.id,
|
||||
kind: trigger.kind,
|
||||
firedAt: Date.now(),
|
||||
sourceTabId: options?.sourceTabId,
|
||||
sourceUrl: options?.sourceUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @fileoverview 变量类型定义
|
||||
* @description 定义 Record-Replay V3 中使用的变量指针和持久化变量
|
||||
*/
|
||||
|
||||
import type { JsonValue, UnixMillis } from './json';
|
||||
|
||||
/** 变量名称 */
|
||||
export type VariableName = string;
|
||||
|
||||
/** 持久化变量名称(以 $ 开头) */
|
||||
export type PersistentVariableName = `$${string}`;
|
||||
|
||||
/** 变量作用域 */
|
||||
export type VariableScope = 'run' | 'flow' | 'persistent';
|
||||
|
||||
/**
|
||||
* 变量指针
|
||||
* @description 指向变量的引用,支持 JSON path 访问
|
||||
*/
|
||||
export interface VariablePointer {
|
||||
/** 变量作用域 */
|
||||
scope: VariableScope;
|
||||
/** 变量名称 */
|
||||
name: VariableName;
|
||||
/** JSON path(用于访问嵌套属性) */
|
||||
path?: ReadonlyArray<string | number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 变量定义
|
||||
* @description Flow 中声明的变量
|
||||
*/
|
||||
export interface VariableDefinition {
|
||||
/** 变量名称 */
|
||||
name: VariableName;
|
||||
/** 显示标签 */
|
||||
label?: string;
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
/** 是否敏感(不显示/导出) */
|
||||
sensitive?: boolean;
|
||||
/** 是否必需 */
|
||||
required?: boolean;
|
||||
/** 默认值 */
|
||||
default?: JsonValue;
|
||||
/** 作用域(不含 persistent,persistent 通过 $ 前缀判断) */
|
||||
scope?: Exclude<VariableScope, 'persistent'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化变量记录
|
||||
* @description 存储在 IndexedDB 中的持久化变量
|
||||
*/
|
||||
export interface PersistentVarRecord {
|
||||
/** 变量键(以 $ 开头) */
|
||||
key: PersistentVariableName;
|
||||
/** 变量值 */
|
||||
value: JsonValue;
|
||||
/** 最后更新时间 */
|
||||
updatedAt: UnixMillis;
|
||||
/** 版本号(单调递增,用于 LWW 和调试) */
|
||||
version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断变量名是否为持久化变量
|
||||
*/
|
||||
export function isPersistentVariable(name: string): name is PersistentVariableName {
|
||||
return name.startsWith('$');
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析变量指针字符串
|
||||
* @example "$user.name" -> { scope: 'persistent', name: '$user', path: ['name'] }
|
||||
*/
|
||||
export function parseVariablePointer(ref: string): VariablePointer | null {
|
||||
if (!ref) return null;
|
||||
|
||||
const parts = ref.split('.');
|
||||
const name = parts[0];
|
||||
const path = parts.slice(1);
|
||||
|
||||
if (isPersistentVariable(name)) {
|
||||
return {
|
||||
scope: 'persistent',
|
||||
name,
|
||||
path: path.length > 0 ? path : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 默认为 run 作用域
|
||||
return {
|
||||
scope: 'run',
|
||||
name,
|
||||
path: path.length > 0 ? path : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @fileoverview Engine 层导出入口
|
||||
*/
|
||||
|
||||
// Kernel
|
||||
export * from './kernel';
|
||||
|
||||
// Queue
|
||||
export * from './queue';
|
||||
|
||||
// Plugins
|
||||
export * from './plugins';
|
||||
|
||||
// Transport
|
||||
export * from './transport';
|
||||
|
||||
// Keepalive
|
||||
export * from './keepalive';
|
||||
|
||||
// Recovery
|
||||
export * from './recovery';
|
||||
|
||||
// Triggers
|
||||
export * from './triggers';
|
||||
|
||||
// Storage Port
|
||||
export * from './storage';
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Keepalive 模块导出入口
|
||||
*/
|
||||
|
||||
export * from './offscreen-keepalive';
|
||||
+451
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* @fileoverview Offscreen Keepalive Controller
|
||||
* @description Keeps the MV3 service worker alive using an Offscreen Document + Port heartbeat.
|
||||
*
|
||||
* Architecture:
|
||||
* - Background (this module) listens for an Offscreen Port connection.
|
||||
* - Offscreen connects and sends heartbeat pings.
|
||||
* - Background replies with pong and controls the heartbeat via `start`/`stop`.
|
||||
*
|
||||
* Contract:
|
||||
* - When at least one keepalive reference is held, keepalive must be running.
|
||||
* - When the reference count drops to zero, keepalive must fully stop (no ping loop, no Port, no reconnect).
|
||||
*/
|
||||
|
||||
import { offscreenManager } from '@/utils/offscreen-manager';
|
||||
import {
|
||||
RR_V3_KEEPALIVE_PORT_NAME,
|
||||
type KeepaliveMessage,
|
||||
} from '@/common/rr-v3-keepalive-protocol';
|
||||
|
||||
// ==================== Runtime Control Protocol ====================
|
||||
|
||||
const KEEPALIVE_CONTROL_MESSAGE_TYPE = 'rr_v3_keepalive.control' as const;
|
||||
|
||||
type KeepaliveControlCommand = 'start' | 'stop';
|
||||
|
||||
interface KeepaliveControlMessage {
|
||||
type: typeof KEEPALIVE_CONTROL_MESSAGE_TYPE;
|
||||
command: KeepaliveControlCommand;
|
||||
}
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
/**
|
||||
* Keepalive controller interface.
|
||||
* @description Manages Service Worker keepalive state.
|
||||
*/
|
||||
export interface KeepaliveController {
|
||||
/**
|
||||
* Acquire (increment reference count).
|
||||
* @param tag Tag used for debugging.
|
||||
* @returns Release function.
|
||||
*/
|
||||
acquire(tag: string): () => void;
|
||||
|
||||
/** Whether any keepalive reference is currently held. */
|
||||
isActive(): boolean;
|
||||
|
||||
/** Current reference count. */
|
||||
getRefCount(): number;
|
||||
|
||||
/** Release all references (primarily for testing). */
|
||||
releaseAll(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offscreen keepalive options.
|
||||
*/
|
||||
export interface OffscreenKeepaliveOptions {
|
||||
/** Logger. */
|
||||
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
}
|
||||
|
||||
// ==================== Factory ====================
|
||||
|
||||
/**
|
||||
* Create an Offscreen keepalive controller.
|
||||
* @description Reuses the global OffscreenManager to avoid creating multiple Offscreen Documents concurrently.
|
||||
*/
|
||||
export function createOffscreenKeepaliveController(
|
||||
options: OffscreenKeepaliveOptions = {},
|
||||
): KeepaliveController {
|
||||
return new OffscreenKeepaliveControllerImpl(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NotImplemented KeepaliveController.
|
||||
* @description Placeholder implementation.
|
||||
*/
|
||||
export function createNotImplementedKeepaliveController(): KeepaliveController {
|
||||
return {
|
||||
acquire: () => {
|
||||
console.warn('[KeepaliveController] Not implemented, returning no-op release');
|
||||
return () => {};
|
||||
},
|
||||
isActive: () => false,
|
||||
getRefCount: () => 0,
|
||||
releaseAll: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Implementation ====================
|
||||
|
||||
/**
|
||||
* Offscreen keepalive controller implementation.
|
||||
*/
|
||||
class OffscreenKeepaliveControllerImpl implements KeepaliveController {
|
||||
private readonly refs = new Map<string, number>();
|
||||
private totalRefs = 0;
|
||||
|
||||
private offscreenPort: chrome.runtime.Port | null = null;
|
||||
private connectionListenerRegistered = false;
|
||||
|
||||
// Used to serialize async operations to avoid races.
|
||||
private syncPromise: Promise<void> = Promise.resolve();
|
||||
|
||||
private readonly logger: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
|
||||
constructor(options: OffscreenKeepaliveOptions) {
|
||||
this.logger = options.logger ?? console;
|
||||
// Register listener eagerly to avoid missing Offscreen connect events.
|
||||
// This prevents race conditions where Offscreen connects before we start listening.
|
||||
this.ensureConnectionListener();
|
||||
}
|
||||
|
||||
acquire(tag: string): () => void {
|
||||
this.totalRefs += 1;
|
||||
|
||||
const count = this.refs.get(tag) ?? 0;
|
||||
this.refs.set(tag, count + 1);
|
||||
|
||||
this.logger.debug(`[OffscreenKeepalive] acquire(${tag}), totalRefs=${this.totalRefs}`);
|
||||
|
||||
// Start keepalive when the first reference is acquired.
|
||||
if (this.totalRefs === 1) {
|
||||
this.scheduleSync();
|
||||
}
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
|
||||
if (this.totalRefs > 0) {
|
||||
this.totalRefs -= 1;
|
||||
}
|
||||
|
||||
const currentCount = this.refs.get(tag) ?? 0;
|
||||
if (currentCount <= 1) {
|
||||
this.refs.delete(tag);
|
||||
} else {
|
||||
this.refs.set(tag, currentCount - 1);
|
||||
}
|
||||
|
||||
this.logger.debug(`[OffscreenKeepalive] release(${tag}), totalRefs=${this.totalRefs}`);
|
||||
|
||||
// Stop keepalive when the reference count drops to zero.
|
||||
if (this.totalRefs === 0) {
|
||||
this.scheduleSync();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.totalRefs > 0;
|
||||
}
|
||||
|
||||
getRefCount(): number {
|
||||
return this.totalRefs;
|
||||
}
|
||||
|
||||
releaseAll(): void {
|
||||
if (this.totalRefs === 0) return;
|
||||
|
||||
this.logger.debug('[OffscreenKeepalive] releaseAll()');
|
||||
this.refs.clear();
|
||||
this.totalRefs = 0;
|
||||
this.scheduleSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current reference counts grouped by tag.
|
||||
* @description Useful for debugging.
|
||||
*/
|
||||
getRefsByTag(): Record<string, number> {
|
||||
return Object.fromEntries(this.refs);
|
||||
}
|
||||
|
||||
// ==================== Private Methods ====================
|
||||
|
||||
/**
|
||||
* Schedule a sync operation.
|
||||
* @description Serializes async operations to avoid races.
|
||||
*/
|
||||
private scheduleSync(): void {
|
||||
this.syncPromise = this.syncPromise
|
||||
.catch(() => {
|
||||
// Ignore previous operation errors.
|
||||
})
|
||||
.then(() => this.syncOnce())
|
||||
.catch((e) => {
|
||||
this.logger.warn('[OffscreenKeepalive] sync failed:', e);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a single sync step based on the current ref count.
|
||||
*/
|
||||
private async syncOnce(): Promise<void> {
|
||||
if (this.totalRefs > 0) {
|
||||
// Ensure listener exists before Offscreen connects (race prevention).
|
||||
this.ensureConnectionListener();
|
||||
|
||||
// Ensure the Offscreen document exists.
|
||||
await offscreenManager.ensureOffscreenDocument();
|
||||
|
||||
// Re-check after await: state may have changed while we were creating the document.
|
||||
if (this.totalRefs === 0) {
|
||||
await this.teardown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send start command via runtime message (works even if Port is not connected).
|
||||
await this.sendRuntimeControl('start');
|
||||
// Also send via Port if connected.
|
||||
this.sendStartCommand();
|
||||
} else {
|
||||
// Send stop via Port first (if connected).
|
||||
this.sendStopCommand();
|
||||
// Then send via runtime message to ensure Offscreen stops.
|
||||
await this.sendRuntimeControl('stop');
|
||||
await this.teardown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources.
|
||||
*/
|
||||
private async teardown(): Promise<void> {
|
||||
this.disconnectPort();
|
||||
// Note: We do not close the Offscreen Document here because it may be used by other modules.
|
||||
// If Offscreen Document lifecycle needs ref-counting, it should be implemented in OffscreenManager.
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the Port connection listener is registered.
|
||||
*/
|
||||
private ensureConnectionListener(): void {
|
||||
if (this.connectionListenerRegistered) return;
|
||||
|
||||
if (typeof chrome === 'undefined' || !chrome.runtime?.onConnect) {
|
||||
this.logger.warn('[OffscreenKeepalive] chrome.runtime.onConnect not available');
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.runtime.onConnect.addListener(this.handleConnect);
|
||||
this.connectionListenerRegistered = true;
|
||||
|
||||
this.logger.debug('[OffscreenKeepalive] Connection listener registered');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Port connections from Offscreen.
|
||||
*/
|
||||
private handleConnect = (port: chrome.runtime.Port): void => {
|
||||
if (port.name !== RR_V3_KEEPALIVE_PORT_NAME) return;
|
||||
|
||||
this.logger.debug('[OffscreenKeepalive] Offscreen connected');
|
||||
|
||||
// Store Port reference.
|
||||
this.offscreenPort = port;
|
||||
|
||||
// Listen to messages.
|
||||
port.onMessage.addListener(this.handlePortMessage);
|
||||
|
||||
// Listen to disconnect.
|
||||
port.onDisconnect.addListener(() => {
|
||||
this.logger.debug('[OffscreenKeepalive] Offscreen disconnected');
|
||||
if (this.offscreenPort === port) {
|
||||
this.offscreenPort = null;
|
||||
}
|
||||
});
|
||||
|
||||
// If active, send the start command.
|
||||
if (this.totalRefs > 0) {
|
||||
this.sendStartCommand();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle messages from Offscreen.
|
||||
*/
|
||||
private handlePortMessage = (msg: unknown): void => {
|
||||
const m = msg as Partial<KeepaliveMessage> | null;
|
||||
if (!m || typeof m !== 'object') return;
|
||||
|
||||
if (m.type === 'keepalive.ping') {
|
||||
this.logger.debug('[OffscreenKeepalive] Received ping, sending pong');
|
||||
this.sendPong();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Disconnect the Port.
|
||||
*/
|
||||
private disconnectPort(): void {
|
||||
if (!this.offscreenPort) return;
|
||||
|
||||
const port = this.offscreenPort;
|
||||
this.offscreenPort = null;
|
||||
|
||||
try {
|
||||
port.disconnect();
|
||||
} catch {
|
||||
// Port may already be disconnected.
|
||||
}
|
||||
|
||||
this.logger.debug('[OffscreenKeepalive] Port disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the start command to Offscreen (Port channel).
|
||||
*/
|
||||
private sendStartCommand(): void {
|
||||
if (!this.offscreenPort) return;
|
||||
|
||||
const msg: KeepaliveMessage = {
|
||||
type: 'keepalive.start',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
this.offscreenPort.postMessage(msg);
|
||||
this.logger.debug('[OffscreenKeepalive] Sent start command via Port');
|
||||
} catch (e) {
|
||||
this.logger.warn('[OffscreenKeepalive] Failed to send start command:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the stop command to Offscreen (Port channel).
|
||||
*/
|
||||
private sendStopCommand(): void {
|
||||
if (!this.offscreenPort) return;
|
||||
|
||||
const msg: KeepaliveMessage = {
|
||||
type: 'keepalive.stop',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
this.offscreenPort.postMessage(msg);
|
||||
this.logger.debug('[OffscreenKeepalive] Sent stop command via Port');
|
||||
} catch (e) {
|
||||
this.logger.warn('[OffscreenKeepalive] Failed to send stop command:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a pong response.
|
||||
*/
|
||||
private sendPong(): void {
|
||||
if (!this.offscreenPort) return;
|
||||
|
||||
const msg: KeepaliveMessage = {
|
||||
type: 'keepalive.pong',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
this.offscreenPort.postMessage(msg);
|
||||
} catch (e) {
|
||||
this.logger.warn('[OffscreenKeepalive] Failed to send pong:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a runtime control command to Offscreen.
|
||||
* This is the control plane used to start/stop keepalive even when the Port is not connected.
|
||||
*/
|
||||
private async sendRuntimeControl(command: KeepaliveControlCommand): Promise<void> {
|
||||
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
|
||||
this.logger.warn('[OffscreenKeepalive] chrome.runtime.sendMessage not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const msg: KeepaliveControlMessage = {
|
||||
type: KEEPALIVE_CONTROL_MESSAGE_TYPE,
|
||||
command,
|
||||
};
|
||||
|
||||
// Retry with delays for start command (Offscreen document may not be ready yet).
|
||||
const delaysMs = command === 'start' ? [0, 50, 200] : [0];
|
||||
for (const delayMs of delaysMs) {
|
||||
if (delayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
try {
|
||||
await chrome.runtime.sendMessage(msg);
|
||||
this.logger.debug(`[OffscreenKeepalive] Sent runtime ${command} command`);
|
||||
return;
|
||||
} catch {
|
||||
// Best-effort: Offscreen document may not be ready yet.
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.warn(`[OffscreenKeepalive] Failed to send runtime ${command} command`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Test Utilities ====================
|
||||
|
||||
/**
|
||||
* In-memory keepalive controller.
|
||||
* @description For tests only: tracks reference counts without using Offscreen.
|
||||
*/
|
||||
export class InMemoryKeepaliveController implements KeepaliveController {
|
||||
private refs = new Map<string, number>();
|
||||
|
||||
acquire(tag: string): () => void {
|
||||
const count = this.refs.get(tag) ?? 0;
|
||||
this.refs.set(tag, count + 1);
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
|
||||
const currentCount = this.refs.get(tag) ?? 0;
|
||||
if (currentCount <= 1) {
|
||||
this.refs.delete(tag);
|
||||
} else {
|
||||
this.refs.set(tag, currentCount - 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.refs.size > 0;
|
||||
}
|
||||
|
||||
getRefCount(): number {
|
||||
let total = 0;
|
||||
for (const count of this.refs.values()) {
|
||||
total += count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
releaseAll(): void {
|
||||
this.refs.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current reference counts grouped by tag.
|
||||
* @description Useful for debugging.
|
||||
*/
|
||||
getRefsByTag(): Record<string, number> {
|
||||
return Object.fromEntries(this.refs);
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* @fileoverview 工件(Artifacts)接口
|
||||
* @description 定义截图等工件的获取和存储接口
|
||||
*/
|
||||
|
||||
import type { NodeId, RunId } from '../../domain/ids';
|
||||
import type { RRError } from '../../domain/errors';
|
||||
import { RR_ERROR_CODES, createRRError } from '../../domain/errors';
|
||||
|
||||
/**
|
||||
* 截图结果
|
||||
*/
|
||||
export type ScreenshotResult = { ok: true; base64: string } | { ok: false; error: RRError };
|
||||
|
||||
/**
|
||||
* 工件服务接口
|
||||
* @description 提供工件获取和存储功能
|
||||
*/
|
||||
export interface ArtifactService {
|
||||
/**
|
||||
* 截取页面截图
|
||||
* @param tabId Tab ID
|
||||
* @param options 截图选项
|
||||
*/
|
||||
screenshot(
|
||||
tabId: number,
|
||||
options?: {
|
||||
format?: 'png' | 'jpeg';
|
||||
quality?: number;
|
||||
},
|
||||
): Promise<ScreenshotResult>;
|
||||
|
||||
/**
|
||||
* 保存截图
|
||||
* @param runId Run ID
|
||||
* @param nodeId Node ID
|
||||
* @param base64 截图数据
|
||||
* @param filename 文件名(可选)
|
||||
*/
|
||||
saveScreenshot(
|
||||
runId: RunId,
|
||||
nodeId: NodeId,
|
||||
base64: string,
|
||||
filename?: string,
|
||||
): Promise<{ savedAs: string } | { error: RRError }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 NotImplemented 的 ArtifactService
|
||||
* @description Phase 0-1 占位实现
|
||||
*/
|
||||
export function createNotImplementedArtifactService(): ArtifactService {
|
||||
return {
|
||||
screenshot: async () => ({
|
||||
ok: false,
|
||||
error: createRRError(RR_ERROR_CODES.INTERNAL, 'ArtifactService.screenshot not implemented'),
|
||||
}),
|
||||
saveScreenshot: async () => ({
|
||||
error: createRRError(
|
||||
RR_ERROR_CODES.INTERNAL,
|
||||
'ArtifactService.saveScreenshot not implemented',
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建基于 chrome.tabs.captureVisibleTab 的 ArtifactService
|
||||
* @description 使用 Chrome API 截取可见标签页
|
||||
*/
|
||||
export function createChromeArtifactService(): ArtifactService {
|
||||
// In-memory storage for screenshots (could be replaced with IndexedDB)
|
||||
const screenshotStore = new Map<string, string>();
|
||||
|
||||
return {
|
||||
screenshot: async (tabId, options) => {
|
||||
try {
|
||||
// Get the window ID for the tab
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (!tab.windowId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: createRRError(RR_ERROR_CODES.INTERNAL, `Tab ${tabId} has no window`),
|
||||
};
|
||||
}
|
||||
|
||||
// Capture the visible tab
|
||||
const format = options?.format ?? 'png';
|
||||
const quality = options?.quality ?? 100;
|
||||
|
||||
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
|
||||
format,
|
||||
quality: format === 'jpeg' ? quality : undefined,
|
||||
});
|
||||
|
||||
// Extract base64 from data URL
|
||||
const base64Match = dataUrl.match(/^data:image\/\w+;base64,(.+)$/);
|
||||
if (!base64Match) {
|
||||
return {
|
||||
ok: false,
|
||||
error: createRRError(RR_ERROR_CODES.INTERNAL, 'Invalid screenshot data URL'),
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, base64: base64Match[1] };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
ok: false,
|
||||
error: createRRError(RR_ERROR_CODES.INTERNAL, `Screenshot failed: ${message}`),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
saveScreenshot: async (runId, nodeId, base64, filename) => {
|
||||
try {
|
||||
// Generate filename if not provided
|
||||
const savedAs = filename ?? `${runId}_${nodeId}_${Date.now()}.png`;
|
||||
const key = `${runId}/${savedAs}`;
|
||||
|
||||
// Store in memory (in production, this would go to IndexedDB or cloud storage)
|
||||
screenshotStore.set(key, base64);
|
||||
|
||||
return { savedAs };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
error: createRRError(RR_ERROR_CODES.INTERNAL, `Save screenshot failed: ${message}`),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 工件策略执行器
|
||||
* @description 根据策略配置决定是否获取工件
|
||||
*/
|
||||
export interface ArtifactPolicyExecutor {
|
||||
/**
|
||||
* 执行截图策略
|
||||
* @param policy 截图策略
|
||||
* @param context 上下文
|
||||
*/
|
||||
executeScreenshotPolicy(
|
||||
policy: 'never' | 'onFailure' | 'always',
|
||||
context: {
|
||||
tabId: number;
|
||||
runId: RunId;
|
||||
nodeId: NodeId;
|
||||
failed: boolean;
|
||||
saveAs?: string;
|
||||
},
|
||||
): Promise<{ captured: boolean; savedAs?: string; error?: RRError }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认的工件策略执行器
|
||||
*/
|
||||
export function createArtifactPolicyExecutor(service: ArtifactService): ArtifactPolicyExecutor {
|
||||
return {
|
||||
executeScreenshotPolicy: async (policy, context) => {
|
||||
// 根据策略决定是否截图
|
||||
const shouldCapture = policy === 'always' || (policy === 'onFailure' && context.failed);
|
||||
|
||||
if (!shouldCapture) {
|
||||
return { captured: false };
|
||||
}
|
||||
|
||||
// 截图
|
||||
const result = await service.screenshot(context.tabId);
|
||||
if (!result.ok) {
|
||||
return { captured: false, error: result.error };
|
||||
}
|
||||
|
||||
// 保存(如果指定了文件名)
|
||||
if (context.saveAs) {
|
||||
const saveResult = await service.saveScreenshot(
|
||||
context.runId,
|
||||
context.nodeId,
|
||||
result.base64,
|
||||
context.saveAs,
|
||||
);
|
||||
if ('error' in saveResult) {
|
||||
return { captured: true, error: saveResult.error };
|
||||
}
|
||||
return { captured: true, savedAs: saveResult.savedAs };
|
||||
}
|
||||
|
||||
return { captured: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* @fileoverview 断点管理器
|
||||
* @description 管理调试断点的添加、删除和命中检测
|
||||
*/
|
||||
|
||||
import type { NodeId, RunId } from '../../domain/ids';
|
||||
import type { Breakpoint, DebuggerState } from '../../domain/debug';
|
||||
|
||||
/**
|
||||
* 断点管理器
|
||||
* @description 管理单个 Run 的断点
|
||||
*/
|
||||
export class BreakpointManager {
|
||||
private breakpoints = new Map<NodeId, Breakpoint>();
|
||||
private stepMode: 'none' | 'stepOver' = 'none';
|
||||
|
||||
constructor(initialBreakpoints?: NodeId[]) {
|
||||
if (initialBreakpoints) {
|
||||
for (const nodeId of initialBreakpoints) {
|
||||
this.add(nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加断点
|
||||
*/
|
||||
add(nodeId: NodeId): void {
|
||||
this.breakpoints.set(nodeId, { nodeId, enabled: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除断点
|
||||
*/
|
||||
remove(nodeId: NodeId): void {
|
||||
this.breakpoints.delete(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置断点列表(替换所有现有断点)
|
||||
*/
|
||||
setAll(nodeIds: NodeId[]): void {
|
||||
this.breakpoints.clear();
|
||||
for (const nodeId of nodeIds) {
|
||||
this.add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用断点
|
||||
*/
|
||||
enable(nodeId: NodeId): void {
|
||||
const bp = this.breakpoints.get(nodeId);
|
||||
if (bp) {
|
||||
bp.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用断点
|
||||
*/
|
||||
disable(nodeId: NodeId): void {
|
||||
const bp = this.breakpoints.get(nodeId);
|
||||
if (bp) {
|
||||
bp.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查节点是否有启用的断点
|
||||
*/
|
||||
hasBreakpoint(nodeId: NodeId): boolean {
|
||||
const bp = this.breakpoints.get(nodeId);
|
||||
return bp?.enabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该在节点处暂停
|
||||
* @description 考虑断点和单步模式
|
||||
*/
|
||||
shouldPauseAt(nodeId: NodeId): boolean {
|
||||
// 如果在单步模式,总是暂停
|
||||
if (this.stepMode === 'stepOver') {
|
||||
return true;
|
||||
}
|
||||
// 否则检查断点
|
||||
return this.hasBreakpoint(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有断点
|
||||
*/
|
||||
getAll(): Breakpoint[] {
|
||||
return Array.from(this.breakpoints.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用的断点
|
||||
*/
|
||||
getEnabled(): Breakpoint[] {
|
||||
return this.getAll().filter((bp) => bp.enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置单步模式
|
||||
*/
|
||||
setStepMode(mode: 'none' | 'stepOver'): void {
|
||||
this.stepMode = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单步模式
|
||||
*/
|
||||
getStepMode(): 'none' | 'stepOver' {
|
||||
return this.stepMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有断点
|
||||
*/
|
||||
clear(): void {
|
||||
this.breakpoints.clear();
|
||||
this.stepMode = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断点管理器注册表
|
||||
* @description 管理多个 Run 的断点管理器
|
||||
*/
|
||||
export class BreakpointRegistry {
|
||||
private managers = new Map<RunId, BreakpointManager>();
|
||||
|
||||
/**
|
||||
* 获取或创建断点管理器
|
||||
*/
|
||||
getOrCreate(runId: RunId, initialBreakpoints?: NodeId[]): BreakpointManager {
|
||||
let manager = this.managers.get(runId);
|
||||
if (!manager) {
|
||||
manager = new BreakpointManager(initialBreakpoints);
|
||||
this.managers.set(runId, manager);
|
||||
}
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取断点管理器
|
||||
*/
|
||||
get(runId: RunId): BreakpointManager | undefined {
|
||||
return this.managers.get(runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除断点管理器
|
||||
*/
|
||||
remove(runId: RunId): void {
|
||||
this.managers.delete(runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有
|
||||
*/
|
||||
clear(): void {
|
||||
this.managers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局断点注册表 */
|
||||
let globalBreakpointRegistry: BreakpointRegistry | null = null;
|
||||
|
||||
/**
|
||||
* 获取全局断点注册表
|
||||
*/
|
||||
export function getBreakpointRegistry(): BreakpointRegistry {
|
||||
if (!globalBreakpointRegistry) {
|
||||
globalBreakpointRegistry = new BreakpointRegistry();
|
||||
}
|
||||
return globalBreakpointRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置全局断点注册表
|
||||
* @description 主要用于测试
|
||||
*/
|
||||
export function resetBreakpointRegistry(): void {
|
||||
globalBreakpointRegistry = null;
|
||||
}
|
||||
+485
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* @fileoverview Debug Controller
|
||||
* @description Central control plane for debugging - command routing, state aggregation, and UI push
|
||||
*/
|
||||
|
||||
import type { NodeId, RunId } from '../../domain/ids';
|
||||
import type { JsonValue } from '../../domain/json';
|
||||
import type { PauseReason, RunEvent, Unsubscribe } from '../../domain/events';
|
||||
import type {
|
||||
DebuggerCommand,
|
||||
DebuggerResponse,
|
||||
DebuggerState,
|
||||
Breakpoint,
|
||||
} from '../../domain/debug';
|
||||
import { createInitialDebuggerState } from '../../domain/debug';
|
||||
|
||||
import type { StoragePort } from '../storage/storage-port';
|
||||
import type { EventsBus } from '../transport/events-bus';
|
||||
import type { RunRunner } from './runner';
|
||||
import { BreakpointManager, getBreakpointRegistry } from './breakpoints';
|
||||
|
||||
/**
|
||||
* Runner registry for managing active runners
|
||||
*/
|
||||
export interface RunnerRegistry {
|
||||
get(runId: RunId): RunRunner | undefined;
|
||||
register(runId: RunId, runner: RunRunner): void;
|
||||
unregister(runId: RunId): void;
|
||||
list(): RunId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple runner registry
|
||||
*/
|
||||
export function createRunnerRegistry(): RunnerRegistry {
|
||||
const runners = new Map<RunId, RunRunner>();
|
||||
return {
|
||||
get: (runId) => runners.get(runId),
|
||||
register: (runId, runner) => runners.set(runId, runner),
|
||||
unregister: (runId) => runners.delete(runId),
|
||||
list: () => Array.from(runners.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug session state (per-run)
|
||||
*/
|
||||
interface DebugSession {
|
||||
runId: RunId;
|
||||
attached: boolean;
|
||||
lastPauseReason?: PauseReason;
|
||||
lastKnownNodeId?: NodeId;
|
||||
lastKnownExecution: 'running' | 'paused';
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug state listener
|
||||
*/
|
||||
type DebugStateListener = (state: DebuggerState) => void;
|
||||
|
||||
/**
|
||||
* Debug Controller Configuration
|
||||
*/
|
||||
export interface DebugControllerConfig {
|
||||
storage: StoragePort;
|
||||
events: EventsBus;
|
||||
runners: RunnerRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug Controller
|
||||
* @description Single entry point for all debug operations
|
||||
*/
|
||||
export class DebugController {
|
||||
private readonly storage: StoragePort;
|
||||
private readonly events: EventsBus;
|
||||
private readonly runners: RunnerRegistry;
|
||||
|
||||
private readonly sessions = new Map<RunId, DebugSession>();
|
||||
private readonly listeners = new Map<RunId | null, Set<DebugStateListener>>();
|
||||
private eventUnsubscribe: Unsubscribe | null = null;
|
||||
|
||||
constructor(config: DebugControllerConfig) {
|
||||
this.storage = config.storage;
|
||||
this.events = config.events;
|
||||
this.runners = config.runners;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the debug controller
|
||||
*/
|
||||
start(): void {
|
||||
// Subscribe to all events to track pause/resume state
|
||||
this.eventUnsubscribe = this.events.subscribe((event) => {
|
||||
this.handleEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the debug controller
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.eventUnsubscribe) {
|
||||
this.eventUnsubscribe();
|
||||
this.eventUnsubscribe = null;
|
||||
}
|
||||
this.sessions.clear();
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a debug command
|
||||
*/
|
||||
async handle(cmd: DebuggerCommand): Promise<DebuggerResponse> {
|
||||
try {
|
||||
switch (cmd.type) {
|
||||
case 'debug.attach':
|
||||
return this.handleAttach(cmd.runId);
|
||||
|
||||
case 'debug.detach':
|
||||
return this.handleDetach(cmd.runId);
|
||||
|
||||
case 'debug.pause':
|
||||
return this.handlePause(cmd.runId);
|
||||
|
||||
case 'debug.resume':
|
||||
return this.handleResume(cmd.runId);
|
||||
|
||||
case 'debug.stepOver':
|
||||
return this.handleStepOver(cmd.runId);
|
||||
|
||||
case 'debug.setBreakpoints':
|
||||
return this.handleSetBreakpoints(cmd.runId, cmd.nodeIds);
|
||||
|
||||
case 'debug.addBreakpoint':
|
||||
return this.handleAddBreakpoint(cmd.runId, cmd.nodeId);
|
||||
|
||||
case 'debug.removeBreakpoint':
|
||||
return this.handleRemoveBreakpoint(cmd.runId, cmd.nodeId);
|
||||
|
||||
case 'debug.getState':
|
||||
return this.handleGetState(cmd.runId);
|
||||
|
||||
case 'debug.getVar':
|
||||
return this.handleGetVar(cmd.runId, cmd.name);
|
||||
|
||||
case 'debug.setVar':
|
||||
return this.handleSetVar(cmd.runId, cmd.name, cmd.value);
|
||||
|
||||
default:
|
||||
return { ok: false, error: `Unknown debug command: ${(cmd as { type: string }).type}` };
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to debug state changes
|
||||
*/
|
||||
subscribe(listener: DebugStateListener, filter?: { runId?: RunId }): Unsubscribe {
|
||||
const key = filter?.runId ?? null;
|
||||
let set = this.listeners.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.listeners.set(key, set);
|
||||
}
|
||||
set.add(listener);
|
||||
|
||||
return () => {
|
||||
set?.delete(listener);
|
||||
if (set?.size === 0) {
|
||||
this.listeners.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current debug state for a run
|
||||
*/
|
||||
async getState(runId: RunId): Promise<DebuggerState> {
|
||||
const session = this.sessions.get(runId);
|
||||
const run = await this.storage.runs.get(runId);
|
||||
const bpManager = getBreakpointRegistry().get(runId);
|
||||
|
||||
const state: DebuggerState = {
|
||||
runId,
|
||||
status: session?.attached ? 'attached' : 'detached',
|
||||
execution: session?.lastKnownExecution ?? (run?.status === 'paused' ? 'paused' : 'running'),
|
||||
pauseReason: session?.lastPauseReason,
|
||||
currentNodeId: session?.lastKnownNodeId ?? run?.currentNodeId,
|
||||
breakpoints: bpManager?.getAll() ?? [],
|
||||
stepMode: bpManager?.getStepMode() ?? 'none',
|
||||
};
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
// ==================== Command Handlers ====================
|
||||
|
||||
private async handleAttach(runId: RunId): Promise<DebuggerResponse> {
|
||||
const run = await this.storage.runs.get(runId);
|
||||
if (!run) {
|
||||
return { ok: false, error: `Run "${runId}" not found` };
|
||||
}
|
||||
|
||||
// Create or update session
|
||||
let session = this.sessions.get(runId);
|
||||
if (!session) {
|
||||
session = {
|
||||
runId,
|
||||
attached: true,
|
||||
lastKnownExecution: run.status === 'paused' ? 'paused' : 'running',
|
||||
lastKnownNodeId: run.currentNodeId,
|
||||
};
|
||||
this.sessions.set(runId, session);
|
||||
} else {
|
||||
session.attached = true;
|
||||
}
|
||||
|
||||
// Get or create breakpoint manager
|
||||
getBreakpointRegistry().getOrCreate(runId, run.debug?.breakpoints);
|
||||
|
||||
const state = await this.getState(runId);
|
||||
this.notifyStateChange(runId, state);
|
||||
return { ok: true, state };
|
||||
}
|
||||
|
||||
private async handleDetach(runId: RunId): Promise<DebuggerResponse> {
|
||||
const session = this.sessions.get(runId);
|
||||
if (session) {
|
||||
session.attached = false;
|
||||
}
|
||||
|
||||
const state = await this.getState(runId);
|
||||
this.notifyStateChange(runId, state);
|
||||
return { ok: true, state };
|
||||
}
|
||||
|
||||
private async handlePause(runId: RunId): Promise<DebuggerResponse> {
|
||||
const runner = this.runners.get(runId);
|
||||
if (!runner) {
|
||||
return { ok: false, error: `Runner for "${runId}" not found` };
|
||||
}
|
||||
|
||||
runner.pause();
|
||||
const state = await this.getState(runId);
|
||||
return { ok: true, state };
|
||||
}
|
||||
|
||||
private async handleResume(runId: RunId): Promise<DebuggerResponse> {
|
||||
const runner = this.runners.get(runId);
|
||||
if (!runner) {
|
||||
return { ok: false, error: `Runner for "${runId}" not found` };
|
||||
}
|
||||
|
||||
runner.resume();
|
||||
const state = await this.getState(runId);
|
||||
return { ok: true, state };
|
||||
}
|
||||
|
||||
private async handleStepOver(runId: RunId): Promise<DebuggerResponse> {
|
||||
const runner = this.runners.get(runId);
|
||||
if (!runner) {
|
||||
return { ok: false, error: `Runner for "${runId}" not found` };
|
||||
}
|
||||
|
||||
// Set step mode to stepOver (will pause at next node)
|
||||
const bpManager = getBreakpointRegistry().getOrCreate(runId);
|
||||
bpManager.setStepMode('stepOver');
|
||||
|
||||
// Resume execution - runner will pause at next node due to stepOver mode
|
||||
runner.resume();
|
||||
|
||||
const state = await this.getState(runId);
|
||||
return { ok: true, state };
|
||||
}
|
||||
|
||||
private async handleSetBreakpoints(runId: RunId, nodeIds: NodeId[]): Promise<DebuggerResponse> {
|
||||
const bpManager = getBreakpointRegistry().getOrCreate(runId);
|
||||
bpManager.setAll(nodeIds);
|
||||
|
||||
// Persist breakpoints to run record
|
||||
await this.persistBreakpoints(runId, bpManager);
|
||||
|
||||
const state = await this.getState(runId);
|
||||
this.notifyStateChange(runId, state);
|
||||
return { ok: true, state };
|
||||
}
|
||||
|
||||
private async handleAddBreakpoint(runId: RunId, nodeId: NodeId): Promise<DebuggerResponse> {
|
||||
const bpManager = getBreakpointRegistry().getOrCreate(runId);
|
||||
bpManager.add(nodeId);
|
||||
|
||||
await this.persistBreakpoints(runId, bpManager);
|
||||
|
||||
const state = await this.getState(runId);
|
||||
this.notifyStateChange(runId, state);
|
||||
return { ok: true, state };
|
||||
}
|
||||
|
||||
private async handleRemoveBreakpoint(runId: RunId, nodeId: NodeId): Promise<DebuggerResponse> {
|
||||
const bpManager = getBreakpointRegistry().getOrCreate(runId);
|
||||
bpManager.remove(nodeId);
|
||||
|
||||
await this.persistBreakpoints(runId, bpManager);
|
||||
|
||||
const state = await this.getState(runId);
|
||||
this.notifyStateChange(runId, state);
|
||||
return { ok: true, state };
|
||||
}
|
||||
|
||||
private async handleGetState(runId: RunId): Promise<DebuggerResponse> {
|
||||
const state = await this.getState(runId);
|
||||
return { ok: true, state };
|
||||
}
|
||||
|
||||
private async handleGetVar(runId: RunId, name: string): Promise<DebuggerResponse> {
|
||||
// Try to get from active runner first
|
||||
const runner = this.runners.get(runId);
|
||||
if (runner) {
|
||||
const value = runner.getVar(name);
|
||||
return { ok: true, value: value ?? null };
|
||||
}
|
||||
|
||||
// Fallback: reconstruct from events
|
||||
const value = await this.reconstructVar(runId, name);
|
||||
return { ok: true, value: value ?? null };
|
||||
}
|
||||
|
||||
private async handleSetVar(
|
||||
runId: RunId,
|
||||
name: string,
|
||||
value: JsonValue,
|
||||
): Promise<DebuggerResponse> {
|
||||
const runner = this.runners.get(runId);
|
||||
if (!runner) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Runner for "${runId}" not found - cannot set variable on inactive run`,
|
||||
};
|
||||
}
|
||||
|
||||
runner.setVar(name, value);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ==================== Event Handling ====================
|
||||
|
||||
private handleEvent(event: RunEvent): void {
|
||||
const { runId } = event;
|
||||
let session = this.sessions.get(runId);
|
||||
|
||||
// Track pause/resume state
|
||||
if (event.type === 'run.paused') {
|
||||
if (!session) {
|
||||
session = {
|
||||
runId,
|
||||
attached: false,
|
||||
lastKnownExecution: 'paused',
|
||||
};
|
||||
this.sessions.set(runId, session);
|
||||
}
|
||||
session.lastKnownExecution = 'paused';
|
||||
session.lastPauseReason = event.reason;
|
||||
session.lastKnownNodeId = event.nodeId;
|
||||
} else if (event.type === 'run.resumed') {
|
||||
if (session) {
|
||||
session.lastKnownExecution = 'running';
|
||||
session.lastPauseReason = undefined;
|
||||
}
|
||||
} else if (event.type === 'run.started') {
|
||||
if (!session) {
|
||||
session = {
|
||||
runId,
|
||||
attached: false,
|
||||
lastKnownExecution: 'running',
|
||||
};
|
||||
this.sessions.set(runId, session);
|
||||
}
|
||||
} else if (
|
||||
event.type === 'run.succeeded' ||
|
||||
event.type === 'run.failed' ||
|
||||
event.type === 'run.canceled'
|
||||
) {
|
||||
// Run ended - keep session for querying but mark as not running
|
||||
if (session) {
|
||||
session.lastKnownExecution = 'running'; // Technically ended, but not paused
|
||||
}
|
||||
} else if (event.type === 'node.started') {
|
||||
if (session) {
|
||||
session.lastKnownNodeId = event.nodeId;
|
||||
}
|
||||
}
|
||||
|
||||
// Notify listeners if session is attached
|
||||
if (session?.attached) {
|
||||
void this.getState(runId).then((state) => {
|
||||
this.notifyStateChange(runId, state);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
private async persistBreakpoints(runId: RunId, bpManager: BreakpointManager): Promise<void> {
|
||||
const breakpoints = bpManager.getEnabled().map((bp) => bp.nodeId);
|
||||
try {
|
||||
await this.storage.runs.patch(runId, {
|
||||
debug: { breakpoints },
|
||||
});
|
||||
} catch {
|
||||
// Run may not exist yet - ignore persistence error
|
||||
}
|
||||
}
|
||||
|
||||
private async reconstructVar(runId: RunId, name: string): Promise<JsonValue | undefined> {
|
||||
// Get flow and run to reconstruct initial vars
|
||||
const run = await this.storage.runs.get(runId);
|
||||
if (!run) return undefined;
|
||||
|
||||
const flow = await this.storage.flows.get(run.flowId);
|
||||
if (!flow) return undefined;
|
||||
|
||||
// Build initial vars
|
||||
const vars: Record<string, JsonValue> = { ...(run.args ?? {}) };
|
||||
for (const def of flow.variables ?? []) {
|
||||
if (vars[def.name] === undefined && def.default !== undefined) {
|
||||
vars[def.name] = def.default;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply all vars.patch events
|
||||
const events = await this.storage.events.list(runId);
|
||||
for (const event of events) {
|
||||
if (event.type === 'vars.patch') {
|
||||
for (const op of event.patch) {
|
||||
if (op.op === 'set') {
|
||||
vars[op.name] = op.value ?? null;
|
||||
} else {
|
||||
delete vars[op.name];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return vars[name];
|
||||
}
|
||||
|
||||
private notifyStateChange(runId: RunId, state: DebuggerState): void {
|
||||
// Notify specific run listeners
|
||||
const runListeners = this.listeners.get(runId);
|
||||
if (runListeners) {
|
||||
for (const listener of runListeners) {
|
||||
try {
|
||||
listener(state);
|
||||
} catch (e) {
|
||||
console.error('[DebugController] Listener error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notify global listeners
|
||||
const globalListeners = this.listeners.get(null);
|
||||
if (globalListeners) {
|
||||
for (const listener of globalListeners) {
|
||||
try {
|
||||
listener(state);
|
||||
} catch (e) {
|
||||
console.error('[DebugController] Listener error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and start a debug controller
|
||||
*/
|
||||
export function createDebugController(config: DebugControllerConfig): DebugController {
|
||||
const controller = new DebugController(config);
|
||||
controller.start();
|
||||
return controller;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @fileoverview Kernel 模块导出入口
|
||||
*/
|
||||
|
||||
export * from './kernel';
|
||||
export * from './runner';
|
||||
export * from './traversal';
|
||||
export * from './breakpoints';
|
||||
export * from './artifacts';
|
||||
export * from './debug-controller';
|
||||
export * from './recovery-kernel';
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @fileoverview ExecutionKernel 接口定义
|
||||
* @description 定义 Record-Replay V3 的核心执行引擎接口
|
||||
*/
|
||||
|
||||
import type { JsonObject } from '../../domain/json';
|
||||
import type { FlowId, NodeId, RunId } from '../../domain/ids';
|
||||
import type { RRError } from '../../domain/errors';
|
||||
import type { FlowV3 } from '../../domain/flow';
|
||||
import type { DebuggerCommand, DebuggerState } from '../../domain/debug';
|
||||
import type { RunEvent, RunStatus, Unsubscribe } from '../../domain/events';
|
||||
|
||||
/**
|
||||
* Run 启动请求
|
||||
*/
|
||||
export interface RunStartRequest {
|
||||
/** Run ID(由调用方生成) */
|
||||
runId: RunId;
|
||||
/** Flow ID */
|
||||
flowId: FlowId;
|
||||
/** Flow 快照(执行时使用的完整 Flow 定义) */
|
||||
flowSnapshot: FlowV3;
|
||||
/** 运行参数 */
|
||||
args?: JsonObject;
|
||||
/** 起始节点 ID(默认为 Flow 的 entryNodeId) */
|
||||
startNodeId?: NodeId;
|
||||
/** Tab ID(必须由调用方分配,每 Run 独占) */
|
||||
tabId: number;
|
||||
/** 调试配置 */
|
||||
debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run 执行结果
|
||||
*/
|
||||
export interface RunResult {
|
||||
/** Run ID */
|
||||
runId: RunId;
|
||||
/** 最终状态 */
|
||||
status: Extract<RunStatus, 'succeeded' | 'failed' | 'canceled'>;
|
||||
/** 总耗时(毫秒) */
|
||||
tookMs: number;
|
||||
/** 错误信息(如果失败) */
|
||||
error?: RRError;
|
||||
/** 输出结果 */
|
||||
outputs?: JsonObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run 状态查询结果
|
||||
*/
|
||||
export interface RunStatusInfo {
|
||||
/** 当前状态 */
|
||||
status: RunStatus;
|
||||
/** 当前节点 ID */
|
||||
currentNodeId?: NodeId;
|
||||
/** 开始时间 */
|
||||
startedAt?: number;
|
||||
/** 最后更新时间 */
|
||||
updatedAt: number;
|
||||
/** Tab ID */
|
||||
tabId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* ExecutionKernel 接口
|
||||
* @description Record-Replay V3 的核心执行引擎
|
||||
*/
|
||||
export interface ExecutionKernel {
|
||||
/**
|
||||
* 订阅事件流
|
||||
* @param listener 事件监听器
|
||||
* @returns 取消订阅函数
|
||||
*/
|
||||
onEvent(listener: (event: RunEvent) => void): Unsubscribe;
|
||||
|
||||
/**
|
||||
* 启动 Run
|
||||
* @description 将 Run 加入队列并开始执行
|
||||
*/
|
||||
startRun(req: RunStartRequest): Promise<void>;
|
||||
|
||||
/**
|
||||
* 暂停 Run
|
||||
* @param runId Run ID
|
||||
* @param reason 暂停原因
|
||||
*/
|
||||
pauseRun(runId: RunId, reason?: { kind: 'command' }): Promise<void>;
|
||||
|
||||
/**
|
||||
* 恢复 Run
|
||||
* @param runId Run ID
|
||||
*/
|
||||
resumeRun(runId: RunId): Promise<void>;
|
||||
|
||||
/**
|
||||
* 取消 Run
|
||||
* @param runId Run ID
|
||||
* @param reason 取消原因
|
||||
*/
|
||||
cancelRun(runId: RunId, reason?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 执行调试命令
|
||||
* @param runId Run ID
|
||||
* @param cmd 调试命令
|
||||
*/
|
||||
debug(
|
||||
runId: RunId,
|
||||
cmd: DebuggerCommand,
|
||||
): Promise<{ ok: true; state?: DebuggerState } | { ok: false; error: string }>;
|
||||
|
||||
/**
|
||||
* 获取 Run 状态
|
||||
* @param runId Run ID
|
||||
* @returns Run 状态信息或 null(如果不存在)
|
||||
*/
|
||||
getRunStatus(runId: RunId): Promise<RunStatusInfo | null>;
|
||||
|
||||
/**
|
||||
* 恢复执行
|
||||
* @description 在 Service Worker 重启后调用,恢复中断的 Run
|
||||
*/
|
||||
recover(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 NotImplemented 的 ExecutionKernel
|
||||
* @description Phase 0 占位实现
|
||||
*/
|
||||
export function createNotImplementedKernel(): ExecutionKernel {
|
||||
const notImplemented = () => {
|
||||
throw new Error('ExecutionKernel not implemented');
|
||||
};
|
||||
|
||||
return {
|
||||
onEvent: () => {
|
||||
notImplemented();
|
||||
return () => {};
|
||||
},
|
||||
startRun: async () => notImplemented(),
|
||||
pauseRun: async () => notImplemented(),
|
||||
resumeRun: async () => notImplemented(),
|
||||
cancelRun: async () => notImplemented(),
|
||||
debug: async () => notImplemented(),
|
||||
getRunStatus: async () => notImplemented(),
|
||||
recover: async () => notImplemented(),
|
||||
};
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @fileoverview 支持崩溃恢复的 ExecutionKernel 实现 (P3-06)
|
||||
* @description
|
||||
* 提供 ExecutionKernel 的恢复增强实现,支持 `recover()` 方法。
|
||||
* 通过委托给 RecoveryCoordinator 实现崩溃恢复。
|
||||
*
|
||||
* 其他执行方法(startRun, pauseRun 等)暂未实现,将在后续阶段完成。
|
||||
*/
|
||||
|
||||
import type { UnixMillis } from '../../domain/json';
|
||||
import type { RunId } from '../../domain/ids';
|
||||
import type { DebuggerCommand, DebuggerState } from '../../domain/debug';
|
||||
|
||||
import type { StoragePort } from '../storage/storage-port';
|
||||
import type { EventsBus } from '../transport/events-bus';
|
||||
import { recoverFromCrash } from '../recovery/recovery-coordinator';
|
||||
|
||||
import type { ExecutionKernel, RunStartRequest, RunStatusInfo } from './kernel';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
/**
|
||||
* 支持恢复的 Kernel 依赖
|
||||
*/
|
||||
export interface RecoveryEnabledKernelDeps {
|
||||
/** 存储层 */
|
||||
storage: StoragePort;
|
||||
/** 事件总线 */
|
||||
events: EventsBus;
|
||||
/** 当前 Service Worker 的 ownerId */
|
||||
ownerId: string;
|
||||
/** 时间源 */
|
||||
now?: () => UnixMillis;
|
||||
/** 日志器 */
|
||||
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
}
|
||||
|
||||
// ==================== Factory ====================
|
||||
|
||||
/**
|
||||
* 创建支持恢复的 ExecutionKernel
|
||||
* @description
|
||||
* 此实现仅支持 `recover()` 和 `getRunStatus()` 方法。
|
||||
* 其他执行方法暂未实现,将在后续阶段完成。
|
||||
*/
|
||||
export function createRecoveryEnabledKernel(deps: RecoveryEnabledKernelDeps): ExecutionKernel {
|
||||
const logger = deps.logger ?? console;
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
|
||||
if (!deps.ownerId) {
|
||||
throw new Error('ownerId is required');
|
||||
}
|
||||
|
||||
const notImplemented = (name: string): never => {
|
||||
throw new Error(`ExecutionKernel.${name} not implemented`);
|
||||
};
|
||||
|
||||
return {
|
||||
onEvent: (listener) => deps.events.subscribe(listener),
|
||||
|
||||
startRun: async (_req: RunStartRequest) => notImplemented('startRun'),
|
||||
pauseRun: async (_runId: RunId) => notImplemented('pauseRun'),
|
||||
resumeRun: async (_runId: RunId) => notImplemented('resumeRun'),
|
||||
cancelRun: async (_runId: RunId) => notImplemented('cancelRun'),
|
||||
|
||||
debug: async (
|
||||
_runId: RunId,
|
||||
_cmd: DebuggerCommand,
|
||||
): Promise<{ ok: true; state?: DebuggerState } | { ok: false; error: string }> => {
|
||||
return { ok: false, error: 'ExecutionKernel.debug not configured' };
|
||||
},
|
||||
|
||||
getRunStatus: async (runId: RunId): Promise<RunStatusInfo | null> => {
|
||||
const run = await deps.storage.runs.get(runId);
|
||||
if (!run) return null;
|
||||
return {
|
||||
status: run.status,
|
||||
currentNodeId: run.currentNodeId,
|
||||
startedAt: run.startedAt,
|
||||
updatedAt: run.updatedAt,
|
||||
tabId: run.tabId,
|
||||
};
|
||||
},
|
||||
|
||||
recover: async (): Promise<void> => {
|
||||
logger.info('[RecoveryKernel] Starting crash recovery...');
|
||||
const result = await recoverFromCrash({
|
||||
storage: deps.storage,
|
||||
events: deps.events,
|
||||
ownerId: deps.ownerId,
|
||||
now,
|
||||
logger,
|
||||
});
|
||||
logger.info('[RecoveryKernel] Recovery complete:', result);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,893 @@
|
||||
/**
|
||||
* @fileoverview RunRunner 接口和实现
|
||||
* @description 定义和实现单个 Run 的顺序执行器
|
||||
*/
|
||||
|
||||
import type { NodeId, RunId } from '../../domain/ids';
|
||||
import { EDGE_LABELS } from '../../domain/ids';
|
||||
import type { FlowV3, NodeV3 } from '../../domain/flow';
|
||||
import { findNodeById } from '../../domain/flow';
|
||||
import type {
|
||||
PauseReason,
|
||||
RunEvent,
|
||||
RunEventInput,
|
||||
RunRecordV3,
|
||||
Unsubscribe,
|
||||
} from '../../domain/events';
|
||||
import { RUN_SCHEMA_VERSION } from '../../domain/events';
|
||||
import type { JsonObject, JsonValue } from '../../domain/json';
|
||||
import { RR_ERROR_CODES, createRRError, type RRError } from '../../domain/errors';
|
||||
import type { NodePolicy, RetryPolicy } from '../../domain/policy';
|
||||
import { mergeNodePolicy } from '../../domain/policy';
|
||||
|
||||
import type { EventsBus } from '../transport/events-bus';
|
||||
import type { StoragePort } from '../storage/storage-port';
|
||||
import type { PluginRegistry } from '../plugins/registry';
|
||||
import { getPluginRegistry } from '../plugins/registry';
|
||||
import type { NodeExecutionContext, NodeExecutionResult, VarsPatchOp } from '../plugins/types';
|
||||
|
||||
import type { ArtifactService } from './artifacts';
|
||||
import { createNotImplementedArtifactService } from './artifacts';
|
||||
import { getBreakpointRegistry, type BreakpointManager } from './breakpoints';
|
||||
import { findEdgeByLabel, findNextNode, validateFlowDAG } from './traversal';
|
||||
import type { RunResult } from './kernel';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
/**
|
||||
* RunRunner 运行时状态
|
||||
*/
|
||||
export interface RunnerRuntimeState {
|
||||
/** Run ID */
|
||||
runId: RunId;
|
||||
/** 当前节点 ID */
|
||||
currentNodeId: NodeId | null;
|
||||
/** 当前尝试次数 */
|
||||
attempt: number;
|
||||
/** 变量表 */
|
||||
vars: Record<string, JsonValue>;
|
||||
/** 是否暂停 */
|
||||
paused: boolean;
|
||||
/** 是否取消 */
|
||||
canceled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* RunRunner 配置
|
||||
*/
|
||||
export interface RunnerConfig {
|
||||
/** Flow 快照 */
|
||||
flow: FlowV3;
|
||||
/** Tab ID */
|
||||
tabId: number;
|
||||
/** 初始参数 */
|
||||
args?: JsonObject;
|
||||
/** 起始节点 ID */
|
||||
startNodeId?: NodeId;
|
||||
/** 调试配置 */
|
||||
debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* RunRunner 接口
|
||||
*/
|
||||
export interface RunRunner {
|
||||
/** Run ID */
|
||||
readonly runId: RunId;
|
||||
/** 当前状态 */
|
||||
readonly state: RunnerRuntimeState;
|
||||
/** 订阅事件 */
|
||||
onEvent(listener: (event: RunEvent) => void): Unsubscribe;
|
||||
/** 开始执行 */
|
||||
start(): Promise<RunResult>;
|
||||
/** 暂停执行 */
|
||||
pause(): void;
|
||||
/** 恢复执行 */
|
||||
resume(): void;
|
||||
/** 取消执行 */
|
||||
cancel(reason?: string): void;
|
||||
/** 获取变量值 */
|
||||
getVar(name: string): JsonValue | undefined;
|
||||
/** 设置变量值 */
|
||||
setVar(name: string, value: JsonValue): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* RunRunner 工厂接口
|
||||
*/
|
||||
export interface RunRunnerFactory {
|
||||
create(runId: RunId, config: RunnerConfig): RunRunner;
|
||||
}
|
||||
|
||||
/**
|
||||
* RunRunner 工厂依赖
|
||||
*/
|
||||
export interface RunRunnerFactoryDeps {
|
||||
storage: StoragePort;
|
||||
events: EventsBus;
|
||||
plugins?: PluginRegistry;
|
||||
artifactService?: ArtifactService;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (err && typeof err === 'object' && 'message' in err)
|
||||
return String((err as { message: unknown }).message);
|
||||
return String(err);
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
p: Promise<T>,
|
||||
ms: number | undefined,
|
||||
onTimeout: () => RRError,
|
||||
): Promise<T> {
|
||||
if (ms === undefined || !Number.isFinite(ms) || ms <= 0) {
|
||||
return p;
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
p,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(onTimeout()), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function computeRetryDelayMs(policy: RetryPolicy, attempt: number): number {
|
||||
const base = Math.max(0, policy.intervalMs);
|
||||
let delay = base;
|
||||
const backoff = policy.backoff ?? 'none';
|
||||
|
||||
if (backoff === 'linear') {
|
||||
delay = base * attempt;
|
||||
} else if (backoff === 'exp') {
|
||||
delay = base * Math.pow(2, Math.max(0, attempt - 1));
|
||||
}
|
||||
|
||||
if (policy.maxIntervalMs !== undefined) {
|
||||
delay = Math.min(delay, Math.max(0, policy.maxIntervalMs));
|
||||
}
|
||||
|
||||
if (policy.jitter === 'full') {
|
||||
delay = Math.floor(Math.random() * (delay + 1));
|
||||
}
|
||||
|
||||
return Math.max(0, Math.floor(delay));
|
||||
}
|
||||
|
||||
function applyVarsPatch(vars: Record<string, JsonValue>, patch: VarsPatchOp[]): void {
|
||||
for (const op of patch) {
|
||||
if (op.op === 'set') {
|
||||
vars[op.name] = op.value ?? null;
|
||||
} else {
|
||||
delete vars[op.name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toRRError(err: unknown, fallback: { code: string; message: string }): RRError {
|
||||
if (err && typeof err === 'object' && 'code' in err && 'message' in err) {
|
||||
return err as RRError;
|
||||
}
|
||||
return createRRError(
|
||||
fallback.code as RRError['code'],
|
||||
`${fallback.message}: ${errorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serial queue for write operations
|
||||
* Ensures event ordering and reduces write races
|
||||
*/
|
||||
class SerialQueue {
|
||||
private tail: Promise<void> = Promise.resolve();
|
||||
|
||||
run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const next = this.tail.then(fn, fn);
|
||||
this.tail = next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Factory ====================
|
||||
|
||||
/**
|
||||
* 创建 NotImplemented 的 RunRunnerFactory
|
||||
*/
|
||||
export function createNotImplementedRunnerFactory(): RunRunnerFactory {
|
||||
return {
|
||||
create: () => {
|
||||
throw new Error('RunRunnerFactory not implemented');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 RunRunner 工厂
|
||||
*/
|
||||
export function createRunRunnerFactory(deps: RunRunnerFactoryDeps): RunRunnerFactory {
|
||||
const plugins = deps.plugins ?? getPluginRegistry();
|
||||
const artifactService = deps.artifactService ?? createNotImplementedArtifactService();
|
||||
const now = deps.now ?? Date.now;
|
||||
|
||||
return {
|
||||
create: (runId, config) =>
|
||||
new StorageBackedRunRunner(runId, config, {
|
||||
storage: deps.storage,
|
||||
events: deps.events,
|
||||
plugins,
|
||||
artifactService,
|
||||
now,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Implementation ====================
|
||||
|
||||
interface RunnerEnv {
|
||||
storage: StoragePort;
|
||||
events: EventsBus;
|
||||
plugins: PluginRegistry;
|
||||
artifactService: ArtifactService;
|
||||
now: () => number;
|
||||
}
|
||||
|
||||
type OnErrorDecision =
|
||||
| { kind: 'stop' }
|
||||
| { kind: 'continue' }
|
||||
| {
|
||||
kind: 'goto';
|
||||
target: { kind: 'edgeLabel'; label: string } | { kind: 'node'; nodeId: NodeId };
|
||||
}
|
||||
| { kind: 'retry'; retryPolicy: RetryPolicy | null };
|
||||
|
||||
type NodeRunResult =
|
||||
| { nextNodeId: NodeId | null }
|
||||
| { terminal: 'failed'; error: RRError }
|
||||
| { terminal: 'canceled' };
|
||||
|
||||
/**
|
||||
* Storage-backed RunRunner implementation
|
||||
*/
|
||||
class StorageBackedRunRunner implements RunRunner {
|
||||
readonly runId: RunId;
|
||||
readonly state: RunnerRuntimeState;
|
||||
|
||||
private readonly config: RunnerConfig;
|
||||
private readonly env: RunnerEnv;
|
||||
private readonly queue = new SerialQueue();
|
||||
private readonly breakpoints: BreakpointManager;
|
||||
|
||||
private startPromise: Promise<RunResult> | null = null;
|
||||
private outputs: JsonObject = {};
|
||||
private cancelReason: string | undefined;
|
||||
private pauseWaiter: Deferred<void> | null = null;
|
||||
|
||||
constructor(runId: RunId, config: RunnerConfig, env: RunnerEnv) {
|
||||
this.runId = runId;
|
||||
this.config = config;
|
||||
this.env = env;
|
||||
|
||||
this.state = {
|
||||
runId,
|
||||
currentNodeId: null,
|
||||
attempt: 0,
|
||||
vars: this.buildInitialVars(),
|
||||
paused: false,
|
||||
canceled: false,
|
||||
};
|
||||
|
||||
this.breakpoints = getBreakpointRegistry().getOrCreate(runId, config.debug?.breakpoints);
|
||||
}
|
||||
|
||||
onEvent(listener: (event: RunEvent) => void): Unsubscribe {
|
||||
return this.env.events.subscribe(listener, { runId: this.runId });
|
||||
}
|
||||
|
||||
start(): Promise<RunResult> {
|
||||
if (!this.startPromise) {
|
||||
this.startPromise = this.run();
|
||||
}
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.requestPause({ kind: 'command' });
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (!this.state.paused) return;
|
||||
this.state.paused = false;
|
||||
this.pauseWaiter?.resolve(undefined);
|
||||
this.pauseWaiter = null;
|
||||
|
||||
void this.queue
|
||||
.run(async () => {
|
||||
await this.env.storage.runs.patch(this.runId, { status: 'running' });
|
||||
await this.env.events.append({ runId: this.runId, type: 'run.resumed' } as RunEventInput);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('[RunRunner] resume persistence failed:', e);
|
||||
});
|
||||
}
|
||||
|
||||
cancel(reason?: string): void {
|
||||
if (this.state.canceled) return;
|
||||
this.state.canceled = true;
|
||||
this.cancelReason = reason;
|
||||
|
||||
if (this.state.paused) {
|
||||
this.state.paused = false;
|
||||
this.pauseWaiter?.resolve(undefined);
|
||||
this.pauseWaiter = null;
|
||||
}
|
||||
}
|
||||
|
||||
getVar(name: string): JsonValue | undefined {
|
||||
return this.state.vars[name];
|
||||
}
|
||||
|
||||
setVar(name: string, value: JsonValue): void {
|
||||
this.state.vars[name] = value;
|
||||
|
||||
// Best-effort: emit vars.patch event
|
||||
void this.queue
|
||||
.run(() =>
|
||||
this.env.events.append({
|
||||
runId: this.runId,
|
||||
type: 'vars.patch',
|
||||
patch: [{ op: 'set', name, value }],
|
||||
} as RunEventInput),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ==================== Private Methods ====================
|
||||
|
||||
private buildInitialVars(): Record<string, JsonValue> {
|
||||
const vars: Record<string, JsonValue> = { ...(this.config.args ?? {}) };
|
||||
for (const def of this.config.flow.variables ?? []) {
|
||||
if (vars[def.name] === undefined && def.default !== undefined) {
|
||||
vars[def.name] = def.default;
|
||||
}
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
private requestPause(reason: PauseReason): void {
|
||||
if (this.state.canceled) return;
|
||||
if (this.state.paused) return;
|
||||
|
||||
this.state.paused = true;
|
||||
if (!this.pauseWaiter) {
|
||||
this.pauseWaiter = createDeferred<void>();
|
||||
}
|
||||
|
||||
const nodeId = this.state.currentNodeId ?? undefined;
|
||||
void this.queue
|
||||
.run(async () => {
|
||||
await this.env.storage.runs.patch(this.runId, {
|
||||
status: 'paused',
|
||||
...(nodeId ? { currentNodeId: nodeId } : {}),
|
||||
});
|
||||
await this.env.events.append({
|
||||
runId: this.runId,
|
||||
type: 'run.paused',
|
||||
reason,
|
||||
...(nodeId ? { nodeId } : {}),
|
||||
} as RunEventInput);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('[RunRunner] pause persistence failed:', e);
|
||||
});
|
||||
}
|
||||
|
||||
private async waitIfPaused(): Promise<void> {
|
||||
while (this.state.paused && !this.state.canceled) {
|
||||
if (!this.pauseWaiter) {
|
||||
this.pauseWaiter = createDeferred<void>();
|
||||
}
|
||||
await this.pauseWaiter.promise;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRunRecord(startNodeId: NodeId, startedAt: number): Promise<void> {
|
||||
await this.queue.run(async () => {
|
||||
const existing = await this.env.storage.runs.get(this.runId);
|
||||
if (!existing) {
|
||||
const record: RunRecordV3 = {
|
||||
schemaVersion: RUN_SCHEMA_VERSION,
|
||||
id: this.runId,
|
||||
flowId: this.config.flow.id,
|
||||
status: 'running',
|
||||
createdAt: startedAt,
|
||||
updatedAt: startedAt,
|
||||
startedAt,
|
||||
tabId: this.config.tabId,
|
||||
startNodeId: this.config.startNodeId,
|
||||
currentNodeId: startNodeId,
|
||||
attempt: 0,
|
||||
maxAttempts: 1,
|
||||
args: this.config.args,
|
||||
debug: this.config.debug,
|
||||
nextSeq: 1,
|
||||
};
|
||||
await this.env.storage.runs.save(record);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isSafeInteger(existing.nextSeq) || existing.nextSeq < 0) {
|
||||
throw createRRError(
|
||||
RR_ERROR_CODES.INVARIANT_VIOLATION,
|
||||
`Invalid nextSeq for run "${this.runId}": ${String(existing.nextSeq)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const patch: Partial<RunRecordV3> = {
|
||||
status: 'running',
|
||||
tabId: this.config.tabId,
|
||||
currentNodeId: startNodeId,
|
||||
};
|
||||
if (existing.startedAt === undefined) patch.startedAt = startedAt;
|
||||
if (this.config.startNodeId !== undefined) patch.startNodeId = this.config.startNodeId;
|
||||
if (this.config.args !== undefined) patch.args = this.config.args;
|
||||
if (this.config.debug !== undefined) patch.debug = this.config.debug;
|
||||
await this.env.storage.runs.patch(this.runId, patch);
|
||||
});
|
||||
}
|
||||
|
||||
private async run(): Promise<RunResult> {
|
||||
const startedAt = this.env.now();
|
||||
const { flow } = this.config;
|
||||
|
||||
const startNodeId = (this.config.startNodeId ?? flow.entryNodeId) as NodeId;
|
||||
|
||||
// Ensure Run record exists FIRST (before DAG validation)
|
||||
// so that finishFailed can safely patch the record
|
||||
await this.ensureRunRecord(startNodeId, startedAt);
|
||||
|
||||
// Validate DAG
|
||||
const validation = validateFlowDAG(flow);
|
||||
if (!validation.ok) {
|
||||
const error =
|
||||
validation.errors[0] ?? createRRError(RR_ERROR_CODES.DAG_INVALID, 'Invalid DAG');
|
||||
return this.finishFailed(startedAt, error, undefined);
|
||||
}
|
||||
|
||||
if (this.state.canceled) {
|
||||
return this.finishCanceled(startedAt);
|
||||
}
|
||||
|
||||
// Emit run.started
|
||||
await this.queue.run(() =>
|
||||
this.env.events.append({
|
||||
runId: this.runId,
|
||||
type: 'run.started',
|
||||
flowId: flow.id,
|
||||
tabId: this.config.tabId,
|
||||
} as RunEventInput),
|
||||
);
|
||||
|
||||
// Handle pauseOnStart
|
||||
if (this.config.debug?.pauseOnStart) {
|
||||
this.requestPause({ kind: 'policy', nodeId: startNodeId, reason: 'pauseOnStart' });
|
||||
}
|
||||
|
||||
// Main execution loop
|
||||
let currentNodeId: NodeId | null = startNodeId;
|
||||
while (currentNodeId) {
|
||||
this.state.currentNodeId = currentNodeId;
|
||||
|
||||
// Only update currentNodeId, not status (to preserve paused state)
|
||||
const nodeIdToUpdate = currentNodeId; // Capture for closure
|
||||
await this.queue.run(() =>
|
||||
this.env.storage.runs.patch(this.runId, { currentNodeId: nodeIdToUpdate }),
|
||||
);
|
||||
|
||||
if (this.state.canceled) break;
|
||||
await this.waitIfPaused();
|
||||
if (this.state.canceled) break;
|
||||
|
||||
const node = findNodeById(flow, currentNodeId);
|
||||
if (!node) {
|
||||
const error = createRRError(
|
||||
RR_ERROR_CODES.DAG_INVALID,
|
||||
`Node "${currentNodeId}" not found in flow`,
|
||||
);
|
||||
return this.finishFailed(startedAt, error, currentNodeId);
|
||||
}
|
||||
|
||||
// Skip disabled nodes
|
||||
if (node.disabled) {
|
||||
await this.queue.run(() =>
|
||||
this.env.events.append({
|
||||
runId: this.runId,
|
||||
type: 'node.skipped',
|
||||
nodeId: node.id,
|
||||
reason: 'disabled',
|
||||
} as RunEventInput),
|
||||
);
|
||||
currentNodeId = findNextNode(flow, node.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check breakpoints
|
||||
if (this.breakpoints.shouldPauseAt(node.id)) {
|
||||
const reason: PauseReason =
|
||||
this.breakpoints.getStepMode() === 'stepOver'
|
||||
? { kind: 'step', nodeId: node.id }
|
||||
: { kind: 'breakpoint', nodeId: node.id };
|
||||
|
||||
// Clear step mode after hitting (to avoid infinite pause loop)
|
||||
if (this.breakpoints.getStepMode() === 'stepOver') {
|
||||
this.breakpoints.setStepMode('none');
|
||||
}
|
||||
|
||||
this.requestPause(reason);
|
||||
await this.waitIfPaused();
|
||||
// After resume, proceed to execute the node (don't continue loop)
|
||||
}
|
||||
|
||||
// Emit node.queued
|
||||
await this.queue.run(() =>
|
||||
this.env.events.append({
|
||||
runId: this.runId,
|
||||
type: 'node.queued',
|
||||
nodeId: node.id,
|
||||
} as RunEventInput),
|
||||
);
|
||||
|
||||
// Execute node
|
||||
const nodeStartAt = this.env.now();
|
||||
const next = await this.runNode(flow, node, nodeStartAt);
|
||||
if ('terminal' in next) {
|
||||
if (next.terminal === 'canceled') break;
|
||||
if (next.terminal === 'failed') {
|
||||
return this.finishFailed(startedAt, next.error, node.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
currentNodeId = next.nextNodeId;
|
||||
}
|
||||
|
||||
if (this.state.canceled) {
|
||||
return this.finishCanceled(startedAt);
|
||||
}
|
||||
|
||||
return this.finishSucceeded(startedAt);
|
||||
}
|
||||
|
||||
private async runNode(flow: FlowV3, node: NodeV3, nodeStartAt: number): Promise<NodeRunResult> {
|
||||
let attempt = 1;
|
||||
|
||||
for (;;) {
|
||||
if (this.state.canceled) return { terminal: 'canceled' };
|
||||
await this.waitIfPaused();
|
||||
if (this.state.canceled) return { terminal: 'canceled' };
|
||||
|
||||
this.state.attempt = attempt;
|
||||
|
||||
// Emit node.started
|
||||
await this.queue.run(() =>
|
||||
this.env.events.append({
|
||||
runId: this.runId,
|
||||
type: 'node.started',
|
||||
nodeId: node.id,
|
||||
attempt,
|
||||
} as RunEventInput),
|
||||
);
|
||||
|
||||
const exec = await this.executeNodeAttempt(flow, node);
|
||||
if (exec.status === 'succeeded') {
|
||||
const tookMs = this.env.now() - nodeStartAt;
|
||||
|
||||
// Apply vars patch
|
||||
if (exec.varsPatch && exec.varsPatch.length > 0) {
|
||||
applyVarsPatch(this.state.vars, exec.varsPatch);
|
||||
await this.queue.run(() =>
|
||||
this.env.events.append({
|
||||
runId: this.runId,
|
||||
type: 'vars.patch',
|
||||
patch: exec.varsPatch,
|
||||
} as RunEventInput),
|
||||
);
|
||||
}
|
||||
|
||||
// Merge outputs
|
||||
if (exec.outputs) {
|
||||
this.outputs = { ...this.outputs, ...exec.outputs };
|
||||
}
|
||||
|
||||
// Emit node.succeeded
|
||||
await this.queue.run(() =>
|
||||
this.env.events.append({
|
||||
runId: this.runId,
|
||||
type: 'node.succeeded',
|
||||
nodeId: node.id,
|
||||
tookMs,
|
||||
...(exec.next ? { next: exec.next } : {}),
|
||||
} as RunEventInput),
|
||||
);
|
||||
|
||||
if (exec.next?.kind === 'end') {
|
||||
return { nextNodeId: null };
|
||||
}
|
||||
|
||||
const label = exec.next?.kind === 'edgeLabel' ? exec.next.label : undefined;
|
||||
return { nextNodeId: findNextNode(flow, node.id, label) };
|
||||
}
|
||||
|
||||
// Handle failure
|
||||
const error = exec.error;
|
||||
const policy = this.resolveNodePolicy(flow, node);
|
||||
const decision = this.decideOnError(flow, node, policy, error);
|
||||
|
||||
// Emit node.failed
|
||||
await this.queue.run(() =>
|
||||
this.env.events.append({
|
||||
runId: this.runId,
|
||||
type: 'node.failed',
|
||||
nodeId: node.id,
|
||||
attempt,
|
||||
error,
|
||||
decision: decision.kind,
|
||||
} as RunEventInput),
|
||||
);
|
||||
|
||||
if (decision.kind === 'retry' && decision.retryPolicy) {
|
||||
const maxAttempts = 1 + Math.max(0, decision.retryPolicy.retries);
|
||||
const canRetry =
|
||||
attempt < maxAttempts &&
|
||||
(decision.retryPolicy.retryOn
|
||||
? decision.retryPolicy.retryOn.includes(
|
||||
error.code as (typeof decision.retryPolicy.retryOn)[number],
|
||||
)
|
||||
: true);
|
||||
|
||||
if (!canRetry) {
|
||||
return { terminal: 'failed', error };
|
||||
}
|
||||
|
||||
const delay = computeRetryDelayMs(decision.retryPolicy, attempt);
|
||||
if (delay > 0) {
|
||||
await sleep(delay);
|
||||
}
|
||||
attempt++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (decision.kind === 'continue') {
|
||||
return { nextNodeId: findNextNode(flow, node.id) };
|
||||
}
|
||||
|
||||
if (decision.kind === 'goto') {
|
||||
if (decision.target.kind === 'node') {
|
||||
return { nextNodeId: decision.target.nodeId };
|
||||
}
|
||||
return { nextNodeId: findNextNode(flow, node.id, decision.target.label) };
|
||||
}
|
||||
|
||||
return { terminal: 'failed', error };
|
||||
}
|
||||
}
|
||||
|
||||
private resolveNodePolicy(flow: FlowV3, node: NodeV3): NodePolicy {
|
||||
const def = this.env.plugins.getNode(node.kind);
|
||||
const flowDefault = flow.policy?.defaultNodePolicy;
|
||||
const pluginDefault = def?.defaultPolicy;
|
||||
const merged1 = mergeNodePolicy(flowDefault, pluginDefault);
|
||||
return mergeNodePolicy(merged1, node.policy);
|
||||
}
|
||||
|
||||
private decideOnError(
|
||||
flow: FlowV3,
|
||||
node: NodeV3,
|
||||
policy: NodePolicy,
|
||||
_error: RRError,
|
||||
): OnErrorDecision {
|
||||
const configured = policy.onError;
|
||||
|
||||
// Default: if there's an ON_ERROR edge, use it
|
||||
if (!configured) {
|
||||
const onErrorEdge = findEdgeByLabel(flow, node.id, EDGE_LABELS.ON_ERROR);
|
||||
if (onErrorEdge) {
|
||||
return { kind: 'goto', target: { kind: 'edgeLabel', label: EDGE_LABELS.ON_ERROR } };
|
||||
}
|
||||
return { kind: 'stop' };
|
||||
}
|
||||
|
||||
if (configured.kind === 'stop') return { kind: 'stop' };
|
||||
if (configured.kind === 'continue') return { kind: 'continue' };
|
||||
if (configured.kind === 'goto') {
|
||||
return {
|
||||
kind: 'goto',
|
||||
target: configured.target as
|
||||
| { kind: 'edgeLabel'; label: string }
|
||||
| { kind: 'node'; nodeId: NodeId },
|
||||
};
|
||||
}
|
||||
|
||||
// retry
|
||||
const base: RetryPolicy = policy.retry ?? { retries: 1, intervalMs: 0 };
|
||||
const retryPolicy: RetryPolicy = configured.override
|
||||
? { ...base, ...configured.override }
|
||||
: base;
|
||||
return { kind: 'retry', retryPolicy };
|
||||
}
|
||||
|
||||
private async executeNodeAttempt(flow: FlowV3, node: NodeV3): Promise<NodeExecutionResult> {
|
||||
const def = this.env.plugins.getNode(node.kind);
|
||||
if (!def) {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: createRRError(
|
||||
RR_ERROR_CODES.UNSUPPORTED_NODE,
|
||||
`Node kind "${node.kind}" is not registered`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let parsedConfig: unknown = node.config;
|
||||
try {
|
||||
parsedConfig = def.schema.parse(node.config);
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: createRRError(
|
||||
RR_ERROR_CODES.VALIDATION_ERROR,
|
||||
`Invalid node config: ${errorMessage(e)}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const ctx: NodeExecutionContext = {
|
||||
runId: this.runId,
|
||||
flow,
|
||||
nodeId: node.id,
|
||||
tabId: this.config.tabId,
|
||||
vars: this.state.vars,
|
||||
log: (level, message, data) => {
|
||||
void this.queue
|
||||
.run(() =>
|
||||
this.env.events.append({
|
||||
runId: this.runId,
|
||||
type: 'log',
|
||||
level,
|
||||
message,
|
||||
...(data !== undefined ? { data } : {}),
|
||||
} as RunEventInput),
|
||||
)
|
||||
.catch(() => {});
|
||||
},
|
||||
chooseNext: (label) => ({ kind: 'edgeLabel', label }),
|
||||
artifacts: {
|
||||
screenshot: () => this.env.artifactService.screenshot(this.config.tabId),
|
||||
},
|
||||
persistent: {
|
||||
get: async (name) => (await this.env.storage.persistentVars.get(name))?.value,
|
||||
set: async (name, value) => {
|
||||
await this.env.storage.persistentVars.set(name, value);
|
||||
},
|
||||
delete: async (name) => {
|
||||
await this.env.storage.persistentVars.delete(name);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const policy = this.resolveNodePolicy(flow, node);
|
||||
const timeoutMs = policy.timeout?.ms;
|
||||
const scope = policy.timeout?.scope ?? 'attempt';
|
||||
const attemptTimeoutMs = scope === 'attempt' && timeoutMs !== undefined ? timeoutMs : undefined;
|
||||
|
||||
try {
|
||||
const nodeWithConfig = { ...node, config: parsedConfig } as Parameters<typeof def.execute>[1];
|
||||
const execPromise = def.execute(ctx, nodeWithConfig);
|
||||
const result = await withTimeout(execPromise, attemptTimeoutMs, () =>
|
||||
createRRError(RR_ERROR_CODES.TIMEOUT, `Node "${node.id}" timed out`),
|
||||
);
|
||||
return result;
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: toRRError(e, { code: RR_ERROR_CODES.INTERNAL, message: 'Node execution threw' }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async finishSucceeded(startedAt: number): Promise<RunResult> {
|
||||
const tookMs = this.env.now() - startedAt;
|
||||
await this.queue.run(async () => {
|
||||
await this.env.storage.runs.patch(this.runId, {
|
||||
status: 'succeeded',
|
||||
finishedAt: this.env.now(),
|
||||
tookMs,
|
||||
outputs: this.outputs,
|
||||
});
|
||||
await this.env.events.append({
|
||||
runId: this.runId,
|
||||
type: 'run.succeeded',
|
||||
tookMs,
|
||||
outputs: this.outputs,
|
||||
} as RunEventInput);
|
||||
});
|
||||
|
||||
return { runId: this.runId, status: 'succeeded', tookMs, outputs: this.outputs };
|
||||
}
|
||||
|
||||
private async finishFailed(
|
||||
startedAt: number,
|
||||
error: RRError,
|
||||
nodeId?: NodeId,
|
||||
): Promise<RunResult> {
|
||||
const tookMs = this.env.now() - startedAt;
|
||||
await this.queue.run(async () => {
|
||||
await this.env.storage.runs.patch(this.runId, {
|
||||
status: 'failed',
|
||||
finishedAt: this.env.now(),
|
||||
tookMs,
|
||||
error,
|
||||
...(nodeId ? { currentNodeId: nodeId } : {}),
|
||||
});
|
||||
await this.env.events.append({
|
||||
runId: this.runId,
|
||||
type: 'run.failed',
|
||||
error,
|
||||
...(nodeId ? { nodeId } : {}),
|
||||
} as RunEventInput);
|
||||
});
|
||||
|
||||
return { runId: this.runId, status: 'failed', tookMs, error };
|
||||
}
|
||||
|
||||
private async finishCanceled(startedAt: number): Promise<RunResult> {
|
||||
const tookMs = this.env.now() - startedAt;
|
||||
await this.queue.run(async () => {
|
||||
await this.env.storage.runs.patch(this.runId, {
|
||||
status: 'canceled',
|
||||
finishedAt: this.env.now(),
|
||||
tookMs,
|
||||
});
|
||||
await this.env.events.append({
|
||||
runId: this.runId,
|
||||
type: 'run.canceled',
|
||||
...(this.cancelReason ? { reason: this.cancelReason } : {}),
|
||||
} as RunEventInput);
|
||||
});
|
||||
|
||||
return { runId: this.runId, status: 'canceled', tookMs };
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* @fileoverview DAG 遍历和校验
|
||||
* @description 提供 Flow DAG 的校验、遍历和下一节点查找功能
|
||||
*/
|
||||
|
||||
import type { NodeId, EdgeLabel } from '../../domain/ids';
|
||||
import type { FlowV3, EdgeV3 } from '../../domain/flow';
|
||||
import { EDGE_LABELS } from '../../domain/ids';
|
||||
import { RR_ERROR_CODES, createRRError, type RRError } from '../../domain/errors';
|
||||
|
||||
/**
|
||||
* DAG 校验结果
|
||||
*/
|
||||
export type ValidateFlowDAGResult = { ok: true } | { ok: false; errors: RRError[] };
|
||||
|
||||
/**
|
||||
* 校验 Flow DAG 结构
|
||||
* @param flow Flow 定义
|
||||
* @returns 校验结果
|
||||
*/
|
||||
export function validateFlowDAG(flow: FlowV3): ValidateFlowDAGResult {
|
||||
const errors: RRError[] = [];
|
||||
const nodeIds = new Set(flow.nodes.map((n) => n.id));
|
||||
|
||||
// 检查 entryNodeId 是否存在
|
||||
if (!nodeIds.has(flow.entryNodeId)) {
|
||||
errors.push(
|
||||
createRRError(
|
||||
RR_ERROR_CODES.DAG_INVALID,
|
||||
`Entry node "${flow.entryNodeId}" does not exist in flow`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 检查边引用的节点是否存在
|
||||
for (const edge of flow.edges) {
|
||||
if (!nodeIds.has(edge.from)) {
|
||||
errors.push(
|
||||
createRRError(
|
||||
RR_ERROR_CODES.DAG_INVALID,
|
||||
`Edge "${edge.id}" references non-existent source node "${edge.from}"`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!nodeIds.has(edge.to)) {
|
||||
errors.push(
|
||||
createRRError(
|
||||
RR_ERROR_CODES.DAG_INVALID,
|
||||
`Edge "${edge.id}" references non-existent target node "${edge.to}"`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查循环
|
||||
const cycle = detectCycle(flow);
|
||||
if (cycle) {
|
||||
errors.push(
|
||||
createRRError(RR_ERROR_CODES.DAG_CYCLE, `Cycle detected in flow: ${cycle.join(' -> ')}`),
|
||||
);
|
||||
}
|
||||
|
||||
return errors.length > 0 ? { ok: false, errors } : { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 DAG 中的循环
|
||||
* @param flow Flow 定义
|
||||
* @returns 循环路径(如果存在)或 null
|
||||
*/
|
||||
export function detectCycle(flow: FlowV3): NodeId[] | null {
|
||||
const adjacency = buildAdjacencyMap(flow);
|
||||
const visited = new Set<NodeId>();
|
||||
const recursionStack = new Set<NodeId>();
|
||||
const path: NodeId[] = [];
|
||||
|
||||
function dfs(nodeId: NodeId): boolean {
|
||||
visited.add(nodeId);
|
||||
recursionStack.add(nodeId);
|
||||
path.push(nodeId);
|
||||
|
||||
const neighbors = adjacency.get(nodeId) || [];
|
||||
for (const neighbor of neighbors) {
|
||||
if (!visited.has(neighbor)) {
|
||||
if (dfs(neighbor)) {
|
||||
return true;
|
||||
}
|
||||
} else if (recursionStack.has(neighbor)) {
|
||||
// 找到循环
|
||||
const cycleStart = path.indexOf(neighbor);
|
||||
path.push(neighbor); // 闭合循环
|
||||
path.splice(0, cycleStart); // 移除循环前的节点
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
path.pop();
|
||||
recursionStack.delete(nodeId);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const node of flow.nodes) {
|
||||
if (!visited.has(node.id)) {
|
||||
if (dfs(node.id)) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找下一个节点
|
||||
* @param flow Flow 定义
|
||||
* @param currentNodeId 当前节点 ID
|
||||
* @param label 边标签(可选,默认使用 default)
|
||||
* @returns 下一个节点 ID 或 null(如果没有后续节点)
|
||||
*/
|
||||
export function findNextNode(
|
||||
flow: FlowV3,
|
||||
currentNodeId: NodeId,
|
||||
label?: EdgeLabel,
|
||||
): NodeId | null {
|
||||
const outEdges = flow.edges.filter((e) => e.from === currentNodeId);
|
||||
|
||||
if (outEdges.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果指定了 label,优先匹配
|
||||
if (label) {
|
||||
const matchedEdge = outEdges.find((e) => e.label === label);
|
||||
if (matchedEdge) {
|
||||
return matchedEdge.to;
|
||||
}
|
||||
}
|
||||
|
||||
// 否则使用 default 边
|
||||
const defaultEdge = outEdges.find(
|
||||
(e) => e.label === EDGE_LABELS.DEFAULT || e.label === undefined,
|
||||
);
|
||||
if (defaultEdge) {
|
||||
return defaultEdge.to;
|
||||
}
|
||||
|
||||
// 如果只有一条边,使用它
|
||||
if (outEdges.length === 1) {
|
||||
return outEdges[0].to;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找指定标签的边
|
||||
*/
|
||||
export function findEdgeByLabel(
|
||||
flow: FlowV3,
|
||||
fromNodeId: NodeId,
|
||||
label: EdgeLabel,
|
||||
): EdgeV3 | undefined {
|
||||
return flow.edges.find((e) => e.from === fromNodeId && e.label === label);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的所有出边
|
||||
*/
|
||||
export function getOutEdges(flow: FlowV3, nodeId: NodeId): EdgeV3[] {
|
||||
return flow.edges.filter((e) => e.from === nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的所有入边
|
||||
*/
|
||||
export function getInEdges(flow: FlowV3, nodeId: NodeId): EdgeV3[] {
|
||||
return flow.edges.filter((e) => e.to === nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建邻接表
|
||||
*/
|
||||
function buildAdjacencyMap(flow: FlowV3): Map<NodeId, NodeId[]> {
|
||||
const map = new Map<NodeId, NodeId[]>();
|
||||
|
||||
for (const node of flow.nodes) {
|
||||
map.set(node.id, []);
|
||||
}
|
||||
|
||||
for (const edge of flow.edges) {
|
||||
const neighbors = map.get(edge.from);
|
||||
if (neighbors) {
|
||||
neighbors.push(edge.to);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取从入口节点可达的所有节点
|
||||
*/
|
||||
export function getReachableNodes(flow: FlowV3): Set<NodeId> {
|
||||
const reachable = new Set<NodeId>();
|
||||
const adjacency = buildAdjacencyMap(flow);
|
||||
|
||||
function dfs(nodeId: NodeId): void {
|
||||
if (reachable.has(nodeId)) return;
|
||||
reachable.add(nodeId);
|
||||
|
||||
const neighbors = adjacency.get(nodeId) || [];
|
||||
for (const neighbor of neighbors) {
|
||||
dfs(neighbor);
|
||||
}
|
||||
}
|
||||
|
||||
dfs(flow.entryNodeId);
|
||||
return reachable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查节点是否可达
|
||||
*/
|
||||
export function isNodeReachable(flow: FlowV3, nodeId: NodeId): boolean {
|
||||
return getReachableNodes(flow).has(nodeId);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @fileoverview 插件系统导出入口
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export * from './registry';
|
||||
export * from './v2-action-adapter';
|
||||
export * from './register-v2-replay-nodes';
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @fileoverview Register RR-V2 replay action handlers as RR-V3 nodes
|
||||
* @description
|
||||
* Batch registration of V2 action handlers into the V3 PluginRegistry.
|
||||
* This enables V3 to execute flows that use V2 action types.
|
||||
*/
|
||||
|
||||
import { createReplayActionRegistry } from '@/entrypoints/background/record-replay/actions/handlers';
|
||||
import type {
|
||||
ActionHandler,
|
||||
ExecutableActionType,
|
||||
} from '@/entrypoints/background/record-replay/actions/types';
|
||||
|
||||
import type { PluginRegistry } from './registry';
|
||||
import {
|
||||
adaptV2ActionHandlerToV3NodeDefinition,
|
||||
type V2ActionNodeAdapterOptions,
|
||||
} from './v2-action-adapter';
|
||||
|
||||
export interface RegisterV2ReplayNodesOptions extends V2ActionNodeAdapterOptions {
|
||||
/**
|
||||
* Only include these action types. If not specified, all V2 handlers are included.
|
||||
*/
|
||||
include?: ReadonlyArray<string>;
|
||||
|
||||
/**
|
||||
* Exclude these action types. Applied after include filter.
|
||||
*/
|
||||
exclude?: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register V2 replay action handlers as V3 node definitions.
|
||||
*
|
||||
* @param registry The V3 PluginRegistry to register nodes into
|
||||
* @param options Configuration options
|
||||
* @returns Array of registered node kinds
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const plugins = new PluginRegistry();
|
||||
* const registered = registerV2ReplayNodesAsV3Nodes(plugins, {
|
||||
* // Exclude control flow handlers that V3 runner doesn't support
|
||||
* exclude: ['foreach', 'while'],
|
||||
* });
|
||||
* console.log('Registered:', registered);
|
||||
* ```
|
||||
*/
|
||||
export function registerV2ReplayNodesAsV3Nodes(
|
||||
registry: PluginRegistry,
|
||||
options: RegisterV2ReplayNodesOptions = {},
|
||||
): string[] {
|
||||
const actionRegistry = createReplayActionRegistry();
|
||||
const handlers = actionRegistry.list();
|
||||
|
||||
const include = options.include ? new Set(options.include) : null;
|
||||
const exclude = options.exclude ? new Set(options.exclude) : null;
|
||||
|
||||
const registered: string[] = [];
|
||||
|
||||
for (const handler of handlers) {
|
||||
if (include && !include.has(handler.type)) continue;
|
||||
if (exclude && exclude.has(handler.type)) continue;
|
||||
|
||||
// Cast needed because V2 handler types don't perfectly align with V3 NodeKind
|
||||
const nodeDef = adaptV2ActionHandlerToV3NodeDefinition(
|
||||
handler as ActionHandler<ExecutableActionType>,
|
||||
options,
|
||||
);
|
||||
registry.registerNode(nodeDef as unknown as Parameters<typeof registry.registerNode>[0]);
|
||||
registered.push(handler.type);
|
||||
}
|
||||
|
||||
return registered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of V2 action types that can be registered.
|
||||
* Useful for debugging and documentation.
|
||||
*/
|
||||
export function listV2ActionTypes(): string[] {
|
||||
const actionRegistry = createReplayActionRegistry();
|
||||
return actionRegistry.list().map((h) => h.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default exclude list for V3 registration.
|
||||
* These handlers rely on V2 control directives that V3 runner doesn't support.
|
||||
*/
|
||||
export const DEFAULT_V2_EXCLUDE_LIST = ['foreach', 'while'] as const;
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @fileoverview 插件注册表
|
||||
* @description 管理节点和触发器插件的注册和查询
|
||||
*/
|
||||
|
||||
import type { NodeKind } from '../../domain/flow';
|
||||
import type { TriggerKind } from '../../domain/triggers';
|
||||
import { RR_ERROR_CODES, createRRError } from '../../domain/errors';
|
||||
import type {
|
||||
NodeDefinition,
|
||||
TriggerDefinition,
|
||||
PluginRegistrationContext,
|
||||
RRPlugin,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* 插件注册表
|
||||
* @description 单例模式,管理所有已注册的节点和触发器
|
||||
*/
|
||||
export class PluginRegistry implements PluginRegistrationContext {
|
||||
private nodes = new Map<NodeKind, NodeDefinition>();
|
||||
private triggers = new Map<TriggerKind, TriggerDefinition>();
|
||||
|
||||
/**
|
||||
* 注册节点定义
|
||||
* @description 如果已存在同名节点,会覆盖
|
||||
*/
|
||||
registerNode(def: NodeDefinition): void {
|
||||
this.nodes.set(def.kind, def);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册触发器定义
|
||||
* @description 如果已存在同名触发器,会覆盖
|
||||
*/
|
||||
registerTrigger(def: TriggerDefinition): void {
|
||||
this.triggers.set(def.kind, def);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点定义
|
||||
* @returns 节点定义或 undefined
|
||||
*/
|
||||
getNode(kind: NodeKind): NodeDefinition | undefined {
|
||||
return this.nodes.get(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点定义(必须存在)
|
||||
* @throws RRError 如果节点未注册
|
||||
*/
|
||||
getNodeOrThrow(kind: NodeKind): NodeDefinition {
|
||||
const def = this.nodes.get(kind);
|
||||
if (!def) {
|
||||
throw createRRError(RR_ERROR_CODES.UNSUPPORTED_NODE, `Node kind "${kind}" is not registered`);
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取触发器定义
|
||||
* @returns 触发器定义或 undefined
|
||||
*/
|
||||
getTrigger(kind: TriggerKind): TriggerDefinition | undefined {
|
||||
return this.triggers.get(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取触发器定义(必须存在)
|
||||
* @throws RRError 如果触发器未注册
|
||||
*/
|
||||
getTriggerOrThrow(kind: TriggerKind): TriggerDefinition {
|
||||
const def = this.triggers.get(kind);
|
||||
if (!def) {
|
||||
throw createRRError(
|
||||
RR_ERROR_CODES.UNSUPPORTED_NODE,
|
||||
`Trigger kind "${kind}" is not registered`,
|
||||
);
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查节点是否已注册
|
||||
*/
|
||||
hasNode(kind: NodeKind): boolean {
|
||||
return this.nodes.has(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查触发器是否已注册
|
||||
*/
|
||||
hasTrigger(kind: TriggerKind): boolean {
|
||||
return this.triggers.has(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的节点类型
|
||||
*/
|
||||
listNodeKinds(): NodeKind[] {
|
||||
return Array.from(this.nodes.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的触发器类型
|
||||
*/
|
||||
listTriggerKinds(): TriggerKind[] {
|
||||
return Array.from(this.triggers.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册插件
|
||||
* @description 调用插件的 register 方法
|
||||
*/
|
||||
registerPlugin(plugin: RRPlugin): void {
|
||||
plugin.register(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量注册插件
|
||||
*/
|
||||
registerPlugins(plugins: RRPlugin[]): void {
|
||||
for (const plugin of plugins) {
|
||||
this.registerPlugin(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有注册
|
||||
* @description 主要用于测试
|
||||
*/
|
||||
clear(): void {
|
||||
this.nodes.clear();
|
||||
this.triggers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局插件注册表实例 */
|
||||
let globalRegistry: PluginRegistry | null = null;
|
||||
|
||||
/**
|
||||
* 获取全局插件注册表
|
||||
*/
|
||||
export function getPluginRegistry(): PluginRegistry {
|
||||
if (!globalRegistry) {
|
||||
globalRegistry = new PluginRegistry();
|
||||
}
|
||||
return globalRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置全局插件注册表
|
||||
* @description 主要用于测试
|
||||
*/
|
||||
export function resetPluginRegistry(): void {
|
||||
globalRegistry = null;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @fileoverview 插件类型定义
|
||||
* @description 定义 Record-Replay V3 中的节点和触发器插件接口
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { JsonObject, JsonValue } from '../../domain/json';
|
||||
import type { FlowId, NodeId, RunId, TriggerId } from '../../domain/ids';
|
||||
import type { NodeKind } from '../../domain/flow';
|
||||
import type { RRError } from '../../domain/errors';
|
||||
import type { NodePolicy } from '../../domain/policy';
|
||||
import type { FlowV3, NodeV3 } from '../../domain/flow';
|
||||
import type { TriggerKind } from '../../domain/triggers';
|
||||
|
||||
/**
|
||||
* Schema 类型
|
||||
* @description 使用 Zod 进行配置校验
|
||||
*/
|
||||
export type Schema<T> = z.ZodType<T, z.ZodTypeDef, unknown>;
|
||||
|
||||
/**
|
||||
* 节点执行上下文
|
||||
* @description 提供给节点执行器的运行时上下文
|
||||
*/
|
||||
export interface NodeExecutionContext {
|
||||
/** Run ID */
|
||||
runId: RunId;
|
||||
/** Flow 定义(快照) */
|
||||
flow: FlowV3;
|
||||
/** 当前节点 ID */
|
||||
nodeId: NodeId;
|
||||
|
||||
/** 绑定的 Tab ID(每 Run 独占) */
|
||||
tabId: number;
|
||||
/** Frame ID(默认 0 为主框架) */
|
||||
frameId?: number;
|
||||
|
||||
/** 当前变量表 */
|
||||
vars: Record<string, JsonValue>;
|
||||
|
||||
/**
|
||||
* 日志记录
|
||||
*/
|
||||
log: (level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: JsonValue) => void;
|
||||
|
||||
/**
|
||||
* 选择下一个边
|
||||
* @description 用于条件分支节点
|
||||
*/
|
||||
chooseNext: (label: string) => { kind: 'edgeLabel'; label: string };
|
||||
|
||||
/**
|
||||
* 工件操作
|
||||
*/
|
||||
artifacts: {
|
||||
/** 截取当前页面截图 */
|
||||
screenshot: () => Promise<{ ok: true; base64: string } | { ok: false; error: RRError }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 持久化变量操作
|
||||
*/
|
||||
persistent: {
|
||||
/** 获取持久化变量 */
|
||||
get: (name: `$${string}`) => Promise<JsonValue | undefined>;
|
||||
/** 设置持久化变量 */
|
||||
set: (name: `$${string}`, value: JsonValue) => Promise<void>;
|
||||
/** 删除持久化变量 */
|
||||
delete: (name: `$${string}`) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 变量补丁操作
|
||||
*/
|
||||
export interface VarsPatchOp {
|
||||
op: 'set' | 'delete';
|
||||
name: string;
|
||||
value?: JsonValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点执行结果
|
||||
*/
|
||||
export type NodeExecutionResult =
|
||||
| {
|
||||
status: 'succeeded';
|
||||
/** 下一步执行方向 */
|
||||
next?: { kind: 'edgeLabel'; label: string } | { kind: 'end' };
|
||||
/** 输出结果 */
|
||||
outputs?: JsonObject;
|
||||
/** 变量修改 */
|
||||
varsPatch?: VarsPatchOp[];
|
||||
}
|
||||
| { status: 'failed'; error: RRError };
|
||||
|
||||
/**
|
||||
* 节点定义
|
||||
* @description 定义一种节点类型的执行逻辑
|
||||
*/
|
||||
export interface NodeDefinition<
|
||||
TKind extends NodeKind = NodeKind,
|
||||
TConfig extends JsonObject = JsonObject,
|
||||
> {
|
||||
/** 节点类型标识 */
|
||||
kind: TKind;
|
||||
/** 配置校验 Schema */
|
||||
schema: Schema<TConfig>;
|
||||
/** 默认策略 */
|
||||
defaultPolicy?: NodePolicy;
|
||||
/**
|
||||
* 执行节点
|
||||
* @param ctx 执行上下文
|
||||
* @param node 节点定义(含配置)
|
||||
*/
|
||||
execute(
|
||||
ctx: NodeExecutionContext,
|
||||
node: NodeV3 & { kind: TKind; config: TConfig },
|
||||
): Promise<NodeExecutionResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发器安装上下文
|
||||
*/
|
||||
export interface TriggerInstallContext<
|
||||
TKind extends TriggerKind = TriggerKind,
|
||||
TConfig extends JsonObject = JsonObject,
|
||||
> {
|
||||
/** 触发器 ID */
|
||||
triggerId: TriggerId;
|
||||
/** 触发器类型 */
|
||||
kind: TKind;
|
||||
/** 是否启用 */
|
||||
enabled: boolean;
|
||||
/** 关联的 Flow ID */
|
||||
flowId: FlowId;
|
||||
/** 触发器配置 */
|
||||
config: TConfig;
|
||||
/** 传递给 Flow 的参数 */
|
||||
args?: JsonObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发器定义
|
||||
* @description 定义一种触发器类型的安装和卸载逻辑
|
||||
*/
|
||||
export interface TriggerDefinition<
|
||||
TKind extends TriggerKind = TriggerKind,
|
||||
TConfig extends JsonObject = JsonObject,
|
||||
> {
|
||||
/** 触发器类型标识 */
|
||||
kind: TKind;
|
||||
/** 配置校验 Schema */
|
||||
schema: Schema<TConfig>;
|
||||
/** 安装触发器 */
|
||||
install(ctx: TriggerInstallContext<TKind, TConfig>): Promise<void> | void;
|
||||
/** 卸载触发器 */
|
||||
uninstall(ctx: TriggerInstallContext<TKind, TConfig>): Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件注册上下文
|
||||
*/
|
||||
export interface PluginRegistrationContext {
|
||||
/** 注册节点定义 */
|
||||
registerNode(def: NodeDefinition): void;
|
||||
/** 注册触发器定义 */
|
||||
registerTrigger(def: TriggerDefinition): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件接口
|
||||
* @description Record-Replay 插件的标准接口
|
||||
*/
|
||||
export interface RRPlugin {
|
||||
/** 插件名称 */
|
||||
name: string;
|
||||
/** 注册插件内容 */
|
||||
register(ctx: PluginRegistrationContext): void;
|
||||
}
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* @fileoverview V2 ActionHandler -> V3 NodeDefinition adapter
|
||||
* @description Bridges legacy RR-V2 action handlers into the RR-V3 PluginRegistry.
|
||||
*
|
||||
* Design notes:
|
||||
* - V3 requires variable mutations to be represented as varsPatch so they are auditable in the event log.
|
||||
* - V2 handlers mutate ctx.vars directly, so we run them against a cloned VariableStore and diff it.
|
||||
* - Cross-node state (tabId/frameId changes from switchFrame/openTab/switchTab) is persisted in internal vars.
|
||||
*
|
||||
* WARNING: This adapter accesses V2 handler internals and may need updates if V2 types change.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import type {
|
||||
ActionExecutionContext,
|
||||
ActionExecutionResult,
|
||||
ActionHandler,
|
||||
ActionError,
|
||||
ActionErrorCode,
|
||||
ActionPolicy,
|
||||
ExecutableActionType,
|
||||
ValidationResult,
|
||||
Action,
|
||||
} from '@/entrypoints/background/record-replay/actions/types';
|
||||
|
||||
import type { JsonValue, JsonObject } from '../../domain/json';
|
||||
import { RR_ERROR_CODES, createRRError, type RRError, type RRErrorCode } from '../../domain/errors';
|
||||
import type { NodePolicy } from '../../domain/policy';
|
||||
import { mergeNodePolicy } from '../../domain/policy';
|
||||
|
||||
import type {
|
||||
NodeDefinition,
|
||||
NodeExecutionContext,
|
||||
NodeExecutionResult,
|
||||
VarsPatchOp,
|
||||
} from './types';
|
||||
|
||||
// Internal run-scoped state keys used to emulate V2 "mutable context" across nodes.
|
||||
const DEFAULT_TAB_ID_VAR = '__rr_v2__tabId';
|
||||
const DEFAULT_FRAME_ID_VAR = '__rr_v2__frameId';
|
||||
|
||||
export interface V2ActionNodeAdapterOptions {
|
||||
/**
|
||||
* Whether to emit v2 ActionExecutionResult.output into V3 NodeExecutionResult.outputs.
|
||||
* Defaults to true.
|
||||
*/
|
||||
includeOutput?: boolean;
|
||||
|
||||
/**
|
||||
* Where to store cross-node "mutable context" state (tabId/frameId).
|
||||
* Defaults are "__rr_v2__tabId" and "__rr_v2__frameId".
|
||||
*/
|
||||
stateVars?: {
|
||||
tabIdVar?: string;
|
||||
frameIdVar?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Execution flags forwarded into V2 ActionExecutionContext.execution.
|
||||
* Keep default undefined to preserve V2 handler behavior.
|
||||
*/
|
||||
executionFlags?: ActionExecutionContext['execution'];
|
||||
}
|
||||
|
||||
// ==================== Utilities ====================
|
||||
|
||||
function toErrorMessage(e: unknown): string {
|
||||
if (e instanceof Error) return e.message;
|
||||
if (e && typeof e === 'object' && 'message' in e)
|
||||
return String((e as { message: unknown }).message);
|
||||
return String(e);
|
||||
}
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
const sc = (globalThis as unknown as { structuredClone?: <U>(v: U) => U }).structuredClone;
|
||||
if (typeof sc === 'function') return sc(value);
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function safeJsonValue(value: unknown): JsonValue {
|
||||
if (value === undefined) return null;
|
||||
try {
|
||||
const s = JSON.stringify(value);
|
||||
if (s === undefined) return String(value);
|
||||
return JSON.parse(s) as JsonValue;
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function mapLogLevel(level: 'info' | 'warn' | 'error' | undefined): 'info' | 'warn' | 'error' {
|
||||
return level ?? 'info';
|
||||
}
|
||||
|
||||
function mapV2ErrorCode(code: ActionErrorCode): RRErrorCode {
|
||||
switch (code) {
|
||||
case 'VALIDATION_ERROR':
|
||||
return RR_ERROR_CODES.VALIDATION_ERROR;
|
||||
case 'TIMEOUT':
|
||||
return RR_ERROR_CODES.TIMEOUT;
|
||||
case 'TAB_NOT_FOUND':
|
||||
return RR_ERROR_CODES.TAB_NOT_FOUND;
|
||||
case 'FRAME_NOT_FOUND':
|
||||
return RR_ERROR_CODES.FRAME_NOT_FOUND;
|
||||
case 'TARGET_NOT_FOUND':
|
||||
return RR_ERROR_CODES.TARGET_NOT_FOUND;
|
||||
case 'ELEMENT_NOT_VISIBLE':
|
||||
return RR_ERROR_CODES.ELEMENT_NOT_VISIBLE;
|
||||
case 'NAVIGATION_FAILED':
|
||||
return RR_ERROR_CODES.NAVIGATION_FAILED;
|
||||
case 'NETWORK_REQUEST_FAILED':
|
||||
return RR_ERROR_CODES.NETWORK_REQUEST_FAILED;
|
||||
case 'SCRIPT_FAILED':
|
||||
return RR_ERROR_CODES.SCRIPT_FAILED;
|
||||
|
||||
// V3 doesn't currently have dedicated codes for these.
|
||||
case 'DOWNLOAD_FAILED':
|
||||
case 'ASSERTION_FAILED':
|
||||
return RR_ERROR_CODES.TOOL_ERROR;
|
||||
|
||||
case 'UNKNOWN':
|
||||
default:
|
||||
return RR_ERROR_CODES.INTERNAL;
|
||||
}
|
||||
}
|
||||
|
||||
function toRRErrorFromV2(error: ActionError): RRError {
|
||||
const data = error.data !== undefined ? safeJsonValue(error.data) : undefined;
|
||||
return createRRError(
|
||||
mapV2ErrorCode(error.code),
|
||||
error.message,
|
||||
data !== undefined ? { data } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function jsonEquals(a: JsonValue, b: JsonValue): boolean {
|
||||
if (a === b) return true;
|
||||
|
||||
const aIsArray = Array.isArray(a);
|
||||
const bIsArray = Array.isArray(b);
|
||||
if (aIsArray || bIsArray) {
|
||||
if (!aIsArray || !bIsArray) return false;
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!jsonEquals(a[i] as JsonValue, b[i] as JsonValue)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const aIsObj = isRecord(a);
|
||||
const bIsObj = isRecord(b);
|
||||
if (aIsObj || bIsObj) {
|
||||
if (!aIsObj || !bIsObj) return false;
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
for (const k of aKeys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
|
||||
if (!jsonEquals(a[k] as JsonValue, (b as Record<string, unknown>)[k] as JsonValue))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function diffVars(
|
||||
before: Record<string, JsonValue>,
|
||||
after: Record<string, JsonValue>,
|
||||
): VarsPatchOp[] {
|
||||
const patch: VarsPatchOp[] = [];
|
||||
const keys = new Set<string>([...Object.keys(before), ...Object.keys(after)]);
|
||||
|
||||
for (const key of keys) {
|
||||
const beforeHas = Object.prototype.hasOwnProperty.call(before, key);
|
||||
const afterHas = Object.prototype.hasOwnProperty.call(after, key);
|
||||
|
||||
if (!afterHas) {
|
||||
if (beforeHas) patch.push({ op: 'delete', name: key });
|
||||
continue;
|
||||
}
|
||||
|
||||
const afterVal = after[key];
|
||||
if (!beforeHas) {
|
||||
patch.push({ op: 'set', name: key, value: afterVal });
|
||||
continue;
|
||||
}
|
||||
|
||||
const beforeVal = before[key];
|
||||
if (!jsonEquals(beforeVal, afterVal)) {
|
||||
patch.push({ op: 'set', name: key, value: afterVal });
|
||||
}
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
function readNumberVar(vars: Record<string, JsonValue>, key: string): number | undefined {
|
||||
const v = vars[key];
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
function toV2ActionPolicy(policy: NodePolicy | undefined): ActionPolicy | undefined {
|
||||
if (!policy) return undefined;
|
||||
|
||||
const timeout = policy.timeout
|
||||
? {
|
||||
ms: policy.timeout.ms,
|
||||
scope: policy.timeout.scope === 'node' ? ('action' as const) : ('attempt' as const),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// NodePolicy/ActionPolicy are structurally similar; we only normalize timeout.scope.
|
||||
return {
|
||||
...(timeout ? { timeout } : {}),
|
||||
...(policy.retry ? { retry: policy.retry as unknown as ActionPolicy['retry'] } : {}),
|
||||
...(policy.artifacts
|
||||
? { artifacts: policy.artifacts as unknown as ActionPolicy['artifacts'] }
|
||||
: {}),
|
||||
...(policy.onError
|
||||
? (() => {
|
||||
// V2 only supports goto by edge label. Node-target goto can't be represented.
|
||||
if (policy.onError.kind === 'goto' && policy.onError.target.kind === 'node') {
|
||||
return { onError: { kind: 'stop' } as ActionPolicy['onError'] };
|
||||
}
|
||||
if (policy.onError.kind === 'continue') {
|
||||
return {
|
||||
onError: {
|
||||
kind: 'continue',
|
||||
level: policy.onError.as,
|
||||
} as ActionPolicy['onError'],
|
||||
};
|
||||
}
|
||||
if (policy.onError.kind === 'goto') {
|
||||
const target = policy.onError.target;
|
||||
if (target.kind === 'edgeLabel') {
|
||||
return {
|
||||
onError: {
|
||||
kind: 'goto',
|
||||
label: target.label,
|
||||
} as ActionPolicy['onError'],
|
||||
};
|
||||
}
|
||||
// Node target can't be represented in V2, fall through to stop
|
||||
return { onError: { kind: 'stop' } as ActionPolicy['onError'] };
|
||||
}
|
||||
if (policy.onError.kind === 'retry') {
|
||||
// V2 has retry policy on action.policy.retry; keep onError as stop to avoid double semantics.
|
||||
return { onError: { kind: 'stop' } as ActionPolicy['onError'] };
|
||||
}
|
||||
return { onError: policy.onError as unknown as ActionPolicy['onError'] };
|
||||
})()
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toJsonRecord(value: unknown): Record<string, JsonValue> {
|
||||
const out: Record<string, JsonValue> = {};
|
||||
if (!isRecord(value)) return out;
|
||||
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
// Treat undefined as deletion (omit).
|
||||
if (v === undefined) continue;
|
||||
out[k] = safeJsonValue(v);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ==================== Main Adapter ====================
|
||||
|
||||
/**
|
||||
* Adapt a single V2 ActionHandler into a V3 NodeDefinition.
|
||||
*/
|
||||
export function adaptV2ActionHandlerToV3NodeDefinition<T extends ExecutableActionType>(
|
||||
handler: ActionHandler<T>,
|
||||
options: V2ActionNodeAdapterOptions = {},
|
||||
): NodeDefinition<T, JsonObject> {
|
||||
const tabIdVar = options.stateVars?.tabIdVar ?? DEFAULT_TAB_ID_VAR;
|
||||
const frameIdVar = options.stateVars?.frameIdVar ?? DEFAULT_FRAME_ID_VAR;
|
||||
|
||||
return {
|
||||
kind: handler.type,
|
||||
schema: z.record(z.any()) as unknown as NodeDefinition<T, JsonObject>['schema'],
|
||||
execute: async (ctx: NodeExecutionContext, node): Promise<NodeExecutionResult> => {
|
||||
const beforeVars = ctx.vars;
|
||||
|
||||
const effectiveTabId = readNumberVar(beforeVars, tabIdVar) ?? ctx.tabId;
|
||||
const effectiveFrameId = readNumberVar(beforeVars, frameIdVar);
|
||||
|
||||
// Run against a cloned variable store to prevent bypassing vars.patch event stream.
|
||||
const v2Vars = deepClone(beforeVars) as unknown as Record<string, unknown>;
|
||||
|
||||
const v2Ctx: ActionExecutionContext = {
|
||||
vars: v2Vars as unknown as ActionExecutionContext['vars'],
|
||||
tabId: effectiveTabId,
|
||||
frameId: effectiveFrameId,
|
||||
runId: ctx.runId,
|
||||
log: (message, level) => ctx.log(mapLogLevel(level), message),
|
||||
pushLog: (entry) => {
|
||||
try {
|
||||
ctx.log('debug', 'v2.pushLog', safeJsonValue(entry));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
captureScreenshot: async () => {
|
||||
const r = await ctx.artifacts.screenshot();
|
||||
if (r.ok) return r.base64;
|
||||
throw new Error(r.error.message);
|
||||
},
|
||||
...(options.executionFlags ? { execution: options.executionFlags } : {}),
|
||||
};
|
||||
|
||||
const effectivePolicy = mergeNodePolicy(ctx.flow.policy?.defaultNodePolicy, node.policy);
|
||||
const v2Policy = toV2ActionPolicy(effectivePolicy);
|
||||
|
||||
const action: Action<T> = {
|
||||
id: node.id as Action<T>['id'],
|
||||
type: handler.type,
|
||||
...(node.name ? { name: node.name } : {}),
|
||||
...(node.disabled ? { disabled: true } : {}),
|
||||
...(v2Policy ? { policy: v2Policy } : {}),
|
||||
params: node.config as unknown as Action<T>['params'],
|
||||
...(node.ui ? { ui: node.ui as Action<T>['ui'] } : {}),
|
||||
};
|
||||
|
||||
// V2 handler-level validation
|
||||
if (handler.validate) {
|
||||
const v: ValidationResult = handler.validate(action);
|
||||
if (!v.ok) {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: createRRError(RR_ERROR_CODES.VALIDATION_ERROR, v.errors.join(', ')),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let result: ActionExecutionResult<T>;
|
||||
try {
|
||||
result = await handler.run(v2Ctx, action);
|
||||
} catch (e) {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: createRRError(
|
||||
RR_ERROR_CODES.INTERNAL,
|
||||
`V2 handler "${handler.type}" threw: ${toErrorMessage(e)}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (result.status === 'failed') {
|
||||
const err = result.error
|
||||
? toRRErrorFromV2(result.error)
|
||||
: createRRError(RR_ERROR_CODES.INTERNAL, `V2 handler "${handler.type}" failed`);
|
||||
return { status: 'failed', error: err };
|
||||
}
|
||||
|
||||
if (result.status === 'paused') {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: createRRError(
|
||||
RR_ERROR_CODES.RUN_PAUSED,
|
||||
`V2 handler "${handler.type}" returned paused (not supported in V3 NodeExecutionResult)`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// V3 does not support V2 scheduler control directives (foreach/while).
|
||||
if (result.control) {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: createRRError(
|
||||
RR_ERROR_CODES.UNSUPPORTED_NODE,
|
||||
`V2 control directive "${result.control.kind}" is not supported by the V3 runner`,
|
||||
{ data: safeJsonValue(result.control) },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Persist cross-node context changes via internal vars.
|
||||
if (typeof v2Ctx.frameId === 'number' && Number.isFinite(v2Ctx.frameId)) {
|
||||
v2Vars[frameIdVar] = v2Ctx.frameId;
|
||||
} else {
|
||||
delete v2Vars[frameIdVar];
|
||||
}
|
||||
|
||||
if (typeof result.newTabId === 'number' && Number.isFinite(result.newTabId)) {
|
||||
v2Vars[tabIdVar] = result.newTabId;
|
||||
}
|
||||
|
||||
const afterVars = toJsonRecord(v2Vars);
|
||||
const varsPatch = diffVars(beforeVars, afterVars);
|
||||
|
||||
const outputs: Record<string, JsonValue> | undefined =
|
||||
options.includeOutput === false || result.output === undefined
|
||||
? undefined
|
||||
: { [node.id]: safeJsonValue(result.output) };
|
||||
|
||||
return {
|
||||
status: 'succeeded',
|
||||
...(result.nextLabel ? { next: ctx.chooseNext(result.nextLabel) } : {}),
|
||||
...(outputs ? { outputs } : {}),
|
||||
...(varsPatch.length > 0 ? { varsPatch } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* @fileoverview 共享入队服务
|
||||
* @description
|
||||
* 提供统一的 Run 入队逻辑,供 RPC Server 和 TriggerManager 共用。
|
||||
*
|
||||
* 设计理由:
|
||||
* - 将原本位于 RpcServer 的入队逻辑抽离为独立服务
|
||||
* - 避免 RPC 和 TriggerManager 之间的行为漂移
|
||||
* - 统一参数校验、Run 创建、队列入队、事件发布流程
|
||||
*/
|
||||
|
||||
import type { JsonObject, UnixMillis } from '../../domain/json';
|
||||
import type { FlowId, NodeId, RunId } from '../../domain/ids';
|
||||
import type { TriggerFireContext } from '../../domain/triggers';
|
||||
import { RUN_SCHEMA_VERSION, type RunRecordV3 } from '../../domain/events';
|
||||
import type { StoragePort } from '../storage/storage-port';
|
||||
import type { EventsBus } from '../transport/events-bus';
|
||||
import type { RunScheduler } from './scheduler';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
/**
|
||||
* 入队服务依赖
|
||||
*/
|
||||
export interface EnqueueRunDeps {
|
||||
/** 存储层 (仅需 flows/runs/queue) */
|
||||
storage: Pick<StoragePort, 'flows' | 'runs' | 'queue'>;
|
||||
/** 事件总线 */
|
||||
events: Pick<EventsBus, 'append'>;
|
||||
/** 调度器 (可选) */
|
||||
scheduler?: Pick<RunScheduler, 'kick'>;
|
||||
/** RunId 生成器 (用于测试注入) */
|
||||
generateRunId?: () => RunId;
|
||||
/** 时间源 (用于测试注入) */
|
||||
now?: () => UnixMillis;
|
||||
}
|
||||
|
||||
/**
|
||||
* 入队请求参数
|
||||
*/
|
||||
export interface EnqueueRunInput {
|
||||
/** Flow ID (必选) */
|
||||
flowId: FlowId;
|
||||
/** 起始节点 ID (可选,默认使用 Flow 的 entryNodeId) */
|
||||
startNodeId?: NodeId;
|
||||
/** 优先级 (默认 0) */
|
||||
priority?: number;
|
||||
/** 最大尝试次数 (默认 1) */
|
||||
maxAttempts?: number;
|
||||
/** 传递给 Flow 的参数 */
|
||||
args?: JsonObject;
|
||||
/** 触发上下文 (由 TriggerManager 设置) */
|
||||
trigger?: TriggerFireContext;
|
||||
/** 调试选项 */
|
||||
debug?: {
|
||||
breakpoints?: NodeId[];
|
||||
pauseOnStart?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 入队结果
|
||||
*/
|
||||
export interface EnqueueRunResult {
|
||||
/** 新创建的 Run ID */
|
||||
runId: RunId;
|
||||
/** 在队列中的位置 (1-based) */
|
||||
position: number;
|
||||
}
|
||||
|
||||
// ==================== Utilities ====================
|
||||
|
||||
/**
|
||||
* 默认 RunId 生成器
|
||||
*/
|
||||
function defaultGenerateRunId(): RunId {
|
||||
return `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验整数参数
|
||||
*/
|
||||
function validateInt(
|
||||
value: unknown,
|
||||
defaultValue: number,
|
||||
fieldName: string,
|
||||
opts?: { min?: number; max?: number },
|
||||
): number {
|
||||
if (value === undefined || value === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new Error(`${fieldName} must be a finite number`);
|
||||
}
|
||||
const intValue = Math.floor(value);
|
||||
if (opts?.min !== undefined && intValue < opts.min) {
|
||||
throw new Error(`${fieldName} must be >= ${opts.min}`);
|
||||
}
|
||||
if (opts?.max !== undefined && intValue > opts.max) {
|
||||
throw new Error(`${fieldName} must be <= ${opts.max}`);
|
||||
}
|
||||
return intValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 Run 在队列中的位置
|
||||
* @description 按调度顺序: priority DESC + createdAt ASC
|
||||
* @returns 1-based position, or -1 if run not found in queued items
|
||||
*
|
||||
* Note: Due to race conditions (scheduler may claim the run before this is called),
|
||||
* position may be -1. Callers should handle this gracefully.
|
||||
*/
|
||||
async function computeQueuePosition(
|
||||
storage: Pick<StoragePort, 'queue'>,
|
||||
runId: RunId,
|
||||
): Promise<number> {
|
||||
const queueItems = await storage.queue.list('queued');
|
||||
queueItems.sort((a, b) => {
|
||||
if (a.priority !== b.priority) return b.priority - a.priority;
|
||||
return a.createdAt - b.createdAt;
|
||||
});
|
||||
const index = queueItems.findIndex((item) => item.id === runId);
|
||||
// Return -1 if not found (run may have been claimed already)
|
||||
return index === -1 ? -1 : index + 1;
|
||||
}
|
||||
|
||||
// ==================== Main Function ====================
|
||||
|
||||
/**
|
||||
* 入队执行一个 Run
|
||||
* @description
|
||||
* 执行步骤:
|
||||
* 1. 参数校验
|
||||
* 2. 验证 Flow 存在
|
||||
* 3. 创建 RunRecordV3 (status=queued)
|
||||
* 4. 入队到 RunQueue
|
||||
* 5. 发布 run.queued 事件
|
||||
* 6. 触发调度 (best-effort)
|
||||
* 7. 计算队列位置
|
||||
*/
|
||||
export async function enqueueRun(
|
||||
deps: EnqueueRunDeps,
|
||||
input: EnqueueRunInput,
|
||||
): Promise<EnqueueRunResult> {
|
||||
const { flowId } = input;
|
||||
if (!flowId) {
|
||||
throw new Error('flowId is required');
|
||||
}
|
||||
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const generateRunId = deps.generateRunId ?? defaultGenerateRunId;
|
||||
|
||||
// 参数校验
|
||||
const priority = validateInt(input.priority, 0, 'priority');
|
||||
const maxAttempts = validateInt(input.maxAttempts, 1, 'maxAttempts', { min: 1 });
|
||||
|
||||
// 验证 Flow 存在
|
||||
const flow = await deps.storage.flows.get(flowId);
|
||||
if (!flow) {
|
||||
throw new Error(`Flow "${flowId}" not found`);
|
||||
}
|
||||
|
||||
// 验证 startNodeId 存在于 Flow 中
|
||||
if (input.startNodeId) {
|
||||
const nodeExists = flow.nodes.some((n) => n.id === input.startNodeId);
|
||||
if (!nodeExists) {
|
||||
throw new Error(`startNodeId "${input.startNodeId}" not found in flow "${flowId}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const ts = now();
|
||||
const runId = generateRunId();
|
||||
|
||||
// 1. 创建 RunRecordV3
|
||||
const runRecord: RunRecordV3 = {
|
||||
schemaVersion: RUN_SCHEMA_VERSION,
|
||||
id: runId,
|
||||
flowId,
|
||||
status: 'queued',
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
attempt: 0,
|
||||
maxAttempts,
|
||||
args: input.args,
|
||||
trigger: input.trigger,
|
||||
debug: input.debug,
|
||||
startNodeId: input.startNodeId,
|
||||
nextSeq: 0,
|
||||
};
|
||||
await deps.storage.runs.save(runRecord);
|
||||
|
||||
// 2. 入队
|
||||
await deps.storage.queue.enqueue({
|
||||
id: runId,
|
||||
flowId,
|
||||
priority,
|
||||
maxAttempts,
|
||||
args: input.args,
|
||||
trigger: input.trigger,
|
||||
debug: input.debug,
|
||||
});
|
||||
|
||||
// 3. 发布 run.queued 事件
|
||||
await deps.events.append({
|
||||
runId,
|
||||
type: 'run.queued',
|
||||
flowId,
|
||||
});
|
||||
|
||||
// 4. 计算队列位置 (在 kick 之前计算,减少竞态条件导致 position=-1 的概率)
|
||||
const position = await computeQueuePosition(deps.storage, runId);
|
||||
|
||||
// 5. 触发调度 (best-effort, 不阻塞返回)
|
||||
if (deps.scheduler) {
|
||||
void deps.scheduler.kick();
|
||||
}
|
||||
|
||||
return { runId, position };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @fileoverview Queue 模块导出入口
|
||||
*/
|
||||
|
||||
export * from './queue';
|
||||
export * from './leasing';
|
||||
export * from './scheduler';
|
||||
export * from './enqueue-run';
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @fileoverview 租约管理
|
||||
* @description 管理 Run 的租约续约和过期回收
|
||||
*/
|
||||
|
||||
import type { UnixMillis } from '../../domain/json';
|
||||
import type { RunId } from '../../domain/ids';
|
||||
import type { RunQueue, RunQueueConfig, Lease } from './queue';
|
||||
|
||||
/**
|
||||
* 租约管理器
|
||||
* @description 管理租约续约和过期检测
|
||||
*/
|
||||
export interface LeaseManager {
|
||||
/**
|
||||
* 开始心跳
|
||||
* @param ownerId 持有者 ID
|
||||
*/
|
||||
startHeartbeat(ownerId: string): void;
|
||||
|
||||
/**
|
||||
* 停止心跳
|
||||
* @param ownerId 持有者 ID
|
||||
*/
|
||||
stopHeartbeat(ownerId: string): void;
|
||||
|
||||
/**
|
||||
* 检查并回收过期租约
|
||||
* @param now 当前时间
|
||||
* @returns 被回收的 Run ID 列表
|
||||
*/
|
||||
reclaimExpiredLeases(now: UnixMillis): Promise<RunId[]>;
|
||||
|
||||
/**
|
||||
* 判断租约是否过期
|
||||
*/
|
||||
isLeaseExpired(lease: Lease, now: UnixMillis): boolean;
|
||||
|
||||
/**
|
||||
* 创建新租约
|
||||
*/
|
||||
createLease(ownerId: string, now: UnixMillis): Lease;
|
||||
|
||||
/**
|
||||
* 停止所有心跳
|
||||
*/
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建租约管理器
|
||||
*/
|
||||
export function createLeaseManager(queue: RunQueue, config: RunQueueConfig): LeaseManager {
|
||||
const heartbeatTimers = new Map<string, ReturnType<typeof setInterval>>();
|
||||
|
||||
return {
|
||||
startHeartbeat(ownerId: string): void {
|
||||
// 如果已有定时器,先停止
|
||||
this.stopHeartbeat(ownerId);
|
||||
|
||||
// 创建新的心跳定时器
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
await queue.heartbeat(ownerId, Date.now());
|
||||
} catch (error) {
|
||||
console.error(`[LeaseManager] Heartbeat failed for ${ownerId}:`, error);
|
||||
}
|
||||
}, config.heartbeatIntervalMs);
|
||||
|
||||
heartbeatTimers.set(ownerId, timer);
|
||||
},
|
||||
|
||||
stopHeartbeat(ownerId: string): void {
|
||||
const timer = heartbeatTimers.get(ownerId);
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
heartbeatTimers.delete(ownerId);
|
||||
}
|
||||
},
|
||||
|
||||
async reclaimExpiredLeases(now: UnixMillis): Promise<RunId[]> {
|
||||
// Delegate to the queue implementation which uses the lease_expiresAt index
|
||||
// for efficient scanning and updates storage atomically.
|
||||
return queue.reclaimExpiredLeases(now);
|
||||
},
|
||||
|
||||
isLeaseExpired(lease: Lease, now: UnixMillis): boolean {
|
||||
return lease.expiresAt < now;
|
||||
},
|
||||
|
||||
createLease(ownerId: string, now: UnixMillis): Lease {
|
||||
return {
|
||||
ownerId,
|
||||
expiresAt: now + config.leaseTtlMs,
|
||||
};
|
||||
},
|
||||
|
||||
dispose(): void {
|
||||
for (const timer of heartbeatTimers.values()) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
heartbeatTimers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一的 owner ID
|
||||
* @description 用于标识当前 Service Worker 实例
|
||||
*/
|
||||
export function generateOwnerId(): string {
|
||||
return `sw_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @fileoverview RunQueue 接口定义
|
||||
* @description 定义 Run 队列的管理接口
|
||||
*/
|
||||
|
||||
import type { JsonObject, UnixMillis } from '../../domain/json';
|
||||
import type { FlowId, NodeId, RunId } from '../../domain/ids';
|
||||
import type { TriggerFireContext } from '../../domain/triggers';
|
||||
|
||||
/**
|
||||
* RunQueue 配置
|
||||
*/
|
||||
export interface RunQueueConfig {
|
||||
/** 最大并行 Run 数量 */
|
||||
maxParallelRuns: number;
|
||||
/** 租约 TTL(毫秒) */
|
||||
leaseTtlMs: number;
|
||||
/** 心跳间隔(毫秒) */
|
||||
heartbeatIntervalMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认队列配置
|
||||
*/
|
||||
export const DEFAULT_QUEUE_CONFIG: RunQueueConfig = {
|
||||
maxParallelRuns: 3,
|
||||
leaseTtlMs: 15_000,
|
||||
heartbeatIntervalMs: 5_000,
|
||||
};
|
||||
|
||||
/**
|
||||
* 队列项状态
|
||||
*/
|
||||
export type QueueItemStatus = 'queued' | 'running' | 'paused';
|
||||
|
||||
/**
|
||||
* 租约信息
|
||||
*/
|
||||
export interface Lease {
|
||||
/** 持有者 ID */
|
||||
ownerId: string;
|
||||
/** 过期时间 */
|
||||
expiresAt: UnixMillis;
|
||||
}
|
||||
|
||||
/**
|
||||
* RunQueue 队列项
|
||||
*/
|
||||
export interface RunQueueItem {
|
||||
/** Run ID */
|
||||
id: RunId;
|
||||
/** Flow ID */
|
||||
flowId: FlowId;
|
||||
/** 状态 */
|
||||
status: QueueItemStatus;
|
||||
/** 创建时间 */
|
||||
createdAt: UnixMillis;
|
||||
/** 更新时间 */
|
||||
updatedAt: UnixMillis;
|
||||
/** 优先级(数字越大优先级越高) */
|
||||
priority: number;
|
||||
/** 当前尝试次数 */
|
||||
attempt: number;
|
||||
/** 最大尝试次数 */
|
||||
maxAttempts: number;
|
||||
/** Tab ID */
|
||||
tabId?: number;
|
||||
/** 运行参数 */
|
||||
args?: JsonObject;
|
||||
/** 触发器上下文 */
|
||||
trigger?: TriggerFireContext;
|
||||
/** 租约信息 */
|
||||
lease?: Lease;
|
||||
/** 调试配置 */
|
||||
debug?: { breakpoints?: NodeId[]; pauseOnStart?: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* 入队请求(不含自动生成的字段)
|
||||
* - priority 默认为 0
|
||||
* - maxAttempts 默认为 1
|
||||
*/
|
||||
export type EnqueueInput = Omit<
|
||||
RunQueueItem,
|
||||
'status' | 'createdAt' | 'updatedAt' | 'attempt' | 'lease' | 'priority' | 'maxAttempts'
|
||||
> & {
|
||||
id: RunId;
|
||||
/** 优先级(数字越大优先级越高,默认 0) */
|
||||
priority?: number;
|
||||
/** 最大尝试次数(默认 1) */
|
||||
maxAttempts?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* RunQueue 接口
|
||||
* @description 管理 Run 的队列和调度
|
||||
*/
|
||||
export interface RunQueue {
|
||||
/**
|
||||
* 入队
|
||||
* @param input 入队请求
|
||||
* @returns 队列项
|
||||
*/
|
||||
enqueue(input: EnqueueInput): Promise<RunQueueItem>;
|
||||
|
||||
/**
|
||||
* 领取下一个可执行的 Run
|
||||
* @param ownerId 领取者 ID
|
||||
* @param now 当前时间
|
||||
* @returns 队列项或 null
|
||||
*/
|
||||
claimNext(ownerId: string, now: UnixMillis): Promise<RunQueueItem | null>;
|
||||
|
||||
/**
|
||||
* 续约心跳
|
||||
* @param ownerId 领取者 ID
|
||||
* @param now 当前时间
|
||||
*/
|
||||
heartbeat(ownerId: string, now: UnixMillis): Promise<void>;
|
||||
|
||||
/**
|
||||
* 回收过期租约
|
||||
* @description 将 lease.expiresAt < now 的 running/paused 项回收为 queued
|
||||
* @param now 当前时间
|
||||
* @returns 被回收的 Run ID 列表
|
||||
*/
|
||||
reclaimExpiredLeases(now: UnixMillis): Promise<RunId[]>;
|
||||
|
||||
/**
|
||||
* 恢复孤儿租约(SW 重启后调用)
|
||||
* @description
|
||||
* - 将孤儿 running 项回收为 queued(status -> queued,租约清除)
|
||||
* - 将孤儿 paused 项接管(保持 status=paused,租约 ownerId 更新为新 ownerId)
|
||||
* @param ownerId 新的 ownerId(当前 Service Worker 实例)
|
||||
* @param now 当前时间
|
||||
* @returns 受影响的 runId 列表(含原 ownerId 用于审计)
|
||||
*/
|
||||
recoverOrphanLeases(
|
||||
ownerId: string,
|
||||
now: UnixMillis,
|
||||
): Promise<{
|
||||
requeuedRunning: Array<{ runId: RunId; prevOwnerId?: string }>;
|
||||
adoptedPaused: Array<{ runId: RunId; prevOwnerId?: string }>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 标记为 running
|
||||
*/
|
||||
markRunning(runId: RunId, ownerId: string, now: UnixMillis): Promise<void>;
|
||||
|
||||
/**
|
||||
* 标记为 paused
|
||||
*/
|
||||
markPaused(runId: RunId, ownerId: string, now: UnixMillis): Promise<void>;
|
||||
|
||||
/**
|
||||
* 标记为完成(从队列移除)
|
||||
*/
|
||||
markDone(runId: RunId, now: UnixMillis): Promise<void>;
|
||||
|
||||
/**
|
||||
* 取消 Run
|
||||
*/
|
||||
cancel(runId: RunId, now: UnixMillis, reason?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 获取队列项
|
||||
*/
|
||||
get(runId: RunId): Promise<RunQueueItem | null>;
|
||||
|
||||
/**
|
||||
* 列出队列项
|
||||
*/
|
||||
list(status?: QueueItemStatus): Promise<RunQueueItem[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 NotImplemented 的 RunQueue
|
||||
* @description Phase 0 占位实现
|
||||
*/
|
||||
export function createNotImplementedQueue(): RunQueue {
|
||||
const notImplemented = () => {
|
||||
throw new Error('RunQueue not implemented');
|
||||
};
|
||||
|
||||
return {
|
||||
enqueue: async () => notImplemented(),
|
||||
claimNext: async () => notImplemented(),
|
||||
heartbeat: async () => notImplemented(),
|
||||
reclaimExpiredLeases: async () => notImplemented(),
|
||||
recoverOrphanLeases: async () => notImplemented(),
|
||||
markRunning: async () => notImplemented(),
|
||||
markPaused: async () => notImplemented(),
|
||||
markDone: async () => notImplemented(),
|
||||
cancel: async () => notImplemented(),
|
||||
get: async () => notImplemented(),
|
||||
list: async () => notImplemented(),
|
||||
};
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* @fileoverview RunQueue scheduler (maxParallelRuns)
|
||||
* @description
|
||||
* Orchestrates atomic claims from RunQueue and launches execution with an injected executor.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Enforce maxParallelRuns (per scheduler instance)
|
||||
* - Backfill available slots when runs complete
|
||||
* - Periodically reclaim expired leases (best-effort)
|
||||
* - Start/stop lease heartbeats via LeaseManager
|
||||
* - Acquire/release keepalive to prevent MV3 SW termination (P3-05)
|
||||
*
|
||||
* Non-responsibilities:
|
||||
* - Run execution details (Flow loading, tab allocation, etc.) are injected via RunExecutor
|
||||
*/
|
||||
|
||||
import type { UnixMillis } from '../../domain/json';
|
||||
import type { RunId } from '../../domain/ids';
|
||||
import type { LeaseManager } from './leasing';
|
||||
import type { RunQueue, RunQueueConfig, RunQueueItem } from './queue';
|
||||
import type { KeepaliveController } from '../keepalive/offscreen-keepalive';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
/**
|
||||
* Run executor contract:
|
||||
* - Resolve when the run reaches a terminal state (succeeded/failed/canceled).
|
||||
* - Throw/reject only for unexpected infrastructure errors.
|
||||
*/
|
||||
export type RunExecutor = (item: RunQueueItem) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Scheduler tuning parameters
|
||||
*/
|
||||
export interface RunSchedulerTuning {
|
||||
/**
|
||||
* Poll interval for queue consumption fallback.
|
||||
* Set to 0 to disable polling (kick-only).
|
||||
*/
|
||||
pollIntervalMs?: number;
|
||||
|
||||
/**
|
||||
* Minimum interval between lease reclaim scans.
|
||||
* Set to 0 to disable periodic reclaim (not recommended in production).
|
||||
*/
|
||||
reclaimIntervalMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduler dependencies (dependency injection)
|
||||
*/
|
||||
export interface RunSchedulerDeps {
|
||||
queue: Pick<RunQueue, 'claimNext' | 'markDone'>;
|
||||
leaseManager: Pick<LeaseManager, 'startHeartbeat' | 'stopHeartbeat' | 'reclaimExpiredLeases'>;
|
||||
keepalive: Pick<KeepaliveController, 'acquire'>;
|
||||
config: RunQueueConfig;
|
||||
ownerId: string;
|
||||
execute: RunExecutor;
|
||||
now?: () => UnixMillis;
|
||||
tuning?: RunSchedulerTuning;
|
||||
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduler state for inspection
|
||||
*/
|
||||
export interface RunSchedulerState {
|
||||
started: boolean;
|
||||
ownerId: string;
|
||||
maxParallelRuns: number;
|
||||
activeRunIds: RunId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduler interface
|
||||
*/
|
||||
export interface RunScheduler {
|
||||
/** Start the scheduler */
|
||||
start(): void;
|
||||
/** Stop the scheduler */
|
||||
stop(): void;
|
||||
/**
|
||||
* Trigger a scheduling pass.
|
||||
* Safe to call frequently; re-entrancy is coalesced.
|
||||
*/
|
||||
kick(): Promise<void>;
|
||||
/** Get current state */
|
||||
getState(): RunSchedulerState;
|
||||
/** Dispose the scheduler */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
// ==================== Constants ====================
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 500;
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
function clampNonNegativeInt(value: unknown, fallback: number): number {
|
||||
const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : fallback;
|
||||
return Math.max(0, n);
|
||||
}
|
||||
|
||||
function defaultReclaimIntervalMs(leaseTtlMs: number): number {
|
||||
const ttl = clampNonNegativeInt(leaseTtlMs, 0);
|
||||
// Reclaim at most every ~TTL/2, but never less than 1s to avoid tight loops.
|
||||
return Math.max(1_000, Math.floor(ttl / 2));
|
||||
}
|
||||
|
||||
// ==================== Factory ====================
|
||||
|
||||
/**
|
||||
* Create a RunScheduler
|
||||
*
|
||||
* Scheduling model:
|
||||
* - Concurrency is enforced by an in-memory set of active runIds.
|
||||
* - Ordering is delegated to RunQueue.claimNext() (priority DESC, createdAt ASC).
|
||||
*
|
||||
* MV3 Service Worker may be suspended/restarted, so we use a "kick + polling" strategy:
|
||||
* - kick: Immediate scheduling trigger on enqueue/completion (low latency)
|
||||
* - polling: Fallback to ensure queue is consumed even if caller forgets to kick
|
||||
*/
|
||||
export function createRunScheduler(deps: RunSchedulerDeps): RunScheduler {
|
||||
const logger = deps.logger ?? console;
|
||||
|
||||
if (!deps.ownerId) {
|
||||
throw new Error('ownerId is required');
|
||||
}
|
||||
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const maxParallelRuns = clampNonNegativeInt(deps.config.maxParallelRuns, 0);
|
||||
const pollIntervalMs = clampNonNegativeInt(
|
||||
deps.tuning?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
||||
DEFAULT_POLL_INTERVAL_MS,
|
||||
);
|
||||
const reclaimIntervalMs = clampNonNegativeInt(
|
||||
deps.tuning?.reclaimIntervalMs ?? defaultReclaimIntervalMs(deps.config.leaseTtlMs),
|
||||
defaultReclaimIntervalMs(deps.config.leaseTtlMs),
|
||||
);
|
||||
|
||||
let started = false;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let releaseKeepalive: (() => void) | null = null;
|
||||
|
||||
const activeRunIds = new Set<RunId>();
|
||||
|
||||
// Coalesced re-entrancy control for tick()
|
||||
let pendingKick = false;
|
||||
let pumpPromise: Promise<void> | null = null;
|
||||
|
||||
let lastReclaimAt: UnixMillis | null = null;
|
||||
|
||||
/**
|
||||
* Single scheduling tick:
|
||||
* 1. Reclaim expired leases (if interval elapsed)
|
||||
* 2. Fill available slots up to maxParallelRuns
|
||||
*/
|
||||
async function tick(): Promise<void> {
|
||||
const t = now();
|
||||
|
||||
// Best-effort lease reclaim (disabled when reclaimIntervalMs === 0)
|
||||
if (reclaimIntervalMs > 0) {
|
||||
const shouldReclaim = lastReclaimAt === null || t - lastReclaimAt >= reclaimIntervalMs;
|
||||
if (shouldReclaim) {
|
||||
lastReclaimAt = t;
|
||||
try {
|
||||
await deps.leaseManager.reclaimExpiredLeases(t);
|
||||
} catch (e) {
|
||||
logger.warn('[RunScheduler] reclaimExpiredLeases failed:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fill available slots up to maxParallelRuns
|
||||
//
|
||||
// Note: `stop()` can be called while an async claim is in-flight. Guard the loop
|
||||
// with `started` to prevent claiming additional items after stop is requested.
|
||||
while (started && activeRunIds.size < maxParallelRuns) {
|
||||
let claimed: RunQueueItem | null = null;
|
||||
try {
|
||||
claimed = await deps.queue.claimNext(deps.ownerId, t);
|
||||
} catch (e) {
|
||||
logger.error('[RunScheduler] claimNext failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!claimed) return;
|
||||
|
||||
// Guard against double-launch within the same scheduler instance
|
||||
if (activeRunIds.has(claimed.id)) {
|
||||
logger.error(
|
||||
`[RunScheduler] Invariant violation: run "${claimed.id}" was claimed twice in the same scheduler instance`,
|
||||
);
|
||||
// Best-effort cleanup: avoid a stuck running entry
|
||||
void deps.queue
|
||||
.markDone(claimed.id, now())
|
||||
.catch((err) =>
|
||||
logger.warn('[RunScheduler] markDone after duplicate claim failed:', err),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
activeRunIds.add(claimed.id);
|
||||
|
||||
// Capture claimed item for the closure
|
||||
const claimedItem = claimed;
|
||||
|
||||
const runPromise = Promise.resolve()
|
||||
.then(() => deps.execute(claimedItem))
|
||||
.catch((e) => {
|
||||
// If execution failed unexpectedly, log but still cleanup
|
||||
logger.error(`[RunScheduler] execute failed for run "${claimedItem.id}":`, e);
|
||||
})
|
||||
.finally(async () => {
|
||||
activeRunIds.delete(claimedItem.id);
|
||||
try {
|
||||
await deps.queue.markDone(claimedItem.id, now());
|
||||
} catch (e) {
|
||||
logger.warn(`[RunScheduler] markDone failed for run "${claimedItem.id}":`, e);
|
||||
}
|
||||
|
||||
// Backfill immediately when a slot frees up
|
||||
if (started) {
|
||||
void kick();
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure no floating promise warnings
|
||||
void runPromise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pump loop: keeps running while pendingKick is set
|
||||
*/
|
||||
async function pump(): Promise<void> {
|
||||
try {
|
||||
while (started && pendingKick) {
|
||||
pendingKick = false;
|
||||
try {
|
||||
await tick();
|
||||
} catch (e) {
|
||||
logger.error('[RunScheduler] tick failed:', e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pumpPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
// Acquire keepalive to prevent MV3 SW termination
|
||||
try {
|
||||
releaseKeepalive = deps.keepalive.acquire('scheduler');
|
||||
} catch (e) {
|
||||
logger.warn('[RunScheduler] keepalive.acquire failed:', e);
|
||||
releaseKeepalive = null;
|
||||
}
|
||||
|
||||
try {
|
||||
deps.leaseManager.startHeartbeat(deps.ownerId);
|
||||
} catch (e) {
|
||||
logger.warn('[RunScheduler] startHeartbeat failed:', e);
|
||||
}
|
||||
|
||||
if (pollIntervalMs > 0) {
|
||||
pollTimer = setInterval(() => {
|
||||
void kick();
|
||||
}, pollIntervalMs);
|
||||
}
|
||||
|
||||
void kick();
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (!started) return;
|
||||
|
||||
if (activeRunIds.size > 0) {
|
||||
logger.warn(
|
||||
`[RunScheduler] stop() called with ${activeRunIds.size} active runs; heartbeats will stop and leases may expire/reclaim concurrently`,
|
||||
);
|
||||
}
|
||||
|
||||
started = false;
|
||||
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
deps.leaseManager.stopHeartbeat(deps.ownerId);
|
||||
} catch (e) {
|
||||
logger.warn('[RunScheduler] stopHeartbeat failed:', e);
|
||||
}
|
||||
|
||||
// Release keepalive
|
||||
if (releaseKeepalive) {
|
||||
try {
|
||||
releaseKeepalive();
|
||||
} catch (e) {
|
||||
logger.warn('[RunScheduler] keepalive release failed:', e);
|
||||
}
|
||||
releaseKeepalive = null;
|
||||
}
|
||||
}
|
||||
|
||||
function kick(): Promise<void> {
|
||||
if (!started) return Promise.resolve();
|
||||
|
||||
pendingKick = true;
|
||||
if (!pumpPromise) {
|
||||
pumpPromise = pump();
|
||||
}
|
||||
return pumpPromise;
|
||||
}
|
||||
|
||||
function getState(): RunSchedulerState {
|
||||
return {
|
||||
started,
|
||||
ownerId: deps.ownerId,
|
||||
maxParallelRuns,
|
||||
activeRunIds: Array.from(activeRunIds),
|
||||
};
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
stop();
|
||||
activeRunIds.clear();
|
||||
}
|
||||
|
||||
return { start, stop, kick, getState, dispose };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @fileoverview Recovery module exports
|
||||
* @description 崩溃恢复模块导出
|
||||
*/
|
||||
|
||||
export * from './recovery-coordinator';
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @fileoverview 崩溃恢复协调器 (P3-06)
|
||||
* @description
|
||||
* MV3 Service Worker 可能随时被终止。此协调器在 SW 启动时协调队列状态和 Run 记录,
|
||||
* 使中断的 Run 能够被恢复执行。
|
||||
*
|
||||
* 恢复策略:
|
||||
* - 孤儿 running 项:回收为 queued,等待重新调度(从头重跑)
|
||||
* - 孤儿 paused 项:接管 lease,保持 paused 状态
|
||||
* - 已终态 Run 的队列残留:清理
|
||||
*
|
||||
* 调用时机:
|
||||
* - 必须在 scheduler.start() 之前调用
|
||||
* - 通常在 SW 启动时调用一次
|
||||
*/
|
||||
|
||||
import type { UnixMillis } from '../../domain/json';
|
||||
import type { RunId } from '../../domain/ids';
|
||||
import { isTerminalStatus, type RunStatus } from '../../domain/events';
|
||||
import type { StoragePort } from '../storage/storage-port';
|
||||
import type { EventsBus } from '../transport/events-bus';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
/**
|
||||
* 恢复结果
|
||||
*/
|
||||
export interface RecoveryResult {
|
||||
/** 被回收为 queued 的 running Run ID */
|
||||
requeuedRunning: RunId[];
|
||||
/** 被接管的 paused Run ID */
|
||||
adoptedPaused: RunId[];
|
||||
/** 被清理的已终态 Run ID */
|
||||
cleanedTerminal: RunId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复协调器依赖
|
||||
*/
|
||||
export interface RecoveryCoordinatorDeps {
|
||||
/** 存储层 */
|
||||
storage: StoragePort;
|
||||
/** 事件总线 */
|
||||
events: EventsBus;
|
||||
/** 当前 Service Worker 的 ownerId */
|
||||
ownerId: string;
|
||||
/** 时间源 */
|
||||
now: () => UnixMillis;
|
||||
/** 日志器 */
|
||||
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
}
|
||||
|
||||
// ==================== Main Function ====================
|
||||
|
||||
/**
|
||||
* 执行崩溃恢复
|
||||
* @description
|
||||
* 在 SW 启动时调用,协调队列状态和 Run 记录。
|
||||
*
|
||||
* 执行顺序:
|
||||
* 1. 预清理:检查队列中的所有项,清理已终态或无对应 RunRecord 的残留
|
||||
* 2. 恢复孤儿租约:回收 running,接管 paused
|
||||
* 3. 同步 RunRecord 状态:确保 RunRecord 与队列状态一致
|
||||
* 4. 发送恢复事件:为 requeued running 项发送 run.recovered 事件
|
||||
*/
|
||||
export async function recoverFromCrash(deps: RecoveryCoordinatorDeps): Promise<RecoveryResult> {
|
||||
const logger = deps.logger ?? console;
|
||||
|
||||
if (!deps.ownerId) {
|
||||
throw new Error('ownerId is required');
|
||||
}
|
||||
|
||||
const now = deps.now();
|
||||
|
||||
// 设计理由:恢复过程必须"先清理后接管/回收",否则可能把已经终态的 Run 重新排队执行
|
||||
const cleanedTerminalSet = new Set<RunId>();
|
||||
|
||||
// ==================== Step 1: 预清理 ====================
|
||||
// 检查队列中的所有项,清理已终态或无对应 RunRecord 的残留
|
||||
try {
|
||||
const items = await deps.storage.queue.list();
|
||||
for (const item of items) {
|
||||
const runId = item.id;
|
||||
const run = await deps.storage.runs.get(runId);
|
||||
|
||||
// 防御性清理:无 RunRecord 的队列项无法执行
|
||||
if (!run) {
|
||||
try {
|
||||
await deps.storage.queue.markDone(runId, now);
|
||||
cleanedTerminalSet.add(runId);
|
||||
logger.debug(`[Recovery] Cleaned orphan queue item without RunRecord: ${runId}`);
|
||||
} catch (e) {
|
||||
logger.warn('[Recovery] markDone for missing RunRecord failed:', runId, e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 清理已终态的 Run(SW 可能在 runner 完成后、scheduler markDone 前崩溃)
|
||||
if (isTerminalStatus(run.status)) {
|
||||
try {
|
||||
await deps.storage.queue.markDone(runId, now);
|
||||
cleanedTerminalSet.add(runId);
|
||||
logger.debug(`[Recovery] Cleaned terminal queue item: ${runId} (status=${run.status})`);
|
||||
} catch (e) {
|
||||
logger.warn('[Recovery] markDone for terminal run failed:', runId, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('[Recovery] Pre-clean failed:', e);
|
||||
}
|
||||
|
||||
// ==================== Step 2: 恢复孤儿租约 ====================
|
||||
// Best-effort:即使失败也不应该阻止启动
|
||||
let requeuedRunning: Array<{ runId: RunId; prevOwnerId?: string }> = [];
|
||||
let adoptedPaused: Array<{ runId: RunId; prevOwnerId?: string }> = [];
|
||||
try {
|
||||
const result = await deps.storage.queue.recoverOrphanLeases(deps.ownerId, now);
|
||||
requeuedRunning = result.requeuedRunning;
|
||||
adoptedPaused = result.adoptedPaused;
|
||||
} catch (e) {
|
||||
logger.error('[Recovery] recoverOrphanLeases failed:', e);
|
||||
// 继续执行,不阻止启动
|
||||
}
|
||||
|
||||
// ==================== Step 3: 同步 RunRecord 状态 ====================
|
||||
const requeuedRunningIds: RunId[] = [];
|
||||
for (const entry of requeuedRunning) {
|
||||
const runId = entry.runId;
|
||||
requeuedRunningIds.push(runId);
|
||||
|
||||
// 跳过在 Step 1 中已清理的项
|
||||
if (cleanedTerminalSet.has(runId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const run = await deps.storage.runs.get(runId);
|
||||
if (!run) {
|
||||
// RunRecord 不存在,清理队列项(防御性)
|
||||
try {
|
||||
await deps.storage.queue.markDone(runId, now);
|
||||
cleanedTerminalSet.add(runId);
|
||||
} catch (markDoneErr) {
|
||||
logger.warn(
|
||||
'[Recovery] markDone for missing RunRecord in Step3 failed:',
|
||||
runId,
|
||||
markDoneErr,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过已终态的 Run(可能在恢复过程中被其他逻辑更新)
|
||||
// 同时清理队列项,防止残留
|
||||
if (isTerminalStatus(run.status)) {
|
||||
try {
|
||||
await deps.storage.queue.markDone(runId, now);
|
||||
cleanedTerminalSet.add(runId);
|
||||
logger.debug(
|
||||
`[Recovery] Cleaned terminal queue item in Step3: ${runId} (status=${run.status})`,
|
||||
);
|
||||
} catch (markDoneErr) {
|
||||
logger.warn('[Recovery] markDone for terminal run in Step3 failed:', runId, markDoneErr);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新 RunRecord 状态为 queued
|
||||
await deps.storage.runs.patch(runId, { status: 'queued', updatedAt: now });
|
||||
|
||||
// 发送恢复事件(best-effort,失败不影响恢复流程)
|
||||
try {
|
||||
const fromStatus: 'running' | 'paused' = run.status === 'paused' ? 'paused' : 'running';
|
||||
await deps.events.append({
|
||||
runId,
|
||||
type: 'run.recovered',
|
||||
reason: 'sw_restart',
|
||||
fromStatus,
|
||||
toStatus: 'queued',
|
||||
prevOwnerId: entry.prevOwnerId,
|
||||
ts: now,
|
||||
});
|
||||
logger.info(`[Recovery] Requeued orphan running run: ${runId} (from=${fromStatus})`);
|
||||
} catch (eventErr) {
|
||||
logger.warn('[Recovery] Failed to emit run.recovered event:', runId, eventErr);
|
||||
// 继续执行,不影响恢复流程
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('[Recovery] Reconcile requeued running failed:', runId, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Step 4: 同步 adopted paused 的 RunRecord ====================
|
||||
const adoptedPausedIds: RunId[] = [];
|
||||
for (const entry of adoptedPaused) {
|
||||
const runId = entry.runId;
|
||||
adoptedPausedIds.push(runId);
|
||||
|
||||
// 跳过在 Step 1 中已清理的项
|
||||
if (cleanedTerminalSet.has(runId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const run = await deps.storage.runs.get(runId);
|
||||
if (!run) {
|
||||
// RunRecord 不存在,清理队列项(防御性)
|
||||
try {
|
||||
await deps.storage.queue.markDone(runId, now);
|
||||
cleanedTerminalSet.add(runId);
|
||||
} catch (markDoneErr) {
|
||||
logger.warn(
|
||||
'[Recovery] markDone for missing RunRecord in Step4 failed:',
|
||||
runId,
|
||||
markDoneErr,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过已终态的 Run,同时清理队列项
|
||||
if (isTerminalStatus(run.status)) {
|
||||
try {
|
||||
await deps.storage.queue.markDone(runId, now);
|
||||
cleanedTerminalSet.add(runId);
|
||||
logger.debug(
|
||||
`[Recovery] Cleaned terminal queue item in Step4: ${runId} (status=${run.status})`,
|
||||
);
|
||||
} catch (markDoneErr) {
|
||||
logger.warn('[Recovery] markDone for terminal run in Step4 failed:', runId, markDoneErr);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果 RunRecord 状态不是 paused,同步更新
|
||||
if (run.status !== 'paused') {
|
||||
await deps.storage.runs.patch(runId, { status: 'paused' as RunStatus, updatedAt: now });
|
||||
}
|
||||
|
||||
logger.info(`[Recovery] Adopted orphan paused run: ${runId}`);
|
||||
} catch (e) {
|
||||
logger.warn('[Recovery] Reconcile adopted paused failed:', runId, e);
|
||||
}
|
||||
}
|
||||
|
||||
const result: RecoveryResult = {
|
||||
requeuedRunning: requeuedRunningIds,
|
||||
adoptedPaused: adoptedPausedIds,
|
||||
cleanedTerminal: Array.from(cleanedTerminalSet),
|
||||
};
|
||||
|
||||
logger.info('[Recovery] Complete:', {
|
||||
requeuedRunning: result.requeuedRunning.length,
|
||||
adoptedPaused: result.adoptedPaused.length,
|
||||
cleanedTerminal: result.cleanedTerminal.length,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Engine Storage 模块导出入口
|
||||
*/
|
||||
|
||||
export * from './storage-port';
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @fileoverview StoragePort 接口定义
|
||||
* @description 定义 Storage 层的抽象接口,用于依赖注入
|
||||
*/
|
||||
|
||||
import type { FlowId, RunId, TriggerId } from '../../domain/ids';
|
||||
import type { FlowV3 } from '../../domain/flow';
|
||||
import type { RunEvent, RunEventInput, RunRecordV3 } from '../../domain/events';
|
||||
import type { PersistentVarRecord, PersistentVariableName } from '../../domain/variables';
|
||||
import type { TriggerSpec } from '../../domain/triggers';
|
||||
import type { RunQueue } from '../queue/queue';
|
||||
|
||||
/**
|
||||
* FlowsStore 接口
|
||||
*/
|
||||
export interface FlowsStore {
|
||||
/** 列出所有 Flow */
|
||||
list(): Promise<FlowV3[]>;
|
||||
/** 获取单个 Flow */
|
||||
get(id: FlowId): Promise<FlowV3 | null>;
|
||||
/** 保存 Flow */
|
||||
save(flow: FlowV3): Promise<void>;
|
||||
/** 删除 Flow */
|
||||
delete(id: FlowId): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* RunsStore 接口
|
||||
*/
|
||||
export interface RunsStore {
|
||||
/** 列出所有 Run 记录 */
|
||||
list(): Promise<RunRecordV3[]>;
|
||||
/** 获取单个 Run 记录 */
|
||||
get(id: RunId): Promise<RunRecordV3 | null>;
|
||||
/** 保存 Run 记录 */
|
||||
save(record: RunRecordV3): Promise<void>;
|
||||
/** 部分更新 Run 记录 */
|
||||
patch(id: RunId, patch: Partial<RunRecordV3>): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventsStore 接口
|
||||
* @description seq 分配必须由 append() 内部原子完成
|
||||
*/
|
||||
export interface EventsStore {
|
||||
/**
|
||||
* 追加事件并原子分配 seq
|
||||
* @description 在单个事务中:读取 RunRecordV3.nextSeq -> 写入事件 -> 递增 nextSeq
|
||||
* @param event 事件输入(不含 seq)
|
||||
* @returns 完整事件(含分配的 seq 和 ts)
|
||||
*/
|
||||
append(event: RunEventInput): Promise<RunEvent>;
|
||||
|
||||
/**
|
||||
* 列出事件
|
||||
* @param runId Run ID
|
||||
* @param opts 查询选项
|
||||
*/
|
||||
list(runId: RunId, opts?: { fromSeq?: number; limit?: number }): Promise<RunEvent[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* PersistentVarsStore 接口
|
||||
*/
|
||||
export interface PersistentVarsStore {
|
||||
/** 获取持久化变量 */
|
||||
get(key: PersistentVariableName): Promise<PersistentVarRecord | undefined>;
|
||||
/** 设置持久化变量 */
|
||||
set(
|
||||
key: PersistentVariableName,
|
||||
value: PersistentVarRecord['value'],
|
||||
): Promise<PersistentVarRecord>;
|
||||
/** 删除持久化变量 */
|
||||
delete(key: PersistentVariableName): Promise<void>;
|
||||
/** 列出持久化变量 */
|
||||
list(prefix?: PersistentVariableName): Promise<PersistentVarRecord[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* TriggersStore 接口
|
||||
*/
|
||||
export interface TriggersStore {
|
||||
/** 列出所有触发器 */
|
||||
list(): Promise<TriggerSpec[]>;
|
||||
/** 获取单个触发器 */
|
||||
get(id: TriggerId): Promise<TriggerSpec | null>;
|
||||
/** 保存触发器 */
|
||||
save(spec: TriggerSpec): Promise<void>;
|
||||
/** 删除触发器 */
|
||||
delete(id: TriggerId): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* StoragePort 接口
|
||||
* @description 聚合所有存储接口,用于依赖注入
|
||||
*/
|
||||
export interface StoragePort {
|
||||
/** Flows 存储 */
|
||||
flows: FlowsStore;
|
||||
/** Runs 存储 */
|
||||
runs: RunsStore;
|
||||
/** Events 存储 */
|
||||
events: EventsStore;
|
||||
/** Queue 存储 */
|
||||
queue: RunQueue;
|
||||
/** 持久化变量存储 */
|
||||
persistentVars: PersistentVarsStore;
|
||||
/** 触发器存储 */
|
||||
triggers: TriggersStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 NotImplemented 的 Store
|
||||
* @description 避免 Proxy 生成 'then' 导致 thenable 行为
|
||||
*/
|
||||
function createNotImplementedStore<T extends object>(name: string): T {
|
||||
const target = {} as T;
|
||||
return new Proxy(target, {
|
||||
get(_, prop) {
|
||||
// Avoid thenable behavior by returning undefined for 'then'
|
||||
if (prop === 'then') {
|
||||
return undefined;
|
||||
}
|
||||
return async () => {
|
||||
throw new Error(`${name}.${String(prop)} not implemented`);
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 NotImplemented 的 StoragePort
|
||||
* @description Phase 0 占位实现
|
||||
*/
|
||||
export function createNotImplementedStoragePort(): StoragePort {
|
||||
return {
|
||||
flows: createNotImplementedStore<FlowsStore>('FlowsStore'),
|
||||
runs: createNotImplementedStore<RunsStore>('RunsStore'),
|
||||
events: createNotImplementedStore<EventsStore>('EventsStore'),
|
||||
queue: createNotImplementedStore<RunQueue>('RunQueue'),
|
||||
persistentVars: createNotImplementedStore<PersistentVarsStore>('PersistentVarsStore'),
|
||||
triggers: createNotImplementedStore<TriggersStore>('TriggersStore'),
|
||||
};
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @fileoverview EventsBus Interface and Implementation
|
||||
* @description Event subscription, publishing, and persistence
|
||||
*/
|
||||
|
||||
import type { RunId } from '../../domain/ids';
|
||||
import type { RunEvent, RunEventInput, Unsubscribe } from '../../domain/events';
|
||||
import type { EventsStore } from '../storage/storage-port';
|
||||
|
||||
/**
|
||||
* Event query parameters
|
||||
*/
|
||||
export interface EventsQuery {
|
||||
/** Run ID */
|
||||
runId: RunId;
|
||||
/** Starting sequence number (inclusive) */
|
||||
fromSeq?: number;
|
||||
/** Maximum number of results */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription filter
|
||||
*/
|
||||
export interface EventsFilter {
|
||||
/** Only receive events for this Run */
|
||||
runId?: RunId;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventsBus Interface
|
||||
* @description Responsible for event subscription, publishing, and persistence
|
||||
*/
|
||||
export interface EventsBus {
|
||||
/**
|
||||
* Subscribe to events
|
||||
* @param listener Event listener
|
||||
* @param filter Optional filter
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe;
|
||||
|
||||
/**
|
||||
* Append event
|
||||
* @description Delegates to EventsStore for atomic seq allocation, then broadcasts
|
||||
* @param event Event input (without seq)
|
||||
* @returns Complete event (with seq and ts)
|
||||
*/
|
||||
append(event: RunEventInput): Promise<RunEvent>;
|
||||
|
||||
/**
|
||||
* Query historical events
|
||||
* @param query Query parameters
|
||||
* @returns Events sorted by seq ascending
|
||||
*/
|
||||
list(query: EventsQuery): Promise<RunEvent[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create NotImplemented EventsBus
|
||||
* @description Phase 0 placeholder
|
||||
*/
|
||||
export function createNotImplementedEventsBus(): EventsBus {
|
||||
const notImplemented = () => {
|
||||
throw new Error('EventsBus not implemented');
|
||||
};
|
||||
|
||||
return {
|
||||
subscribe: () => {
|
||||
notImplemented();
|
||||
return () => {};
|
||||
},
|
||||
append: async () => notImplemented(),
|
||||
list: async () => notImplemented(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener entry for subscription management
|
||||
*/
|
||||
interface ListenerEntry {
|
||||
listener: (event: RunEvent) => void;
|
||||
filter?: EventsFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage-backed EventsBus Implementation
|
||||
* @description
|
||||
* - seq allocation is done by EventsStore.append() (atomic with RunRecordV3.nextSeq)
|
||||
* - broadcast happens only after append resolves (i.e. after commit)
|
||||
*/
|
||||
export class StorageBackedEventsBus implements EventsBus {
|
||||
private listeners = new Set<ListenerEntry>();
|
||||
|
||||
constructor(private readonly store: EventsStore) {}
|
||||
|
||||
subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe {
|
||||
const entry: ListenerEntry = { listener, filter };
|
||||
this.listeners.add(entry);
|
||||
return () => {
|
||||
this.listeners.delete(entry);
|
||||
};
|
||||
}
|
||||
|
||||
async append(input: RunEventInput): Promise<RunEvent> {
|
||||
// Delegate to storage for atomic seq allocation
|
||||
const event = await this.store.append(input);
|
||||
|
||||
// Broadcast after successful commit
|
||||
this.broadcast(event);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
async list(query: EventsQuery): Promise<RunEvent[]> {
|
||||
return this.store.list(query.runId, {
|
||||
fromSeq: query.fromSeq,
|
||||
limit: query.limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast event to all matching listeners
|
||||
*/
|
||||
private broadcast(event: RunEvent): void {
|
||||
const { runId } = event;
|
||||
for (const { listener, filter } of this.listeners) {
|
||||
if (!filter || !filter.runId || filter.runId === runId) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.error('[StorageBackedEventsBus] Listener error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory EventsBus for testing
|
||||
* @description Uses internal seq counter, NOT suitable for production
|
||||
* @deprecated Use StorageBackedEventsBus with mock EventsStore for testing
|
||||
*/
|
||||
export class InMemoryEventsBus implements EventsBus {
|
||||
private events = new Map<RunId, RunEvent[]>();
|
||||
private seqCounters = new Map<RunId, number>();
|
||||
private listeners = new Set<ListenerEntry>();
|
||||
|
||||
subscribe(listener: (event: RunEvent) => void, filter?: EventsFilter): Unsubscribe {
|
||||
const entry: ListenerEntry = { listener, filter };
|
||||
this.listeners.add(entry);
|
||||
return () => {
|
||||
this.listeners.delete(entry);
|
||||
};
|
||||
}
|
||||
|
||||
async append(input: RunEventInput): Promise<RunEvent> {
|
||||
const { runId } = input;
|
||||
|
||||
// Allocate seq (NOT atomic, for testing only)
|
||||
const currentSeq = this.seqCounters.get(runId) ?? 0;
|
||||
const seq = currentSeq + 1;
|
||||
this.seqCounters.set(runId, seq);
|
||||
|
||||
// Create complete event
|
||||
const event: RunEvent = {
|
||||
...input,
|
||||
seq,
|
||||
ts: input.ts ?? Date.now(),
|
||||
} as RunEvent;
|
||||
|
||||
// Store
|
||||
const runEvents = this.events.get(runId) ?? [];
|
||||
runEvents.push(event);
|
||||
this.events.set(runId, runEvents);
|
||||
|
||||
// Broadcast
|
||||
for (const { listener, filter } of this.listeners) {
|
||||
if (!filter || !filter.runId || filter.runId === runId) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.error('[InMemoryEventsBus] Listener error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
async list(query: EventsQuery): Promise<RunEvent[]> {
|
||||
const runEvents = this.events.get(query.runId) ?? [];
|
||||
|
||||
let result = runEvents;
|
||||
|
||||
if (query.fromSeq !== undefined) {
|
||||
result = result.filter((e) => e.seq >= query.fromSeq!);
|
||||
}
|
||||
|
||||
if (query.limit !== undefined) {
|
||||
result = result.slice(0, query.limit);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data (for testing)
|
||||
*/
|
||||
clear(): void {
|
||||
this.events.clear();
|
||||
this.seqCounters.clear();
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current seq for a run (for testing)
|
||||
*/
|
||||
getSeq(runId: RunId): number {
|
||||
return this.seqCounters.get(runId) ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @fileoverview Transport 模块导出入口
|
||||
*/
|
||||
|
||||
export * from './rpc';
|
||||
export * from './rpc-server';
|
||||
export * from './events-bus';
|
||||
+1168
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @fileoverview Port RPC 协议定义
|
||||
* @description 定义通过 chrome.runtime.Port 进行通信的协议类型
|
||||
*/
|
||||
|
||||
import type { JsonObject, JsonValue } from '../../domain/json';
|
||||
import type { RunId } from '../../domain/ids';
|
||||
import type { RunEvent } from '../../domain/events';
|
||||
|
||||
/** Port 名称 */
|
||||
export const RR_V3_PORT_NAME = 'rr_v3' as const;
|
||||
|
||||
/**
|
||||
* RPC 方法名称
|
||||
*/
|
||||
export type RpcMethod =
|
||||
// 查询方法
|
||||
| 'rr_v3.listRuns'
|
||||
| 'rr_v3.getRun'
|
||||
| 'rr_v3.getEvents'
|
||||
// Flow 管理方法
|
||||
| 'rr_v3.getFlow'
|
||||
| 'rr_v3.listFlows'
|
||||
| 'rr_v3.saveFlow'
|
||||
| 'rr_v3.deleteFlow'
|
||||
// 触发器管理方法
|
||||
| 'rr_v3.createTrigger'
|
||||
| 'rr_v3.updateTrigger'
|
||||
| 'rr_v3.deleteTrigger'
|
||||
| 'rr_v3.getTrigger'
|
||||
| 'rr_v3.listTriggers'
|
||||
| 'rr_v3.enableTrigger'
|
||||
| 'rr_v3.disableTrigger'
|
||||
| 'rr_v3.fireTrigger'
|
||||
// 队列管理方法
|
||||
| 'rr_v3.enqueueRun'
|
||||
| 'rr_v3.listQueue'
|
||||
| 'rr_v3.cancelQueueItem'
|
||||
// 控制方法
|
||||
| 'rr_v3.startRun'
|
||||
| 'rr_v3.cancelRun'
|
||||
| 'rr_v3.pauseRun'
|
||||
| 'rr_v3.resumeRun'
|
||||
// 调试方法
|
||||
| 'rr_v3.debug'
|
||||
// 订阅方法
|
||||
| 'rr_v3.subscribe'
|
||||
| 'rr_v3.unsubscribe';
|
||||
|
||||
/**
|
||||
* RPC 请求消息
|
||||
*/
|
||||
export interface RpcRequest {
|
||||
type: 'rr_v3.request';
|
||||
/** 请求 ID(用于匹配响应) */
|
||||
requestId: string;
|
||||
/** 方法名 */
|
||||
method: RpcMethod;
|
||||
/** 参数 */
|
||||
params?: JsonObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC 成功响应
|
||||
*/
|
||||
export interface RpcResponseOk {
|
||||
type: 'rr_v3.response';
|
||||
/** 对应的请求 ID */
|
||||
requestId: string;
|
||||
ok: true;
|
||||
/** 返回结果 */
|
||||
result: JsonValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC 错误响应
|
||||
*/
|
||||
export interface RpcResponseErr {
|
||||
type: 'rr_v3.response';
|
||||
/** 对应的请求 ID */
|
||||
requestId: string;
|
||||
ok: false;
|
||||
/** 错误信息 */
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC 响应
|
||||
*/
|
||||
export type RpcResponse = RpcResponseOk | RpcResponseErr;
|
||||
|
||||
/**
|
||||
* RPC 事件推送
|
||||
*/
|
||||
export interface RpcEventMessage {
|
||||
type: 'rr_v3.event';
|
||||
/** 事件数据 */
|
||||
event: RunEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC 订阅确认
|
||||
*/
|
||||
export interface RpcSubscribeAck {
|
||||
type: 'rr_v3.subscribeAck';
|
||||
/** 订阅的 Run ID(可选,null 表示订阅所有) */
|
||||
runId: RunId | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有 RPC 消息类型
|
||||
*/
|
||||
export type RpcMessage =
|
||||
| RpcRequest
|
||||
| RpcResponseOk
|
||||
| RpcResponseErr
|
||||
| RpcEventMessage
|
||||
| RpcSubscribeAck;
|
||||
|
||||
/**
|
||||
* 生成唯一的请求 ID
|
||||
*/
|
||||
export function generateRequestId(): string {
|
||||
return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断消息是否为 RPC 请求
|
||||
*/
|
||||
export function isRpcRequest(msg: unknown): msg is RpcRequest {
|
||||
return typeof msg === 'object' && msg !== null && (msg as RpcRequest).type === 'rr_v3.request';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断消息是否为 RPC 响应
|
||||
*/
|
||||
export function isRpcResponse(msg: unknown): msg is RpcResponse {
|
||||
return typeof msg === 'object' && msg !== null && (msg as RpcResponse).type === 'rr_v3.response';
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断消息是否为 RPC 事件
|
||||
*/
|
||||
export function isRpcEvent(msg: unknown): msg is RpcEventMessage {
|
||||
return typeof msg === 'object' && msg !== null && (msg as RpcEventMessage).type === 'rr_v3.event';
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 RPC 请求
|
||||
*/
|
||||
export function createRpcRequest(method: RpcMethod, params?: JsonObject): RpcRequest {
|
||||
return {
|
||||
type: 'rr_v3.request',
|
||||
requestId: generateRequestId(),
|
||||
method,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建成功响应
|
||||
*/
|
||||
export function createRpcResponseOk(requestId: string, result: JsonValue): RpcResponseOk {
|
||||
return {
|
||||
type: 'rr_v3.response',
|
||||
requestId,
|
||||
ok: true,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建错误响应
|
||||
*/
|
||||
export function createRpcResponseErr(requestId: string, error: string): RpcResponseErr {
|
||||
return {
|
||||
type: 'rr_v3.response',
|
||||
requestId,
|
||||
ok: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建事件消息
|
||||
*/
|
||||
export function createRpcEventMessage(event: RunEvent): RpcEventMessage {
|
||||
return {
|
||||
type: 'rr_v3.event',
|
||||
event,
|
||||
};
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @fileoverview Command Trigger Handler (P4-04)
|
||||
* @description
|
||||
* Listens to `chrome.commands.onCommand` and fires installed command triggers.
|
||||
*
|
||||
* Command triggers allow users to execute flows via keyboard shortcuts
|
||||
* defined in the extension's manifest.
|
||||
*
|
||||
* Design notes:
|
||||
* - Commands must be registered in manifest.json under the "commands" key
|
||||
* - Each command is identified by its commandKey (e.g., "run-flow-1")
|
||||
* - Active tab info is captured when available
|
||||
*/
|
||||
|
||||
import type { TriggerId } from '../../domain/ids';
|
||||
import type { TriggerSpecByKind } from '../../domain/triggers';
|
||||
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface CommandTriggerHandlerDeps {
|
||||
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
}
|
||||
|
||||
type CommandTriggerSpec = TriggerSpecByKind<'command'>;
|
||||
|
||||
interface InstalledCommandTrigger {
|
||||
spec: CommandTriggerSpec;
|
||||
}
|
||||
|
||||
// ==================== Handler Implementation ====================
|
||||
|
||||
/**
|
||||
* Create command trigger handler factory
|
||||
*/
|
||||
export function createCommandTriggerHandlerFactory(
|
||||
deps?: CommandTriggerHandlerDeps,
|
||||
): TriggerHandlerFactory<'command'> {
|
||||
return (fireCallback) => createCommandTriggerHandler(fireCallback, deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create command trigger handler
|
||||
*/
|
||||
export function createCommandTriggerHandler(
|
||||
fireCallback: TriggerFireCallback,
|
||||
deps?: CommandTriggerHandlerDeps,
|
||||
): TriggerHandler<'command'> {
|
||||
const logger = deps?.logger ?? console;
|
||||
|
||||
// Map commandKey -> triggerId for fast lookup
|
||||
const commandKeyToTriggerId = new Map<string, TriggerId>();
|
||||
const installed = new Map<TriggerId, InstalledCommandTrigger>();
|
||||
let listening = false;
|
||||
|
||||
/**
|
||||
* Handle chrome.commands.onCommand event
|
||||
*/
|
||||
const onCommand = (command: string, tab?: chrome.tabs.Tab): void => {
|
||||
const triggerId = commandKeyToTriggerId.get(command);
|
||||
if (!triggerId) return;
|
||||
|
||||
const trigger = installed.get(triggerId);
|
||||
if (!trigger) return;
|
||||
|
||||
// Fire and forget: chrome event listeners should not block
|
||||
Promise.resolve(
|
||||
fireCallback.onFire(triggerId, {
|
||||
sourceTabId: tab?.id,
|
||||
sourceUrl: tab?.url,
|
||||
}),
|
||||
).catch((e) => {
|
||||
logger.error(`[CommandTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure listener is registered
|
||||
*/
|
||||
function ensureListening(): void {
|
||||
if (listening) return;
|
||||
if (!chrome.commands?.onCommand?.addListener) {
|
||||
logger.warn('[CommandTriggerHandler] chrome.commands.onCommand is unavailable');
|
||||
return;
|
||||
}
|
||||
chrome.commands.onCommand.addListener(onCommand);
|
||||
listening = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening
|
||||
*/
|
||||
function stopListening(): void {
|
||||
if (!listening) return;
|
||||
try {
|
||||
chrome.commands.onCommand.removeListener(onCommand);
|
||||
} catch (e) {
|
||||
logger.debug('[CommandTriggerHandler] removeListener failed:', e);
|
||||
} finally {
|
||||
listening = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'command',
|
||||
|
||||
async install(trigger: CommandTriggerSpec): Promise<void> {
|
||||
const { id, commandKey } = trigger;
|
||||
|
||||
// Warn if commandKey already used by another trigger
|
||||
const existingTriggerId = commandKeyToTriggerId.get(commandKey);
|
||||
if (existingTriggerId && existingTriggerId !== id) {
|
||||
logger.warn(
|
||||
`[CommandTriggerHandler] Command "${commandKey}" already used by trigger "${existingTriggerId}", overwriting with "${id}"`,
|
||||
);
|
||||
// Remove old mapping
|
||||
installed.delete(existingTriggerId);
|
||||
}
|
||||
|
||||
installed.set(id, { spec: trigger });
|
||||
commandKeyToTriggerId.set(commandKey, id);
|
||||
ensureListening();
|
||||
},
|
||||
|
||||
async uninstall(triggerId: string): Promise<void> {
|
||||
const trigger = installed.get(triggerId as TriggerId);
|
||||
if (trigger) {
|
||||
commandKeyToTriggerId.delete(trigger.spec.commandKey);
|
||||
installed.delete(triggerId as TriggerId);
|
||||
}
|
||||
|
||||
if (installed.size === 0) {
|
||||
stopListening();
|
||||
}
|
||||
},
|
||||
|
||||
async uninstallAll(): Promise<void> {
|
||||
installed.clear();
|
||||
commandKeyToTriggerId.clear();
|
||||
stopListening();
|
||||
},
|
||||
|
||||
getInstalledIds(): string[] {
|
||||
return Array.from(installed.keys());
|
||||
},
|
||||
};
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* @fileoverview ContextMenu Trigger Handler (P4-05)
|
||||
* @description
|
||||
* Uses `chrome.contextMenus` API to create right-click menu items that fire triggers.
|
||||
*
|
||||
* Design notes:
|
||||
* - Each trigger creates a separate menu item with unique ID
|
||||
* - Menu item ID is prefixed with 'rr_v3_' to avoid conflicts
|
||||
* - Context types: 'page', 'selection', 'link', 'image', 'video', 'audio', etc.
|
||||
* - Captures click info and tab info for trigger context
|
||||
*/
|
||||
|
||||
import type { TriggerId } from '../../domain/ids';
|
||||
import type { TriggerSpecByKind } from '../../domain/triggers';
|
||||
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface ContextMenuTriggerHandlerDeps {
|
||||
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
}
|
||||
|
||||
type ContextMenuTriggerSpec = TriggerSpecByKind<'contextMenu'>;
|
||||
|
||||
interface InstalledContextMenuTrigger {
|
||||
spec: ContextMenuTriggerSpec;
|
||||
menuItemId: string;
|
||||
}
|
||||
|
||||
// ==================== Constants ====================
|
||||
|
||||
const MENU_ITEM_PREFIX = 'rr_v3_';
|
||||
|
||||
// Default context types if not specified
|
||||
const DEFAULT_CONTEXTS: chrome.contextMenus.ContextType[] = ['page'];
|
||||
|
||||
// ==================== Handler Implementation ====================
|
||||
|
||||
/**
|
||||
* Create context menu trigger handler factory
|
||||
*/
|
||||
export function createContextMenuTriggerHandlerFactory(
|
||||
deps?: ContextMenuTriggerHandlerDeps,
|
||||
): TriggerHandlerFactory<'contextMenu'> {
|
||||
return (fireCallback) => createContextMenuTriggerHandler(fireCallback, deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create context menu trigger handler
|
||||
*/
|
||||
export function createContextMenuTriggerHandler(
|
||||
fireCallback: TriggerFireCallback,
|
||||
deps?: ContextMenuTriggerHandlerDeps,
|
||||
): TriggerHandler<'contextMenu'> {
|
||||
const logger = deps?.logger ?? console;
|
||||
|
||||
// Map menuItemId -> triggerId for fast lookup
|
||||
const menuItemIdToTriggerId = new Map<string, TriggerId>();
|
||||
const installed = new Map<TriggerId, InstalledContextMenuTrigger>();
|
||||
let listening = false;
|
||||
|
||||
/**
|
||||
* Generate unique menu item ID for a trigger
|
||||
*/
|
||||
function generateMenuItemId(triggerId: TriggerId): string {
|
||||
return `${MENU_ITEM_PREFIX}${triggerId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle chrome.contextMenus.onClicked event
|
||||
*/
|
||||
const onClicked = (info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab): void => {
|
||||
const menuItemId = String(info.menuItemId);
|
||||
const triggerId = menuItemIdToTriggerId.get(menuItemId);
|
||||
if (!triggerId) return;
|
||||
|
||||
const trigger = installed.get(triggerId);
|
||||
if (!trigger) return;
|
||||
|
||||
// Fire and forget: chrome event listeners should not block
|
||||
Promise.resolve(
|
||||
fireCallback.onFire(triggerId, {
|
||||
sourceTabId: tab?.id,
|
||||
sourceUrl: info.pageUrl ?? tab?.url,
|
||||
}),
|
||||
).catch((e) => {
|
||||
logger.error(`[ContextMenuTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure listener is registered
|
||||
*/
|
||||
function ensureListening(): void {
|
||||
if (listening) return;
|
||||
if (!chrome.contextMenus?.onClicked?.addListener) {
|
||||
logger.warn('[ContextMenuTriggerHandler] chrome.contextMenus.onClicked is unavailable');
|
||||
return;
|
||||
}
|
||||
chrome.contextMenus.onClicked.addListener(onClicked);
|
||||
listening = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening
|
||||
*/
|
||||
function stopListening(): void {
|
||||
if (!listening) return;
|
||||
try {
|
||||
chrome.contextMenus.onClicked.removeListener(onClicked);
|
||||
} catch (e) {
|
||||
logger.debug('[ContextMenuTriggerHandler] removeListener failed:', e);
|
||||
} finally {
|
||||
listening = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert context types from spec to chrome API format
|
||||
*/
|
||||
function normalizeContexts(
|
||||
contexts: ReadonlyArray<string> | undefined,
|
||||
): chrome.contextMenus.ContextType[] {
|
||||
if (!contexts || contexts.length === 0) {
|
||||
return DEFAULT_CONTEXTS;
|
||||
}
|
||||
return contexts as chrome.contextMenus.ContextType[];
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'contextMenu',
|
||||
|
||||
async install(trigger: ContextMenuTriggerSpec): Promise<void> {
|
||||
const { id, title, contexts } = trigger;
|
||||
const menuItemId = generateMenuItemId(id);
|
||||
|
||||
// Check if chrome.contextMenus.create is available
|
||||
if (!chrome.contextMenus?.create) {
|
||||
logger.warn('[ContextMenuTriggerHandler] chrome.contextMenus.create is unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create menu item
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
chrome.contextMenus.create(
|
||||
{
|
||||
id: menuItemId,
|
||||
title: title,
|
||||
contexts: normalizeContexts(contexts),
|
||||
},
|
||||
() => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
installed.set(id, { spec: trigger, menuItemId });
|
||||
menuItemIdToTriggerId.set(menuItemId, id);
|
||||
ensureListening();
|
||||
},
|
||||
|
||||
async uninstall(triggerId: string): Promise<void> {
|
||||
const trigger = installed.get(triggerId as TriggerId);
|
||||
if (!trigger) return;
|
||||
|
||||
// Remove menu item
|
||||
if (chrome.contextMenus?.remove) {
|
||||
await new Promise<void>((resolve) => {
|
||||
chrome.contextMenus.remove(trigger.menuItemId, () => {
|
||||
// Ignore errors (item may not exist)
|
||||
if (chrome.runtime.lastError) {
|
||||
logger.debug(
|
||||
`[ContextMenuTriggerHandler] Failed to remove menu item: ${chrome.runtime.lastError.message}`,
|
||||
);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
menuItemIdToTriggerId.delete(trigger.menuItemId);
|
||||
installed.delete(triggerId as TriggerId);
|
||||
|
||||
if (installed.size === 0) {
|
||||
stopListening();
|
||||
}
|
||||
},
|
||||
|
||||
async uninstallAll(): Promise<void> {
|
||||
// Remove all menu items created by this handler
|
||||
if (chrome.contextMenus?.remove) {
|
||||
const removePromises = Array.from(installed.values()).map(
|
||||
(trigger) =>
|
||||
new Promise<void>((resolve) => {
|
||||
chrome.contextMenus.remove(trigger.menuItemId, () => {
|
||||
// Ignore errors
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
);
|
||||
await Promise.all(removePromises);
|
||||
}
|
||||
|
||||
installed.clear();
|
||||
menuItemIdToTriggerId.clear();
|
||||
stopListening();
|
||||
},
|
||||
|
||||
getInstalledIds(): string[] {
|
||||
return Array.from(installed.keys());
|
||||
},
|
||||
};
|
||||
}
|
||||
+583
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* @fileoverview Cron Trigger Handler (P4-07)
|
||||
* @description
|
||||
* Schedules cron triggers via `chrome.alarms` (MV3).
|
||||
*
|
||||
* Strategy:
|
||||
* - One alarm per trigger (one-shot `when` alarm).
|
||||
* - When fired: call `fireCallback.onFire(triggerId)` then compute and schedule next.
|
||||
*
|
||||
* Timezone:
|
||||
* - Accepts IANA timezones (e.g. "UTC", "Asia/Shanghai").
|
||||
* - Validated via `Intl.DateTimeFormat(..., { timeZone })`.
|
||||
*
|
||||
* Cron parsing:
|
||||
* - Delegated to an external library (recommended: `cron-parser`) to avoid DST edge cases.
|
||||
* - Falls back to a minimal built-in parser if library not available.
|
||||
*/
|
||||
|
||||
import type { UnixMillis } from '../../domain/json';
|
||||
import type { TriggerId } from '../../domain/ids';
|
||||
import type { TriggerSpecByKind } from '../../domain/triggers';
|
||||
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
type CronTriggerSpec = TriggerSpecByKind<'cron'>;
|
||||
|
||||
/**
|
||||
* Function to compute next fire time from cron expression
|
||||
*/
|
||||
export type ComputeNextFireAtMs = (input: {
|
||||
cron: string;
|
||||
timezone?: string;
|
||||
fromMs: UnixMillis;
|
||||
}) => UnixMillis | Promise<UnixMillis>;
|
||||
|
||||
export interface CronTriggerHandlerDeps {
|
||||
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
now?: () => UnixMillis;
|
||||
computeNextFireAtMs?: ComputeNextFireAtMs;
|
||||
}
|
||||
|
||||
interface InstalledCronTrigger {
|
||||
spec: CronTriggerSpec;
|
||||
timezone?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// ==================== Constants ====================
|
||||
|
||||
const ALARM_PREFIX = 'rr_v3_cron_';
|
||||
|
||||
// ==================== Utilities ====================
|
||||
|
||||
/**
|
||||
* Normalize cron expression
|
||||
*/
|
||||
function normalizeCronExpression(value: unknown): string {
|
||||
const raw = typeof value === 'string' ? value : String(value ?? '');
|
||||
const normalized = raw.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) {
|
||||
throw new Error('cron must be a non-empty string');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize timezone
|
||||
*/
|
||||
function normalizeTimezone(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('timezone must be a string');
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
try {
|
||||
// Throws RangeError for invalid IANA timezones
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: trimmed }).format(new Date(0));
|
||||
} catch {
|
||||
throw new Error(`Invalid timezone: "${trimmed}"`);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate alarm name for trigger
|
||||
*/
|
||||
function alarmNameForTrigger(triggerId: TriggerId): string {
|
||||
return `${ALARM_PREFIX}${triggerId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse trigger ID from alarm name
|
||||
*/
|
||||
function parseTriggerIdFromAlarmName(name: string): TriggerId | null {
|
||||
if (!name.startsWith(ALARM_PREFIX)) return null;
|
||||
const id = name.slice(ALARM_PREFIX.length);
|
||||
return id ? (id as TriggerId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple cron expression parser (minimal subset)
|
||||
* Supports: minute hour day-of-month month day-of-week
|
||||
* Values: numbers, * (any), intervals (e.g., * /5)
|
||||
*
|
||||
* For production use with complex cron expressions, install 'cron-parser'.
|
||||
*/
|
||||
function parseSimpleCron(cron: string): {
|
||||
minute: number[];
|
||||
hour: number[];
|
||||
dayOfMonth: number[];
|
||||
month: number[];
|
||||
dayOfWeek: number[];
|
||||
} {
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length !== 5) {
|
||||
throw new Error(`Invalid cron expression: expected 5 fields, got ${parts.length}`);
|
||||
}
|
||||
|
||||
function parseField(field: string, min: number, max: number): number[] {
|
||||
const values: number[] = [];
|
||||
|
||||
for (const part of field.split(',')) {
|
||||
if (part === '*') {
|
||||
for (let i = min; i <= max; i++) values.push(i);
|
||||
} else if (part.includes('/')) {
|
||||
const [range, stepStr] = part.split('/');
|
||||
const step = parseInt(stepStr, 10);
|
||||
// Guard against infinite loop: step must be positive
|
||||
if (!Number.isFinite(step) || step < 1) {
|
||||
throw new Error(`Invalid step in cron field: "${part}" (step must be >= 1)`);
|
||||
}
|
||||
const start = range === '*' ? min : parseInt(range, 10);
|
||||
if (!Number.isFinite(start) || start < min || start > max) {
|
||||
throw new Error(`Invalid range start in cron field: "${part}"`);
|
||||
}
|
||||
for (let i = start; i <= max; i += step) values.push(i);
|
||||
} else if (part.includes('-')) {
|
||||
const [startStr, endStr] = part.split('-');
|
||||
const start = parseInt(startStr, 10);
|
||||
const end = parseInt(endStr, 10);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) {
|
||||
throw new Error(`Invalid range in cron field: "${part}"`);
|
||||
}
|
||||
for (let i = start; i <= end; i++) values.push(i);
|
||||
} else {
|
||||
const num = parseInt(part, 10);
|
||||
if (!Number.isFinite(num)) {
|
||||
throw new Error(`Invalid number in cron field: "${part}"`);
|
||||
}
|
||||
values.push(num);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate all values are within bounds
|
||||
for (const v of values) {
|
||||
if (v < min || v > max) {
|
||||
throw new Error(`Cron field value ${v} out of range [${min}, ${max}]`);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(values)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
return {
|
||||
minute: parseField(parts[0], 0, 59),
|
||||
hour: parseField(parts[1], 0, 23),
|
||||
dayOfMonth: parseField(parts[2], 1, 31),
|
||||
month: parseField(parts[3], 1, 12),
|
||||
dayOfWeek: parseField(parts[4], 0, 6),
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Timezone Utilities ====================
|
||||
|
||||
interface ZonedTimeParts {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
dayOfWeek: number;
|
||||
}
|
||||
|
||||
// Cache DateTimeFormat instances per timezone for performance
|
||||
const dtfCache = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
/**
|
||||
* Get or create cached DateTimeFormat for a timezone
|
||||
*/
|
||||
function getDateTimeFormat(timezone: string): Intl.DateTimeFormat {
|
||||
let dtf = dtfCache.get(timezone);
|
||||
if (!dtf) {
|
||||
dtf = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
weekday: 'short',
|
||||
});
|
||||
dtfCache.set(timezone, dtf);
|
||||
}
|
||||
return dtf;
|
||||
}
|
||||
|
||||
// Map weekday string to number (0=Sunday)
|
||||
const WEEKDAY_MAP: Record<string, number> = {
|
||||
Sun: 0,
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get time parts in a specific timezone using Intl.DateTimeFormat
|
||||
*/
|
||||
function getZonedTimeParts(utcMs: UnixMillis, timezone: string): ZonedTimeParts {
|
||||
const dtf = getDateTimeFormat(timezone);
|
||||
const parts = dtf.formatToParts(new Date(utcMs));
|
||||
const map: Record<string, string> = Object.create(null);
|
||||
for (const p of parts) {
|
||||
if (p.type !== 'literal') map[p.type] = p.value;
|
||||
}
|
||||
|
||||
// Handle edge case: some environments emit "24" for midnight
|
||||
const rawHour = Number(map.hour);
|
||||
|
||||
return {
|
||||
year: Number(map.year),
|
||||
month: Number(map.month),
|
||||
day: Number(map.day),
|
||||
hour: rawHour === 24 ? 0 : rawHour,
|
||||
minute: Number(map.minute),
|
||||
dayOfWeek: WEEKDAY_MAP[map.weekday] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate timezone offset in milliseconds at a given UTC timestamp
|
||||
* Positive offset means timezone is ahead of UTC (e.g., Asia/Shanghai = +8h = +28800000ms)
|
||||
*/
|
||||
function getTimezoneOffsetMs(utcMs: UnixMillis, timezone: string): number {
|
||||
const z = getZonedTimeParts(utcMs, timezone);
|
||||
const asUtc = Date.UTC(z.year, z.month - 1, z.day, z.hour, z.minute, 0);
|
||||
return asUtc - utcMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert zoned datetime to UTC milliseconds
|
||||
* Uses iterative refinement to handle DST transitions
|
||||
*/
|
||||
function zonedToUtcMs(
|
||||
zoned: { year: number; month: number; day: number; hour: number; minute: number },
|
||||
timezone: string,
|
||||
): UnixMillis {
|
||||
// Start with the zoned time interpreted as UTC
|
||||
const baseUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0);
|
||||
|
||||
// Iteratively solve: utcMs = baseUtc - offset(utcMs)
|
||||
let utcMs = baseUtc;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const offsetMs = getTimezoneOffsetMs(utcMs, timezone);
|
||||
const next = baseUtc - offsetMs;
|
||||
if (next === utcMs) break;
|
||||
utcMs = next;
|
||||
}
|
||||
return utcMs;
|
||||
}
|
||||
|
||||
// ==================== Cron Computation ====================
|
||||
|
||||
/**
|
||||
* Compute next fire time using built-in simple parser (local timezone)
|
||||
*/
|
||||
function computeNextFireAtMsLocal(
|
||||
parsed: ReturnType<typeof parseSimpleCron>,
|
||||
fromMs: UnixMillis,
|
||||
): UnixMillis {
|
||||
const baseDate = new Date(fromMs + 1000); // Add 1 second to ensure next occurrence
|
||||
|
||||
for (let dayOffset = 0; dayOffset < 366; dayOffset++) {
|
||||
for (const hour of parsed.hour) {
|
||||
for (const minute of parsed.minute) {
|
||||
const candidate = new Date(baseDate);
|
||||
candidate.setDate(candidate.getDate() + dayOffset);
|
||||
candidate.setHours(hour, minute, 0, 0);
|
||||
|
||||
if (candidate.getTime() <= fromMs) continue;
|
||||
|
||||
const month = candidate.getMonth() + 1;
|
||||
const dayOfMonth = candidate.getDate();
|
||||
const dayOfWeek = candidate.getDay();
|
||||
|
||||
if (!parsed.month.includes(month)) continue;
|
||||
if (!parsed.dayOfMonth.includes(dayOfMonth) && !parsed.dayOfWeek.includes(dayOfWeek))
|
||||
continue;
|
||||
|
||||
return candidate.getTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to compute next cron fire time within 1 year');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute next fire time in a specific timezone
|
||||
*/
|
||||
function computeNextFireAtMsZoned(
|
||||
parsed: ReturnType<typeof parseSimpleCron>,
|
||||
fromMs: UnixMillis,
|
||||
timezone: string,
|
||||
): UnixMillis {
|
||||
const baseZoned = getZonedTimeParts(fromMs + 1000, timezone);
|
||||
const dayCursor = new Date(Date.UTC(baseZoned.year, baseZoned.month - 1, baseZoned.day));
|
||||
|
||||
for (let dayOffset = 0; dayOffset < 366; dayOffset++) {
|
||||
if (dayOffset > 0) dayCursor.setUTCDate(dayCursor.getUTCDate() + 1);
|
||||
|
||||
const year = dayCursor.getUTCFullYear();
|
||||
const month = dayCursor.getUTCMonth() + 1;
|
||||
const dayOfMonth = dayCursor.getUTCDate();
|
||||
const dayOfWeek = dayCursor.getUTCDay();
|
||||
|
||||
if (!parsed.month.includes(month)) continue;
|
||||
if (!parsed.dayOfMonth.includes(dayOfMonth) && !parsed.dayOfWeek.includes(dayOfWeek)) continue;
|
||||
|
||||
for (const hour of parsed.hour) {
|
||||
for (const minute of parsed.minute) {
|
||||
const candidateUtcMs = zonedToUtcMs(
|
||||
{ year, month, day: dayOfMonth, hour, minute },
|
||||
timezone,
|
||||
);
|
||||
|
||||
if (candidateUtcMs <= fromMs) continue;
|
||||
|
||||
// Validate conversion didn't drift (DST gaps/ambiguity can cause skipped times)
|
||||
const candidateZoned = getZonedTimeParts(candidateUtcMs, timezone);
|
||||
if (
|
||||
candidateZoned.year !== year ||
|
||||
candidateZoned.month !== month ||
|
||||
candidateZoned.day !== dayOfMonth ||
|
||||
candidateZoned.hour !== hour ||
|
||||
candidateZoned.minute !== minute
|
||||
) {
|
||||
continue; // Skip DST gap times
|
||||
}
|
||||
|
||||
return candidateUtcMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to compute next cron fire time within 1 year');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute next fire time using built-in simple parser
|
||||
*/
|
||||
function computeNextFireAtMsSimple(input: {
|
||||
cron: string;
|
||||
timezone?: string;
|
||||
fromMs: UnixMillis;
|
||||
}): UnixMillis {
|
||||
const parsed = parseSimpleCron(input.cron);
|
||||
|
||||
if (input.timezone) {
|
||||
return computeNextFireAtMsZoned(parsed, input.fromMs, input.timezone);
|
||||
}
|
||||
|
||||
return computeNextFireAtMsLocal(parsed, input.fromMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default compute next fire time function
|
||||
* Uses simple built-in parser
|
||||
*/
|
||||
function defaultComputeNextFireAtMs(input: {
|
||||
cron: string;
|
||||
timezone?: string;
|
||||
fromMs: UnixMillis;
|
||||
}): UnixMillis {
|
||||
return computeNextFireAtMsSimple(input);
|
||||
}
|
||||
|
||||
// ==================== Handler Implementation ====================
|
||||
|
||||
/**
|
||||
* Create cron trigger handler factory
|
||||
*/
|
||||
export function createCronTriggerHandlerFactory(
|
||||
deps?: CronTriggerHandlerDeps,
|
||||
): TriggerHandlerFactory<'cron'> {
|
||||
return (fireCallback) => createCronTriggerHandler(fireCallback, deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create cron trigger handler
|
||||
*/
|
||||
export function createCronTriggerHandler(
|
||||
fireCallback: TriggerFireCallback,
|
||||
deps?: CronTriggerHandlerDeps,
|
||||
): TriggerHandler<'cron'> {
|
||||
const logger = deps?.logger ?? console;
|
||||
const now = deps?.now ?? (() => Date.now());
|
||||
const computeNextFireAtMs: ComputeNextFireAtMs =
|
||||
deps?.computeNextFireAtMs ?? defaultComputeNextFireAtMs;
|
||||
|
||||
const installed = new Map<TriggerId, InstalledCronTrigger>();
|
||||
const versions = new Map<TriggerId, number>();
|
||||
let listening = false;
|
||||
|
||||
/**
|
||||
* Bump version to invalidate pending operations
|
||||
*/
|
||||
function bumpVersion(triggerId: TriggerId): number {
|
||||
const next = (versions.get(triggerId) ?? 0) + 1;
|
||||
versions.set(triggerId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear alarm by name
|
||||
*/
|
||||
async function clearAlarmByName(name: string): Promise<void> {
|
||||
if (!chrome.alarms?.clear) return;
|
||||
try {
|
||||
await Promise.resolve(chrome.alarms.clear(name));
|
||||
} catch (e) {
|
||||
logger.debug('[CronTriggerHandler] alarms.clear failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cron alarms
|
||||
*/
|
||||
async function clearAllCronAlarms(): Promise<void> {
|
||||
if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return;
|
||||
try {
|
||||
const alarms = await Promise.resolve(chrome.alarms.getAll());
|
||||
const list = Array.isArray(alarms) ? alarms : [];
|
||||
await Promise.all(
|
||||
list
|
||||
.filter((a) => a?.name && a.name.startsWith(ALARM_PREFIX))
|
||||
.map((a) => clearAlarmByName(a.name)),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.debug('[CronTriggerHandler] alarms.getAll failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule next alarm for trigger
|
||||
*/
|
||||
async function scheduleNext(triggerId: TriggerId, expectedVersion: number): Promise<void> {
|
||||
if (!chrome.alarms?.create) {
|
||||
logger.warn('[CronTriggerHandler] chrome.alarms.create is unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = installed.get(triggerId);
|
||||
if (!entry || entry.version !== expectedVersion) return;
|
||||
|
||||
const fromMs = now();
|
||||
const nextMs = await Promise.resolve(
|
||||
computeNextFireAtMs({
|
||||
cron: entry.spec.cron,
|
||||
timezone: entry.timezone,
|
||||
fromMs,
|
||||
}),
|
||||
);
|
||||
|
||||
// Check version again after async
|
||||
if (installed.get(triggerId)?.version !== expectedVersion) return;
|
||||
|
||||
const name = alarmNameForTrigger(triggerId);
|
||||
await Promise.resolve(chrome.alarms.create(name, { when: nextMs }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle alarm event
|
||||
*/
|
||||
const onAlarm = (alarm: chrome.alarms.Alarm): void => {
|
||||
const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? '');
|
||||
if (!triggerId) return;
|
||||
|
||||
const entry = installed.get(triggerId);
|
||||
if (!entry) return;
|
||||
|
||||
const expectedVersion = entry.version;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await fireCallback.onFire(triggerId, {
|
||||
sourceTabId: undefined,
|
||||
sourceUrl: undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(`[CronTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
|
||||
} finally {
|
||||
// Reschedule if still valid
|
||||
// eslint-disable-next-line no-unsafe-finally
|
||||
if (installed.get(triggerId)?.version !== expectedVersion) return;
|
||||
try {
|
||||
await scheduleNext(triggerId, expectedVersion);
|
||||
} catch (e) {
|
||||
logger.error(`[CronTriggerHandler] Failed to reschedule trigger "${triggerId}":`, e);
|
||||
}
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
function ensureListening(): void {
|
||||
if (listening) return;
|
||||
if (!chrome.alarms?.onAlarm?.addListener) {
|
||||
logger.warn('[CronTriggerHandler] chrome.alarms.onAlarm is unavailable');
|
||||
return;
|
||||
}
|
||||
chrome.alarms.onAlarm.addListener(onAlarm);
|
||||
listening = true;
|
||||
}
|
||||
|
||||
function stopListening(): void {
|
||||
if (!listening) return;
|
||||
try {
|
||||
chrome.alarms.onAlarm.removeListener(onAlarm);
|
||||
} catch (e) {
|
||||
logger.debug('[CronTriggerHandler] alarms.onAlarm.removeListener failed:', e);
|
||||
} finally {
|
||||
listening = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'cron',
|
||||
|
||||
async install(trigger: CronTriggerSpec): Promise<void> {
|
||||
const cron = normalizeCronExpression(trigger.cron);
|
||||
const timezone = normalizeTimezone(trigger.timezone);
|
||||
|
||||
const version = bumpVersion(trigger.id);
|
||||
installed.set(trigger.id, {
|
||||
spec: { ...trigger, cron },
|
||||
timezone,
|
||||
version,
|
||||
});
|
||||
|
||||
ensureListening();
|
||||
await scheduleNext(trigger.id, version);
|
||||
},
|
||||
|
||||
async uninstall(triggerId: string): Promise<void> {
|
||||
const id = triggerId as TriggerId;
|
||||
bumpVersion(id);
|
||||
installed.delete(id);
|
||||
await clearAlarmByName(alarmNameForTrigger(id));
|
||||
|
||||
if (installed.size === 0) {
|
||||
stopListening();
|
||||
}
|
||||
},
|
||||
|
||||
async uninstallAll(): Promise<void> {
|
||||
for (const id of installed.keys()) bumpVersion(id);
|
||||
installed.clear();
|
||||
await clearAllCronAlarms();
|
||||
stopListening();
|
||||
},
|
||||
|
||||
getInstalledIds(): string[] {
|
||||
return Array.from(installed.keys());
|
||||
},
|
||||
};
|
||||
}
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* @fileoverview DOM Trigger Handler (P4-06)
|
||||
* @description
|
||||
* Bridges DOM triggers to a content-script MutationObserver (`inject-scripts/dom-observer.js`).
|
||||
*
|
||||
* Contract:
|
||||
* - Background -> content: { action: 'set_dom_triggers', triggers: [...] }
|
||||
* - Content -> background: { action: 'dom_trigger_fired', triggerId, url }
|
||||
* - Ping: { action: 'dom_observer_ping' } -> { status:'pong' }
|
||||
*
|
||||
* Design notes:
|
||||
* - Reuses existing V2 dom observer script for consistency and auditability.
|
||||
* - Single handler instance manages multiple triggers.
|
||||
* - Sync is coalesced to avoid storms during TriggerManager.refresh().
|
||||
* - Top-frame only (no frameId in TriggerFireContext).
|
||||
*/
|
||||
|
||||
import type { TriggerId } from '../../domain/ids';
|
||||
import type { TriggerSpecByKind } from '../../domain/triggers';
|
||||
import { CONTENT_MESSAGE_TYPES, TOOL_MESSAGE_TYPES } from '../../../../../common/message-types';
|
||||
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface DomTriggerHandlerDeps {
|
||||
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
}
|
||||
|
||||
type DomTriggerSpec = TriggerSpecByKind<'dom'>;
|
||||
|
||||
/**
|
||||
* Payload sent to dom-observer content script
|
||||
*/
|
||||
interface DomObserverTriggerPayload {
|
||||
id: string;
|
||||
selector: string;
|
||||
appear: boolean;
|
||||
once: boolean;
|
||||
debounceMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message received when DOM trigger fires
|
||||
*/
|
||||
interface DomTriggerFiredMessage {
|
||||
action: string;
|
||||
triggerId: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
// ==================== Constants ====================
|
||||
|
||||
const DOM_OBSERVER_SCRIPT_FILE = 'inject-scripts/dom-observer.js';
|
||||
const DEFAULT_DEBOUNCE_MS = 800;
|
||||
|
||||
// ==================== Utilities ====================
|
||||
|
||||
function normalizeDebounceMs(value: unknown): number {
|
||||
if (value === undefined || value === null) return DEFAULT_DEBOUNCE_MS;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_DEBOUNCE_MS;
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build payload for dom-observer content script
|
||||
*/
|
||||
function buildDomObserverPayload(
|
||||
installed: Map<TriggerId, DomTriggerSpec>,
|
||||
): DomObserverTriggerPayload[] {
|
||||
const out: DomObserverTriggerPayload[] = [];
|
||||
|
||||
for (const t of installed.values()) {
|
||||
const selector = String(t.selector ?? '').trim();
|
||||
if (!selector) continue;
|
||||
|
||||
out.push({
|
||||
id: t.id,
|
||||
selector,
|
||||
appear: t.appear !== false, // default true
|
||||
once: t.once !== false, // default true
|
||||
debounceMs: normalizeDebounceMs(t.debounceMs),
|
||||
});
|
||||
}
|
||||
|
||||
// Deterministic ordering for tests and debugging
|
||||
out.sort((a, b) => a.id.localeCompare(b.id));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL is injectable (http/https/file)
|
||||
*/
|
||||
function isInjectableUrl(url: string): boolean {
|
||||
return /^(https?:|file:)/i.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for DOM trigger fired message
|
||||
*/
|
||||
function isDomTriggerFiredMessage(msg: unknown): msg is DomTriggerFiredMessage {
|
||||
if (!msg || typeof msg !== 'object') return false;
|
||||
const anyMsg = msg as Record<string, unknown>;
|
||||
return (
|
||||
anyMsg.action === TOOL_MESSAGE_TYPES.DOM_TRIGGER_FIRED && typeof anyMsg.triggerId === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Handler Implementation ====================
|
||||
|
||||
/**
|
||||
* Create DOM trigger handler factory
|
||||
*/
|
||||
export function createDomTriggerHandlerFactory(
|
||||
deps?: DomTriggerHandlerDeps,
|
||||
): TriggerHandlerFactory<'dom'> {
|
||||
return (fireCallback) => createDomTriggerHandler(fireCallback, deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create DOM trigger handler
|
||||
*/
|
||||
export function createDomTriggerHandler(
|
||||
fireCallback: TriggerFireCallback,
|
||||
deps?: DomTriggerHandlerDeps,
|
||||
): TriggerHandler<'dom'> {
|
||||
const logger = deps?.logger ?? console;
|
||||
|
||||
const installed = new Map<TriggerId, DomTriggerSpec>();
|
||||
|
||||
// Payload cache for efficiency
|
||||
let payloadDirty = true;
|
||||
let payloadCache: DomObserverTriggerPayload[] = [];
|
||||
|
||||
// Listener states
|
||||
let messageListening = false;
|
||||
let navigationListening = false;
|
||||
|
||||
// Coalesce sync to avoid storms (e.g. TriggerManager.refresh)
|
||||
let syncPromise: Promise<void> | null = null;
|
||||
let pendingSync = false;
|
||||
|
||||
function markPayloadDirty(): void {
|
||||
payloadDirty = true;
|
||||
}
|
||||
|
||||
function getPayload(): DomObserverTriggerPayload[] {
|
||||
if (!payloadDirty) return payloadCache;
|
||||
payloadCache = buildDomObserverPayload(installed);
|
||||
payloadDirty = false;
|
||||
return payloadCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ping dom-observer to check if injected
|
||||
*/
|
||||
async function pingDomObserver(tabId: number): Promise<boolean> {
|
||||
try {
|
||||
const resp = await chrome.tabs.sendMessage(tabId, {
|
||||
action: CONTENT_MESSAGE_TYPES.DOM_OBSERVER_PING,
|
||||
});
|
||||
return (resp as { status?: string } | undefined)?.status === 'pong';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject dom-observer script if not present
|
||||
*/
|
||||
async function ensureDomObserverInjected(tabId: number): Promise<void> {
|
||||
const ok = await pingDomObserver(tabId);
|
||||
if (ok) return;
|
||||
|
||||
if (!chrome.scripting?.executeScript) {
|
||||
logger.warn('[DomTriggerHandler] chrome.scripting.executeScript is unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: [DOM_OBSERVER_SCRIPT_FILE],
|
||||
world: 'ISOLATED',
|
||||
});
|
||||
} catch (e) {
|
||||
// Best-effort: injection can fail on restricted pages (chrome://, etc.)
|
||||
logger.debug('[DomTriggerHandler] executeScript failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send triggers to dom-observer
|
||||
*/
|
||||
async function setDomTriggers(
|
||||
tabId: number,
|
||||
triggers: DomObserverTriggerPayload[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tabId, {
|
||||
action: TOOL_MESSAGE_TYPES.SET_DOM_TRIGGERS,
|
||||
triggers,
|
||||
});
|
||||
} catch (e) {
|
||||
// No receiver / restricted pages are expected; keep best-effort.
|
||||
logger.debug('[DomTriggerHandler] set_dom_triggers sendMessage failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync triggers to a single tab
|
||||
*/
|
||||
async function syncTab(tabId: number, url: string | undefined): Promise<void> {
|
||||
if (typeof url === 'string' && url && !isInjectableUrl(url)) return;
|
||||
|
||||
const payload = getPayload();
|
||||
if (payload.length > 0) {
|
||||
await ensureDomObserverInjected(tabId);
|
||||
}
|
||||
await setDomTriggers(tabId, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync triggers to all tabs
|
||||
*/
|
||||
async function doSyncAllTabs(): Promise<void> {
|
||||
if (!chrome.tabs?.query) {
|
||||
logger.warn('[DomTriggerHandler] chrome.tabs.query is unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
let tabs: chrome.tabs.Tab[] = [];
|
||||
try {
|
||||
tabs = await chrome.tabs.query({});
|
||||
} catch (e) {
|
||||
logger.debug('[DomTriggerHandler] tabs.query failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
tabs
|
||||
.filter((t) => typeof t.id === 'number')
|
||||
.filter((t) => (typeof t.url === 'string' ? isInjectableUrl(t.url) : true))
|
||||
.map((t) => syncTab(t.id as number, t.url)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request sync (coalesced)
|
||||
*/
|
||||
async function requestSyncAllTabs(): Promise<void> {
|
||||
pendingSync = true;
|
||||
if (!syncPromise) {
|
||||
syncPromise = (async () => {
|
||||
while (pendingSync) {
|
||||
pendingSync = false;
|
||||
await doSyncAllTabs();
|
||||
}
|
||||
})().finally(() => {
|
||||
syncPromise = null;
|
||||
});
|
||||
}
|
||||
return syncPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle runtime message (dom_trigger_fired)
|
||||
*/
|
||||
const onRuntimeMessage = (
|
||||
message: unknown,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response?: unknown) => void,
|
||||
): boolean => {
|
||||
if (!isDomTriggerFiredMessage(message)) return false;
|
||||
|
||||
const triggerId = message.triggerId as TriggerId;
|
||||
if (!installed.has(triggerId)) {
|
||||
try {
|
||||
sendResponse({ ok: false });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourceTabId = sender.tab?.id;
|
||||
const sourceUrl = message.url ?? sender.tab?.url;
|
||||
|
||||
// Fire-and-forget: do not block chrome messaging thread
|
||||
Promise.resolve(fireCallback.onFire(triggerId, { sourceTabId, sourceUrl })).catch((e) => {
|
||||
logger.error(`[DomTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
|
||||
});
|
||||
|
||||
try {
|
||||
sendResponse({ ok: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle navigation completed (re-sync triggers to tab)
|
||||
*/
|
||||
const onNavigationCompleted = (
|
||||
details: chrome.webNavigation.WebNavigationFramedCallbackDetails,
|
||||
): void => {
|
||||
if (details.frameId !== 0) return; // Top frame only
|
||||
if (installed.size === 0) return;
|
||||
if (typeof details.url === 'string' && details.url && !isInjectableUrl(details.url)) return;
|
||||
|
||||
void syncTab(details.tabId, details.url).catch((e) => {
|
||||
logger.debug('[DomTriggerHandler] syncTab on navigation failed:', e);
|
||||
});
|
||||
};
|
||||
|
||||
function ensureMessageListening(): void {
|
||||
if (messageListening) return;
|
||||
if (!chrome.runtime?.onMessage?.addListener) {
|
||||
logger.warn('[DomTriggerHandler] chrome.runtime.onMessage is unavailable');
|
||||
return;
|
||||
}
|
||||
chrome.runtime.onMessage.addListener(onRuntimeMessage);
|
||||
messageListening = true;
|
||||
}
|
||||
|
||||
function stopMessageListening(): void {
|
||||
if (!messageListening) return;
|
||||
try {
|
||||
chrome.runtime.onMessage.removeListener(onRuntimeMessage);
|
||||
} catch (e) {
|
||||
logger.debug('[DomTriggerHandler] runtime.onMessage.removeListener failed:', e);
|
||||
} finally {
|
||||
messageListening = false;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNavigationListening(): void {
|
||||
if (navigationListening) return;
|
||||
if (!chrome.webNavigation?.onCompleted?.addListener) {
|
||||
logger.warn('[DomTriggerHandler] chrome.webNavigation.onCompleted is unavailable');
|
||||
return;
|
||||
}
|
||||
chrome.webNavigation.onCompleted.addListener(onNavigationCompleted);
|
||||
navigationListening = true;
|
||||
}
|
||||
|
||||
function stopNavigationListening(): void {
|
||||
if (!navigationListening) return;
|
||||
try {
|
||||
chrome.webNavigation.onCompleted.removeListener(onNavigationCompleted);
|
||||
} catch (e) {
|
||||
logger.debug('[DomTriggerHandler] webNavigation.onCompleted.removeListener failed:', e);
|
||||
} finally {
|
||||
navigationListening = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'dom',
|
||||
|
||||
async install(trigger: DomTriggerSpec): Promise<void> {
|
||||
installed.set(trigger.id, trigger);
|
||||
markPayloadDirty();
|
||||
|
||||
// Ensure listeners are ready before pushing triggers
|
||||
ensureMessageListening();
|
||||
ensureNavigationListening();
|
||||
|
||||
await requestSyncAllTabs();
|
||||
},
|
||||
|
||||
async uninstall(triggerId: string): Promise<void> {
|
||||
installed.delete(triggerId as TriggerId);
|
||||
markPayloadDirty();
|
||||
|
||||
await requestSyncAllTabs();
|
||||
|
||||
if (installed.size === 0) {
|
||||
stopNavigationListening();
|
||||
stopMessageListening();
|
||||
}
|
||||
},
|
||||
|
||||
async uninstallAll(): Promise<void> {
|
||||
installed.clear();
|
||||
markPayloadDirty();
|
||||
|
||||
await requestSyncAllTabs();
|
||||
|
||||
stopNavigationListening();
|
||||
stopMessageListening();
|
||||
},
|
||||
|
||||
getInstalledIds(): string[] {
|
||||
return Array.from(installed.keys());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @fileoverview Triggers 模块导出入口
|
||||
*/
|
||||
|
||||
export * from './trigger-handler';
|
||||
export * from './trigger-manager';
|
||||
export * from './url-trigger';
|
||||
export * from './command-trigger';
|
||||
export * from './context-menu-trigger';
|
||||
export * from './dom-trigger';
|
||||
export * from './cron-trigger';
|
||||
export * from './manual-trigger';
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @fileoverview Interval Trigger Handler (M3.1)
|
||||
* @description
|
||||
* 使用 chrome.alarms 的 periodInMinutes 实现固定间隔触发。
|
||||
*
|
||||
* 策略:
|
||||
* - 每个触发器对应一个重复 alarm
|
||||
* - 使用 delayInMinutes 使首次触发在配置的间隔后
|
||||
*/
|
||||
|
||||
import type { TriggerId } from '../../domain/ids';
|
||||
import type { TriggerSpecByKind } from '../../domain/triggers';
|
||||
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
type IntervalTriggerSpec = TriggerSpecByKind<'interval'>;
|
||||
|
||||
export interface IntervalTriggerHandlerDeps {
|
||||
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
}
|
||||
|
||||
interface InstalledIntervalTrigger {
|
||||
spec: IntervalTriggerSpec;
|
||||
periodMinutes: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// ==================== Constants ====================
|
||||
|
||||
const ALARM_PREFIX = 'rr_v3_interval_';
|
||||
|
||||
// ==================== Utilities ====================
|
||||
|
||||
/**
|
||||
* 校验并规范化 periodMinutes
|
||||
*/
|
||||
function normalizePeriodMinutes(value: unknown): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new Error('periodMinutes must be a finite number');
|
||||
}
|
||||
if (value < 1) {
|
||||
throw new Error('periodMinutes must be >= 1');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 alarm 名称
|
||||
*/
|
||||
function alarmNameForTrigger(triggerId: TriggerId): string {
|
||||
return `${ALARM_PREFIX}${triggerId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 alarm 名称解析 triggerId
|
||||
*/
|
||||
function parseTriggerIdFromAlarmName(name: string): TriggerId | null {
|
||||
if (!name.startsWith(ALARM_PREFIX)) return null;
|
||||
const id = name.slice(ALARM_PREFIX.length);
|
||||
return id ? (id as TriggerId) : null;
|
||||
}
|
||||
|
||||
// ==================== Handler Implementation ====================
|
||||
|
||||
/**
|
||||
* 创建 interval 触发器处理器工厂
|
||||
*/
|
||||
export function createIntervalTriggerHandlerFactory(
|
||||
deps?: IntervalTriggerHandlerDeps,
|
||||
): TriggerHandlerFactory<'interval'> {
|
||||
return (fireCallback) => createIntervalTriggerHandler(fireCallback, deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 interval 触发器处理器
|
||||
*/
|
||||
export function createIntervalTriggerHandler(
|
||||
fireCallback: TriggerFireCallback,
|
||||
deps?: IntervalTriggerHandlerDeps,
|
||||
): TriggerHandler<'interval'> {
|
||||
const logger = deps?.logger ?? console;
|
||||
|
||||
const installed = new Map<TriggerId, InstalledIntervalTrigger>();
|
||||
const versions = new Map<TriggerId, number>();
|
||||
let listening = false;
|
||||
|
||||
/**
|
||||
* 递增版本号以使挂起的操作失效
|
||||
*/
|
||||
function bumpVersion(triggerId: TriggerId): number {
|
||||
const next = (versions.get(triggerId) ?? 0) + 1;
|
||||
versions.set(triggerId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定 alarm
|
||||
*/
|
||||
async function clearAlarmByName(name: string): Promise<void> {
|
||||
if (!chrome.alarms?.clear) return;
|
||||
try {
|
||||
await Promise.resolve(chrome.alarms.clear(name));
|
||||
} catch (e) {
|
||||
logger.debug('[IntervalTriggerHandler] alarms.clear failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有 interval alarms
|
||||
*/
|
||||
async function clearAllIntervalAlarms(): Promise<void> {
|
||||
if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return;
|
||||
try {
|
||||
const alarms = await Promise.resolve(chrome.alarms.getAll());
|
||||
const list = Array.isArray(alarms) ? alarms : [];
|
||||
await Promise.all(
|
||||
list.filter((a) => a?.name?.startsWith(ALARM_PREFIX)).map((a) => clearAlarmByName(a.name)),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.debug('[IntervalTriggerHandler] alarms.getAll failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度 alarm
|
||||
*/
|
||||
async function schedule(triggerId: TriggerId, expectedVersion: number): Promise<void> {
|
||||
if (!chrome.alarms?.create) {
|
||||
logger.warn('[IntervalTriggerHandler] chrome.alarms.create is unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = installed.get(triggerId);
|
||||
if (!entry || entry.version !== expectedVersion) return;
|
||||
|
||||
const name = alarmNameForTrigger(triggerId);
|
||||
const periodInMinutes = entry.periodMinutes;
|
||||
|
||||
try {
|
||||
// 使用 delayInMinutes 和 periodInMinutes 创建重复 alarm
|
||||
// 首次触发在 periodInMinutes 后,之后每隔 periodInMinutes 触发
|
||||
await Promise.resolve(
|
||||
chrome.alarms.create(name, {
|
||||
delayInMinutes: periodInMinutes,
|
||||
periodInMinutes,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error(`[IntervalTriggerHandler] alarms.create failed for trigger "${triggerId}":`, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alarm 事件处理
|
||||
*/
|
||||
const onAlarm = (alarm: chrome.alarms.Alarm): void => {
|
||||
const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? '');
|
||||
if (!triggerId) return;
|
||||
|
||||
const entry = installed.get(triggerId);
|
||||
if (!entry) return;
|
||||
|
||||
// 触发回调
|
||||
Promise.resolve(
|
||||
fireCallback.onFire(triggerId, {
|
||||
sourceTabId: undefined,
|
||||
sourceUrl: undefined,
|
||||
}),
|
||||
).catch((e) => {
|
||||
logger.error(`[IntervalTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 确保正在监听 alarm 事件
|
||||
*/
|
||||
function ensureListening(): void {
|
||||
if (listening) return;
|
||||
if (!chrome.alarms?.onAlarm?.addListener) {
|
||||
logger.warn('[IntervalTriggerHandler] chrome.alarms.onAlarm is unavailable');
|
||||
return;
|
||||
}
|
||||
chrome.alarms.onAlarm.addListener(onAlarm);
|
||||
listening = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止监听 alarm 事件
|
||||
*/
|
||||
function stopListening(): void {
|
||||
if (!listening) return;
|
||||
try {
|
||||
chrome.alarms.onAlarm.removeListener(onAlarm);
|
||||
} catch (e) {
|
||||
logger.debug('[IntervalTriggerHandler] removeListener failed:', e);
|
||||
} finally {
|
||||
listening = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'interval',
|
||||
|
||||
async install(trigger: IntervalTriggerSpec): Promise<void> {
|
||||
const periodMinutes = normalizePeriodMinutes(trigger.periodMinutes);
|
||||
|
||||
const version = bumpVersion(trigger.id);
|
||||
installed.set(trigger.id, {
|
||||
spec: { ...trigger, periodMinutes },
|
||||
periodMinutes,
|
||||
version,
|
||||
});
|
||||
|
||||
ensureListening();
|
||||
await schedule(trigger.id, version);
|
||||
},
|
||||
|
||||
async uninstall(triggerId: string): Promise<void> {
|
||||
const id = triggerId as TriggerId;
|
||||
bumpVersion(id);
|
||||
installed.delete(id);
|
||||
await clearAlarmByName(alarmNameForTrigger(id));
|
||||
|
||||
if (installed.size === 0) {
|
||||
stopListening();
|
||||
}
|
||||
},
|
||||
|
||||
async uninstallAll(): Promise<void> {
|
||||
for (const id of installed.keys()) {
|
||||
bumpVersion(id);
|
||||
}
|
||||
installed.clear();
|
||||
await clearAllIntervalAlarms();
|
||||
stopListening();
|
||||
},
|
||||
|
||||
getInstalledIds(): string[] {
|
||||
return Array.from(installed.keys());
|
||||
},
|
||||
};
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @fileoverview Manual Trigger Handler (P4-08)
|
||||
* @description
|
||||
* Manual triggers are the simplest trigger type - they don't auto-fire.
|
||||
* They're only triggered programmatically via RPC or UI.
|
||||
*
|
||||
* This handler just tracks installed triggers but doesn't set up any listeners.
|
||||
* Manual triggers are fired by calling TriggerManager's fire method directly.
|
||||
*/
|
||||
|
||||
import type { TriggerId } from '../../domain/ids';
|
||||
import type { TriggerSpecByKind } from '../../domain/triggers';
|
||||
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface ManualTriggerHandlerDeps {
|
||||
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
}
|
||||
|
||||
type ManualTriggerSpec = TriggerSpecByKind<'manual'>;
|
||||
|
||||
// ==================== Handler Implementation ====================
|
||||
|
||||
/**
|
||||
* Create manual trigger handler factory
|
||||
*/
|
||||
export function createManualTriggerHandlerFactory(
|
||||
deps?: ManualTriggerHandlerDeps,
|
||||
): TriggerHandlerFactory<'manual'> {
|
||||
return (fireCallback) => createManualTriggerHandler(fireCallback, deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create manual trigger handler
|
||||
*
|
||||
* Manual triggers don't auto-fire - they're only triggered via RPC.
|
||||
* This handler just tracks which manual triggers are installed.
|
||||
*/
|
||||
export function createManualTriggerHandler(
|
||||
_fireCallback: TriggerFireCallback,
|
||||
_deps?: ManualTriggerHandlerDeps,
|
||||
): TriggerHandler<'manual'> {
|
||||
const installed = new Map<TriggerId, ManualTriggerSpec>();
|
||||
|
||||
return {
|
||||
kind: 'manual',
|
||||
|
||||
async install(trigger: ManualTriggerSpec): Promise<void> {
|
||||
installed.set(trigger.id, trigger);
|
||||
},
|
||||
|
||||
async uninstall(triggerId: string): Promise<void> {
|
||||
installed.delete(triggerId as TriggerId);
|
||||
},
|
||||
|
||||
async uninstallAll(): Promise<void> {
|
||||
installed.clear();
|
||||
},
|
||||
|
||||
getInstalledIds(): string[] {
|
||||
return Array.from(installed.keys());
|
||||
},
|
||||
};
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* @fileoverview Once Trigger Handler (M3.1)
|
||||
* @description
|
||||
* 使用 chrome.alarms 的 when 参数实现一次性定时触发。
|
||||
*
|
||||
* 行为:
|
||||
* - 每个触发器对应一个一次性 alarm
|
||||
* - 触发后自动将触发器禁用 (enabled=false) 并卸载
|
||||
*/
|
||||
|
||||
import type { UnixMillis } from '../../domain/json';
|
||||
import type { TriggerId } from '../../domain/ids';
|
||||
import type { TriggerSpecByKind } from '../../domain/triggers';
|
||||
import { createTriggersStore } from '../../storage/triggers';
|
||||
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
type OnceTriggerSpec = TriggerSpecByKind<'once'>;
|
||||
|
||||
export interface OnceTriggerHandlerDeps {
|
||||
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
/**
|
||||
* 可选:自定义禁用触发器的方法
|
||||
* 如果未提供,将直接更新 TriggerStore
|
||||
*/
|
||||
disableTrigger?: (triggerId: TriggerId) => Promise<void>;
|
||||
}
|
||||
|
||||
interface InstalledOnceTrigger {
|
||||
spec: OnceTriggerSpec;
|
||||
whenMs: UnixMillis;
|
||||
version: number;
|
||||
}
|
||||
|
||||
// ==================== Constants ====================
|
||||
|
||||
const ALARM_PREFIX = 'rr_v3_once_';
|
||||
|
||||
// ==================== Utilities ====================
|
||||
|
||||
/**
|
||||
* 校验并规范化 whenMs
|
||||
*/
|
||||
function normalizeWhenMs(value: unknown): UnixMillis {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new Error('whenMs must be a finite number');
|
||||
}
|
||||
return Math.floor(value) as UnixMillis;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 alarm 名称
|
||||
*/
|
||||
function alarmNameForTrigger(triggerId: TriggerId): string {
|
||||
return `${ALARM_PREFIX}${triggerId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 alarm 名称解析 triggerId
|
||||
*/
|
||||
function parseTriggerIdFromAlarmName(name: string): TriggerId | null {
|
||||
if (!name.startsWith(ALARM_PREFIX)) return null;
|
||||
const id = name.slice(ALARM_PREFIX.length);
|
||||
return id ? (id as TriggerId) : null;
|
||||
}
|
||||
|
||||
// ==================== Handler Implementation ====================
|
||||
|
||||
/**
|
||||
* 创建 once 触发器处理器工厂
|
||||
*/
|
||||
export function createOnceTriggerHandlerFactory(
|
||||
deps?: OnceTriggerHandlerDeps,
|
||||
): TriggerHandlerFactory<'once'> {
|
||||
return (fireCallback) => createOnceTriggerHandler(fireCallback, deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 once 触发器处理器
|
||||
*/
|
||||
export function createOnceTriggerHandler(
|
||||
fireCallback: TriggerFireCallback,
|
||||
deps?: OnceTriggerHandlerDeps,
|
||||
): TriggerHandler<'once'> {
|
||||
const logger = deps?.logger ?? console;
|
||||
|
||||
// 延迟创建 store,避免在测试环境中出问题
|
||||
let triggersStore: ReturnType<typeof createTriggersStore> | null = null;
|
||||
const getTriggersStore = () => {
|
||||
if (!triggersStore) {
|
||||
triggersStore = createTriggersStore();
|
||||
}
|
||||
return triggersStore;
|
||||
};
|
||||
|
||||
const disableTrigger =
|
||||
deps?.disableTrigger ??
|
||||
(async (triggerId: TriggerId) => {
|
||||
const store = getTriggersStore();
|
||||
const existing = await store.get(triggerId);
|
||||
if (!existing) return;
|
||||
if (!existing.enabled) return;
|
||||
await store.save({ ...existing, enabled: false });
|
||||
});
|
||||
|
||||
const installed = new Map<TriggerId, InstalledOnceTrigger>();
|
||||
const versions = new Map<TriggerId, number>();
|
||||
let listening = false;
|
||||
|
||||
/**
|
||||
* 递增版本号以使挂起的操作失效
|
||||
*/
|
||||
function bumpVersion(triggerId: TriggerId): number {
|
||||
const next = (versions.get(triggerId) ?? 0) + 1;
|
||||
versions.set(triggerId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定 alarm
|
||||
*/
|
||||
async function clearAlarmByName(name: string): Promise<void> {
|
||||
if (!chrome.alarms?.clear) return;
|
||||
try {
|
||||
await Promise.resolve(chrome.alarms.clear(name));
|
||||
} catch (e) {
|
||||
logger.debug('[OnceTriggerHandler] alarms.clear failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有 once alarms
|
||||
*/
|
||||
async function clearAllOnceAlarms(): Promise<void> {
|
||||
if (!chrome.alarms?.getAll || !chrome.alarms?.clear) return;
|
||||
try {
|
||||
const alarms = await Promise.resolve(chrome.alarms.getAll());
|
||||
const list = Array.isArray(alarms) ? alarms : [];
|
||||
await Promise.all(
|
||||
list.filter((a) => a?.name?.startsWith(ALARM_PREFIX)).map((a) => clearAlarmByName(a.name)),
|
||||
);
|
||||
} catch (e) {
|
||||
logger.debug('[OnceTriggerHandler] alarms.getAll failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度 alarm
|
||||
*/
|
||||
async function schedule(triggerId: TriggerId, expectedVersion: number): Promise<void> {
|
||||
if (!chrome.alarms?.create) {
|
||||
logger.warn('[OnceTriggerHandler] chrome.alarms.create is unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = installed.get(triggerId);
|
||||
if (!entry || entry.version !== expectedVersion) return;
|
||||
|
||||
const name = alarmNameForTrigger(triggerId);
|
||||
|
||||
try {
|
||||
await Promise.resolve(chrome.alarms.create(name, { when: entry.whenMs }));
|
||||
} catch (e) {
|
||||
logger.error(`[OnceTriggerHandler] alarms.create failed for trigger "${triggerId}":`, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部卸载逻辑(不触发外部 uninstall)
|
||||
*/
|
||||
async function uninstallInternal(triggerId: TriggerId): Promise<void> {
|
||||
bumpVersion(triggerId);
|
||||
installed.delete(triggerId);
|
||||
await clearAlarmByName(alarmNameForTrigger(triggerId));
|
||||
|
||||
if (installed.size === 0) {
|
||||
stopListening();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alarm 事件处理
|
||||
*/
|
||||
const onAlarm = (alarm: chrome.alarms.Alarm): void => {
|
||||
const triggerId = parseTriggerIdFromAlarmName(alarm?.name ?? '');
|
||||
if (!triggerId) return;
|
||||
|
||||
const entry = installed.get(triggerId);
|
||||
if (!entry) return;
|
||||
|
||||
const expectedVersion = entry.version;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await fireCallback.onFire(triggerId, {
|
||||
sourceTabId: undefined,
|
||||
sourceUrl: undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(`[OnceTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
|
||||
} finally {
|
||||
// 检查版本是否仍然有效
|
||||
if (installed.get(triggerId)?.version === expectedVersion) {
|
||||
// 禁用触发器
|
||||
try {
|
||||
await disableTrigger(triggerId);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`[OnceTriggerHandler] Failed to disable trigger "${triggerId}" after fire:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
|
||||
// 卸载触发器
|
||||
try {
|
||||
await uninstallInternal(triggerId);
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`[OnceTriggerHandler] Failed to uninstall trigger "${triggerId}" after fire:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
/**
|
||||
* 确保正在监听 alarm 事件
|
||||
*/
|
||||
function ensureListening(): void {
|
||||
if (listening) return;
|
||||
if (!chrome.alarms?.onAlarm?.addListener) {
|
||||
logger.warn('[OnceTriggerHandler] chrome.alarms.onAlarm is unavailable');
|
||||
return;
|
||||
}
|
||||
chrome.alarms.onAlarm.addListener(onAlarm);
|
||||
listening = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止监听 alarm 事件
|
||||
*/
|
||||
function stopListening(): void {
|
||||
if (!listening) return;
|
||||
try {
|
||||
chrome.alarms.onAlarm.removeListener(onAlarm);
|
||||
} catch (e) {
|
||||
logger.debug('[OnceTriggerHandler] removeListener failed:', e);
|
||||
} finally {
|
||||
listening = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'once',
|
||||
|
||||
async install(trigger: OnceTriggerSpec): Promise<void> {
|
||||
const whenMs = normalizeWhenMs(trigger.whenMs);
|
||||
|
||||
const version = bumpVersion(trigger.id);
|
||||
installed.set(trigger.id, {
|
||||
spec: { ...trigger, whenMs },
|
||||
whenMs,
|
||||
version,
|
||||
});
|
||||
|
||||
ensureListening();
|
||||
await schedule(trigger.id, version);
|
||||
},
|
||||
|
||||
async uninstall(triggerId: string): Promise<void> {
|
||||
await uninstallInternal(triggerId as TriggerId);
|
||||
},
|
||||
|
||||
async uninstallAll(): Promise<void> {
|
||||
for (const id of installed.keys()) {
|
||||
bumpVersion(id);
|
||||
}
|
||||
installed.clear();
|
||||
await clearAllOnceAlarms();
|
||||
stopListening();
|
||||
},
|
||||
|
||||
getInstalledIds(): string[] {
|
||||
return Array.from(installed.keys());
|
||||
},
|
||||
};
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @fileoverview 触发器处理器接口定义
|
||||
* @description 定义各类触发器的统一接口
|
||||
*/
|
||||
|
||||
import type { TriggerSpec, TriggerKind } from '../../domain/triggers';
|
||||
|
||||
/**
|
||||
* 触发器处理器接口
|
||||
* @description 每种触发器类型需要实现此接口
|
||||
*/
|
||||
export interface TriggerHandler<K extends TriggerKind = TriggerKind> {
|
||||
/** 触发器类型 */
|
||||
readonly kind: K;
|
||||
|
||||
/**
|
||||
* 安装触发器
|
||||
* @description 注册 chrome API 监听器等
|
||||
* @param trigger 触发器规范
|
||||
*/
|
||||
install(trigger: Extract<TriggerSpec, { kind: K }>): Promise<void>;
|
||||
|
||||
/**
|
||||
* 卸载触发器
|
||||
* @description 移除 chrome API 监听器等
|
||||
* @param triggerId 触发器 ID
|
||||
*/
|
||||
uninstall(triggerId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* 卸载所有触发器
|
||||
* @description 清理所有此类型的触发器
|
||||
*/
|
||||
uninstallAll(): Promise<void>;
|
||||
|
||||
/**
|
||||
* 获取已安装的触发器 ID 列表
|
||||
*/
|
||||
getInstalledIds(): string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发器触发回调
|
||||
* @description TriggerManager 注入给各 Handler 的回调
|
||||
*/
|
||||
export interface TriggerFireCallback {
|
||||
/**
|
||||
* 触发器被触发时调用
|
||||
* @param triggerId 触发器 ID
|
||||
* @param context 触发上下文
|
||||
*/
|
||||
onFire(
|
||||
triggerId: string,
|
||||
context: {
|
||||
sourceTabId?: number;
|
||||
sourceUrl?: string;
|
||||
},
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发器处理器工厂
|
||||
*/
|
||||
export type TriggerHandlerFactory<K extends TriggerKind> = (
|
||||
fireCallback: TriggerFireCallback,
|
||||
) => TriggerHandler<K>;
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* @fileoverview 触发器管理器
|
||||
* @description
|
||||
* TriggerManager 负责管理所有触发器 Handler 的生命周期:
|
||||
* - 从 TriggerStore 加载触发器并安装
|
||||
* - 处理触发器触发事件,调用 enqueueRun
|
||||
* - 提供防风暴机制 (cooldown + maxQueued)
|
||||
*
|
||||
* 设计理由:
|
||||
* - Orchestrator 模式:TriggerManager 不直接实现各类触发器逻辑,而是委托给 per-kind Handler
|
||||
* - Handler 工厂模式:TriggerManager 在构造时创建 Handler 实例,注入 fireCallback
|
||||
* - 防风暴:cooldown (per-trigger) + maxQueued (global best-effort)
|
||||
*/
|
||||
|
||||
import type { UnixMillis } from '../../domain/json';
|
||||
import type { RunId, TriggerId } from '../../domain/ids';
|
||||
import type { TriggerFireContext, TriggerKind, TriggerSpec } from '../../domain/triggers';
|
||||
import type { StoragePort } from '../storage/storage-port';
|
||||
import type { EventsBus } from '../transport/events-bus';
|
||||
import type { RunScheduler } from '../queue/scheduler';
|
||||
import { enqueueRun, type EnqueueRunResult } from '../queue/enqueue-run';
|
||||
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
/**
|
||||
* Handler 工厂映射
|
||||
*/
|
||||
export type TriggerHandlerFactories = Partial<{
|
||||
[K in TriggerKind]: TriggerHandlerFactory<K>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 防风暴配置
|
||||
*/
|
||||
export interface TriggerManagerStormControl {
|
||||
/**
|
||||
* 同一触发器两次触发之间的最小间隔 (ms)
|
||||
* - 0 或 undefined 表示禁用冷却
|
||||
*/
|
||||
cooldownMs?: number;
|
||||
|
||||
/**
|
||||
* 全局最大排队 Run 数量
|
||||
* - 达到上限时拒绝新的触发
|
||||
* - undefined 表示禁用上限检查
|
||||
* - 注意:这是 best-effort 检查,非原子性
|
||||
*/
|
||||
maxQueued?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* TriggerManager 依赖
|
||||
*/
|
||||
export interface TriggerManagerDeps {
|
||||
/** 存储层 */
|
||||
storage: Pick<StoragePort, 'triggers' | 'flows' | 'runs' | 'queue'>;
|
||||
/** 事件总线 */
|
||||
events: Pick<EventsBus, 'append'>;
|
||||
/** 调度器 (可选) */
|
||||
scheduler?: Pick<RunScheduler, 'kick'>;
|
||||
/** Handler 工厂映射 */
|
||||
handlerFactories: TriggerHandlerFactories;
|
||||
/** 防风暴配置 */
|
||||
storm?: TriggerManagerStormControl;
|
||||
/** RunId 生成器 (用于测试注入) */
|
||||
generateRunId?: () => RunId;
|
||||
/** 时间源 (用于测试注入) */
|
||||
now?: () => UnixMillis;
|
||||
/** 日志器 */
|
||||
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* TriggerManager 状态
|
||||
*/
|
||||
export interface TriggerManagerState {
|
||||
/** 是否已启动 */
|
||||
started: boolean;
|
||||
/** 已安装的触发器 ID 列表 */
|
||||
installedTriggerIds: TriggerId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* TriggerManager 接口
|
||||
*/
|
||||
export interface TriggerManager {
|
||||
/** 启动管理器,加载并安装所有启用的触发器 */
|
||||
start(): Promise<void>;
|
||||
/** 停止管理器,卸载所有触发器 */
|
||||
stop(): Promise<void>;
|
||||
/** 刷新触发器,重新从存储加载并安装 */
|
||||
refresh(): Promise<void>;
|
||||
/**
|
||||
* 手动触发一个触发器
|
||||
* @description 仅供 RPC/UI 调用,用于 manual 触发器
|
||||
*/
|
||||
fire(
|
||||
triggerId: TriggerId,
|
||||
context?: { sourceTabId?: number; sourceUrl?: string },
|
||||
): Promise<EnqueueRunResult>;
|
||||
/** 销毁管理器 */
|
||||
dispose(): Promise<void>;
|
||||
/** 获取当前状态 */
|
||||
getState(): TriggerManagerState;
|
||||
}
|
||||
|
||||
// ==================== Utilities ====================
|
||||
|
||||
/**
|
||||
* 校验非负整数
|
||||
*/
|
||||
function normalizeNonNegativeInt(value: unknown, fallback: number, fieldName: string): number {
|
||||
if (value === undefined || value === null) return fallback;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new Error(`${fieldName} must be a finite number`);
|
||||
}
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验正整数
|
||||
*/
|
||||
function normalizePositiveInt(value: unknown, fieldName: string): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new Error(`${fieldName} must be a finite number`);
|
||||
}
|
||||
const intValue = Math.floor(value);
|
||||
if (intValue < 1) {
|
||||
throw new Error(`${fieldName} must be >= 1`);
|
||||
}
|
||||
return intValue;
|
||||
}
|
||||
|
||||
// ==================== Implementation ====================
|
||||
|
||||
/**
|
||||
* 创建 TriggerManager
|
||||
*/
|
||||
export function createTriggerManager(deps: TriggerManagerDeps): TriggerManager {
|
||||
const logger = deps.logger ?? console;
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
|
||||
// 防风暴参数
|
||||
const cooldownMs = normalizeNonNegativeInt(deps.storm?.cooldownMs, 0, 'storm.cooldownMs');
|
||||
const maxQueued =
|
||||
deps.storm?.maxQueued === undefined || deps.storm?.maxQueued === null
|
||||
? undefined
|
||||
: normalizePositiveInt(deps.storm.maxQueued, 'storm.maxQueued');
|
||||
|
||||
// 状态
|
||||
const installed = new Map<TriggerId, TriggerSpec>();
|
||||
const lastFireAt = new Map<TriggerId, UnixMillis>();
|
||||
let started = false;
|
||||
let inFlightEnqueues = 0;
|
||||
|
||||
// 防止 refresh 重入
|
||||
let refreshPromise: Promise<void> | null = null;
|
||||
let pendingRefresh = false;
|
||||
|
||||
// Handler 实例
|
||||
const handlers = new Map<TriggerKind, TriggerHandler<TriggerKind>>();
|
||||
|
||||
// 触发回调
|
||||
const fireCallback: TriggerFireCallback = {
|
||||
onFire: async (triggerId, context) => {
|
||||
// 捕获所有异常,避免抛入 chrome API 监听器
|
||||
try {
|
||||
await handleFire(triggerId as TriggerId, context);
|
||||
} catch (e) {
|
||||
logger.error('[TriggerManager] onFire failed:', e);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 初始化 Handler 实例
|
||||
for (const [kind, factory] of Object.entries(deps.handlerFactories) as Array<
|
||||
[TriggerKind, TriggerHandlerFactory<TriggerKind> | undefined]
|
||||
>) {
|
||||
if (!factory) continue; // Skip undefined factory values
|
||||
|
||||
const handler = factory(fireCallback) as TriggerHandler<TriggerKind>;
|
||||
if (handler.kind !== kind) {
|
||||
throw new Error(
|
||||
`[TriggerManager] Handler kind mismatch: factory key is "${kind}", but handler.kind is "${handler.kind}"`,
|
||||
);
|
||||
}
|
||||
handlers.set(kind, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理触发器触发(内部方法)
|
||||
* @param throwOnDrop 如果为 true,则在 cooldown/maxQueued 等情况下抛出错误
|
||||
* @returns EnqueueRunResult 或 null(静默丢弃)
|
||||
*/
|
||||
async function handleFire(
|
||||
triggerId: TriggerId,
|
||||
context: { sourceTabId?: number; sourceUrl?: string },
|
||||
options?: { throwOnDrop?: boolean },
|
||||
): Promise<EnqueueRunResult | null> {
|
||||
if (!started) {
|
||||
if (options?.throwOnDrop) {
|
||||
throw new Error('TriggerManager is not started');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const trigger = installed.get(triggerId);
|
||||
if (!trigger) {
|
||||
if (options?.throwOnDrop) {
|
||||
throw new Error(`Trigger "${triggerId}" is not installed`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const t = now();
|
||||
|
||||
// Per-trigger cooldown 检查
|
||||
const prevLastFireAt = lastFireAt.get(triggerId);
|
||||
if (cooldownMs > 0 && prevLastFireAt !== undefined && t - prevLastFireAt < cooldownMs) {
|
||||
logger.debug(`[TriggerManager] Dropping trigger "${triggerId}" (cooldown ${cooldownMs}ms)`);
|
||||
if (options?.throwOnDrop) {
|
||||
throw new Error(`Trigger "${triggerId}" dropped (cooldown ${cooldownMs}ms)`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Global maxQueued 检查 (best-effort)
|
||||
// 注意:在 cooldown 设置前检查,避免因 maxQueued drop 而误设 cooldown
|
||||
if (maxQueued !== undefined) {
|
||||
const queued = await deps.storage.queue.list('queued');
|
||||
if (queued.length + inFlightEnqueues >= maxQueued) {
|
||||
logger.warn(
|
||||
`[TriggerManager] Dropping trigger "${triggerId}" (queued=${queued.length}, inFlight=${inFlightEnqueues}, maxQueued=${maxQueued})`,
|
||||
);
|
||||
if (options?.throwOnDrop) {
|
||||
throw new Error(`Trigger "${triggerId}" dropped (maxQueued=${maxQueued})`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 lastFireAt 以抑制并发触发(在 maxQueued 检查通过后)
|
||||
if (cooldownMs > 0) {
|
||||
lastFireAt.set(triggerId, t);
|
||||
}
|
||||
|
||||
// 构建触发上下文
|
||||
const triggerContext: TriggerFireContext = {
|
||||
triggerId: trigger.id,
|
||||
kind: trigger.kind,
|
||||
firedAt: t,
|
||||
sourceTabId: context.sourceTabId,
|
||||
sourceUrl: context.sourceUrl,
|
||||
};
|
||||
|
||||
inFlightEnqueues += 1;
|
||||
try {
|
||||
const result = await enqueueRun(
|
||||
{
|
||||
storage: deps.storage,
|
||||
events: deps.events,
|
||||
scheduler: deps.scheduler,
|
||||
generateRunId: deps.generateRunId,
|
||||
now,
|
||||
},
|
||||
{
|
||||
flowId: trigger.flowId,
|
||||
args: trigger.args,
|
||||
trigger: triggerContext,
|
||||
},
|
||||
);
|
||||
return result;
|
||||
} catch (e) {
|
||||
// 入队失败时回滚 cooldown 标记
|
||||
if (cooldownMs > 0) {
|
||||
if (prevLastFireAt === undefined) {
|
||||
lastFireAt.delete(triggerId);
|
||||
} else {
|
||||
lastFireAt.set(triggerId, prevLastFireAt);
|
||||
}
|
||||
}
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error(`[TriggerManager] enqueueRun failed for trigger "${triggerId}":`, e);
|
||||
if (options?.throwOnDrop) {
|
||||
throw new Error(`enqueueRun failed for trigger "${triggerId}": ${msg}`);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
inFlightEnqueues -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发一个触发器(对外暴露)
|
||||
* @description 用于 RPC/UI 调用,会抛出错误而不是静默丢弃
|
||||
*/
|
||||
async function fire(
|
||||
triggerId: TriggerId,
|
||||
context: { sourceTabId?: number; sourceUrl?: string } = {},
|
||||
): Promise<EnqueueRunResult> {
|
||||
const result = await handleFire(triggerId, context, { throwOnDrop: true });
|
||||
if (!result) {
|
||||
throw new Error(`Trigger "${triggerId}" did not enqueue a run`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行刷新
|
||||
*/
|
||||
async function doRefresh(): Promise<void> {
|
||||
const triggers = await deps.storage.triggers.list();
|
||||
if (!started) return;
|
||||
|
||||
// 先卸载所有,再重新安装 (简单策略,保证一致性)
|
||||
// Best-effort: 单个 handler 卸载失败不影响其他
|
||||
for (const handler of handlers.values()) {
|
||||
try {
|
||||
await handler.uninstallAll();
|
||||
} catch (e) {
|
||||
logger.warn(`[TriggerManager] Error during uninstallAll for kind "${handler.kind}":`, e);
|
||||
}
|
||||
}
|
||||
installed.clear();
|
||||
|
||||
// 安装启用的触发器
|
||||
for (const trigger of triggers) {
|
||||
if (!started) return;
|
||||
if (!trigger.enabled) continue;
|
||||
|
||||
const handler = handlers.get(trigger.kind);
|
||||
if (!handler) {
|
||||
logger.warn(`[TriggerManager] No handler registered for kind "${trigger.kind}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await handler.install(trigger as Parameters<typeof handler.install>[0]);
|
||||
installed.set(trigger.id, trigger);
|
||||
} catch (e) {
|
||||
logger.error(`[TriggerManager] Failed to install trigger "${trigger.id}":`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新触发器 (合并并发调用)
|
||||
*/
|
||||
async function refresh(): Promise<void> {
|
||||
if (!started) {
|
||||
throw new Error('TriggerManager is not started');
|
||||
}
|
||||
|
||||
pendingRefresh = true;
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
while (started && pendingRefresh) {
|
||||
pendingRefresh = false;
|
||||
await doRefresh();
|
||||
}
|
||||
})().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动管理器
|
||||
*/
|
||||
async function start(): Promise<void> {
|
||||
if (started) return;
|
||||
started = true;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止管理器
|
||||
*/
|
||||
async function stop(): Promise<void> {
|
||||
if (!started) return;
|
||||
|
||||
started = false;
|
||||
pendingRefresh = false;
|
||||
|
||||
// 等待进行中的 refresh 完成
|
||||
if (refreshPromise) {
|
||||
try {
|
||||
await refreshPromise;
|
||||
} catch {
|
||||
// 忽略 refresh 错误
|
||||
}
|
||||
}
|
||||
|
||||
// 卸载所有触发器
|
||||
for (const handler of handlers.values()) {
|
||||
try {
|
||||
await handler.uninstallAll();
|
||||
} catch (e) {
|
||||
logger.warn('[TriggerManager] Error uninstalling handler:', e);
|
||||
}
|
||||
}
|
||||
installed.clear();
|
||||
lastFireAt.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁管理器
|
||||
*/
|
||||
async function dispose(): Promise<void> {
|
||||
await stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态
|
||||
*/
|
||||
function getState(): TriggerManagerState {
|
||||
return {
|
||||
started,
|
||||
installedTriggerIds: Array.from(installed.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
return { start, stop, refresh, fire, dispose, getState };
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* @fileoverview URL Trigger Handler (P4-03)
|
||||
* @description
|
||||
* Listens to `chrome.webNavigation.onCompleted` and fires installed URL triggers.
|
||||
*
|
||||
* URL matching semantics:
|
||||
* - kind:'url' - Full URL prefix match (allows query/hash variations)
|
||||
* - kind:'domain' - Safe subdomain match (hostname === domain OR hostname.endsWith('.' + domain))
|
||||
* - kind:'path' - Pathname prefix match
|
||||
*
|
||||
* Design rationale:
|
||||
* - No regex/wildcards for performance and auditability
|
||||
* - Domain matching uses safe subdomain logic to avoid false positives (e.g. 'notexample.com')
|
||||
* - Single listener instance manages multiple triggers efficiently
|
||||
*/
|
||||
|
||||
import type { TriggerId } from '../../domain/ids';
|
||||
import type { TriggerSpecByKind, UrlMatchRule } from '../../domain/triggers';
|
||||
import type { TriggerFireCallback, TriggerHandler, TriggerHandlerFactory } from './trigger-handler';
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface UrlTriggerHandlerDeps {
|
||||
logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
|
||||
}
|
||||
|
||||
type UrlTriggerSpec = TriggerSpecByKind<'url'>;
|
||||
|
||||
/**
|
||||
* Compiled URL match rules for efficient matching
|
||||
*/
|
||||
interface CompiledUrlRules {
|
||||
/** Full URL prefixes */
|
||||
urlPrefixes: string[];
|
||||
/** Normalized domains (lowercase, no leading/trailing dots) */
|
||||
domains: string[];
|
||||
/** Normalized path prefixes (always starts with '/') */
|
||||
pathPrefixes: string[];
|
||||
}
|
||||
|
||||
interface InstalledUrlTrigger {
|
||||
spec: UrlTriggerSpec;
|
||||
rules: CompiledUrlRules;
|
||||
}
|
||||
|
||||
// ==================== Normalization Utilities ====================
|
||||
|
||||
/**
|
||||
* Normalize domain value
|
||||
* - Trim whitespace
|
||||
* - Convert to lowercase
|
||||
* - Remove leading/trailing dots
|
||||
*/
|
||||
function normalizeDomain(value: string): string | null {
|
||||
const normalized = value.trim().toLowerCase().replace(/^\.+/, '').replace(/\.+$/, '');
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path prefix
|
||||
* - Trim whitespace
|
||||
* - Ensure starts with '/'
|
||||
*/
|
||||
function normalizePathPrefix(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize URL prefix
|
||||
* - Trim whitespace only
|
||||
*/
|
||||
function normalizeUrlPrefix(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile URL match rules from spec
|
||||
*/
|
||||
function compileUrlMatchRules(match: UrlMatchRule[] | undefined): CompiledUrlRules {
|
||||
const urlPrefixes: string[] = [];
|
||||
const domains: string[] = [];
|
||||
const pathPrefixes: string[] = [];
|
||||
|
||||
for (const rule of match ?? []) {
|
||||
const { kind } = rule;
|
||||
const raw = typeof rule.value === 'string' ? rule.value : String(rule.value ?? '');
|
||||
|
||||
switch (kind) {
|
||||
case 'url': {
|
||||
const normalized = normalizeUrlPrefix(raw);
|
||||
if (normalized) urlPrefixes.push(normalized);
|
||||
break;
|
||||
}
|
||||
case 'domain': {
|
||||
const normalized = normalizeDomain(raw);
|
||||
if (normalized) domains.push(normalized);
|
||||
break;
|
||||
}
|
||||
case 'path': {
|
||||
const normalized = normalizePathPrefix(raw);
|
||||
if (normalized) pathPrefixes.push(normalized);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { urlPrefixes, domains, pathPrefixes };
|
||||
}
|
||||
|
||||
// ==================== Matching Logic ====================
|
||||
|
||||
/**
|
||||
* Check if hostname matches domain (exact or subdomain)
|
||||
*/
|
||||
function hostnameMatchesDomain(hostname: string, domain: string): boolean {
|
||||
if (hostname === domain) return true;
|
||||
return hostname.endsWith(`.${domain}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL matches any of the compiled rules
|
||||
*/
|
||||
function matchesRules(compiled: CompiledUrlRules, urlString: string, parsed: URL): boolean {
|
||||
// URL prefix match
|
||||
for (const prefix of compiled.urlPrefixes) {
|
||||
if (urlString.startsWith(prefix)) return true;
|
||||
}
|
||||
|
||||
// Domain match
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
for (const domain of compiled.domains) {
|
||||
if (hostnameMatchesDomain(hostname, domain)) return true;
|
||||
}
|
||||
|
||||
// Path prefix match
|
||||
const pathname = parsed.pathname || '/';
|
||||
for (const prefix of compiled.pathPrefixes) {
|
||||
if (pathname.startsWith(prefix)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== Handler Implementation ====================
|
||||
|
||||
/**
|
||||
* Create URL trigger handler factory
|
||||
*/
|
||||
export function createUrlTriggerHandlerFactory(
|
||||
deps?: UrlTriggerHandlerDeps,
|
||||
): TriggerHandlerFactory<'url'> {
|
||||
return (fireCallback) => createUrlTriggerHandler(fireCallback, deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create URL trigger handler
|
||||
*/
|
||||
export function createUrlTriggerHandler(
|
||||
fireCallback: TriggerFireCallback,
|
||||
deps?: UrlTriggerHandlerDeps,
|
||||
): TriggerHandler<'url'> {
|
||||
const logger = deps?.logger ?? console;
|
||||
|
||||
const installed = new Map<TriggerId, InstalledUrlTrigger>();
|
||||
let listening = false;
|
||||
|
||||
/**
|
||||
* Handle webNavigation.onCompleted event
|
||||
*/
|
||||
const onCompleted = (details: chrome.webNavigation.WebNavigationFramedCallbackDetails): void => {
|
||||
// Only handle main frame navigations
|
||||
if (details.frameId !== 0) return;
|
||||
|
||||
const urlString = details.url;
|
||||
|
||||
// Parse URL
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(urlString);
|
||||
} catch {
|
||||
return; // Invalid URL, skip
|
||||
}
|
||||
|
||||
if (installed.size === 0) return;
|
||||
|
||||
// Snapshot to avoid iteration hazards during concurrent install/uninstall
|
||||
const snapshot = Array.from(installed.entries());
|
||||
|
||||
for (const [triggerId, trigger] of snapshot) {
|
||||
if (!matchesRules(trigger.rules, urlString, parsed)) continue;
|
||||
|
||||
// Fire and forget: chrome event listeners should not block navigation
|
||||
Promise.resolve(
|
||||
fireCallback.onFire(triggerId, {
|
||||
sourceTabId: details.tabId,
|
||||
sourceUrl: urlString,
|
||||
}),
|
||||
).catch((e) => {
|
||||
logger.error(`[UrlTriggerHandler] onFire failed for trigger "${triggerId}":`, e);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure listener is registered
|
||||
*/
|
||||
function ensureListening(): void {
|
||||
if (listening) return;
|
||||
if (!chrome.webNavigation?.onCompleted?.addListener) {
|
||||
logger.warn('[UrlTriggerHandler] chrome.webNavigation.onCompleted is unavailable');
|
||||
return;
|
||||
}
|
||||
chrome.webNavigation.onCompleted.addListener(onCompleted);
|
||||
listening = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening
|
||||
*/
|
||||
function stopListening(): void {
|
||||
if (!listening) return;
|
||||
try {
|
||||
chrome.webNavigation.onCompleted.removeListener(onCompleted);
|
||||
} catch (e) {
|
||||
logger.debug('[UrlTriggerHandler] removeListener failed:', e);
|
||||
} finally {
|
||||
listening = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'url',
|
||||
|
||||
async install(trigger: UrlTriggerSpec): Promise<void> {
|
||||
installed.set(trigger.id, {
|
||||
spec: trigger,
|
||||
rules: compileUrlMatchRules(trigger.match),
|
||||
});
|
||||
ensureListening();
|
||||
},
|
||||
|
||||
async uninstall(triggerId: string): Promise<void> {
|
||||
installed.delete(triggerId as TriggerId);
|
||||
if (installed.size === 0) {
|
||||
stopListening();
|
||||
}
|
||||
},
|
||||
|
||||
async uninstallAll(): Promise<void> {
|
||||
installed.clear();
|
||||
stopListening();
|
||||
},
|
||||
|
||||
getInstalledIds(): string[] {
|
||||
return Array.from(installed.keys());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @fileoverview Record-Replay V3 公共 API 入口
|
||||
* @description 导出所有公共类型和接口
|
||||
*/
|
||||
|
||||
// ==================== Domain ====================
|
||||
export * from './domain';
|
||||
|
||||
// ==================== Engine ====================
|
||||
export * from './engine';
|
||||
|
||||
// ==================== Storage ====================
|
||||
export * from './storage';
|
||||
|
||||
// ==================== Factory Functions ====================
|
||||
|
||||
import type { StoragePort } from './engine/storage/storage-port';
|
||||
import { createFlowsStore } from './storage/flows';
|
||||
import { createRunsStore } from './storage/runs';
|
||||
import { createEventsStore } from './storage/events';
|
||||
import { createQueueStore } from './storage/queue';
|
||||
import { createPersistentVarsStore } from './storage/persistent-vars';
|
||||
import { createTriggersStore } from './storage/triggers';
|
||||
|
||||
/**
|
||||
* 创建完整的 StoragePort 实现
|
||||
*/
|
||||
export function createStoragePort(): StoragePort {
|
||||
return {
|
||||
flows: createFlowsStore(),
|
||||
runs: createRunsStore(),
|
||||
events: createEventsStore(),
|
||||
queue: createQueueStore(),
|
||||
persistentVars: createPersistentVarsStore(),
|
||||
triggers: createTriggersStore(),
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Version ====================
|
||||
|
||||
/** V3 API 版本 */
|
||||
export const RR_V3_VERSION = '3.0.0' as const;
|
||||
|
||||
/** 是否为 V3 API */
|
||||
export const IS_RR_V3 = true as const;
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @fileoverview V3 IndexedDB 数据库定义
|
||||
* @description 定义 rr_v3 数据库的 schema 和初始化逻辑
|
||||
*/
|
||||
|
||||
/** 数据库名称 */
|
||||
export const RR_V3_DB_NAME = 'rr_v3';
|
||||
|
||||
/** 数据库版本 */
|
||||
export const RR_V3_DB_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Store 名称常量
|
||||
*/
|
||||
export const RR_V3_STORES = {
|
||||
FLOWS: 'flows',
|
||||
RUNS: 'runs',
|
||||
EVENTS: 'events',
|
||||
QUEUE: 'queue',
|
||||
PERSISTENT_VARS: 'persistent_vars',
|
||||
TRIGGERS: 'triggers',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Store 配置
|
||||
*/
|
||||
export interface StoreConfig {
|
||||
keyPath: string | string[];
|
||||
autoIncrement?: boolean;
|
||||
indexes?: Array<{
|
||||
name: string;
|
||||
keyPath: string | string[];
|
||||
options?: IDBIndexParameters;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* V3 Store Schema 定义
|
||||
* @description 包含 Phase 1-3 所需的所有索引,避免后续升级
|
||||
*/
|
||||
export const RR_V3_STORE_SCHEMAS: Record<string, StoreConfig> = {
|
||||
[RR_V3_STORES.FLOWS]: {
|
||||
keyPath: 'id',
|
||||
indexes: [
|
||||
{ name: 'name', keyPath: 'name' },
|
||||
{ name: 'updatedAt', keyPath: 'updatedAt' },
|
||||
],
|
||||
},
|
||||
[RR_V3_STORES.RUNS]: {
|
||||
keyPath: 'id',
|
||||
indexes: [
|
||||
{ name: 'status', keyPath: 'status' },
|
||||
{ name: 'flowId', keyPath: 'flowId' },
|
||||
{ name: 'createdAt', keyPath: 'createdAt' },
|
||||
{ name: 'updatedAt', keyPath: 'updatedAt' },
|
||||
// Compound index for listing runs by flow and status
|
||||
{ name: 'flowId_status', keyPath: ['flowId', 'status'] },
|
||||
],
|
||||
},
|
||||
[RR_V3_STORES.EVENTS]: {
|
||||
keyPath: ['runId', 'seq'],
|
||||
indexes: [
|
||||
{ name: 'runId', keyPath: 'runId' },
|
||||
{ name: 'type', keyPath: 'type' },
|
||||
// Compound index for filtering events by run and type
|
||||
{ name: 'runId_type', keyPath: ['runId', 'type'] },
|
||||
],
|
||||
},
|
||||
[RR_V3_STORES.QUEUE]: {
|
||||
keyPath: 'id',
|
||||
indexes: [
|
||||
{ name: 'status', keyPath: 'status' },
|
||||
{ name: 'priority', keyPath: 'priority' },
|
||||
{ name: 'createdAt', keyPath: 'createdAt' },
|
||||
{ name: 'flowId', keyPath: 'flowId' },
|
||||
// Phase 3: Used by claimNext(); cursor direction + key ranges implement priority DESC + createdAt ASC.
|
||||
{ name: 'status_priority_createdAt', keyPath: ['status', 'priority', 'createdAt'] },
|
||||
// Phase 3: Lease expiration tracking
|
||||
{ name: 'lease_expiresAt', keyPath: 'lease.expiresAt' },
|
||||
],
|
||||
},
|
||||
[RR_V3_STORES.PERSISTENT_VARS]: {
|
||||
keyPath: 'key',
|
||||
indexes: [{ name: 'updatedAt', keyPath: 'updatedAt' }],
|
||||
},
|
||||
[RR_V3_STORES.TRIGGERS]: {
|
||||
keyPath: 'id',
|
||||
indexes: [
|
||||
{ name: 'kind', keyPath: 'kind' },
|
||||
{ name: 'flowId', keyPath: 'flowId' },
|
||||
{ name: 'enabled', keyPath: 'enabled' },
|
||||
// Compound index for listing enabled triggers by kind
|
||||
{ name: 'kind_enabled', keyPath: ['kind', 'enabled'] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 数据库升级处理器
|
||||
*/
|
||||
export function handleUpgrade(db: IDBDatabase, oldVersion: number, _newVersion: number): void {
|
||||
// Version 0 -> 1: 创建所有 stores
|
||||
if (oldVersion < 1) {
|
||||
for (const [storeName, config] of Object.entries(RR_V3_STORE_SCHEMAS)) {
|
||||
const store = db.createObjectStore(storeName, {
|
||||
keyPath: config.keyPath,
|
||||
autoIncrement: config.autoIncrement,
|
||||
});
|
||||
|
||||
// 创建索引
|
||||
if (config.indexes) {
|
||||
for (const index of config.indexes) {
|
||||
store.createIndex(index.name, index.keyPath, index.options);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局数据库实例 */
|
||||
let dbInstance: IDBDatabase | null = null;
|
||||
let dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
/**
|
||||
* 打开 V3 数据库
|
||||
* @description 单例模式,确保只有一个数据库连接
|
||||
*/
|
||||
export async function openRrV3Db(): Promise<IDBDatabase> {
|
||||
if (dbInstance) {
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
if (dbPromise) {
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
dbPromise = new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(RR_V3_DB_NAME, RR_V3_DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
dbPromise = null;
|
||||
reject(new Error(`Failed to open database: ${request.error?.message}`));
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
dbInstance = request.result;
|
||||
|
||||
// 处理版本变更(其他 tab 升级了数据库)
|
||||
dbInstance.onversionchange = () => {
|
||||
dbInstance?.close();
|
||||
dbInstance = null;
|
||||
dbPromise = null;
|
||||
};
|
||||
|
||||
resolve(dbInstance);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = request.result;
|
||||
const oldVersion = event.oldVersion;
|
||||
const newVersion = event.newVersion ?? RR_V3_DB_VERSION;
|
||||
handleUpgrade(db, oldVersion, newVersion);
|
||||
};
|
||||
});
|
||||
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭数据库连接
|
||||
* @description 主要用于测试
|
||||
*/
|
||||
export function closeRrV3Db(): void {
|
||||
if (dbInstance) {
|
||||
dbInstance.close();
|
||||
dbInstance = null;
|
||||
dbPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除数据库
|
||||
* @description 主要用于测试
|
||||
*/
|
||||
export async function deleteRrV3Db(): Promise<void> {
|
||||
closeRrV3Db();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase(RR_V3_DB_NAME);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行事务
|
||||
* @param storeNames Store 名称(单个或多个)
|
||||
* @param mode 事务模式
|
||||
* @param callback 事务回调
|
||||
*/
|
||||
export async function withTransaction<T>(
|
||||
storeNames: string | string[],
|
||||
mode: IDBTransactionMode,
|
||||
callback: (stores: Record<string, IDBObjectStore>) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
const db = await openRrV3Db();
|
||||
const names = Array.isArray(storeNames) ? storeNames : [storeNames];
|
||||
const tx = db.transaction(names, mode);
|
||||
|
||||
const stores: Record<string, IDBObjectStore> = {};
|
||||
for (const name of names) {
|
||||
stores[name] = tx.objectStore(name);
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let result: T;
|
||||
|
||||
tx.oncomplete = () => resolve(result);
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error || new Error('Transaction aborted'));
|
||||
|
||||
Promise.resolve(callback(stores))
|
||||
.then((r) => {
|
||||
result = r;
|
||||
})
|
||||
.catch((err) => {
|
||||
tx.abort();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @fileoverview RunEvent 持久化
|
||||
* @description 实现事件的原子 seq 分配和存储
|
||||
*/
|
||||
|
||||
import type { RunId } from '../domain/ids';
|
||||
import type { RunEvent, RunEventInput, RunRecordV3 } from '../domain/events';
|
||||
import { RR_ERROR_CODES, createRRError } from '../domain/errors';
|
||||
import type { EventsStore } from '../engine/storage/storage-port';
|
||||
import { RR_V3_STORES, withTransaction } from './db';
|
||||
|
||||
/**
|
||||
* IDB request helper - promisify IDBRequest with RRError wrapping
|
||||
*/
|
||||
function idbRequest<T>(request: IDBRequest<T>, context: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => {
|
||||
const error = request.error;
|
||||
reject(
|
||||
createRRError(
|
||||
RR_ERROR_CODES.INTERNAL,
|
||||
`IDB error in ${context}: ${error?.message ?? 'unknown'}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 EventsStore 实现
|
||||
* @description
|
||||
* - append() 在单个事务中原子分配 seq
|
||||
* - seq 由 RunRecordV3.nextSeq 作为单一事实来源
|
||||
*/
|
||||
export function createEventsStore(): EventsStore {
|
||||
return {
|
||||
/**
|
||||
* 追加事件并原子分配 seq
|
||||
* @description 在单个事务中:读取 RunRecordV3.nextSeq -> 写入事件 -> 递增 nextSeq
|
||||
*/
|
||||
async append(input: RunEventInput): Promise<RunEvent> {
|
||||
return withTransaction(
|
||||
[RR_V3_STORES.RUNS, RR_V3_STORES.EVENTS],
|
||||
'readwrite',
|
||||
async (stores) => {
|
||||
const runsStore = stores[RR_V3_STORES.RUNS];
|
||||
const eventsStore = stores[RR_V3_STORES.EVENTS];
|
||||
|
||||
// Step 1: Read nextSeq from RunRecordV3 (single source of truth)
|
||||
const run = await idbRequest<RunRecordV3 | undefined>(
|
||||
runsStore.get(input.runId),
|
||||
`append.getRun(${input.runId})`,
|
||||
);
|
||||
|
||||
if (!run) {
|
||||
throw createRRError(
|
||||
RR_ERROR_CODES.INTERNAL,
|
||||
`Run "${input.runId}" not found when appending event`,
|
||||
);
|
||||
}
|
||||
|
||||
const seq = run.nextSeq;
|
||||
|
||||
// Validate seq integrity
|
||||
if (!Number.isSafeInteger(seq) || seq < 0) {
|
||||
throw createRRError(
|
||||
RR_ERROR_CODES.INVARIANT_VIOLATION,
|
||||
`Invalid nextSeq for run "${input.runId}": ${String(seq)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Create complete event with allocated seq
|
||||
const event: RunEvent = {
|
||||
...input,
|
||||
seq,
|
||||
ts: input.ts ?? Date.now(),
|
||||
} as RunEvent;
|
||||
|
||||
// Step 3: Write event to events store
|
||||
await idbRequest(eventsStore.add(event), `append.addEvent(${input.runId}, seq=${seq})`);
|
||||
|
||||
// Step 4: Increment nextSeq in runs store (same transaction)
|
||||
const updatedRun: RunRecordV3 = {
|
||||
...run,
|
||||
nextSeq: seq + 1,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await idbRequest(
|
||||
runsStore.put(updatedRun),
|
||||
`append.updateNextSeq(${input.runId}, nextSeq=${seq + 1})`,
|
||||
);
|
||||
|
||||
return event;
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 列出事件
|
||||
* @description 利用复合主键 [runId, seq] 实现高效范围查询
|
||||
*/
|
||||
async list(runId: RunId, opts?: { fromSeq?: number; limit?: number }): Promise<RunEvent[]> {
|
||||
return withTransaction(RR_V3_STORES.EVENTS, 'readonly', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.EVENTS];
|
||||
const fromSeq = opts?.fromSeq ?? 0;
|
||||
const limit = opts?.limit;
|
||||
|
||||
// Early return for zero limit
|
||||
if (limit === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return new Promise<RunEvent[]>((resolve, reject) => {
|
||||
const results: RunEvent[] = [];
|
||||
|
||||
// Use compound primary key [runId, seq] for efficient range query
|
||||
// This yields events in seq-ascending order naturally
|
||||
const range = IDBKeyRange.bound([runId, fromSeq], [runId, Number.MAX_SAFE_INTEGER]);
|
||||
|
||||
const request = store.openCursor(range);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
|
||||
if (!cursor) {
|
||||
resolve(results);
|
||||
return;
|
||||
}
|
||||
|
||||
const event = cursor.value as RunEvent;
|
||||
results.push(event);
|
||||
|
||||
// Check limit
|
||||
if (limit !== undefined && results.length >= limit) {
|
||||
resolve(results);
|
||||
return;
|
||||
}
|
||||
|
||||
cursor.continue();
|
||||
};
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @fileoverview FlowV3 持久化
|
||||
* @description 实现 Flow 的 CRUD 操作
|
||||
*/
|
||||
|
||||
import type { FlowId } from '../domain/ids';
|
||||
import type { FlowV3 } from '../domain/flow';
|
||||
import { FLOW_SCHEMA_VERSION } from '../domain/flow';
|
||||
import { RR_ERROR_CODES, createRRError } from '../domain/errors';
|
||||
import type { FlowsStore } from '../engine/storage/storage-port';
|
||||
import { RR_V3_STORES, withTransaction } from './db';
|
||||
|
||||
/**
|
||||
* 校验 Flow 结构
|
||||
*/
|
||||
function validateFlow(flow: FlowV3): void {
|
||||
// 校验 schema 版本
|
||||
if (flow.schemaVersion !== FLOW_SCHEMA_VERSION) {
|
||||
throw createRRError(
|
||||
RR_ERROR_CODES.VALIDATION_ERROR,
|
||||
`Invalid schema version: expected ${FLOW_SCHEMA_VERSION}, got ${flow.schemaVersion}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 校验必填字段
|
||||
if (!flow.id) {
|
||||
throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Flow id is required');
|
||||
}
|
||||
if (!flow.name) {
|
||||
throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Flow name is required');
|
||||
}
|
||||
if (!flow.entryNodeId) {
|
||||
throw createRRError(RR_ERROR_CODES.VALIDATION_ERROR, 'Flow entryNodeId is required');
|
||||
}
|
||||
|
||||
// 校验 entryNodeId 存在
|
||||
const nodeIds = new Set(flow.nodes.map((n) => n.id));
|
||||
if (!nodeIds.has(flow.entryNodeId)) {
|
||||
throw createRRError(
|
||||
RR_ERROR_CODES.VALIDATION_ERROR,
|
||||
`Entry node "${flow.entryNodeId}" does not exist in flow`,
|
||||
);
|
||||
}
|
||||
|
||||
// 校验边引用
|
||||
for (const edge of flow.edges) {
|
||||
if (!nodeIds.has(edge.from)) {
|
||||
throw createRRError(
|
||||
RR_ERROR_CODES.VALIDATION_ERROR,
|
||||
`Edge "${edge.id}" references non-existent source node "${edge.from}"`,
|
||||
);
|
||||
}
|
||||
if (!nodeIds.has(edge.to)) {
|
||||
throw createRRError(
|
||||
RR_ERROR_CODES.VALIDATION_ERROR,
|
||||
`Edge "${edge.id}" references non-existent target node "${edge.to}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 FlowsStore 实现
|
||||
*/
|
||||
export function createFlowsStore(): FlowsStore {
|
||||
return {
|
||||
async list(): Promise<FlowV3[]> {
|
||||
return withTransaction(RR_V3_STORES.FLOWS, 'readonly', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.FLOWS];
|
||||
return new Promise<FlowV3[]>((resolve, reject) => {
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result as FlowV3[]);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async get(id: FlowId): Promise<FlowV3 | null> {
|
||||
return withTransaction(RR_V3_STORES.FLOWS, 'readonly', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.FLOWS];
|
||||
return new Promise<FlowV3 | null>((resolve, reject) => {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve((request.result as FlowV3) ?? null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async save(flow: FlowV3): Promise<void> {
|
||||
// 校验
|
||||
validateFlow(flow);
|
||||
|
||||
return withTransaction(RR_V3_STORES.FLOWS, 'readwrite', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.FLOWS];
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = store.put(flow);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async delete(id: FlowId): Promise<void> {
|
||||
return withTransaction(RR_V3_STORES.FLOWS, 'readwrite', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.FLOWS];
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @fileoverview Import 模块导出入口
|
||||
*/
|
||||
|
||||
export * from './v2-reader';
|
||||
export * from './v2-to-v3';
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @fileoverview V2 数据读取器
|
||||
* @description 读取 V2 格式的数据(占位实现)
|
||||
*/
|
||||
|
||||
/**
|
||||
* V2 数据读取器接口
|
||||
* @description Phase 5+ 实现
|
||||
*/
|
||||
export interface V2Reader {
|
||||
/** 读取 V2 Flows */
|
||||
readFlows(): Promise<unknown[]>;
|
||||
/** 读取 V2 Runs */
|
||||
readRuns(): Promise<unknown[]>;
|
||||
/** 读取 V2 Triggers */
|
||||
readTriggers(): Promise<unknown[]>;
|
||||
/** 读取 V2 Schedules */
|
||||
readSchedules(): Promise<unknown[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 NotImplemented 的 V2Reader
|
||||
*/
|
||||
export function createNotImplementedV2Reader(): V2Reader {
|
||||
const notImplemented = async () => {
|
||||
throw new Error('V2Reader not implemented');
|
||||
};
|
||||
|
||||
return {
|
||||
readFlows: notImplemented,
|
||||
readRuns: notImplemented,
|
||||
readTriggers: notImplemented,
|
||||
readSchedules: notImplemented,
|
||||
};
|
||||
}
|
||||
+671
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* @fileoverview V2 到 V3 数据转换器
|
||||
* @description 将 V2 格式数据转换为 V3 格式,支持双向转换
|
||||
*/
|
||||
|
||||
import type { FlowV3, NodeV3, EdgeV3, FlowBinding } from '../../domain/flow';
|
||||
import type { TriggerSpec } from '../../domain/triggers';
|
||||
import type { VariableDefinition } from '../../domain/variables';
|
||||
import type { NodeId, FlowId, EdgeId } from '../../domain/ids';
|
||||
import type { ISODateTimeString } from '../../domain/json';
|
||||
import { FLOW_SCHEMA_VERSION } from '../../domain/flow';
|
||||
|
||||
// ==================== V2 Types (imported from record-replay) ====================
|
||||
|
||||
/** V2 Node type definition */
|
||||
interface V2Node {
|
||||
id: string;
|
||||
type: string;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
ui?: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** V2 Edge type definition */
|
||||
interface V2Edge {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** V2 Variable definition */
|
||||
interface V2VariableDef {
|
||||
key: string;
|
||||
label?: string;
|
||||
sensitive?: boolean;
|
||||
default?: unknown;
|
||||
type?: string;
|
||||
rules?: { required?: boolean; pattern?: string; enum?: string[] };
|
||||
}
|
||||
|
||||
/** V2 Flow binding */
|
||||
interface V2Binding {
|
||||
type: 'domain' | 'path' | 'url';
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** V2 Flow definition */
|
||||
interface V2Flow {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
version: number;
|
||||
meta?: {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
domain?: string;
|
||||
tags?: string[];
|
||||
bindings?: V2Binding[];
|
||||
tool?: { category?: string; description?: string };
|
||||
exposedOutputs?: Array<{ nodeId: string; as: string }>;
|
||||
};
|
||||
variables?: V2VariableDef[];
|
||||
nodes?: V2Node[];
|
||||
edges?: V2Edge[];
|
||||
subflows?: Record<string, { nodes: V2Node[]; edges: V2Edge[] }>;
|
||||
}
|
||||
|
||||
// ==================== Conversion Result Types ====================
|
||||
|
||||
export interface ConversionResult<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
// ==================== V2 -> V3 Conversion ====================
|
||||
|
||||
/**
|
||||
* 将 V2 Flow 转换为 V3 Flow
|
||||
* @param v2Flow V2 格式的 Flow
|
||||
* @returns 转换结果,包含成功/失败状态、数据和错误/警告信息
|
||||
*/
|
||||
export function convertFlowV2ToV3(v2Flow: V2Flow): ConversionResult<FlowV3> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 1. 基础字段验证
|
||||
if (!v2Flow.id) {
|
||||
errors.push('V2 Flow missing required field: id');
|
||||
}
|
||||
if (!v2Flow.name) {
|
||||
errors.push('V2 Flow missing required field: name');
|
||||
}
|
||||
if (!v2Flow.nodes || v2Flow.nodes.length === 0) {
|
||||
errors.push('V2 Flow has no nodes');
|
||||
}
|
||||
|
||||
// 2. 检查不支持的特性
|
||||
if (v2Flow.subflows && Object.keys(v2Flow.subflows).length > 0) {
|
||||
errors.push(
|
||||
'V3 does not support subflows yet. Flow contains subflows: ' +
|
||||
Object.keys(v2Flow.subflows).join(', '),
|
||||
);
|
||||
}
|
||||
|
||||
// 检查 foreach/while 节点
|
||||
const unsupportedNodes = (v2Flow.nodes || []).filter(
|
||||
(n) => n.type === 'foreach' || n.type === 'while',
|
||||
);
|
||||
if (unsupportedNodes.length > 0) {
|
||||
errors.push(
|
||||
'V3 does not support foreach/while nodes yet. Found: ' +
|
||||
unsupportedNodes.map((n) => `${n.id} (${n.type})`).join(', '),
|
||||
);
|
||||
}
|
||||
|
||||
// 如果有致命错误,直接返回
|
||||
if (errors.length > 0) {
|
||||
return { success: false, errors, warnings };
|
||||
}
|
||||
|
||||
// 3. 转换节点
|
||||
const nodes: NodeV3[] = [];
|
||||
for (const v2Node of v2Flow.nodes || []) {
|
||||
const node = convertNodeV2ToV3(v2Node);
|
||||
if (node) {
|
||||
nodes.push(node);
|
||||
} else {
|
||||
warnings.push(`Skipped invalid node: ${v2Node.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 转换边
|
||||
const edges: EdgeV3[] = [];
|
||||
for (const v2Edge of v2Flow.edges || []) {
|
||||
const edge = convertEdgeV2ToV3(v2Edge);
|
||||
if (edge) {
|
||||
edges.push(edge);
|
||||
} else {
|
||||
warnings.push(`Skipped invalid edge: ${v2Edge.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 计算 entryNodeId
|
||||
const entryResult = findEntryNodeId(nodes, edges);
|
||||
warnings.push(...entryResult.warnings);
|
||||
if (!entryResult.nodeId) {
|
||||
errors.push('Could not determine entry node. No valid root node found.');
|
||||
return { success: false, errors, warnings };
|
||||
}
|
||||
const entryNodeId = entryResult.nodeId;
|
||||
|
||||
// 6. 转换变量
|
||||
const variables = convertVariablesV2ToV3(v2Flow.variables || []);
|
||||
|
||||
// 7. 转换元数据
|
||||
const meta = convertMetaV2ToV3(v2Flow.meta);
|
||||
|
||||
// 8. 构建 V3 Flow
|
||||
const now = new Date().toISOString() as ISODateTimeString;
|
||||
const v3Flow: FlowV3 = {
|
||||
schemaVersion: FLOW_SCHEMA_VERSION,
|
||||
id: v2Flow.id as FlowId,
|
||||
name: v2Flow.name,
|
||||
createdAt: (v2Flow.meta?.createdAt as ISODateTimeString) || now,
|
||||
updatedAt: (v2Flow.meta?.updatedAt as ISODateTimeString) || now,
|
||||
entryNodeId,
|
||||
nodes,
|
||||
edges,
|
||||
};
|
||||
|
||||
// 可选字段
|
||||
if (v2Flow.description) {
|
||||
v3Flow.description = v2Flow.description;
|
||||
}
|
||||
if (variables.length > 0) {
|
||||
v3Flow.variables = variables;
|
||||
}
|
||||
if (meta) {
|
||||
v3Flow.meta = meta;
|
||||
}
|
||||
|
||||
return { success: true, data: v3Flow, errors, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换单个 V2 Node 为 V3 Node
|
||||
*/
|
||||
function convertNodeV2ToV3(v2Node: V2Node): NodeV3 | null {
|
||||
if (!v2Node.id || !v2Node.type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const node: NodeV3 = {
|
||||
id: v2Node.id as NodeId,
|
||||
kind: v2Node.type, // V2 type -> V3 kind
|
||||
config: (v2Node.config as Record<string, unknown>) || {},
|
||||
};
|
||||
|
||||
// 可选字段
|
||||
if (v2Node.name) {
|
||||
node.name = v2Node.name;
|
||||
}
|
||||
if (v2Node.disabled) {
|
||||
node.disabled = v2Node.disabled;
|
||||
}
|
||||
if (v2Node.ui) {
|
||||
node.ui = v2Node.ui;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换单个 V2 Edge 为 V3 Edge
|
||||
*/
|
||||
function convertEdgeV2ToV3(v2Edge: V2Edge): EdgeV3 | null {
|
||||
if (!v2Edge.id || !v2Edge.from || !v2Edge.to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const edge: EdgeV3 = {
|
||||
id: v2Edge.id as EdgeId,
|
||||
from: v2Edge.from as NodeId,
|
||||
to: v2Edge.to as NodeId,
|
||||
};
|
||||
|
||||
// label 直接传递
|
||||
if (v2Edge.label) {
|
||||
edge.label = v2Edge.label as EdgeV3['label'];
|
||||
}
|
||||
|
||||
return edge;
|
||||
}
|
||||
|
||||
/** entryNodeId 计算结果 */
|
||||
interface EntryNodeResult {
|
||||
nodeId: NodeId | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 找到入口节点 ID
|
||||
*
|
||||
* 规则:
|
||||
* 1. 排除 trigger 类型节点(这些是 UI 节点,不参与执行)
|
||||
* 2. 只统计「可执行节点 -> 可执行节点」的边来计算入度(忽略 trigger 指出的边)
|
||||
* 3. 找到入度为 0 的节点作为候选
|
||||
* 4. 如果有多个候选,使用稳定选择规则:
|
||||
* - 优先选择 UI 坐标最靠左上的节点(按 x 升序,x 相同按 y 升序)
|
||||
* - 如果无 UI 坐标,按 ID 字典序取第一个
|
||||
*/
|
||||
function findEntryNodeId(nodes: NodeV3[], edges: EdgeV3[]): EntryNodeResult {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 1. 排除 trigger 节点,获取可执行节点
|
||||
const executableNodes = nodes.filter((n) => n.kind !== 'trigger');
|
||||
if (executableNodes.length === 0) {
|
||||
warnings.push('No executable nodes found; cannot determine entry node');
|
||||
return { nodeId: null, warnings };
|
||||
}
|
||||
|
||||
const executableNodeIds = new Set<NodeId>(executableNodes.map((n) => n.id));
|
||||
|
||||
// 2. 计算入度(只统计可执行节点之间的边)
|
||||
const inDegree = new Map<NodeId, number>();
|
||||
for (const node of executableNodes) {
|
||||
inDegree.set(node.id, 0);
|
||||
}
|
||||
for (const edge of edges) {
|
||||
// 忽略从非可执行节点(如 trigger)指出的边
|
||||
if (!executableNodeIds.has(edge.from)) {
|
||||
continue;
|
||||
}
|
||||
// 忽略指向非可执行节点的边
|
||||
if (!executableNodeIds.has(edge.to)) {
|
||||
continue;
|
||||
}
|
||||
inDegree.set(edge.to, (inDegree.get(edge.to) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// 3. 找入度为 0 的节点
|
||||
const rootNodes = executableNodes.filter((n) => inDegree.get(n.id) === 0);
|
||||
|
||||
if (rootNodes.length === 0) {
|
||||
// 没有入度为 0 的节点,说明图中存在环,使用稳定选择器选择 fallback
|
||||
const fallbackResult = selectStableRootNode(executableNodes);
|
||||
warnings.push(
|
||||
`No inDegree=0 executable node found (graph may contain cycles); ` +
|
||||
`falling back to "${fallbackResult.node.id}" by ${fallbackResult.rule}`,
|
||||
);
|
||||
return { nodeId: fallbackResult.node.id, warnings };
|
||||
}
|
||||
|
||||
// 4. 单个根节点,直接返回
|
||||
if (rootNodes.length === 1) {
|
||||
return { nodeId: rootNodes[0].id, warnings };
|
||||
}
|
||||
|
||||
// 5. 多个根节点,使用稳定选择规则
|
||||
const selectedResult = selectStableRootNode(rootNodes);
|
||||
const candidateIds = rootNodes
|
||||
.map((n) => n.id)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join(', ');
|
||||
warnings.push(
|
||||
`Multiple inDegree=0 executable nodes (${candidateIds}); ` +
|
||||
`selected "${selectedResult.node.id}" by ${selectedResult.rule}`,
|
||||
);
|
||||
|
||||
return { nodeId: selectedResult.node.id, warnings };
|
||||
}
|
||||
|
||||
/** 稳定选择结果 */
|
||||
interface StableSelectionResult {
|
||||
node: NodeV3;
|
||||
rule: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从多个根节点中选择一个稳定的入口节点
|
||||
* 优先按 UI 坐标(左上角优先),其次按 ID 字典序
|
||||
*/
|
||||
function selectStableRootNode(nodes: NodeV3[]): StableSelectionResult {
|
||||
// 检查节点是否有有效的 UI 坐标
|
||||
const hasValidUi = (n: NodeV3): n is NodeV3 & { ui: { x: number; y: number } } =>
|
||||
!!n.ui && Number.isFinite(n.ui.x) && Number.isFinite(n.ui.y);
|
||||
|
||||
const nodesWithUi = nodes.filter(hasValidUi);
|
||||
|
||||
if (nodesWithUi.length > 0) {
|
||||
// 按 UI 坐标排序:x 升序 -> y 升序 -> id 字典序(作为 tie-breaker)
|
||||
nodesWithUi.sort((a, b) => {
|
||||
if (a.ui.x !== b.ui.x) return a.ui.x - b.ui.x;
|
||||
if (a.ui.y !== b.ui.y) return a.ui.y - b.ui.y;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
const selected = nodesWithUi[0];
|
||||
return {
|
||||
node: selected,
|
||||
rule: `ui(x=${selected.ui.x}, y=${selected.ui.y})`,
|
||||
};
|
||||
}
|
||||
|
||||
// 无 UI 坐标,按 ID 字典序
|
||||
const sortedById = [...nodes].sort((a, b) => a.id.localeCompare(b.id));
|
||||
return { node: sortedById[0], rule: 'id' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换变量定义
|
||||
*/
|
||||
function convertVariablesV2ToV3(v2Variables: V2VariableDef[]): VariableDefinition[] {
|
||||
return v2Variables
|
||||
.filter((v) => v.key)
|
||||
.map((v) => {
|
||||
const variable: VariableDefinition = {
|
||||
name: v.key,
|
||||
};
|
||||
|
||||
if (v.label) {
|
||||
variable.label = v.label;
|
||||
}
|
||||
if (v.sensitive) {
|
||||
variable.sensitive = v.sensitive;
|
||||
}
|
||||
if (v.default !== undefined) {
|
||||
variable.default = v.default;
|
||||
}
|
||||
if (v.rules?.required) {
|
||||
variable.required = v.rules.required;
|
||||
}
|
||||
|
||||
return variable;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换元数据
|
||||
*/
|
||||
function convertMetaV2ToV3(v2Meta: V2Flow['meta']): FlowV3['meta'] | undefined {
|
||||
if (!v2Meta) return undefined;
|
||||
|
||||
const meta: FlowV3['meta'] = {};
|
||||
|
||||
if (v2Meta.tags && v2Meta.tags.length > 0) {
|
||||
meta.tags = v2Meta.tags;
|
||||
}
|
||||
|
||||
if (v2Meta.bindings && v2Meta.bindings.length > 0) {
|
||||
meta.bindings = v2Meta.bindings.map((b) => ({
|
||||
kind: b.type, // V2 type -> V3 kind
|
||||
value: b.value,
|
||||
}));
|
||||
}
|
||||
|
||||
// 如果 meta 为空对象,返回 undefined
|
||||
if (Object.keys(meta).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
// ==================== V3 -> V2 Conversion ====================
|
||||
|
||||
/**
|
||||
* 将 V3 Flow 转换为 V2 Flow(用于在 V2 Builder 中编辑)
|
||||
* @param v3Flow V3 格式的 Flow
|
||||
* @returns 转换结果
|
||||
*/
|
||||
export function convertFlowV3ToV2(v3Flow: FlowV3): ConversionResult<V2Flow> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 1. 转换节点
|
||||
const nodes: V2Node[] = v3Flow.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: n.kind, // V3 kind -> V2 type
|
||||
name: n.name,
|
||||
disabled: n.disabled,
|
||||
config: n.config as Record<string, unknown>,
|
||||
ui: n.ui,
|
||||
}));
|
||||
|
||||
// 2. 转换边
|
||||
const edges: V2Edge[] = v3Flow.edges.map((e) => ({
|
||||
id: e.id,
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
label: e.label,
|
||||
}));
|
||||
|
||||
// 3. 转换变量
|
||||
const variables: V2VariableDef[] = (v3Flow.variables || []).map((v) => ({
|
||||
key: v.name,
|
||||
label: v.label,
|
||||
sensitive: v.sensitive,
|
||||
default: v.default,
|
||||
rules: v.required ? { required: v.required } : undefined,
|
||||
}));
|
||||
|
||||
// 4. 转换元数据
|
||||
const meta: V2Flow['meta'] = {
|
||||
createdAt: v3Flow.createdAt,
|
||||
updatedAt: v3Flow.updatedAt,
|
||||
};
|
||||
|
||||
if (v3Flow.meta?.tags) {
|
||||
meta.tags = v3Flow.meta.tags;
|
||||
}
|
||||
|
||||
if (v3Flow.meta?.bindings) {
|
||||
meta.bindings = v3Flow.meta.bindings.map((b) => ({
|
||||
type: b.kind, // V3 kind -> V2 type
|
||||
value: b.value,
|
||||
}));
|
||||
}
|
||||
|
||||
// 5. 构建 V2 Flow
|
||||
const v2Flow: V2Flow = {
|
||||
id: v3Flow.id,
|
||||
name: v3Flow.name,
|
||||
description: v3Flow.description,
|
||||
version: 2, // V2 版本
|
||||
meta,
|
||||
variables: variables.length > 0 ? variables : undefined,
|
||||
nodes,
|
||||
edges,
|
||||
};
|
||||
|
||||
return { success: true, data: v2Flow, errors, warnings };
|
||||
}
|
||||
|
||||
// ==================== Trigger Conversion ====================
|
||||
|
||||
/** V2 Trigger 定义 */
|
||||
interface V2Trigger {
|
||||
id: string;
|
||||
type: 'url' | 'command' | 'manual' | 'schedule' | 'element';
|
||||
flowId: string;
|
||||
enabled?: boolean;
|
||||
match?: Array<{ kind: string; value: string }>;
|
||||
title?: string;
|
||||
commandKey?: string;
|
||||
selector?: string;
|
||||
appear?: boolean;
|
||||
once?: boolean;
|
||||
debounceMs?: number;
|
||||
schedule?: {
|
||||
type: 'interval' | 'daily' | 'weekly';
|
||||
intervalMs?: number;
|
||||
time?: string;
|
||||
days?: number[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 V2 Trigger 转换为 V3 TriggerSpec
|
||||
* @param v2Trigger V2 格式的 Trigger
|
||||
* @returns 转换结果
|
||||
*/
|
||||
export function convertTriggerV2ToV3(v2Trigger: V2Trigger): ConversionResult<TriggerSpec> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!v2Trigger.id) {
|
||||
errors.push('V2 Trigger missing required field: id');
|
||||
}
|
||||
if (!v2Trigger.flowId) {
|
||||
errors.push('V2 Trigger missing required field: flowId');
|
||||
}
|
||||
if (!v2Trigger.type) {
|
||||
errors.push('V2 Trigger missing required field: type');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { success: false, errors, warnings };
|
||||
}
|
||||
|
||||
// 根据 type 构建不同的 TriggerSpec
|
||||
let trigger: TriggerSpec;
|
||||
|
||||
switch (v2Trigger.type) {
|
||||
case 'manual':
|
||||
trigger = {
|
||||
id: v2Trigger.id,
|
||||
kind: 'manual',
|
||||
flowId: v2Trigger.flowId as FlowId,
|
||||
enabled: v2Trigger.enabled ?? true,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'command':
|
||||
trigger = {
|
||||
id: v2Trigger.id,
|
||||
kind: 'command',
|
||||
flowId: v2Trigger.flowId as FlowId,
|
||||
enabled: v2Trigger.enabled ?? true,
|
||||
command: v2Trigger.commandKey || 'run_workflow',
|
||||
};
|
||||
break;
|
||||
|
||||
case 'url':
|
||||
trigger = {
|
||||
id: v2Trigger.id,
|
||||
kind: 'url',
|
||||
flowId: v2Trigger.flowId as FlowId,
|
||||
enabled: v2Trigger.enabled ?? true,
|
||||
patterns: (v2Trigger.match || []).map((m) => m.value),
|
||||
};
|
||||
break;
|
||||
|
||||
case 'schedule': { // 将 V2 schedule 转换为 cron 表达式
|
||||
const cron = convertScheduleToCron(v2Trigger.schedule);
|
||||
if (!cron) {
|
||||
errors.push('Could not convert V2 schedule to cron expression');
|
||||
return { success: false, errors, warnings };
|
||||
}
|
||||
trigger = {
|
||||
id: v2Trigger.id,
|
||||
kind: 'cron',
|
||||
flowId: v2Trigger.flowId as FlowId,
|
||||
enabled: v2Trigger.enabled ?? true,
|
||||
cron,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case 'element':
|
||||
warnings.push('Element trigger is not fully supported in V3, converting to manual');
|
||||
trigger = {
|
||||
id: v2Trigger.id,
|
||||
kind: 'manual',
|
||||
flowId: v2Trigger.flowId as FlowId,
|
||||
enabled: v2Trigger.enabled ?? true,
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
errors.push(`Unknown V2 trigger type: ${v2Trigger.type}`);
|
||||
return { success: false, errors, warnings };
|
||||
}
|
||||
|
||||
return { success: true, data: trigger, errors, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 V2 schedule 配置转换为 cron 表达式
|
||||
*/
|
||||
function convertScheduleToCron(schedule: V2Trigger['schedule']): string | null {
|
||||
if (!schedule) return null;
|
||||
|
||||
switch (schedule.type) {
|
||||
case 'interval': { // 将间隔转换为近似 cron(每 N 分钟)
|
||||
const intervalMinutes = Math.max(1, Math.round((schedule.intervalMs || 60000) / 60000));
|
||||
if (intervalMinutes < 60) {
|
||||
return `*/${intervalMinutes} * * * *`;
|
||||
} else {
|
||||
const hours = Math.round(intervalMinutes / 60);
|
||||
return `0 */${hours} * * *`;
|
||||
}
|
||||
}
|
||||
|
||||
case 'daily':
|
||||
// 每天指定时间
|
||||
if (schedule.time) {
|
||||
const [hour, minute] = schedule.time.split(':').map(Number);
|
||||
return `${minute || 0} ${hour || 0} * * *`;
|
||||
}
|
||||
return '0 0 * * *'; // 默认每天 0:00
|
||||
|
||||
case 'weekly': { // 每周指定天数和时间
|
||||
const days = (schedule.days || [0]).join(',');
|
||||
if (schedule.time) {
|
||||
const [hour, minute] = schedule.time.split(':').map(Number);
|
||||
return `${minute || 0} ${hour || 0} * * ${days}`;
|
||||
}
|
||||
return `0 0 * * ${days}`;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Converter Interface ====================
|
||||
|
||||
/**
|
||||
* V2 到 V3 转换器接口
|
||||
*/
|
||||
export interface V2ToV3Converter {
|
||||
/** 转换 Flow */
|
||||
convertFlow(v2Flow: unknown): FlowV3;
|
||||
/** 转换 Trigger */
|
||||
convertTrigger(v2Trigger: unknown): TriggerSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 V2ToV3Converter 实例
|
||||
*/
|
||||
export function createV2ToV3Converter(): V2ToV3Converter {
|
||||
return {
|
||||
convertFlow(v2Flow: unknown): FlowV3 {
|
||||
const result = convertFlowV2ToV3(v2Flow as V2Flow);
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(`Flow conversion failed: ${result.errors.join('; ')}`);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
|
||||
convertTrigger(v2Trigger: unknown): TriggerSpec {
|
||||
const result = convertTriggerV2ToV3(v2Trigger as V2Trigger);
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(`Trigger conversion failed: ${result.errors.join('; ')}`);
|
||||
}
|
||||
return result.data;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 NotImplemented 的 V2ToV3Converter(向后兼容)
|
||||
* @deprecated 使用 createV2ToV3Converter() 替代
|
||||
*/
|
||||
export function createNotImplementedV2ToV3Converter(): V2ToV3Converter {
|
||||
return createV2ToV3Converter();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @fileoverview Storage 层导出入口
|
||||
*/
|
||||
|
||||
export * from './db';
|
||||
export * from './flows';
|
||||
export * from './runs';
|
||||
export * from './events';
|
||||
export * from './queue';
|
||||
export * from './persistent-vars';
|
||||
export * from './triggers';
|
||||
export * from './import';
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @fileoverview 持久化变量存储
|
||||
* @description 实现 $ 前缀变量的持久化,使用 LWW(Last-Write-Wins)策略
|
||||
*/
|
||||
|
||||
import type { PersistentVarRecord, PersistentVariableName } from '../domain/variables';
|
||||
import type { JsonValue } from '../domain/json';
|
||||
import type { PersistentVarsStore } from '../engine/storage/storage-port';
|
||||
import { RR_V3_STORES, withTransaction } from './db';
|
||||
|
||||
/**
|
||||
* 创建 PersistentVarsStore 实现
|
||||
*/
|
||||
export function createPersistentVarsStore(): PersistentVarsStore {
|
||||
return {
|
||||
async get(key: PersistentVariableName): Promise<PersistentVarRecord | undefined> {
|
||||
return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readonly', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.PERSISTENT_VARS];
|
||||
return new Promise<PersistentVarRecord | undefined>((resolve, reject) => {
|
||||
const request = store.get(key);
|
||||
request.onsuccess = () => resolve(request.result as PersistentVarRecord | undefined);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async set(key: PersistentVariableName, value: JsonValue): Promise<PersistentVarRecord> {
|
||||
return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readwrite', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.PERSISTENT_VARS];
|
||||
|
||||
// 先读取现有记录(用于 version 递增)
|
||||
const existing = await new Promise<PersistentVarRecord | undefined>((resolve, reject) => {
|
||||
const request = store.get(key);
|
||||
request.onsuccess = () => resolve(request.result as PersistentVarRecord | undefined);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const record: PersistentVarRecord = {
|
||||
key,
|
||||
value,
|
||||
updatedAt: now,
|
||||
version: (existing?.version ?? 0) + 1,
|
||||
};
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put(record);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
return record;
|
||||
});
|
||||
},
|
||||
|
||||
async delete(key: PersistentVariableName): Promise<void> {
|
||||
return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readwrite', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.PERSISTENT_VARS];
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = store.delete(key);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async list(prefix?: PersistentVariableName): Promise<PersistentVarRecord[]> {
|
||||
return withTransaction(RR_V3_STORES.PERSISTENT_VARS, 'readonly', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.PERSISTENT_VARS];
|
||||
|
||||
return new Promise<PersistentVarRecord[]>((resolve, reject) => {
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => {
|
||||
let results = request.result as PersistentVarRecord[];
|
||||
|
||||
// 如果指定了前缀,过滤结果
|
||||
if (prefix) {
|
||||
results = results.filter((r) => r.key.startsWith(prefix));
|
||||
}
|
||||
|
||||
resolve(results);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* @fileoverview RunQueue 持久化
|
||||
* @description 实现队列的 CRUD 操作和原子 claim
|
||||
*/
|
||||
|
||||
import type { RunId } from '../domain/ids';
|
||||
import {
|
||||
DEFAULT_QUEUE_CONFIG,
|
||||
type EnqueueInput,
|
||||
type QueueItemStatus,
|
||||
type RunQueue,
|
||||
type RunQueueItem,
|
||||
} from '../engine/queue/queue';
|
||||
import { RR_V3_STORES, withTransaction } from './db';
|
||||
|
||||
/** Default lease TTL in milliseconds (from shared config to avoid drift) */
|
||||
const DEFAULT_LEASE_TTL_MS = DEFAULT_QUEUE_CONFIG.leaseTtlMs;
|
||||
|
||||
/**
|
||||
* IDB key range bounds for numeric fields.
|
||||
* Use MAX_VALUE to cover the full range of finite numbers (not just safe integers).
|
||||
*/
|
||||
const IDB_NUMBER_MIN = -Number.MAX_VALUE;
|
||||
const IDB_NUMBER_MAX = Number.MAX_VALUE;
|
||||
|
||||
/**
|
||||
* 创建 RunQueue 持久化实现
|
||||
* @description 实现队列持久化,包括 Phase 3 原子 claim
|
||||
*/
|
||||
export function createQueueStore(): RunQueue {
|
||||
return {
|
||||
async enqueue(input: EnqueueInput): Promise<RunQueueItem> {
|
||||
const now = Date.now();
|
||||
const item: RunQueueItem = {
|
||||
...input,
|
||||
priority: input.priority ?? 0,
|
||||
maxAttempts: input.maxAttempts ?? 1,
|
||||
status: 'queued',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
attempt: 0,
|
||||
};
|
||||
|
||||
await withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.QUEUE];
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = store.add(item);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
|
||||
return item;
|
||||
},
|
||||
|
||||
async claimNext(ownerId: string, now: number): Promise<RunQueueItem | null> {
|
||||
// Validate inputs
|
||||
if (!ownerId) {
|
||||
throw new Error('ownerId is required');
|
||||
}
|
||||
if (!Number.isFinite(now)) {
|
||||
throw new Error(`Invalid now: ${String(now)}`);
|
||||
}
|
||||
|
||||
return withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.QUEUE];
|
||||
const index = store.index('status_priority_createdAt');
|
||||
|
||||
/**
|
||||
* Atomic claim implementation using two-step cursor approach:
|
||||
*
|
||||
* Desired ordering: priority DESC, createdAt ASC (FIFO within same priority)
|
||||
*
|
||||
* IndexedDB compound indexes only support single sort direction for the entire tuple.
|
||||
* The index ['status', 'priority', 'createdAt'] is stored ASC.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Use 'prev' cursor to find the highest priority (overall DESC)
|
||||
* 2. Use 'next' cursor within that priority to find earliest createdAt (FIFO)
|
||||
*
|
||||
* Both operations are within the same readwrite transaction, ensuring atomicity
|
||||
* since IndexedDB serializes readwrite transactions on the same store.
|
||||
*/
|
||||
|
||||
// Step 1: Find the highest priority among queued items
|
||||
const queuedRange = IDBKeyRange.bound(
|
||||
['queued', IDB_NUMBER_MIN, IDB_NUMBER_MIN],
|
||||
['queued', IDB_NUMBER_MAX, IDB_NUMBER_MAX],
|
||||
);
|
||||
|
||||
const highestPriority = await new Promise<number | null>((resolve, reject) => {
|
||||
const request = index.openCursor(queuedRange, 'prev');
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const item = cursor.value as RunQueueItem;
|
||||
resolve(item.priority);
|
||||
};
|
||||
});
|
||||
|
||||
// No queued items available
|
||||
if (highestPriority === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 2: Find the earliest createdAt within the highest priority (FIFO)
|
||||
const fifoRange = IDBKeyRange.bound(
|
||||
['queued', highestPriority, IDB_NUMBER_MIN],
|
||||
['queued', highestPriority, IDB_NUMBER_MAX],
|
||||
);
|
||||
|
||||
return new Promise<RunQueueItem | null>((resolve, reject) => {
|
||||
const request = index.openCursor(fifoRange, 'next');
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
// No items found (should not happen given step 1 succeeded)
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = cursor.value as RunQueueItem;
|
||||
|
||||
// Defensive check: ensure status is still queued
|
||||
if (existing.status !== 'queued') {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Atomically update to running with lease
|
||||
const updated: RunQueueItem = {
|
||||
...existing,
|
||||
status: 'running',
|
||||
updatedAt: now,
|
||||
attempt: existing.attempt + 1,
|
||||
lease: {
|
||||
ownerId,
|
||||
expiresAt: now + DEFAULT_LEASE_TTL_MS,
|
||||
},
|
||||
};
|
||||
|
||||
const updateRequest = cursor.update(updated);
|
||||
updateRequest.onerror = () => reject(updateRequest.error);
|
||||
updateRequest.onsuccess = () => resolve(updated);
|
||||
};
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async heartbeat(ownerId: string, now: number): Promise<void> {
|
||||
// Validate inputs
|
||||
if (!ownerId) {
|
||||
throw new Error('ownerId is required');
|
||||
}
|
||||
if (!Number.isFinite(now)) {
|
||||
throw new Error(`Invalid now: ${String(now)}`);
|
||||
}
|
||||
|
||||
await withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.QUEUE];
|
||||
const statusIndex = store.index('status');
|
||||
|
||||
/**
|
||||
* Renew leases for all items owned by ownerId in the given status.
|
||||
* Uses cursor iteration to update each item atomically.
|
||||
*/
|
||||
const renewForStatus = async (status: QueueItemStatus): Promise<void> => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = statusIndex.openCursor(IDBKeyRange.only(status));
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const item = cursor.value as RunQueueItem;
|
||||
const lease = item.lease;
|
||||
|
||||
// Skip items not owned by this ownerId
|
||||
if (!lease || lease.ownerId !== ownerId) {
|
||||
cursor.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
// Renew the lease
|
||||
const updated: RunQueueItem = {
|
||||
...item,
|
||||
updatedAt: now,
|
||||
lease: {
|
||||
...lease,
|
||||
expiresAt: now + DEFAULT_LEASE_TTL_MS,
|
||||
},
|
||||
};
|
||||
|
||||
const updateRequest = cursor.update(updated);
|
||||
updateRequest.onerror = () => reject(updateRequest.error);
|
||||
updateRequest.onsuccess = () => cursor.continue();
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Renew both running and paused items for the owner.
|
||||
// Paused items also need renewal to prevent TTL expiration during debug/manual pause.
|
||||
await renewForStatus('running');
|
||||
await renewForStatus('paused');
|
||||
});
|
||||
},
|
||||
|
||||
async reclaimExpiredLeases(now: number): Promise<RunId[]> {
|
||||
if (!Number.isFinite(now)) {
|
||||
throw new Error(`Invalid now: ${String(now)}`);
|
||||
}
|
||||
|
||||
return withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.QUEUE];
|
||||
const leaseIndex = store.index('lease_expiresAt');
|
||||
|
||||
// Scan all items where lease.expiresAt < now (strictly less than)
|
||||
const expiredRange = IDBKeyRange.upperBound(now, true);
|
||||
|
||||
return new Promise<RunId[]>((resolve, reject) => {
|
||||
const reclaimed: RunId[] = [];
|
||||
const request = leaseIndex.openCursor(expiredRange);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve(reclaimed);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = cursor.value as RunQueueItem;
|
||||
const expiresAtKey = cursor.key;
|
||||
|
||||
// Defensive: index key should be a finite number (Unix millis)
|
||||
if (typeof expiresAtKey !== 'number' || !Number.isFinite(expiresAtKey)) {
|
||||
cursor.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
// The key range already guarantees expiresAtKey < now, but keep a guard
|
||||
// to be resilient to non-standard IndexedDB implementations.
|
||||
if (expiresAtKey >= now) {
|
||||
cursor.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
const isReclaimable = item.status === 'running' || item.status === 'paused';
|
||||
|
||||
// Reclaim policy:
|
||||
// - running/paused + expired lease => move back to queued, drop lease
|
||||
// - any other status + expired lease => drop lease defensively (shouldn't happen)
|
||||
// Note: attempt is NOT reset on reclaim - preserves retry history.
|
||||
const { lease: _droppedLease, ...itemWithoutLease } = item;
|
||||
const updated: RunQueueItem = isReclaimable
|
||||
? { ...itemWithoutLease, status: 'queued', updatedAt: now }
|
||||
: { ...itemWithoutLease, updatedAt: now };
|
||||
|
||||
const updateRequest = cursor.update(updated);
|
||||
updateRequest.onerror = () => reject(updateRequest.error);
|
||||
updateRequest.onsuccess = () => {
|
||||
if (isReclaimable) {
|
||||
reclaimed.push(item.id);
|
||||
}
|
||||
cursor.continue();
|
||||
};
|
||||
};
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async recoverOrphanLeases(
|
||||
ownerId: string,
|
||||
now: number,
|
||||
): Promise<{
|
||||
requeuedRunning: Array<{ runId: RunId; prevOwnerId?: string }>;
|
||||
adoptedPaused: Array<{ runId: RunId; prevOwnerId?: string }>;
|
||||
}> {
|
||||
// Validate inputs
|
||||
if (!ownerId) {
|
||||
throw new Error('ownerId is required');
|
||||
}
|
||||
if (!Number.isFinite(now)) {
|
||||
throw new Error(`Invalid now: ${String(now)}`);
|
||||
}
|
||||
|
||||
return withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.QUEUE];
|
||||
const statusIndex = store.index('status');
|
||||
|
||||
const requeuedRunning: Array<{ runId: RunId; prevOwnerId?: string }> = [];
|
||||
const adoptedPaused: Array<{ runId: RunId; prevOwnerId?: string }> = [];
|
||||
|
||||
/**
|
||||
* 扫描并回收孤儿 running 项
|
||||
* @description
|
||||
* - 孤儿定义:无租约或 lease.ownerId !== currentOwnerId
|
||||
* - 回收策略:status -> queued,清除 lease,保留 attempt
|
||||
*/
|
||||
const recoverRunningItems = (): Promise<void> =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const request = statusIndex.openCursor(IDBKeyRange.only('running'));
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const item = cursor.value as RunQueueItem;
|
||||
const prevOwnerId = item.lease?.ownerId;
|
||||
|
||||
// 非孤儿:lease 存在且属于当前 ownerId
|
||||
const isOrphan = !item.lease || item.lease.ownerId !== ownerId;
|
||||
if (!isOrphan) {
|
||||
cursor.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
// 回收:移除 lease,状态改为 queued
|
||||
const { lease: _droppedLease, ...itemWithoutLease } = item;
|
||||
const updated: RunQueueItem = {
|
||||
...itemWithoutLease,
|
||||
status: 'queued',
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const updateRequest = cursor.update(updated);
|
||||
updateRequest.onerror = () => reject(updateRequest.error);
|
||||
updateRequest.onsuccess = () => {
|
||||
requeuedRunning.push({
|
||||
runId: item.id,
|
||||
...(prevOwnerId ? { prevOwnerId } : {}),
|
||||
});
|
||||
cursor.continue();
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 扫描并接管孤儿 paused 项
|
||||
* @description
|
||||
* - 孤儿定义:无租约或 lease.ownerId !== currentOwnerId
|
||||
* - 接管策略:保持 status=paused,更新 lease.ownerId 为新 ownerId,续约 TTL
|
||||
*/
|
||||
const recoverPausedItems = (): Promise<void> =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const request = statusIndex.openCursor(IDBKeyRange.only('paused'));
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const item = cursor.value as RunQueueItem;
|
||||
const prevOwnerId = item.lease?.ownerId;
|
||||
|
||||
// 非孤儿:lease 存在且属于当前 ownerId
|
||||
const isOrphan = !item.lease || item.lease.ownerId !== ownerId;
|
||||
if (!isOrphan) {
|
||||
cursor.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
// 接管:更新 lease 为新 ownerId,续约 TTL
|
||||
const updated: RunQueueItem = {
|
||||
...item,
|
||||
updatedAt: now,
|
||||
lease: {
|
||||
ownerId,
|
||||
expiresAt: now + DEFAULT_LEASE_TTL_MS,
|
||||
},
|
||||
};
|
||||
|
||||
const updateRequest = cursor.update(updated);
|
||||
updateRequest.onerror = () => reject(updateRequest.error);
|
||||
updateRequest.onsuccess = () => {
|
||||
adoptedPaused.push({
|
||||
runId: item.id,
|
||||
...(prevOwnerId ? { prevOwnerId } : {}),
|
||||
});
|
||||
cursor.continue();
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
// 顺序执行:先处理 running,再处理 paused
|
||||
await recoverRunningItems();
|
||||
await recoverPausedItems();
|
||||
|
||||
return { requeuedRunning, adoptedPaused };
|
||||
});
|
||||
},
|
||||
|
||||
async markRunning(runId: RunId, ownerId: string, now: number): Promise<void> {
|
||||
await withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.QUEUE];
|
||||
|
||||
const existing = await new Promise<RunQueueItem | null>((resolve, reject) => {
|
||||
const request = store.get(runId);
|
||||
request.onsuccess = () => resolve((request.result as RunQueueItem) ?? null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new Error(`Queue item "${runId}" not found`);
|
||||
}
|
||||
|
||||
// Attempt semantics:
|
||||
// - queued -> running: attempt + 1 (a new scheduling attempt)
|
||||
// - paused/running -> running: attempt unchanged (resume/idempotent)
|
||||
const nextAttempt = existing.status === 'queued' ? existing.attempt + 1 : existing.attempt;
|
||||
|
||||
const updated: RunQueueItem = {
|
||||
...existing,
|
||||
status: 'running',
|
||||
updatedAt: now,
|
||||
attempt: nextAttempt,
|
||||
lease: {
|
||||
ownerId,
|
||||
expiresAt: now + DEFAULT_LEASE_TTL_MS,
|
||||
},
|
||||
};
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = store.put(updated);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async markPaused(runId: RunId, ownerId: string, now: number): Promise<void> {
|
||||
await withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.QUEUE];
|
||||
|
||||
const existing = await new Promise<RunQueueItem | null>((resolve, reject) => {
|
||||
const request = store.get(runId);
|
||||
request.onsuccess = () => resolve((request.result as RunQueueItem) ?? null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new Error(`Queue item "${runId}" not found`);
|
||||
}
|
||||
|
||||
const updated: RunQueueItem = {
|
||||
...existing,
|
||||
status: 'paused',
|
||||
updatedAt: now,
|
||||
lease: {
|
||||
ownerId,
|
||||
expiresAt: now + DEFAULT_LEASE_TTL_MS,
|
||||
},
|
||||
};
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = store.put(updated);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async markDone(runId: RunId, now: number): Promise<void> {
|
||||
await withTransaction(RR_V3_STORES.QUEUE, 'readwrite', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.QUEUE];
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = store.delete(runId);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async cancel(runId: RunId, _now: number, _reason?: string): Promise<void> {
|
||||
// 从队列中删除
|
||||
await this.markDone(runId, _now);
|
||||
},
|
||||
|
||||
async get(runId: RunId): Promise<RunQueueItem | null> {
|
||||
return withTransaction(RR_V3_STORES.QUEUE, 'readonly', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.QUEUE];
|
||||
return new Promise<RunQueueItem | null>((resolve, reject) => {
|
||||
const request = store.get(runId);
|
||||
request.onsuccess = () => resolve((request.result as RunQueueItem) ?? null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async list(status?: QueueItemStatus): Promise<RunQueueItem[]> {
|
||||
return withTransaction(RR_V3_STORES.QUEUE, 'readonly', async (stores) => {
|
||||
const store = stores[RR_V3_STORES.QUEUE];
|
||||
|
||||
if (status) {
|
||||
// 使用索引查询
|
||||
const index = store.index('status');
|
||||
return new Promise<RunQueueItem[]>((resolve, reject) => {
|
||||
const request = index.getAll(IDBKeyRange.only(status));
|
||||
request.onsuccess = () => resolve(request.result as RunQueueItem[]);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// 获取所有
|
||||
return new Promise<RunQueueItem[]>((resolve, reject) => {
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result as RunQueueItem[]);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user