@dobby.ai/dobby 0.1.2 → 0.3.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/README.md +56 -1
- package/dist/src/core/connector-supervisor.js +411 -0
- package/dist/src/core/gateway.js +27 -5
- package/dist/src/extension/registry.js +10 -3
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -214,6 +214,37 @@ dobby cron resume <jobId>
|
|
|
214
214
|
dobby cron remove <jobId>
|
|
215
215
|
```
|
|
216
216
|
|
|
217
|
+
## Release 流程
|
|
218
|
+
|
|
219
|
+
仓库现在内置了两条 GitHub Actions:
|
|
220
|
+
|
|
221
|
+
- `.github/workflows/ci.yml`
|
|
222
|
+
- 在 PR / push 到 `main` 时执行 `npm ci`、`npm run plugins:install`、`npm run check`、`npm run build`、`npm run test:cli`、`npm run plugins:check`、`npm run plugins:build`
|
|
223
|
+
- `.github/workflows/release.yml`
|
|
224
|
+
- 在 push 到 `main` 时运行 Release Please
|
|
225
|
+
- 有 releasable commit 时自动维护 release PR
|
|
226
|
+
- release PR 合并后自动发布对应 npm 包,并为每个包生成独立 GitHub release / tag
|
|
227
|
+
|
|
228
|
+
推荐的日常流程:
|
|
229
|
+
|
|
230
|
+
1. 正常提交功能改动到 PR(建议继续使用 Conventional Commits 风格,例如 `feat(...)` / `fix(...)`)
|
|
231
|
+
2. 合并到 `main`
|
|
232
|
+
3. 等待 Release Please 自动更新或创建 release PR
|
|
233
|
+
4. review 并合并 release PR
|
|
234
|
+
5. 合并后由 `release.yml` 自动执行 npm trusted publishing
|
|
235
|
+
|
|
236
|
+
注意:
|
|
237
|
+
|
|
238
|
+
- 首次启用前,需要在 npm 后台为每个 `@dobby.ai/*` 包配置 GitHub trusted publisher,指向当前仓库和 `.github/workflows/release.yml`
|
|
239
|
+
- 建议在 GitHub 仓库里创建 `npm-publish` environment,后续若需要人工审批可以直接加保护规则
|
|
240
|
+
- 进入自动发版流程后,后续版本号应由 Release Please 维护,不再手动执行 `npm version`
|
|
241
|
+
- 本地手动兜底发布仍然保留,可用:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
node scripts/publish-packages.mjs --package plugins/provider-codex-cli --skip-existing
|
|
245
|
+
node scripts/publish-packages.mjs --package . --tag next
|
|
246
|
+
```
|
|
247
|
+
|
|
217
248
|
## Gateway 配置模型
|
|
218
249
|
|
|
219
250
|
顶层结构:
|
|
@@ -305,9 +336,30 @@ codex login status
|
|
|
305
336
|
|
|
306
337
|
- `command`(默认 `codex`)
|
|
307
338
|
- `commandArgs`(默认 `[]`)
|
|
308
|
-
- `model
|
|
339
|
+
- `model`(可选;未设置时沿用 Codex CLI 当前 profile / `~/.codex/config.toml` 的默认模型)
|
|
340
|
+
- `profile`(可选;等价于 `codex -p <profile>`)
|
|
341
|
+
- `approvalPolicy`(可选;默认 `never`)
|
|
342
|
+
- `sandboxMode`(可选;不填时按 route 的 `tools` 推导:`readonly -> read-only`,`full -> workspace-write`)
|
|
343
|
+
- `configOverrides`(可选;字符串数组,按原样转成重复的 `codex -c key=value`)
|
|
309
344
|
- `skipGitRepoCheck`(默认 `false`)
|
|
310
345
|
|
|
346
|
+
例如希望网关里的 Codex 会话复用本机 profile,并显式打开无人值守执行:
|
|
347
|
+
|
|
348
|
+
```json
|
|
349
|
+
{
|
|
350
|
+
"type": "provider.codex-cli",
|
|
351
|
+
"command": "codex",
|
|
352
|
+
"profile": "background",
|
|
353
|
+
"approvalPolicy": "never",
|
|
354
|
+
"sandboxMode": "danger-full-access",
|
|
355
|
+
"configOverrides": [
|
|
356
|
+
"model_reasoning_effort = \"xhigh\""
|
|
357
|
+
]
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
注意:`provider.codex-cli` 当前是 host-only,`danger-full-access` 会直接作用在宿主机上。
|
|
362
|
+
|
|
311
363
|
`--enable` 的行为:
|
|
312
364
|
|
|
313
365
|
- 把包写入 `extensions.allowList`
|
|
@@ -342,6 +394,9 @@ npm run start -- cron add daily-report \
|
|
|
342
394
|
|
|
343
395
|
## Discord 连接器的当前行为
|
|
344
396
|
|
|
397
|
+
- 所有 connector 都会经过宿主侧 health supervisor 包装
|
|
398
|
+
- 统一暴露 `starting / ready / degraded / reconnecting / failed / stopped` 状态
|
|
399
|
+
- 若 connector 长时间停留在 `starting`、`degraded`、`reconnecting` 或 `failed`,宿主会 stop 并重建实例
|
|
345
400
|
- guild channel 仍按显式 binding 匹配
|
|
346
401
|
- DM 可通过 `bindings.default` 回落到默认 route
|
|
347
402
|
- 线程消息使用父频道 ID 做 binding 查找
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
const DEFAULT_MONITOR_INTERVAL_MS = 5_000;
|
|
2
|
+
const DEFAULT_START_TIMEOUT_MS = 30_000;
|
|
3
|
+
const DEFAULT_DEGRADED_RESTART_THRESHOLD_MS = 90_000;
|
|
4
|
+
const DEFAULT_RECONNECTING_RESTART_THRESHOLD_MS = 180_000;
|
|
5
|
+
const DEFAULT_RESTART_BACKOFF_MS = 5_000;
|
|
6
|
+
const DEFAULT_MAX_RESTART_BACKOFF_MS = 60_000;
|
|
7
|
+
function createHealth(status, detail) {
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
return {
|
|
10
|
+
status,
|
|
11
|
+
statusSinceMs: now,
|
|
12
|
+
updatedAtMs: now,
|
|
13
|
+
...(detail ? { detail } : {}),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function errorMessage(error) {
|
|
17
|
+
return error instanceof Error ? error.message : String(error);
|
|
18
|
+
}
|
|
19
|
+
export class SupervisedConnector {
|
|
20
|
+
descriptor;
|
|
21
|
+
createInstance;
|
|
22
|
+
logger;
|
|
23
|
+
monitorIntervalMs;
|
|
24
|
+
startTimeoutMs;
|
|
25
|
+
degradedRestartThresholdMs;
|
|
26
|
+
reconnectingRestartThresholdMs;
|
|
27
|
+
restartBackoffMs;
|
|
28
|
+
maxRestartBackoffMs;
|
|
29
|
+
current;
|
|
30
|
+
ctx = null;
|
|
31
|
+
health = createHealth("stopped");
|
|
32
|
+
started = false;
|
|
33
|
+
stopping = false;
|
|
34
|
+
restarting = false;
|
|
35
|
+
generation = 0;
|
|
36
|
+
monitorTimer = null;
|
|
37
|
+
restartTimer = null;
|
|
38
|
+
restartFailures = 0;
|
|
39
|
+
restartCount = 0;
|
|
40
|
+
activeRestart = null;
|
|
41
|
+
constructor(options) {
|
|
42
|
+
this.current = options.initialConnector;
|
|
43
|
+
this.createInstance = options.createInstance;
|
|
44
|
+
this.logger = options.logger;
|
|
45
|
+
this.monitorIntervalMs = options.monitorIntervalMs ?? DEFAULT_MONITOR_INTERVAL_MS;
|
|
46
|
+
this.startTimeoutMs = options.startTimeoutMs ?? DEFAULT_START_TIMEOUT_MS;
|
|
47
|
+
this.degradedRestartThresholdMs = options.degradedRestartThresholdMs ?? DEFAULT_DEGRADED_RESTART_THRESHOLD_MS;
|
|
48
|
+
this.reconnectingRestartThresholdMs =
|
|
49
|
+
options.reconnectingRestartThresholdMs ?? DEFAULT_RECONNECTING_RESTART_THRESHOLD_MS;
|
|
50
|
+
this.restartBackoffMs = options.restartBackoffMs ?? DEFAULT_RESTART_BACKOFF_MS;
|
|
51
|
+
this.maxRestartBackoffMs = options.maxRestartBackoffMs ?? DEFAULT_MAX_RESTART_BACKOFF_MS;
|
|
52
|
+
this.descriptor = {
|
|
53
|
+
id: options.initialConnector.id,
|
|
54
|
+
platform: options.initialConnector.platform,
|
|
55
|
+
name: options.initialConnector.name,
|
|
56
|
+
capabilities: options.initialConnector.capabilities,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
get id() {
|
|
60
|
+
return this.descriptor.id;
|
|
61
|
+
}
|
|
62
|
+
get platform() {
|
|
63
|
+
return this.descriptor.platform;
|
|
64
|
+
}
|
|
65
|
+
get name() {
|
|
66
|
+
return this.descriptor.name;
|
|
67
|
+
}
|
|
68
|
+
get capabilities() {
|
|
69
|
+
return this.descriptor.capabilities;
|
|
70
|
+
}
|
|
71
|
+
async start(ctx) {
|
|
72
|
+
if (this.started) {
|
|
73
|
+
this.logger.warn({ connectorId: this.id }, "Supervised connector start called while already started");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this.ctx = ctx;
|
|
77
|
+
this.started = true;
|
|
78
|
+
this.stopping = false;
|
|
79
|
+
this.restartFailures = 0;
|
|
80
|
+
this.clearRestartTimer();
|
|
81
|
+
this.updateHealth({ status: "starting", detail: "Starting connector" });
|
|
82
|
+
this.generation += 1;
|
|
83
|
+
const generation = this.generation;
|
|
84
|
+
try {
|
|
85
|
+
await this.startConnectorInstance(this.current, generation, "initial connector start");
|
|
86
|
+
this.syncCurrentHealth("Connector started");
|
|
87
|
+
this.startMonitor();
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
const message = errorMessage(error);
|
|
91
|
+
this.updateHealth({
|
|
92
|
+
status: "failed",
|
|
93
|
+
detail: "Initial connector start failed",
|
|
94
|
+
lastError: message,
|
|
95
|
+
lastErrorAtMs: Date.now(),
|
|
96
|
+
});
|
|
97
|
+
this.started = false;
|
|
98
|
+
this.ctx = null;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async send(message) {
|
|
103
|
+
try {
|
|
104
|
+
const result = await this.current.send(message);
|
|
105
|
+
this.noteOutbound();
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
this.noteRuntimeError("Failed to send outbound message", error);
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async sendTyping(message) {
|
|
114
|
+
const sendTyping = this.current.sendTyping;
|
|
115
|
+
if (!sendTyping) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
await sendTyping.call(this.current, message);
|
|
120
|
+
this.noteOutbound();
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
this.noteRuntimeError("Failed to send typing indicator", error);
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
getHealth() {
|
|
128
|
+
this.syncCurrentHealth();
|
|
129
|
+
return { ...this.health, restartCount: this.restartCount };
|
|
130
|
+
}
|
|
131
|
+
async stop() {
|
|
132
|
+
if (!this.started && this.health.status === "stopped") {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.stopping = true;
|
|
136
|
+
this.started = false;
|
|
137
|
+
this.ctx = null;
|
|
138
|
+
this.generation += 1;
|
|
139
|
+
this.stopMonitor();
|
|
140
|
+
this.clearRestartTimer();
|
|
141
|
+
try {
|
|
142
|
+
await this.current.stop();
|
|
143
|
+
await this.activeRestart;
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
this.updateHealth({ status: "stopped", detail: "Connector stopped by host" });
|
|
147
|
+
this.stopping = false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
startMonitor() {
|
|
151
|
+
if (this.monitorTimer) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
this.monitorTimer = setInterval(() => {
|
|
155
|
+
void this.monitor();
|
|
156
|
+
}, this.monitorIntervalMs);
|
|
157
|
+
}
|
|
158
|
+
stopMonitor() {
|
|
159
|
+
if (!this.monitorTimer) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
clearInterval(this.monitorTimer);
|
|
163
|
+
this.monitorTimer = null;
|
|
164
|
+
}
|
|
165
|
+
async monitor() {
|
|
166
|
+
if (!this.started || this.stopping || this.restarting) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.syncCurrentHealth();
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
const unhealthyForMs = now - this.health.statusSinceMs;
|
|
172
|
+
switch (this.health.status) {
|
|
173
|
+
case "starting":
|
|
174
|
+
if (unhealthyForMs >= this.startTimeoutMs) {
|
|
175
|
+
this.scheduleRestart("start_timeout", true);
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
case "degraded":
|
|
179
|
+
if (unhealthyForMs >= this.degradedRestartThresholdMs) {
|
|
180
|
+
this.scheduleRestart("degraded_timeout", true);
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
case "reconnecting":
|
|
184
|
+
if (unhealthyForMs >= this.reconnectingRestartThresholdMs) {
|
|
185
|
+
this.scheduleRestart("reconnecting_timeout", true);
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
case "failed":
|
|
189
|
+
this.scheduleRestart("connector_failed", false);
|
|
190
|
+
return;
|
|
191
|
+
default:
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
syncCurrentHealth(fallbackDetail) {
|
|
196
|
+
try {
|
|
197
|
+
const observed = this.current.getHealth?.();
|
|
198
|
+
if (!observed) {
|
|
199
|
+
if (this.started && !this.stopping && !this.restarting && this.health.status === "starting") {
|
|
200
|
+
this.updateHealth({ status: "ready", detail: fallbackDetail ?? "Connector ready" });
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (observed.status === "stopped" && this.started && !this.stopping) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.updateHealth(observed);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
this.noteRuntimeError("Failed to read connector health", error);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
createManagedContext(generation) {
|
|
214
|
+
return {
|
|
215
|
+
emitInbound: async (message) => {
|
|
216
|
+
if (generation !== this.generation || !this.ctx) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
this.noteInbound();
|
|
220
|
+
await this.ctx.emitInbound(message);
|
|
221
|
+
},
|
|
222
|
+
emitControl: async (event) => {
|
|
223
|
+
if (generation !== this.generation || !this.ctx) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
await this.ctx.emitControl(event);
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
noteInbound() {
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
this.updateHealth({
|
|
233
|
+
status: "ready",
|
|
234
|
+
detail: "Observed inbound connector activity",
|
|
235
|
+
lastInboundAtMs: now,
|
|
236
|
+
lastReadyAtMs: this.health.lastReadyAtMs ?? now,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
noteOutbound() {
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
this.updateHealth({
|
|
242
|
+
status: "ready",
|
|
243
|
+
detail: "Observed outbound connector activity",
|
|
244
|
+
lastOutboundAtMs: now,
|
|
245
|
+
lastReadyAtMs: this.health.lastReadyAtMs ?? now,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
noteRuntimeError(detail, error) {
|
|
249
|
+
const message = errorMessage(error);
|
|
250
|
+
this.updateHealth({
|
|
251
|
+
status: this.health.status === "reconnecting" ? "reconnecting" : "degraded",
|
|
252
|
+
detail,
|
|
253
|
+
lastError: message,
|
|
254
|
+
lastErrorAtMs: Date.now(),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
updateHealth(next) {
|
|
258
|
+
const previous = this.health;
|
|
259
|
+
const now = next.updatedAtMs ?? Date.now();
|
|
260
|
+
const statusChanged = next.status !== previous.status;
|
|
261
|
+
const merged = {
|
|
262
|
+
...previous,
|
|
263
|
+
...next,
|
|
264
|
+
status: next.status,
|
|
265
|
+
statusSinceMs: next.statusSinceMs ?? (statusChanged ? now : previous.statusSinceMs),
|
|
266
|
+
updatedAtMs: now,
|
|
267
|
+
restartCount: this.restartCount,
|
|
268
|
+
};
|
|
269
|
+
this.health = merged;
|
|
270
|
+
if (statusChanged || merged.detail !== previous.detail) {
|
|
271
|
+
this.logHealthTransition(previous, merged);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
logHealthTransition(previous, next) {
|
|
275
|
+
const payload = {
|
|
276
|
+
connectorId: this.id,
|
|
277
|
+
previousStatus: previous.status,
|
|
278
|
+
status: next.status,
|
|
279
|
+
detail: next.detail ?? null,
|
|
280
|
+
restartCount: this.restartCount,
|
|
281
|
+
lastError: next.lastError ?? null,
|
|
282
|
+
};
|
|
283
|
+
if (next.status === "failed") {
|
|
284
|
+
this.logger.error(payload, "Connector health changed");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (next.status === "degraded" || next.status === "reconnecting") {
|
|
288
|
+
this.logger.warn(payload, "Connector health changed");
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
this.logger.info(payload, "Connector health changed");
|
|
292
|
+
}
|
|
293
|
+
scheduleRestart(reason, immediate) {
|
|
294
|
+
if (!this.started || this.stopping || this.restarting || this.restartTimer) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const delayMs = immediate ? 0 : this.computeRestartDelay();
|
|
298
|
+
this.updateHealth({
|
|
299
|
+
status: "reconnecting",
|
|
300
|
+
detail: delayMs > 0
|
|
301
|
+
? `Supervisor scheduled connector restart in ${delayMs}ms (${reason})`
|
|
302
|
+
: `Supervisor restarting connector (${reason})`,
|
|
303
|
+
});
|
|
304
|
+
if (delayMs === 0) {
|
|
305
|
+
const restart = this.restart(reason);
|
|
306
|
+
this.activeRestart = restart.finally(() => {
|
|
307
|
+
if (this.activeRestart === restart) {
|
|
308
|
+
this.activeRestart = null;
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
this.logger.warn({ connectorId: this.id, reason, delayMs }, "Scheduling supervised connector restart");
|
|
314
|
+
this.restartTimer = setTimeout(() => {
|
|
315
|
+
this.restartTimer = null;
|
|
316
|
+
const restart = this.restart(reason);
|
|
317
|
+
this.activeRestart = restart.finally(() => {
|
|
318
|
+
if (this.activeRestart === restart) {
|
|
319
|
+
this.activeRestart = null;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
}, delayMs);
|
|
323
|
+
}
|
|
324
|
+
clearRestartTimer() {
|
|
325
|
+
if (!this.restartTimer) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
clearTimeout(this.restartTimer);
|
|
329
|
+
this.restartTimer = null;
|
|
330
|
+
}
|
|
331
|
+
computeRestartDelay() {
|
|
332
|
+
return Math.min(this.restartBackoffMs * 2 ** this.restartFailures, this.maxRestartBackoffMs);
|
|
333
|
+
}
|
|
334
|
+
async restart(reason) {
|
|
335
|
+
if (!this.started || this.stopping || this.restarting || !this.ctx) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
this.restarting = true;
|
|
339
|
+
this.clearRestartTimer();
|
|
340
|
+
const previous = this.current;
|
|
341
|
+
const nextGeneration = this.generation + 1;
|
|
342
|
+
this.generation = nextGeneration;
|
|
343
|
+
this.updateHealth({ status: "reconnecting", detail: `Supervisor restarting connector (${reason})` });
|
|
344
|
+
this.logger.warn({ connectorId: this.id, reason }, "Restarting connector through supervisor");
|
|
345
|
+
let shouldRetry = false;
|
|
346
|
+
try {
|
|
347
|
+
await previous.stop().catch((error) => {
|
|
348
|
+
this.logger.warn({ err: error, connectorId: this.id, reason }, "Failed to stop connector before restart");
|
|
349
|
+
});
|
|
350
|
+
if (!this.started || this.stopping || !this.ctx) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const candidate = await this.createInstance();
|
|
354
|
+
this.assertCompatible(candidate);
|
|
355
|
+
if (!this.started || this.stopping || !this.ctx) {
|
|
356
|
+
await this.safeStopConnector(candidate, "Failed to clean up replacement connector after stop during restart");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
this.updateHealth({ status: "starting", detail: `Starting replacement connector (${reason})` });
|
|
360
|
+
await this.startConnectorInstance(candidate, nextGeneration, `replacement connector start (${reason})`);
|
|
361
|
+
if (!this.started || this.stopping || !this.ctx || this.generation !== nextGeneration) {
|
|
362
|
+
await this.safeStopConnector(candidate, "Failed to clean up replacement connector after stop during replacement start");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
this.current = candidate;
|
|
366
|
+
this.restartFailures = 0;
|
|
367
|
+
this.restartCount += 1;
|
|
368
|
+
this.syncCurrentHealth("Replacement connector started");
|
|
369
|
+
this.logger.info({ connectorId: this.id, reason, restartCount: this.restartCount }, "Connector restarted");
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
shouldRetry = true;
|
|
373
|
+
this.restartFailures += 1;
|
|
374
|
+
this.updateHealth({
|
|
375
|
+
status: "failed",
|
|
376
|
+
detail: `Connector restart failed (${reason})`,
|
|
377
|
+
lastError: errorMessage(error),
|
|
378
|
+
lastErrorAtMs: Date.now(),
|
|
379
|
+
});
|
|
380
|
+
this.logger.error({ err: error, connectorId: this.id, reason, restartFailures: this.restartFailures }, "Failed to restart connector");
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
this.restarting = false;
|
|
384
|
+
}
|
|
385
|
+
if (shouldRetry) {
|
|
386
|
+
this.scheduleRestart(`retry_after_${reason}`, false);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async startConnectorInstance(connector, generation, phase) {
|
|
390
|
+
try {
|
|
391
|
+
await connector.start(this.createManagedContext(generation));
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
await this.safeStopConnector(connector, `Failed to clean up connector after ${phase}`);
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async safeStopConnector(connector, errorMessage) {
|
|
399
|
+
try {
|
|
400
|
+
await connector.stop();
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
this.logger.warn({ err: error, connectorId: connector.id }, errorMessage);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
assertCompatible(candidate) {
|
|
407
|
+
if (candidate.id !== this.id || candidate.platform !== this.platform || candidate.name !== this.name) {
|
|
408
|
+
throw new Error(`Replacement connector metadata mismatch for '${this.id}' (${candidate.id}/${candidate.platform}/${candidate.name})`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
package/dist/src/core/gateway.js
CHANGED
|
@@ -26,11 +26,33 @@ export class Gateway {
|
|
|
26
26
|
return;
|
|
27
27
|
await this.options.dedupStore.load();
|
|
28
28
|
this.options.dedupStore.startAutoFlush();
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
const startedConnectors = [];
|
|
30
|
+
try {
|
|
31
|
+
for (const connector of this.options.connectors) {
|
|
32
|
+
await connector.start({
|
|
33
|
+
emitInbound: async (message) => this.handleInbound(message),
|
|
34
|
+
emitControl: async (event) => this.handleControl(event),
|
|
35
|
+
});
|
|
36
|
+
startedConnectors.push(connector);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
for (const connector of startedConnectors.reverse()) {
|
|
41
|
+
try {
|
|
42
|
+
await connector.stop();
|
|
43
|
+
}
|
|
44
|
+
catch (stopError) {
|
|
45
|
+
this.options.logger.warn({ err: stopError, connectorId: connector.id }, "Failed to roll back connector after startup failure");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
this.options.dedupStore.stopAutoFlush();
|
|
49
|
+
try {
|
|
50
|
+
await this.options.dedupStore.flush();
|
|
51
|
+
}
|
|
52
|
+
catch (flushError) {
|
|
53
|
+
this.options.logger.warn({ err: flushError }, "Failed to flush dedup store after startup failure");
|
|
54
|
+
}
|
|
55
|
+
throw error;
|
|
34
56
|
}
|
|
35
57
|
this.started = true;
|
|
36
58
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
+
import { SupervisedConnector } from "../core/connector-supervisor.js";
|
|
2
3
|
function isRecord(value) {
|
|
3
4
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
4
5
|
}
|
|
@@ -108,13 +109,19 @@ export class ExtensionRegistry {
|
|
|
108
109
|
if (!contribution) {
|
|
109
110
|
throw new Error(`Connector instance '${instanceId}' references unknown contribution '${instanceConfig.type}'`);
|
|
110
111
|
}
|
|
111
|
-
const
|
|
112
|
+
const attachmentsRoot = join(attachmentsBaseDir, instanceId);
|
|
113
|
+
const createInstance = () => contribution.createInstance({
|
|
112
114
|
instanceId,
|
|
113
115
|
config: instanceConfig.config,
|
|
114
116
|
host: context,
|
|
115
|
-
attachmentsRoot
|
|
117
|
+
attachmentsRoot,
|
|
116
118
|
});
|
|
117
|
-
|
|
119
|
+
const connector = await createInstance();
|
|
120
|
+
instances.push(new SupervisedConnector({
|
|
121
|
+
initialConnector: connector,
|
|
122
|
+
createInstance,
|
|
123
|
+
logger: context.logger,
|
|
124
|
+
}));
|
|
118
125
|
}
|
|
119
126
|
return instances;
|
|
120
127
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dobby.ai/dobby",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Discord-first local agent gateway built on pi packages",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/don7panic/dobby"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/don7panic/dobby#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/don7panic/dobby/issues"
|
|
14
|
+
},
|
|
7
15
|
"bin": {
|
|
8
16
|
"dobby": "dist/src/main.js"
|
|
9
17
|
},
|