# 桥接协议 v2 规范

> 为 NX Copilot 项目制定的 NX 桥接 ↔ 外部主控通信协议。
> **v2 目标**：修复 v1 的 6 个缺陷（无版本协商、无进度+取消、状态不区分、固定过期、无互斥、幽灵进程）。
> **核心设计保留**：纯文件 IPC、零端口、车间电脑友好。

---

## 1. 目录结构

```
<bridge_root>/                              ← 由主控程序指定（默认 ~/.nx_copilot/bridge/）
├── v2/
│   ├── inbox/                              ← 主控写命令，NX 桥读
│   │   └── cmd_<command_id>.json
│   ├── outbox/                             ← NX 桥写响应，主控读
│   │   └── resp_<command_id>.json
│   ├── progress/                           ← NX 桥写长任务进度，主控轮询
│   │   └── prog_<command_id>.json
│   ├── cancel/                             ← 主控写取消请求，NX 桥检测
│   │   └── cancel_<command_id>.json
│   ├── locks/                              ← 主控互斥锁（发送前创建，完成后删除）
│   │   └── send_<uuid>.lock
│   ├── hello.json                          ← NX 桥启动时写，版本协商用
│   └── status.json                         ← NX 桥心跳（每 2-3s 更新一次）
└── v1/                                     ← 旧版目录（保留，不删除，P0-09 收敛时清理）
```

**所有文件名均为纯 ASCII**（避免 Windows 上中文路径编码问题）。

---

## 2. 消息信封（Command Envelope）

`inbox/cmd_<id>.json` 的 JSON 结构：

```jsonc
{
    "proto": "2",              // 必填，协议版本号。主控和 NX 桥必须一致
    "id": "550e8400-e29b-41d4-a716-446655440000",  // 必填，UUID v4，全局唯一
    "type": "PING",            // 必填，命令类型（见第 3 节）
    "params": {},              // 选填，命令参数（任意 JSON 对象，由 type 决定）
    "ts": 1792129676.512,      // 必填，发送时间戳（Unix 秒，带毫秒）
    "deadline": 1792129736.512,// 选填，超时截止时间（Unix 秒）
                               //   NX 桥超过此时间仍未处理则放弃
                               //   主控超过此时间仍未收到 resp 则视为超时
                               //   若不填，默认 ts + 60
    "trace": "manual_test_001" // 选填，链路追踪标记（日志关联用）
}
```

| 字段 | 类型 | 必填 | 含义 |
|---|---|---|---|
| `proto` | string | ✅ | 协议版本号。当前固定 `"2"`。NX 桥读到不匹配的 proto 应写 resp `{ok:false, error:"proto_mismatch"}` |
| `id` | string (UUID) | ✅ | 命令唯一标识。同一条命令的所有文件（cmd/resp/prog/cancel）都用这个 id |
| `type` | string | ✅ | 命令类型。必须是 NX 桥支持的命令之一 |
| `params` | object | ❌ | 命令参数。结构由 type 决定。空对象 `{}` 也可以 |
| `ts` | float | ✅ | 客户端发送时的 Unix 时间戳（秒，精度到毫秒） |
| `deadline` | float | ❌ | 超时截止 Unix 秒。不填则默认 `ts + 60` |
| `trace` | string | ❌ | 链路追踪标签（日志关联、调试用） |

---

## 3. 命令类型

NX 桥（v2）最小支持集：

| type | 说明 | 典型 params |
|---|---|---|
| `PING` | 健康检查 | `{}` |
| `HELLO` | 请求桥能力信息 | `{}` |
| `GET_CONTEXT` | 当前 NX 会话信息 | `{}` |
| `EXEC_SCRIPT` | 执行受控操作（如导出图纸） | `{ "script": "export_drawings", "args": {...} }` |
| `CANCEL` | 取消正在执行的命令 | `{ "target_id": "<uuid>" }` |

**注意**：`CANCEL` 有两种触发方式——写 outbox 中的 `cancel_<id>.json`（推荐）或发 type=`CANCEL` 命令。两者等价。

---

## 4. 响应（Response）

`outbox/resp_<id>.json`：

```jsonc
{
    "ok": true,                // 必填，成功/失败
    "status": "done",          // 必填，done / accepted / cancelled / error
    "id": "550e8400-...",      // 必填，原样回传命令 id
    "proto": "2",              // 必填，原样回传
    "ts": 1792129677.100,      // 必填，NX 桥写响应的时间戳
    "data": {},                // 选填，成功时的返回数据（结构由 type 决定）
    "error": null              // 选填，失败时的错误信息（ok=false 时必填）
}
```

