@dobby.ai/dobby 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/.env.example +9 -0
- package/AGENTS.md +267 -0
- package/README.md +382 -0
- package/ROADMAP.md +34 -0
- package/config/cron.example.json +9 -0
- package/config/gateway.example.json +128 -0
- package/config/models.custom.example.json +27 -0
- package/dist/src/agent/event-forwarder.js +341 -0
- package/dist/src/agent/tests/event-forwarder.test.js +113 -0
- package/dist/src/cli/commands/config.js +243 -0
- package/dist/src/cli/commands/configure.js +61 -0
- package/dist/src/cli/commands/cron.js +288 -0
- package/dist/src/cli/commands/doctor.js +189 -0
- package/dist/src/cli/commands/extension.js +151 -0
- package/dist/src/cli/commands/init.js +286 -0
- package/dist/src/cli/commands/start.js +177 -0
- package/dist/src/cli/commands/topology.js +254 -0
- package/dist/src/cli/index.js +8 -0
- package/dist/src/cli/program.js +386 -0
- package/dist/src/cli/shared/config-io.js +223 -0
- package/dist/src/cli/shared/config-mutators.js +345 -0
- package/dist/src/cli/shared/config-path.js +207 -0
- package/dist/src/cli/shared/config-schema.js +159 -0
- package/dist/src/cli/shared/config-types.js +1 -0
- package/dist/src/cli/shared/configure-sections.js +429 -0
- package/dist/src/cli/shared/discord-config.js +12 -0
- package/dist/src/cli/shared/init-catalog.js +115 -0
- package/dist/src/cli/shared/init-models-file.js +65 -0
- package/dist/src/cli/shared/presets.js +86 -0
- package/dist/src/cli/shared/runtime.js +29 -0
- package/dist/src/cli/shared/schema-prompts.js +325 -0
- package/dist/src/cli/tests/config-command.test.js +42 -0
- package/dist/src/cli/tests/config-io.test.js +64 -0
- package/dist/src/cli/tests/config-mutators.test.js +47 -0
- package/dist/src/cli/tests/config-path.test.js +21 -0
- package/dist/src/cli/tests/discord-config.test.js +23 -0
- package/dist/src/cli/tests/doctor.test.js +107 -0
- package/dist/src/cli/tests/init-catalog.test.js +87 -0
- package/dist/src/cli/tests/presets.test.js +41 -0
- package/dist/src/cli/tests/program-options.test.js +92 -0
- package/dist/src/cli/tests/routing-config.test.js +199 -0
- package/dist/src/cli/tests/routing-legacy.test.js +191 -0
- package/dist/src/core/control-command.js +12 -0
- package/dist/src/core/dedup-store.js +92 -0
- package/dist/src/core/gateway.js +432 -0
- package/dist/src/core/routing.js +306 -0
- package/dist/src/core/runtime-registry.js +119 -0
- package/dist/src/core/tests/control-command.test.js +17 -0
- package/dist/src/core/tests/gateway-update-strategy.test.js +167 -0
- package/dist/src/core/tests/runtime-registry.test.js +116 -0
- package/dist/src/core/tests/typing-controller.test.js +103 -0
- package/dist/src/core/types.js +1 -0
- package/dist/src/core/typing-controller.js +88 -0
- package/dist/src/cron/config.js +114 -0
- package/dist/src/cron/schedule.js +49 -0
- package/dist/src/cron/service.js +196 -0
- package/dist/src/cron/store.js +142 -0
- package/dist/src/cron/types.js +1 -0
- package/dist/src/extension/loader.js +97 -0
- package/dist/src/extension/manager.js +269 -0
- package/dist/src/extension/manifest.js +21 -0
- package/dist/src/extension/registry.js +137 -0
- package/dist/src/main.js +6 -0
- package/dist/src/sandbox/executor.js +1 -0
- package/dist/src/sandbox/host-executor.js +111 -0
- package/docs/BOXLITE_SANDBOX_FEASIBILITY.md +175 -0
- package/docs/CRON_SCHEDULER_DESIGN.md +374 -0
- package/docs/DOCKER_SANDBOX_vs_BOXLITE.md +77 -0
- package/docs/EXTENSION_SYSTEM_ARCHITECTURE.md +119 -0
- package/docs/MVP.md +135 -0
- package/docs/RUNBOOK.md +242 -0
- package/docs/TEAMWORK_HANDOFF_DESIGN.md +440 -0
- package/package.json +43 -0
- package/plugins/connector-discord/dobby.manifest.json +18 -0
- package/plugins/connector-discord/index.js +1 -0
- package/plugins/connector-discord/package-lock.json +360 -0
- package/plugins/connector-discord/package.json +38 -0
- package/plugins/connector-discord/src/connector.ts +350 -0
- package/plugins/connector-discord/src/contribution.ts +21 -0
- package/plugins/connector-discord/src/mapper.ts +102 -0
- package/plugins/connector-discord/tsconfig.json +19 -0
- package/plugins/connector-feishu/dobby.manifest.json +18 -0
- package/plugins/connector-feishu/index.js +1 -0
- package/plugins/connector-feishu/package-lock.json +618 -0
- package/plugins/connector-feishu/package.json +38 -0
- package/plugins/connector-feishu/src/connector.ts +343 -0
- package/plugins/connector-feishu/src/contribution.ts +26 -0
- package/plugins/connector-feishu/src/mapper.ts +401 -0
- package/plugins/connector-feishu/tsconfig.json +19 -0
- package/plugins/plugin-sdk/index.d.ts +261 -0
- package/plugins/plugin-sdk/index.js +1 -0
- package/plugins/plugin-sdk/package-lock.json +12 -0
- package/plugins/plugin-sdk/package.json +22 -0
- package/plugins/provider-claude/dobby.manifest.json +17 -0
- package/plugins/provider-claude/index.js +1 -0
- package/plugins/provider-claude/package-lock.json +3398 -0
- package/plugins/provider-claude/package.json +39 -0
- package/plugins/provider-claude/src/contribution.ts +1018 -0
- package/plugins/provider-claude/tsconfig.json +19 -0
- package/plugins/provider-claude-cli/dobby.manifest.json +17 -0
- package/plugins/provider-claude-cli/index.js +1 -0
- package/plugins/provider-claude-cli/package-lock.json +2898 -0
- package/plugins/provider-claude-cli/package.json +38 -0
- package/plugins/provider-claude-cli/src/contribution.ts +1673 -0
- package/plugins/provider-claude-cli/tsconfig.json +19 -0
- package/plugins/provider-pi/dobby.manifest.json +17 -0
- package/plugins/provider-pi/index.js +1 -0
- package/plugins/provider-pi/package-lock.json +3877 -0
- package/plugins/provider-pi/package.json +40 -0
- package/plugins/provider-pi/src/contribution.ts +476 -0
- package/plugins/provider-pi/tsconfig.json +19 -0
- package/plugins/sandbox-core/boxlite.js +1 -0
- package/plugins/sandbox-core/dobby.manifest.json +17 -0
- package/plugins/sandbox-core/docker.js +1 -0
- package/plugins/sandbox-core/package-lock.json +136 -0
- package/plugins/sandbox-core/package.json +39 -0
- package/plugins/sandbox-core/src/boxlite-context.ts +2 -0
- package/plugins/sandbox-core/src/boxlite-contribution.ts +53 -0
- package/plugins/sandbox-core/src/boxlite-executor.ts +911 -0
- package/plugins/sandbox-core/src/docker-contribution.ts +43 -0
- package/plugins/sandbox-core/src/docker-executor.ts +217 -0
- package/plugins/sandbox-core/tsconfig.json +19 -0
- package/scripts/local-extensions.mjs +168 -0
- package/src/agent/event-forwarder.ts +414 -0
- package/src/cli/commands/config.ts +328 -0
- package/src/cli/commands/configure.ts +92 -0
- package/src/cli/commands/cron.ts +410 -0
- package/src/cli/commands/doctor.ts +230 -0
- package/src/cli/commands/extension.ts +205 -0
- package/src/cli/commands/init.ts +396 -0
- package/src/cli/commands/start.ts +223 -0
- package/src/cli/commands/topology.ts +383 -0
- package/src/cli/index.ts +9 -0
- package/src/cli/program.ts +465 -0
- package/src/cli/shared/config-io.ts +277 -0
- package/src/cli/shared/config-mutators.ts +440 -0
- package/src/cli/shared/config-schema.ts +228 -0
- package/src/cli/shared/config-types.ts +121 -0
- package/src/cli/shared/configure-sections.ts +551 -0
- package/src/cli/shared/discord-config.ts +14 -0
- package/src/cli/shared/init-catalog.ts +189 -0
- package/src/cli/shared/init-models-file.ts +77 -0
- package/src/cli/shared/runtime.ts +33 -0
- package/src/cli/shared/schema-prompts.ts +414 -0
- package/src/cli/tests/config-command.test.ts +56 -0
- package/src/cli/tests/config-io.test.ts +92 -0
- package/src/cli/tests/config-mutators.test.ts +59 -0
- package/src/cli/tests/doctor.test.ts +120 -0
- package/src/cli/tests/init-catalog.test.ts +96 -0
- package/src/cli/tests/program-options.test.ts +113 -0
- package/src/cli/tests/routing-config.test.ts +209 -0
- package/src/core/control-command.ts +12 -0
- package/src/core/dedup-store.ts +103 -0
- package/src/core/gateway.ts +607 -0
- package/src/core/routing.ts +379 -0
- package/src/core/runtime-registry.ts +141 -0
- package/src/core/tests/control-command.test.ts +20 -0
- package/src/core/tests/runtime-registry.test.ts +140 -0
- package/src/core/tests/typing-controller.test.ts +129 -0
- package/src/core/types.ts +318 -0
- package/src/core/typing-controller.ts +119 -0
- package/src/cron/config.ts +154 -0
- package/src/cron/schedule.ts +61 -0
- package/src/cron/service.ts +249 -0
- package/src/cron/store.ts +155 -0
- package/src/cron/types.ts +60 -0
- package/src/extension/loader.ts +145 -0
- package/src/extension/manager.ts +355 -0
- package/src/extension/manifest.ts +26 -0
- package/src/extension/registry.ts +229 -0
- package/src/main.ts +8 -0
- package/src/sandbox/executor.ts +44 -0
- package/src/sandbox/host-executor.ts +118 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { BUILTIN_HOST_SANDBOX_ID } from "./types.js";
|
|
7
|
+
const extensionItemSchema = z.object({
|
|
8
|
+
type: z.string().trim().min(1),
|
|
9
|
+
}).catchall(z.unknown());
|
|
10
|
+
const routeDefaultsSchema = z.object({
|
|
11
|
+
provider: z.string().trim().min(1).optional(),
|
|
12
|
+
sandbox: z.string().trim().min(1).optional(),
|
|
13
|
+
tools: z.enum(["full", "readonly"]).optional(),
|
|
14
|
+
mentions: z.enum(["required", "optional"]).optional(),
|
|
15
|
+
}).strict();
|
|
16
|
+
const routeItemSchema = z.object({
|
|
17
|
+
projectRoot: z.string().trim().min(1),
|
|
18
|
+
tools: z.enum(["full", "readonly"]).optional(),
|
|
19
|
+
mentions: z.enum(["required", "optional"]).optional(),
|
|
20
|
+
provider: z.string().trim().min(1).optional(),
|
|
21
|
+
sandbox: z.string().trim().min(1).optional(),
|
|
22
|
+
systemPromptFile: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.transform((value) => (value && value.trim().length > 0 ? value : undefined)),
|
|
26
|
+
}).strict();
|
|
27
|
+
const bindingSourceSchema = z.object({
|
|
28
|
+
type: z.enum(["channel", "chat"]),
|
|
29
|
+
id: z.string().trim().min(1),
|
|
30
|
+
}).strict();
|
|
31
|
+
const bindingItemSchema = z.object({
|
|
32
|
+
connector: z.string().trim().min(1),
|
|
33
|
+
source: bindingSourceSchema,
|
|
34
|
+
route: z.string().trim().min(1),
|
|
35
|
+
}).strict();
|
|
36
|
+
const gatewayConfigSchema = z.object({
|
|
37
|
+
extensions: z.object({
|
|
38
|
+
allowList: z
|
|
39
|
+
.array(z.object({
|
|
40
|
+
package: z.string().trim().min(1),
|
|
41
|
+
enabled: z.boolean().default(true),
|
|
42
|
+
}).strict())
|
|
43
|
+
.default([]),
|
|
44
|
+
}).strict(),
|
|
45
|
+
providers: z.object({
|
|
46
|
+
default: z.string().trim().min(1),
|
|
47
|
+
items: z.record(z.string(), extensionItemSchema),
|
|
48
|
+
}).strict(),
|
|
49
|
+
connectors: z.object({
|
|
50
|
+
items: z.record(z.string(), extensionItemSchema),
|
|
51
|
+
}).strict(),
|
|
52
|
+
sandboxes: z.object({
|
|
53
|
+
default: z.string().trim().min(1).optional(),
|
|
54
|
+
items: z.record(z.string(), extensionItemSchema).default({}),
|
|
55
|
+
}).strict(),
|
|
56
|
+
routes: z.object({
|
|
57
|
+
defaults: routeDefaultsSchema.default({}),
|
|
58
|
+
items: z.record(z.string(), routeItemSchema),
|
|
59
|
+
}).strict(),
|
|
60
|
+
bindings: z.object({
|
|
61
|
+
items: z.record(z.string(), bindingItemSchema).default({}),
|
|
62
|
+
}).strict(),
|
|
63
|
+
data: z.object({
|
|
64
|
+
rootDir: z.string().default("./data"),
|
|
65
|
+
dedupTtlMs: z.number().int().positive().default(7 * 24 * 60 * 60 * 1000),
|
|
66
|
+
}).strict(),
|
|
67
|
+
}).strict();
|
|
68
|
+
const FORBIDDEN_CONNECTOR_CONFIG_KEYS = {
|
|
69
|
+
botChannelMap: "Use bindings.items to map connector sources to routes.",
|
|
70
|
+
chatRouteMap: "Use bindings.items to map connector sources to routes.",
|
|
71
|
+
botTokenEnv: "Set botToken directly in connector config or inject it before the config is loaded.",
|
|
72
|
+
};
|
|
73
|
+
function isDobbyRepoRoot(candidateDir) {
|
|
74
|
+
const packageJsonPath = resolve(candidateDir, "package.json");
|
|
75
|
+
const repoConfigPath = resolve(candidateDir, "config", "gateway.json");
|
|
76
|
+
const localExtensionsScriptPath = resolve(candidateDir, "scripts", "local-extensions.mjs");
|
|
77
|
+
if (!existsSync(packageJsonPath) || !existsSync(repoConfigPath) || !existsSync(localExtensionsScriptPath)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const packageJsonRaw = readFileSync(packageJsonPath, "utf-8");
|
|
82
|
+
const parsed = JSON.parse(packageJsonRaw);
|
|
83
|
+
return parsed.name === "dobby";
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function resolveConfigBaseDir(configPath) {
|
|
90
|
+
const absoluteConfigPath = resolve(configPath);
|
|
91
|
+
const configDir = dirname(absoluteConfigPath);
|
|
92
|
+
const repoRoot = dirname(configDir);
|
|
93
|
+
if (absoluteConfigPath === resolve(repoRoot, "config", "gateway.json") && isDobbyRepoRoot(repoRoot)) {
|
|
94
|
+
return repoRoot;
|
|
95
|
+
}
|
|
96
|
+
return configDir;
|
|
97
|
+
}
|
|
98
|
+
function resolveMaybeAbsolute(baseDir, value) {
|
|
99
|
+
const expanded = expandHome(value);
|
|
100
|
+
return isAbsolute(expanded) ? resolve(expanded) : resolve(baseDir, expanded);
|
|
101
|
+
}
|
|
102
|
+
function expandHome(value) {
|
|
103
|
+
if (value === "~") {
|
|
104
|
+
return homedir();
|
|
105
|
+
}
|
|
106
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
107
|
+
return resolve(homedir(), value.slice(2));
|
|
108
|
+
}
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
function normalizeInstanceItem(item) {
|
|
112
|
+
const { type, ...config } = item;
|
|
113
|
+
return {
|
|
114
|
+
type,
|
|
115
|
+
config,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function normalizeInstances(parsedItems) {
|
|
119
|
+
const normalized = {};
|
|
120
|
+
for (const [id, item] of Object.entries(parsedItems)) {
|
|
121
|
+
normalized[id] = normalizeInstanceItem(item);
|
|
122
|
+
}
|
|
123
|
+
return normalized;
|
|
124
|
+
}
|
|
125
|
+
function normalizeExtensions(parsed) {
|
|
126
|
+
return {
|
|
127
|
+
allowList: parsed.allowList.map((item) => ({
|
|
128
|
+
package: item.package,
|
|
129
|
+
enabled: item.enabled,
|
|
130
|
+
})),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function normalizeProviders(parsed) {
|
|
134
|
+
return {
|
|
135
|
+
default: parsed.default,
|
|
136
|
+
items: normalizeInstances(parsed.items),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function normalizeConnectors(parsed) {
|
|
140
|
+
return {
|
|
141
|
+
items: normalizeInstances(parsed.items),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function normalizeSandboxes(parsed) {
|
|
145
|
+
return {
|
|
146
|
+
...(parsed.default ? { default: parsed.default } : {}),
|
|
147
|
+
items: normalizeInstances(parsed.items),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function normalizeRouteProfile(baseDir, profile, defaults) {
|
|
151
|
+
const normalized = {
|
|
152
|
+
projectRoot: resolveMaybeAbsolute(baseDir, profile.projectRoot),
|
|
153
|
+
tools: profile.tools ?? defaults.tools,
|
|
154
|
+
mentions: profile.mentions ?? defaults.mentions,
|
|
155
|
+
provider: profile.provider ?? defaults.provider,
|
|
156
|
+
sandbox: profile.sandbox ?? defaults.sandbox,
|
|
157
|
+
};
|
|
158
|
+
if (profile.systemPromptFile) {
|
|
159
|
+
normalized.systemPromptFile = resolveMaybeAbsolute(baseDir, profile.systemPromptFile);
|
|
160
|
+
}
|
|
161
|
+
return normalized;
|
|
162
|
+
}
|
|
163
|
+
function normalizeRoutes(parsed, baseDir, defaults) {
|
|
164
|
+
const items = {};
|
|
165
|
+
for (const [routeId, profile] of Object.entries(parsed.items)) {
|
|
166
|
+
items[routeId] = normalizeRouteProfile(baseDir, profile, defaults);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
defaults,
|
|
170
|
+
items,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function normalizeBindings(parsed) {
|
|
174
|
+
const items = {};
|
|
175
|
+
for (const [bindingId, binding] of Object.entries(parsed.items)) {
|
|
176
|
+
items[bindingId] = {
|
|
177
|
+
connector: binding.connector,
|
|
178
|
+
source: {
|
|
179
|
+
type: binding.source.type,
|
|
180
|
+
id: binding.source.id,
|
|
181
|
+
},
|
|
182
|
+
route: binding.route,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return { items };
|
|
186
|
+
}
|
|
187
|
+
function validateConnectorConfigKeys(parsed) {
|
|
188
|
+
for (const [instanceId, item] of Object.entries(parsed.items)) {
|
|
189
|
+
for (const [key, message] of Object.entries(FORBIDDEN_CONNECTOR_CONFIG_KEYS)) {
|
|
190
|
+
if (Object.prototype.hasOwnProperty.call(item, key)) {
|
|
191
|
+
throw new Error(`connectors.items['${instanceId}'] must not include '${key}'. ${message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function validateReferences(parsed, normalizedRoutes) {
|
|
197
|
+
if (!parsed.providers.items[parsed.providers.default]) {
|
|
198
|
+
throw new Error(`providers.default '${parsed.providers.default}' does not exist in providers.items`);
|
|
199
|
+
}
|
|
200
|
+
const defaultSandbox = parsed.sandboxes.default ?? BUILTIN_HOST_SANDBOX_ID;
|
|
201
|
+
if (defaultSandbox !== BUILTIN_HOST_SANDBOX_ID && !parsed.sandboxes.items[defaultSandbox]) {
|
|
202
|
+
throw new Error(`sandboxes.default '${defaultSandbox}' does not exist in sandboxes.items`);
|
|
203
|
+
}
|
|
204
|
+
const resolvedDefaults = {
|
|
205
|
+
provider: parsed.routes.defaults.provider ?? parsed.providers.default,
|
|
206
|
+
sandbox: parsed.routes.defaults.sandbox ?? parsed.sandboxes.default ?? BUILTIN_HOST_SANDBOX_ID,
|
|
207
|
+
tools: parsed.routes.defaults.tools ?? "full",
|
|
208
|
+
mentions: parsed.routes.defaults.mentions ?? "required",
|
|
209
|
+
};
|
|
210
|
+
if (!parsed.providers.items[resolvedDefaults.provider]) {
|
|
211
|
+
throw new Error(`routes.defaults.provider references unknown provider '${resolvedDefaults.provider}'`);
|
|
212
|
+
}
|
|
213
|
+
if (resolvedDefaults.sandbox !== BUILTIN_HOST_SANDBOX_ID && !parsed.sandboxes.items[resolvedDefaults.sandbox]) {
|
|
214
|
+
throw new Error(`routes.defaults.sandbox references unknown sandbox '${resolvedDefaults.sandbox}'`);
|
|
215
|
+
}
|
|
216
|
+
for (const [routeId, profile] of Object.entries(normalizedRoutes.items)) {
|
|
217
|
+
if (!parsed.providers.items[profile.provider]) {
|
|
218
|
+
throw new Error(`routes.items['${routeId}'].provider references unknown provider '${profile.provider}'`);
|
|
219
|
+
}
|
|
220
|
+
if (profile.sandbox !== BUILTIN_HOST_SANDBOX_ID && !parsed.sandboxes.items[profile.sandbox]) {
|
|
221
|
+
throw new Error(`routes.items['${routeId}'].sandbox references unknown sandbox '${profile.sandbox}'`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const seenSources = new Map();
|
|
225
|
+
for (const [bindingId, binding] of Object.entries(parsed.bindings.items)) {
|
|
226
|
+
if (!parsed.connectors.items[binding.connector]) {
|
|
227
|
+
throw new Error(`bindings.items['${bindingId}'].connector references unknown connector '${binding.connector}'`);
|
|
228
|
+
}
|
|
229
|
+
if (!normalizedRoutes.items[binding.route]) {
|
|
230
|
+
throw new Error(`bindings.items['${bindingId}'].route references unknown route '${binding.route}'`);
|
|
231
|
+
}
|
|
232
|
+
const bindingKey = `${binding.connector}:${binding.source.type}:${binding.source.id}`;
|
|
233
|
+
const existingBindingId = seenSources.get(bindingKey);
|
|
234
|
+
if (existingBindingId) {
|
|
235
|
+
throw new Error(`bindings.items['${bindingId}'] duplicates source '${bindingKey}' already used by bindings.items['${existingBindingId}']`);
|
|
236
|
+
}
|
|
237
|
+
seenSources.set(bindingKey, bindingId);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
export async function loadGatewayConfig(configPath) {
|
|
241
|
+
const absoluteConfigPath = resolve(configPath);
|
|
242
|
+
const configBaseDir = resolveConfigBaseDir(absoluteConfigPath);
|
|
243
|
+
const raw = await readFile(absoluteConfigPath, "utf-8");
|
|
244
|
+
const parsed = gatewayConfigSchema.parse(JSON.parse(raw));
|
|
245
|
+
validateConnectorConfigKeys(parsed.connectors);
|
|
246
|
+
const routeDefaults = {
|
|
247
|
+
provider: parsed.routes.defaults.provider ?? parsed.providers.default,
|
|
248
|
+
sandbox: parsed.routes.defaults.sandbox ?? parsed.sandboxes.default ?? BUILTIN_HOST_SANDBOX_ID,
|
|
249
|
+
tools: parsed.routes.defaults.tools ?? "full",
|
|
250
|
+
mentions: parsed.routes.defaults.mentions ?? "required",
|
|
251
|
+
};
|
|
252
|
+
const normalizedRoutes = normalizeRoutes(parsed.routes, configBaseDir, routeDefaults);
|
|
253
|
+
validateReferences(parsed, normalizedRoutes);
|
|
254
|
+
const rootDir = resolveMaybeAbsolute(configBaseDir, parsed.data.rootDir);
|
|
255
|
+
return {
|
|
256
|
+
extensions: normalizeExtensions(parsed.extensions),
|
|
257
|
+
providers: normalizeProviders(parsed.providers),
|
|
258
|
+
connectors: normalizeConnectors(parsed.connectors),
|
|
259
|
+
sandboxes: normalizeSandboxes(parsed.sandboxes),
|
|
260
|
+
routes: normalizedRoutes,
|
|
261
|
+
bindings: normalizeBindings(parsed.bindings),
|
|
262
|
+
data: {
|
|
263
|
+
rootDir,
|
|
264
|
+
sessionsDir: resolve(rootDir, "sessions"),
|
|
265
|
+
attachmentsDir: resolve(rootDir, "attachments"),
|
|
266
|
+
logsDir: resolve(rootDir, "logs"),
|
|
267
|
+
stateDir: resolve(rootDir, "state"),
|
|
268
|
+
dedupTtlMs: parsed.data.dedupTtlMs,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
export class RouteResolver {
|
|
273
|
+
routes;
|
|
274
|
+
constructor(routes) {
|
|
275
|
+
this.routes = routes;
|
|
276
|
+
}
|
|
277
|
+
resolve(routeId) {
|
|
278
|
+
const normalizedRouteId = routeId.trim();
|
|
279
|
+
if (!normalizedRouteId)
|
|
280
|
+
return null;
|
|
281
|
+
const profile = this.routes.items[normalizedRouteId];
|
|
282
|
+
if (!profile)
|
|
283
|
+
return null;
|
|
284
|
+
return { routeId: normalizedRouteId, profile };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
export class BindingResolver {
|
|
288
|
+
bindingsBySource = new Map();
|
|
289
|
+
constructor(bindings) {
|
|
290
|
+
for (const [bindingId, binding] of Object.entries(bindings.items)) {
|
|
291
|
+
this.bindingsBySource.set(this.buildKey(binding.connector, binding.source), {
|
|
292
|
+
bindingId,
|
|
293
|
+
config: binding,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
resolve(connectorId, source) {
|
|
298
|
+
if (!connectorId.trim() || !source.id.trim()) {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
return this.bindingsBySource.get(this.buildKey(connectorId, source)) ?? null;
|
|
302
|
+
}
|
|
303
|
+
buildKey(connectorId, source) {
|
|
304
|
+
return `${connectorId.trim()}:${source.type}:${source.id.trim()}`;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export class RuntimeRegistry {
|
|
2
|
+
logger;
|
|
3
|
+
entries = new Map();
|
|
4
|
+
constructor(logger) {
|
|
5
|
+
this.logger = logger;
|
|
6
|
+
}
|
|
7
|
+
async run(key, createFn, task) {
|
|
8
|
+
const entry = this.getOrCreateEntry(key);
|
|
9
|
+
const scheduledEpoch = entry.epoch;
|
|
10
|
+
entry.scheduledTasks += 1;
|
|
11
|
+
const run = entry.tail.then(async () => {
|
|
12
|
+
if (scheduledEpoch !== entry.epoch)
|
|
13
|
+
return;
|
|
14
|
+
let runtime = entry.runtime;
|
|
15
|
+
if (!runtime) {
|
|
16
|
+
const created = await createFn();
|
|
17
|
+
if (scheduledEpoch !== entry.epoch) {
|
|
18
|
+
await this.closeRuntime(key, created, "Discarding runtime created for stale queued task");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
entry.runtime = created;
|
|
22
|
+
runtime = created;
|
|
23
|
+
}
|
|
24
|
+
await task(runtime);
|
|
25
|
+
});
|
|
26
|
+
const managedRun = run.finally(() => {
|
|
27
|
+
entry.scheduledTasks = Math.max(0, entry.scheduledTasks - 1);
|
|
28
|
+
});
|
|
29
|
+
this.attachTail(key, entry, managedRun, "Queued task failed");
|
|
30
|
+
await managedRun;
|
|
31
|
+
}
|
|
32
|
+
async abort(key) {
|
|
33
|
+
const entry = this.entries.get(key);
|
|
34
|
+
if (!entry?.runtime)
|
|
35
|
+
return false;
|
|
36
|
+
return this.abortRuntime(key, entry.runtime, "Failed to abort runtime");
|
|
37
|
+
}
|
|
38
|
+
async cancel(key) {
|
|
39
|
+
const entry = this.entries.get(key);
|
|
40
|
+
if (!entry)
|
|
41
|
+
return false;
|
|
42
|
+
if (entry.scheduledTasks === 0)
|
|
43
|
+
return false;
|
|
44
|
+
entry.epoch += 1;
|
|
45
|
+
if (!entry.runtime)
|
|
46
|
+
return true;
|
|
47
|
+
return this.abortRuntime(key, entry.runtime, "Failed to cancel runtime");
|
|
48
|
+
}
|
|
49
|
+
async reset(key) {
|
|
50
|
+
const entry = this.entries.get(key);
|
|
51
|
+
if (!entry)
|
|
52
|
+
return false;
|
|
53
|
+
entry.epoch += 1;
|
|
54
|
+
if (entry.runtime) {
|
|
55
|
+
await this.abortRuntime(key, entry.runtime, "Failed to abort runtime during reset");
|
|
56
|
+
}
|
|
57
|
+
const close = entry.tail.then(async () => {
|
|
58
|
+
const runtime = entry.runtime;
|
|
59
|
+
entry.runtime = undefined;
|
|
60
|
+
if (!runtime)
|
|
61
|
+
return;
|
|
62
|
+
await this.closeRuntime(key, runtime, "Failed to close runtime during reset");
|
|
63
|
+
});
|
|
64
|
+
this.attachTail(key, entry, close);
|
|
65
|
+
await close;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
async closeAll() {
|
|
69
|
+
const keys = [...this.entries.keys()];
|
|
70
|
+
await Promise.all(keys.map((key) => this.reset(key)));
|
|
71
|
+
this.entries.clear();
|
|
72
|
+
}
|
|
73
|
+
getOrCreateEntry(key) {
|
|
74
|
+
const existing = this.entries.get(key);
|
|
75
|
+
if (existing)
|
|
76
|
+
return existing;
|
|
77
|
+
const entry = {
|
|
78
|
+
runtime: undefined,
|
|
79
|
+
tail: Promise.resolve(),
|
|
80
|
+
epoch: 0,
|
|
81
|
+
scheduledTasks: 0,
|
|
82
|
+
};
|
|
83
|
+
this.entries.set(key, entry);
|
|
84
|
+
return entry;
|
|
85
|
+
}
|
|
86
|
+
attachTail(key, entry, run, errorMessage = "Queued task failed") {
|
|
87
|
+
const nextTail = run.catch((error) => {
|
|
88
|
+
this.logger.error({ err: error, conversationKey: key }, errorMessage);
|
|
89
|
+
});
|
|
90
|
+
entry.tail = nextTail;
|
|
91
|
+
void nextTail.finally(() => {
|
|
92
|
+
if (this.entries.get(key) !== entry)
|
|
93
|
+
return;
|
|
94
|
+
if (entry.runtime !== undefined)
|
|
95
|
+
return;
|
|
96
|
+
if (entry.tail !== nextTail)
|
|
97
|
+
return;
|
|
98
|
+
this.entries.delete(key);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
async abortRuntime(key, runtime, errorMessage) {
|
|
102
|
+
try {
|
|
103
|
+
await runtime.runtime.abort();
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
this.logger.error({ err: error, conversationKey: key }, errorMessage);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async closeRuntime(key, runtime, errorMessage) {
|
|
112
|
+
try {
|
|
113
|
+
await runtime.close();
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
this.logger.error({ err: error, conversationKey: key }, errorMessage);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { parseControlCommand } from "../control-command.js";
|
|
4
|
+
test("parseControlCommand recognizes cancel aliases", () => {
|
|
5
|
+
assert.equal(parseControlCommand("stop"), "cancel");
|
|
6
|
+
assert.equal(parseControlCommand(" /STOP "), "cancel");
|
|
7
|
+
assert.equal(parseControlCommand("/cancel"), "cancel");
|
|
8
|
+
});
|
|
9
|
+
test("parseControlCommand recognizes new session aliases", () => {
|
|
10
|
+
assert.equal(parseControlCommand("/new"), "new_session");
|
|
11
|
+
assert.equal(parseControlCommand(" /reset "), "new_session");
|
|
12
|
+
});
|
|
13
|
+
test("parseControlCommand ignores regular messages", () => {
|
|
14
|
+
assert.equal(parseControlCommand("please /new"), null);
|
|
15
|
+
assert.equal(parseControlCommand(""), null);
|
|
16
|
+
assert.equal(parseControlCommand("hello"), null);
|
|
17
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { Gateway } from "../gateway.js";
|
|
4
|
+
class FakeConnector {
|
|
5
|
+
id = "connector.test";
|
|
6
|
+
platform = "test";
|
|
7
|
+
name = "test";
|
|
8
|
+
capabilities;
|
|
9
|
+
sent = [];
|
|
10
|
+
sentCount = 0;
|
|
11
|
+
constructor(updateStrategy) {
|
|
12
|
+
this.capabilities = {
|
|
13
|
+
updateStrategy,
|
|
14
|
+
supportsThread: false,
|
|
15
|
+
supportsTyping: false,
|
|
16
|
+
supportsFileUpload: false,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
async start(_ctx) { }
|
|
20
|
+
async send(message) {
|
|
21
|
+
this.sent.push(message);
|
|
22
|
+
this.sentCount += 1;
|
|
23
|
+
return { messageId: `msg-${this.sentCount}` };
|
|
24
|
+
}
|
|
25
|
+
async stop() { }
|
|
26
|
+
}
|
|
27
|
+
class ThrowingRuntime {
|
|
28
|
+
listener = null;
|
|
29
|
+
subscribe(listener) {
|
|
30
|
+
this.listener = listener;
|
|
31
|
+
return () => {
|
|
32
|
+
this.listener = null;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
async prompt() {
|
|
36
|
+
this.listener?.({ type: "message_complete", text: "partial output" });
|
|
37
|
+
throw new Error("boom");
|
|
38
|
+
}
|
|
39
|
+
async abort() { }
|
|
40
|
+
dispose() {
|
|
41
|
+
this.listener = null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const route = {
|
|
45
|
+
routeId: "route.main",
|
|
46
|
+
profile: {
|
|
47
|
+
projectRoot: "/tmp/project",
|
|
48
|
+
tools: "readonly",
|
|
49
|
+
allowMentionsOnly: false,
|
|
50
|
+
maxConcurrentTurns: 1,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const noopLogger = {
|
|
54
|
+
info: () => { },
|
|
55
|
+
warn: () => { },
|
|
56
|
+
error: () => { },
|
|
57
|
+
debug: () => { },
|
|
58
|
+
};
|
|
59
|
+
const fakeExecutor = {
|
|
60
|
+
exec: async () => ({ stdout: "", stderr: "", code: 0, killed: false }),
|
|
61
|
+
spawn: () => {
|
|
62
|
+
throw new Error("spawn should not be called in this test");
|
|
63
|
+
},
|
|
64
|
+
close: async () => { },
|
|
65
|
+
};
|
|
66
|
+
const fakeDedupStore = {
|
|
67
|
+
has: () => false,
|
|
68
|
+
add: () => { },
|
|
69
|
+
load: async () => { },
|
|
70
|
+
startAutoFlush: () => { },
|
|
71
|
+
stopAutoFlush: () => { },
|
|
72
|
+
flush: async () => { },
|
|
73
|
+
};
|
|
74
|
+
const fakeRuntimeRegistry = {
|
|
75
|
+
getOrCreate: async () => {
|
|
76
|
+
throw new Error("getOrCreate should not be called for scheduled stateless runs");
|
|
77
|
+
},
|
|
78
|
+
enqueue: async () => {
|
|
79
|
+
throw new Error("enqueue should not be called for scheduled stateless runs");
|
|
80
|
+
},
|
|
81
|
+
abort: async () => false,
|
|
82
|
+
closeAll: async () => { },
|
|
83
|
+
};
|
|
84
|
+
function buildGateway(updateStrategy) {
|
|
85
|
+
const connector = new FakeConnector(updateStrategy);
|
|
86
|
+
const provider = {
|
|
87
|
+
id: "provider.main",
|
|
88
|
+
createRuntime: async () => new ThrowingRuntime(),
|
|
89
|
+
};
|
|
90
|
+
const config = {
|
|
91
|
+
extensions: { allowList: [] },
|
|
92
|
+
providers: {
|
|
93
|
+
defaultProviderId: "provider.main",
|
|
94
|
+
instances: {},
|
|
95
|
+
},
|
|
96
|
+
connectors: {
|
|
97
|
+
instances: {},
|
|
98
|
+
},
|
|
99
|
+
sandboxes: {
|
|
100
|
+
defaultSandboxId: "sandbox.main",
|
|
101
|
+
instances: {},
|
|
102
|
+
},
|
|
103
|
+
routing: {
|
|
104
|
+
routes: {
|
|
105
|
+
[route.routeId]: route.profile,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
data: {
|
|
109
|
+
rootDir: "/tmp",
|
|
110
|
+
sessionsDir: "/tmp/sessions",
|
|
111
|
+
attachmentsDir: "/tmp/attachments",
|
|
112
|
+
logsDir: "/tmp/logs",
|
|
113
|
+
stateDir: "/tmp/state",
|
|
114
|
+
dedupTtlMs: 60_000,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
const routeResolver = {
|
|
118
|
+
resolve: (routeId) => (routeId === route.routeId ? route : null),
|
|
119
|
+
};
|
|
120
|
+
const gateway = new Gateway({
|
|
121
|
+
config,
|
|
122
|
+
connectors: [connector],
|
|
123
|
+
providers: new Map([["provider.main", provider]]),
|
|
124
|
+
executors: new Map([["sandbox.main", fakeExecutor]]),
|
|
125
|
+
routeResolver,
|
|
126
|
+
dedupStore: fakeDedupStore,
|
|
127
|
+
runtimeRegistry: fakeRuntimeRegistry,
|
|
128
|
+
logger: noopLogger,
|
|
129
|
+
});
|
|
130
|
+
return { gateway, connector };
|
|
131
|
+
}
|
|
132
|
+
async function runScheduledAndCollect(updateStrategy) {
|
|
133
|
+
const { gateway, connector } = buildGateway(updateStrategy);
|
|
134
|
+
await gateway.handleScheduled({
|
|
135
|
+
jobId: "job-1",
|
|
136
|
+
runId: `run-${updateStrategy}`,
|
|
137
|
+
connectorId: connector.id,
|
|
138
|
+
routeId: route.routeId,
|
|
139
|
+
channelId: "chat-1",
|
|
140
|
+
prompt: "hello",
|
|
141
|
+
});
|
|
142
|
+
return connector;
|
|
143
|
+
}
|
|
144
|
+
test("gateway error path uses update for edit strategy", async () => {
|
|
145
|
+
const connector = await runScheduledAndCollect("edit");
|
|
146
|
+
assert.equal(connector.sent.length, 2);
|
|
147
|
+
assert.equal(connector.sent[0]?.mode, "create");
|
|
148
|
+
assert.equal(connector.sent[0]?.text, "partial output");
|
|
149
|
+
assert.equal(connector.sent[1]?.mode, "update");
|
|
150
|
+
assert.equal(connector.sent[1]?.targetMessageId, "msg-1");
|
|
151
|
+
assert.equal(connector.sent[1]?.text, "Error: boom");
|
|
152
|
+
});
|
|
153
|
+
test("gateway error path uses create for append strategy", async () => {
|
|
154
|
+
const connector = await runScheduledAndCollect("append");
|
|
155
|
+
assert.equal(connector.sent.length, 2);
|
|
156
|
+
assert.equal(connector.sent[0]?.mode, "create");
|
|
157
|
+
assert.equal(connector.sent[0]?.text, "partial output");
|
|
158
|
+
assert.equal(connector.sent[1]?.mode, "create");
|
|
159
|
+
assert.equal(connector.sent[1]?.replyToMessageId, "msg-1");
|
|
160
|
+
assert.equal(connector.sent[1]?.text, "Error: boom");
|
|
161
|
+
});
|
|
162
|
+
test("gateway error path uses create for final_only strategy", async () => {
|
|
163
|
+
const connector = await runScheduledAndCollect("final_only");
|
|
164
|
+
assert.equal(connector.sent.length, 1);
|
|
165
|
+
assert.equal(connector.sent[0]?.mode, "create");
|
|
166
|
+
assert.equal(connector.sent[0]?.text, "Error: boom");
|
|
167
|
+
});
|