@huaqiu/dsh-hqsch 0.4.9 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huaqiu/dsh-hqsch",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "type": "module",
5
5
  "main": "./lib/index.mjs",
6
6
  "types": "./lib/index.d.mts",
@@ -21,7 +21,7 @@
21
21
  "@deepseek-ai/cordis": "^4.0.1"
22
22
  },
23
23
  "dependencies": {
24
- "@huaqiu/dsh-plugin-log": "0.4.9"
24
+ "@huaqiu/dsh-plugin-log": "0.4.10"
25
25
  },
26
26
  "files": [
27
27
  "lib",
@@ -23,10 +23,14 @@
23
23
  7. `docs/editing-a-circuit.md` — **局部改电路(Flow C)** / 确认 diff / 修改集 / 验收
24
24
  8. `docs/rpc-availability.md` — 接口可用性、禁建列表、耗时基线
25
25
  9. `docs/serialization.md` — toJsonString 与 BigInt
26
- 10. `docs/rpc/*.md` — 仅当需要某个 RPC 的精确字段名时查阅
26
+ 10. `docs/script-lifetime.md` — **进程生命周期:必须用 `hqMain`,否则泄漏 node 进程**(写任何脚本前先读)
27
+ 11. `docs/rpc/*.md` — 仅当需要某个 RPC 的精确字段名时查阅
27
28
 
28
29
  ## 硬性规则
29
30
 
31
+ - **所有脚本必须经 `scripts/lib/hq.ts` 的 `hqMain` / `hqMainWithProject` 进入**;**禁止**裸 `connect()` + `main().catch(e => process.exit(1))`。该外壳负责:每个 RPC 超时、硬看门狗、`client.close()`、保证进程退出。见 `docs/script-lifetime.md`。
32
+ - **不要依赖"脚本会自己退出"**:卡住的 RPC 会让 node 进程永久存活,重试一次多泄漏一个。
33
+
30
34
  - 器件(R/C/Q/U):在线搜索 + `client.componentPlace.placeKicadSymbol`;**禁止**手写 `json_part` / `PlacePart`
31
35
  - 电源符号:`ListSymbolLibraries` → `PlaceSymbolFromLibrary`
32
36
  - `kind`:电源/地用 **`LibrarySymbolKind.GLOBAL`(=1)**;`OFF_PAGE=2`,`PORT=3`
@@ -37,7 +41,7 @@
37
41
  - **每条 net 先跑 routing gate**:量距离 → 短(≤300 ext、交叉<3)用 `autoConnect`;长/拥挤用双端 `placePinStubWireAndNetAlias`(同一 `netName`)
38
42
  - 字母引脚(CC1、A5):用 `pinName` / `pinNumber` **字符串**,禁止 `Number("A5")`
39
43
  - **pattern 的 host 电源引脚必须用 `pinName` 绑定**(如 `pinName:"VIN"`);用 `pinNum` 会返回 `PIN_RESOLVE_FAILED`
40
- - **放置后必须等对象注册再 apply**:轮询 `getPageOccupancy` 直到所有新 id 出现,否则 apply 报 `OBJECT_NOT_FOUND` 或假 `PARTIAL`
44
+ - **放置后必须等对象注册再 apply**:轮询 `client.patternLayout.getPageOccupancy`(**不是 canvasOps**;响应字段是 `items[].objectId`,**没有** `objectIds`)直到所有新 id 出现,否则 apply 报 `OBJECT_NOT_FOUND` 或假 `PARTIAL`。推荐直接调用 `waitForRegistered(client, ctx, ids)`(`scripts/lib/hq.ts`)。
41
45
  - **读取导线段字段是 `wires`**(`listWireSegments().wires`),**不是** `wireSegments`
42
46
  - 打印 protobuf 响应用 `toJsonString`;禁止对 RPC 结果裸 `JSON.stringify()`。遍历快照 `position.x/y`(bigint)时见 `docs/serialization.md` replacer
43
47
  - 耗时基线(区间,非 SLA):快照约 **1.6–3.2s**;单次 pattern/放置脚本约 **15–20s**(抖动正常)。勿用超时反推「未实现」
@@ -74,7 +78,7 @@
74
78
  ```
75
79
  connect → getActiveProject → createProjectContext
76
80
  → 放置 host / 独立器件(part-search → placeKicadSymbol;电源符号 → PlaceSymbolFromLibrary)
77
- → 【等待注册】轮询 getPageOccupancy 直到所有新 id 出现(关键!否则 apply 失败)
81
+ → 【等待注册】轮询 `client.patternLayout.getPageOccupancy`(或 `waitForRegistered()`)直到所有新 id 出现(关键!否则 apply 失败)
78
82
  → 规范位号 / 容值(覆盖引擎自动分配的 C1..C4)
79
83
  → applyCircuitPattern(每个适用 pattern 一次;先 planOnly 可选,再正式 apply)
80
84
  → 仅对 pattern 未覆盖的 net 做 hand-wire(每条 net 先跑 routing gate)
@@ -157,26 +161,23 @@
157
161
  * [<leftover>] → hand (routing gate: 短 autoConnect / 长 stub+alias)
158
162
  * =====================================
159
163
  */
160
- import { connect, toJsonString } from "@huaqiu/huaqiu-client";
161
164
  import { HqServicesV1PatternLayoutService } from "@hqedge/connect";
165
+ import { hqMainWithProject, waitForRegistered } from "./lib/hq.js";
162
166
 
163
167
  const PL = HqServicesV1PatternLayoutService; // 枚举来源
164
- const INSTANCE = process.env.HQ_INSTANCE_ID;
165
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
166
-
167
- async function main() {
168
- const client = INSTANCE
169
- ? await connect({ instanceId: INSTANCE })
170
- : await connect();
171
168
 
172
- const editorCtx = client.createEditorContext();
173
- const active = await client.project.getActiveProject({ context: editorCtx });
174
- const projectId = active.project?.projectId;
175
- if (!projectId) throw new Error("请先在 HQ EDA 中打开原理图工程");
176
- const ctx = client.createProjectContext(projectId);
169
+ // ⚠ 必须走 hqMainWithProject:它给每个 RPC 加超时、挂硬看门狗、finally 里
170
+ // client.close(),并保证进程退出。裸 connect() 会泄漏 node 进程。
171
+ hqMainWithProject(async ({ client, projectId, projectContext: ctx }) => {
172
+ console.log("projectId:", projectId);
177
173
 
178
174
  // 1) 放置 host / 独立器件(placeKicadSymbol;电源符号 PlaceSymbolFromLibrary)
179
- // 2) 【关键】等待注册:轮询 getPageOccupancy 直到所有新 id 出现
175
+ // 记下返回的 objectId,放进 newIds
176
+ const newIds: bigint[] = [];
177
+
178
+ // 2) 【关键】等待注册:所有新 id 都出现在页面上再继续
179
+ await waitForRegistered(client, ctx, newIds);
180
+
180
181
  // 3) 规范位号 / 容值
181
182
  // 4) applyCircuitPattern — host 引脚用 pinName 绑定
182
183
  // pattern: PL.CircuitPatternId.CIRCUIT_PATTERN_xxx
@@ -189,11 +190,12 @@ async function main() {
189
190
 
190
191
  await client.canvasOps.zoomAll({ context: ctx });
191
192
  console.log("done");
192
- }
193
-
194
- main().catch((e) => { console.error(e); process.exit(1); });
193
+ });
195
194
  ```
196
195
 
196
+ **禁止**把结尾写成 `main().catch((e) => { console.error(e); process.exit(1); })` ——
197
+ 成功路径没有 `process.exit(0)`,且卡住的 RPC 会让进程永不退出。
198
+
197
199
  ## 输出格式
198
200
 
199
201
  1. 简短设计说明(用了哪些 pattern、接线策略)
@@ -23,10 +23,14 @@
23
23
  7. `docs/editing-a-circuit.md` — **局部改电路(Flow C)** / 确认 diff / 修改集 / 验收
24
24
  8. `docs/rpc-availability.md` — 接口可用性、禁建列表、耗时基线
25
25
  9. `docs/serialization.md` — toJsonString 与 BigInt
26
- 10. `docs/rpc/*.md` — 仅当需要某个 RPC 的精确字段名时查阅
26
+ 10. `docs/script-lifetime.md` — **进程生命周期:必须用 `hqMain`,否则泄漏 node 进程**(写任何脚本前先读)
27
+ 11. `docs/rpc/*.md` — 仅当需要某个 RPC 的精确字段名时查阅
27
28
 
28
29
  ## 硬性规则
29
30
 
31
+ - **所有脚本必须经 `scripts/lib/hq.ts` 的 `hqMain` / `hqMainWithProject` 进入**;**禁止**裸 `connect()` + `main().catch(e => process.exit(1))`。该外壳负责:每个 RPC 超时、硬看门狗、`client.close()`、保证进程退出。见 `docs/script-lifetime.md`。
32
+ - **不要依赖"脚本会自己退出"**:卡住的 RPC 会让 node 进程永久存活,重试一次多泄漏一个。
33
+
30
34
  - 器件(R/C/Q/U):在线搜索 + `client.componentPlace.placeKicadSymbol`;**禁止**手写 `json_part` / `PlacePart`
31
35
  - 电源符号:`ListSymbolLibraries` → `PlaceSymbolFromLibrary`
32
36
  - `kind`:电源/地用 **`LibrarySymbolKind.GLOBAL`(=1)**;`OFF_PAGE=2`,`PORT=3`
@@ -37,7 +41,7 @@
37
41
  - **每条 net 先跑 routing gate**:量距离 → 短(≤300 ext、交叉<3)用 `autoConnect`;长/拥挤用双端 `placePinStubWireAndNetAlias`(同一 `netName`)
38
42
  - 字母引脚(CC1、A5):用 `pinName` / `pinNumber` **字符串**,禁止 `Number("A5")`
39
43
  - **pattern 的 host 电源引脚必须用 `pinName` 绑定**(如 `pinName:"VIN"`);用 `pinNum` 会返回 `PIN_RESOLVE_FAILED`
40
- - **放置后必须等对象注册再 apply**:轮询 `getPageOccupancy` 直到所有新 id 出现,否则 apply 报 `OBJECT_NOT_FOUND` 或假 `PARTIAL`
44
+ - **放置后必须等对象注册再 apply**:轮询 `client.patternLayout.getPageOccupancy`(**不是 canvasOps**;响应字段是 `items[].objectId`,**没有** `objectIds`)直到所有新 id 出现,否则 apply 报 `OBJECT_NOT_FOUND` 或假 `PARTIAL`。推荐直接调用 `waitForRegistered(client, ctx, ids)`(`scripts/lib/hq.ts`)。
41
45
  - **读取导线段字段是 `wires`**(`listWireSegments().wires`),**不是** `wireSegments`
42
46
  - 打印 protobuf 响应用 `toJsonString`;禁止对 RPC 结果裸 `JSON.stringify()`。遍历快照 `position.x/y`(bigint)时见 `docs/serialization.md` replacer
43
47
  - 耗时基线(区间,非 SLA):快照约 **1.6–3.2s**;单次 pattern/放置脚本约 **15–20s**(抖动正常)。勿用超时反推「未实现」
@@ -74,7 +78,7 @@
74
78
  ```
75
79
  connect → getActiveProject → createProjectContext
76
80
  → 放置 host / 独立器件(part-search → placeKicadSymbol;电源符号 → PlaceSymbolFromLibrary)
77
- → 【等待注册】轮询 getPageOccupancy 直到所有新 id 出现(关键!否则 apply 失败)
81
+ → 【等待注册】轮询 `client.patternLayout.getPageOccupancy`(或 `waitForRegistered()`)直到所有新 id 出现(关键!否则 apply 失败)
78
82
  → 规范位号 / 容值(覆盖引擎自动分配的 C1..C4)
79
83
  → applyCircuitPattern(每个适用 pattern 一次;先 planOnly 可选,再正式 apply)
80
84
  → 仅对 pattern 未覆盖的 net 做 hand-wire(每条 net 先跑 routing gate)
@@ -157,26 +161,23 @@
157
161
  * [<leftover>] → hand (routing gate: 短 autoConnect / 长 stub+alias)
158
162
  * =====================================
159
163
  */
160
- import { connect, toJsonString } from "@huaqiu/huaqiu-client";
161
164
  import { HqServicesV1PatternLayoutService } from "@hqedge/connect";
165
+ import { hqMainWithProject, waitForRegistered } from "./lib/hq.js";
162
166
 
163
167
  const PL = HqServicesV1PatternLayoutService; // 枚举来源
164
- const INSTANCE = process.env.HQ_INSTANCE_ID;
165
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
166
-
167
- async function main() {
168
- const client = INSTANCE
169
- ? await connect({ instanceId: INSTANCE })
170
- : await connect();
171
168
 
172
- const editorCtx = client.createEditorContext();
173
- const active = await client.project.getActiveProject({ context: editorCtx });
174
- const projectId = active.project?.projectId;
175
- if (!projectId) throw new Error("请先在 HQ EDA 中打开原理图工程");
176
- const ctx = client.createProjectContext(projectId);
169
+ // ⚠ 必须走 hqMainWithProject:它给每个 RPC 加超时、挂硬看门狗、finally 里
170
+ // client.close(),并保证进程退出。裸 connect() 会泄漏 node 进程。
171
+ hqMainWithProject(async ({ client, projectId, projectContext: ctx }) => {
172
+ console.log("projectId:", projectId);
177
173
 
178
174
  // 1) 放置 host / 独立器件(placeKicadSymbol;电源符号 PlaceSymbolFromLibrary)
179
- // 2) 【关键】等待注册:轮询 getPageOccupancy 直到所有新 id 出现
175
+ // 记下返回的 objectId,放进 newIds
176
+ const newIds: bigint[] = [];
177
+
178
+ // 2) 【关键】等待注册:所有新 id 都出现在页面上再继续
179
+ await waitForRegistered(client, ctx, newIds);
180
+
180
181
  // 3) 规范位号 / 容值
181
182
  // 4) applyCircuitPattern — host 引脚用 pinName 绑定
182
183
  // pattern: PL.CircuitPatternId.CIRCUIT_PATTERN_xxx
@@ -189,11 +190,12 @@ async function main() {
189
190
 
190
191
  await client.canvasOps.zoomAll({ context: ctx });
191
192
  console.log("done");
192
- }
193
-
194
- main().catch((e) => { console.error(e); process.exit(1); });
193
+ });
195
194
  ```
196
195
 
196
+ **禁止**把结尾写成 `main().catch((e) => { console.error(e); process.exit(1); })` ——
197
+ 成功路径没有 `process.exit(0)`,且卡住的 RPC 会让进程永不退出。
198
+
197
199
  ## 输出格式
198
200
 
199
201
  1. 简短设计说明(用了哪些 pattern、接线策略)
@@ -13,13 +13,13 @@
13
13
  | `docs/` | LLM 知识库(工程规范 + 常用 RPC) |
14
14
  | `template/` | 本地脚本工程 + 连通性测试 |
15
15
 
16
- 维护者更新 guides 或 Scenario-B playbooks 后在本仓库运行:
16
+ **没有组装脚本。** 本目录(`packages/dsh-hqsch/skills/hqsch-scenario-b/`)就是权威来源,
17
+ `docs/` 与 `template/` 直接在此维护;`@huaqiu/dsh-hqsch` 在加载时通过
18
+ `ctx.skills.register` 把整个目录注册为 DSH 内建 skill(`resourceBase = <本目录>`)。
17
19
 
18
- ```bash
19
- cd skills/hqeda && pnpm run build:scenario-b
20
- ```
21
-
22
- `build:scenario-b` 会从 monorepo 拷贝 guides + RPC 文档,再合并 `onboarding/scenario-b/docs/`(读电路 / 改电路 / rpc-availability 等)与 `onboarding/scenario-b/template/`。
20
+ > 旧版本 INSTALL 提到的 `cd skills/hqeda && pnpm run build:scenario-b` **已不存在**:
21
+ > 仓库中没有 `skills/hqeda` 目录,也没有任何 `package.json` 定义该 script。
22
+ > 改动 `docs/`、`SYSTEM-PROMPT.md` 后请同步 `AGENTS.md` / `CLAUDE.md`(三者内容一致)。
23
23
 
24
24
  ---
25
25
 
@@ -48,7 +48,7 @@ Skill 包**不包含** HQ EDA 软件本身。
48
48
  ```
49
49
  my-project/
50
50
  ├── AGENTS.md ← 从本包复制或合并
51
- ├── hqeda-scenario-b/ ← 或解压内容直接放在根目录
51
+ ├── hqsch-scenario-b/ ← 或解压内容直接放在根目录
52
52
  │ ├── docs/
53
53
  │ ├── template/
54
54
  │ └── ...
@@ -66,7 +66,7 @@ my-project/
66
66
  ### 3. 安装依赖并冒烟测试
67
67
 
68
68
  ```bash
69
- cd template # 或你的 hqeda-scenario-b/template
69
+ cd template # 或你的 hqsch-scenario-b/template
70
70
  npm install
71
71
  npx tsx scripts/hello.ts
72
72
  ```
@@ -124,17 +124,19 @@ npx skills add <git-url> --path hqeda/scenario-b
124
124
 
125
125
  ## 五、分发给他人
126
126
 
127
- 将整个 `scenario-b/` 打成 `hqeda-scenario-b.zip`:
127
+ 将整个 `hqsch-scenario-b/` 打成 `hqsch-scenario-b.zip`:
128
128
 
129
129
  ```
130
- hqeda-scenario-b.zip
130
+ hqsch-scenario-b.zip
131
131
  ├── SKILL.md
132
132
  ├── SYSTEM-PROMPT.md
133
133
  ├── AGENTS.md ← Codex 项目根
134
134
  ├── CLAUDE.md ← Work Buddy / Claude UI
135
135
  ├── INSTALL.md
136
136
  ├── docs/
137
+ │ └── script-lifetime.md ← 进程生命周期(必读)
137
138
  └── template/
139
+ └── scripts/lib/hq.ts ← 所有脚本的运行时外壳(必用)
138
140
  ```
139
141
 
140
142
  附一句:**「解压 → 配置 AGENTS.md 或粘贴 CLAUDE.md → 跑 template/hello.ts → 开始描述电路」**
@@ -48,7 +48,8 @@ Follow [SYSTEM-PROMPT.md](./SYSTEM-PROMPT.md) in full. The agent **must auto-run
48
48
  | 7 | [docs/editing-a-circuit.md](./docs/editing-a-circuit.md) | **Local edit (Flow C)** — diff, blast radius, verify |
49
49
  | 8 | [docs/rpc-availability.md](./docs/rpc-availability.md) | Which RPCs work / ban list / timing baselines |
50
50
  | 9 | [docs/serialization.md](./docs/serialization.md) | `toJsonString` + BigInt replacer |
51
- | 10 | [docs/rpc/](./docs/rpc/) | RPC field reference |
51
+ | 10 | [docs/script-lifetime.md](./docs/script-lifetime.md) | **Process lifetime — must use `hqMain`, or node processes leak** |
52
+ | 11 | [docs/rpc/](./docs/rpc/) | RPC field reference |
52
53
 
53
54
  **Runtime (agent executes on user's machine):**
54
55
 
@@ -58,6 +59,11 @@ cd template && npm install && npx tsx scripts/hello.ts
58
59
 
59
60
  HQ EDA desktop must be running with a schematic project open.
60
61
 
62
+ > **Every script must enter through `hqMain` / `hqMainWithProject` in
63
+ > `scripts/lib/hq.ts`** (per-RPC deadline + hard watchdog + guaranteed exit).
64
+ > A stalled RPC pins the Node event loop and makes the process immortal, so each
65
+ > retry leaks another one. See [docs/script-lifetime.md](./docs/script-lifetime.md).
66
+
61
67
  ## Execution flow (summary)
62
68
 
63
69
  **Three task types — pick first:**
@@ -103,5 +109,10 @@ HQ EDA desktop must be running with a schematic project open.
103
109
  `RunChecks`, `GetSelection` and `Export*` are **unimplemented** in this engine
104
110
  build — do not build flows on them. Full map: [docs/rpc-availability.md](./docs/rpc-availability.md).
105
111
  - Timing baselines (not SLA): snapshot ~1.6–3.2 s; pattern/place script ~15–20 s.
112
+ - Scripts enter via `hqMain` / `hqMainWithProject` (`scripts/lib/hq.ts`) — never a bare
113
+ `connect()` + `main().catch()`. Otherwise leaked node processes accumulate on retry.
114
+ - `GetPageOccupancy` is on **`patternLayout`**, not `canvasOps`; its response field is
115
+ `items[].objectId` (there is no `objectIds`).
116
+ - `GetSnapshot` payloads live one level down: `response.snapshot.symbolInstances`.
106
117
 
107
118
  Full rules: [SYSTEM-PROMPT.md](./SYSTEM-PROMPT.md).
@@ -23,10 +23,14 @@
23
23
  7. `docs/editing-a-circuit.md` — **局部改电路(Flow C)** / 确认 diff / 修改集 / 验收
24
24
  8. `docs/rpc-availability.md` — 接口可用性、禁建列表、耗时基线
25
25
  9. `docs/serialization.md` — toJsonString 与 BigInt
26
- 10. `docs/rpc/*.md` — 仅当需要某个 RPC 的精确字段名时查阅
26
+ 10. `docs/script-lifetime.md` — **进程生命周期:必须用 `hqMain`,否则泄漏 node 进程**(写任何脚本前先读)
27
+ 11. `docs/rpc/*.md` — 仅当需要某个 RPC 的精确字段名时查阅
27
28
 
28
29
  ## 硬性规则
29
30
 
31
+ - **所有脚本必须经 `scripts/lib/hq.ts` 的 `hqMain` / `hqMainWithProject` 进入**;**禁止**裸 `connect()` + `main().catch(e => process.exit(1))`。该外壳负责:每个 RPC 超时、硬看门狗、`client.close()`、保证进程退出。见 `docs/script-lifetime.md`。
32
+ - **不要依赖"脚本会自己退出"**:卡住的 RPC 会让 node 进程永久存活,重试一次多泄漏一个。
33
+
30
34
  - 器件(R/C/Q/U):在线搜索 + `client.componentPlace.placeKicadSymbol`;**禁止**手写 `json_part` / `PlacePart`
31
35
  - 电源符号:`ListSymbolLibraries` → `PlaceSymbolFromLibrary`
32
36
  - `kind`:电源/地用 **`LibrarySymbolKind.GLOBAL`(=1)**;`OFF_PAGE=2`,`PORT=3`
@@ -37,7 +41,7 @@
37
41
  - **每条 net 先跑 routing gate**:量距离 → 短(≤300 ext、交叉<3)用 `autoConnect`;长/拥挤用双端 `placePinStubWireAndNetAlias`(同一 `netName`)
38
42
  - 字母引脚(CC1、A5):用 `pinName` / `pinNumber` **字符串**,禁止 `Number("A5")`
39
43
  - **pattern 的 host 电源引脚必须用 `pinName` 绑定**(如 `pinName:"VIN"`);用 `pinNum` 会返回 `PIN_RESOLVE_FAILED`
40
- - **放置后必须等对象注册再 apply**:轮询 `getPageOccupancy` 直到所有新 id 出现,否则 apply 报 `OBJECT_NOT_FOUND` 或假 `PARTIAL`
44
+ - **放置后必须等对象注册再 apply**:轮询 `client.patternLayout.getPageOccupancy`(**不是 canvasOps**;响应字段是 `items[].objectId`,**没有** `objectIds`)直到所有新 id 出现,否则 apply 报 `OBJECT_NOT_FOUND` 或假 `PARTIAL`。推荐直接调用 `waitForRegistered(client, ctx, ids)`(`scripts/lib/hq.ts`)。
41
45
  - **读取导线段字段是 `wires`**(`listWireSegments().wires`),**不是** `wireSegments`
42
46
  - 打印 protobuf 响应用 `toJsonString`;禁止对 RPC 结果裸 `JSON.stringify()`。遍历快照 `position.x/y`(bigint)时见 `docs/serialization.md` replacer
43
47
  - 耗时基线(区间,非 SLA):快照约 **1.6–3.2s**;单次 pattern/放置脚本约 **15–20s**(抖动正常)。勿用超时反推「未实现」
@@ -74,7 +78,7 @@
74
78
  ```
75
79
  connect → getActiveProject → createProjectContext
76
80
  → 放置 host / 独立器件(part-search → placeKicadSymbol;电源符号 → PlaceSymbolFromLibrary)
77
- → 【等待注册】轮询 getPageOccupancy 直到所有新 id 出现(关键!否则 apply 失败)
81
+ → 【等待注册】轮询 `client.patternLayout.getPageOccupancy`(或 `waitForRegistered()`)直到所有新 id 出现(关键!否则 apply 失败)
78
82
  → 规范位号 / 容值(覆盖引擎自动分配的 C1..C4)
79
83
  → applyCircuitPattern(每个适用 pattern 一次;先 planOnly 可选,再正式 apply)
80
84
  → 仅对 pattern 未覆盖的 net 做 hand-wire(每条 net 先跑 routing gate)
@@ -157,26 +161,23 @@
157
161
  * [<leftover>] → hand (routing gate: 短 autoConnect / 长 stub+alias)
158
162
  * =====================================
159
163
  */
160
- import { connect, toJsonString } from "@huaqiu/huaqiu-client";
161
164
  import { HqServicesV1PatternLayoutService } from "@hqedge/connect";
165
+ import { hqMainWithProject, waitForRegistered } from "./lib/hq.js";
162
166
 
163
167
  const PL = HqServicesV1PatternLayoutService; // 枚举来源
164
- const INSTANCE = process.env.HQ_INSTANCE_ID;
165
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
166
-
167
- async function main() {
168
- const client = INSTANCE
169
- ? await connect({ instanceId: INSTANCE })
170
- : await connect();
171
168
 
172
- const editorCtx = client.createEditorContext();
173
- const active = await client.project.getActiveProject({ context: editorCtx });
174
- const projectId = active.project?.projectId;
175
- if (!projectId) throw new Error("请先在 HQ EDA 中打开原理图工程");
176
- const ctx = client.createProjectContext(projectId);
169
+ // ⚠ 必须走 hqMainWithProject:它给每个 RPC 加超时、挂硬看门狗、finally 里
170
+ // client.close(),并保证进程退出。裸 connect() 会泄漏 node 进程。
171
+ hqMainWithProject(async ({ client, projectId, projectContext: ctx }) => {
172
+ console.log("projectId:", projectId);
177
173
 
178
174
  // 1) 放置 host / 独立器件(placeKicadSymbol;电源符号 PlaceSymbolFromLibrary)
179
- // 2) 【关键】等待注册:轮询 getPageOccupancy 直到所有新 id 出现
175
+ // 记下返回的 objectId,放进 newIds
176
+ const newIds: bigint[] = [];
177
+
178
+ // 2) 【关键】等待注册:所有新 id 都出现在页面上再继续
179
+ await waitForRegistered(client, ctx, newIds);
180
+
180
181
  // 3) 规范位号 / 容值
181
182
  // 4) applyCircuitPattern — host 引脚用 pinName 绑定
182
183
  // pattern: PL.CircuitPatternId.CIRCUIT_PATTERN_xxx
@@ -189,11 +190,12 @@ async function main() {
189
190
 
190
191
  await client.canvasOps.zoomAll({ context: ctx });
191
192
  console.log("done");
192
- }
193
-
194
- main().catch((e) => { console.error(e); process.exit(1); });
193
+ });
195
194
  ```
196
195
 
196
+ **禁止**把结尾写成 `main().catch((e) => { console.error(e); process.exit(1); })` ——
197
+ 成功路径没有 `process.exit(0)`,且卡住的 RPC 会让进程永不退出。
198
+
197
199
  ## 输出格式
198
200
 
199
201
  1. 简短设计说明(用了哪些 pattern、接线策略)
@@ -20,13 +20,21 @@ Do not reverse-engineer “performance regression” from a single hang: some
20
20
  unimplemented RPCs historically blocked for minutes before returning a clean
21
21
  `unimplemented` in ~2 ms. Prefer this table over timeout guessing.
22
22
 
23
+ > **But never wait indefinitely.** A stalled RPC leaves the HTTP/2 stream open,
24
+ > which `ref()`s the Node event loop and makes the `tsx` process immortal — every
25
+ > retry leaks another one. All scripts therefore go through `hqMain` /
26
+ > `hqMainWithProject` in `scripts/lib/hq.ts`, which injects a per-RPC deadline
27
+ > (default 30 s) and a hard watchdog (default 180 s, exit code `124`).
28
+ > See [script-lifetime.md](./script-lifetime.md).
29
+
23
30
  ## Works — OK for flows
24
31
 
25
32
  | Service | Method | Role |
26
33
  | --- | --- | --- |
27
34
  | `kernel` | `GetSnapshot` | **Primary** read-circuit / verify source (project-wide) |
28
35
  | `netList` | `GetProjectNetList` / `GetActivePageNetList` / `GetSelectionNetList` | Netlist semantics; accessor is **`netList`** (capital L) |
29
- | `canvasOps` / pattern host | `GetPageOccupancy` | Object registration poll after place |
36
+ | `patternLayout` (**not** `canvasOps`) | `GetPageOccupancy` | Object registration poll after place; response is **`items[].objectId`** — there is no `objectIds` field |
37
+ | `canvasOps` | `AutoConnectObjectsById` | Pin-to-pin connect; `pinNum` accepts **numeric pins only** |
30
38
  | `canvasOps` | `ListWireSegments` | Wire stats — field is **`wires`**, not `wireSegments` |
31
39
  | `canvasOps` | `GetObjectsJsonByIds` / `GetObjectJsonById` | Optional deep object JSON (advanced) |
32
40
  | `find` | `FindObject` | Optional object query (advanced; not Flow B primary) |
@@ -0,0 +1,92 @@
1
+ # 脚本进程生命周期(强制)
2
+
3
+ > 每个脚本都必须通过 `scripts/lib/hq.ts` 的 `hqMain` / `hqMainWithProject` 进入。
4
+ > 直接写 `connect(...)` + `main().catch(() => process.exit(1))` 会泄漏 node 进程。
5
+
6
+ ## 症状
7
+
8
+ 跑完脚本后 `ps aux | grep tsx` 里残留一堆 node 进程;agent 重试 3 次就多 3 个;
9
+ 最终机器变卡、端口/内存被吃光,而 agent 看到的只是"命令没返回"。
10
+
11
+ ## 根因
12
+
13
+ `connect()` 内部用 `createGrpcTransport`。HTTP/2 stream 打开期间,会话管理器会
14
+ `ref()` Node 事件循环。若某个 RPC 迟迟不返回:
15
+
16
+ 1. `await` 永不 settle;
17
+ 2. stream 不关闭 → 事件循环无法排空;
18
+ 3. node 进程永远不退出。
19
+
20
+ `docs/rpc-availability.md` 明确记录过"部分 RPC 历史上阻塞数分钟后才返回 clean
21
+ unimplemented",而它同时又叮嘱"不要用超时反推未实现"——两者叠加,agent 就会
22
+ 无限等下去,再重试,再泄漏一个进程。
23
+
24
+ ### 为什么 `finally { client.close() }` 救不了
25
+
26
+ `await` 不 settle 时,控制流根本到不了 `finally`。必须先让 `await` 一定会 settle,
27
+ 清理才有意义。`EditorClient.close()` 的官方注释也只保证"测试进程能退出",
28
+ 不保证"卡住的 await 能被唤醒"。
29
+
30
+ ## `scripts/lib/hq.ts` 做什么
31
+
32
+ | 机制 | 作用 |
33
+ | --- | --- |
34
+ | 每个 RPC 注入 `AbortSignal` 超时 | 让 `await` 一定会 settle(默认 30s) |
35
+ | 硬看门狗 | 无论如何进程都会在 N ms 内终止(默认 180s,退出码 124) |
36
+ | `finally { client.close() }` | 释放 HTTP/2 连接 |
37
+ | `process.exitCode` + 冲刷宽限 | 保证 stdout/stderr 完整输出,不会被 `process.exit()` 截断 |
38
+
39
+ 超时由客户端级代理注入,所以**调用点不用改**——
40
+ `client.kernel.getSnapshot({ context })` 依旧能写,底层已经带 deadline。
41
+ 这一点是刻意的:靠 agent 记得给每个 RPC 传 `{ signal }` 是不可靠的。
42
+
43
+ ## 用法
44
+
45
+ ```typescript
46
+ import { hqMain, hqMainWithProject, waitForRegistered } from "./lib/hq.js";
47
+
48
+ // 只需要连接
49
+ hqMain(async (client) => { /* ... */ });
50
+
51
+ // 需要活动工程(绝大多数 Flow A/B/C 脚本)
52
+ hqMainWithProject(async ({ client, projectId, projectContext: ctx }) => {
53
+ // ...
54
+ await waitForRegistered(client, ctx, newIds); // 放置后等注册
55
+ });
56
+ ```
57
+
58
+ **不要**再写:
59
+
60
+ ```typescript
61
+ const client = await connect({ instanceId: process.env.HQ_INSTANCE_ID });
62
+ // ...
63
+ main().catch((e) => { console.error(e); process.exit(1); });
64
+ ```
65
+
66
+ ## 环境变量
67
+
68
+ | 变量 | 默认 | 说明 |
69
+ | --- | --- | --- |
70
+ | `HQ_INSTANCE_ID` | — | 多开编辑器时指定实例;**未设时不要传 `undefined`**,否则绕过自动发现 |
71
+ | `HQ_RPC_TIMEOUT_MS` | 30000 | 单个 RPC 超时 |
72
+ | `HQ_HARD_TIMEOUT_MS` | 180000 | 整个脚本硬上限 |
73
+
74
+ ## 退出码
75
+
76
+ | 码 | 含义 |
77
+ | --- | --- |
78
+ | `0` | 成功 |
79
+ | `1` | 脚本抛错(stderr 有 `❌` 前缀) |
80
+ | `124` | 硬看门狗触发 —— 说明有 RPC 卡死且 deadline 未生效,需排查而非调大超时 |
81
+
82
+ 看到 124 **不要**简单调大 `HQ_HARD_TIMEOUT_MS`:那只是把泄漏时间拉长。
83
+ 应该查是哪个 RPC 卡住,并把它加入 `docs/rpc-availability.md` 的禁建表。
84
+
85
+ ## 顺带修掉的两个坑(同一批改动)
86
+
87
+ - `GetPageOccupancy` 在 **`client.patternLayout`** 上,不在 `canvasOps`;
88
+ 响应字段是 **`items[].objectId`**,没有 `objectIds`。旧写法
89
+ `(occ.objectIds ?? [])` 恒为空 → 轮询第一次就"通过"。用 `waitForRegistered()`。
90
+ - `GetSnapshotResponse` 的实体在 **`snapshot` 内层**:
91
+ `snap.snapshot.symbolInstances`,不是 `snap.symbolInstances`
92
+ (后者恒为 `undefined`,会静默打印 0)。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://schema.huaqiu.com/skills/v1/skill.schema.json",
3
- "id": "hqeda-scenario-b",
3
+ "id": "hqsch-scenario-b",
4
4
  "name": "HQ EDA Scenario B — Coding Agent + Local Scripts",
5
5
  "vendor": "Huaqiu Electronics",
6
6
  "version": "0.1.0",
@@ -1,15 +1,17 @@
1
1
  {
2
- "name": "hqeda-scenario-b",
2
+ "name": "hqsch-scenario-b",
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "scripts": {
6
- "hello": "tsx scripts/hello.ts"
6
+ "hello": "tsx scripts/hello.ts",
7
+ "typecheck": "tsc --noEmit"
7
8
  },
8
9
  "dependencies": {
9
10
  "@huaqiu/huaqiu-client": "^0.1.7",
10
11
  "@huaqiu/hqeda": "^0.2.7"
11
12
  },
12
13
  "devDependencies": {
14
+ "@types/node": "^22.10.0",
13
15
  "tsx": "^4.19.0",
14
16
  "typescript": "^5.7.0"
15
17
  }
@@ -2,20 +2,15 @@
2
2
  * Flow C 配方:按位号改属性
3
3
  * REF=C201 PROP=Value NEW="1 µF" CONFIRM=1 npx tsx scripts/edit-property-by-ref.ts
4
4
  */
5
- import { connect } from "@huaqiu/huaqiu-client";
5
+ import { hqMainWithProject } from "./lib/hq.js";
6
6
 
7
7
  const REF = process.env.REF;
8
8
  const PROP = process.env.PROP ?? "Value";
9
9
  const NEW = process.env.NEW;
10
10
  const CONFIRM = process.env.CONFIRM === "1";
11
11
 
12
- async function main() {
12
+ hqMainWithProject(async ({ client, projectId, projectContext: ctx }) => {
13
13
  if (!REF || NEW === undefined) throw new Error("需要 REF= 与 NEW=");
14
- const client = await connect({ instanceId: process.env.HQ_INSTANCE_ID });
15
- const active = await client.project.getActiveProject({ context: client.createEditorContext() });
16
- const projectId = active.project?.projectId;
17
- if (!projectId) throw new Error("请先打开工程");
18
- const ctx = client.createProjectContext(projectId);
19
14
  console.log("projectId:", projectId);
20
15
 
21
16
  const found = await client.canvasOps.findObjectByProperty({ context: ctx, propKey: "Reference", propValue: REF });
@@ -39,6 +34,4 @@ async function main() {
39
34
  .symbolInstances?.find((s) => s.designator === REF);
40
35
  const v = sym?.metadata?.properties?.find((p) => p.key === PROP);
41
36
  console.log("快照验收:", PROP, "=", v ? String(Object.values(v.value as object)[0] ?? v.value) : NEW);
42
- }
43
-
44
- main().catch((e) => { console.error("❌", e.message); process.exit(1); });
37
+ });
@@ -3,7 +3,7 @@
3
3
  * C1 → C_IN (10 μF), C2 → C_IN_BYPASS (0.1 μF)
4
4
  * C3 → C_OUT1 (10 μF), C4 → C_OUT2 (0.1 μF)
5
5
  */
6
- import { connect } from "@huaqiu/huaqiu-client";
6
+ import { hqMainWithProject } from "./lib/hq.js";
7
7
 
8
8
  const RENAMES: Array<[string, string, string]> = [
9
9
  // [oldRef, newRef, value]
@@ -13,13 +13,7 @@ const RENAMES: Array<[string, string, string]> = [
13
13
  ["C4", "C_OUT2", "0.1 μF"],
14
14
  ];
15
15
 
16
- async function main() {
17
- const client = await connect();
18
- const active = await client.project.getActiveProject({ context: client.createEditorContext() });
19
- const projectId = active.project?.projectId;
20
- if (!projectId) throw new Error("no project");
21
- const ctx = client.createProjectContext(projectId);
22
-
16
+ hqMainWithProject(async ({ client, projectContext: ctx }) => {
23
17
  for (const [oldRef, newRef, value] of RENAMES) {
24
18
  const found = await client.canvasOps.findObjectByProperty({
25
19
  context: ctx, propKey: "Reference", propValue: oldRef,
@@ -47,6 +41,4 @@ async function main() {
47
41
  console.log(` ${newRef}: ${(r.objectIds?.length ?? 0) > 0 ? "✓" : "✗"}`);
48
42
  }
49
43
  await client.canvasOps.zoomAll({ context: ctx });
50
- }
51
-
52
- main().catch((e) => { console.error("错误:", e.message); process.exit(1); });
44
+ });
@@ -5,19 +5,16 @@
5
5
  * npx tsx scripts/hello.ts
6
6
  *
7
7
  * Optional: HQ_INSTANCE_ID=<id> when multiple editors are open (run listEditors first).
8
+ *
9
+ * 进程一定会退出(含 RPC 超时与硬看门狗),见 scripts/lib/hq.ts。
8
10
  */
9
- import { connect, listEditors, toJsonString } from "@huaqiu/huaqiu-client";
10
-
11
- const INSTANCE = process.env.HQ_INSTANCE_ID;
11
+ import { listEditors, toJsonString } from "@huaqiu/huaqiu-client";
12
+ import { hqMain } from "./lib/hq.js";
12
13
 
13
- async function main() {
14
+ hqMain(async (client) => {
14
15
  const editors = await listEditors();
15
16
  console.log("editors:", toJsonString(editors, { prettySpaces: 2 }));
16
17
 
17
- const client = INSTANCE
18
- ? await connect({ instanceId: INSTANCE })
19
- : await connect();
20
-
21
18
  console.log("connected:", client.info.instanceId);
22
19
 
23
20
  const editorCtx = client.createEditorContext();
@@ -29,18 +26,15 @@ async function main() {
29
26
  console.log("active project:", active.project?.name, projectId);
30
27
 
31
28
  const ctx = client.createProjectContext(projectId);
32
- const snap = await client.kernel.getSnapshot({ context: ctx });
33
- const parts = snap.symbolInstances?.length ?? 0;
34
- const wires = snap.wireSegments?.length ?? 0;
29
+ // 注意:实体在 `snapshot` 内层(`GetSnapshotResponse.snapshot`),直接读
30
+ // `snap.symbolInstances` 恒为 undefined,会静默打印 0。
31
+ const snapshot = (await client.kernel.getSnapshot({ context: ctx })).snapshot;
32
+ const parts = snapshot?.symbolInstances?.length ?? 0;
33
+ const wires = snapshot?.wireSegments?.length ?? 0;
35
34
  console.log(`snapshot: ${parts} symbols, ${wires} wire segments on active page`);
36
35
 
37
36
  await client.canvasOps.zoomAll({ context: ctx });
38
37
  console.log("zoomAll: OK");
39
38
 
40
39
  console.log("OK — ready for LLM-generated placement scripts");
41
- }
42
-
43
- main().catch((e) => {
44
- console.error(e);
45
- process.exit(1);
46
40
  });
@@ -0,0 +1,346 @@
1
+ /**
2
+ * scripts/lib/hq.ts —— Scenario B 所有脚本的统一运行时外壳(强制使用)
3
+ *
4
+ * ── 为什么必须有这一层(改动前请读完)────────────────────────────
5
+ * `connect()` 内部使用 `createGrpcTransport`,它在 HTTP/2 stream 打开期间会
6
+ * `ref()` Node 事件循环。若某个 RPC 迟迟不返回(docs/rpc-availability.md 记录了
7
+ * "历史上阻塞数分钟后才返回 clean unimplemented" 的情况),`await` 永不 settle、
8
+ * stream 不关闭,事件循环无法排空 —— node 进程变成"永生进程"。agent 每次重试
9
+ * (最多 3 次)都会再泄漏一个进程。
10
+ *
11
+ * 关键陷阱:**把 `client.close()` 写进 `finally` 救不了**。`await` 不 settle 时
12
+ * `finally` 根本不会执行。必须先让 `await` 一定会 settle,再谈清理。
13
+ *
14
+ * 因此本外壳同时做三件事:
15
+ * 1. 每个 RPC 注入 `AbortSignal` 超时 → `await` 一定会 settle;
16
+ * 2. 硬看门狗(hard watchdog)→ 无论发生什么,进程都能在 N 毫秒内终止;
17
+ * 3. `finally` 中 `client.close()`,并给 stdout 留出冲刷时间后退出。
18
+ *
19
+ * ── 用法 ───────────────────────────────────────────────────────
20
+ * import { hqMain, hqMainWithProject } from "./lib/hq.js";
21
+ *
22
+ * hqMain(async (client) => { ... }); // 只需连接
23
+ * hqMainWithProject(async ({ client, projectContext }) => { ... }); // 还需要活动工程
24
+ *
25
+ * 禁止再直接写 `connect(...)` + `main().catch(e => process.exit(1))`,
26
+ * 那正是进程泄漏的来源。
27
+ *
28
+ * 环境变量:
29
+ * HQ_INSTANCE_ID 多开编辑器时指定实例
30
+ * HQ_RPC_TIMEOUT_MS 单个 RPC 超时(默认 30000)
31
+ * HQ_HARD_TIMEOUT_MS 整个脚本硬超时(默认 180000)
32
+ */
33
+
34
+ import { connect, type EditorClient } from "@huaqiu/huaqiu-client";
35
+
36
+ export type { EditorClient };
37
+
38
+ /** 单个 RPC 的默认超时。超过即抛 DeadlineExceeded,而不是无限等待。 */
39
+ export const DEFAULT_RPC_TIMEOUT_MS = 30_000;
40
+ /** 整个脚本的硬上限。到点强制退出,杜绝进程堆积。 */
41
+ export const DEFAULT_HARD_TIMEOUT_MS = 180_000;
42
+ /** 退出前留给 stdout/stderr 冲刷的宽限期。 */
43
+ const FLUSH_GRACE_MS = 3_000;
44
+
45
+ export type ProjectContext = ReturnType<EditorClient["createProjectContext"]>;
46
+ export type EditorContext = ReturnType<EditorClient["createEditorContext"]>;
47
+
48
+ export interface HqOptions {
49
+ /** 多开 HQ EDA 时指定实例;未设则走自动发现。 */
50
+ instanceId?: string;
51
+ /** 直连指定 gRPC endpoint(测试 / 远程调试用);未设则走本地注册表发现。 */
52
+ grpcEndpoint?: string;
53
+ /** 覆盖单 RPC 超时(毫秒)。 */
54
+ rpcTimeoutMs?: number;
55
+ /** 覆盖硬看门狗(毫秒)。 */
56
+ hardTimeoutMs?: number;
57
+ }
58
+
59
+ export interface HqProjectSession {
60
+ /** 已注入 RPC 超时代理的客户端(务必用这个,不要用裸 connect 的结果)。 */
61
+ client: EditorClient;
62
+ projectId: string;
63
+ projectContext: ProjectContext;
64
+ editorContext: EditorContext;
65
+ }
66
+
67
+ type AnyFn = (...args: any[]) => any;
68
+ type CallOptions = { signal?: AbortSignal; timeoutMs?: number; [k: string]: unknown };
69
+
70
+ function positiveIntEnv(name: string, fallback: number): number {
71
+ const raw = process.env[name];
72
+ if (raw === undefined || raw === "") return fallback;
73
+ const n = Number(raw);
74
+ return Number.isFinite(n) && n > 0 ? n : fallback;
75
+ }
76
+
77
+ /**
78
+ * EditorClient 上属于 gRPC-CONNECT 服务的命名空间。
79
+ * 只有这些需要注入 deadline;`parts`(part-search,HTTP)与 `info`/`sessionId`
80
+ * 等普通字段不在此列。
81
+ */
82
+ const RPC_NAMESPACES = new Set<string>([
83
+ "discovery",
84
+ "capability",
85
+ "context",
86
+ "project",
87
+ "kernel",
88
+ "graph",
89
+ "selection",
90
+ "canvasOps",
91
+ "componentPlace",
92
+ "objPlace",
93
+ "patternLayout",
94
+ "interactivePlace",
95
+ "erc",
96
+ "edaBom",
97
+ "netList",
98
+ "runtime",
99
+ "transaction",
100
+ "event",
101
+ "find",
102
+ "import",
103
+ "export",
104
+ ]);
105
+
106
+ /**
107
+ * 给单个服务命名空间套一层代理:每次调用都自动补上 `signal`。
108
+ *
109
+ * 这样调用点无需改动——`client.kernel.getSnapshot({ context })` 依然写得出来,
110
+ * 但底层已经带上了超时。这一点很重要:agent 生成的脚本不可能记得给每个 RPC
111
+ * 手动传 options,必须由外壳兜住。
112
+ */
113
+ function withDeadline<T extends object>(service: T, timeoutMs: number): T {
114
+ return new Proxy(service, {
115
+ get(target, prop) {
116
+ const value = (target as Record<string | symbol, unknown>)[prop];
117
+ if (typeof value !== "function") return value;
118
+
119
+ const fn = value as AnyFn;
120
+ return (request?: unknown, callOptions?: CallOptions) => {
121
+ const controller = new AbortController();
122
+ const timer = setTimeout(() => {
123
+ controller.abort(new Error(`RPC 超时 ${timeoutMs}ms`));
124
+ }, timeoutMs);
125
+ const call = Promise.resolve(
126
+ fn.call(target, request, { signal: controller.signal, ...(callOptions ?? {}) }),
127
+ );
128
+ // 无论成功失败都清掉计时器,避免无谓地挂住事件循环。
129
+ return call.finally(() => clearTimeout(timer));
130
+ };
131
+ },
132
+ });
133
+ }
134
+
135
+ /** 客户端级代理:方法绑定回原对象,RPC 命名空间则换成带 deadline 的版本。 */
136
+ function withDeadlineClient(client: EditorClient, timeoutMs: number): EditorClient {
137
+ return new Proxy(client, {
138
+ get(target, prop) {
139
+ const value = Reflect.get(target, prop, target);
140
+ if (typeof prop === "string" && RPC_NAMESPACES.has(prop) && typeof value === "object" && value !== null) {
141
+ return withDeadline(value as object, timeoutMs);
142
+ }
143
+ return typeof value === "function" ? (value as AnyFn).bind(target) : value;
144
+ },
145
+ }) as EditorClient;
146
+ }
147
+
148
+ /**
149
+ * 退出:先设 exitCode 让 stdout/stderr 有机会冲刷干净;
150
+ * 若仍有句柄吊住事件循环,兜底强杀。绝不让进程"安静地永远活着"。
151
+ */
152
+ function finish(code: number): void {
153
+ process.exitCode = code;
154
+ const grace = setTimeout(() => process.exit(code), FLUSH_GRACE_MS);
155
+ (grace as unknown as { unref?: () => void }).unref?.();
156
+ }
157
+
158
+ /** 连接 → 执行 → 关闭 → 退出。Scenario B 脚本的唯一入口。 */
159
+ export async function hqMain(
160
+ fn: (client: EditorClient) => Promise<void>,
161
+ options: HqOptions = {},
162
+ ): Promise<void> {
163
+ const rpcTimeoutMs = options.rpcTimeoutMs ?? positiveIntEnv("HQ_RPC_TIMEOUT_MS", DEFAULT_RPC_TIMEOUT_MS);
164
+ const hardTimeoutMs = options.hardTimeoutMs ?? positiveIntEnv("HQ_HARD_TIMEOUT_MS", DEFAULT_HARD_TIMEOUT_MS);
165
+ const instanceId = options.instanceId ?? process.env.HQ_INSTANCE_ID;
166
+
167
+ // 硬看门狗:故意不 unref——即使 await 永不 settle、且没有任何 ref 住的句柄
168
+ // (那种情况下 Node 会静默退出、脚本像"成功"一样消失),也要让它变成一次
169
+ // 响亮的 exit 124。
170
+ const watchdog = setTimeout(() => {
171
+ console.error(
172
+ `\n⏱ 硬看门狗触发(${hardTimeoutMs}ms):强制终止进程。\n` +
173
+ ` 常见原因:某个 RPC 未返回且 deadline 未生效。调大 HQ_HARD_TIMEOUT_MS 只能掩盖问题。`,
174
+ );
175
+ process.exit(124);
176
+ }, hardTimeoutMs);
177
+
178
+ let client: EditorClient | undefined;
179
+ let exitCode = 0;
180
+
181
+ try {
182
+ // 只在真的有值时传 instanceId——传 undefined 会绕过自动发现逻辑。
183
+ const connectOptions: { instanceId?: string; grpcEndpoint?: string } = {};
184
+ if (options.grpcEndpoint) connectOptions.grpcEndpoint = options.grpcEndpoint;
185
+ else if (instanceId) connectOptions.instanceId = instanceId;
186
+ client = await connect(connectOptions);
187
+ await fn(withDeadlineClient(client, rpcTimeoutMs));
188
+ } catch (e) {
189
+ exitCode = 1;
190
+ console.error("❌", e instanceof Error ? e.message : e);
191
+ } finally {
192
+ clearTimeout(watchdog);
193
+ try {
194
+ client?.close();
195
+ } catch {
196
+ // 关闭失败无所谓,连接已经要没了
197
+ }
198
+ }
199
+
200
+ finish(exitCode);
201
+ }
202
+
203
+ /** `hqMain` + 取活动工程并建好 ProjectContext。Flow A/B/C 绝大多数脚本用这个。 */
204
+ export async function hqMainWithProject(
205
+ fn: (session: HqProjectSession) => Promise<void>,
206
+ options: HqOptions = {},
207
+ ): Promise<void> {
208
+ return hqMain(async (client) => {
209
+ const editorContext = client.createEditorContext();
210
+ const active = await client.project.getActiveProject({ context: editorContext });
211
+ const projectId = active.project?.projectId;
212
+ if (!projectId) throw new Error("请先在 HQ EDA 中打开原理图工程");
213
+ await fn({
214
+ client,
215
+ projectId,
216
+ projectContext: client.createProjectContext(projectId),
217
+ editorContext,
218
+ });
219
+ }, options);
220
+ }
221
+
222
+ /**
223
+ * 自然序比较,用于引脚号排序。
224
+ *
225
+ * 禁止用 `Number(a) - Number(b)`:位号引脚常是 "A5"/"CC1" 这类字母前缀,
226
+ * `Number("A5")` 得到 NaN,排序直接失去意义。
227
+ */
228
+ export function naturalCompare(a: string, b: string): number {
229
+ const re = /(\d+)|(\D+)/g;
230
+ const pa = String(a).match(re) ?? [];
231
+ const pb = String(b).match(re) ?? [];
232
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
233
+ const x = pa[i];
234
+ const y = pb[i];
235
+ if (x === undefined) return -1;
236
+ if (y === undefined) return 1;
237
+ const nx = /^\d/.test(x);
238
+ const ny = /^\d/.test(y);
239
+ if (nx && ny) {
240
+ const d = Number(x) - Number(y);
241
+ if (d !== 0) return d;
242
+ } else if (x !== y) {
243
+ return x < y ? -1 : 1;
244
+ }
245
+ }
246
+ return 0;
247
+ }
248
+
249
+ /**
250
+ * 把引脚号解析为 RPC 需要的数字。
251
+ *
252
+ * `pinNum1` 只接受数字,而原理图上引脚号可能是 "A5"/"CC1"。硬规则:不要对
253
+ * 非纯数字引脚做 `Number()` —— 那会静默产生 NaN 并连错脚。这里显式抛错,
254
+ * 让调用方改用 pinName 路径。
255
+ */
256
+ export function pinNumber(pin: string | undefined, label = "PIN"): number {
257
+ const s = String(pin ?? "").trim();
258
+ if (!/^\d+$/.test(s)) {
259
+ throw new Error(
260
+ `${label}="${s}" 不是纯数字引脚号,无法用于 pinNum(autoConnect)。` +
261
+ `字母前缀引脚请改用 pinName 路径,或先在快照中确认引脚编号。`,
262
+ );
263
+ }
264
+ return Number(s);
265
+ }
266
+
267
+ /** 轮询用 sleep。 */
268
+ export const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
269
+
270
+ /* ────────────────────────────────────────────────────────────────
271
+ * 对象注册等待(放置后的关键步骤)
272
+ * ────────────────────────────────────────────────────────────────
273
+ * 两个易错点,都已在旧版脚本/文档中出现过:
274
+ * 1. `GetPageOccupancy` 属于 **PatternLayoutService**(`client.patternLayout`),
275
+ * 不在 `canvasOps` 上。
276
+ * 2. 响应字段是 **`items: PageOccupancyItem[]`**(每项 `objectId`/`objType`/
277
+ * `designator`/`bbox`),**没有** `objectIds` 这个字段。
278
+ * 读 `occ.objectIds ?? []` 恒为空 → 轮询第一次就"通过",随后 apply
279
+ * 报 `OBJECT_NOT_FOUND` 或假 `PARTIAL`。
280
+ * ──────────────────────────────────────────────────────────────── */
281
+
282
+ async function occupancyIds(client: EditorClient, ctx: ProjectContext): Promise<Set<string>> {
283
+ const occ = await client.patternLayout.getPageOccupancy({ context: ctx });
284
+ const items = (occ as unknown as { items?: Array<{ objectId?: bigint | string }> }).items ?? [];
285
+ return new Set(items.map((it) => String(it.objectId)));
286
+ }
287
+
288
+ export interface PollOptions {
289
+ /** 最长等待时间,默认 15000ms。超时抛错,不静默放过。 */
290
+ timeoutMs?: number;
291
+ /** 轮询间隔,默认 300ms。 */
292
+ intervalMs?: number;
293
+ /** 日志前缀。 */
294
+ label?: string;
295
+ }
296
+
297
+ /** 等到所有 objectId 都出现在页面上(放置后立即调用,再 apply pattern)。 */
298
+ export async function waitForRegistered(
299
+ client: EditorClient,
300
+ ctx: ProjectContext,
301
+ objectIds: Array<string | bigint>,
302
+ options: PollOptions = {},
303
+ ): Promise<void> {
304
+ const timeoutMs = options.timeoutMs ?? 15_000;
305
+ const intervalMs = options.intervalMs ?? 300;
306
+ const wanted = objectIds.map(String);
307
+ const deadline = Date.now() + timeoutMs;
308
+
309
+ for (;;) {
310
+ const present = await occupancyIds(client, ctx);
311
+ const missing = wanted.filter((id) => !present.has(id));
312
+ if (missing.length === 0) return;
313
+ if (Date.now() >= deadline) {
314
+ throw new Error(
315
+ `${options.label ?? "waitForRegistered"}: ${timeoutMs}ms 内未注册完成,仍缺 ${missing.length} 个对象 ` +
316
+ `(${missing.slice(0, 5).join(", ")}). 继续 apply 会得到 OBJECT_NOT_FOUND 或假 PARTIAL。`,
317
+ );
318
+ }
319
+ await sleep(intervalMs);
320
+ }
321
+ }
322
+
323
+ /** 等到指定 objectId 全部从页面上消失(删除后调用)。 */
324
+ export async function waitForRemoved(
325
+ client: EditorClient,
326
+ ctx: ProjectContext,
327
+ objectIds: Array<string | bigint>,
328
+ options: PollOptions = {},
329
+ ): Promise<void> {
330
+ const timeoutMs = options.timeoutMs ?? 15_000;
331
+ const intervalMs = options.intervalMs ?? 300;
332
+ const wanted = new Set(objectIds.map(String));
333
+ const deadline = Date.now() + timeoutMs;
334
+
335
+ for (;;) {
336
+ const present = await occupancyIds(client, ctx);
337
+ const still = [...wanted].filter((id) => present.has(id));
338
+ if (still.length === 0) return;
339
+ if (Date.now() >= deadline) {
340
+ throw new Error(
341
+ `${options.label ?? "waitForRemoved"}: ${timeoutMs}ms 后对象仍存在 (${still.slice(0, 5).join(", ")})`,
342
+ );
343
+ }
344
+ await sleep(intervalMs);
345
+ }
346
+ }
@@ -7,9 +7,8 @@
7
7
  * NET=+3.3V npx tsx scripts/read-circuit.ts
8
8
  * REF=VR201 TRACE=1 npx tsx scripts/read-circuit.ts
9
9
  */
10
- import { connect } from "@huaqiu/huaqiu-client";
10
+ import { hqMainWithProject, naturalCompare, type EditorClient, type ProjectContext } from "./lib/hq.js";
11
11
 
12
- const INSTANCE = process.env.HQ_INSTANCE_ID;
13
12
  const REF = process.env.REF;
14
13
  const NET = process.env.NET;
15
14
  const TRACE = process.env.TRACE === "1" || process.env.TRACE === "true";
@@ -135,7 +134,8 @@ class SnapCtx {
135
134
  net: this.netOf.get(pinMetaId(p)) ?? "(未连接)",
136
135
  pos: p.position,
137
136
  }))
138
- .sort((a, b) => Number(a.num) - Number(b.num));
137
+ // 引脚号可能是 "A5"/"CC1" —— 禁止 Number() 强转,用自然序比较。
138
+ .sort((a, b) => naturalCompare(a.num, b.num));
139
139
  }
140
140
  netMembers(netName: string, excludeRef?: string): Array<{ ref: string; pinLabel: string }> {
141
141
  const net = this.netByName.get(netName);
@@ -271,12 +271,7 @@ function printRoleSummary(ref: string, sym: Sym, rows: PinRow[], props: Array<{
271
271
  console.log(` 总结: 结合邻域${TRACE ? "/穿透" : ""}块描述 ${ref} 在局部子电路中的功能`);
272
272
  }
273
273
 
274
- async function resolveCanvasIds(
275
- client: Awaited<ReturnType<typeof connect>>,
276
- ctx: ReturnType<Awaited<ReturnType<typeof connect>>["createProjectContext"]>,
277
- ref: string,
278
- sym: Sym,
279
- ): Promise<void> {
274
+ async function resolveCanvasIds(client: EditorClient, ctx: ProjectContext, ref: string, sym: Sym): Promise<void> {
280
275
  console.log(`\n ── canvas 编辑 id(读懂 → 改电路桥接)──`);
281
276
  console.log(` snapshot metadata.id: ${symMetaId(sym)}`);
282
277
  console.log(` canvasObjectId: ${sym.canvasObjectId ?? "(空)"}`);
@@ -288,12 +283,7 @@ async function resolveCanvasIds(
288
283
  }
289
284
  }
290
285
 
291
- async function main() {
292
- const client = INSTANCE ? await connect({ instanceId: INSTANCE }) : await connect();
293
- const active = await client.project.getActiveProject({ context: client.createEditorContext() });
294
- const projectId = active.project?.projectId;
295
- if (!projectId) throw new Error("请先打开原理图工程");
296
- const pctx = client.createProjectContext(projectId);
286
+ hqMainWithProject(async ({ client, projectId, projectContext: pctx }) => {
297
287
  console.log(`工程 projectId: ${projectId}\n`);
298
288
 
299
289
  const t = Date.now();
@@ -352,6 +342,4 @@ async function main() {
352
342
  for (const b of bridgesFromNet(buildSeriesBridgeGraph(ctx), NET))
353
343
  console.log(` 串联: ${b.ref} → 对侧 ${otherNet(b, NET)}`);
354
344
  }
355
- }
356
-
357
- main().catch((e) => { console.error("❌", e.message); process.exit(1); });
345
+ });
@@ -3,24 +3,19 @@
3
3
  * OLD_REF=U3 CONFIRM=1 npx tsx scripts/replace-part-by-ref.ts
4
4
  *
5
5
  * 完整替换需:searchParts → placeKicadSymbol → 按 OLD 的 pin→net 逐脚 autoConnect
6
+ * ⚠ 本脚本只做"定位 + 删除",不放置新器件、不重连引脚。
6
7
  */
7
- import { connect } from "@huaqiu/huaqiu-client";
8
+ import { hqMainWithProject, waitForRemoved } from "./lib/hq.js";
8
9
 
9
10
  const OLD_REF = process.env.OLD_REF;
10
11
  const CONFIRM = process.env.CONFIRM === "1";
11
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
12
12
 
13
- async function main() {
13
+ hqMainWithProject(async ({ client, projectId, projectContext: ctx }) => {
14
14
  if (!OLD_REF) throw new Error("需要 OLD_REF=");
15
- const client = await connect({ instanceId: process.env.HQ_INSTANCE_ID });
16
- const active = await client.project.getActiveProject({ context: client.createEditorContext() });
17
- const projectId = active.project?.projectId;
18
- if (!projectId) throw new Error("请先打开工程");
19
- const ctx = client.createProjectContext(projectId);
20
15
  console.log("projectId:", projectId);
21
16
 
22
17
  const snap = await client.kernel.getSnapshot({ context: ctx });
23
- const s = snap.snapshot as Record<string, unknown[]>;
18
+ const s = snap.snapshot as unknown as Record<string, unknown[]>;
24
19
  const sym = (s.symbolInstances as Array<Record<string, unknown>>)?.find((x) => x.designator === OLD_REF);
25
20
  if (!sym) throw new Error(`快照无 ${OLD_REF}`);
26
21
 
@@ -32,13 +27,7 @@ async function main() {
32
27
  const id = found.objectIds?.[0];
33
28
  if (!id) throw new Error("FindObject 失败");
34
29
  await client.canvasOps.deleteObjectsByIds({ context: ctx, objectIds: [id] });
35
- for (let i = 0; i < 10; i++) {
36
- await sleep(300);
37
- const occ = await client.canvasOps.getPageOccupancy({ context: ctx });
38
- const ids = (occ.objectIds ?? []).map(String);
39
- if (!ids.includes(String(id))) break;
40
- }
30
+ // 删除后等对象真正消失(GetPageOccupancy 在 patternLayout 上,字段是 items[].objectId)
31
+ await waitForRemoved(client, ctx, [id], { label: `删除 ${OLD_REF}` });
41
32
  console.log(`✓ 已删除 ${OLD_REF} — 请 placeKicadSymbol 并 autoConnect 各 pin`);
42
- }
43
-
44
- main().catch((e) => { console.error("❌", e.message); process.exit(1); });
33
+ });
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Flow C 配方:把 REF 的某 pin 接到 TARGET_REF 的 TARGET_PIN(autoConnect)
3
3
  * REF=R201 PIN=1 TARGET_REF=U301 TARGET_PIN=14 CONFIRM=1 npx tsx scripts/rewire-pin-by-ref.ts
4
+ *
5
+ * 注意:`pinNum` 只接受纯数字。字母前缀引脚(A5/CC1…)请改用 pinName 路径,
6
+ * 本脚本会显式报错而不是静默传 NaN。
4
7
  */
5
- import { connect } from "@huaqiu/huaqiu-client";
8
+ import { hqMainWithProject, pinNumber } from "./lib/hq.js";
6
9
 
7
10
  const REF = process.env.REF;
8
11
  const PIN = process.env.PIN;
@@ -10,13 +13,8 @@ const TARGET_REF = process.env.TARGET_REF;
10
13
  const TARGET_PIN = process.env.TARGET_PIN ?? "1";
11
14
  const CONFIRM = process.env.CONFIRM === "1";
12
15
 
13
- async function main() {
16
+ hqMainWithProject(async ({ client, projectId, projectContext: ctx }) => {
14
17
  if (!REF || !PIN || !TARGET_REF) throw new Error("需要 REF= PIN= TARGET_REF=");
15
- const client = await connect({ instanceId: process.env.HQ_INSTANCE_ID });
16
- const active = await client.project.getActiveProject({ context: client.createEditorContext() });
17
- const projectId = active.project?.projectId;
18
- if (!projectId) throw new Error("请先打开工程");
19
- const ctx = client.createProjectContext(projectId);
20
18
  console.log("projectId:", projectId);
21
19
 
22
20
  const a = await client.canvasOps.findObjectByProperty({ context: ctx, propKey: "Reference", propValue: REF });
@@ -32,11 +30,9 @@ async function main() {
32
30
  await client.canvasOps.autoConnectObjectsById({
33
31
  context: ctx,
34
32
  objectId1: id1,
35
- pinNum1: [Number(PIN)],
33
+ pinNum1: [pinNumber(PIN, "PIN")],
36
34
  objectId2: id2,
37
- pinNum2: [Number(TARGET_PIN)],
35
+ pinNum2: [pinNumber(TARGET_PIN, "TARGET_PIN")],
38
36
  });
39
37
  console.log("✓ autoConnect 完成 — 请 GetSnapshot 验收 pin→net");
40
- }
41
-
42
- main().catch((e) => { console.error("❌", e.message); process.exit(1); });
38
+ });