| status 值 | 含义 |
|---|---|
| `done` | 命令已完成（同步或异步） |
| `accepted` | 异步命令已接收，正在执行中（主控应轮询 progress/） |
| `cancelled` | 命令已被取消（被主控或用户手动取消） |
| `error` | 执行出错（详见 error 字段） |

---

## 5. 长任务进度（Progress）

`progress/prog_<id>.json`——只有长任务（响应 status=accepted）才会有：

```jsonc
{
    "id": "550e8400-...",
    "proto": "2",
    "step": 3,                 // 当前步骤序号（0 基）
    "total": 5,                // 总步骤数
    "message": "正在导出图纸 3/5...",  // 人类可读状态信息
    "ts": 1792129680.000,      // NX 桥写这条进度的时间戳
    "done": false,             // 是否已完成（最后一条进度应为 true）
    "ok": null,                // 最终成功/失败（done=true 时必填）
    "error": null              // 最终错误信息（done=true 且 ok=false 时必填）
}
```

主控轮询 progress 的建议间隔：**200-500ms**。

**幂等**：progress 文件可以被多次覆盖（NX 桥更新进度时直接写最新值）。主控只需读最新值，不保留历史。

---

## 6. 取消（Cancel）

主控写 `cancel/cancel_<id>.json`——NX 桥应该在检测到后中止执行：

```jsonc
{
    "id": "550e8400-...",
    "ts": 1792129682.000,
    "reason": "user_cancel"
}
```

NX 桥收到取消后：
1. 立即停止当前操作
2. 写 resp `{ok:true, status:"cancelled", ...}`
3. 如果已有 progress 文件，更新 progress.done=true, progress.ok=false, progress.error="cancelled_by_user"

---

## 7. 心跳状态（status.json）

NX 桥每 2-3 秒更新一次：

```jsonc
{
    "proto": "2",
    "pid": 22580,
    "state": "idle",           // 见第 8 节状态机
    "ts": 1792129676.512,      // 上次心跳更新时间
    "nx_version": "2412",      // 当前 NX 版本（如果已识别）
    "nx_open": true,           // NX 主窗口是否打开
    "part": "(none)",          // 当前部件名（无打开部件时为 "(none)"）
    "last_cmd": null,          // 最近一条命令的 id（busy/modal 时有值）
    "last_cmd_ts": null        // 最近一条命令开始时间
}
```

---

## 8. 状态机

```
                 ┌─────────┐
                 │   idle   │  ← 空闲，可接收新命令
                 └────┬────┘
                      │ 收到命令
                      ▼
                 ┌─────────┐         模态对话框弹出
                 │   busy   │ ──────────────────────► ┌─────────┐
                 └────┬────┘                          │  modal  │
                      │ 命令完成                        └────┬────┘
                      │                                       │ 用户关闭对话框
                      ▼                                       │
                   idle ◄─────────────────────────────────────┘
                       （或 error，如果执行出错）
```

| state | 含义 | 主控动作 |
|---|---|---|
| `idle` | NX 桥空闲 | 可以发送命令 |
| `busy` | 正在执行命令 | 主控可以选择等或发 cancel |
| `modal` | 被模态对话框卡住 | 主控应该提示用户："请关闭 NX 弹窗后继续" |
| `error` | 桥内部出错 | 主控应该提示重启 NX 或桥接 |

---

## 9. Hello 协商（hello.json）

NX 桥启动时立即写这个文件。主控可以在发送第一条命令前读它来做版本协商：

```jsonc
{
    "proto": "2",                        // 协议版本
    "nx_version": "2412",                // NX 版本
    "nx_full_version": "2412.5501",      // NX build 号
    "nx_open": true,                     // 当前 NX 是否已打开
    "pid": 22580,                        // 桥接进程 pid
    "capabilities": {                    // 能力位（可选）
        "long_task_progress": true,      // 支持长任务进度回传
        "cancel": true,                  // 支持取消
        "version": "2.0.0"               // 桥接自身版本
    },
    "supported_commands": [              // 支持的命令类型列表
        "PING", "HELLO", "GET_CONTEXT", "EXEC_SCRIPT", "CANCEL"
    ],
    "started_at": 1792129670.000         // 桥接启动时间
}
```

