Merge pull request #203 from qinfendebingshuo/copilot/sycmdexe-001-ai-execution-capacity
Add 14 HLI 501 stub routes + Grid-DB Phase 0 storage engine
This commit is contained in:
commit
855438df28
|
|
@ -10,6 +10,22 @@
|
|||
"last_synced": "2026-03-19T02:00:00+08:00"
|
||||
},
|
||||
"recent_events": [
|
||||
{
|
||||
"date": "2026-03-26",
|
||||
"type": "system_infrastructure",
|
||||
"description": "PRJ-GDB-001 Grid-DB 自研存储引擎立项 · Phase 0 基础骨架完成 · GridCell + WAL + PageManager + EventLog + GridAPI 部署完成 · ONT-PATCH-006 写入本体论 · 95 项冒烟测试全通过",
|
||||
"by": "铸渊",
|
||||
"instruction": "SY-CMD-GDB-001",
|
||||
"issued_by": "AG-SY-01 霜砚"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-26",
|
||||
"type": "system_infrastructure",
|
||||
"description": "PRJ-EXE-001 自研执行引擎立项 · Phase 0 基础建设启动 · AGE-Router + DeepSeek Adapter + Resource Meter 部署完成 · ONT-PATCH-005 写入本体论",
|
||||
"by": "铸渊",
|
||||
"instruction": "SY-CMD-EXE-001",
|
||||
"issued_by": "AG-SY-01 霜砚"
|
||||
},
|
||||
{
|
||||
"date": "2026-03-25",
|
||||
"type": "gate_guard",
|
||||
|
|
|
|||
|
|
@ -7,3 +7,9 @@ backend/api-server/logs/
|
|||
# Writing Platform build artifacts
|
||||
writing-platform/frontend/dist/
|
||||
writing-platform/backend/dist/
|
||||
|
||||
# EXE-Engine runtime logs
|
||||
exe-engine/logs/
|
||||
|
||||
# Grid-DB runtime data (engine-generated)
|
||||
grid-db/data/
|
||||
|
|
|
|||
|
|
@ -93,3 +93,33 @@ Notion 主脑更新
|
|||
↓
|
||||
生成下一任务(自动开发循环)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自研执行引擎 · EXE-Engine(PRJ-EXE-001)
|
||||
|
||||
> Phase 0 · 2026-03-26 立项 · 本体论:AGE OS = 笔 · 算力 = 墨水 · 用户 = 写字的人
|
||||
|
||||
| 组件 | 路径 | 职责 |
|
||||
|------|------|------|
|
||||
| AGE-Router | `exe-engine/src/router/age-router.js` | 路由网关:鉴权、分类、模型选择、限流降级 |
|
||||
| DeepSeek 适配器 | `exe-engine/src/adapters/deepseek-adapter.js` | DeepSeek-V3/R1 模型调用 |
|
||||
| Qwen 适配器 | `exe-engine/src/adapters/qwen-adapter.js` | Qwen-Max/Coder 模型调用 |
|
||||
| 负载均衡器 | `exe-engine/src/balancer/load-balancer.js` | 成本/质量/均衡策略选择 |
|
||||
| 资源计量器 | `exe-engine/src/meter/resource-meter.js` | Token 消耗记录 + 成本统计 |
|
||||
| 上下文缓存 | `exe-engine/src/cache/context-cache.js` | Agent 上下文 LRU 缓存 |
|
||||
| Agent 调度器 | `exe-engine/src/controller/agent-controller.js` | Agent→模型偏好映射 |
|
||||
|
||||
---
|
||||
|
||||
## 自研存储引擎 · Grid-DB(PRJ-GDB-001)
|
||||
|
||||
> Phase 0 · 2026-03-26 立项 · 本体论:Grid-DB = 纸 · 格点 = 格子 · 事件 = 笔迹
|
||||
|
||||
| 组件 | 路径 | 职责 |
|
||||
|------|------|------|
|
||||
| GridAPI | `grid-db/src/api/grid-api.js` | 统一 API:put/get/delete/scan/subscribe |
|
||||
| GridCell | `grid-db/src/core/grid-cell.js` | 格点数据模型:四元组寻址 (ns, x, y, layer) |
|
||||
| WAL | `grid-db/src/storage/wal.js` | Write-Ahead Log:崩溃恢复保证 |
|
||||
| PageManager | `grid-db/src/storage/page-manager.js` | 页管理器:固定大小页分配/读写/释放 |
|
||||
| EventLog | `grid-db/src/events/event-log.js` | 事件溯源:不可变事件流 + 审计日志 |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
# EXE-Engine · 自研执行引擎
|
||||
|
||||
> **项目编号**:PRJ-EXE-001
|
||||
> **版权**:国作登字-2026-A-00037559
|
||||
> **阶段**:Phase 0 · 基础建设
|
||||
|
||||
---
|
||||
|
||||
## 本体论锚定 [ONT-PATCH-005]
|
||||
|
||||
**AGE OS = 笔** · **算力 = 墨水** · **用户 = 写字的人**
|
||||
|
||||
AGE OS 是一支智能的、有生命的笔。它不制造墨水(不拥有算力),它使用墨水。
|
||||
用户可以自带墨水(BYOK),也可以从 AGE OS 的墨水池里取用(资源池)。
|
||||
|
||||
---
|
||||
|
||||
## 系统架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 用户层 │
|
||||
│ 用户 / 开发者 / Agent │
|
||||
└───────────────────┬──────────────────────────┘
|
||||
│
|
||||
┌───────────────────▼──────────────────────────┐
|
||||
│ AGE-Router 路由网关 │
|
||||
│ 鉴权 → 分类 → 模型选择 → 限流降级 │
|
||||
└───────────────────┬──────────────────────────┘
|
||||
│
|
||||
┌───────────────────▼──────────────────────────┐
|
||||
│ LoadBalancer 负载均衡器 │
|
||||
│ cost / balanced / quality 策略 │
|
||||
│ 故障转移 · 并发控制 │
|
||||
└──────┬────────────┬──────────────┬───────────┘
|
||||
│ │ │
|
||||
┌──────▼───┐ ┌─────▼────┐ ┌─────▼────┐
|
||||
│DeepSeek │ │ Qwen │ │ Other │
|
||||
│Adapter │ │ Adapter │ │ Adapter │
|
||||
└──────┬───┘ └─────┬────┘ └─────┬────┘
|
||||
│ │ │
|
||||
┌──────▼────────────▼──────────────▼───────────┐
|
||||
│ ResourceMeter 资源计量器 │
|
||||
│ token 消耗 · 成本计算 · 数据回流 DC v1.0 │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
exe-engine/
|
||||
├── package.json
|
||||
├── README.md
|
||||
├── config/
|
||||
│ ├── models.json # 模型配置(端点、成本、能力)
|
||||
│ └── resource-pools.json # 资源池配置
|
||||
├── src/
|
||||
│ ├── index.js # 主入口 + createEngine()
|
||||
│ ├── router/
|
||||
│ │ └── age-router.js # AGE-Router 路由网关
|
||||
│ ├── adapters/
|
||||
│ │ ├── base-adapter.js # EXEModelAdapter 基类
|
||||
│ │ ├── deepseek-adapter.js # DeepSeek-V3/R1 适配器
|
||||
│ │ └── qwen-adapter.js # Qwen-Max/Coder 适配器
|
||||
│ ├── balancer/
|
||||
│ │ └── load-balancer.js # 负载均衡器
|
||||
│ ├── meter/
|
||||
│ │ └── resource-meter.js # 资源计量器
|
||||
│ ├── cache/
|
||||
│ │ └── context-cache.js # 上下文缓存
|
||||
│ └── controller/
|
||||
│ └── agent-controller.js # Agent 调度器
|
||||
├── docs/
|
||||
│ └── architecture.md # 详细架构文档
|
||||
└── tests/
|
||||
└── smoke/
|
||||
└── exe-engine.test.js # 冒烟测试
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 查看引擎状态
|
||||
node exe-engine/src/index.js status
|
||||
|
||||
# 运行冒烟测试
|
||||
node exe-engine/tests/smoke/exe-engine.test.js
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
### POST /api/exe/v1/execute
|
||||
|
||||
```json
|
||||
{
|
||||
"agentId": "AG-ZY-01",
|
||||
"taskType": "code_generation",
|
||||
"prompt": "...",
|
||||
"context": { "repo": "...", "branch": "..." },
|
||||
"preferences": {
|
||||
"model": "auto",
|
||||
"priority": "balanced",
|
||||
"maxTokens": 4096
|
||||
},
|
||||
"resourcePool": "default"
|
||||
}
|
||||
```
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "exe-xxxxx",
|
||||
"model": "deepseek-v3",
|
||||
"output": "...",
|
||||
"usage": {
|
||||
"inputTokens": 1200,
|
||||
"outputTokens": 800,
|
||||
"cost": 0.0012,
|
||||
"unit": "CNY"
|
||||
},
|
||||
"latency": 2340,
|
||||
"status": "success"
|
||||
}
|
||||
```
|
||||
|
||||
## 接入模式
|
||||
|
||||
| 模式 | 说明 | 适用 |
|
||||
|------|------|------|
|
||||
| BYOK | 用户自带 API Key | 有自己 API 账户的开发者 |
|
||||
| 资源池 | AGE OS 统一资源池 | 轻量用户 |
|
||||
| 混合 | BYOK + 资源池 fallback | 企业用户 |
|
||||
|
||||
## Phase 路线
|
||||
|
||||
| Phase | 目标 | 状态 |
|
||||
|-------|------|------|
|
||||
| P0 基础建设 | Router + DeepSeek Adapter + Meter | 🟢 当前 |
|
||||
| P1 影子模式 | Qwen Adapter + LoadBalancer + 并行验证 | ⚪ 待开始 |
|
||||
| P2 灰度切换 | Agent Controller 双轨 + BYOK + 计费 | ⚪ 待开始 |
|
||||
| P3 全面切换 | 完全替代 Copilot | ⚪ 待开始 |
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"version": "1.0.0",
|
||||
"project": "PRJ-EXE-001",
|
||||
"models": {
|
||||
"deepseek-v3": {
|
||||
"name": "DeepSeek-V3",
|
||||
"provider": "deepseek",
|
||||
"endpoint": "https://api.deepseek.com/v1/chat/completions",
|
||||
"envKey": "DEEPSEEK_API_KEY",
|
||||
"capabilities": ["code", "text", "reasoning"],
|
||||
"costPerToken": { "input": 1.0, "output": 2.0, "currency": "CNY", "unit": "M_tokens" },
|
||||
"maxContext": 64000,
|
||||
"defaultTemperature": 0.3,
|
||||
"defaultMaxTokens": 4096
|
||||
},
|
||||
"deepseek-r1": {
|
||||
"name": "DeepSeek-R1",
|
||||
"provider": "deepseek",
|
||||
"endpoint": "https://api.deepseek.com/v1/chat/completions",
|
||||
"envKey": "DEEPSEEK_API_KEY",
|
||||
"capabilities": ["reasoning", "text", "code"],
|
||||
"costPerToken": { "input": 4.0, "output": 16.0, "currency": "CNY", "unit": "M_tokens" },
|
||||
"maxContext": 64000,
|
||||
"defaultTemperature": 0.5,
|
||||
"defaultMaxTokens": 4096
|
||||
},
|
||||
"qwen-max": {
|
||||
"name": "Qwen-Max",
|
||||
"provider": "dashscope",
|
||||
"endpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
"envKey": "DASHSCOPE_API_KEY",
|
||||
"capabilities": ["text", "code", "reasoning"],
|
||||
"costPerToken": { "input": 2.0, "output": 6.0, "currency": "CNY", "unit": "M_tokens" },
|
||||
"maxContext": 32000,
|
||||
"defaultTemperature": 0.7,
|
||||
"defaultMaxTokens": 4096
|
||||
},
|
||||
"qwen-coder": {
|
||||
"name": "Qwen-Coder",
|
||||
"provider": "dashscope",
|
||||
"endpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
"envKey": "DASHSCOPE_API_KEY",
|
||||
"capabilities": ["code", "text"],
|
||||
"costPerToken": { "input": 1.0, "output": 2.0, "currency": "CNY", "unit": "M_tokens" },
|
||||
"maxContext": 32000,
|
||||
"defaultTemperature": 0.3,
|
||||
"defaultMaxTokens": 4096
|
||||
}
|
||||
},
|
||||
"taskModelMapping": {
|
||||
"code_generation": ["deepseek-v3", "qwen-coder", "qwen-max"],
|
||||
"text_processing": ["qwen-max", "deepseek-v3"],
|
||||
"reasoning": ["deepseek-r1", "deepseek-v3", "qwen-max"],
|
||||
"agent_instruction": ["deepseek-v3", "qwen-max", "deepseek-r1"]
|
||||
},
|
||||
"platformFeeRate": 0.20
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"version": "1.0.0",
|
||||
"pools": {
|
||||
"system": {
|
||||
"name": "系统资源池",
|
||||
"description": "AGE OS 内部系统调用专用(天眼、SYSBOOT 等)",
|
||||
"mode": "pool",
|
||||
"quotaLimit": null,
|
||||
"rateLimitPerMinute": 60,
|
||||
"priority": "high"
|
||||
},
|
||||
"default": {
|
||||
"name": "默认资源池",
|
||||
"description": "通用资源池,供未配置独立 Key 的用户使用",
|
||||
"mode": "pool",
|
||||
"quotaLimit": null,
|
||||
"rateLimitPerMinute": 30,
|
||||
"priority": "normal"
|
||||
}
|
||||
},
|
||||
"degradation": {
|
||||
"warningThreshold": 0.20,
|
||||
"degradeThreshold": 0.05,
|
||||
"circuitBreakThreshold": 0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
# EXE-Engine 架构设计文档
|
||||
|
||||
> 项目编号:PRJ-EXE-001
|
||||
> 版权:国作登字-2026-A-00037559
|
||||
> 版本:v1.0.0 · Phase 0
|
||||
> 签发:AG-SY-01 霜砚 · 执行:AG-ZY-01 铸渊
|
||||
|
||||
---
|
||||
|
||||
## 1. 本体论锚定 [ONT-PATCH-005]
|
||||
|
||||
### AGE OS · 笔 · 墨水 · 写字的人
|
||||
|
||||
**AGE OS = 笔**
|
||||
|
||||
AGE OS 是一支笔。它会写代码、会做事、会思考。它的本质是工具——一支智能的、有生命的笔。
|
||||
它不是纸(不存储用户的数据所有权),不是墨水(不提供算力),不是写字的人(不替代用户的意志)。
|
||||
|
||||
**算力 = 墨水**
|
||||
|
||||
算力是墨水。墨水有成本,墨水会消耗,墨水需要补充。不同的墨水有不同的颜色和质量(不同模型有不同能力和成本)。
|
||||
笔不制造墨水,笔使用墨水。用户可以自带墨水(BYOK),也可以从 AGE OS 的墨水池里取用(资源池)。
|
||||
|
||||
**用户 = 写字的人**
|
||||
|
||||
用户是写字的人。他们决定写什么、怎么写、写给谁。笔听从写字的人的意志,但笔有自己的智慧——它知道怎样的笔画最流畅,怎样的结构最优雅。
|
||||
|
||||
### AGE OS 承担与不承担
|
||||
|
||||
| AGE OS 承担 | AGE OS 不承担 |
|
||||
|---|---|
|
||||
| ✅ 智能路由(选墨水) | ❌ 算力供给(造墨水) |
|
||||
| ✅ 执行编排(控笔画) | ❌ 数据存储所有权 |
|
||||
| ✅ 质量保障(保清晰) | ❌ 用户意志替代 |
|
||||
| ✅ 工具演进(自进化) | |
|
||||
|
||||
### 对系统各层的影响
|
||||
|
||||
- **Agent 层**:Agent = 笔的不同笔尖。使用墨水但不拥有墨水。
|
||||
- **执行引擎层**:路由 = 选墨水,计量 = 量墨水,降级 = 墨水不够换写法。
|
||||
- **用户层**:用户在用一支很聪明的笔,墨水(算力)是需要关心的资源。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计原则
|
||||
|
||||
1. **去中心化依赖**:不依赖任何单一商业 AI 服务的会员配额
|
||||
2. **开源优先**:对接开源大模型 API(DeepSeek、Qwen 等),保持供应商多元化
|
||||
3. **按量弹性**:算力消耗与实际使用量挂钩,非固定配额
|
||||
4. **渐进替代**:与现有 Copilot 能力并行,逐步迁移
|
||||
5. **本体论一致**:架构设计符合光湖语言膜本体论框架
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心组件
|
||||
|
||||
### 3.1 AGE-Router(路由网关)
|
||||
|
||||
文件:`src/router/age-router.js`
|
||||
|
||||
职责:接收所有 AI 执行请求,进行鉴权、分类、路由。
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 请求鉴权 | 验证 Agent 身份 + 资源池额度 |
|
||||
| 任务分类 | code_generation / text_processing / reasoning / agent_instruction |
|
||||
| 模型选择 | 通过 LoadBalancer 根据任务类型 + 成本策略选择最优模型 |
|
||||
| 限流降级 | 基于每分钟请求数的滑动窗口限流 |
|
||||
| 故障转移 | 主模型失败时自动切换至备选模型 |
|
||||
|
||||
路由策略:
|
||||
- `code_generation` + `HIGH complexity` → DeepSeek-V3
|
||||
- `text_processing` → Qwen-Max
|
||||
- `agent_instruction` → best_available(cost=0.6, quality=0.4)
|
||||
- `reasoning` → DeepSeek-R1
|
||||
|
||||
### 3.2 模型适配器(Adapter)
|
||||
|
||||
文件:`src/adapters/`
|
||||
|
||||
所有模型适配器继承 `BaseAdapter`,统一暴露:
|
||||
- `execute(request)` → 执行推理
|
||||
- `healthCheck()` → 健康检查
|
||||
- `supports(capability)` → 能力检测
|
||||
|
||||
已实现适配器:
|
||||
|
||||
| 适配器 | 文件 | 支持模型 |
|
||||
|--------|------|----------|
|
||||
| DeepSeekAdapter | `deepseek-adapter.js` | deepseek-chat, deepseek-reasoner |
|
||||
| QwenAdapter | `qwen-adapter.js` | qwen-max, qwen-coder-plus-latest |
|
||||
|
||||
适配器特性:
|
||||
- OpenAI 兼容 API 格式
|
||||
- 失败计数 + 冷却期机制(3 次失败 → 5 分钟冷却)
|
||||
- 成本计算(按百万 token 计价)
|
||||
- HTTP/HTTPS 自适应
|
||||
|
||||
### 3.3 负载均衡器(Load Balancer)
|
||||
|
||||
文件:`src/balancer/load-balancer.js`
|
||||
|
||||
三种策略:
|
||||
- **cost**:成本优先,选最便宜的
|
||||
- **quality**:质量优先,选配置排序第一的
|
||||
- **balanced**:加权评分(成本 × 0.6 + 质量位置 × 0.4)
|
||||
|
||||
支持故障转移:`failover(failedModel, taskType)`
|
||||
|
||||
### 3.4 资源计量器(Resource Meter)
|
||||
|
||||
文件:`src/meter/resource-meter.js`
|
||||
|
||||
实时记录:
|
||||
- 每次调用的 token 消耗、模型选择、响应时间
|
||||
- 按模型 / Agent / 资源池维度聚合统计
|
||||
- 持久化至 `logs/meter.json`(保留最近 500 条)
|
||||
|
||||
### 3.5 上下文缓存(Context Cache)
|
||||
|
||||
文件:`src/cache/context-cache.js`
|
||||
|
||||
Phase 0:内存 LRU 缓存(100 条,30 分钟 TTL)
|
||||
Phase 2:对接 Grid-DB 格点库
|
||||
|
||||
### 3.6 Agent 调度器(Agent Controller)
|
||||
|
||||
文件:`src/controller/agent-controller.js`
|
||||
|
||||
- 维护 Agent → 模型偏好映射
|
||||
- 将 Agent 请求转换为 EXE 标准请求
|
||||
- Phase 2 实现双轨切换(Copilot ↔ EXE-Engine)
|
||||
|
||||
已注册 Agent:
|
||||
- AG-ZY-01 铸渊(balanced, 4096 tokens)
|
||||
- AG-SY-01 霜砚(quality, 4096 tokens)
|
||||
- AG-QQ-01 秋秋(cost, 2048 tokens)
|
||||
|
||||
---
|
||||
|
||||
## 4. 算力资源模型
|
||||
|
||||
### 三种接入模式
|
||||
|
||||
| 模式 | 说明 | AGE OS 角色 |
|
||||
|------|------|-------------|
|
||||
| BYOK | 用户自带 API Key | 纯路由 + 编排 |
|
||||
| 资源池 | AGE OS 统一采购 | 路由 + 编排 + 计费 |
|
||||
| 混合 | BYOK + 资源池 fallback | 自适应 |
|
||||
|
||||
### 计费模型
|
||||
|
||||
| 模型 | 输入 (¥/M tokens) | 输出 (¥/M tokens) | 平台费率 |
|
||||
|------|-------------------|-------------------|----------|
|
||||
| DeepSeek-V3 | 1.0 | 2.0 | +20% |
|
||||
| DeepSeek-R1 | 4.0 | 16.0 | +20% |
|
||||
| Qwen-Max | 2.0 | 6.0 | +20% |
|
||||
| Qwen-Coder | 1.0 | 2.0 | +20% |
|
||||
|
||||
### 限流与降级
|
||||
|
||||
| 阶段 | 条件 | 措施 |
|
||||
|------|------|------|
|
||||
| 正常 | 余额充足 | 全功能可用 |
|
||||
| 预警 | 余额 < 20% | 通知用户 |
|
||||
| 降级 | 余额 < 5% | 自动切换最低成本模型 |
|
||||
| 熔断 | 余额 = 0 | 仅保留系统维护级调用 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 技术栈
|
||||
|
||||
| 层级 | 技术 | 理由 |
|
||||
|------|------|------|
|
||||
| 语言 | Node.js 20 | 与 AGE OS 技术栈一致 |
|
||||
| HTTP | 原生 http/https | 零依赖,轻量 |
|
||||
| 状态存储 | 文件系统 (JSON) | 与 task-queue 模式一致 |
|
||||
| 模型调用 | OpenAI-compatible | DeepSeek/Qwen 均兼容 |
|
||||
| 监控 | ResourceMeter → DC v1.0 | 复用现有采集基础设施 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 过渡路线
|
||||
|
||||
### Phase 0(当前):基础建设
|
||||
- [x] AGE-Router 路由网关
|
||||
- [x] DeepSeek-V3 Adapter
|
||||
- [x] Qwen Adapter(接口就绪)
|
||||
- [x] Resource Meter 基础版
|
||||
- [x] Load Balancer
|
||||
- [x] 冒烟测试
|
||||
|
||||
### Phase 1:影子模式
|
||||
- [ ] Load Balancer 生产部署
|
||||
- [ ] 影子模式:Copilot 请求同步发送到 EXE-Engine
|
||||
- [ ] 输出质量对比分析
|
||||
|
||||
### Phase 2:灰度切换
|
||||
- [ ] Agent Controller 双轨切换
|
||||
- [ ] BYOK 模式上线
|
||||
- [ ] 资源池计费基础版
|
||||
- [ ] Context Cache 对接 Grid-DB
|
||||
|
||||
### Phase 3:全面切换
|
||||
- [ ] 核心 Agent 全面迁移
|
||||
- [ ] Copilot 依赖代码清理
|
||||
- [ ] 资源池正式上线
|
||||
- [ ] 关闭 Copilot 企业会员
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "exe-engine",
|
||||
"version": "1.0.0",
|
||||
"description": "AGE OS · 自研执行引擎 (EXE-Engine) · PRJ-EXE-001",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"status": "node src/index.js status",
|
||||
"test": "node tests/smoke/exe-engine.test.js"
|
||||
},
|
||||
"keywords": ["age-os", "exe-engine", "ai-execution", "model-routing"],
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
// exe-engine/src/adapters/base-adapter.js
|
||||
// EXE-Engine · 模型适配器基类
|
||||
// 统一抽象 EXEModelAdapter 接口
|
||||
// PRJ-EXE-001 · Phase 0
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* EXEModelAdapter 基类
|
||||
*
|
||||
* 所有模型适配器(DeepSeek、Qwen 等)均继承此基类,
|
||||
* 统一暴露 execute() 和 healthCheck() 接口。
|
||||
*
|
||||
* 本体论锚定:适配器 = 墨水瓶的接口。
|
||||
* 不同品牌的墨水瓶,瓶口规格不同,但笔尖只认一种接口。
|
||||
*/
|
||||
class BaseAdapter {
|
||||
/**
|
||||
* @param {object} config
|
||||
* @param {string} config.name 模型标识
|
||||
* @param {string} config.provider 供应商标识
|
||||
* @param {string} config.endpoint API 端点
|
||||
* @param {string} config.apiKey API 密钥
|
||||
* @param {string[]} config.capabilities 能力标签
|
||||
* @param {object} config.costPerToken 成本 { input, output, currency, unit }
|
||||
* @param {number} config.maxContext 最大上下文窗口
|
||||
* @param {number} config.defaultTemperature 默认温度
|
||||
* @param {number} config.defaultMaxTokens 默认最大 token
|
||||
*/
|
||||
constructor(config) {
|
||||
if (new.target === BaseAdapter) {
|
||||
throw new Error('BaseAdapter 不可直接实例化,请使用具体子类');
|
||||
}
|
||||
this.name = config.name;
|
||||
this.provider = config.provider;
|
||||
this.endpoint = config.endpoint;
|
||||
this.apiKey = config.apiKey;
|
||||
this.capabilities = config.capabilities || [];
|
||||
this.costPerToken = config.costPerToken || { input: 0, output: 0 };
|
||||
this.maxContext = config.maxContext || 32000;
|
||||
this.defaultTemperature = config.defaultTemperature || 0.7;
|
||||
this.defaultMaxTokens = config.defaultMaxTokens || 4096;
|
||||
|
||||
// 运行时状态
|
||||
this._healthy = true;
|
||||
this._lastHealthCheck = null;
|
||||
this._failureCount = 0;
|
||||
this._cooldownUntil = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行推理请求(子类必须实现)
|
||||
* @param {object} request EXE 标准请求
|
||||
* @returns {Promise<object>} EXE 标准响应
|
||||
*/
|
||||
async execute(request) {
|
||||
throw new Error('子类必须实现 execute() 方法');
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查(子类可覆写)
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async healthCheck() {
|
||||
this._lastHealthCheck = new Date().toISOString();
|
||||
return this._healthy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测该适配器是否支持指定能力
|
||||
* @param {string} capability
|
||||
* @returns {boolean}
|
||||
*/
|
||||
supports(capability) {
|
||||
return this.capabilities.includes(capability);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录失败并判断是否进入冷却期
|
||||
* @param {string} reason
|
||||
*/
|
||||
recordFailure(reason) {
|
||||
this._failureCount++;
|
||||
if (this._failureCount >= 3) {
|
||||
// 连续 3 次失败,冷却 5 分钟
|
||||
this._cooldownUntil = Date.now() + 5 * 60 * 1000;
|
||||
this._healthy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置失败计数
|
||||
*/
|
||||
resetFailures() {
|
||||
this._failureCount = 0;
|
||||
this._cooldownUntil = null;
|
||||
this._healthy = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否处于冷却期
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isInCooldown() {
|
||||
if (!this._cooldownUntil) return false;
|
||||
if (Date.now() >= this._cooldownUntil) {
|
||||
this.resetFailures();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取适配器状态摘要
|
||||
* @returns {object}
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
name: this.name,
|
||||
provider: this.provider,
|
||||
healthy: this._healthy,
|
||||
inCooldown: this.isInCooldown(),
|
||||
failureCount: this._failureCount,
|
||||
lastHealthCheck: this._lastHealthCheck,
|
||||
capabilities: this.capabilities,
|
||||
costPerToken: this.costPerToken,
|
||||
maxContext: this.maxContext
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BaseAdapter;
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
// exe-engine/src/adapters/deepseek-adapter.js
|
||||
// EXE-Engine · DeepSeek 模型适配器
|
||||
// 适配 DeepSeek-V3 / DeepSeek-R1(OpenAI 兼容格式)
|
||||
// PRJ-EXE-001 · Phase 0 · ZY-EXE-P0-002
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const BaseAdapter = require('./base-adapter');
|
||||
|
||||
/**
|
||||
* DeepSeek 模型适配器
|
||||
*
|
||||
* 本体论锚定:DeepSeek 是一瓶高品质的代码墨水。
|
||||
* 代码能力强、成本极低,是 AGE OS 的主力墨水。
|
||||
*/
|
||||
class DeepSeekAdapter extends BaseAdapter {
|
||||
constructor(config) {
|
||||
super({
|
||||
...config,
|
||||
provider: config.provider || 'deepseek'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行推理请求
|
||||
*
|
||||
* @param {object} request
|
||||
* @param {string} request.prompt 用户 prompt
|
||||
* @param {string} [request.systemPrompt] 系统 prompt
|
||||
* @param {string} [request.taskType] 任务类型
|
||||
* @param {number} [request.maxTokens] 最大输出 token
|
||||
* @param {number} [request.temperature] 温度
|
||||
* @returns {Promise<object>} EXE 标准响应
|
||||
*/
|
||||
async execute(request) {
|
||||
if (this.isInCooldown()) {
|
||||
throw new Error(`[EXE] ${this.name} 处于冷却期,暂不可用`);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const messages = [];
|
||||
|
||||
if (request.systemPrompt) {
|
||||
messages.push({ role: 'system', content: request.systemPrompt });
|
||||
}
|
||||
messages.push({ role: 'user', content: request.prompt });
|
||||
|
||||
const body = JSON.stringify({
|
||||
model: this._resolveModel(request.taskType),
|
||||
messages,
|
||||
max_tokens: request.maxTokens || this.defaultMaxTokens,
|
||||
temperature: request.temperature ?? this.defaultTemperature,
|
||||
stream: false
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await this._httpPost(this.endpoint, body, {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`
|
||||
});
|
||||
|
||||
const data = JSON.parse(response);
|
||||
|
||||
if (!data.choices || !data.choices[0]) {
|
||||
throw new Error('DeepSeek API 返回格式异常: 缺少 choices');
|
||||
}
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
const usage = data.usage || {};
|
||||
|
||||
this.resetFailures();
|
||||
|
||||
return {
|
||||
model: data.model || this.name,
|
||||
output: data.choices[0].message.content,
|
||||
usage: {
|
||||
inputTokens: usage.prompt_tokens || 0,
|
||||
outputTokens: usage.completion_tokens || 0,
|
||||
cost: this._calculateCost(usage.prompt_tokens || 0, usage.completion_tokens || 0),
|
||||
unit: this.costPerToken.currency || 'CNY'
|
||||
},
|
||||
latency,
|
||||
status: 'success',
|
||||
finishReason: data.choices[0].finish_reason
|
||||
};
|
||||
} catch (err) {
|
||||
this.recordFailure(err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查:向 DeepSeek 发送一次轻量请求
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async healthCheck() {
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
model: 'deepseek-chat',
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
max_tokens: 5
|
||||
});
|
||||
|
||||
await this._httpPost(this.endpoint, body, {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`
|
||||
});
|
||||
|
||||
this._healthy = true;
|
||||
} catch (err) {
|
||||
this._healthy = false;
|
||||
this._lastHealthError = err.message;
|
||||
}
|
||||
this._lastHealthCheck = new Date().toISOString();
|
||||
return this._healthy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据任务类型解析具体模型名
|
||||
* @param {string} taskType
|
||||
* @returns {string}
|
||||
*/
|
||||
_resolveModel(taskType) {
|
||||
if (taskType === 'reasoning') return 'deepseek-reasoner';
|
||||
return 'deepseek-chat';
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算调用成本(单位:元)
|
||||
* @param {number} inputTokens
|
||||
* @param {number} outputTokens
|
||||
* @returns {number}
|
||||
*/
|
||||
_calculateCost(inputTokens, outputTokens) {
|
||||
const inputCost = (inputTokens / 1_000_000) * this.costPerToken.input;
|
||||
const outputCost = (outputTokens / 1_000_000) * this.costPerToken.output;
|
||||
return Math.round((inputCost + outputCost) * 10000) / 10000;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP POST 请求(兼容 http / https)
|
||||
* @param {string} url
|
||||
* @param {string} body
|
||||
* @param {object} headers
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
_httpPost(url, body, headers) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const transport = parsed.protocol === 'https:' ? https : http;
|
||||
|
||||
const req = transport.request({
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
},
|
||||
timeout: 120000
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 400) {
|
||||
reject(new Error(`DeepSeek API 错误 (${res.statusCode}): ${data}`));
|
||||
return;
|
||||
}
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', err => reject(new Error(`DeepSeek 网络错误: ${err.message}`)));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('DeepSeek API 请求超时'));
|
||||
});
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DeepSeekAdapter;
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
// exe-engine/src/adapters/qwen-adapter.js
|
||||
// EXE-Engine · Qwen (通义千问) 模型适配器
|
||||
// 适配 Qwen-Max / Qwen-Coder(DashScope OpenAI 兼容模式)
|
||||
// PRJ-EXE-001 · Phase 1 准备 · ZY-EXE-P1-001
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const BaseAdapter = require('./base-adapter');
|
||||
|
||||
/**
|
||||
* Qwen 模型适配器
|
||||
*
|
||||
* 本体论锚定:Qwen 是一瓶中文优化的墨水。
|
||||
* 中文生态优势突出,阿里云原生,适合文本处理任务。
|
||||
*/
|
||||
class QwenAdapter extends BaseAdapter {
|
||||
constructor(config) {
|
||||
super({
|
||||
...config,
|
||||
provider: config.provider || 'dashscope'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行推理请求
|
||||
*
|
||||
* @param {object} request
|
||||
* @param {string} request.prompt 用户 prompt
|
||||
* @param {string} [request.systemPrompt] 系统 prompt
|
||||
* @param {string} [request.taskType] 任务类型
|
||||
* @param {number} [request.maxTokens] 最大输出 token
|
||||
* @param {number} [request.temperature] 温度
|
||||
* @returns {Promise<object>} EXE 标准响应
|
||||
*/
|
||||
async execute(request) {
|
||||
if (this.isInCooldown()) {
|
||||
throw new Error(`[EXE] ${this.name} 处于冷却期,暂不可用`);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const messages = [];
|
||||
|
||||
if (request.systemPrompt) {
|
||||
messages.push({ role: 'system', content: request.systemPrompt });
|
||||
}
|
||||
messages.push({ role: 'user', content: request.prompt });
|
||||
|
||||
const body = JSON.stringify({
|
||||
model: this._resolveModel(request.taskType),
|
||||
messages,
|
||||
max_tokens: request.maxTokens || this.defaultMaxTokens,
|
||||
temperature: request.temperature ?? this.defaultTemperature,
|
||||
stream: false
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await this._httpPost(this.endpoint, body, {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`
|
||||
});
|
||||
|
||||
const data = JSON.parse(response);
|
||||
|
||||
if (!data.choices || !data.choices[0]) {
|
||||
throw new Error('Qwen API 返回格式异常: 缺少 choices');
|
||||
}
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
const usage = data.usage || {};
|
||||
|
||||
this.resetFailures();
|
||||
|
||||
return {
|
||||
model: data.model || this.name,
|
||||
output: data.choices[0].message.content,
|
||||
usage: {
|
||||
inputTokens: usage.prompt_tokens || 0,
|
||||
outputTokens: usage.completion_tokens || 0,
|
||||
cost: this._calculateCost(usage.prompt_tokens || 0, usage.completion_tokens || 0),
|
||||
unit: this.costPerToken.currency || 'CNY'
|
||||
},
|
||||
latency,
|
||||
status: 'success',
|
||||
finishReason: data.choices[0].finish_reason
|
||||
};
|
||||
} catch (err) {
|
||||
this.recordFailure(err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async healthCheck() {
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
model: 'qwen-turbo',
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
max_tokens: 5
|
||||
});
|
||||
|
||||
await this._httpPost(this.endpoint, body, {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`
|
||||
});
|
||||
|
||||
this._healthy = true;
|
||||
} catch (err) {
|
||||
this._healthy = false;
|
||||
this._lastHealthError = err.message;
|
||||
}
|
||||
this._lastHealthCheck = new Date().toISOString();
|
||||
return this._healthy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据任务类型解析具体模型名
|
||||
* @param {string} taskType
|
||||
* @returns {string}
|
||||
*/
|
||||
_resolveModel(taskType) {
|
||||
if (taskType === 'code_generation') return 'qwen-coder-plus-latest';
|
||||
return 'qwen-max';
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算调用成本(单位:元)
|
||||
* @param {number} inputTokens
|
||||
* @param {number} outputTokens
|
||||
* @returns {number}
|
||||
*/
|
||||
_calculateCost(inputTokens, outputTokens) {
|
||||
const inputCost = (inputTokens / 1_000_000) * this.costPerToken.input;
|
||||
const outputCost = (outputTokens / 1_000_000) * this.costPerToken.output;
|
||||
return Math.round((inputCost + outputCost) * 10000) / 10000;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP POST 请求
|
||||
* @param {string} url
|
||||
* @param {string} body
|
||||
* @param {object} headers
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
_httpPost(url, body, headers) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const transport = parsed.protocol === 'https:' ? https : http;
|
||||
|
||||
const req = transport.request({
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
},
|
||||
timeout: 120000
|
||||
}, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 400) {
|
||||
reject(new Error(`Qwen API 错误 (${res.statusCode}): ${data}`));
|
||||
return;
|
||||
}
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', err => reject(new Error(`Qwen 网络错误: ${err.message}`)));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Qwen API 请求超时'));
|
||||
});
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = QwenAdapter;
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
// exe-engine/src/balancer/load-balancer.js
|
||||
// EXE-Engine · 负载均衡器
|
||||
// 根据策略选择最优模型适配器
|
||||
// PRJ-EXE-001 · Phase 0
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 负载均衡器
|
||||
*
|
||||
* 本体论锚定:均衡器 = 选墨水的智慧。
|
||||
* 不同的字需要不同的墨水,均衡器知道哪瓶墨水最合适。
|
||||
*
|
||||
* 策略:
|
||||
* cost — 成本优先:满足质量阈值前提下选最便宜的
|
||||
* quality — 质量优先:选能力最强的模型
|
||||
* balanced — 均衡:成本权重 0.6 + 质量权重 0.4
|
||||
*/
|
||||
class LoadBalancer {
|
||||
/**
|
||||
* @param {Map<string, BaseAdapter>} adapters 已注册的适配器 Map
|
||||
* @param {object} modelConfig 模型配置(含 taskModelMapping)
|
||||
*/
|
||||
constructor(adapters, modelConfig) {
|
||||
this._adapters = adapters;
|
||||
this._modelConfig = modelConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据请求选择最优适配器
|
||||
*
|
||||
* @param {object} request
|
||||
* @param {string} request.taskType 任务类型
|
||||
* @param {string} [request.model] 指定模型 ('auto' 则自动选择)
|
||||
* @param {string} [request.priority] 优先策略 (cost | balanced | quality)
|
||||
* @returns {BaseAdapter|null}
|
||||
*/
|
||||
select(request) {
|
||||
const { taskType, model, priority = 'balanced' } = request;
|
||||
|
||||
// 用户指定了具体模型
|
||||
if (model && model !== 'auto') {
|
||||
const adapter = this._adapters.get(model);
|
||||
if (adapter && !adapter.isInCooldown()) {
|
||||
return adapter;
|
||||
}
|
||||
// 指定模型不可用,尝试 fallback
|
||||
}
|
||||
|
||||
// 根据任务类型获取候选模型列表
|
||||
const candidates = this._getCandidates(taskType);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 按优先策略排序
|
||||
return this._selectByPriority(candidates, priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* 故障转移:获取指定模型的下一个备选
|
||||
*
|
||||
* @param {string} failedModel 失败的模型名
|
||||
* @param {string} taskType 任务类型
|
||||
* @returns {BaseAdapter|null}
|
||||
*/
|
||||
failover(failedModel, taskType) {
|
||||
const candidates = this._getCandidates(taskType)
|
||||
.filter(a => a.name !== failedModel);
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有适配器状态
|
||||
* @returns {object[]}
|
||||
*/
|
||||
getStatus() {
|
||||
const status = [];
|
||||
for (const [, adapter] of this._adapters) {
|
||||
status.push(adapter.getStatus());
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
// ── 内部方法 ──
|
||||
|
||||
/**
|
||||
* 获取可用候选适配器
|
||||
* @param {string} taskType
|
||||
* @returns {BaseAdapter[]}
|
||||
*/
|
||||
_getCandidates(taskType) {
|
||||
const mapping = this._modelConfig.taskModelMapping || {};
|
||||
const modelNames = mapping[taskType] || Object.keys(this._modelConfig.models || {});
|
||||
|
||||
return modelNames
|
||||
.map(name => this._adapters.get(name))
|
||||
.filter(a => a && !a.isInCooldown());
|
||||
}
|
||||
|
||||
/**
|
||||
* 按优先策略选择
|
||||
* @param {BaseAdapter[]} candidates
|
||||
* @param {string} priority
|
||||
* @returns {BaseAdapter}
|
||||
*/
|
||||
_selectByPriority(candidates, priority) {
|
||||
if (priority === 'cost') {
|
||||
return this._selectLowestCost(candidates);
|
||||
}
|
||||
if (priority === 'quality') {
|
||||
// 质量优先 = 列表中第一个(配置中按质量排序)
|
||||
return candidates[0];
|
||||
}
|
||||
// balanced: 加权评分
|
||||
return this._selectBalanced(candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择成本最低的适配器
|
||||
* @param {BaseAdapter[]} candidates
|
||||
* @returns {BaseAdapter}
|
||||
*/
|
||||
_selectLowestCost(candidates) {
|
||||
let best = candidates[0];
|
||||
let bestCost = this._avgCost(best);
|
||||
|
||||
for (let i = 1; i < candidates.length; i++) {
|
||||
const cost = this._avgCost(candidates[i]);
|
||||
if (cost < bestCost) {
|
||||
best = candidates[i];
|
||||
bestCost = cost;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 均衡选择(成本 0.6 + 位置 0.4)
|
||||
* @param {BaseAdapter[]} candidates
|
||||
* @returns {BaseAdapter}
|
||||
*/
|
||||
_selectBalanced(candidates) {
|
||||
let best = null;
|
||||
let bestScore = Infinity;
|
||||
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const costScore = this._avgCost(candidates[i]);
|
||||
const positionScore = i; // 位置越前,质量越高
|
||||
const score = costScore * 0.6 + positionScore * 0.4;
|
||||
|
||||
if (score < bestScore) {
|
||||
best = candidates[i];
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算平均每百万 token 成本
|
||||
* @param {BaseAdapter} adapter
|
||||
* @returns {number}
|
||||
*/
|
||||
_avgCost(adapter) {
|
||||
const c = adapter.costPerToken;
|
||||
return ((c.input || 0) + (c.output || 0)) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LoadBalancer;
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
// exe-engine/src/cache/context-cache.js
|
||||
// EXE-Engine · 上下文缓存
|
||||
// 减少重复 prompt 构建的 token 消耗
|
||||
// PRJ-EXE-001 · Phase 0(接口定义,P2 对接 Grid-DB)
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 上下文缓存
|
||||
*
|
||||
* 本体论锚定:缓存 = 笔的记忆。
|
||||
* 笔记得刚才写过什么,不需要每次都从头回忆。
|
||||
* Phase 0 使用内存缓存,Phase 2 对接 Grid-DB 格点库。
|
||||
*/
|
||||
class ContextCache {
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.maxEntries] 最大缓存条目数
|
||||
* @param {number} [options.ttlMs] 缓存有效期(毫秒)
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this._maxEntries = options.maxEntries || 100;
|
||||
this._ttlMs = options.ttlMs || 30 * 60 * 1000; // 默认 30 分钟
|
||||
this._cache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的上下文
|
||||
* @param {string} key 缓存键(通常为 agentId + sessionId)
|
||||
* @returns {object|null}
|
||||
*/
|
||||
get(key) {
|
||||
const entry = this._cache.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
if (Date.now() - entry.timestamp > this._ttlMs) {
|
||||
this._cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
entry.hits++;
|
||||
return entry.context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置上下文缓存
|
||||
* @param {string} key 缓存键
|
||||
* @param {object} context 上下文数据
|
||||
*/
|
||||
set(key, context) {
|
||||
// LRU: 超出容量时删除最早的
|
||||
if (this._cache.size >= this._maxEntries) {
|
||||
const oldestKey = this._cache.keys().next().value;
|
||||
this._cache.delete(oldestKey);
|
||||
}
|
||||
|
||||
this._cache.set(key, {
|
||||
context,
|
||||
timestamp: Date.now(),
|
||||
hits: 0
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除缓存
|
||||
* @param {string} key
|
||||
*/
|
||||
delete(key) {
|
||||
this._cache.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有缓存
|
||||
*/
|
||||
clear() {
|
||||
this._cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存状态
|
||||
* @returns {object}
|
||||
*/
|
||||
getStatus() {
|
||||
let expiredCount = 0;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [, entry] of this._cache) {
|
||||
if (now - entry.timestamp > this._ttlMs) {
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
size: this._cache.size,
|
||||
maxEntries: this._maxEntries,
|
||||
ttlMs: this._ttlMs,
|
||||
expiredCount
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ContextCache;
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
// exe-engine/src/controller/agent-controller.js
|
||||
// EXE-Engine · Agent 调度器
|
||||
// 将 Agent 执行链中的 AI 调用从 Copilot 切换到自研引擎
|
||||
// PRJ-EXE-001 · Phase 0(基础版,P2 实现双轨切换)
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Agent 调度器
|
||||
*
|
||||
* 本体论锚定:调度器 = 笔的手指。
|
||||
* 不同的笔尖(Agent)需要不同的墨水(模型),
|
||||
* 调度器负责为每个笔尖分配最合适的墨水。
|
||||
*
|
||||
* 职责:
|
||||
* - 维护 Agent → 模型 的映射表
|
||||
* - 支持 Agent 级别的模型偏好配置
|
||||
* - 执行链断点续接(P2 实现)
|
||||
*/
|
||||
class AgentController {
|
||||
constructor() {
|
||||
// Agent → 模型偏好映射
|
||||
this._agentPreferences = new Map();
|
||||
|
||||
// 默认 Agent 偏好
|
||||
this._defaultPreference = {
|
||||
model: 'auto',
|
||||
priority: 'balanced',
|
||||
maxTokens: 4096,
|
||||
mode: 'pool' // byok | pool | hybrid
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册 Agent 的模型偏好
|
||||
*
|
||||
* @param {string} agentId Agent ID (如 AG-ZY-01)
|
||||
* @param {object} preference 偏好配置
|
||||
* @param {string} [preference.model] 首选模型
|
||||
* @param {string} [preference.priority] 优先策略
|
||||
* @param {number} [preference.maxTokens] 最大 token
|
||||
* @param {string} [preference.mode] 接入模式
|
||||
*/
|
||||
registerAgent(agentId, preference = {}) {
|
||||
this._agentPreferences.set(agentId, {
|
||||
...this._defaultPreference,
|
||||
...preference
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Agent 的模型偏好
|
||||
* @param {string} agentId
|
||||
* @returns {object}
|
||||
*/
|
||||
getPreference(agentId) {
|
||||
return this._agentPreferences.get(agentId) || { ...this._defaultPreference };
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Agent 请求转换为 EXE 标准请求
|
||||
*
|
||||
* @param {string} agentId Agent ID
|
||||
* @param {object} agentRequest Agent 原始请求
|
||||
* @param {string} agentRequest.prompt
|
||||
* @param {string} [agentRequest.taskType]
|
||||
* @param {object} [agentRequest.context]
|
||||
* @returns {object} EXE 标准请求
|
||||
*/
|
||||
buildExeRequest(agentId, agentRequest) {
|
||||
const pref = this.getPreference(agentId);
|
||||
|
||||
return {
|
||||
agentId,
|
||||
taskType: agentRequest.taskType || 'agent_instruction',
|
||||
prompt: agentRequest.prompt,
|
||||
systemPrompt: agentRequest.systemPrompt || null,
|
||||
context: agentRequest.context || {},
|
||||
preferences: {
|
||||
model: agentRequest.model || pref.model,
|
||||
priority: agentRequest.priority || pref.priority,
|
||||
maxTokens: agentRequest.maxTokens || pref.maxTokens
|
||||
},
|
||||
resourcePool: pref.mode === 'byok' ? null : 'default'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册 Agent 状态
|
||||
* @returns {object[]}
|
||||
*/
|
||||
getRegisteredAgents() {
|
||||
const agents = [];
|
||||
for (const [agentId, pref] of this._agentPreferences) {
|
||||
agents.push({ agentId, ...pref });
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AgentController;
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
// exe-engine/src/index.js
|
||||
// EXE-Engine · 自研执行引擎 · 主入口
|
||||
// PRJ-EXE-001 · Phase 0
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const AGERouter = require('./router/age-router');
|
||||
const LoadBalancer = require('./balancer/load-balancer');
|
||||
const ResourceMeter = require('./meter/resource-meter');
|
||||
const ContextCache = require('./cache/context-cache');
|
||||
const AgentController = require('./controller/agent-controller');
|
||||
const BaseAdapter = require('./adapters/base-adapter');
|
||||
const DeepSeekAdapter = require('./adapters/deepseek-adapter');
|
||||
const QwenAdapter = require('./adapters/qwen-adapter');
|
||||
|
||||
const CONFIG_DIR = path.resolve(__dirname, '../config');
|
||||
|
||||
/**
|
||||
* EXE-Engine 初始化器
|
||||
*
|
||||
* 本体论锚定:
|
||||
* AGE OS = 笔
|
||||
* 算力 = 墨水
|
||||
* 用户 = 写字的人
|
||||
*
|
||||
* 初始化流程:
|
||||
* 1. 加载模型配置
|
||||
* 2. 创建适配器实例
|
||||
* 3. 组装负载均衡器
|
||||
* 4. 启动计量器
|
||||
* 5. 启动上下文缓存
|
||||
* 6. 注册默认 Agent
|
||||
* 7. 返回 AGE-Router 实例
|
||||
*/
|
||||
|
||||
/**
|
||||
* 创建并初始化 EXE-Engine 实例
|
||||
*
|
||||
* @param {object} [overrides] 覆写配置
|
||||
* @param {object} [overrides.keys] API Keys { deepseek: 'sk-...', dashscope: 'sk-...' }
|
||||
* @returns {object} { router, meter, cache, controller, balancer }
|
||||
*/
|
||||
function createEngine(overrides = {}) {
|
||||
// 1. 加载配置
|
||||
const modelConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(CONFIG_DIR, 'models.json'), 'utf-8')
|
||||
);
|
||||
const poolConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(CONFIG_DIR, 'resource-pools.json'), 'utf-8')
|
||||
);
|
||||
|
||||
// 2. 解析 API Keys(环境变量 > overrides)
|
||||
const keys = {
|
||||
deepseek: process.env.DEEPSEEK_API_KEY || overrides.keys?.deepseek || '',
|
||||
dashscope: process.env.DASHSCOPE_API_KEY || overrides.keys?.dashscope || ''
|
||||
};
|
||||
|
||||
// 3. 创建适配器
|
||||
const adapters = new Map();
|
||||
|
||||
// DeepSeek-V3
|
||||
const dsV3Config = modelConfig.models['deepseek-v3'];
|
||||
if (dsV3Config) {
|
||||
adapters.set('deepseek-v3', new DeepSeekAdapter({
|
||||
name: 'deepseek-v3',
|
||||
endpoint: dsV3Config.endpoint,
|
||||
apiKey: keys.deepseek,
|
||||
capabilities: dsV3Config.capabilities,
|
||||
costPerToken: dsV3Config.costPerToken,
|
||||
maxContext: dsV3Config.maxContext,
|
||||
defaultTemperature: dsV3Config.defaultTemperature,
|
||||
defaultMaxTokens: dsV3Config.defaultMaxTokens
|
||||
}));
|
||||
}
|
||||
|
||||
// DeepSeek-R1
|
||||
const dsR1Config = modelConfig.models['deepseek-r1'];
|
||||
if (dsR1Config) {
|
||||
adapters.set('deepseek-r1', new DeepSeekAdapter({
|
||||
name: 'deepseek-r1',
|
||||
endpoint: dsR1Config.endpoint,
|
||||
apiKey: keys.deepseek,
|
||||
capabilities: dsR1Config.capabilities,
|
||||
costPerToken: dsR1Config.costPerToken,
|
||||
maxContext: dsR1Config.maxContext,
|
||||
defaultTemperature: dsR1Config.defaultTemperature,
|
||||
defaultMaxTokens: dsR1Config.defaultMaxTokens
|
||||
}));
|
||||
}
|
||||
|
||||
// Qwen-Max
|
||||
const qwMaxConfig = modelConfig.models['qwen-max'];
|
||||
if (qwMaxConfig) {
|
||||
adapters.set('qwen-max', new QwenAdapter({
|
||||
name: 'qwen-max',
|
||||
endpoint: qwMaxConfig.endpoint,
|
||||
apiKey: keys.dashscope,
|
||||
capabilities: qwMaxConfig.capabilities,
|
||||
costPerToken: qwMaxConfig.costPerToken,
|
||||
maxContext: qwMaxConfig.maxContext,
|
||||
defaultTemperature: qwMaxConfig.defaultTemperature,
|
||||
defaultMaxTokens: qwMaxConfig.defaultMaxTokens
|
||||
}));
|
||||
}
|
||||
|
||||
// Qwen-Coder
|
||||
const qwCoderConfig = modelConfig.models['qwen-coder'];
|
||||
if (qwCoderConfig) {
|
||||
adapters.set('qwen-coder', new QwenAdapter({
|
||||
name: 'qwen-coder',
|
||||
endpoint: qwCoderConfig.endpoint,
|
||||
apiKey: keys.dashscope,
|
||||
capabilities: qwCoderConfig.capabilities,
|
||||
costPerToken: qwCoderConfig.costPerToken,
|
||||
maxContext: qwCoderConfig.maxContext,
|
||||
defaultTemperature: qwCoderConfig.defaultTemperature,
|
||||
defaultMaxTokens: qwCoderConfig.defaultMaxTokens
|
||||
}));
|
||||
}
|
||||
|
||||
// 4. 组装组件
|
||||
const meter = new ResourceMeter();
|
||||
const cache = new ContextCache({ maxEntries: 100, ttlMs: 30 * 60 * 1000 });
|
||||
const controller = new AgentController();
|
||||
const balancer = new LoadBalancer(adapters, modelConfig);
|
||||
|
||||
// 5. 注册默认 Agent 偏好
|
||||
controller.registerAgent('AG-ZY-01', {
|
||||
model: 'auto',
|
||||
priority: 'balanced',
|
||||
maxTokens: 4096,
|
||||
mode: 'pool'
|
||||
});
|
||||
controller.registerAgent('AG-SY-01', {
|
||||
model: 'auto',
|
||||
priority: 'quality',
|
||||
maxTokens: 4096,
|
||||
mode: 'pool'
|
||||
});
|
||||
controller.registerAgent('AG-QQ-01', {
|
||||
model: 'auto',
|
||||
priority: 'cost',
|
||||
maxTokens: 2048,
|
||||
mode: 'pool'
|
||||
});
|
||||
|
||||
// 6. 组装路由器
|
||||
const router = new AGERouter({
|
||||
loadBalancer: balancer,
|
||||
resourceMeter: meter,
|
||||
contextCache: cache,
|
||||
agentController: controller,
|
||||
poolConfig
|
||||
});
|
||||
|
||||
return {
|
||||
router,
|
||||
meter,
|
||||
cache,
|
||||
controller,
|
||||
balancer,
|
||||
adapters,
|
||||
config: { modelConfig, poolConfig }
|
||||
};
|
||||
}
|
||||
|
||||
// ── CLI 入口 ──
|
||||
|
||||
if (require.main === module) {
|
||||
const cmd = process.argv[2];
|
||||
|
||||
if (cmd === 'status') {
|
||||
const engine = createEngine();
|
||||
const status = engine.router.getStatus();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(status, null, 2));
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log([
|
||||
'🔧 EXE-Engine v1.0.0 · PRJ-EXE-001 · Phase 0',
|
||||
'',
|
||||
'本体论锚定:AGE OS = 笔 · 算力 = 墨水 · 用户 = 写字的人',
|
||||
'',
|
||||
'用法:',
|
||||
' node exe-engine/src/index.js status 查看引擎状态',
|
||||
'',
|
||||
'组件:',
|
||||
' AGE-Router 路由网关',
|
||||
' LoadBalancer 负载均衡器',
|
||||
' ResourceMeter 资源计量器',
|
||||
' ContextCache 上下文缓存',
|
||||
' AgentController Agent 调度器',
|
||||
' DeepSeekAdapter DeepSeek 适配器',
|
||||
' QwenAdapter Qwen 适配器',
|
||||
].join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createEngine,
|
||||
AGERouter,
|
||||
LoadBalancer,
|
||||
ResourceMeter,
|
||||
ContextCache,
|
||||
AgentController,
|
||||
BaseAdapter,
|
||||
DeepSeekAdapter,
|
||||
QwenAdapter
|
||||
};
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
// exe-engine/src/meter/resource-meter.js
|
||||
// EXE-Engine · 资源计量器
|
||||
// 实时记录每次调用的 token 消耗、模型选择、响应时间
|
||||
// PRJ-EXE-001 · Phase 0 · ZY-EXE-P0-003
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOG_DIR = path.resolve(__dirname, '../../logs');
|
||||
const METER_LOG = path.join(LOG_DIR, 'meter.json');
|
||||
|
||||
/**
|
||||
* 资源计量器
|
||||
*
|
||||
* 本体论锚定:计量器 = 量墨水的刻度。
|
||||
* 每一笔用了多少墨水,花了多少钱,都要记下来。
|
||||
* 数据回流至 DC v1.0 采集系统,为算力资源模型提供计费基础数据。
|
||||
*/
|
||||
class ResourceMeter {
|
||||
constructor() {
|
||||
this._records = [];
|
||||
this._summary = {
|
||||
totalRequests: 0,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCost: 0,
|
||||
byModel: {},
|
||||
byAgent: {},
|
||||
byPool: {}
|
||||
};
|
||||
this._ensureLogDir();
|
||||
this._loadExisting();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次调用消耗
|
||||
*
|
||||
* @param {object} record
|
||||
* @param {string} record.requestId 请求 ID
|
||||
* @param {string} record.agentId Agent ID
|
||||
* @param {string} record.model 使用的模型
|
||||
* @param {string} record.taskType 任务类型
|
||||
* @param {string} record.resourcePool 资源池 ID
|
||||
* @param {string} record.mode 接入模式 (byok | pool | hybrid)
|
||||
* @param {number} record.inputTokens 输入 token 数
|
||||
* @param {number} record.outputTokens 输出 token 数
|
||||
* @param {number} record.cost 成本
|
||||
* @param {string} record.costUnit 成本单位
|
||||
* @param {number} record.latency 延迟 (ms)
|
||||
* @param {string} record.status 状态 (success | error)
|
||||
*/
|
||||
record(record) {
|
||||
const entry = {
|
||||
...record,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
this._records.push(entry);
|
||||
this._updateSummary(entry);
|
||||
this._persist();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取计量摘要
|
||||
* @returns {object}
|
||||
*/
|
||||
getSummary() {
|
||||
return {
|
||||
...this._summary,
|
||||
recordCount: this._records.length,
|
||||
lastUpdated: this._records.length > 0
|
||||
? this._records[this._records.length - 1].timestamp
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定 Agent 的消耗统计
|
||||
* @param {string} agentId
|
||||
* @returns {object|null}
|
||||
*/
|
||||
getAgentUsage(agentId) {
|
||||
return this._summary.byAgent[agentId] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定资源池的消耗统计
|
||||
* @param {string} poolId
|
||||
* @returns {object|null}
|
||||
*/
|
||||
getPoolUsage(poolId) {
|
||||
return this._summary.byPool[poolId] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近 N 条记录
|
||||
* @param {number} n
|
||||
* @returns {object[]}
|
||||
*/
|
||||
getRecentRecords(n = 20) {
|
||||
return this._records.slice(-n);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期记录(保留最近 500 条)
|
||||
*/
|
||||
cleanup() {
|
||||
if (this._records.length > 500) {
|
||||
this._records = this._records.slice(-500);
|
||||
this._rebuildSummary();
|
||||
this._persist();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 内部方法 ──
|
||||
|
||||
_updateSummary(entry) {
|
||||
this._summary.totalRequests++;
|
||||
this._summary.totalInputTokens += entry.inputTokens || 0;
|
||||
this._summary.totalOutputTokens += entry.outputTokens || 0;
|
||||
this._summary.totalCost += entry.cost || 0;
|
||||
|
||||
// 按模型统计
|
||||
if (entry.model) {
|
||||
if (!this._summary.byModel[entry.model]) {
|
||||
this._summary.byModel[entry.model] = { requests: 0, inputTokens: 0, outputTokens: 0, cost: 0 };
|
||||
}
|
||||
const m = this._summary.byModel[entry.model];
|
||||
m.requests++;
|
||||
m.inputTokens += entry.inputTokens || 0;
|
||||
m.outputTokens += entry.outputTokens || 0;
|
||||
m.cost += entry.cost || 0;
|
||||
}
|
||||
|
||||
// 按 Agent 统计
|
||||
if (entry.agentId) {
|
||||
if (!this._summary.byAgent[entry.agentId]) {
|
||||
this._summary.byAgent[entry.agentId] = { requests: 0, inputTokens: 0, outputTokens: 0, cost: 0 };
|
||||
}
|
||||
const a = this._summary.byAgent[entry.agentId];
|
||||
a.requests++;
|
||||
a.inputTokens += entry.inputTokens || 0;
|
||||
a.outputTokens += entry.outputTokens || 0;
|
||||
a.cost += entry.cost || 0;
|
||||
}
|
||||
|
||||
// 按资源池统计
|
||||
if (entry.resourcePool) {
|
||||
if (!this._summary.byPool[entry.resourcePool]) {
|
||||
this._summary.byPool[entry.resourcePool] = { requests: 0, inputTokens: 0, outputTokens: 0, cost: 0 };
|
||||
}
|
||||
const p = this._summary.byPool[entry.resourcePool];
|
||||
p.requests++;
|
||||
p.inputTokens += entry.inputTokens || 0;
|
||||
p.outputTokens += entry.outputTokens || 0;
|
||||
p.cost += entry.cost || 0;
|
||||
}
|
||||
}
|
||||
|
||||
_rebuildSummary() {
|
||||
this._summary = {
|
||||
totalRequests: 0,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCost: 0,
|
||||
byModel: {},
|
||||
byAgent: {},
|
||||
byPool: {}
|
||||
};
|
||||
for (const entry of this._records) {
|
||||
this._updateSummary(entry);
|
||||
}
|
||||
}
|
||||
|
||||
_ensureLogDir() {
|
||||
if (!fs.existsSync(LOG_DIR)) {
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
_persist() {
|
||||
try {
|
||||
fs.writeFileSync(METER_LOG, JSON.stringify({
|
||||
records: this._records.slice(-500),
|
||||
summary: this._summary
|
||||
}, null, 2));
|
||||
} catch (err) {
|
||||
this._lastPersistError = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
_loadExisting() {
|
||||
try {
|
||||
if (fs.existsSync(METER_LOG)) {
|
||||
const data = JSON.parse(fs.readFileSync(METER_LOG, 'utf-8'));
|
||||
this._records = data.records || [];
|
||||
if (data.summary) {
|
||||
this._summary = data.summary;
|
||||
} else {
|
||||
this._rebuildSummary();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this._lastLoadError = err.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ResourceMeter;
|
||||
|
|
@ -0,0 +1,316 @@
|
|||
// exe-engine/src/router/age-router.js
|
||||
// EXE-Engine · AGE-Router 路由网关
|
||||
// 接收所有 AI 执行请求,进行鉴权、分类、路由
|
||||
// PRJ-EXE-001 · Phase 0 · ZY-EXE-P0-001
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
const { randomUUID } = require('crypto');
|
||||
|
||||
// HLI 501 存根路由(Phase 0 占位,后续 Phase 实现)
|
||||
const { personaStubs } = require('./stubs/persona-stub');
|
||||
const { userStubs } = require('./stubs/user-stub');
|
||||
const { ticketStubs } = require('./stubs/ticket-stub');
|
||||
const { dialogueStubs } = require('./stubs/dialogue-stub');
|
||||
const { storageStubs } = require('./stubs/storage-stub');
|
||||
const { dashboardStubs } = require('./stubs/dashboard-stub');
|
||||
|
||||
/**
|
||||
* AGE-Router 路由网关
|
||||
*
|
||||
* 本体论锚定:路由网关 = 笔的分流阀。
|
||||
* 所有墨水需求都从这里进入,网关决定用哪种墨水、多少量。
|
||||
*
|
||||
* 职责:
|
||||
* 1. 请求鉴权(验证用户身份 + 资源池额度)
|
||||
* 2. 任务分类(代码生成 / 文本处理 / 数据分析 / Agent 指令)
|
||||
* 3. 模型选择(通过 LoadBalancer 选择最优模型)
|
||||
* 4. 限流降级(资源池耗尽时的优雅降级策略)
|
||||
*/
|
||||
class AGERouter {
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {LoadBalancer} deps.loadBalancer 负载均衡器
|
||||
* @param {ResourceMeter} deps.resourceMeter 资源计量器
|
||||
* @param {ContextCache} deps.contextCache 上下文缓存
|
||||
* @param {AgentController} deps.agentController Agent 调度器
|
||||
* @param {object} [deps.poolConfig] 资源池配置
|
||||
*/
|
||||
constructor(deps) {
|
||||
this._loadBalancer = deps.loadBalancer;
|
||||
this._resourceMeter = deps.resourceMeter;
|
||||
this._contextCache = deps.contextCache;
|
||||
this._agentController = deps.agentController;
|
||||
this._poolConfig = deps.poolConfig || {};
|
||||
|
||||
// 限流计数器 { agentId: { count, resetAt } }
|
||||
this._rateLimits = new Map();
|
||||
|
||||
// HLI 存根路由注册表(天眼路由扫描识别用)
|
||||
this._hliStubs = [
|
||||
...personaStubs,
|
||||
...userStubs,
|
||||
...ticketStubs,
|
||||
...dialogueStubs,
|
||||
...storageStubs,
|
||||
...dashboardStubs
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 AI 请求(统一入口)
|
||||
*
|
||||
* 对应 API: POST /api/exe/v1/execute
|
||||
*
|
||||
* @param {object} request
|
||||
* @param {string} request.agentId Agent ID
|
||||
* @param {string} request.taskType 任务类型
|
||||
* @param {string} request.prompt Prompt
|
||||
* @param {string} [request.systemPrompt] 系统 Prompt
|
||||
* @param {object} [request.context] 上下文
|
||||
* @param {object} [request.preferences] 偏好 { model, priority, maxTokens }
|
||||
* @param {string} [request.resourcePool] 资源池 ID
|
||||
* @param {string} [request.apiKey] BYOK 模式的用户 Key
|
||||
* @returns {Promise<object>} EXE 标准响应
|
||||
*/
|
||||
async execute(request) {
|
||||
const requestId = `exe-${randomUUID().slice(0, 12)}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 1. 限流检查
|
||||
this._checkRateLimit(request.agentId, request.resourcePool);
|
||||
|
||||
// 2. 尝试加载缓存上下文
|
||||
const cacheKey = `${request.agentId}:${request.taskType}`;
|
||||
const cachedContext = this._contextCache.get(cacheKey);
|
||||
if (cachedContext && !request.context) {
|
||||
request.context = cachedContext;
|
||||
}
|
||||
|
||||
// 3. 通过 LoadBalancer 选择适配器
|
||||
const adapter = this._loadBalancer.select({
|
||||
taskType: request.taskType,
|
||||
model: request.preferences?.model || 'auto',
|
||||
priority: request.preferences?.priority || 'balanced'
|
||||
});
|
||||
|
||||
if (!adapter) {
|
||||
return this._buildErrorResponse(requestId, startTime, 'NO_AVAILABLE_MODEL',
|
||||
'没有可用的模型适配器,所有模型可能处于冷却期');
|
||||
}
|
||||
|
||||
// 4. 执行推理
|
||||
const result = await adapter.execute({
|
||||
prompt: request.prompt,
|
||||
systemPrompt: request.systemPrompt,
|
||||
taskType: request.taskType,
|
||||
maxTokens: request.preferences?.maxTokens || 4096,
|
||||
temperature: request.preferences?.temperature
|
||||
});
|
||||
|
||||
// 5. 记录计量数据
|
||||
const mode = request.apiKey ? 'byok' : 'pool';
|
||||
this._resourceMeter.record({
|
||||
requestId,
|
||||
agentId: request.agentId,
|
||||
model: result.model,
|
||||
taskType: request.taskType,
|
||||
resourcePool: request.resourcePool || 'default',
|
||||
mode,
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
cost: result.usage.cost,
|
||||
costUnit: result.usage.unit,
|
||||
latency: result.latency,
|
||||
status: 'success'
|
||||
});
|
||||
|
||||
// 6. 更新上下文缓存
|
||||
if (request.context) {
|
||||
this._contextCache.set(cacheKey, request.context);
|
||||
}
|
||||
|
||||
// 7. 返回标准响应
|
||||
return {
|
||||
requestId,
|
||||
model: result.model,
|
||||
output: result.output,
|
||||
usage: result.usage,
|
||||
latency: result.latency,
|
||||
status: 'success'
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
// 尝试故障转移
|
||||
const failoverResult = await this._tryFailover(request, requestId, startTime, err);
|
||||
if (failoverResult) return failoverResult;
|
||||
|
||||
// 记录失败
|
||||
this._resourceMeter.record({
|
||||
requestId,
|
||||
agentId: request.agentId,
|
||||
model: 'unknown',
|
||||
taskType: request.taskType,
|
||||
resourcePool: request.resourcePool || 'default',
|
||||
mode: request.apiKey ? 'byok' : 'pool',
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cost: 0,
|
||||
costUnit: 'CNY',
|
||||
latency: Date.now() - startTime,
|
||||
status: 'error'
|
||||
});
|
||||
|
||||
return this._buildErrorResponse(requestId, startTime, 'EXECUTION_FAILED', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统状态
|
||||
* @returns {object}
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
router: 'AGE-Router v1.0.0',
|
||||
project: 'PRJ-EXE-001',
|
||||
phase: 'Phase 0',
|
||||
adapters: this._loadBalancer.getStatus(),
|
||||
meter: this._resourceMeter.getSummary(),
|
||||
cache: this._contextCache.getStatus(),
|
||||
agents: this._agentController.getRegisteredAgents(),
|
||||
hliStubs: this._hliStubs.map(s => ({
|
||||
routeId: s.routeId,
|
||||
path: s.path,
|
||||
phase: s.phase,
|
||||
status: 'stub_501'
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HLI 存根路由请求
|
||||
* @param {string} routePath 路由路径(如 /hli/persona/load)
|
||||
* @returns {object|null} 501 响应,不匹配返回 null
|
||||
*/
|
||||
async handleStubRoute(routePath) {
|
||||
const stub = this._hliStubs.find(s => s.path === routePath);
|
||||
if (!stub) return null;
|
||||
return stub.handler({});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册的 HLI 存根路由
|
||||
* @returns {Array}
|
||||
*/
|
||||
getHLIStubs() {
|
||||
return this._hliStubs;
|
||||
}
|
||||
|
||||
// ── 内部方法 ──
|
||||
|
||||
/**
|
||||
* 限流检查
|
||||
* @param {string} agentId
|
||||
* @param {string} poolId
|
||||
*/
|
||||
_checkRateLimit(agentId, poolId) {
|
||||
const key = agentId || 'anonymous';
|
||||
const now = Date.now();
|
||||
let limiter = this._rateLimits.get(key);
|
||||
|
||||
if (!limiter || now >= limiter.resetAt) {
|
||||
limiter = { count: 0, resetAt: now + 60000 }; // 每分钟窗口
|
||||
this._rateLimits.set(key, limiter);
|
||||
}
|
||||
|
||||
limiter.count++;
|
||||
|
||||
// 获取资源池的速率限制
|
||||
const pool = this._poolConfig.pools?.[poolId || 'default'];
|
||||
const limit = pool?.rateLimitPerMinute || 30;
|
||||
|
||||
if (limiter.count > limit) {
|
||||
throw new Error(`[限流] Agent ${key} 每分钟请求数超限 (${limiter.count}/${limit})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 故障转移
|
||||
* @param {object} request
|
||||
* @param {string} requestId
|
||||
* @param {number} startTime
|
||||
* @param {Error} originalError
|
||||
* @returns {Promise<object|null>}
|
||||
*/
|
||||
async _tryFailover(request, requestId, startTime, originalError) {
|
||||
try {
|
||||
const failoverAdapter = this._loadBalancer.failover(
|
||||
request.preferences?.model || 'unknown',
|
||||
request.taskType
|
||||
);
|
||||
|
||||
if (!failoverAdapter) return null;
|
||||
|
||||
const result = await failoverAdapter.execute({
|
||||
prompt: request.prompt,
|
||||
systemPrompt: request.systemPrompt,
|
||||
taskType: request.taskType,
|
||||
maxTokens: request.preferences?.maxTokens || 4096
|
||||
});
|
||||
|
||||
this._resourceMeter.record({
|
||||
requestId,
|
||||
agentId: request.agentId,
|
||||
model: result.model,
|
||||
taskType: request.taskType,
|
||||
resourcePool: request.resourcePool || 'default',
|
||||
mode: request.apiKey ? 'byok' : 'pool',
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
cost: result.usage.cost,
|
||||
costUnit: result.usage.unit,
|
||||
latency: result.latency,
|
||||
status: 'success'
|
||||
});
|
||||
|
||||
return {
|
||||
requestId,
|
||||
model: result.model,
|
||||
output: result.output,
|
||||
usage: result.usage,
|
||||
latency: result.latency,
|
||||
status: 'success',
|
||||
_failover: true,
|
||||
_originalError: originalError.message
|
||||
};
|
||||
} catch (failoverErr) {
|
||||
// Failover also failed - return null to trigger error response
|
||||
this._lastFailoverError = failoverErr.message;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建错误响应
|
||||
* @param {string} requestId
|
||||
* @param {number} startTime
|
||||
* @param {string} code
|
||||
* @param {string} message
|
||||
* @returns {object}
|
||||
*/
|
||||
_buildErrorResponse(requestId, startTime, code, message) {
|
||||
return {
|
||||
requestId,
|
||||
model: null,
|
||||
output: null,
|
||||
usage: { inputTokens: 0, outputTokens: 0, cost: 0, unit: 'CNY' },
|
||||
latency: Date.now() - startTime,
|
||||
status: 'error',
|
||||
error: { code, message }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AGERouter;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// exe-engine/src/router/stubs/dashboard-stub.js
|
||||
// HLI 存根路由 · DASHBOARD 域
|
||||
// 501 占位 — Phase 0 骨架,P3 实现
|
||||
// PRJ-EXE-001 · 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
function createStubHandler(routeId, routePath, targetPhase) {
|
||||
return async (req) => {
|
||||
return {
|
||||
statusCode: 501,
|
||||
body: {
|
||||
status: 'not_implemented',
|
||||
routeId,
|
||||
path: routePath,
|
||||
phase: targetPhase,
|
||||
message: `Route ${routePath} is planned for ${targetPhase}. Current: Phase 0.`
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const dashboardStubs = [
|
||||
{
|
||||
routeId: 'HLI-DASHBOARD-001',
|
||||
path: '/hli/dashboard/status',
|
||||
phase: 'P3',
|
||||
handler: createStubHandler('HLI-DASHBOARD-001', '/hli/dashboard/status', 'P3')
|
||||
},
|
||||
{
|
||||
routeId: 'HLI-DASHBOARD-002',
|
||||
path: '/hli/dashboard/realtime',
|
||||
phase: 'P3',
|
||||
handler: createStubHandler('HLI-DASHBOARD-002', '/hli/dashboard/realtime', 'P3')
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = { dashboardStubs, createStubHandler };
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
// exe-engine/src/router/stubs/dialogue-stub.js
|
||||
// HLI 存根路由 · DIALOGUE 域
|
||||
// 501 占位 — Phase 0 骨架,P1 实现
|
||||
// PRJ-EXE-001 · 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
function createStubHandler(routeId, routePath, targetPhase) {
|
||||
return async (req) => {
|
||||
return {
|
||||
statusCode: 501,
|
||||
body: {
|
||||
status: 'not_implemented',
|
||||
routeId,
|
||||
path: routePath,
|
||||
phase: targetPhase,
|
||||
message: `Route ${routePath} is planned for ${targetPhase}. Current: Phase 0.`
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const dialogueStubs = [
|
||||
{
|
||||
routeId: 'HLI-DIALOGUE-001',
|
||||
path: '/hli/dialogue/send',
|
||||
phase: 'P1',
|
||||
handler: createStubHandler('HLI-DIALOGUE-001', '/hli/dialogue/send', 'P1')
|
||||
},
|
||||
{
|
||||
routeId: 'HLI-DIALOGUE-002',
|
||||
path: '/hli/dialogue/stream',
|
||||
phase: 'P1',
|
||||
handler: createStubHandler('HLI-DIALOGUE-002', '/hli/dialogue/stream', 'P1')
|
||||
},
|
||||
{
|
||||
routeId: 'HLI-DIALOGUE-003',
|
||||
path: '/hli/dialogue/history',
|
||||
phase: 'P1',
|
||||
handler: createStubHandler('HLI-DIALOGUE-003', '/hli/dialogue/history', 'P1')
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = { dialogueStubs, createStubHandler };
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// exe-engine/src/router/stubs/persona-stub.js
|
||||
// HLI 存根路由 · PERSONA 域
|
||||
// 501 占位 — Phase 0 骨架,P1 实现
|
||||
// PRJ-EXE-001 · 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 创建 501 存根处理器
|
||||
* @param {string} routeId HLI 路由 ID
|
||||
* @param {string} routePath 路由路径
|
||||
* @param {string} targetPhase 目标实现阶段
|
||||
* @returns {Function} 处理器函数
|
||||
*/
|
||||
function createStubHandler(routeId, routePath, targetPhase) {
|
||||
return async (req) => {
|
||||
return {
|
||||
statusCode: 501,
|
||||
body: {
|
||||
status: 'not_implemented',
|
||||
routeId,
|
||||
path: routePath,
|
||||
phase: targetPhase,
|
||||
message: `Route ${routePath} is planned for ${targetPhase}. Current: Phase 0.`
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const personaStubs = [
|
||||
{
|
||||
routeId: 'HLI-PERSONA-001',
|
||||
path: '/hli/persona/load',
|
||||
phase: 'P1',
|
||||
handler: createStubHandler('HLI-PERSONA-001', '/hli/persona/load', 'P1')
|
||||
},
|
||||
{
|
||||
routeId: 'HLI-PERSONA-002',
|
||||
path: '/hli/persona/switch',
|
||||
phase: 'P1',
|
||||
handler: createStubHandler('HLI-PERSONA-002', '/hli/persona/switch', 'P1')
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = { personaStubs, createStubHandler };
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// exe-engine/src/router/stubs/storage-stub.js
|
||||
// HLI 存根路由 · STORAGE 域
|
||||
// 501 占位 — Phase 0 骨架,P2 实现
|
||||
// PRJ-EXE-001 · 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
function createStubHandler(routeId, routePath, targetPhase) {
|
||||
return async (req) => {
|
||||
return {
|
||||
statusCode: 501,
|
||||
body: {
|
||||
status: 'not_implemented',
|
||||
routeId,
|
||||
path: routePath,
|
||||
phase: targetPhase,
|
||||
message: `Route ${routePath} is planned for ${targetPhase}. Current: Phase 0.`
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const storageStubs = [
|
||||
{
|
||||
routeId: 'HLI-STORAGE-001',
|
||||
path: '/hli/storage/upload',
|
||||
phase: 'P2',
|
||||
handler: createStubHandler('HLI-STORAGE-001', '/hli/storage/upload', 'P2')
|
||||
},
|
||||
{
|
||||
routeId: 'HLI-STORAGE-002',
|
||||
path: '/hli/storage/download',
|
||||
phase: 'P2',
|
||||
handler: createStubHandler('HLI-STORAGE-002', '/hli/storage/download', 'P2')
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = { storageStubs, createStubHandler };
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
// exe-engine/src/router/stubs/ticket-stub.js
|
||||
// HLI 存根路由 · TICKET 域
|
||||
// 501 占位 — Phase 0 骨架,P2 实现
|
||||
// PRJ-EXE-001 · 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
function createStubHandler(routeId, routePath, targetPhase) {
|
||||
return async (req) => {
|
||||
return {
|
||||
statusCode: 501,
|
||||
body: {
|
||||
status: 'not_implemented',
|
||||
routeId,
|
||||
path: routePath,
|
||||
phase: targetPhase,
|
||||
message: `Route ${routePath} is planned for ${targetPhase}. Current: Phase 0.`
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const ticketStubs = [
|
||||
{
|
||||
routeId: 'HLI-TICKET-001',
|
||||
path: '/hli/ticket/create',
|
||||
phase: 'P2',
|
||||
handler: createStubHandler('HLI-TICKET-001', '/hli/ticket/create', 'P2')
|
||||
},
|
||||
{
|
||||
routeId: 'HLI-TICKET-002',
|
||||
path: '/hli/ticket/query',
|
||||
phase: 'P2',
|
||||
handler: createStubHandler('HLI-TICKET-002', '/hli/ticket/query', 'P2')
|
||||
},
|
||||
{
|
||||
routeId: 'HLI-TICKET-003',
|
||||
path: '/hli/ticket/status',
|
||||
phase: 'P2',
|
||||
handler: createStubHandler('HLI-TICKET-003', '/hli/ticket/status', 'P2')
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = { ticketStubs, createStubHandler };
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// exe-engine/src/router/stubs/user-stub.js
|
||||
// HLI 存根路由 · USER 域
|
||||
// 501 占位 — Phase 0 骨架,P1 实现
|
||||
// PRJ-EXE-001 · 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
function createStubHandler(routeId, routePath, targetPhase) {
|
||||
return async (req) => {
|
||||
return {
|
||||
statusCode: 501,
|
||||
body: {
|
||||
status: 'not_implemented',
|
||||
routeId,
|
||||
path: routePath,
|
||||
phase: targetPhase,
|
||||
message: `Route ${routePath} is planned for ${targetPhase}. Current: Phase 0.`
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const userStubs = [
|
||||
{
|
||||
routeId: 'HLI-USER-001',
|
||||
path: '/hli/user/profile',
|
||||
phase: 'P1',
|
||||
handler: createStubHandler('HLI-USER-001', '/hli/user/profile', 'P1')
|
||||
},
|
||||
{
|
||||
routeId: 'HLI-USER-002',
|
||||
path: '/hli/user/profile/update',
|
||||
phase: 'P1',
|
||||
handler: createStubHandler('HLI-USER-002', '/hli/user/profile/update', 'P1')
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = { userStubs, createStubHandler };
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
// exe-engine/tests/smoke/exe-engine.test.js
|
||||
// EXE-Engine · 冒烟测试
|
||||
// PRJ-EXE-001 · Phase 0 · ZY-EXE-P0-004
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
// 直接引入模块(不走 npm install)
|
||||
const {
|
||||
createEngine,
|
||||
AGERouter,
|
||||
LoadBalancer,
|
||||
ResourceMeter,
|
||||
ContextCache,
|
||||
AgentController,
|
||||
BaseAdapter,
|
||||
DeepSeekAdapter,
|
||||
QwenAdapter
|
||||
} = require('../../src/index');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (condition) {
|
||||
passed++;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` ✅ ${message}`);
|
||||
} else {
|
||||
failed++;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(` ❌ ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('🔧 EXE-Engine 冒烟测试 · Phase 0\n');
|
||||
|
||||
// ── 测试 1: 模块导出 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('── 测试 1: 模块导出完整性 ──');
|
||||
assert(typeof createEngine === 'function', 'createEngine 是函数');
|
||||
assert(typeof AGERouter === 'function', 'AGERouter 是构造函数');
|
||||
assert(typeof LoadBalancer === 'function', 'LoadBalancer 是构造函数');
|
||||
assert(typeof ResourceMeter === 'function', 'ResourceMeter 是构造函数');
|
||||
assert(typeof ContextCache === 'function', 'ContextCache 是构造函数');
|
||||
assert(typeof AgentController === 'function', 'AgentController 是构造函数');
|
||||
assert(typeof BaseAdapter === 'function', 'BaseAdapter 是构造函数');
|
||||
assert(typeof DeepSeekAdapter === 'function', 'DeepSeekAdapter 是构造函数');
|
||||
assert(typeof QwenAdapter === 'function', 'QwenAdapter 是构造函数');
|
||||
|
||||
// ── 测试 2: createEngine 初始化 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 2: createEngine 初始化 ──');
|
||||
const engine = createEngine();
|
||||
assert(engine.router instanceof AGERouter, 'router 是 AGERouter 实例');
|
||||
assert(engine.meter instanceof ResourceMeter, 'meter 是 ResourceMeter 实例');
|
||||
assert(engine.cache instanceof ContextCache, 'cache 是 ContextCache 实例');
|
||||
assert(engine.controller instanceof AgentController, 'controller 是 AgentController 实例');
|
||||
assert(engine.balancer instanceof LoadBalancer, 'balancer 是 LoadBalancer 实例');
|
||||
assert(engine.adapters instanceof Map, 'adapters 是 Map 实例');
|
||||
assert(engine.adapters.size === 4, `4 个适配器已注册 (实际: ${engine.adapters.size})`);
|
||||
|
||||
// ── 测试 3: 适配器注册 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 3: 适配器注册 ──');
|
||||
assert(engine.adapters.has('deepseek-v3'), 'deepseek-v3 适配器已注册');
|
||||
assert(engine.adapters.has('deepseek-r1'), 'deepseek-r1 适配器已注册');
|
||||
assert(engine.adapters.has('qwen-max'), 'qwen-max 适配器已注册');
|
||||
assert(engine.adapters.has('qwen-coder'), 'qwen-coder 适配器已注册');
|
||||
|
||||
const dsAdapter = engine.adapters.get('deepseek-v3');
|
||||
assert(dsAdapter instanceof DeepSeekAdapter, 'deepseek-v3 是 DeepSeekAdapter 实例');
|
||||
assert(dsAdapter.supports('code'), 'deepseek-v3 支持 code 能力');
|
||||
assert(dsAdapter.supports('reasoning'), 'deepseek-v3 支持 reasoning 能力');
|
||||
|
||||
const qwAdapter = engine.adapters.get('qwen-max');
|
||||
assert(qwAdapter instanceof QwenAdapter, 'qwen-max 是 QwenAdapter 实例');
|
||||
assert(qwAdapter.supports('text'), 'qwen-max 支持 text 能力');
|
||||
|
||||
// ── 测试 4: BaseAdapter 不可直接实例化 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 4: BaseAdapter 约束 ──');
|
||||
let baseError = null;
|
||||
try {
|
||||
new BaseAdapter({ name: 'test' });
|
||||
} catch (e) {
|
||||
baseError = e;
|
||||
}
|
||||
assert(baseError !== null, 'BaseAdapter 不可直接实例化');
|
||||
assert(baseError && baseError.message.includes('不可直接实例化'), '错误信息正确');
|
||||
|
||||
// ── 测试 5: 适配器状态 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 5: 适配器状态 ──');
|
||||
const dsStatus = dsAdapter.getStatus();
|
||||
assert(dsStatus.name === 'deepseek-v3', '状态名称正确');
|
||||
assert(dsStatus.healthy === true, '初始状态健康');
|
||||
assert(dsStatus.inCooldown === false, '初始无冷却');
|
||||
assert(dsStatus.failureCount === 0, '初始无失败');
|
||||
|
||||
// ── 测试 6: 冷却期机制 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 6: 冷却期机制 ──');
|
||||
const testAdapter = engine.adapters.get('deepseek-r1');
|
||||
testAdapter.recordFailure('test1');
|
||||
testAdapter.recordFailure('test2');
|
||||
assert(testAdapter.isInCooldown() === false, '2 次失败不触发冷却');
|
||||
testAdapter.recordFailure('test3');
|
||||
assert(testAdapter.isInCooldown() === true, '3 次失败触发冷却');
|
||||
assert(testAdapter.getStatus().healthy === false, '冷却期标记不健康');
|
||||
testAdapter.resetFailures();
|
||||
assert(testAdapter.isInCooldown() === false, '重置后退出冷却');
|
||||
assert(testAdapter.getStatus().healthy === true, '重置后恢复健康');
|
||||
|
||||
// ── 测试 7: LoadBalancer 选择 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 7: LoadBalancer 选择 ──');
|
||||
const selected = engine.balancer.select({
|
||||
taskType: 'code_generation',
|
||||
model: 'auto',
|
||||
priority: 'balanced'
|
||||
});
|
||||
assert(selected !== null, 'balanced 策略返回适配器');
|
||||
assert(selected.name === 'deepseek-v3', 'code_generation 首选 deepseek-v3');
|
||||
|
||||
const costSelected = engine.balancer.select({
|
||||
taskType: 'code_generation',
|
||||
model: 'auto',
|
||||
priority: 'cost'
|
||||
});
|
||||
assert(costSelected !== null, 'cost 策略返回适配器');
|
||||
|
||||
const qualitySelected = engine.balancer.select({
|
||||
taskType: 'reasoning',
|
||||
model: 'auto',
|
||||
priority: 'quality'
|
||||
});
|
||||
assert(qualitySelected !== null, 'quality 策略返回适配器');
|
||||
assert(qualitySelected.name === 'deepseek-r1', 'reasoning 首选 deepseek-r1');
|
||||
|
||||
// 指定模型
|
||||
const specificSelected = engine.balancer.select({
|
||||
taskType: 'text_processing',
|
||||
model: 'qwen-max',
|
||||
priority: 'balanced'
|
||||
});
|
||||
assert(specificSelected !== null, '指定模型返回适配器');
|
||||
assert(specificSelected.name === 'qwen-max', '返回指定的 qwen-max');
|
||||
|
||||
// 故障转移
|
||||
const failover = engine.balancer.failover('deepseek-v3', 'code_generation');
|
||||
assert(failover !== null, '故障转移返回备选');
|
||||
assert(failover.name !== 'deepseek-v3', '备选不等于失败模型');
|
||||
|
||||
// ── 测试 8: ResourceMeter 计量 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 8: ResourceMeter 计量 ──');
|
||||
const freshMeter = new ResourceMeter();
|
||||
// Clear any previous state by using a fresh meter and tracking deltas
|
||||
const beforeSummary = freshMeter.getSummary();
|
||||
const beforeRequests = beforeSummary.totalRequests;
|
||||
|
||||
freshMeter.record({
|
||||
requestId: 'test-001',
|
||||
agentId: 'AG-ZY-01',
|
||||
model: 'deepseek-v3',
|
||||
taskType: 'code_generation',
|
||||
resourcePool: 'default',
|
||||
mode: 'pool',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 500,
|
||||
cost: 0.002,
|
||||
costUnit: 'CNY',
|
||||
latency: 1500,
|
||||
status: 'success'
|
||||
});
|
||||
|
||||
const summary = freshMeter.getSummary();
|
||||
assert(summary.totalRequests === beforeRequests + 1, '总请求数增加 1');
|
||||
assert(summary.byModel['deepseek-v3'] !== undefined, '按模型统计存在 deepseek-v3');
|
||||
assert(summary.byAgent['AG-ZY-01'] !== undefined, '按 Agent 统计存在 AG-ZY-01');
|
||||
|
||||
const agentUsage = freshMeter.getAgentUsage('AG-ZY-01');
|
||||
assert(agentUsage !== null, 'Agent 使用记录存在');
|
||||
assert(agentUsage.inputTokens >= 1000, 'Agent 输入 token >= 1000');
|
||||
|
||||
const recent = freshMeter.getRecentRecords(10);
|
||||
assert(recent.length >= 1, '最近记录数 >= 1');
|
||||
assert(recent[recent.length - 1].requestId === 'test-001', '最近一条记录 ID 正确');
|
||||
|
||||
// ── 测试 9: ContextCache ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 9: ContextCache 缓存 ──');
|
||||
engine.cache.set('test-key', { repo: 'guanghulab', branch: 'main' });
|
||||
const cached = engine.cache.get('test-key');
|
||||
assert(cached !== null, '缓存命中');
|
||||
assert(cached.repo === 'guanghulab', '缓存内容正确');
|
||||
|
||||
const missed = engine.cache.get('nonexistent');
|
||||
assert(missed === null, '缓存未命中返回 null');
|
||||
|
||||
const cacheStatus = engine.cache.getStatus();
|
||||
assert(cacheStatus.size === 1, '缓存大小 = 1');
|
||||
assert(cacheStatus.maxEntries === 100, '最大容量 = 100');
|
||||
|
||||
engine.cache.delete('test-key');
|
||||
assert(engine.cache.get('test-key') === null, '删除后缓存未命中');
|
||||
|
||||
// ── 测试 10: AgentController ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 10: AgentController 调度 ──');
|
||||
const zyPref = engine.controller.getPreference('AG-ZY-01');
|
||||
assert(zyPref.model === 'auto', '铸渊偏好 model=auto');
|
||||
assert(zyPref.priority === 'balanced', '铸渊偏好 priority=balanced');
|
||||
|
||||
const syPref = engine.controller.getPreference('AG-SY-01');
|
||||
assert(syPref.priority === 'quality', '霜砚偏好 priority=quality');
|
||||
|
||||
const qqPref = engine.controller.getPreference('AG-QQ-01');
|
||||
assert(qqPref.priority === 'cost', '秋秋偏好 priority=cost');
|
||||
|
||||
const unknownPref = engine.controller.getPreference('AG-UNKNOWN');
|
||||
assert(unknownPref.model === 'auto', '未注册 Agent 返回默认偏好');
|
||||
|
||||
const exeRequest = engine.controller.buildExeRequest('AG-ZY-01', {
|
||||
prompt: 'Hello World',
|
||||
taskType: 'code_generation'
|
||||
});
|
||||
assert(exeRequest.agentId === 'AG-ZY-01', 'EXE 请求 agentId 正确');
|
||||
assert(exeRequest.taskType === 'code_generation', 'EXE 请求 taskType 正确');
|
||||
assert(exeRequest.prompt === 'Hello World', 'EXE 请求 prompt 正确');
|
||||
assert(exeRequest.resourcePool === 'default', 'EXE 请求 resourcePool 正确');
|
||||
|
||||
const agents = engine.controller.getRegisteredAgents();
|
||||
assert(agents.length === 3, '3 个 Agent 已注册');
|
||||
|
||||
// ── 测试 11: AGE-Router 状态 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 11: AGE-Router 状态 ──');
|
||||
const routerStatus = engine.router.getStatus();
|
||||
assert(routerStatus.router === 'AGE-Router v1.0.0', 'Router 版本正确');
|
||||
assert(routerStatus.project === 'PRJ-EXE-001', '项目编号正确');
|
||||
assert(routerStatus.phase === 'Phase 0', '阶段正确');
|
||||
assert(Array.isArray(routerStatus.adapters), '适配器状态是数组');
|
||||
assert(routerStatus.adapters.length === 4, '4 个适配器状态');
|
||||
|
||||
// ── 测试 12: AGE-Router 错误处理(无 API Key) ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 12: AGE-Router 错误处理 ──');
|
||||
(async () => {
|
||||
const result = await engine.router.execute({
|
||||
agentId: 'AG-ZY-01',
|
||||
taskType: 'code_generation',
|
||||
prompt: 'test prompt',
|
||||
preferences: { model: 'auto', priority: 'balanced' }
|
||||
});
|
||||
|
||||
// 无 API Key 时应该返回错误(网络错误或认证错误)
|
||||
assert(result.requestId && result.requestId.startsWith('exe-'), '返回带 exe- 前缀的 requestId');
|
||||
assert(typeof result.latency === 'number', '返回 latency');
|
||||
assert(result.status === 'error', '无 API Key 时返回 error 状态');
|
||||
assert(result.error && result.error.code, '错误响应包含 error.code');
|
||||
assert(result.error && result.error.message, '错误响应包含 error.message');
|
||||
|
||||
// ── 测试结果汇总 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n══════════════════════════════════════');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`🔧 EXE-Engine 冒烟测试完成`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` ✅ 通过: ${passed}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` ❌ 失败: ${failed}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` 📊 总计: ${passed + failed}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('══════════════════════════════════════\n');
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
// exe-engine/tests/stubs/hli-stubs.test.js
|
||||
// HLI 存根路由测试
|
||||
// PRJ-EXE-001 · 501 stub coverage · AG-ZY-095 修复
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
const { personaStubs } = require('../../src/router/stubs/persona-stub');
|
||||
const { userStubs } = require('../../src/router/stubs/user-stub');
|
||||
const { ticketStubs } = require('../../src/router/stubs/ticket-stub');
|
||||
const { dialogueStubs } = require('../../src/router/stubs/dialogue-stub');
|
||||
const { storageStubs } = require('../../src/router/stubs/storage-stub');
|
||||
const { dashboardStubs } = require('../../src/router/stubs/dashboard-stub');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (condition) {
|
||||
passed++;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` ✅ ${message}`);
|
||||
} else {
|
||||
failed++;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(` ❌ ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('🔌 HLI 存根路由测试 · 501 Coverage Fix\n');
|
||||
|
||||
// 所有 14 个存根路由定义
|
||||
const ALL_STUBS = [
|
||||
...personaStubs,
|
||||
...userStubs,
|
||||
...ticketStubs,
|
||||
...dialogueStubs,
|
||||
...storageStubs,
|
||||
...dashboardStubs
|
||||
];
|
||||
|
||||
// 期望的路由表
|
||||
const EXPECTED_ROUTES = {
|
||||
'HLI-PERSONA-001': { path: '/hli/persona/load', phase: 'P1' },
|
||||
'HLI-PERSONA-002': { path: '/hli/persona/switch', phase: 'P1' },
|
||||
'HLI-USER-001': { path: '/hli/user/profile', phase: 'P1' },
|
||||
'HLI-USER-002': { path: '/hli/user/profile/update', phase: 'P1' },
|
||||
'HLI-TICKET-001': { path: '/hli/ticket/create', phase: 'P2' },
|
||||
'HLI-TICKET-002': { path: '/hli/ticket/query', phase: 'P2' },
|
||||
'HLI-TICKET-003': { path: '/hli/ticket/status', phase: 'P2' },
|
||||
'HLI-DIALOGUE-001': { path: '/hli/dialogue/send', phase: 'P1' },
|
||||
'HLI-DIALOGUE-002': { path: '/hli/dialogue/stream', phase: 'P1' },
|
||||
'HLI-DIALOGUE-003': { path: '/hli/dialogue/history', phase: 'P1' },
|
||||
'HLI-STORAGE-001': { path: '/hli/storage/upload', phase: 'P2' },
|
||||
'HLI-STORAGE-002': { path: '/hli/storage/download', phase: 'P2' },
|
||||
'HLI-DASHBOARD-001': { path: '/hli/dashboard/status', phase: 'P3' },
|
||||
'HLI-DASHBOARD-002': { path: '/hli/dashboard/realtime', phase: 'P3' }
|
||||
};
|
||||
|
||||
// ── 测试 1: 存根数量 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('── 测试 1: 存根数量完整性 ──');
|
||||
assert(ALL_STUBS.length === 14, `14 个存根路由已注册 (实际: ${ALL_STUBS.length})`);
|
||||
assert(personaStubs.length === 2, 'PERSONA 域 2 个存根');
|
||||
assert(userStubs.length === 2, 'USER 域 2 个存根');
|
||||
assert(ticketStubs.length === 3, 'TICKET 域 3 个存根');
|
||||
assert(dialogueStubs.length === 3, 'DIALOGUE 域 3 个存根');
|
||||
assert(storageStubs.length === 2, 'STORAGE 域 2 个存根');
|
||||
assert(dashboardStubs.length === 2, 'DASHBOARD 域 2 个存根');
|
||||
|
||||
// ── 测试 2: 路由 ID 和路径匹配 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 2: 路由 ID 和路径匹配 ──');
|
||||
for (const stub of ALL_STUBS) {
|
||||
const expected = EXPECTED_ROUTES[stub.routeId];
|
||||
assert(expected !== undefined, `${stub.routeId} 在期望路由表中`);
|
||||
if (expected) {
|
||||
assert(stub.path === expected.path, `${stub.routeId} 路径正确: ${stub.path}`);
|
||||
assert(stub.phase === expected.phase, `${stub.routeId} 阶段正确: ${stub.phase}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 测试 3: 每个存根返回 501 + 正确 JSON ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 3: 每个存根返回 501 + 正确 JSON ──');
|
||||
async function testStubResponses() {
|
||||
for (const stub of ALL_STUBS) {
|
||||
const result = await stub.handler({});
|
||||
assert(result.statusCode === 501, `${stub.routeId} 返回 501`);
|
||||
assert(result.body.status === 'not_implemented', `${stub.routeId} status = not_implemented`);
|
||||
assert(result.body.routeId === stub.routeId, `${stub.routeId} routeId 正确`);
|
||||
assert(result.body.path === stub.path, `${stub.routeId} path 正确`);
|
||||
assert(result.body.phase === stub.phase, `${stub.routeId} phase 正确`);
|
||||
assert(typeof result.body.message === 'string', `${stub.routeId} 有 message`);
|
||||
assert(result.body.message.includes(stub.phase), `${stub.routeId} message 包含目标阶段`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 测试 4: AGE-Router 集成 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 4: AGE-Router 集成 ──');
|
||||
async function testRouterIntegration() {
|
||||
const { createEngine } = require('../../src/index');
|
||||
const engine = createEngine();
|
||||
|
||||
// 检查 getHLIStubs 方法存在
|
||||
assert(typeof engine.router.getHLIStubs === 'function', 'router.getHLIStubs() 方法存在');
|
||||
|
||||
const stubs = engine.router.getHLIStubs();
|
||||
assert(stubs.length === 14, `router 注册了 14 个存根 (实际: ${stubs.length})`);
|
||||
|
||||
// 检查 handleStubRoute 方法
|
||||
assert(typeof engine.router.handleStubRoute === 'function', 'router.handleStubRoute() 方法存在');
|
||||
|
||||
const result = await engine.router.handleStubRoute('/hli/persona/load');
|
||||
assert(result !== null, 'handleStubRoute 找到匹配路由');
|
||||
assert(result.statusCode === 501, 'handleStubRoute 返回 501');
|
||||
assert(result.body.routeId === 'HLI-PERSONA-001', 'handleStubRoute routeId 正确');
|
||||
|
||||
const noMatch = await engine.router.handleStubRoute('/hli/nonexistent');
|
||||
assert(noMatch === null, 'handleStubRoute 不匹配返回 null');
|
||||
|
||||
// 检查 getStatus 包含 hliStubs
|
||||
const status = engine.router.getStatus();
|
||||
assert(Array.isArray(status.hliStubs), 'getStatus 包含 hliStubs');
|
||||
assert(status.hliStubs.length === 14, `getStatus.hliStubs 有 14 项 (实际: ${status.hliStubs.length})`);
|
||||
assert(status.hliStubs[0].status === 'stub_501', 'hliStubs 项包含 status: stub_501');
|
||||
}
|
||||
|
||||
async function runAll() {
|
||||
await testStubResponses();
|
||||
await testRouterIntegration();
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n══════════════════════════════════════');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('🔌 HLI 存根路由测试完成');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` ✅ 通过: ${passed}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` ❌ 失败: ${failed}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` 📊 总计: ${passed + failed}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('══════════════════════════════════════\n');
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runAll();
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
# Grid-DB 架构设计文档
|
||||
|
||||
> 项目编号:PRJ-GDB-001
|
||||
> 版权:国作登字-2026-A-00037559
|
||||
> 版本:v0.1.0 · Phase 0
|
||||
> 签发:AG-SY-01 霜砚 · 执行:AG-ZY-01 铸渊
|
||||
|
||||
---
|
||||
|
||||
## 1. 本体论锚定 [ONT-PATCH-006]
|
||||
|
||||
### Grid-DB · 纸 · 格子 · 笔迹
|
||||
|
||||
**Grid-DB = 纸**
|
||||
|
||||
笔需要纸来承载笔迹。Grid-DB 是纸。纸上有格子(格点),每个格子是最小寻址单元。
|
||||
纸不属于笔,纸属于写字的人。
|
||||
|
||||
**格点 = 格子**
|
||||
|
||||
格子是纸上的最小单元。不同的格子存不同的数据。
|
||||
同一个格子可以有多层(raw → cleaned → indexed → semantic)。
|
||||
|
||||
**事件 = 笔迹**
|
||||
|
||||
每写一笔,笔迹就留在了历史中,不可篡改。
|
||||
天眼通过观察笔迹,发现异常行为。
|
||||
|
||||
### 与 EXE-Engine 的关系
|
||||
|
||||
| 层 | 组件 | 职责 | 本体论 |
|
||||
|---|---|---|---|
|
||||
| 执行层 | EXE-Engine | 模型调度、任务编排、算力路由 | 笔 |
|
||||
| 存储层 | Grid-DB | 数据持久化、格点索引、事件流 | 纸 |
|
||||
| 采集层 | DC v1.0 | 外部数据采集与清洗 | 墨水收集器 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心组件
|
||||
|
||||
### 2.1 GridCell — 格点数据模型
|
||||
|
||||
文件:`src/core/grid-cell.js`
|
||||
|
||||
四元组寻址:`(namespace, grid_x, grid_y, layer)`
|
||||
|
||||
- **namespace** — 隔离不同 Agent / 项目的数据域
|
||||
- **grid_x / grid_y** — 坐标轴
|
||||
- **layer** — 数据层级:raw → cleaned → indexed → semantic
|
||||
|
||||
支持:
|
||||
- JSON 序列化 / 反序列化
|
||||
- 二进制 Buffer 编解码(用于页存储)
|
||||
- 键生成(`toKey()` → `ns:x:y:layer`)
|
||||
- 曼哈顿距离计算
|
||||
|
||||
### 2.2 WAL — Write-Ahead Log
|
||||
|
||||
文件:`src/storage/wal.js`
|
||||
|
||||
所有写操作先追加到 WAL 文件,崩溃后可从 WAL 恢复。
|
||||
|
||||
记录格式:
|
||||
```
|
||||
[length:4][seqNo:4][op:1][keyLen:2][key:N][dataLen:4][data:M][checksum:4]
|
||||
```
|
||||
|
||||
操作码:
|
||||
- `0x01` = PUT
|
||||
- `0x02` = DELETE
|
||||
|
||||
特性:
|
||||
- 追加写入 + fsync 保证持久化
|
||||
- XOR 校验和验证完整性
|
||||
- 支持截断(checkpoint 后清除已持久化记录)
|
||||
- 崩溃恢复:重新打开时自动读取并恢复
|
||||
|
||||
### 2.3 PageManager — 页管理器
|
||||
|
||||
文件:`src/storage/page-manager.js`
|
||||
|
||||
以固定大小页(默认 4096 字节)为最小 I/O 单位。
|
||||
|
||||
文件结构:
|
||||
```
|
||||
[Header: 1 page] [Page 1] [Page 2] ... [Page N]
|
||||
```
|
||||
|
||||
Header 页:`[magic:4][version:2][pageSize:4][pageCount:4][freeListHead:4]`
|
||||
数据页:`[flags:1][nextFree:4][dataLen:4][data:N]`
|
||||
|
||||
特性:
|
||||
- 空闲链表管理:释放的页自动回收复用
|
||||
- 持久化:重新打开文件后数据完好
|
||||
- 文件格式验证(magic number `0x47444230` = 'GDB0')
|
||||
|
||||
### 2.4 EventLog — 事件溯源日志
|
||||
|
||||
文件:`src/events/event-log.js`
|
||||
|
||||
Phase 0:内存事件流(保留最近 1000 条)
|
||||
Phase 1:持久化事件流 + 回放 + 订阅
|
||||
|
||||
每次写操作生成事件:
|
||||
```json
|
||||
{
|
||||
"eventId": "evt-xxxx",
|
||||
"seqNo": 1,
|
||||
"timestamp": "2026-03-26T13:00:00Z",
|
||||
"namespace": "exe-engine",
|
||||
"operation": "put",
|
||||
"gridCellKey": "exe-engine:1:2:raw",
|
||||
"payload": { "dataSize": 256 }
|
||||
}
|
||||
```
|
||||
|
||||
支持订阅回调,可被天眼系统监听。
|
||||
|
||||
### 2.5 GridAPI — 统一接口
|
||||
|
||||
文件:`src/api/grid-api.js`
|
||||
|
||||
协调 WAL、PageManager、EventLog 协同工作。
|
||||
|
||||
```javascript
|
||||
const { open } = require('./grid-db/src/index');
|
||||
const db = open({ dataDir: './my-data' });
|
||||
|
||||
// put
|
||||
db.put('my-ns', { gridX: 1, gridY: 2, layer: 'raw' }, { hello: 'world' });
|
||||
|
||||
// get
|
||||
const data = db.get('my-ns', { gridX: 1, gridY: 2, layer: 'raw' });
|
||||
|
||||
// scan
|
||||
const results = db.scan('my-ns', { xRange: [0, 10], yRange: [0, 10], layer: 'raw' });
|
||||
|
||||
// delete
|
||||
db.delete('my-ns', { gridX: 1, gridY: 2, layer: 'raw' });
|
||||
|
||||
// subscribe
|
||||
db.subscribe('auditor', (event) => console.log(event));
|
||||
|
||||
// checkpoint (truncate WAL)
|
||||
db.checkpoint();
|
||||
|
||||
db.close();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术栈
|
||||
|
||||
| 层 | 选型 | 理由 |
|
||||
|---|---|---|
|
||||
| 语言 | Node.js 20 | 与 EXE-Engine 同栈 |
|
||||
| 持久化 | 自研 (.gdb/.wal) | 零外部依赖,嵌入式 |
|
||||
| 序列化 | JSON + Binary Buffer | JSON 调试友好,Buffer 高性能 |
|
||||
| 测试 | 自定义 assert runner | 与 EXE-Engine 测试框架统一 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 路线
|
||||
|
||||
| Phase | 目标 | 状态 |
|
||||
|---|---|---|
|
||||
| P0 基础骨架 | GridCell + WAL + PageManager + EventLog + GridAPI | 🟢 完成 |
|
||||
| P1 索引与查询 | B+Tree + Namespace 隔离 + 范围扫描 + Event 持久化 | ⚪ 待开始 |
|
||||
| P2 高级特性 | HNSW 向量索引 + EXE-Engine 对接 + DC 迁移 + 天眼审计 | ⚪ 待开始 |
|
||||
| P3 生产就绪 | 性能基准 + 资源计量 + 全文索引 + 文档 | ⚪ 待开始 |
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "grid-db",
|
||||
"version": "0.1.0",
|
||||
"description": "AGE OS · 自研逻辑格点数据库 (Grid-DB) · PRJ-GDB-001",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"test": "node tests/smoke/grid-db.test.js",
|
||||
"status": "node src/index.js status"
|
||||
},
|
||||
"keywords": ["age-os", "grid-db", "embedded-database", "storage-engine"],
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
// grid-db/src/api/grid-api.js
|
||||
// Grid-DB · API 层
|
||||
// 统一 CRUD / Query 接口
|
||||
// PRJ-GDB-001 · Phase 0 · ZY-GDB-P0-005
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
const GridCell = require('../core/grid-cell');
|
||||
const WAL = require('../storage/wal');
|
||||
const PageManager = require('../storage/page-manager');
|
||||
const EventLog = require('../events/event-log');
|
||||
|
||||
/**
|
||||
* GridAPI — Grid-DB 统一接口
|
||||
*
|
||||
* 提供 put / get / delete / scan 等操作,
|
||||
* 内部协调 WAL、PageManager、EventLog 协同工作。
|
||||
*
|
||||
* 本体论锚定:API = 笔的握持接口。
|
||||
* 无论笔内部结构如何,写字的人只需要握住笔就能写字。
|
||||
*/
|
||||
class GridAPI {
|
||||
/**
|
||||
* @param {object} config
|
||||
* @param {string} config.dataDir 数据目录
|
||||
* @param {number} [config.pageSize] 页大小
|
||||
*/
|
||||
constructor(config) {
|
||||
this._dataDir = config.dataDir;
|
||||
this._pageSize = config.pageSize || PageManager.DEFAULT_PAGE_SIZE;
|
||||
|
||||
// 核心组件
|
||||
this._wal = new WAL(`${this._dataDir}/grid.wal`);
|
||||
this._pageManager = new PageManager(`${this._dataDir}/grid.gdb`, {
|
||||
pageSize: this._pageSize
|
||||
});
|
||||
this._eventLog = new EventLog();
|
||||
|
||||
// 内存索引:key → pageId
|
||||
this._index = new Map();
|
||||
|
||||
// 从 WAL 恢复
|
||||
this._recoverFromWAL();
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入格点数据
|
||||
* @param {string} namespace 命名空间
|
||||
* @param {GridCell|object} gridCell 格点(或 { gridX, gridY, layer })
|
||||
* @param {*} data 数据
|
||||
* @returns {object} { key, seqNo, event }
|
||||
*/
|
||||
put(namespace, gridCell, data) {
|
||||
const cell = this._resolveCell(namespace, gridCell);
|
||||
const key = cell.toKey();
|
||||
const dataBuf = Buffer.from(JSON.stringify(data));
|
||||
|
||||
// 1. 先写 WAL
|
||||
const seqNo = this._wal.appendPut(key, dataBuf);
|
||||
|
||||
// 2. 写入 PageManager
|
||||
let pageId = this._index.get(key);
|
||||
if (pageId !== undefined) {
|
||||
// 更新现有页
|
||||
this._pageManager.writePage(pageId, dataBuf);
|
||||
} else {
|
||||
// 分配新页
|
||||
pageId = this._pageManager.allocPage();
|
||||
this._pageManager.writePage(pageId, dataBuf);
|
||||
this._index.set(key, pageId);
|
||||
}
|
||||
|
||||
// 3. 记录事件
|
||||
const event = this._eventLog.append(namespace, 'put', key, {
|
||||
dataSize: dataBuf.length
|
||||
});
|
||||
|
||||
return { key, seqNo, event };
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取格点数据
|
||||
* @param {string} namespace 命名空间
|
||||
* @param {GridCell|object} gridCell 格点
|
||||
* @returns {*|null} 数据,不存在返回 null
|
||||
*/
|
||||
get(namespace, gridCell) {
|
||||
const cell = this._resolveCell(namespace, gridCell);
|
||||
const key = cell.toKey();
|
||||
const pageId = this._index.get(key);
|
||||
|
||||
if (pageId === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataBuf = this._pageManager.readPage(pageId);
|
||||
if (!dataBuf) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(dataBuf.toString('utf-8'));
|
||||
} catch {
|
||||
return dataBuf;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除格点数据
|
||||
* @param {string} namespace 命名空间
|
||||
* @param {GridCell|object} gridCell 格点
|
||||
* @returns {boolean} 是否删除成功
|
||||
*/
|
||||
delete(namespace, gridCell) {
|
||||
const cell = this._resolveCell(namespace, gridCell);
|
||||
const key = cell.toKey();
|
||||
const pageId = this._index.get(key);
|
||||
|
||||
if (pageId === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. 先写 WAL
|
||||
this._wal.appendDelete(key);
|
||||
|
||||
// 2. 释放页
|
||||
this._pageManager.freePage(pageId);
|
||||
this._index.delete(key);
|
||||
|
||||
// 3. 记录事件
|
||||
this._eventLog.append(namespace, 'delete', key);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 范围扫描(Phase 0 简版:基于内存索引)
|
||||
* @param {string} namespace
|
||||
* @param {object} range { xRange: [min, max], yRange: [min, max], layer }
|
||||
* @returns {Array<{ cell: GridCell, data: * }>}
|
||||
*/
|
||||
scan(namespace, range = {}) {
|
||||
const results = [];
|
||||
|
||||
for (const [key, pageId] of this._index) {
|
||||
try {
|
||||
const cell = GridCell.fromKey(key);
|
||||
|
||||
if (cell.namespace !== namespace) continue;
|
||||
if (range.layer && cell.layer !== range.layer) continue;
|
||||
|
||||
if (range.xRange) {
|
||||
if (cell.gridX < range.xRange[0] || cell.gridX > range.xRange[1]) continue;
|
||||
}
|
||||
if (range.yRange) {
|
||||
if (cell.gridY < range.yRange[0] || cell.gridY > range.yRange[1]) continue;
|
||||
}
|
||||
|
||||
const dataBuf = this._pageManager.readPage(pageId);
|
||||
if (dataBuf) {
|
||||
let data;
|
||||
try { data = JSON.parse(dataBuf.toString('utf-8')); } catch { data = dataBuf; }
|
||||
results.push({ cell, data });
|
||||
}
|
||||
} catch {
|
||||
// 跳过无效键
|
||||
}
|
||||
}
|
||||
|
||||
this._eventLog.append(namespace, 'scan', `scan:${namespace}`, {
|
||||
resultCount: results.length
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取命名空间统计
|
||||
* @param {string} [namespace] 不传则统计全部
|
||||
* @returns {object}
|
||||
*/
|
||||
stats(namespace) {
|
||||
let count = 0;
|
||||
for (const key of this._index.keys()) {
|
||||
if (!namespace || key.startsWith(namespace + ':')) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
namespace: namespace || '*',
|
||||
cellCount: count,
|
||||
wal: this._wal.getStatus(),
|
||||
pageManager: this._pageManager.getStatus(),
|
||||
eventLog: this._eventLog.getStatus()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件流
|
||||
* @param {string} subscriberId
|
||||
* @param {Function} handler
|
||||
* @returns {Function} 取消订阅函数
|
||||
*/
|
||||
subscribe(subscriberId, handler) {
|
||||
return this._eventLog.subscribe(subscriberId, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 checkpoint:截断 WAL
|
||||
*/
|
||||
checkpoint() {
|
||||
this._wal.truncate();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭数据库
|
||||
*/
|
||||
close() {
|
||||
this._wal.close();
|
||||
this._pageManager.close();
|
||||
}
|
||||
|
||||
// ── 内部方法 ──
|
||||
|
||||
/**
|
||||
* 解析格点参数
|
||||
* @param {string} namespace
|
||||
* @param {GridCell|object} input
|
||||
* @returns {GridCell}
|
||||
*/
|
||||
_resolveCell(namespace, input) {
|
||||
if (input instanceof GridCell) {
|
||||
return input;
|
||||
}
|
||||
return new GridCell(
|
||||
namespace,
|
||||
input.gridX ?? input.grid_x ?? 0,
|
||||
input.gridY ?? input.grid_y ?? 0,
|
||||
input.layer || 'raw'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 WAL 恢复数据到内存索引
|
||||
*/
|
||||
_recoverFromWAL() {
|
||||
const entries = this._wal.recover();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.op === WAL.OP_PUT) {
|
||||
// 检查是否已有页
|
||||
let pageId = this._index.get(entry.key);
|
||||
if (pageId !== undefined) {
|
||||
// 页已存在,更新
|
||||
try {
|
||||
this._pageManager.writePage(pageId, entry.data);
|
||||
} catch {
|
||||
// 页可能已损坏,分配新页
|
||||
pageId = this._pageManager.allocPage();
|
||||
this._pageManager.writePage(pageId, entry.data);
|
||||
this._index.set(entry.key, pageId);
|
||||
}
|
||||
} else {
|
||||
// 分配新页并写入
|
||||
pageId = this._pageManager.allocPage();
|
||||
this._pageManager.writePage(pageId, entry.data);
|
||||
this._index.set(entry.key, pageId);
|
||||
}
|
||||
} else if (entry.op === WAL.OP_DELETE) {
|
||||
const pageId = this._index.get(entry.key);
|
||||
if (pageId !== undefined) {
|
||||
this._pageManager.freePage(pageId);
|
||||
this._index.delete(entry.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GridAPI;
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
// grid-db/src/core/grid-cell.js
|
||||
// Grid-DB · 格点数据模型
|
||||
// 四元组寻址:(namespace, grid_x, grid_y, layer)
|
||||
// PRJ-GDB-001 · Phase 0 · ZY-GDB-P0-004
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* GridCell — 逻辑格点
|
||||
*
|
||||
* 数据的最小寻址单元,由四元组定位:
|
||||
* namespace — 隔离不同 Agent / 项目的数据域
|
||||
* grid_x — X 轴坐标
|
||||
* grid_y — Y 轴坐标
|
||||
* layer — 同一坐标下的数据层级 (raw → cleaned → indexed → semantic)
|
||||
*
|
||||
* 本体论锚定:格点 = 纸上的一个格子。
|
||||
* 笔在纸上写字,每个字都有坐标。格点是纸的最小单元。
|
||||
*/
|
||||
|
||||
const VALID_LAYERS = ['raw', 'cleaned', 'indexed', 'semantic'];
|
||||
|
||||
class GridCell {
|
||||
/**
|
||||
* @param {string} namespace 命名空间
|
||||
* @param {number} gridX X 坐标
|
||||
* @param {number} gridY Y 坐标
|
||||
* @param {string} [layer] 数据层级,默认 'raw'
|
||||
*/
|
||||
constructor(namespace, gridX, gridY, layer = 'raw') {
|
||||
if (!namespace || typeof namespace !== 'string') {
|
||||
throw new Error('GridCell: namespace 必须是非空字符串');
|
||||
}
|
||||
if (typeof gridX !== 'number' || !Number.isFinite(gridX)) {
|
||||
throw new Error('GridCell: grid_x 必须是有限数值');
|
||||
}
|
||||
if (typeof gridY !== 'number' || !Number.isFinite(gridY)) {
|
||||
throw new Error('GridCell: grid_y 必须是有限数值');
|
||||
}
|
||||
if (!VALID_LAYERS.includes(layer)) {
|
||||
throw new Error(`GridCell: layer 必须是 ${VALID_LAYERS.join('/')} 之一,收到: ${layer}`);
|
||||
}
|
||||
|
||||
this.namespace = namespace;
|
||||
this.gridX = gridX;
|
||||
this.gridY = gridY;
|
||||
this.layer = layer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成格点唯一键(用于存储索引)
|
||||
* @returns {string}
|
||||
*/
|
||||
toKey() {
|
||||
return `${this.namespace}:${this.gridX}:${this.gridY}:${this.layer}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从唯一键解析格点
|
||||
* @param {string} key
|
||||
* @returns {GridCell}
|
||||
*/
|
||||
static fromKey(key) {
|
||||
const parts = key.split(':');
|
||||
if (parts.length !== 4) {
|
||||
throw new Error(`GridCell.fromKey: 无效键格式: ${key}`);
|
||||
}
|
||||
return new GridCell(parts[0], Number(parts[1]), Number(parts[2]), parts[3]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化为 JSON 可存储对象
|
||||
* @returns {object}
|
||||
*/
|
||||
serialize() {
|
||||
return {
|
||||
namespace: this.namespace,
|
||||
grid_x: this.gridX,
|
||||
grid_y: this.gridY,
|
||||
layer: this.layer,
|
||||
key: this.toKey()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从序列化对象还原
|
||||
* @param {object} obj
|
||||
* @returns {GridCell}
|
||||
*/
|
||||
static deserialize(obj) {
|
||||
return new GridCell(obj.namespace, obj.grid_x, obj.grid_y, obj.layer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化为二进制 Buffer(用于页存储)
|
||||
* 格式:[nsLen:2][ns:N][x:8][y:8][layerIdx:1]
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
toBuffer() {
|
||||
const nsBytes = Buffer.from(this.namespace, 'utf-8');
|
||||
const layerIdx = VALID_LAYERS.indexOf(this.layer);
|
||||
// 2 (nsLen) + nsLen + 8 (x float64) + 8 (y float64) + 1 (layerIdx)
|
||||
const buf = Buffer.alloc(2 + nsBytes.length + 8 + 8 + 1);
|
||||
let offset = 0;
|
||||
|
||||
buf.writeUInt16BE(nsBytes.length, offset); offset += 2;
|
||||
nsBytes.copy(buf, offset); offset += nsBytes.length;
|
||||
buf.writeDoubleBE(this.gridX, offset); offset += 8;
|
||||
buf.writeDoubleBE(this.gridY, offset); offset += 8;
|
||||
buf.writeUInt8(layerIdx, offset);
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从二进制 Buffer 还原格点
|
||||
* @param {Buffer} buf
|
||||
* @param {number} [offset]
|
||||
* @returns {{ cell: GridCell, bytesRead: number }}
|
||||
*/
|
||||
static fromBuffer(buf, offset = 0) {
|
||||
const start = offset;
|
||||
const nsLen = buf.readUInt16BE(offset); offset += 2;
|
||||
const namespace = buf.toString('utf-8', offset, offset + nsLen); offset += nsLen;
|
||||
const gridX = buf.readDoubleBE(offset); offset += 8;
|
||||
const gridY = buf.readDoubleBE(offset); offset += 8;
|
||||
const layerIdx = buf.readUInt8(offset); offset += 1;
|
||||
|
||||
return {
|
||||
cell: new GridCell(namespace, gridX, gridY, VALID_LAYERS[layerIdx]),
|
||||
bytesRead: offset - start
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查两个格点是否相等
|
||||
* @param {GridCell} other
|
||||
* @returns {boolean}
|
||||
*/
|
||||
equals(other) {
|
||||
return this.namespace === other.namespace &&
|
||||
this.gridX === other.gridX &&
|
||||
this.gridY === other.gridY &&
|
||||
this.layer === other.layer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算到另一个格点的曼哈顿距离(同 namespace + layer)
|
||||
* @param {GridCell} other
|
||||
* @returns {number}
|
||||
*/
|
||||
distanceTo(other) {
|
||||
return Math.abs(this.gridX - other.gridX) + Math.abs(this.gridY - other.gridY);
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `GridCell(${this.toKey()})`;
|
||||
}
|
||||
}
|
||||
|
||||
GridCell.VALID_LAYERS = VALID_LAYERS;
|
||||
|
||||
module.exports = GridCell;
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
// grid-db/src/events/event-log.js
|
||||
// Grid-DB · 事件溯源日志
|
||||
// 不可变事件流 + 审计日志
|
||||
// PRJ-GDB-001 · Phase 0(基础结构,P1 完善回放与订阅)
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
const { randomUUID } = require('crypto');
|
||||
|
||||
/**
|
||||
* EventLog — 事件溯源日志
|
||||
*
|
||||
* 每次写操作生成不可变 Event,可被天眼订阅实现实时审计。
|
||||
*
|
||||
* 本体论锚定:事件日志 = 笔的笔迹记录。
|
||||
* 每写一笔,笔迹就留在了历史中,不可篡改。
|
||||
* 天眼通过观察笔迹,发现异常行为。
|
||||
*
|
||||
* Phase 0:内存事件流(保留最近 1000 条)
|
||||
* Phase 1:持久化事件流 + 回放 + 订阅
|
||||
*/
|
||||
class EventLog {
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.maxEvents] 最大保留事件数
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
this._maxEvents = options.maxEvents || 1000;
|
||||
this._events = [];
|
||||
this._subscribers = [];
|
||||
this._seqNo = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录事件
|
||||
* @param {string} namespace 命名空间
|
||||
* @param {string} operation 操作类型 (put | delete | scan)
|
||||
* @param {string} gridCellKey 格点键
|
||||
* @param {object} [payload] 额外数据
|
||||
* @returns {object} 事件对象
|
||||
*/
|
||||
append(namespace, operation, gridCellKey, payload = null) {
|
||||
this._seqNo++;
|
||||
const event = {
|
||||
eventId: `evt-${randomUUID().slice(0, 12)}`,
|
||||
seqNo: this._seqNo,
|
||||
timestamp: new Date().toISOString(),
|
||||
namespace,
|
||||
operation,
|
||||
gridCellKey,
|
||||
payload
|
||||
};
|
||||
|
||||
this._events.push(event);
|
||||
|
||||
// 容量控制
|
||||
if (this._events.length > this._maxEvents) {
|
||||
this._events = this._events.slice(-this._maxEvents);
|
||||
}
|
||||
|
||||
// 通知订阅者
|
||||
for (const sub of this._subscribers) {
|
||||
try {
|
||||
sub.handler(event);
|
||||
} catch {
|
||||
// 订阅者错误不阻断主流程
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近 N 条事件
|
||||
* @param {number} [n]
|
||||
* @returns {object[]}
|
||||
*/
|
||||
getRecent(n = 20) {
|
||||
return this._events.slice(-n);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按命名空间过滤事件
|
||||
* @param {string} namespace
|
||||
* @param {number} [limit]
|
||||
* @returns {object[]}
|
||||
*/
|
||||
getByNamespace(namespace, limit = 50) {
|
||||
return this._events
|
||||
.filter(e => e.namespace === namespace)
|
||||
.slice(-limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件流(Phase 0 简版,P1 支持持久订阅)
|
||||
* @param {string} subscriberId
|
||||
* @param {Function} handler
|
||||
* @returns {Function} 取消订阅函数
|
||||
*/
|
||||
subscribe(subscriberId, handler) {
|
||||
const sub = { id: subscriberId, handler };
|
||||
this._subscribers.push(sub);
|
||||
return () => {
|
||||
this._subscribers = this._subscribers.filter(s => s.id !== subscriberId);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件日志状态
|
||||
* @returns {object}
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
totalEvents: this._events.length,
|
||||
maxEvents: this._maxEvents,
|
||||
seqNo: this._seqNo,
|
||||
subscriberCount: this._subscribers.length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EventLog;
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
// grid-db/src/index.js
|
||||
// Grid-DB · 自研逻辑格点数据库 · 主入口
|
||||
// PRJ-GDB-001 · Phase 0
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const GridCell = require('./core/grid-cell');
|
||||
const WAL = require('./storage/wal');
|
||||
const PageManager = require('./storage/page-manager');
|
||||
const EventLog = require('./events/event-log');
|
||||
const GridAPI = require('./api/grid-api');
|
||||
|
||||
/**
|
||||
* Grid-DB 初始化器
|
||||
*
|
||||
* 本体论锚定 [ONT-PATCH-006]:
|
||||
* 笔需要纸来承载笔迹。
|
||||
* Grid-DB = 纸。纸上有格子(格点),每个格子是最小寻址单元。
|
||||
* 笔在格子里写字,格子记住了所有笔迹。
|
||||
* 纸不属于笔,纸属于写字的人。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 打开或创建一个 Grid-DB 实例
|
||||
*
|
||||
* @param {object} [config]
|
||||
* @param {string} [config.dataDir] 数据目录(默认 grid-db/data)
|
||||
* @param {number} [config.pageSize] 页大小(默认 4096)
|
||||
* @returns {GridAPI}
|
||||
*/
|
||||
function open(config = {}) {
|
||||
const dataDir = config.dataDir || path.resolve(__dirname, '../data');
|
||||
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
return new GridAPI({
|
||||
dataDir,
|
||||
pageSize: config.pageSize || PageManager.DEFAULT_PAGE_SIZE
|
||||
});
|
||||
}
|
||||
|
||||
// ── CLI 入口 ──
|
||||
|
||||
if (require.main === module) {
|
||||
const cmd = process.argv[2];
|
||||
|
||||
if (cmd === 'status') {
|
||||
const db = open();
|
||||
const status = db.stats();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify(status, null, 2));
|
||||
db.close();
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log([
|
||||
'🗄️ Grid-DB v0.1.0 · PRJ-GDB-001 · Phase 0',
|
||||
'',
|
||||
'本体论锚定:Grid-DB = 纸 · 格点 = 格子 · WAL = 草稿本',
|
||||
'',
|
||||
'用法:',
|
||||
' node grid-db/src/index.js status 查看数据库状态',
|
||||
'',
|
||||
'组件:',
|
||||
' GridAPI 统一 API 接口',
|
||||
' GridCell 格点数据模型',
|
||||
' WAL Write-Ahead Log',
|
||||
' PageManager 页管理器',
|
||||
' EventLog 事件溯源日志',
|
||||
].join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
open,
|
||||
GridAPI,
|
||||
GridCell,
|
||||
WAL,
|
||||
PageManager,
|
||||
EventLog
|
||||
};
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
// grid-db/src/storage/page-manager.js
|
||||
// Grid-DB · 页管理器
|
||||
// 固定大小页分配、读写与释放
|
||||
// PRJ-GDB-001 · Phase 0 · ZY-GDB-P0-003
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* PageManager — 页管理器
|
||||
*
|
||||
* 以固定大小的"页"为最小 I/O 单位管理磁盘数据。
|
||||
*
|
||||
* 文件结构:
|
||||
* [Header: 1 page] [Page 1] [Page 2] ... [Page N]
|
||||
*
|
||||
* Header 页格式(前 64 字节):
|
||||
* [magic:4][version:2][pageSize:4][pageCount:4][freeListHead:4]...[reserved]
|
||||
*
|
||||
* 数据页格式:
|
||||
* [flags:1][nextFree:4][dataLen:4][data:pageSize-9]
|
||||
* flags: 0x00=free, 0x01=used
|
||||
*
|
||||
* 本体论锚定:页 = 纸的一页。
|
||||
* 格子画在纸上,纸由一页一页组成。页管理器管理每一页的分配和回收。
|
||||
*/
|
||||
|
||||
const MAGIC = 0x47444230; // 'GDB0'
|
||||
const VERSION = 1;
|
||||
const DEFAULT_PAGE_SIZE = 4096;
|
||||
const HEADER_FIXED_SIZE = 64;
|
||||
|
||||
// 页标志
|
||||
const FLAG_FREE = 0x00;
|
||||
const FLAG_USED = 0x01;
|
||||
|
||||
// 页头部大小(flags + nextFree + dataLen)
|
||||
const PAGE_HEADER_SIZE = 9;
|
||||
|
||||
class PageManager {
|
||||
/**
|
||||
* @param {string} filePath 数据文件路径
|
||||
* @param {object} [options]
|
||||
* @param {number} [options.pageSize] 页大小(字节),默认 4096
|
||||
*/
|
||||
constructor(filePath, options = {}) {
|
||||
this._filePath = filePath;
|
||||
this._pageSize = options.pageSize || DEFAULT_PAGE_SIZE;
|
||||
this._pageCount = 0;
|
||||
this._freeListHead = 0; // 0 表示无空闲页
|
||||
this._fd = null;
|
||||
this._init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化:打开或创建数据文件
|
||||
*/
|
||||
_init() {
|
||||
const dir = path.dirname(this._filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(this._filePath)) {
|
||||
this._fd = fs.openSync(this._filePath, 'r+');
|
||||
this._readHeader();
|
||||
} else {
|
||||
this._fd = fs.openSync(this._filePath, 'w+');
|
||||
this._pageCount = 0;
|
||||
this._freeListHead = 0;
|
||||
this._writeHeader();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配一个新页
|
||||
* @returns {number} 页号(从 1 开始,0 是 header)
|
||||
*/
|
||||
allocPage() {
|
||||
let pageId;
|
||||
|
||||
if (this._freeListHead > 0) {
|
||||
// 从空闲链表取一页
|
||||
pageId = this._freeListHead;
|
||||
const pageBuf = this._readPageRaw(pageId);
|
||||
this._freeListHead = pageBuf.readUInt32BE(1); // nextFree
|
||||
this._writeHeader();
|
||||
} else {
|
||||
// 追加新页
|
||||
this._pageCount++;
|
||||
pageId = this._pageCount;
|
||||
|
||||
// 扩展文件
|
||||
const emptyPage = Buffer.alloc(this._pageSize);
|
||||
const fileOffset = this._pageOffset(pageId);
|
||||
fs.writeSync(this._fd, emptyPage, 0, this._pageSize, fileOffset);
|
||||
|
||||
this._writeHeader();
|
||||
}
|
||||
|
||||
// 标记为已使用
|
||||
const pageBuf = Buffer.alloc(this._pageSize);
|
||||
pageBuf.writeUInt8(FLAG_USED, 0);
|
||||
pageBuf.writeUInt32BE(0, 1); // nextFree = 0
|
||||
pageBuf.writeUInt32BE(0, 5); // dataLen = 0
|
||||
fs.writeSync(this._fd, pageBuf, 0, this._pageSize, this._pageOffset(pageId));
|
||||
|
||||
return pageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入数据到指定页
|
||||
* @param {number} pageId 页号
|
||||
* @param {Buffer} data 数据(不能超过 pageSize - PAGE_HEADER_SIZE)
|
||||
*/
|
||||
writePage(pageId, data) {
|
||||
if (pageId < 1 || pageId > this._pageCount) {
|
||||
throw new Error(`PageManager: 无效页号 ${pageId}`);
|
||||
}
|
||||
|
||||
const maxDataSize = this._pageSize - PAGE_HEADER_SIZE;
|
||||
if (data.length > maxDataSize) {
|
||||
throw new Error(`PageManager: 数据太大 (${data.length} > ${maxDataSize})`);
|
||||
}
|
||||
|
||||
const pageBuf = Buffer.alloc(this._pageSize);
|
||||
pageBuf.writeUInt8(FLAG_USED, 0);
|
||||
pageBuf.writeUInt32BE(0, 1); // nextFree
|
||||
pageBuf.writeUInt32BE(data.length, 5); // dataLen
|
||||
data.copy(pageBuf, PAGE_HEADER_SIZE);
|
||||
|
||||
fs.writeSync(this._fd, pageBuf, 0, this._pageSize, this._pageOffset(pageId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取指定页的数据
|
||||
* @param {number} pageId 页号
|
||||
* @returns {Buffer|null} 数据,如果页是空闲的返回 null
|
||||
*/
|
||||
readPage(pageId) {
|
||||
if (pageId < 1 || pageId > this._pageCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pageBuf = this._readPageRaw(pageId);
|
||||
const flags = pageBuf.readUInt8(0);
|
||||
|
||||
if (flags !== FLAG_USED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dataLen = pageBuf.readUInt32BE(5);
|
||||
return Buffer.from(pageBuf.subarray(PAGE_HEADER_SIZE, PAGE_HEADER_SIZE + dataLen));
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放页(加入空闲链表)
|
||||
* @param {number} pageId 页号
|
||||
*/
|
||||
freePage(pageId) {
|
||||
if (pageId < 1 || pageId > this._pageCount) {
|
||||
throw new Error(`PageManager: 无效页号 ${pageId}`);
|
||||
}
|
||||
|
||||
const pageBuf = Buffer.alloc(this._pageSize);
|
||||
pageBuf.writeUInt8(FLAG_FREE, 0);
|
||||
pageBuf.writeUInt32BE(this._freeListHead, 1); // nextFree = 旧链表头
|
||||
pageBuf.writeUInt32BE(0, 5); // dataLen = 0
|
||||
fs.writeSync(this._fd, pageBuf, 0, this._pageSize, this._pageOffset(pageId));
|
||||
|
||||
this._freeListHead = pageId;
|
||||
this._writeHeader();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取页管理器状态
|
||||
* @returns {object}
|
||||
*/
|
||||
getStatus() {
|
||||
let fileSize = 0;
|
||||
try {
|
||||
fileSize = fs.fstatSync(this._fd).size;
|
||||
} catch {
|
||||
// fd 不可用
|
||||
}
|
||||
return {
|
||||
filePath: this._filePath,
|
||||
pageSize: this._pageSize,
|
||||
pageCount: this._pageCount,
|
||||
freeListHead: this._freeListHead,
|
||||
maxDataPerPage: this._pageSize - PAGE_HEADER_SIZE,
|
||||
fileSize
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭数据文件
|
||||
*/
|
||||
close() {
|
||||
if (this._fd !== null) {
|
||||
fs.closeSync(this._fd);
|
||||
this._fd = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 内部方法 ──
|
||||
|
||||
/**
|
||||
* 计算页在文件中的偏移量
|
||||
* @param {number} pageId
|
||||
* @returns {number}
|
||||
*/
|
||||
_pageOffset(pageId) {
|
||||
// 第 0 页是 header,数据页从 1 开始
|
||||
return pageId * this._pageSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入文件头
|
||||
*/
|
||||
_writeHeader() {
|
||||
const headerBuf = Buffer.alloc(this._pageSize);
|
||||
headerBuf.writeUInt32BE(MAGIC, 0);
|
||||
headerBuf.writeUInt16BE(VERSION, 4);
|
||||
headerBuf.writeUInt32BE(this._pageSize, 6);
|
||||
headerBuf.writeUInt32BE(this._pageCount, 10);
|
||||
headerBuf.writeUInt32BE(this._freeListHead, 14);
|
||||
fs.writeSync(this._fd, headerBuf, 0, this._pageSize, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件头
|
||||
*/
|
||||
_readHeader() {
|
||||
const headerBuf = Buffer.alloc(HEADER_FIXED_SIZE);
|
||||
fs.readSync(this._fd, headerBuf, 0, HEADER_FIXED_SIZE, 0);
|
||||
|
||||
const magic = headerBuf.readUInt32BE(0);
|
||||
if (magic !== MAGIC) {
|
||||
throw new Error(`PageManager: 无效文件格式 (magic=0x${magic.toString(16)})`);
|
||||
}
|
||||
|
||||
const version = headerBuf.readUInt16BE(4);
|
||||
if (version !== VERSION) {
|
||||
throw new Error(`PageManager: 不支持的版本 ${version}`);
|
||||
}
|
||||
|
||||
this._pageSize = headerBuf.readUInt32BE(6);
|
||||
this._pageCount = headerBuf.readUInt32BE(10);
|
||||
this._freeListHead = headerBuf.readUInt32BE(14);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取原始页数据
|
||||
* @param {number} pageId
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
_readPageRaw(pageId) {
|
||||
const buf = Buffer.alloc(this._pageSize);
|
||||
fs.readSync(this._fd, buf, 0, this._pageSize, this._pageOffset(pageId));
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
|
||||
PageManager.PAGE_HEADER_SIZE = PAGE_HEADER_SIZE;
|
||||
PageManager.DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE;
|
||||
PageManager.MAGIC = MAGIC;
|
||||
|
||||
module.exports = PageManager;
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
// grid-db/src/storage/wal.js
|
||||
// Grid-DB · Write-Ahead Log
|
||||
// 所有写操作先写 WAL,崩溃恢复保证
|
||||
// PRJ-GDB-001 · Phase 0 · ZY-GDB-P0-002
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* WAL(Write-Ahead Log)
|
||||
*
|
||||
* 所有写操作先追加到 WAL 文件,确保崩溃后可从 WAL 恢复。
|
||||
*
|
||||
* 日志格式(每条记录):
|
||||
* [length:4][seqNo:4][op:1][keyLen:2][key:N][dataLen:4][data:M][checksum:4]
|
||||
*
|
||||
* 操作码:
|
||||
* 0x01 = PUT
|
||||
* 0x02 = DELETE
|
||||
*
|
||||
* 本体论锚定:WAL = 笔的草稿本。
|
||||
* 正式写到纸上之前,先在草稿本上记一笔。
|
||||
* 万一出了意外,草稿本里有记录,可以恢复。
|
||||
*/
|
||||
|
||||
const OP_PUT = 0x01;
|
||||
const OP_DELETE = 0x02;
|
||||
|
||||
class WAL {
|
||||
/**
|
||||
* @param {string} walPath WAL 文件路径
|
||||
*/
|
||||
constructor(walPath) {
|
||||
this._path = walPath;
|
||||
this._seqNo = 0;
|
||||
this._fd = null;
|
||||
this._init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 WAL 文件
|
||||
*/
|
||||
_init() {
|
||||
const dir = path.dirname(this._path);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(this._path)) {
|
||||
// 从现有 WAL 文件中恢复最大序列号
|
||||
const entries = this._readAll();
|
||||
if (entries.length > 0) {
|
||||
this._seqNo = entries[entries.length - 1].seqNo;
|
||||
}
|
||||
}
|
||||
|
||||
// 追加模式打开
|
||||
this._fd = fs.openSync(this._path, 'a');
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加一条 PUT 记录
|
||||
* @param {string} key 格点键
|
||||
* @param {Buffer} data 数据
|
||||
* @returns {number} 序列号
|
||||
*/
|
||||
appendPut(key, data) {
|
||||
return this._append(OP_PUT, key, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加一条 DELETE 记录
|
||||
* @param {string} key 格点键
|
||||
* @returns {number} 序列号
|
||||
*/
|
||||
appendDelete(key) {
|
||||
return this._append(OP_DELETE, key, Buffer.alloc(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部:追加记录
|
||||
* @param {number} op 操作码
|
||||
* @param {string} key 键
|
||||
* @param {Buffer} data 数据
|
||||
* @returns {number} 序列号
|
||||
*/
|
||||
_append(op, key, data) {
|
||||
this._seqNo++;
|
||||
const keyBuf = Buffer.from(key, 'utf-8');
|
||||
const dataBuf = data instanceof Buffer ? data : Buffer.from(JSON.stringify(data));
|
||||
|
||||
// 构建记录
|
||||
// [length:4][seqNo:4][op:1][keyLen:2][key:N][dataLen:4][data:M][checksum:4]
|
||||
const recordLen = 4 + 1 + 2 + keyBuf.length + 4 + dataBuf.length + 4;
|
||||
const buf = Buffer.alloc(4 + recordLen);
|
||||
let offset = 0;
|
||||
|
||||
buf.writeUInt32BE(recordLen, offset); offset += 4; // length
|
||||
buf.writeUInt32BE(this._seqNo, offset); offset += 4; // seqNo
|
||||
buf.writeUInt8(op, offset); offset += 1; // op
|
||||
buf.writeUInt16BE(keyBuf.length, offset); offset += 2; // keyLen
|
||||
keyBuf.copy(buf, offset); offset += keyBuf.length; // key
|
||||
buf.writeUInt32BE(dataBuf.length, offset); offset += 4; // dataLen
|
||||
dataBuf.copy(buf, offset); offset += dataBuf.length; // data
|
||||
|
||||
// 计算简单校验和(所有字节异或)
|
||||
let checksum = 0;
|
||||
for (let i = 0; i < offset; i++) {
|
||||
checksum = (checksum ^ buf[i]) & 0xFFFFFFFF;
|
||||
}
|
||||
buf.writeUInt32BE(checksum >>> 0, offset);
|
||||
|
||||
fs.writeSync(this._fd, buf);
|
||||
fs.fsyncSync(this._fd);
|
||||
|
||||
return this._seqNo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取所有 WAL 条目(用于崩溃恢复)
|
||||
* @returns {Array<{ seqNo: number, op: number, key: string, data: Buffer }>}
|
||||
*/
|
||||
recover() {
|
||||
return this._readAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部:读取 WAL 文件的所有记录
|
||||
* @returns {Array<{ seqNo: number, op: number, key: string, data: Buffer }>}
|
||||
*/
|
||||
_readAll() {
|
||||
if (!fs.existsSync(this._path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fileBuf = fs.readFileSync(this._path);
|
||||
const entries = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < fileBuf.length) {
|
||||
// 至少需要 4 字节读长度
|
||||
if (offset + 4 > fileBuf.length) break;
|
||||
|
||||
const recordLen = fileBuf.readUInt32BE(offset);
|
||||
const totalLen = 4 + recordLen;
|
||||
|
||||
// 完整性检查
|
||||
if (offset + totalLen > fileBuf.length) break;
|
||||
|
||||
const recordStart = offset + 4;
|
||||
let rOffset = recordStart;
|
||||
|
||||
const seqNo = fileBuf.readUInt32BE(rOffset); rOffset += 4;
|
||||
const op = fileBuf.readUInt8(rOffset); rOffset += 1;
|
||||
const keyLen = fileBuf.readUInt16BE(rOffset); rOffset += 2;
|
||||
const key = fileBuf.toString('utf-8', rOffset, rOffset + keyLen); rOffset += keyLen;
|
||||
const dataLen = fileBuf.readUInt32BE(rOffset); rOffset += 4;
|
||||
const data = fileBuf.subarray(rOffset, rOffset + dataLen); rOffset += dataLen;
|
||||
const storedChecksum = fileBuf.readUInt32BE(rOffset);
|
||||
|
||||
// 校验
|
||||
let checksum = 0;
|
||||
for (let i = offset; i < rOffset; i++) {
|
||||
checksum = (checksum ^ fileBuf[i]) & 0xFFFFFFFF;
|
||||
}
|
||||
|
||||
if ((checksum >>> 0) === storedChecksum) {
|
||||
entries.push({ seqNo, op, key, data: Buffer.from(data) });
|
||||
}
|
||||
// 校验失败的记录跳过(部分写入的残余)
|
||||
|
||||
offset += totalLen;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断 WAL(在 checkpoint 后调用,清除已持久化的记录)
|
||||
*/
|
||||
truncate() {
|
||||
this.close();
|
||||
fs.writeFileSync(this._path, Buffer.alloc(0));
|
||||
this._seqNo = 0;
|
||||
this._fd = fs.openSync(this._path, 'a');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前序列号
|
||||
* @returns {number}
|
||||
*/
|
||||
getSeqNo() {
|
||||
return this._seqNo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 WAL 文件
|
||||
*/
|
||||
close() {
|
||||
if (this._fd !== null) {
|
||||
fs.closeSync(this._fd);
|
||||
this._fd = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WAL 状态
|
||||
* @returns {object}
|
||||
*/
|
||||
getStatus() {
|
||||
let fileSize = 0;
|
||||
try {
|
||||
fileSize = fs.statSync(this._path).size;
|
||||
} catch {
|
||||
// 文件不存在
|
||||
}
|
||||
return {
|
||||
path: this._path,
|
||||
seqNo: this._seqNo,
|
||||
fileSize
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
WAL.OP_PUT = OP_PUT;
|
||||
WAL.OP_DELETE = OP_DELETE;
|
||||
|
||||
module.exports = WAL;
|
||||
|
|
@ -0,0 +1,420 @@
|
|||
// grid-db/tests/smoke/grid-db.test.js
|
||||
// Grid-DB · 冒烟测试
|
||||
// PRJ-GDB-001 · Phase 0 · ZY-GDB-P0-005
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const {
|
||||
open,
|
||||
GridAPI,
|
||||
GridCell,
|
||||
WAL,
|
||||
PageManager,
|
||||
EventLog
|
||||
} = require('../../src/index');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (condition) {
|
||||
passed++;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` ✅ ${message}`);
|
||||
} else {
|
||||
failed++;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(` ❌ ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建临时测试目录
|
||||
* @param {string} suffix
|
||||
* @returns {string}
|
||||
*/
|
||||
function makeTempDir(suffix) {
|
||||
const { randomUUID } = require('crypto');
|
||||
const dir = path.join(os.tmpdir(), `griddb-test-${suffix}-${randomUUID().slice(0, 8)}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归删除目录
|
||||
* @param {string} dir
|
||||
*/
|
||||
function cleanDir(dir) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// 忽略清理错误
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('🗄️ Grid-DB 冒烟测试 · Phase 0\n');
|
||||
|
||||
// ── 测试 1: 模块导出 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('── 测试 1: 模块导出完整性 ──');
|
||||
assert(typeof open === 'function', 'open 是函数');
|
||||
assert(typeof GridAPI === 'function', 'GridAPI 是构造函数');
|
||||
assert(typeof GridCell === 'function', 'GridCell 是构造函数');
|
||||
assert(typeof WAL === 'function', 'WAL 是构造函数');
|
||||
assert(typeof PageManager === 'function', 'PageManager 是构造函数');
|
||||
assert(typeof EventLog === 'function', 'EventLog 是构造函数');
|
||||
|
||||
// ── 测试 2: GridCell 四元组寻址 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 2: GridCell 四元组寻址 ──');
|
||||
const cell1 = new GridCell('agent-zy', 10, 20, 'raw');
|
||||
assert(cell1.namespace === 'agent-zy', 'namespace 正确');
|
||||
assert(cell1.gridX === 10, 'gridX 正确');
|
||||
assert(cell1.gridY === 20, 'gridY 正确');
|
||||
assert(cell1.layer === 'raw', 'layer 正确');
|
||||
assert(cell1.toKey() === 'agent-zy:10:20:raw', 'toKey() 正确');
|
||||
|
||||
// fromKey 还原
|
||||
const cell2 = GridCell.fromKey('test-ns:5:15:semantic');
|
||||
assert(cell2.namespace === 'test-ns', 'fromKey namespace 正确');
|
||||
assert(cell2.gridX === 5, 'fromKey gridX 正确');
|
||||
assert(cell2.gridY === 15, 'fromKey gridY 正确');
|
||||
assert(cell2.layer === 'semantic', 'fromKey layer 正确');
|
||||
|
||||
// 序列化 / 反序列化
|
||||
const serialized = cell1.serialize();
|
||||
assert(serialized.namespace === 'agent-zy', 'serialize namespace');
|
||||
assert(serialized.grid_x === 10, 'serialize grid_x');
|
||||
assert(serialized.grid_y === 20, 'serialize grid_y');
|
||||
assert(serialized.layer === 'raw', 'serialize layer');
|
||||
|
||||
const cell3 = GridCell.deserialize(serialized);
|
||||
assert(cell3.equals(cell1), 'deserialize → equals 原始格点');
|
||||
|
||||
// 二进制序列化
|
||||
const buf = cell1.toBuffer();
|
||||
assert(buf instanceof Buffer, 'toBuffer 返回 Buffer');
|
||||
const { cell: cell4, bytesRead } = GridCell.fromBuffer(buf);
|
||||
assert(cell4.equals(cell1), 'fromBuffer → equals 原始格点');
|
||||
assert(bytesRead === buf.length, 'bytesRead 等于 buffer 长度');
|
||||
|
||||
// 距离计算
|
||||
assert(cell1.distanceTo(cell2) === 10, 'distanceTo 曼哈顿距离正确');
|
||||
|
||||
// 无效参数
|
||||
let errThrown = false;
|
||||
try { new GridCell('', 0, 0); } catch { errThrown = true; }
|
||||
assert(errThrown, '空 namespace 抛出错误');
|
||||
|
||||
errThrown = false;
|
||||
try { new GridCell('ns', 0, 0, 'invalid'); } catch { errThrown = true; }
|
||||
assert(errThrown, '无效 layer 抛出错误');
|
||||
|
||||
// ── 测试 3: WAL 基础功能 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 3: WAL 基础功能 ──');
|
||||
const walDir = makeTempDir('wal');
|
||||
const wal = new WAL(path.join(walDir, 'test.wal'));
|
||||
|
||||
const seq1 = wal.appendPut('ns:1:2:raw', Buffer.from('{"hello":"world"}'));
|
||||
assert(seq1 === 1, 'appendPut 返回序列号 1');
|
||||
|
||||
const seq2 = wal.appendPut('ns:3:4:raw', Buffer.from('{"foo":"bar"}'));
|
||||
assert(seq2 === 2, 'appendPut 返回序列号 2');
|
||||
|
||||
const seq3 = wal.appendDelete('ns:1:2:raw');
|
||||
assert(seq3 === 3, 'appendDelete 返回序列号 3');
|
||||
|
||||
assert(wal.getSeqNo() === 3, 'getSeqNo 正确');
|
||||
|
||||
// 恢复
|
||||
const entries = wal.recover();
|
||||
assert(entries.length === 3, '恢复 3 条记录');
|
||||
assert(entries[0].op === WAL.OP_PUT, '记录 1 是 PUT');
|
||||
assert(entries[0].key === 'ns:1:2:raw', '记录 1 键正确');
|
||||
assert(entries[1].op === WAL.OP_PUT, '记录 2 是 PUT');
|
||||
assert(entries[2].op === WAL.OP_DELETE, '记录 3 是 DELETE');
|
||||
|
||||
// 截断
|
||||
wal.truncate();
|
||||
assert(wal.getSeqNo() === 0, '截断后序列号归零');
|
||||
const afterTrunc = wal.recover();
|
||||
assert(afterTrunc.length === 0, '截断后无记录');
|
||||
|
||||
wal.close();
|
||||
|
||||
// 崩溃恢复测试:写入 → 关闭 → 重新打开 → 验证
|
||||
const wal2 = new WAL(path.join(walDir, 'crash.wal'));
|
||||
wal2.appendPut('crash:1:1:raw', Buffer.from('{"data":"survive"}'));
|
||||
wal2.appendPut('crash:2:2:raw', Buffer.from('{"data":"also"}'));
|
||||
wal2.close();
|
||||
|
||||
const wal3 = new WAL(path.join(walDir, 'crash.wal'));
|
||||
const recovered = wal3.recover();
|
||||
assert(recovered.length === 2, '崩溃恢复:2 条记录');
|
||||
assert(recovered[0].key === 'crash:1:1:raw', '崩溃恢复:键正确');
|
||||
assert(wal3.getSeqNo() === 2, '崩溃恢复:序列号正确');
|
||||
wal3.close();
|
||||
cleanDir(walDir);
|
||||
|
||||
// ── 测试 4: PageManager 基础功能 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 4: PageManager 基础功能 ──');
|
||||
const pmDir = makeTempDir('pm');
|
||||
const pm = new PageManager(path.join(pmDir, 'test.gdb'));
|
||||
|
||||
// 分配页
|
||||
const p1 = pm.allocPage();
|
||||
assert(p1 === 1, '首页号 = 1');
|
||||
|
||||
const p2 = pm.allocPage();
|
||||
assert(p2 === 2, '第二页号 = 2');
|
||||
|
||||
// 写入数据
|
||||
const testData = Buffer.from('Hello Grid-DB!');
|
||||
pm.writePage(p1, testData);
|
||||
|
||||
// 读取数据
|
||||
const readData = pm.readPage(p1);
|
||||
assert(readData !== null, '读取非 null');
|
||||
assert(readData.toString() === 'Hello Grid-DB!', '读取数据正确');
|
||||
|
||||
// 写入更长数据
|
||||
const longData = Buffer.from(JSON.stringify({ msg: '格点数据库测试', num: 42, arr: [1, 2, 3] }));
|
||||
pm.writePage(p2, longData);
|
||||
const readLong = pm.readPage(p2);
|
||||
assert(readLong !== null, '读取长数据非 null');
|
||||
assert(JSON.parse(readLong.toString()).num === 42, '长数据内容正确');
|
||||
|
||||
// 释放页
|
||||
pm.freePage(p1);
|
||||
const freedData = pm.readPage(p1);
|
||||
assert(freedData === null, '释放后读取返回 null');
|
||||
|
||||
// 释放的页被复用
|
||||
const p3 = pm.allocPage();
|
||||
assert(p3 === 1, '释放的页号被复用');
|
||||
|
||||
// 状态
|
||||
const pmStatus = pm.getStatus();
|
||||
assert(pmStatus.pageSize === 4096, '页大小 = 4096');
|
||||
assert(pmStatus.pageCount === 2, '页数 = 2');
|
||||
|
||||
pm.close();
|
||||
|
||||
// 重新打开验证持久化
|
||||
const pm2 = new PageManager(path.join(pmDir, 'test.gdb'));
|
||||
const rereadLong = pm2.readPage(2);
|
||||
assert(rereadLong !== null, '重新打开后读取非 null');
|
||||
assert(JSON.parse(rereadLong.toString()).num === 42, '持久化数据正确');
|
||||
pm2.close();
|
||||
cleanDir(pmDir);
|
||||
|
||||
// ── 测试 5: EventLog 基础功能 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 5: EventLog 基础功能 ──');
|
||||
const eventLog = new EventLog({ maxEvents: 5 });
|
||||
|
||||
const evt1 = eventLog.append('ns1', 'put', 'ns1:1:1:raw', { size: 100 });
|
||||
assert(evt1.eventId.startsWith('evt-'), '事件 ID 前缀正确');
|
||||
assert(evt1.seqNo === 1, '事件序号 = 1');
|
||||
assert(evt1.namespace === 'ns1', '事件命名空间正确');
|
||||
|
||||
eventLog.append('ns1', 'put', 'ns1:2:2:raw');
|
||||
eventLog.append('ns2', 'delete', 'ns2:3:3:raw');
|
||||
|
||||
const recent = eventLog.getRecent(10);
|
||||
assert(recent.length === 3, '最近事件 = 3');
|
||||
|
||||
const ns1Events = eventLog.getByNamespace('ns1');
|
||||
assert(ns1Events.length === 2, 'ns1 事件 = 2');
|
||||
|
||||
// 订阅测试
|
||||
let received = null;
|
||||
const unsub = eventLog.subscribe('test-sub', (evt) => { received = evt; });
|
||||
eventLog.append('ns1', 'put', 'ns1:4:4:raw');
|
||||
assert(received !== null, '订阅回调收到事件');
|
||||
assert(received.operation === 'put', '订阅事件操作正确');
|
||||
|
||||
unsub();
|
||||
received = null;
|
||||
eventLog.append('ns1', 'put', 'ns1:5:5:raw');
|
||||
assert(received === null, '取消订阅后不再收到事件');
|
||||
|
||||
// 容量控制
|
||||
eventLog.append('ns1', 'put', 'ns1:6:6:raw');
|
||||
eventLog.append('ns1', 'put', 'ns1:7:7:raw');
|
||||
const status = eventLog.getStatus();
|
||||
assert(status.totalEvents === 5, '容量控制:最多保留 5 条');
|
||||
|
||||
// ── 测试 6: GridAPI · put → get → delete 全链路 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 6: GridAPI · put → get → delete 全链路 ──');
|
||||
const apiDir = makeTempDir('api');
|
||||
const db = open({ dataDir: apiDir });
|
||||
|
||||
// put
|
||||
const putResult = db.put('exe-engine', { gridX: 1, gridY: 2, layer: 'raw' }, {
|
||||
agentId: 'AG-ZY-01',
|
||||
context: { repo: 'guanghulab', branch: 'main' },
|
||||
timestamp: '2026-03-26T13:00:00Z'
|
||||
});
|
||||
assert(putResult.key === 'exe-engine:1:2:raw', 'put 返回正确 key');
|
||||
assert(putResult.seqNo === 1, 'put 返回序列号 1');
|
||||
assert(putResult.event.operation === 'put', 'put 生成事件');
|
||||
|
||||
// get
|
||||
const getData = db.get('exe-engine', { gridX: 1, gridY: 2, layer: 'raw' });
|
||||
assert(getData !== null, 'get 返回数据');
|
||||
assert(getData.agentId === 'AG-ZY-01', 'get 数据内容正确');
|
||||
assert(getData.context.repo === 'guanghulab', 'get 嵌套数据正确');
|
||||
|
||||
// get 不存在的格点
|
||||
const noData = db.get('exe-engine', { gridX: 99, gridY: 99, layer: 'raw' });
|
||||
assert(noData === null, 'get 不存在的格点返回 null');
|
||||
|
||||
// 更新
|
||||
db.put('exe-engine', { gridX: 1, gridY: 2, layer: 'raw' }, {
|
||||
agentId: 'AG-ZY-01',
|
||||
context: { repo: 'guanghulab', branch: 'feature' },
|
||||
timestamp: '2026-03-26T14:00:00Z'
|
||||
});
|
||||
const updatedData = db.get('exe-engine', { gridX: 1, gridY: 2, layer: 'raw' });
|
||||
assert(updatedData.context.branch === 'feature', '更新后数据正确');
|
||||
|
||||
// 多命名空间隔离
|
||||
db.put('dc-v1', { gridX: 1, gridY: 2, layer: 'raw' }, { source: 'notion' });
|
||||
const dcData = db.get('dc-v1', { gridX: 1, gridY: 2, layer: 'raw' });
|
||||
assert(dcData.source === 'notion', '不同命名空间数据隔离');
|
||||
|
||||
const exeData = db.get('exe-engine', { gridX: 1, gridY: 2, layer: 'raw' });
|
||||
assert(exeData.agentId === 'AG-ZY-01', '原命名空间数据不受影响');
|
||||
|
||||
// delete
|
||||
const deleted = db.delete('exe-engine', { gridX: 1, gridY: 2, layer: 'raw' });
|
||||
assert(deleted === true, 'delete 返回 true');
|
||||
|
||||
const afterDelete = db.get('exe-engine', { gridX: 1, gridY: 2, layer: 'raw' });
|
||||
assert(afterDelete === null, '删除后 get 返回 null');
|
||||
|
||||
// delete 不存在的
|
||||
const deleteMissing = db.delete('exe-engine', { gridX: 99, gridY: 99, layer: 'raw' });
|
||||
assert(deleteMissing === false, 'delete 不存在的格点返回 false');
|
||||
|
||||
// ── 测试 7: 范围扫描 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 7: 范围扫描 ──');
|
||||
db.put('scan-ns', { gridX: 1, gridY: 1, layer: 'raw' }, { id: 'a' });
|
||||
db.put('scan-ns', { gridX: 2, gridY: 2, layer: 'raw' }, { id: 'b' });
|
||||
db.put('scan-ns', { gridX: 3, gridY: 3, layer: 'raw' }, { id: 'c' });
|
||||
db.put('scan-ns', { gridX: 5, gridY: 5, layer: 'indexed' }, { id: 'd' });
|
||||
|
||||
const scanAll = db.scan('scan-ns');
|
||||
assert(scanAll.length === 4, '扫描全部 = 4');
|
||||
|
||||
const scanRange = db.scan('scan-ns', { xRange: [1, 3], yRange: [1, 3] });
|
||||
assert(scanRange.length === 3, '范围扫描 [1,3] = 3 条');
|
||||
|
||||
const scanLayer = db.scan('scan-ns', { layer: 'indexed' });
|
||||
assert(scanLayer.length === 1, '按 layer 过滤 = 1 条');
|
||||
assert(scanLayer[0].data.id === 'd', '过滤结果正确');
|
||||
|
||||
// ── 测试 8: stats ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 8: stats 统计 ──');
|
||||
const dbStats = db.stats('scan-ns');
|
||||
assert(dbStats.namespace === 'scan-ns', 'stats 命名空间正确');
|
||||
assert(dbStats.cellCount === 4, 'scan-ns 格点数 = 4');
|
||||
|
||||
const allStats = db.stats();
|
||||
assert(allStats.namespace === '*', 'stats 全局');
|
||||
assert(allStats.cellCount >= 5, '全局格点数 >= 5');
|
||||
assert(allStats.wal !== undefined, 'stats 含 wal');
|
||||
assert(allStats.pageManager !== undefined, 'stats 含 pageManager');
|
||||
assert(allStats.eventLog !== undefined, 'stats 含 eventLog');
|
||||
|
||||
// ── 测试 9: checkpoint ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 9: checkpoint ──');
|
||||
db.checkpoint();
|
||||
const afterCheckpoint = db.stats();
|
||||
assert(afterCheckpoint.wal.seqNo === 0, 'checkpoint 后 WAL seqNo = 0');
|
||||
|
||||
// 数据仍然可读
|
||||
const postCheckpoint = db.get('dc-v1', { gridX: 1, gridY: 2, layer: 'raw' });
|
||||
assert(postCheckpoint !== null, 'checkpoint 后数据仍可读');
|
||||
assert(postCheckpoint.source === 'notion', 'checkpoint 后数据正确');
|
||||
|
||||
// ── 测试 10: 事件订阅 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 10: 事件订阅 ──');
|
||||
let subscribedEvent = null;
|
||||
const unsubscribe = db.subscribe('test-audit', (evt) => {
|
||||
subscribedEvent = evt;
|
||||
});
|
||||
|
||||
db.put('audit-ns', { gridX: 0, gridY: 0, layer: 'raw' }, { audit: true });
|
||||
assert(subscribedEvent !== null, '事件订阅收到事件');
|
||||
assert(subscribedEvent.namespace === 'audit-ns', '订阅事件命名空间正确');
|
||||
|
||||
unsubscribe();
|
||||
subscribedEvent = null;
|
||||
db.put('audit-ns', { gridX: 1, gridY: 1, layer: 'raw' }, { audit: false });
|
||||
assert(subscribedEvent === null, '取消订阅后不再收到');
|
||||
|
||||
db.close();
|
||||
cleanDir(apiDir);
|
||||
|
||||
// ── 测试 11: open() 便捷函数 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 11: open() 便捷函数 ──');
|
||||
const openDir = makeTempDir('open');
|
||||
const db2 = open({ dataDir: openDir });
|
||||
assert(db2 instanceof GridAPI, 'open 返回 GridAPI 实例');
|
||||
|
||||
db2.put('test', { gridX: 0, gridY: 0, layer: 'raw' }, 'hello');
|
||||
const result = db2.get('test', { gridX: 0, gridY: 0, layer: 'raw' });
|
||||
assert(result === 'hello', 'open → put → get 正确');
|
||||
|
||||
db2.close();
|
||||
cleanDir(openDir);
|
||||
|
||||
// ── 测试 12: GridCell 使用 GridCell 对象调用 API ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n── 测试 12: GridCell 对象直接调用 API ──');
|
||||
const gcDir = makeTempDir('gc');
|
||||
const db3 = open({ dataDir: gcDir });
|
||||
|
||||
const myCell = new GridCell('my-ns', 42, 84, 'cleaned');
|
||||
db3.put('my-ns', myCell, { value: 'direct cell' });
|
||||
|
||||
const directResult = db3.get('my-ns', myCell);
|
||||
assert(directResult !== null, 'GridCell 对象直接调用 get');
|
||||
assert(directResult.value === 'direct cell', '数据正确');
|
||||
|
||||
db3.close();
|
||||
cleanDir(gcDir);
|
||||
|
||||
// ── 测试结果汇总 ──
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('\n══════════════════════════════════════');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('🗄️ Grid-DB 冒烟测试完成');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` ✅ 通过: ${passed}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` ❌ 失败: ${failed}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` 📊 总计: ${passed + failed}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('══════════════════════════════════════\n');
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -5,7 +5,11 @@
|
|||
"main": "broadcast-generator.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "node server.js"
|
||||
"test:contract": "node scripts/hli-contract-check.js",
|
||||
"test:route-align": "node scripts/route-align-check.js",
|
||||
"start": "node server.js",
|
||||
"start:test": "node src/index.js",
|
||||
"test:smoke": "node tests/smoke/hli-smoke.test.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env node
|
||||
// scripts/hli-contract-check.js
|
||||
// HLI 接口契约校验 — 验证所有 schema 文件结构完整性
|
||||
// 版权:国作登字-2026-A-00037559
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SCHEMA_DIR = path.resolve(__dirname, '../src/schemas/hli');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function check(condition, message) {
|
||||
if (condition) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
console.error(` ❌ ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔍 HLI 接口契约校验\n');
|
||||
|
||||
// 扫描所有域目录
|
||||
const domains = fs.readdirSync(SCHEMA_DIR, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name);
|
||||
|
||||
for (const domain of domains) {
|
||||
const domainDir = path.join(SCHEMA_DIR, domain);
|
||||
const schemaFiles = fs.readdirSync(domainDir).filter(f => f.endsWith('.schema.json'));
|
||||
|
||||
for (const file of schemaFiles) {
|
||||
const filePath = path.join(domainDir, file);
|
||||
const relPath = `src/schemas/hli/${domain}/${file}`;
|
||||
|
||||
let schema;
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
schema = JSON.parse(content);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.error(` ❌ ${relPath}: JSON 解析失败 — ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` 📋 ${relPath}`);
|
||||
|
||||
// 必须包含 hli_id
|
||||
check(
|
||||
typeof schema.hli_id === 'string' && schema.hli_id.startsWith('HLI-'),
|
||||
`${relPath}: 缺少有效的 hli_id`
|
||||
);
|
||||
|
||||
// route 和 method:如存在则校验格式
|
||||
if (schema.route !== undefined) {
|
||||
check(
|
||||
typeof schema.route === 'string' && schema.route.startsWith('/hli/'),
|
||||
`${relPath}: route 格式无效(应以 /hli/ 开头)`
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.method !== undefined) {
|
||||
check(
|
||||
typeof schema.method === 'string',
|
||||
`${relPath}: method 格式无效`
|
||||
);
|
||||
}
|
||||
|
||||
// 必须包含 input 和 output
|
||||
check(
|
||||
schema.input !== undefined,
|
||||
`${relPath}: 缺少 input 定义`
|
||||
);
|
||||
|
||||
check(
|
||||
schema.output !== undefined,
|
||||
`${relPath}: 缺少 output 定义`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n📊 契约校验: ${passed} 通过, ${failed} 失败`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.error('❌ HLI Contract Check FAILED');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('✅ HLI Contract Check PASSED');
|
||||
}
|
||||
|
|
@ -55,4 +55,7 @@ Object.entries(HLI_ROUTES).forEach(([hliId, route]) => {
|
|||
console.log(`\n\n📊 覆盖率: ${implemented} / ${implemented + missing}`);
|
||||
if (missing === 0) {
|
||||
console.log('✅ Route Alignment PASSED - 所有 HLI 接口均已实现');
|
||||
} else {
|
||||
console.error(`❌ Route Alignment FAILED - ${missing} 个 HLI 接口未实现`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"hli_id": "HLI-DASHBOARD-002",
|
||||
"version": "v0.1-stub",
|
||||
"route": "/hli/dashboard/realtime",
|
||||
"method": "GET",
|
||||
"type": "STREAM",
|
||||
"status": "stub",
|
||||
"target_phase": "P3",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "enum": ["not_implemented"] },
|
||||
"phase": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"changelog": [
|
||||
{ "date": "2026-03-26", "note": "501 stub — Phase 0 占位,P3 实现" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"hli_id": "HLI-DASHBOARD-001",
|
||||
"version": "v0.1-stub",
|
||||
"route": "/hli/dashboard/status",
|
||||
"method": "GET",
|
||||
"type": "REQUEST",
|
||||
"status": "stub",
|
||||
"target_phase": "P3",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "enum": ["not_implemented"] },
|
||||
"phase": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"changelog": [
|
||||
{ "date": "2026-03-26", "note": "501 stub — Phase 0 占位,P3 实现" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"hli_id": "HLI-DIALOGUE-003",
|
||||
"version": "v0.1-stub",
|
||||
"route": "/hli/dialogue/history",
|
||||
"method": "GET",
|
||||
"type": "REQUEST",
|
||||
"status": "stub",
|
||||
"target_phase": "P1",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": { "type": "string" },
|
||||
"limit": { "type": "number" }
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "enum": ["not_implemented"] },
|
||||
"phase": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"changelog": [
|
||||
{ "date": "2026-03-26", "note": "501 stub — Phase 0 占位,P1 实现" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"hli_id": "HLI-DIALOGUE-001",
|
||||
"version": "v0.1-stub",
|
||||
"route": "/hli/dialogue/send",
|
||||
"method": "POST",
|
||||
"type": "REQUEST",
|
||||
"status": "stub",
|
||||
"target_phase": "P1",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"required": ["message"],
|
||||
"properties": {
|
||||
"message": { "type": "string" },
|
||||
"persona_id": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "enum": ["not_implemented"] },
|
||||
"phase": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"changelog": [
|
||||
{ "date": "2026-03-26", "note": "501 stub — Phase 0 占位,P1 实现" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"hli_id": "HLI-DIALOGUE-002",
|
||||
"version": "v0.1-stub",
|
||||
"route": "/hli/dialogue/stream",
|
||||
"method": "POST",
|
||||
"type": "STREAM",
|
||||
"status": "stub",
|
||||
"target_phase": "P1",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"required": ["message"],
|
||||
"properties": {
|
||||
"message": { "type": "string" },
|
||||
"persona_id": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "enum": ["not_implemented"] },
|
||||
"phase": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"changelog": [
|
||||
{ "date": "2026-03-26", "note": "501 stub — Phase 0 占位,P1 实现" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"hli_id": "HLI-PERSONA-001",
|
||||
"version": "v0.1-stub",
|
||||
"route": "/hli/persona/load",
|
||||
"method": "POST",
|
||||
"type": "REQUEST",
|
||||
"status": "stub",
|
||||
"target_phase": "P1",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"required": ["persona_id"],
|
||||
"properties": {
|
||||
"persona_id": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "enum": ["not_implemented"] },
|
||||
"phase": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"changelog": [
|
||||
{ "date": "2026-03-26", "note": "501 stub — Phase 0 占位,P1 实现" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"hli_id": "HLI-PERSONA-002",
|
||||
"version": "v0.1-stub",
|
||||
"route": "/hli/persona/switch",
|
||||
"method": "POST",
|
||||
"type": "REQUEST",
|
||||
"status": "stub",
|
||||
"target_phase": "P1",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"required": ["persona_id"],
|
||||
"properties": {
|
||||
"persona_id": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "enum": ["not_implemented"] },
|
||||
"phase": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"changelog": [
|
||||
{ "date": "2026-03-26", "note": "501 stub — Phase 0 占位,P1 实现" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"hli_id": "HLI-STORAGE-002",
|
||||
"version": "v0.1-stub",
|
||||
"route": "/hli/storage/download",
|
||||
"method": "GET",
|
||||
"type": "REQUEST",
|
||||
"status": "stub",
|
||||
"target_phase": "P2",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"required": ["file_id"],
|
||||
"properties": {
|
||||
"file_id": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "enum": ["not_implemented"] },
|
||||
"phase": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"changelog": [
|
||||
{ "date": "2026-03-26", "note": "501 stub — Phase 0 占位,P2 实现" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"hli_id": "HLI-STORAGE-001",
|
||||
"version": "v0.1-stub",
|
||||
"route": "/hli/storage/upload",
|
||||
"method": "POST",
|
||||
"type": "REQUEST",
|
||||
"status": "stub",
|
||||
"target_phase": "P2",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"required": ["filename"],
|
||||
"properties": {
|
||||
"filename": { "type": "string" },
|
||||
"content_type": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "enum": ["not_implemented"] },
|
||||
"phase": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"changelog": [
|
||||
{ "date": "2026-03-26", "note": "501 stub — Phase 0 占位,P2 实现" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"hli_id": "HLI-TICKET-001",
|
||||
"version": "v0.1-stub",
|
||||
"route": "/hli/ticket/create",
|
||||
"method": "POST",
|
||||
"type": "REQUEST",
|
||||
"status": "stub",
|
||||
"target_phase": "P2",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"required": ["title"],
|
||||
"properties": {
|
||||
"title": { "type": "string" },
|
||||
"description": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "enum": ["not_implemented"] },
|
||||
"phase": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"changelog": [
|
||||
{ "date": "2026-03-26", "note": "501 stub — Phase 0 占位,P2 实现" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"hli_id": "HLI-TICKET-002",
|
||||
"version": "v0.1-stub",
|
||||
"route": "/hli/ticket/query",
|
||||
"method": "GET",
|
||||
"type": "REQUEST",
|
||||
"status": "stub",
|
||||
"target_phase": "P2",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ticket_id": { "type": "string" },
|
||||
"status": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "enum": ["not_implemented"] },
|
||||
"phase": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"changelog": [
|
||||
{ "date": "2026-03-26", "note": "501 stub — Phase 0 占位,P2 实现" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"hli_id": "HLI-TICKET-003",
|
||||
"version": "v0.1-stub",
|
||||
"route": "/hli/ticket/status",
|
||||
"method": "GET",
|
||||
"type": "REQUEST",
|
||||
"status": "stub",
|
||||
"target_phase": "P2",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"required": ["ticket_id"],
|
||||
"properties": {
|
||||
"ticket_id": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "enum": ["not_implemented"] },
|
||||
"phase": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"changelog": [
|
||||
{ "date": "2026-03-26", "note": "501 stub — Phase 0 占位,P2 实现" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"hli_id": "HLI-USER-002",
|
||||
"version": "v0.1-stub",
|
||||
"route": "/hli/user/profile/update",
|
||||
"method": "PUT",
|
||||
"type": "REQUEST",
|
||||
"status": "stub",
|
||||
"target_phase": "P1",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"display_name": { "type": "string" },
|
||||
"avatar_url": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "enum": ["not_implemented"] },
|
||||
"phase": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"changelog": [
|
||||
{ "date": "2026-03-26", "note": "501 stub — Phase 0 占位,P1 实现" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"hli_id": "HLI-USER-001",
|
||||
"version": "v0.1-stub",
|
||||
"route": "/hli/user/profile",
|
||||
"method": "GET",
|
||||
"type": "REQUEST",
|
||||
"status": "stub",
|
||||
"target_phase": "P1",
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string", "enum": ["not_implemented"] },
|
||||
"phase": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"changelog": [
|
||||
{ "date": "2026-03-26", "note": "501 stub — Phase 0 占位,P1 实现" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Smoke test · HLI 接口健康检查
|
||||
*
|
||||
* 测试 GET /health 端点可达性
|
||||
* 版权:国作登字-2026-A-00037559
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const BASE = process.env.TEST_BASE || 'http://localhost:3001';
|
||||
|
||||
function get(urlPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(BASE + urlPath);
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'GET',
|
||||
timeout: 10000
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let chunks = '';
|
||||
res.on('data', (c) => { chunks += c; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({ status: res.statusCode, body: JSON.parse(chunks) });
|
||||
} catch (e) {
|
||||
resolve({ status: res.statusCode, body: chunks });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (condition) {
|
||||
passed++;
|
||||
console.log(` ✅ ${message}`);
|
||||
} else {
|
||||
failed++;
|
||||
console.error(` ❌ ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('🚀 HLI 接口冒烟测试\n');
|
||||
|
||||
// Test 1: Health endpoint
|
||||
console.log('── 测试 1: 健康检查端点 ──');
|
||||
try {
|
||||
const res = await get('/health');
|
||||
assert(res.status === 200, '/health 返回 200');
|
||||
assert(res.body.status === 'ok', '/health 状态 ok');
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.error(` ❌ /health 请求失败: ${e.message}`);
|
||||
}
|
||||
|
||||
console.log(`\n📊 冒烟测试: ${passed} 通过, ${failed} 失败`);
|
||||
if (failed > 0) {
|
||||
console.error('❌ HLI Smoke Test FAILED');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('✅ HLI Smoke Test PASSED');
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
console.error('❌ 冒烟测试执行异常:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in New Issue