@dobby.ai/dobby 0.2.0 → 0.4.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 +12 -1
- package/dist/src/cli/commands/connector.js +77 -0
- package/dist/src/cli/commands/start.js +52 -0
- package/dist/src/cli/program.js +13 -0
- package/dist/src/core/connector-status.js +132 -0
- package/dist/src/core/connector-supervisor.js +411 -0
- package/dist/src/core/gateway.js +33 -5
- package/dist/src/extension/registry.js +10 -3
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -192,6 +192,12 @@ dobby config schema show <contributionId> [--json]
|
|
|
192
192
|
|
|
193
193
|
配置变更建议直接编辑 `gateway.json`,再通过 `dobby doctor` 或 `dobby start` 做校验。
|
|
194
194
|
|
|
195
|
+
连接器状态:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
dobby connector status [connectorId] [--json]
|
|
199
|
+
```
|
|
200
|
+
|
|
195
201
|
扩展管理:
|
|
196
202
|
|
|
197
203
|
```bash
|
|
@@ -394,6 +400,11 @@ npm run start -- cron add daily-report \
|
|
|
394
400
|
|
|
395
401
|
## Discord 连接器的当前行为
|
|
396
402
|
|
|
403
|
+
- 所有 connector 都会经过宿主侧 health supervisor 包装
|
|
404
|
+
- 统一暴露 `starting / ready / degraded / reconnecting / failed / stopped` 状态
|
|
405
|
+
- 若 connector 长时间停留在 `starting`、`degraded`、`reconnecting` 或 `failed`,宿主会 stop 并重建实例
|
|
406
|
+
- 运行中的 gateway 会把 connector 状态快照写到 `<data.rootDir>/state/connectors-status.json`
|
|
407
|
+
- `dobby connector status` 会读取这份快照并展示当前 connector 健康状态
|
|
397
408
|
- guild channel 仍按显式 binding 匹配
|
|
398
409
|
- DM 可通过 `bindings.default` 回落到默认 route
|
|
399
410
|
- 线程消息使用父频道 ID 做 binding 查找
|
|
@@ -440,7 +451,7 @@ npm run plugins:setup:local
|
|
|
440
451
|
|
|
441
452
|
- `plugins/*` 是扩展源码,不是运行时加载入口
|
|
442
453
|
- 本地扩展安装到 extension store 后,才会被宿主识别
|
|
443
|
-
- `@dobby.ai/plugin-sdk`
|
|
454
|
+
- `@dobby.ai/plugin-sdk` 在插件里按非 optional 的 `peerDependencies` 暴露,开发期通过 `file:../plugin-sdk` 提供
|
|
444
455
|
|
|
445
456
|
## 检查与测试
|
|
446
457
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { connectorStatusSnapshotExists, connectorStatusSnapshotPath, isConnectorStatusSnapshotStale, readConnectorStatusSnapshot, } from "../../core/connector-status.js";
|
|
3
|
+
import { requireRawConfig, resolveConfigPath, resolveDataRootDir } from "../shared/config-io.js";
|
|
4
|
+
function formatTimestamp(timestampMs) {
|
|
5
|
+
return new Date(timestampMs).toISOString();
|
|
6
|
+
}
|
|
7
|
+
function pad(value, width) {
|
|
8
|
+
return value.padEnd(width);
|
|
9
|
+
}
|
|
10
|
+
function renderTable(items) {
|
|
11
|
+
const rows = items.map((item) => ({
|
|
12
|
+
connectorId: item.connectorId,
|
|
13
|
+
platform: item.platform,
|
|
14
|
+
availability: item.availability,
|
|
15
|
+
health: item.health.status,
|
|
16
|
+
restarts: String(item.health.restartCount ?? 0),
|
|
17
|
+
updated: formatTimestamp(item.health.updatedAtMs),
|
|
18
|
+
}));
|
|
19
|
+
const widths = {
|
|
20
|
+
connectorId: Math.max("CONNECTOR".length, ...rows.map((row) => row.connectorId.length)),
|
|
21
|
+
platform: Math.max("PLATFORM".length, ...rows.map((row) => row.platform.length)),
|
|
22
|
+
availability: Math.max("AVAILABILITY".length, ...rows.map((row) => row.availability.length)),
|
|
23
|
+
health: Math.max("HEALTH".length, ...rows.map((row) => row.health.length)),
|
|
24
|
+
restarts: Math.max("RESTARTS".length, ...rows.map((row) => row.restarts.length)),
|
|
25
|
+
updated: Math.max("UPDATED".length, ...rows.map((row) => row.updated.length)),
|
|
26
|
+
};
|
|
27
|
+
const lines = [
|
|
28
|
+
[
|
|
29
|
+
pad("CONNECTOR", widths.connectorId),
|
|
30
|
+
pad("PLATFORM", widths.platform),
|
|
31
|
+
pad("AVAILABILITY", widths.availability),
|
|
32
|
+
pad("HEALTH", widths.health),
|
|
33
|
+
pad("RESTARTS", widths.restarts),
|
|
34
|
+
pad("UPDATED", widths.updated),
|
|
35
|
+
].join(" "),
|
|
36
|
+
];
|
|
37
|
+
for (const row of rows) {
|
|
38
|
+
lines.push([
|
|
39
|
+
pad(row.connectorId, widths.connectorId),
|
|
40
|
+
pad(row.platform, widths.platform),
|
|
41
|
+
pad(row.availability, widths.availability),
|
|
42
|
+
pad(row.health, widths.health),
|
|
43
|
+
pad(row.restarts, widths.restarts),
|
|
44
|
+
pad(row.updated, widths.updated),
|
|
45
|
+
].join(" "));
|
|
46
|
+
}
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
|
49
|
+
export async function runConnectorStatusCommand(options) {
|
|
50
|
+
const configPath = resolveConfigPath();
|
|
51
|
+
const rawConfig = await requireRawConfig(configPath);
|
|
52
|
+
const statusPath = connectorStatusSnapshotPath(join(resolveDataRootDir(configPath, rawConfig), "state"));
|
|
53
|
+
if (!(await connectorStatusSnapshotExists(statusPath))) {
|
|
54
|
+
throw new Error(`Connector status snapshot '${statusPath}' does not exist. Start 'dobby start' first.`);
|
|
55
|
+
}
|
|
56
|
+
const snapshot = await readConnectorStatusSnapshot(statusPath);
|
|
57
|
+
const items = options.connectorId
|
|
58
|
+
? snapshot.items.filter((item) => item.connectorId === options.connectorId)
|
|
59
|
+
: snapshot.items;
|
|
60
|
+
if (options.connectorId && items.length === 0) {
|
|
61
|
+
throw new Error(`Connector '${options.connectorId}' was not found in '${statusPath}'.`);
|
|
62
|
+
}
|
|
63
|
+
if (options.json) {
|
|
64
|
+
console.log(JSON.stringify({ ...snapshot, items }));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (isConnectorStatusSnapshotStale(snapshot)) {
|
|
68
|
+
console.log("Warning: connector status snapshot is stale; the gateway may not be running.");
|
|
69
|
+
}
|
|
70
|
+
if (items.length === 0) {
|
|
71
|
+
console.log("(empty)");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
for (const line of renderTable(items)) {
|
|
75
|
+
console.log(line);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -2,6 +2,7 @@ import { dirname, join } from "node:path";
|
|
|
2
2
|
import { loadCronConfig } from "../../cron/config.js";
|
|
3
3
|
import { CronService } from "../../cron/service.js";
|
|
4
4
|
import { CronStore } from "../../cron/store.js";
|
|
5
|
+
import { connectorStatusSnapshotPath, DEFAULT_CONNECTOR_STATUS_PUBLISH_INTERVAL_MS, DEFAULT_CONNECTOR_STATUS_STALE_AFTER_MS, writeConnectorStatusSnapshot, } from "../../core/connector-status.js";
|
|
5
6
|
import { DedupStore } from "../../core/dedup-store.js";
|
|
6
7
|
import { Gateway } from "../../core/gateway.js";
|
|
7
8
|
import { BindingResolver, loadGatewayConfig, RouteResolver } from "../../core/routing.js";
|
|
@@ -90,6 +91,8 @@ function selectSandboxInstances(config) {
|
|
|
90
91
|
export async function runStartCommand() {
|
|
91
92
|
const configPath = resolveConfigPath();
|
|
92
93
|
const config = await loadGatewayConfig(configPath);
|
|
94
|
+
const gatewayStartedAtMs = Date.now();
|
|
95
|
+
const connectorStatusPath = connectorStatusSnapshotPath(config.data.stateDir);
|
|
93
96
|
await ensureDataDirs(config.data.rootDir);
|
|
94
97
|
const logger = createLogger();
|
|
95
98
|
const loader = new ExtensionLoader(logger, {
|
|
@@ -151,18 +154,67 @@ export async function runStartCommand() {
|
|
|
151
154
|
gateway,
|
|
152
155
|
logger,
|
|
153
156
|
});
|
|
157
|
+
const publishConnectorStatuses = async () => {
|
|
158
|
+
await writeConnectorStatusSnapshot(connectorStatusPath, {
|
|
159
|
+
schemaVersion: 1,
|
|
160
|
+
generatedAtMs: Date.now(),
|
|
161
|
+
staleAfterMs: DEFAULT_CONNECTOR_STATUS_STALE_AFTER_MS,
|
|
162
|
+
gateway: {
|
|
163
|
+
pid: process.pid,
|
|
164
|
+
startedAtMs: gatewayStartedAtMs,
|
|
165
|
+
},
|
|
166
|
+
items: gateway.listConnectorStatuses(),
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
let connectorStatusTimer = null;
|
|
170
|
+
const startConnectorStatusPublisher = async () => {
|
|
171
|
+
try {
|
|
172
|
+
await publishConnectorStatuses();
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
logger.warn({ err: error, connectorStatusPath }, "Failed to write initial connector status snapshot");
|
|
176
|
+
}
|
|
177
|
+
connectorStatusTimer = setInterval(() => {
|
|
178
|
+
void publishConnectorStatuses().catch((error) => {
|
|
179
|
+
logger.warn({ err: error, connectorStatusPath }, "Failed to refresh connector status snapshot");
|
|
180
|
+
});
|
|
181
|
+
}, DEFAULT_CONNECTOR_STATUS_PUBLISH_INTERVAL_MS);
|
|
182
|
+
};
|
|
183
|
+
const stopConnectorStatusPublisher = async () => {
|
|
184
|
+
if (connectorStatusTimer) {
|
|
185
|
+
clearInterval(connectorStatusTimer);
|
|
186
|
+
connectorStatusTimer = null;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
await publishConnectorStatuses();
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
logger.warn({ err: error, connectorStatusPath }, "Failed to write final connector status snapshot");
|
|
193
|
+
}
|
|
194
|
+
};
|
|
154
195
|
await gateway.start();
|
|
155
196
|
await cronService.start();
|
|
197
|
+
await startConnectorStatusPublisher();
|
|
156
198
|
logger.info({
|
|
157
199
|
configPath,
|
|
158
200
|
cronConfigPath: loadedCronConfig.configPath,
|
|
159
201
|
cronConfigSource: loadedCronConfig.source,
|
|
160
202
|
cronEnabled: loadedCronConfig.config.enabled,
|
|
161
203
|
}, "Gateway started");
|
|
204
|
+
let shuttingDown = false;
|
|
162
205
|
const shutdown = async (signal) => {
|
|
206
|
+
if (shuttingDown) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
shuttingDown = true;
|
|
163
210
|
logger.info({ signal }, "Shutting down gateway");
|
|
211
|
+
if (connectorStatusTimer) {
|
|
212
|
+
clearInterval(connectorStatusTimer);
|
|
213
|
+
connectorStatusTimer = null;
|
|
214
|
+
}
|
|
164
215
|
await cronService.stop();
|
|
165
216
|
await gateway.stop();
|
|
217
|
+
await stopConnectorStatusPublisher();
|
|
166
218
|
await hostExecutor.close();
|
|
167
219
|
await closeProviderInstances(providers, logger);
|
|
168
220
|
await closeSandboxInstances(sandboxes, logger);
|
package/dist/src/cli/program.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import { runConfigListCommand, runConfigSchemaListCommand, runConfigSchemaShowCommand, runConfigShowCommand, } from "./commands/config.js";
|
|
5
|
+
import { runConnectorStatusCommand } from "./commands/connector.js";
|
|
5
6
|
import { runCronAddCommand, runCronListCommand, runCronPauseCommand, runCronRemoveCommand, runCronResumeCommand, runCronRunCommand, runCronStatusCommand, runCronUpdateCommand, } from "./commands/cron.js";
|
|
6
7
|
import { runDoctorCommand } from "./commands/doctor.js";
|
|
7
8
|
import { runExtensionInstallCommand, runExtensionListCommand, runExtensionUninstallCommand, } from "./commands/extension.js";
|
|
@@ -127,6 +128,18 @@ export function buildProgram() {
|
|
|
127
128
|
fix: Boolean(opts.fix),
|
|
128
129
|
});
|
|
129
130
|
});
|
|
131
|
+
const connectorCommand = program.command("connector").description("Inspect runtime connector status");
|
|
132
|
+
connectorCommand
|
|
133
|
+
.command("status")
|
|
134
|
+
.description("Show status for all connectors or one connector")
|
|
135
|
+
.argument("[connectorId]", "Connector instance ID")
|
|
136
|
+
.option("--json", "Output JSON", false)
|
|
137
|
+
.action(async (connectorId, opts) => {
|
|
138
|
+
await runConnectorStatusCommand({
|
|
139
|
+
...(typeof connectorId === "string" ? { connectorId } : {}),
|
|
140
|
+
json: Boolean(opts.json),
|
|
141
|
+
});
|
|
142
|
+
});
|
|
130
143
|
const cronCommand = program.command("cron").description("Manage scheduled cron jobs");
|
|
131
144
|
cronCommand
|
|
132
145
|
.command("add")
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
export const CONNECTOR_STATUS_SNAPSHOT_FILENAME = "connectors-status.json";
|
|
4
|
+
export const DEFAULT_CONNECTOR_STATUS_PUBLISH_INTERVAL_MS = 5_000;
|
|
5
|
+
export const DEFAULT_CONNECTOR_STATUS_STALE_AFTER_MS = 15_000;
|
|
6
|
+
function isRecord(value) {
|
|
7
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function createFallbackHealth(detail) {
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
return {
|
|
12
|
+
status: "stopped",
|
|
13
|
+
detail,
|
|
14
|
+
statusSinceMs: now,
|
|
15
|
+
updatedAtMs: now,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function availabilityFromHealthStatus(status) {
|
|
19
|
+
switch (status) {
|
|
20
|
+
case "ready":
|
|
21
|
+
return "online";
|
|
22
|
+
case "degraded":
|
|
23
|
+
return "degraded";
|
|
24
|
+
case "reconnecting":
|
|
25
|
+
return "reconnecting";
|
|
26
|
+
case "starting":
|
|
27
|
+
case "failed":
|
|
28
|
+
case "stopped":
|
|
29
|
+
default:
|
|
30
|
+
return "offline";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function connectorStatusSnapshotPath(stateDir) {
|
|
34
|
+
return join(resolve(stateDir), CONNECTOR_STATUS_SNAPSHOT_FILENAME);
|
|
35
|
+
}
|
|
36
|
+
export function statusItemFromConnector(connector) {
|
|
37
|
+
const health = connector.getHealth?.() ?? createFallbackHealth("Connector health is not available");
|
|
38
|
+
const availability = availabilityFromHealthStatus(health.status);
|
|
39
|
+
return {
|
|
40
|
+
connectorId: connector.id,
|
|
41
|
+
platform: connector.platform,
|
|
42
|
+
connectorName: connector.name,
|
|
43
|
+
availability,
|
|
44
|
+
online: availability === "online",
|
|
45
|
+
health,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async function writeAtomic(filePath, content) {
|
|
49
|
+
const absolutePath = resolve(filePath);
|
|
50
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
51
|
+
const tempPath = `${absolutePath}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
52
|
+
await writeFile(tempPath, content, "utf-8");
|
|
53
|
+
await rename(tempPath, absolutePath);
|
|
54
|
+
}
|
|
55
|
+
export async function writeConnectorStatusSnapshot(filePath, snapshot) {
|
|
56
|
+
await writeAtomic(filePath, `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
57
|
+
}
|
|
58
|
+
export async function connectorStatusSnapshotExists(filePath) {
|
|
59
|
+
try {
|
|
60
|
+
await access(resolve(filePath));
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function parseHealth(value) {
|
|
68
|
+
if (!isRecord(value)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (typeof value.status !== "string" || typeof value.statusSinceMs !== "number" || typeof value.updatedAtMs !== "number") {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
function parseStatusItem(value) {
|
|
77
|
+
if (!isRecord(value)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (typeof value.connectorId !== "string"
|
|
81
|
+
|| typeof value.platform !== "string"
|
|
82
|
+
|| typeof value.connectorName !== "string"
|
|
83
|
+
|| typeof value.availability !== "string"
|
|
84
|
+
|| typeof value.online !== "boolean") {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
const health = parseHealth(value.health);
|
|
88
|
+
if (!health) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
connectorId: value.connectorId,
|
|
93
|
+
platform: value.platform,
|
|
94
|
+
connectorName: value.connectorName,
|
|
95
|
+
availability: value.availability,
|
|
96
|
+
online: value.online,
|
|
97
|
+
health,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
export async function readConnectorStatusSnapshot(filePath) {
|
|
101
|
+
const raw = await readFile(resolve(filePath), "utf-8");
|
|
102
|
+
const parsed = JSON.parse(raw);
|
|
103
|
+
if (!isRecord(parsed) || parsed.schemaVersion !== 1 || typeof parsed.generatedAtMs !== "number" || typeof parsed.staleAfterMs !== "number") {
|
|
104
|
+
throw new Error(`Connector status snapshot '${resolve(filePath)}' has invalid metadata`);
|
|
105
|
+
}
|
|
106
|
+
if (!isRecord(parsed.gateway) || typeof parsed.gateway.pid !== "number" || typeof parsed.gateway.startedAtMs !== "number") {
|
|
107
|
+
throw new Error(`Connector status snapshot '${resolve(filePath)}' has invalid gateway metadata`);
|
|
108
|
+
}
|
|
109
|
+
if (!Array.isArray(parsed.items)) {
|
|
110
|
+
throw new Error(`Connector status snapshot '${resolve(filePath)}' must contain an items array`);
|
|
111
|
+
}
|
|
112
|
+
const items = parsed.items.map((item) => {
|
|
113
|
+
const normalized = parseStatusItem(item);
|
|
114
|
+
if (!normalized) {
|
|
115
|
+
throw new Error(`Connector status snapshot '${resolve(filePath)}' contains an invalid connector entry`);
|
|
116
|
+
}
|
|
117
|
+
return normalized;
|
|
118
|
+
});
|
|
119
|
+
return {
|
|
120
|
+
schemaVersion: 1,
|
|
121
|
+
generatedAtMs: parsed.generatedAtMs,
|
|
122
|
+
staleAfterMs: parsed.staleAfterMs,
|
|
123
|
+
gateway: {
|
|
124
|
+
pid: parsed.gateway.pid,
|
|
125
|
+
startedAtMs: parsed.gateway.startedAtMs,
|
|
126
|
+
},
|
|
127
|
+
items,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export function isConnectorStatusSnapshotStale(snapshot, now = Date.now()) {
|
|
131
|
+
return now - snapshot.generatedAtMs > snapshot.staleAfterMs;
|
|
132
|
+
}
|
|
@@ -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { EventForwarder } from "../agent/event-forwarder.js";
|
|
3
|
+
import { statusItemFromConnector } from "./connector-status.js";
|
|
3
4
|
import { parseControlCommand } from "./control-command.js";
|
|
4
5
|
import { createTypingKeepAliveController } from "./typing-controller.js";
|
|
5
6
|
function isImageAttachment(attachment) {
|
|
@@ -26,11 +27,33 @@ export class Gateway {
|
|
|
26
27
|
return;
|
|
27
28
|
await this.options.dedupStore.load();
|
|
28
29
|
this.options.dedupStore.startAutoFlush();
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
const startedConnectors = [];
|
|
31
|
+
try {
|
|
32
|
+
for (const connector of this.options.connectors) {
|
|
33
|
+
await connector.start({
|
|
34
|
+
emitInbound: async (message) => this.handleInbound(message),
|
|
35
|
+
emitControl: async (event) => this.handleControl(event),
|
|
36
|
+
});
|
|
37
|
+
startedConnectors.push(connector);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
for (const connector of startedConnectors.reverse()) {
|
|
42
|
+
try {
|
|
43
|
+
await connector.stop();
|
|
44
|
+
}
|
|
45
|
+
catch (stopError) {
|
|
46
|
+
this.options.logger.warn({ err: stopError, connectorId: connector.id }, "Failed to roll back connector after startup failure");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
this.options.dedupStore.stopAutoFlush();
|
|
50
|
+
try {
|
|
51
|
+
await this.options.dedupStore.flush();
|
|
52
|
+
}
|
|
53
|
+
catch (flushError) {
|
|
54
|
+
this.options.logger.warn({ err: flushError }, "Failed to flush dedup store after startup failure");
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
34
57
|
}
|
|
35
58
|
this.started = true;
|
|
36
59
|
}
|
|
@@ -45,6 +68,11 @@ export class Gateway {
|
|
|
45
68
|
await this.options.runtimeRegistry.closeAll();
|
|
46
69
|
this.started = false;
|
|
47
70
|
}
|
|
71
|
+
listConnectorStatuses() {
|
|
72
|
+
return this.options.connectors
|
|
73
|
+
.map((connector) => statusItemFromConnector(connector))
|
|
74
|
+
.sort((a, b) => a.connectorId.localeCompare(b.connectorId));
|
|
75
|
+
}
|
|
48
76
|
async handleScheduled(request) {
|
|
49
77
|
const connector = this.connectorsById.get(request.connectorId);
|
|
50
78
|
if (!connector) {
|
|
@@ -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.4.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
|
},
|