**版本协商流程**：
1. NX 桥启动 → 写 hello.json
2. 主控读 hello.json
3. 主控检查：`proto` 是否等于自己支持的版本 → 不等则明确报 "协议版本不兼容"
4. 主控检查：`supported_commands` 是否包含自己要发的 type → 不包含则明确报 "桥不支持此命令"

**hello.json 不存在时**：
- 如果 status.json 也不存在或心跳超时 → `BridgeNotLoaded`
- 如果 status.json 显示 bridge 存在但没有 hello.json → 可能是旧版本桥 → 明确报错

---

## 10. 过期与清理

### 不再用固定 300s 阈值

v2 协议里每条命令自带 `deadline`。清理逻辑改为：

| 角色 | 清理动作 |
|---|---|
| **主控** | 清理 inbox 中的 cmd 文件时检查 `deadline < now`，过期则删除 |
| **NX 桥** | 轮询 inbox 时跳过 `deadline < now` 的 cmd（视为超时，不处理） |
| **共同** | 清理前检查 `locks/` 目录——如果有对应命令 id 的锁文件，跳过（正在被处理） |

### 具体规则

```
过期命令 = cmd.ts + cmd.deadline < now
         = cmd.deadline < now
```

主控超时判定：从写 cmd 的那一刻起计时，超过 deadline - ts 后仍未收到 resp → BridgeTimeout。

---

## 11. 幂等与恢复

| 场景 | 行为 |
|---|---|
| **同 id 第二次发送** | 主控先检查 outbox 是否已有 resp 该 id → 有则直接返回已有结果（幂等） |
| **resp 文件存在 = 完成** | NX 桥写 resp 即视为命令完成。outbox/resp_<id>.json 存在且 ok/status 任一值存在 → 不会再被处理 |
| **bridge 崩溃后重启** | 重新写 hello.json + 清理 inbox 中 deadline < now 的 cmd（不处理） |
| **主控崩溃后重启** | 检查 outbox/ 是否有上次 id 的 resp → 有则恢复结果，无则视为超时 |

---

## 12. 互斥锁

主控并发发送时用 `locks/send_<uuid>.lock` 防止同 id 竞争：
- 创建方式：`os.open(path, os.O_CREAT | os.O_EXCL)` —— 原子创建，已存在则失败
- 作用域：一条命令的完整生命周期（写 cmd → 等 resp → 读 resp → 删锁）
- 清理：命令完成（收到 resp 或超时）后删除锁文件

---

## 13. 原子文件写

**所有 JSON 文件的写入必须原子**：
1. 先写 `<filename>.tmp`
2. `os.replace(<filename>.tmp, <filename>)` —— Windows 上原子替换

原因：避免读到半写文件导致 JSON 解析失败。

---

## 14. 异常层次

主控 Python 客户端抛出的异常：

```
BridgeError                    ← 所有桥相关异常的基类
├── BridgeNotLoaded            ← hello.json 不存在 / 心跳超时（NX 桥未加载）
├── BridgeBusy                 ← NX 桥 state=busy（正忙）
├── BridgeModal                ← NX 桥 state=modal（被对话框卡住）
├── BridgeTimeout              ← 超时未收到 resp
└── BridgeProtocolError        ← 协议不兼容 / resp 解析失败 / 响应损坏
```

目的：让上层代码（桌面 GUI / 命令行工具）**能一眼看出是没装、还是 NX 忙、还是真出错**。

---

## 15. v1 → v2 迁移

- v1 目录不动（保留作为参考）
- v2 目录是独立的 `bridge_root/v2/`
- Python 客户端默认写 v2
- NX 侧 C# 实现也写 v2
- **两版共存**：如果旧 bridge_daemon.py 还在跑，它只处理 v1 inbox 的 cmd（不处理 v2），互不干扰
- P0-09 收敛时统一清理 v1 和幽灵进程

---

## 16. JSON 编码

- UTF-8，**无 BOM**
- `ensure_ascii = False`（允许中文字符）
- indent 任意（两个空格或四个空格，不影响解析）
- 所有时间戳统一 Unix 秒（float，精度到毫秒）

---

## 17. C# 实现契约（NX 桥侧精确行为规格）

本章节是 **P0-08 C# 实现的精确行为规格**——直接从 `bridge_v2_selftest.py` 的 MockBridge 行为中抽取，确保 C# 服务端和 Python 客户端的行为完全一致。

