dsh-wake-relay 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.en.md +206 -0
- package/README.md +219 -2
- package/client.mjs +71 -0
- package/cordis.patch.yml +21 -0
- package/docs/why-external.md +65 -0
- package/icon.svg +7 -0
- package/index.js +544 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +67 -4
- package/watcher/rules.example.json +46 -0
- package/watcher/rules.json +4 -0
- package/watcher/selftest.py +283 -0
- package/watcher/waiter.py +606 -0
- package/watcher/wakeclient.py +104 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LittleCodeSauce
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# dsh-wake-relay
|
|
2
|
+
|
|
3
|
+
Let any external process wake a DeepSeek Harness session — build watchers, job runners, CI callbacks, scrapers, backup scripts, phone-push relays. Anything that can send one HTTP POST.
|
|
4
|
+
|
|
5
|
+
The point is not "wake an agent". Schedulers and hook plugins already do that. The point is **what happens when the wake cannot be delivered right now**:
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
200 -> delivered AND flushed. Done.
|
|
9
|
+
429 -> dropped by policy (duplicate / cooldown / burst). Do not retry.
|
|
10
|
+
503 -> nothing was recorded. Retry later with the SAME event_id.
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
That is the whole contract. Because 503 means *nothing happened* — no dedupe entry, no rate-limit hit — a retry with the same `event_id` is safe by construction, and the caller can own the retry loop without guessing whether a timeout meant "delivered" or "not delivered".
|
|
14
|
+
|
|
15
|
+
## Where the probe runs
|
|
16
|
+
|
|
17
|
+
Every other wake plugin puts the timer inside the host process. Host exits (upgrade, crash, you closed it) and probing stops with it — a condition that becomes true *and then false* during that window is gone forever.
|
|
18
|
+
|
|
19
|
+
This one splits the two halves:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
external process (yours) host process (this plugin)
|
|
23
|
+
probe every 30s verify token
|
|
24
|
+
condition true -> append to local queue dedupe + per-reason limits
|
|
25
|
+
POST /wake (idempotent event_id) ──────────▶ resolveAgent -> followup -> flush
|
|
26
|
+
503 -> stays queued, retried until ttl ◀──── 200 only after flush
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Two processes, two independent restart cycles:
|
|
30
|
+
|
|
31
|
+
- Host down → the watcher keeps sampling, events queue, and they are delivered when the host returns.
|
|
32
|
+
- Watcher down → the plugin is unaffected; already-queued wakes still deliver.
|
|
33
|
+
- Both down → events wait on the watcher's disk queue until `ttl_s` expires.
|
|
34
|
+
|
|
35
|
+
The cost of this split is that you need a resident external process. `watcher/` is one, but any script will do.
|
|
36
|
+
|
|
37
|
+
## Compared to in-host probe plugins
|
|
38
|
+
|
|
39
|
+
| | This | In-host probes (dsh-sentinel et al.) |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| Where the probe runs | external process | inside the host |
|
|
42
|
+
| Host is down | still sampling, queueing, retrying | nothing is probed |
|
|
43
|
+
| Condition vocabulary | any command exit code + JSON field assertions | fixed sensor types, mostly "did it change" |
|
|
44
|
+
| Failure semantics | 503 + nothing recorded, idempotent retry | internal requeue |
|
|
45
|
+
| UI | none (`GET /status` + log) | dock panel / dashboard |
|
|
46
|
+
| Multiple instances | falls back to an ephemeral port | lease-based leader election |
|
|
47
|
+
| Windows process probe | native `OpenProcess` | usually `pgrep` (unavailable) |
|
|
48
|
+
|
|
49
|
+
No UI is a deliberate choice: this plugin's user is a script.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
dsh plugin --profile desktop add dsh-wake-relay # from npm
|
|
55
|
+
dsh plugin --profile <your-profile> add ./dsh-wake-relay # from a local clone
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Restart DSH afterwards — the plugin tree is composed at process start.
|
|
59
|
+
|
|
60
|
+
### Two traps you only hit while installing
|
|
61
|
+
|
|
62
|
+
**1. Bundle entry ids must be globally unique.** Enable/disable state is persisted per entry id in the profile's `cordis.patch.yml`:
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
- id: wake-bridge
|
|
66
|
+
disabled: true
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
If another bundle's patch inserts the same id, it is swallowed silently — no error, no warning. The only reliable way to tell whether it loaded is to look for its runtime artifacts: a fresh `port` in `wake-endpoint.json`, a new `listening` line in `wake-bridge.log`, a generated `wake-bridge-secret.json`. `install_bundle` returns `applied` even when nothing loaded.
|
|
70
|
+
|
|
71
|
+
**2. An empty `sessionId` is a valid setup that will not answer a bare curl.** Requests must then carry `session`, or you get `503 no_session_bound`. The startup log says which state you are in:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
listening on 127.0.0.1:45871 — NO default session bound: every /wake request must carry "session"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Pin `sessionId` in `cordis.patch.yml`, or pass `session` on every request.
|
|
78
|
+
|
|
79
|
+
> Installing from a local directory links the package (`pnpm link:`), and the linked copy cannot resolve host packages from the workspace root → `ERR_MODULE_NOT_FOUND`. Fix with a junction:
|
|
80
|
+
> ```
|
|
81
|
+
> mklink /J "…\dsh-wake-relay\node_modules\@deepseek-ai\dsh-llm" "C:\Users\<you>\.dsh\profiles\node_modules\@deepseek-ai\dsh-llm"
|
|
82
|
+
> ```
|
|
83
|
+
> After a failed load, `install_bundle` only reports `already-installed`; toggle `include:<id>` off and on to force a re-import.
|
|
84
|
+
|
|
85
|
+
## Config
|
|
86
|
+
|
|
87
|
+
Set on the bundle row in the profile's `cordis.patch.yml`, or in this package's `cordis.patch.yml`:
|
|
88
|
+
|
|
89
|
+
| Field | Default | Meaning |
|
|
90
|
+
|---|---:|---|
|
|
91
|
+
| `port` | 45871 | Bind address is always `127.0.0.1`. If the port is taken, falls back to an ephemeral port and republishes the endpoint file |
|
|
92
|
+
| `token` | empty | Empty = generated on first boot, persisted to `$DSH_HOME/wake-bridge-secret.json` (mode 0600) |
|
|
93
|
+
| `sessionId` | empty | Default target. Empty = only requests carrying an explicit `session` are accepted |
|
|
94
|
+
| `cooldownMs` | 90000 | Per-reason minimum spacing |
|
|
95
|
+
| `reasonWindowMs` / `reasonMaxHits` | 300000 / 2 | How many times one reason may fire inside the window |
|
|
96
|
+
| `globalBurstMs` | 3000 | Minimum gap between any two wakes; collapses simultaneous bursts only |
|
|
97
|
+
| `flushTimeoutMs` | 10000 | Per-delivery ceiling; a timed-out flush returns 503 |
|
|
98
|
+
| `dedupeWindowMs` | 604800000 | `event_id` dedupe window (7 days) |
|
|
99
|
+
| `template` | empty | Message body; supports `{{detail}}`, `{{reason}}`, `{{data.k}}` |
|
|
100
|
+
| `footnote` | empty | One line appended after the template |
|
|
101
|
+
| `watcherStatePath` | empty | External watcher state file folded into `GET /status` |
|
|
102
|
+
|
|
103
|
+
Template variables: `{{event_id}} {{kind}} {{reason}} {{detail}} {{context_path}} {{session}} {{time}}`, plus any `{{data.<path>}}`. Unknown variables are left verbatim.
|
|
104
|
+
|
|
105
|
+
## API
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
GET /health public -> {ok, sessionId, inflight, seen, lastWakeAt}
|
|
109
|
+
GET /status (token) -> counters, per-reason windows, external watcher queue summary
|
|
110
|
+
POST /wake (x-wake-token) -> {accepted, delivered, messageId, sessionId}
|
|
111
|
+
POST /bind (x-wake-token) -> {accepted, sessionId} rebind the default (not persisted)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`POST /wake` body:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"event_id": "build-2026-11-12-7f3a",
|
|
119
|
+
"kind": "signal",
|
|
120
|
+
"reason": "BUILD_DONE",
|
|
121
|
+
"detail": "17 passed, 0 failed",
|
|
122
|
+
"data": { "anything": "reachable as {{data.anything}} in a template" },
|
|
123
|
+
"context_path": "C:\\path\\to\\state.json",
|
|
124
|
+
"session": "session-… (optional, overrides the default)",
|
|
125
|
+
"text": "(optional) full message replacing the template, <= 2000 chars"
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
| Status | Meaning | Caller |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| 200 | delivered and flushed | done |
|
|
132
|
+
| 429 `duplicate` / `cooldown` / `burst` | dropped by policy | do **not** retry |
|
|
133
|
+
| 503 `delivery_failed` / `session_not_found` / `no_session_bound` | nothing recorded | retry with the **same** `event_id` |
|
|
134
|
+
| 401 / 400 | bad token / bad body | fix the caller |
|
|
135
|
+
|
|
136
|
+
> Non-ASCII bodies must be sent as UTF-8 bytes. PowerShell needs `[System.Text.Encoding]::UTF8.GetBytes($body)`, otherwise `detail` arrives as a row of `?`.
|
|
137
|
+
|
|
138
|
+
### Node
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
import { wake } from "dsh-wake-relay/client";
|
|
142
|
+
|
|
143
|
+
const r = await wake({ event_id: `job-${Date.now()}`, reason: "JOB_DONE", detail: "…" });
|
|
144
|
+
if (r.retryable) setTimeout(() => wake(sameEvent), 30_000); // 503 means nothing was recorded
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Anything else
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
EP=$(cat ~/.dsh/wake-endpoint.json) # {port, token, sessionId}
|
|
151
|
+
curl -s -X POST "http://127.0.0.1:$PORT/wake" \
|
|
152
|
+
-H "x-wake-token: $TOKEN" -H "content-type: application/json; charset=utf-8" \
|
|
153
|
+
--data-binary @event.json
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Optional rules engine (Python)
|
|
157
|
+
|
|
158
|
+
`watcher/` is a resident process plus a rule table, for the common case where you would otherwise write one polling script per condition:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
python watcher/waiter.py --rules watcher/rules.example.json --list
|
|
162
|
+
python watcher/waiter.py --rules my-rules.json --once --dry-run # evaluate, send nothing
|
|
163
|
+
pythonw watcher/waiter.py --rules my-rules.json # resident; rules hot-reload
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
| Probe | True when |
|
|
167
|
+
|---|---|
|
|
168
|
+
| `file_exists` / `file_contains` | path exists / content matches a regex |
|
|
169
|
+
| `pid` | process alive or gone (native on Windows, `kill(pid,0)` elsewhere) |
|
|
170
|
+
| `port` | TCP connect succeeds or fails |
|
|
171
|
+
| `http_json` | a JSON field compares (`== != > >= < <= contains matches`) |
|
|
172
|
+
| `command` | any command's exit code matches; stdout becomes `{detail}` |
|
|
173
|
+
|
|
174
|
+
Edge-triggered (fires on the false→true transition only), `rearm_s` debounce, `once`, a persisted retry queue with `ttl_s`, and `session: latest` for the newest ordinary session. The rule file reloads without a restart.
|
|
175
|
+
|
|
176
|
+
State lives in `$DSH_HOME/wake-bridge-watcher` (`waiter-state.json` / `waiter.log` / `waiter.pid`).
|
|
177
|
+
|
|
178
|
+
## Runtime files (`$DSH_HOME`)
|
|
179
|
+
|
|
180
|
+
| File | Content |
|
|
181
|
+
|---|---|
|
|
182
|
+
| `wake-endpoint.json` | `{port, token, sessionId, pid, startedAt}` — callers discover it, no second copy of the config |
|
|
183
|
+
| `wake-bridge-secret.json` | the generated token (0600) |
|
|
184
|
+
| `wake-bridge-events.jsonl` | accepted fires, append-only, 7-day window, compacted when it outgrows the window |
|
|
185
|
+
| `wake-bridge.log` | accept / reject / delivered / failed |
|
|
186
|
+
| `wake-bridge-watcher/` | external watcher edge state + retry queue |
|
|
187
|
+
|
|
188
|
+
## Known limits
|
|
189
|
+
|
|
190
|
+
- **The host must be online to deliver.** While it is down the watcher only accumulates events (within `ttl_s`); nothing is pre-staged for replay.
|
|
191
|
+
- **`/bind` is not persisted.** A restart returns to the configured `sessionId`.
|
|
192
|
+
- **The token is plaintext** in `wake-endpoint.json`. Loopback only — do not port-forward it, and tighten the file on shared machines.
|
|
193
|
+
- **One wake = one full inference + one permanent message.** For high-frequency conditions write state to disk instead of waking; when you do wake, bring the context with you (`context_path`).
|
|
194
|
+
- **`session: latest` is a guess.** It picks the newest ordinary session on disk. Long-running or unattended jobs must pin `session` explicitly, or the wake may land in a throwaway conversation.
|
|
195
|
+
- An explicit `session` that cannot be resolved returns 503. It is **never** silently redirected — waking the wrong context is worse than not waking.
|
|
196
|
+
|
|
197
|
+
## Tests
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
node selftest.mjs # 21 checks: delivery, idempotency, limits, retry, templates, cross-restart dedupe, port fallback
|
|
201
|
+
python watcher/selftest.py # 11 groups: probes, edges, retry queue, session routing, rule validation (offline)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
MIT.
|
package/README.md
CHANGED
|
@@ -1,3 +1,220 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-wake-relay · 外部唤醒
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**English: [README.en.md](./README.en.md)**
|
|
4
|
+
|
|
5
|
+
让**任意外部进程**唤醒一个 DeepSeek Harness 会话:构建监听、任务收尾、CI 回调、抓取完成、备份完成、手机推送转发、你自己的 watcher——任何能发一个 HTTP POST 的东西。
|
|
6
|
+
|
|
7
|
+
一句话说清它和同类插件的分工:**别人解决「agent 睡着了怎么叫醒它」,这个解决「叫醒它的那根线断了怎么办」。**
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 为什么
|
|
12
|
+
|
|
13
|
+
DSH 的原生 `schedule` 和市面上的唤醒插件都把计时器/探针放在**宿主进程里**:宿主退出,探测停、投递停,那段时间里发生又消失的变化永久丢失。
|
|
14
|
+
|
|
15
|
+
这个插件反过来——**判断在宿主外面,投递在宿主里面**:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
外部进程(你自己的脚本 / 自带 watcher)
|
|
19
|
+
└─ 每轮探测,条件成立 → POST 127.0.0.1/wake ──┐
|
|
20
|
+
│ 宿主在线:立即注入并 flush
|
|
21
|
+
│ 宿主离线:连接失败,事件留在你的队列里
|
|
22
|
+
└→ 宿主回来:同一个 event_id 补投
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
关键是这条约定:**HTTP 200 = 已投递并 flush;503 = 什么都没记录,可以用同一个 `event_id` 重试。** 投递从不"大概成功",所以重试权可以完整地交给调用方,去重和幂等由插件这一侧兜住。
|
|
26
|
+
|
|
27
|
+
代价是:你得有一个常驻的外部进程(自带 `watcher/` 里的规则引擎,或者你自己的脚本)。
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 与同类项目的差异
|
|
32
|
+
|
|
33
|
+
| | 本插件 | 宿主内探针类(dsh-sentinel 等) |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| 探针在哪跑 | 外部进程 | 宿主进程内 |
|
|
36
|
+
| 宿主退出期间 | **照常采样,事件排队等重试** | 不采样,窗口内变化丢失 |
|
|
37
|
+
| 判断能力 | 任意命令退出码 + JSON 字段断言 | 固定几类 sensor,多数只判「变了没」 |
|
|
38
|
+
| 失败语义 | 503 + 不记账,重试幂等 | at-least-once / 由插件内部重排 |
|
|
39
|
+
| UI | 无(`GET /status` + 日志) | dock 面板 / 侧栏 dashboard |
|
|
40
|
+
| 多实例 | 撞端口自动退到随机端口 | lease 选主 |
|
|
41
|
+
| Windows 进程探针 | 原生 `OpenProcess` | 多为 `pgrep`(Windows 上不可用) |
|
|
42
|
+
|
|
43
|
+
UI 是有意的取舍:这个插件的读者是**脚本**,不是人的眼睛。
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 安装
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# 从 npm(发布后)
|
|
51
|
+
dsh plugin --profile desktop add dsh-wake-relay
|
|
52
|
+
|
|
53
|
+
# 从本地目录
|
|
54
|
+
git clone <repo> dsh-wake-relay
|
|
55
|
+
dsh plugin --profile <你的 profile> add ./dsh-wake-relay
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
装完**重启 DSH** 生效(插件树在进程启动时组合)。
|
|
59
|
+
|
|
60
|
+
### 两个装的时候才会遇到的陷阱
|
|
61
|
+
|
|
62
|
+
**① bundle 的 entry id 必须全局唯一。** 启用/禁用状态是按 entry id 持久化的,写在 profile 的 `cordis.patch.yml` 里:
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
- id: wake-bridge
|
|
66
|
+
disabled: true
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
如果另一个 bundle 的 patch 也 `insert` 同名 id,它会被这条 disabled **静默吞掉**——不报错、不告警,只在日志里留个空。判断装没装上的唯一可靠方法是看运行时产物:`wake-endpoint.json` 里有没有新端口、`wake-bridge.log` 有没有新的 `listening` 行、`wake-bridge-secret.json` 有没有生成。`install_bundle` 的返回永远是 `applied`,它不代表加载成功。
|
|
70
|
+
|
|
71
|
+
**② `sessionId` 留空是合法配置,但装完不能直接 curl。**留空时请求必须自带 `session`,否则返回 `503 no_session_bound`。启动日志会明说:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
listening on 127.0.0.1:45871 — NO default session bound: every /wake request must carry "session"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
要么在 `cordis.patch.yml` 里配死 `sessionId`,要么每次请求都带 `session`。
|
|
78
|
+
|
|
79
|
+
> 本地目录安装的另一个已知坑:`install_bundle` 传目录落的是 `pnpm link:`,插件从 workspace 真实路径解析不到宿主包 → `ERR_MODULE_NOT_FOUND`。修法是在包目录里建 junction:
|
|
80
|
+
> ```
|
|
81
|
+
> mklink /J "…\dsh-wake-relay\node_modules\@deepseek-ai\dsh-llm" "C:\Users\<你>\.dsh\profiles\node_modules\@deepseek-ai\dsh-llm"
|
|
82
|
+
> ```
|
|
83
|
+
> 安装失败后 `install_bundle` 只会报 `already-installed`,要 `set_plugin` 把 `include:<id>` 关掉再打开才能强制重新 import。
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 配置
|
|
88
|
+
|
|
89
|
+
写进 profile 的 `cordis.patch.yml`(bundle 行的 `config` 段),或本包的 `cordis.patch.yml`:
|
|
90
|
+
|
|
91
|
+
| 字段 | 默认 | 说明 |
|
|
92
|
+
|---|---:|---|
|
|
93
|
+
| `port` | 45871 | 监听端口,只绑 `127.0.0.1`;被占用时自动退到随机端口并重写 endpoint 文件 |
|
|
94
|
+
| `token` | 空 | 空 = 首次启动生成并持久化到 `$DSH_HOME/wake-bridge-secret.json`(0600) |
|
|
95
|
+
| `sessionId` | 空 | 默认目标会话。空 = 只接受带显式 `session` 的请求 |
|
|
96
|
+
| `cooldownMs` | 90000 | **per-reason** 最小间隔 |
|
|
97
|
+
| `reasonWindowMs` / `reasonMaxHits` | 300000 / 2 | 同一 reason 在这个窗口内最多触发几次 |
|
|
98
|
+
| `globalBurstMs` | 3000 | 任意两次唤醒的最小间隔,只压同时爆发 |
|
|
99
|
+
| `flushTimeoutMs` | 10000 | 单次投递的上限,超时回 503 |
|
|
100
|
+
| `dedupeWindowMs` | 604800000 | `event_id` 去重窗口(7 天) |
|
|
101
|
+
| `template` | 空 | 消息模板,支持 `{{detail}}` `{{reason}}` `{{data.k}}` 等 |
|
|
102
|
+
| `footnote` | 空 | 追加在消息末尾的一行 |
|
|
103
|
+
| `watcherStatePath` | 空 | 外部 watcher 的 state 文件,`GET /status` 会折叠它 |
|
|
104
|
+
|
|
105
|
+
模板变量:`{{event_id}} {{kind}} {{reason}} {{detail}} {{context_path}} {{session}} {{time}}`,以及任意 `{{data.<路径>}}`。未知变量原样保留。
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## API
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
GET /health 公开(无 token)→ {ok, sessionId, inflight, seen, lastWakeAt}
|
|
113
|
+
GET /status (token) → 计数器 + reason 命中窗口 + 外部 watcher 的队列摘要
|
|
114
|
+
POST /wake (x-wake-token) → {accepted, delivered, messageId, sessionId}
|
|
115
|
+
POST /bind (x-wake-token) → {accepted, sessionId} 换默认绑定(不持久,重启回配置)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`POST /wake` 请求体:
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"event_id": "build-2026-11-12-7f3a",
|
|
123
|
+
"kind": "signal",
|
|
124
|
+
"reason": "BUILD_DONE",
|
|
125
|
+
"detail": "17 个用例通过,0 失败",
|
|
126
|
+
"data": { "任何": "结构化字段,模板里用 {{data.任何}} 取" },
|
|
127
|
+
"context_path": "C:\\path\\to\\state.json",
|
|
128
|
+
"session": "session-…(可选,覆盖默认绑定)",
|
|
129
|
+
"text": "(可选)整条消息,替代模板,2000 字以内"
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
| 状态 | 含义 | 调用方 |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| 200 | 已投递并 flush | 完成 |
|
|
136
|
+
| 429 `duplicate` / `cooldown` / `burst` | 按策略丢弃 | **不要重试** |
|
|
137
|
+
| 503 `delivery_failed` / `session_not_found` / `no_session_bound` | 什么都没记录 | 用**同一个 event_id** 重试 |
|
|
138
|
+
| 401 / 400 | token 错 / 请求格式错 | 修调用方 |
|
|
139
|
+
|
|
140
|
+
> 中文 body 必须按 UTF-8 字节发。PowerShell 用 `[System.Text.Encoding]::UTF8.GetBytes($body)`,否则 `detail` 会变成一串 `?`。
|
|
141
|
+
|
|
142
|
+
### Node 客户端
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
import { wake } from "dsh-wake-relay/client";
|
|
146
|
+
|
|
147
|
+
const r = await wake({ event_id: `job-${Date.now()}`, reason: "JOB_DONE", detail: "…" });
|
|
148
|
+
if (r.retryable) setTimeout(() => wake(sameEvent), 30_000); // 503 → 值得重试
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### 任何语言
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
EP=$(cat ~/.dsh/wake-endpoint.json) # {port, token, sessionId}
|
|
155
|
+
curl -s -X POST "http://127.0.0.1:$PORT/wake" \
|
|
156
|
+
-H "x-wake-token: $TOKEN" -H "content-type: application/json; charset=utf-8" \
|
|
157
|
+
--data-binary @event.json
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## 自带的规则引擎(可选,Python)
|
|
163
|
+
|
|
164
|
+
`watcher/` 是一个常驻进程 + 一张规则表,替你把「每个场景写一个轮询脚本」收成「加一条规则」:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
python watcher/waiter.py --rules watcher/rules.example.json --list
|
|
168
|
+
python watcher/waiter.py --rules my-rules.json --once --dry-run # 只判断不发
|
|
169
|
+
pythonw watcher/waiter.py --rules my-rules.json # 常驻(改规则不用重启)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
| 探针 | 成立条件 |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `file_exists` / `file_contains` | 路径存在 / 内容匹配正则 |
|
|
175
|
+
| `pid` | 进程在/不在(Windows 原生 `OpenProcess`,*nix `kill(pid,0)`) |
|
|
176
|
+
| `port` | 端口开/关 |
|
|
177
|
+
| `http_json` | 取 JSON 字段后比较(`== != > >= < <= contains matches`) |
|
|
178
|
+
| `command` | 任意命令退出码符合;stdout 进 `{detail}` |
|
|
179
|
+
|
|
180
|
+
边沿触发(只在假→真那一瞬发一次)、`rearm_s` 防抖、`once` 一次性、持久化重试队列 + `ttl_s`、`session: latest` 自动选磁盘上最新的普通会话。规则表热重载。
|
|
181
|
+
|
|
182
|
+
状态目录:`$DSH_HOME/wake-bridge-watcher`(`waiter-state.json` / `waiter.log` / `waiter.pid`)。
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## 运行时文件(`$DSH_HOME`)
|
|
187
|
+
|
|
188
|
+
| 文件 | 内容 |
|
|
189
|
+
|---|---|
|
|
190
|
+
| `wake-endpoint.json` | `{port, token, sessionId, pid, startedAt}`,脚本自动发现,不用手抄 |
|
|
191
|
+
| `wake-bridge-secret.json` | 自动生成的 token(0600) |
|
|
192
|
+
| `wake-bridge-events.jsonl` | 已接受的 fire 记录(append-only,7 天窗口,超限自动 compact) |
|
|
193
|
+
| `wake-bridge.log` | accept / reject / delivered / failed |
|
|
194
|
+
| `wake-bridge-watcher/` | 外部 watcher 的边沿状态 + 重试队列 |
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## 已知边界
|
|
199
|
+
|
|
200
|
+
- **宿主必须在线才能投递**。宿主退出期间外部 watcher 只会攒事件(`ttl_s` 内),不会"预投"到磁盘再回放。
|
|
201
|
+
- **`/bind` 不持久**;重启回配置里的 `sessionId`。
|
|
202
|
+
- **token 是明文**(`wake-endpoint.json`)。只绑 `127.0.0.1`,别做端口转发;多用户机器上把这个文件权限收一收。
|
|
203
|
+
- **一次唤醒 = 一次完整推理 + 一条永久消息**。高频条件请写 state 文件,别叫醒;真要叫,一次把上下文带全(`state_path`)。
|
|
204
|
+
- **`session: latest` 是猜测**:它挑磁盘上最新的普通会话。长任务/无人值守必须显式钉 `session`,否则唤醒可能落进临时对话。
|
|
205
|
+
- 显式 `session` 解析失败返回 503,**绝不静默改投**。
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## 自测
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
node selftest.mjs # 21 条:投递、幂等、限流、重试、模板、跨重启去重、撞端口回退
|
|
213
|
+
python watcher/selftest.py # 11 组:探针、边沿、重试队列、会话路由、规则校验(离线,不发真唤醒)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
MIT。
|
package/client.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal client for dsh-wake-relay.
|
|
3
|
+
*
|
|
4
|
+
* Reads `$DSH_HOME/wake-endpoint.json` (or an explicit {port, token}) and POSTs
|
|
5
|
+
* one wake event. Nothing is retried here on purpose: the bridge returns 503
|
|
6
|
+
* only when it recorded nothing, so the decision to retry — and how often —
|
|
7
|
+
* belongs to whatever owns the schedule.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
|
|
13
|
+
export function dshHome() {
|
|
14
|
+
return process.env.DSH_HOME || join(homedir(), ".dsh");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readEndpoint(home = dshHome()) {
|
|
18
|
+
const raw = JSON.parse(readFileSync(join(home, "wake-endpoint.json"), "utf8"));
|
|
19
|
+
if (!raw || !raw.port) {
|
|
20
|
+
const error = new Error("wake endpoint is not published (host is down or the plugin is not loaded)");
|
|
21
|
+
error.code = "no_endpoint";
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
return raw;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function post(path, payload, endpoint, timeoutMs) {
|
|
28
|
+
const ep = endpoint || readEndpoint();
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(`http://127.0.0.1:${ep.port}${path}`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "content-type": "application/json; charset=utf-8", "x-wake-token": ep.token },
|
|
35
|
+
body: JSON.stringify(payload),
|
|
36
|
+
signal: controller.signal,
|
|
37
|
+
});
|
|
38
|
+
const body = await res.json().catch(() => ({}));
|
|
39
|
+
return { status: res.status, ok: res.status === 200, body };
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return { status: 0, ok: false, body: { reason: error && error.name === "AbortError" ? "timeout" : "unreachable" } };
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* POST one wake event.
|
|
49
|
+
*
|
|
50
|
+
* @returns {Promise<{status:number, ok:boolean, retryable:boolean, body:object}>}
|
|
51
|
+
* 200 -> delivered and flushed.
|
|
52
|
+
* 429 -> dropped by policy (duplicate / cooldown / burst). Do NOT retry.
|
|
53
|
+
* 503 -> nothing was recorded. Same event_id may be retried verbatim.
|
|
54
|
+
*/
|
|
55
|
+
export async function wake(event, { endpoint, timeoutMs = 15_000 } = {}) {
|
|
56
|
+
if (!event || !event.event_id) throw new Error("event_id is required");
|
|
57
|
+
const result = await post("/wake", event, endpoint, timeoutMs);
|
|
58
|
+
return { ...result, retryable: result.status === 503 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function bind(sessionId, { endpoint, timeoutMs = 5_000 } = {}) {
|
|
62
|
+
return post("/bind", { sessionId }, endpoint, timeoutMs);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function status({ endpoint, timeoutMs = 5_000 } = {}) {
|
|
66
|
+
const ep = endpoint || readEndpoint();
|
|
67
|
+
const res = await fetch(`http://127.0.0.1:${ep.port}/status`, {
|
|
68
|
+
headers: { "x-wake-token": ep.token },
|
|
69
|
+
});
|
|
70
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
71
|
+
}
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Bundle patch for dsh-wake-relay.
|
|
2
|
+
#
|
|
3
|
+
# The entry id must not collide with another bundle: profiles persist enable /
|
|
4
|
+
# disable state per entry id, so reusing an id that another install already
|
|
5
|
+
# disabled makes this one silently unavailable.
|
|
6
|
+
#
|
|
7
|
+
# Everything here is optional: with an empty token the plugin generates one on
|
|
8
|
+
# first boot and persists it to $DSH_HOME/wake-bridge-secret.json; with an empty
|
|
9
|
+
# sessionId only requests that carry an explicit `session` are accepted.
|
|
10
|
+
- insert:
|
|
11
|
+
- id: dsh-wake-relay
|
|
12
|
+
name: dsh-wake-relay
|
|
13
|
+
config:
|
|
14
|
+
port: 45871
|
|
15
|
+
token: ''
|
|
16
|
+
sessionId: ''
|
|
17
|
+
cooldownMs: 90000
|
|
18
|
+
reasonWindowMs: 300000
|
|
19
|
+
reasonMaxHits: 2
|
|
20
|
+
globalBurstMs: 3000
|
|
21
|
+
flushTimeoutMs: 10000
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# 为什么把判断放在宿主外面
|
|
2
|
+
|
|
3
|
+
DSH 的插件生态里,「唤醒 agent」这个需求已经有几个实现。它们的共同结构是:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
插件加载 → 定时器 → 探针 → 条件成立 → followup() 唤醒
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
全部跑在宿主进程里。这在 90% 的场景下够用,但有三种情况会漏:
|
|
10
|
+
|
|
11
|
+
| 情况 | 宿主内探针的结果 |
|
|
12
|
+
|---|---|
|
|
13
|
+
| DSH 重启(升级、崩溃、你手动关了) | 计时器消失;这期间到达的条件不再被观察 |
|
|
14
|
+
| 条件成立后又消失(数值穿过阈值又回来、文件写了又删、任务失败后被自动重试成功) | 窗口内的那个边沿永久丢失 |
|
|
15
|
+
| 你需要「即使 agent 不在也要有人记着这件事」 | 没人记着 |
|
|
16
|
+
|
|
17
|
+
根因不是实现质量,是**故障域**:探针和宿主共享同一个生命周期,宿主挂则探针挂。再多的重试逻辑也救不回来,因为重试代码本身也在那个进程里。
|
|
18
|
+
|
|
19
|
+
## 拆成两半
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
┌──────────── 外部进程(你自己的) ────────────┐
|
|
23
|
+
│ 探测 30s 一轮 │
|
|
24
|
+
│ 条件成立 → 事件进本地持久化队列 │
|
|
25
|
+
│ POST /wake(幂等 event_id) │
|
|
26
|
+
│ 503 → 留在队列,TTL 内重试 ← 关键 │
|
|
27
|
+
└──────────────────────────────────────────────┘
|
|
28
|
+
│ HTTP 127.0.0.1
|
|
29
|
+
┌──────────── 宿主进程(插件) ────────────────┐
|
|
30
|
+
│ 验证 token │
|
|
31
|
+
│ 去重 / per-reason 限流 │
|
|
32
|
+
│ resolveAgent → followup → flush │
|
|
33
|
+
│ 200 仅在此后返回;失败不记账 │
|
|
34
|
+
└──────────────────────────────────────────────┘
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
拆开之后,两个进程各自拥有独立的重启周期:
|
|
38
|
+
|
|
39
|
+
- 宿主挂了:外部进程照常采样,事件排队,等宿主回来补投。
|
|
40
|
+
- 外部进程挂了:插件不受影响,宿主里已有的唤醒照常投递。
|
|
41
|
+
- 两个都挂了:事件在外部进程的磁盘队列里,TTL 内起来就能补。
|
|
42
|
+
|
|
43
|
+
## 那个 200/503 约定是全部的关键
|
|
44
|
+
|
|
45
|
+
如果插件在「投递成功之前」就返回 200,调用方会认为事情办完了,事件就永久丢了。反过来如果失败时也记账(写进去重表),重试就会被判成 duplicate,也永久丢。
|
|
46
|
+
|
|
47
|
+
所以:
|
|
48
|
+
|
|
49
|
+
- **200** 只在 `flush()` 真的返回之后发。
|
|
50
|
+
- **503** 只在「什么都没发生」时发——不写去重表、不写限流窗口、不写日志之外的任何东西。
|
|
51
|
+
- **429** 才是「按策略丢掉了」,明确告诉调用方别重试。
|
|
52
|
+
|
|
53
|
+
这三条让重试成为纯安全操作:用同一个 `event_id` 重发,最多成功一次。
|
|
54
|
+
|
|
55
|
+
## 代价
|
|
56
|
+
|
|
57
|
+
1. 你得有一个常驻的外部进程。本包的 `watcher/` 是给这个用的,但任何脚本都行。
|
|
58
|
+
2. 两段式意味着两个失效点——外部进程死了,唤醒就停了。所以它必须由计划任务/服务托管,并且要能自报状态(`GET /status` 会折叠外部 watcher 的队列摘要)。
|
|
59
|
+
3. 判断逻辑不在模型手里。条件写死在规则表里;需要「看到条件后再判断」的场景,应该只把「条件成立」这个事实送进来,让会话去决定做什么——规则里不要写死结论。
|
|
60
|
+
|
|
61
|
+
## 不该用它的时候
|
|
62
|
+
|
|
63
|
+
- 纯时间(3 小时后、每天 9 点):用原生 `schedule`。它持久化、停机后补发、不占进程。
|
|
64
|
+
- 需要毫秒级闭环的动作(实时控制、硬件回环、执行层动作):交给专门的执行系统,别经过一个模型。延迟、成本、可靠性都差三个数量级。
|
|
65
|
+
- 想在会话里「等一会儿再看」:那是宿主内的活,别跨进程。
|
package/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<rect x="2" y="2" width="60" height="60" rx="14" fill="#101820"/>
|
|
3
|
+
<path d="M32 12c-6.6 0-11.4 4.9-11.4 11.6v9.2l-4.2 7.1c-.7 1.2.2 2.7 1.6 2.7h28c1.4 0 2.3-1.5 1.6-2.7l-4.2-7.1v-9.2C43.4 16.9 38.6 12 32 12z"
|
|
4
|
+
fill="none" stroke="#f5a623" stroke-width="3.2" stroke-linejoin="round"/>
|
|
5
|
+
<path d="M26.2 47.2a6 6 0 0 0 11.6 0" fill="none" stroke="#f5a623" stroke-width="3.2" stroke-linecap="round"/>
|
|
6
|
+
<circle cx="46.5" cy="17.5" r="4.5" fill="#e74c3c"/>
|
|
7
|
+
</svg>
|