### 17.1 桥接启动序列

```
1. 启动后立即写 hello.json（见第 9 节）
2. 启动定时器（1s 间隔）：
   a. 每 2s 写一次 status.json 心跳（state 字段）
   b. 轮询 inbox/cmd_*.json（见 17.2）
```

### 17.2 Inbox 轮询循环

```
poll_interval = 100ms（0.1s），不要超过 200ms

对每个 cmd_<id>.json：
  1. 读 JSON → 失败则 skip
  2. 跳过：deadline < now 的命令（已过期）
  3. 跳过：resp_<id>.json 已存在（已处理）
  4. 如果 status.json.state == "modal" → break 本轮（模态下不处理新命令）
  5. 如果 cmd.type == "CANCEL"：
     a. 写 resp status="cancelled"
     b. 删 cancel/cancel_<target_id>.json（如有）
     c. 删 cmd 文件
     d. continue
  6. 检查 cancel/cancel_<id>.json 是否存在：
     a. 存在 → 同 5a-5d
  7. 写 status.json state="busy", last_cmd=<id>, last_cmd_ts=<now>
  8. 查找命令处理器：
     a. 有 → handler(cmd_envelope)
     b. 无 → 写 resp ok=false, error="unknown command type: <type>"
  9. 处理器异常 → 写 resp ok=false, error=<异常消息>
  10. 删 cmd 文件
  11. 写 status.json state="idle"
```

### 17.3 长任务 accepted → done 覆盖序列

```
处理器（EXEC_SCRIPT 等）内：
  1. 立即写 resp status="accepted"（ok=true）
  2. 循环执行子步骤：
     a. 每步写 progress/prog_<id>.json：
        {step, total, message, ts, done=false, ok=null, error=null}
     b. 每步之间 sleep（由命令自己决定）
     c. 每步检查 cancel/cancel_<id>.json：
        存在 → 跳出循环 → 写 resp status="cancelled"（覆盖 accepted）→ progress done=true ok=false error="cancelled_by_user"
  3. 全部步骤完成 → 写最终 resp status="done"（覆盖 accepted）
  4. 写最终 progress done=true ok=true
```

### 17.4 Cancel 检测优先级

```
每次轮询 inbox 时，先检查：
  a. cmd.type == "CANCEL"（显式取消命令）
  b. cancel/cancel_<id>.json 文件存在（隐式取消请求）

两者等价。CANCEL 命令的 params.target_id = 要取消的命令 id。
```

### 17.5 心跳 state 转换

```
idle ──收到命令──▶ busy ──命令完成──▶ idle
                    │
                    └──执行异常──▶ error（短暂，恢复到 idle）

modal 状态：收到模态对话框事件（NXOpen.UF.Disp.ask_dialog 等）
  → 写 state="modal"
  → 停止处理新命令（inbox 轮询时 break）
  → 模态关闭后恢复 state="idle"
```

### 17.6 超时与 deadline

```
桥侧只负责：
  - 跳过 deadline < now 的 cmd（不处理）
  - 长任务里如果 cancel 被触发，不管 deadline 到没到都立即中止

客户端负责：
  - send_command 的 timeout 控制（从写 cmd 开始计时）
  - 超时后写 cancel 请求让桥停止
```

### 17.7 原子写（C# 版）

```csharp
// C# 原子写：File.WriteAllText 是原子的吗？不是。
// 正确做法（同 Python os.replace）：
string tmp = path + ".tmp";
File.WriteAllText(tmp, json, new UTF8Encoding(false));
File.Move(tmp, path, true);  // true = overwrite
// 注意：File.Move 的 third param 在 .NET Framework 4.8 是 supported（bool overwrite）
```

### 17.8 JSON 编码（C# 版）

```csharp
// System.Text.Json 默认 UTF-8 无 BOM，OK
// 禁止 UseBom = true
var options = new JsonSerializerOptions {
    WriteIndented = true,
    Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping  // 允许中文不转义
};
```

### 17.9 异常处理

```csharp
// 所有命令处理器内的异常必须：
//   1. 被 try-catch 捕获
//   2. 写回 resp 的 error 字段（ok=false）
//   3. 绝不冒泡到 NXOpen —— 弄崩 NX 代价太大

try {
    // ... 处理命令 ...
} catch (Exception ex) {
    // 写 resp error
    // 清理状态：state 恢复 idle
}
```
