lazyclaw 6.0.0 → 6.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/README.ko.md +88 -25
- package/README.md +121 -190
- package/channels/handoff.mjs +18 -5
- package/channels/matrix.mjs +23 -5
- package/channels/slack.mjs +83 -50
- package/channels/slack_env.mjs +45 -0
- package/channels/telegram.mjs +49 -6
- package/channels-discord/index.mjs +76 -0
- package/channels-discord/package.json +14 -0
- package/channels-email/index.mjs +109 -0
- package/channels-email/package.json +14 -0
- package/channels-signal/index.mjs +69 -0
- package/channels-signal/package.json +9 -0
- package/channels-voice/index.mjs +81 -0
- package/channels-voice/package.json +9 -0
- package/channels-whatsapp/index.mjs +70 -0
- package/channels-whatsapp/package.json +13 -0
- package/cli.mjs +10 -21
- package/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +254 -0
- package/commands/chat.mjs +1253 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +275 -0
- package/commands/gateway.mjs +194 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +490 -0
- package/commands/service.mjs +113 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +742 -0
- package/commands/setup_channels.mjs +207 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/config_features.mjs +106 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -0
- package/daemon/lib/inbound_dedup.mjs +108 -0
- package/daemon/lib/learn_queue.mjs +46 -0
- package/daemon/lib/provider.mjs +69 -0
- package/daemon/lib/respond.mjs +83 -0
- package/daemon/route_table.mjs +96 -0
- package/daemon/routes/_deps.mjs +42 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +435 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +165 -0
- package/daemon/routes/providers.mjs +211 -0
- package/daemon/routes/rates.mjs +90 -0
- package/daemon/routes/registry.mjs +223 -0
- package/daemon/routes/sessions.mjs +213 -0
- package/daemon/routes/skills.mjs +260 -0
- package/daemon/routes/workflows.mjs +224 -0
- package/dotenv_min.mjs +51 -0
- package/first_run.mjs +24 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/gateway_guard.mjs +100 -0
- package/lib/inbound_client.mjs +108 -0
- package/lib/registry_boot.mjs +55 -0
- package/lib/service_install.mjs +207 -0
- package/package.json +15 -3
- package/providers/probe.mjs +28 -0
- package/secure_write.mjs +46 -0
- package/tui/editor.mjs +18 -2
- package/tui/repl.mjs +25 -4
- package/tui/slash_commands.mjs +4 -0
- package/tui/slash_dispatcher.mjs +118 -0
- package/tui/splash_props.mjs +52 -0
- package/web/dashboard.css +6 -12
- package/web/dashboard.html +2 -34
- package/web/dashboard.js +3 -3
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Daemon route handlers (meta), extracted verbatim from makeHandler (D5).
|
|
2
|
+
// Each handler takes the per-request dispatch context `c` and returns the
|
|
3
|
+
// HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
|
|
4
|
+
import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
// Serve a static asset that lives alongside the dashboard HTML in web/.
|
|
8
|
+
// Used for the split-out dashboard.css / dashboard.js (CLAUDE.md §7: one
|
|
9
|
+
// file = one responsibility). Same loopback origin as the JSON API, so no
|
|
10
|
+
// CORS handling is needed. Returns 404 (not the dashboard's 503 install
|
|
11
|
+
// hint) since a missing asset is a narrower failure.
|
|
12
|
+
function serveWebFile(c, filename, contentType) {
|
|
13
|
+
const { res } = c;
|
|
14
|
+
try {
|
|
15
|
+
const here = nodePath.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const filePath = nodePath.join(here, '..', '..', 'web', filename);
|
|
17
|
+
const body = fs.readFileSync(filePath);
|
|
18
|
+
res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-cache' });
|
|
19
|
+
return res.end(body);
|
|
20
|
+
} catch (e) {
|
|
21
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
22
|
+
return res.end(`not found: ${filename} (${e?.message || e})\n`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function dashboardCss(c) {
|
|
27
|
+
return serveWebFile(c, 'dashboard.css', 'text/css; charset=utf-8');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function dashboardJs(c) {
|
|
31
|
+
return serveWebFile(c, 'dashboard.js', 'text/javascript; charset=utf-8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function dashboard(c) {
|
|
35
|
+
const { res } = c;
|
|
36
|
+
// Serve the lazyclaw-only web dashboard (a single static
|
|
37
|
+
// HTML in src/lazyclaw/web/). Co-resident with the JSON
|
|
38
|
+
// API so a single port handles both — no CORS song and
|
|
39
|
+
// dance, no separate static server. Falls back to a
|
|
40
|
+
// helpful text response when the file is missing (someone
|
|
41
|
+
// ran the daemon out of a partial install).
|
|
42
|
+
try {
|
|
43
|
+
const fs = await import('node:fs');
|
|
44
|
+
const path = await import('node:path');
|
|
45
|
+
const url = await import('node:url');
|
|
46
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
47
|
+
const htmlPath = path.join(here, '..', '..', 'web', 'dashboard.html');
|
|
48
|
+
const body = fs.readFileSync(htmlPath, 'utf8');
|
|
49
|
+
res.writeHead(200, {
|
|
50
|
+
'content-type': 'text/html; charset=utf-8',
|
|
51
|
+
'cache-control': 'no-cache',
|
|
52
|
+
});
|
|
53
|
+
return res.end(body);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
|
|
56
|
+
return res.end(
|
|
57
|
+
`lazyclaw daemon is up but the dashboard HTML wasn't found.\n` +
|
|
58
|
+
`Try \`lazyclaw version\` to confirm install integrity, or hit any /api endpoint directly.\n\n` +
|
|
59
|
+
`error: ${e?.message || e}\n`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function version(c) {
|
|
65
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
66
|
+
return writeJson(res, 200, { version: ctx.version(), nodeVersion: process.version, platform: `${process.platform}-${process.arch}` });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function health(c) {
|
|
70
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
71
|
+
// Conventional liveness check — always 200 if the process
|
|
72
|
+
// is alive enough to hit the route. No config inspection
|
|
73
|
+
// (use /doctor for readiness), no provider probing — this
|
|
74
|
+
// is the "is the daemon up?" probe that load balancers
|
|
75
|
+
// and watchdog scripts expect at this path. m15: K8s
|
|
76
|
+
// readiness probes default to /healthz so we alias.
|
|
77
|
+
return writeJson(res, 200, {
|
|
78
|
+
ok: true,
|
|
79
|
+
status: 'alive',
|
|
80
|
+
uptimeMs: Date.now() - metrics.startedAtMs,
|
|
81
|
+
timestamp: new Date().toISOString(),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function metrics(c) {
|
|
86
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
87
|
+
// Aggregate per-handler counters. cacheStats are pulled per
|
|
88
|
+
// wrapped provider — we report a sum across all populated
|
|
89
|
+
// entries so the figure reflects total cache activity.
|
|
90
|
+
let cacheHits = 0, cacheMisses = 0, cacheSize = 0;
|
|
91
|
+
if (cachedByName) {
|
|
92
|
+
for (const wrapped of cachedByName.values()) {
|
|
93
|
+
const s = typeof wrapped.cacheStats === 'function' ? wrapped.cacheStats() : null;
|
|
94
|
+
if (s) {
|
|
95
|
+
cacheHits += s.hits || 0;
|
|
96
|
+
cacheMisses += s.misses || 0;
|
|
97
|
+
cacheSize += s.size || 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Cumulative tokens / cost — meaningful only when callers used
|
|
102
|
+
// body.usage / body.cost. The fields are always present (zero
|
|
103
|
+
// by default) so monitoring tooling sees a stable schema.
|
|
104
|
+
const tokensTotal = { ...metrics.tokensTotal };
|
|
105
|
+
const costs = {};
|
|
106
|
+
for (const [cur, n] of Object.entries(metrics.costsByCurrency)) {
|
|
107
|
+
// Round to six decimals here too, matching costFromUsage's
|
|
108
|
+
// precision so monitoring deltas line up with per-request
|
|
109
|
+
// breakdowns.
|
|
110
|
+
costs[cur] = Math.round(n * 1_000_000) / 1_000_000;
|
|
111
|
+
}
|
|
112
|
+
// Workflow snapshot — opportunistic. We scan the state dir
|
|
113
|
+
// once per /metrics call and count per bucket. This is
|
|
114
|
+
// cheap unless the user has thousands of state files; for
|
|
115
|
+
// truly large fleets the operator can disable by passing
|
|
116
|
+
// ctx.workflowMetrics === false.
|
|
117
|
+
let workflows = null;
|
|
118
|
+
if (ctx.workflowMetrics !== false) {
|
|
119
|
+
try {
|
|
120
|
+
const stateDir = ctx.workflowStateDir();
|
|
121
|
+
if (fs.existsSync(stateDir)) {
|
|
122
|
+
const sessions = listWorkflowSessions(stateDir);
|
|
123
|
+
workflows = { total: sessions.length, done: 0, resumable: 0, failed: 0, running: 0 };
|
|
124
|
+
for (const s of sessions) {
|
|
125
|
+
if (s.summary.done) workflows.done++;
|
|
126
|
+
if (s.summary.resumable) workflows.resumable++;
|
|
127
|
+
if (s.summary.failed > 0) workflows.failed++;
|
|
128
|
+
if (s.summary.running > 0) workflows.running++;
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
workflows = { total: 0, done: 0, resumable: 0, failed: 0, running: 0 };
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
// Don't fail /metrics because the state dir is unreadable —
|
|
135
|
+
// expose the gap as null and keep monitoring alive.
|
|
136
|
+
workflows = null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return writeJson(res, 200, {
|
|
140
|
+
uptimeMs: Date.now() - metrics.startedAtMs,
|
|
141
|
+
requestsTotal: metrics.requestsTotal,
|
|
142
|
+
requestsByStatus: metrics.requestsByStatus,
|
|
143
|
+
rateLimitDenied: metrics.rateLimitDenied,
|
|
144
|
+
cache: cachedByName ? { hits: cacheHits, misses: cacheMisses, size: cacheSize } : null,
|
|
145
|
+
tokensTotal,
|
|
146
|
+
costsByCurrency: costs,
|
|
147
|
+
workflows,
|
|
148
|
+
timestamp: new Date().toISOString(),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function status(c) {
|
|
153
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
154
|
+
const cfg = ctx.readConfig();
|
|
155
|
+
// v5: surface a one-line summary of trainer / index / sandbox so
|
|
156
|
+
// the dashboard banner doesn't need three more round-trips.
|
|
157
|
+
const trainerCfg = (cfg.trainer && typeof cfg.trainer === 'object') ? cfg.trainer : {};
|
|
158
|
+
const sandboxBackend = (cfg.sandbox && typeof cfg.sandbox === 'object' && cfg.sandbox.default) || 'local';
|
|
159
|
+
let indexRows = null;
|
|
160
|
+
try {
|
|
161
|
+
const db = indexDb.openIndex(gwConfigDir, { runIntegrityCheck: false });
|
|
162
|
+
const r = db.prepare(
|
|
163
|
+
"SELECT (SELECT COUNT(*) FROM fts_sessions) + (SELECT COUNT(*) FROM fts_skills) " +
|
|
164
|
+
" + (SELECT COUNT(*) FROM fts_trajectories) + (SELECT COUNT(*) FROM fts_memories) AS n"
|
|
165
|
+
).get();
|
|
166
|
+
indexRows = r && typeof r.n === 'number' ? r.n : null;
|
|
167
|
+
} catch { /* index may not exist yet */ }
|
|
168
|
+
// Migration backup path is conventionally <configDir>/backup-v4/.
|
|
169
|
+
let migrateBackup = null;
|
|
170
|
+
try {
|
|
171
|
+
const p = nodePath.join(gwConfigDir || skillsDefaultConfigDir(), 'backup-v4');
|
|
172
|
+
if (fs.existsSync(p)) migrateBackup = p;
|
|
173
|
+
} catch { /* ignore */ }
|
|
174
|
+
return writeJson(res, 200, {
|
|
175
|
+
provider: cfg.provider || null,
|
|
176
|
+
model: cfg.model || null,
|
|
177
|
+
keyMasked: maskApiKey(cfg['api-key']),
|
|
178
|
+
v5: {
|
|
179
|
+
trainer: {
|
|
180
|
+
provider: trainerCfg.provider || null,
|
|
181
|
+
model: trainerCfg.model || null,
|
|
182
|
+
},
|
|
183
|
+
sandboxBackend,
|
|
184
|
+
indexRows,
|
|
185
|
+
migrateBackup,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function doctor(c) {
|
|
191
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
192
|
+
// Mirror the CLI doctor output — same field set so any tool that
|
|
193
|
+
// already knows how to read CLI doctor JSON can hit this endpoint.
|
|
194
|
+
const cfg = ctx.readConfig();
|
|
195
|
+
const issues = [];
|
|
196
|
+
if (!cfg.provider) issues.push('config.provider is missing');
|
|
197
|
+
if (cfg.provider && cfg.provider !== 'mock' && !cfg['api-key']) {
|
|
198
|
+
issues.push(`config['api-key'] is missing for provider "${cfg.provider}"`);
|
|
199
|
+
}
|
|
200
|
+
if (cfg.provider && !Object.prototype.hasOwnProperty.call(PROVIDERS, cfg.provider)) {
|
|
201
|
+
issues.push(`unknown provider "${cfg.provider}"`);
|
|
202
|
+
}
|
|
203
|
+
// v5: FTS5 index integrity. Failure here is degraded-not-fatal —
|
|
204
|
+
// surfaced as an issue but doesn't take the daemon down.
|
|
205
|
+
let indexBlock = null;
|
|
206
|
+
try {
|
|
207
|
+
const integ = indexDb.integrityCheck(gwConfigDir);
|
|
208
|
+
const db = indexDb.openIndex(gwConfigDir, { runIntegrityCheck: false });
|
|
209
|
+
const rowCounts = {};
|
|
210
|
+
for (const t of ['fts_sessions', 'fts_skills', 'fts_trajectories', 'fts_memories']) {
|
|
211
|
+
try { rowCounts[t] = db.prepare(`SELECT COUNT(*) AS n FROM ${t}`).get().n; }
|
|
212
|
+
catch { rowCounts[t] = null; }
|
|
213
|
+
}
|
|
214
|
+
indexBlock = {
|
|
215
|
+
ok: !!integ.ok,
|
|
216
|
+
result: integ.result || null,
|
|
217
|
+
rowCounts,
|
|
218
|
+
lastRebuiltAt: ctx.indexLastRebuiltAt || null,
|
|
219
|
+
};
|
|
220
|
+
if (!integ.ok) issues.push(`FTS5 index integrity_check returned ${integ.result}`);
|
|
221
|
+
} catch (e) {
|
|
222
|
+
indexBlock = { ok: false, error: e?.message || String(e), rowCounts: {}, lastRebuiltAt: null };
|
|
223
|
+
issues.push(`FTS5 index unavailable: ${e?.message || e}`);
|
|
224
|
+
}
|
|
225
|
+
const ok = issues.length === 0;
|
|
226
|
+
return writeJson(res, ok ? 200 : 503, {
|
|
227
|
+
ok,
|
|
228
|
+
provider: cfg.provider || null,
|
|
229
|
+
model: cfg.model || null,
|
|
230
|
+
hasApiKey: !!cfg['api-key'],
|
|
231
|
+
nodeVersion: process.version,
|
|
232
|
+
platform: `${process.platform}-${process.arch}`,
|
|
233
|
+
issues,
|
|
234
|
+
knownProviders: Object.keys(PROVIDERS),
|
|
235
|
+
index: indexBlock,
|
|
236
|
+
timestamp: new Date().toISOString(),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Daemon route handlers (ops), extracted verbatim from makeHandler (D5).
|
|
2
|
+
// Each handler takes the per-request dispatch context `c` and returns the
|
|
3
|
+
// HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
|
|
4
|
+
import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
|
|
5
|
+
|
|
6
|
+
// Channel meta + status now live in config_features.mjs (single source for the
|
|
7
|
+
// daemon route, the CLI `channels` command, and the /channels slash). Re-export
|
|
8
|
+
// KNOWN_CHANNELS so existing importers (the drift-guard test) keep working.
|
|
9
|
+
export { KNOWN_CHANNELS } from '../../config_features.mjs';
|
|
10
|
+
import { channelStatusList } from '../../config_features.mjs';
|
|
11
|
+
|
|
12
|
+
export async function trainerStatus(c) {
|
|
13
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
14
|
+
// Reads cfg.trainer.{provider, model, schedule, budget, recipe}
|
|
15
|
+
// and reports last-run state from <configDir>/trainer-state.json
|
|
16
|
+
// if present. No standalone trainer module yet; this is a thin
|
|
17
|
+
// config-surface endpoint the dashboard reads at refresh.
|
|
18
|
+
const cfg = ctx.readConfig();
|
|
19
|
+
const t = (cfg.trainer && typeof cfg.trainer === 'object') ? cfg.trainer : {};
|
|
20
|
+
let lastRunAt = null, callsToday = null;
|
|
21
|
+
try {
|
|
22
|
+
const statePath = nodePath.join(gwConfigDir || skillsDefaultConfigDir(), 'trainer-state.json');
|
|
23
|
+
if (fs.existsSync(statePath)) {
|
|
24
|
+
const st = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
25
|
+
lastRunAt = st?.lastRunAt || null;
|
|
26
|
+
// callsToday: count entries whose ts is within the current
|
|
27
|
+
// UTC day. State writer is the (future) trainer; reader
|
|
28
|
+
// tolerates absence.
|
|
29
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
30
|
+
if (Array.isArray(st?.calls)) {
|
|
31
|
+
callsToday = st.calls.filter((c) => String(c.ts || '').startsWith(today)).length;
|
|
32
|
+
} else if (typeof st?.callsToday === 'number') {
|
|
33
|
+
callsToday = st.callsToday;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch { /* missing/corrupt state → null */ }
|
|
37
|
+
return writeJson(res, 200, {
|
|
38
|
+
provider: t.provider || null,
|
|
39
|
+
model: t.model || null,
|
|
40
|
+
schedule: t.schedule || null,
|
|
41
|
+
budget: t.budget != null ? Number(t.budget) : null,
|
|
42
|
+
recipe: t.recipe || null,
|
|
43
|
+
lastRunAt,
|
|
44
|
+
callsToday,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function recall(c) {
|
|
49
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
50
|
+
// GET /recall?q=...&scope=sessions|skills|trajectories|memories|all&k=N
|
|
51
|
+
const q = url.searchParams.get('q');
|
|
52
|
+
if (!q) return writeJson(res, 400, { error: 'missing q query parameter' });
|
|
53
|
+
const scopeParam = url.searchParams.get('scope') || 'all';
|
|
54
|
+
const scope = scopeParam === 'all'
|
|
55
|
+
? ['sessions', 'skills', 'trajectories', 'memories']
|
|
56
|
+
: scopeParam.split(',').map((x) => x.trim()).filter(Boolean);
|
|
57
|
+
const kParam = url.searchParams.get('k');
|
|
58
|
+
const k = kParam ? Math.max(1, Math.min(50, parseInt(kParam, 10) || 10)) : 10;
|
|
59
|
+
try {
|
|
60
|
+
const r = indexDb.recall(q, { configDir: gwConfigDir, scope, k });
|
|
61
|
+
return writeJson(res, 200, r);
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return writeJson(res, 500, { error: e?.message || String(e) });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function sandboxList(c) {
|
|
68
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
69
|
+
const cfg = ctx.readConfig();
|
|
70
|
+
const sb = (cfg.sandbox && typeof cfg.sandbox === 'object') ? cfg.sandbox : {};
|
|
71
|
+
const active = sb.default || 'local';
|
|
72
|
+
const profiles = sandboxListBackends().map((name) => {
|
|
73
|
+
const section = sb[name];
|
|
74
|
+
const configured = !!section && typeof section === 'object';
|
|
75
|
+
let summary = '';
|
|
76
|
+
if (configured) {
|
|
77
|
+
if (name === 'docker' && section.image) summary = `image: ${section.image}`;
|
|
78
|
+
else if (name === 'ssh' && section.host) summary = `host: ${section.host}`;
|
|
79
|
+
else if (name === 'singularity' && section.image) summary = `image: ${section.image}`;
|
|
80
|
+
else if (name === 'modal' && section.app) summary = `app: ${section.app}`;
|
|
81
|
+
else if (name === 'daytona' && section.workspace) summary = `workspace: ${section.workspace}`;
|
|
82
|
+
else if (name === 'local' && section.confiner) summary = `confiner: ${section.confiner}`;
|
|
83
|
+
}
|
|
84
|
+
return { name, configured, summary };
|
|
85
|
+
});
|
|
86
|
+
return writeJson(res, 200, { profiles, active });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function sandboxTest(c) {
|
|
90
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
91
|
+
// POST /sandbox/<name>/test — opens a session against the named
|
|
92
|
+
// backend, runs `echo hello`, returns { ok, durationMs, stdout }.
|
|
93
|
+
const name = url.pathname.match(/^\/sandbox\/([^/]+)\/test$/)[1];
|
|
94
|
+
try {
|
|
95
|
+
const sandboxMod = await import('../../sandbox/index.mjs');
|
|
96
|
+
const cfg = ctx.readConfig();
|
|
97
|
+
// Synthesise a one-off cfg.sandbox.default override so we can
|
|
98
|
+
// test a backend without mutating the user's persisted choice.
|
|
99
|
+
const probeCfg = {
|
|
100
|
+
...cfg,
|
|
101
|
+
sandbox: { ...(cfg.sandbox || {}), default: name },
|
|
102
|
+
};
|
|
103
|
+
const t0 = Date.now();
|
|
104
|
+
const box = sandboxMod.resolveSandbox(probeCfg, null);
|
|
105
|
+
const sess = await box.open();
|
|
106
|
+
let result;
|
|
107
|
+
try {
|
|
108
|
+
result = await sess.exec(['echo', 'hello'], { stdio: 'pipe' });
|
|
109
|
+
} finally {
|
|
110
|
+
try { await sess.close(); } catch { /* ignore */ }
|
|
111
|
+
}
|
|
112
|
+
const durationMs = Date.now() - t0;
|
|
113
|
+
const ok = result.code === 0;
|
|
114
|
+
return writeJson(res, ok ? 200 : 500, {
|
|
115
|
+
ok,
|
|
116
|
+
durationMs,
|
|
117
|
+
code: result.code,
|
|
118
|
+
stdout: String(result.stdout || '').slice(0, 200),
|
|
119
|
+
stderr: String(result.stderr || '').slice(0, 200),
|
|
120
|
+
});
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return writeJson(res, 500, { ok: false, error: e?.message || String(e), code: e?.code });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function sandboxUse(c) {
|
|
127
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
128
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
129
|
+
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
130
|
+
}
|
|
131
|
+
let body;
|
|
132
|
+
try { body = await readJson(req); }
|
|
133
|
+
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
134
|
+
const name = body && String(body.name || '').trim();
|
|
135
|
+
if (!name) return writeJson(res, 400, { error: 'name is required' });
|
|
136
|
+
if (!sandboxListBackends().includes(name)) {
|
|
137
|
+
return writeJson(res, 400, { error: `unknown sandbox backend: ${name}` });
|
|
138
|
+
}
|
|
139
|
+
const cfg = ctx.readConfig();
|
|
140
|
+
cfg.sandbox = { ...(cfg.sandbox || {}), default: name };
|
|
141
|
+
ctx.writeConfig(cfg);
|
|
142
|
+
return writeJson(res, 200, { ok: true, active: name });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function channels(c) {
|
|
146
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
147
|
+
// Aggregate cfg.channels.<name> via the shared status helper so the
|
|
148
|
+
// dashboard, CLI, and /channels slash all report identically.
|
|
149
|
+
const cfg = ctx.readConfig();
|
|
150
|
+
return writeJson(res, 200, { channels: channelStatusList(cfg) });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function indexRebuild(c) {
|
|
154
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
155
|
+
try {
|
|
156
|
+
// reindexAll, not rebuild: a bare rebuild zeroes the FTS index and
|
|
157
|
+
// never repopulates, so this route used to silently wipe recall.
|
|
158
|
+
indexDb.reindexAll(gwConfigDir);
|
|
159
|
+
ctx.indexLastRebuiltAt = new Date().toISOString();
|
|
160
|
+
return writeJson(res, 200, { ok: true, rebuiltAt: ctx.indexLastRebuiltAt });
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return writeJson(res, 500, { ok: false, error: e?.message || String(e) });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Daemon route handlers (providers), extracted verbatim from makeHandler (D5).
|
|
2
|
+
// Each handler takes the per-request dispatch context `c` and returns the
|
|
3
|
+
// HTTP response. Bodies are unchanged; only the dispatch wrapper is new.
|
|
4
|
+
import { fs, nodePath, PROVIDERS, PROVIDER_INFO, maskApiKey, costFromUsage, RATE_CARD_SHAPE, composeSystemPrompt, listSkills, loadSkill, skillPath, installSkill, removeSkill, parseFrontmatter, skillsDefaultConfigDir, indexDb, skillSynth, sandboxListBackends, summarizeState, listWorkflowSessions, loadWorkflowState, aggregateNodeStats, validateConfig, validateRates, fileExists, readJson, readTextBody, writeJson, writeSseHead, writeSse, statusForProviderError, checkCostCap, accumulateMetricsFromCost, resolveProvider } from './_deps.mjs';
|
|
5
|
+
|
|
6
|
+
export async function providersList(c) {
|
|
7
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
8
|
+
// ?filter=<substr>&limit=<N> mirror v3.33+ list flags.
|
|
9
|
+
// The dashboard reads `custom` / `builtinOpenAICompat` / `endpoint`
|
|
10
|
+
// / `docs` to render the right pills + tooltips; CLI callers only
|
|
11
|
+
// need `name` / `requiresApiKey` / `suggestedModels` and ignore
|
|
12
|
+
// the extras (additive change, no migration).
|
|
13
|
+
let out = Object.keys(PROVIDERS).map(name => {
|
|
14
|
+
const meta = PROVIDER_INFO[name] || { name };
|
|
15
|
+
return {
|
|
16
|
+
name,
|
|
17
|
+
requiresApiKey: !!meta.requiresApiKey,
|
|
18
|
+
defaultModel: meta.defaultModel || null,
|
|
19
|
+
suggestedModels: meta.suggestedModels || [],
|
|
20
|
+
endpoint: meta.endpoint || null,
|
|
21
|
+
docs: meta.docs || null,
|
|
22
|
+
custom: !!meta.custom,
|
|
23
|
+
builtinOpenAICompat: !!meta.builtinOpenAICompat,
|
|
24
|
+
baseUrl: meta.baseUrl || null,
|
|
25
|
+
envKey: meta.envKey || null,
|
|
26
|
+
keyPrefix: meta.keyPrefix || null,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
const filter = url.searchParams.get('filter');
|
|
30
|
+
if (filter) {
|
|
31
|
+
const f = filter.toLowerCase();
|
|
32
|
+
out = out.filter(p => p.name.toLowerCase().includes(f));
|
|
33
|
+
}
|
|
34
|
+
const limitStr = url.searchParams.get('limit');
|
|
35
|
+
if (limitStr) {
|
|
36
|
+
const n = parseInt(limitStr, 10);
|
|
37
|
+
if (Number.isFinite(n) && n > 0) out = out.slice(0, n);
|
|
38
|
+
}
|
|
39
|
+
return writeJson(res, 200, out);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function providerGet(c) {
|
|
43
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
44
|
+
// GET /providers/<name> — full per-provider metadata
|
|
45
|
+
// (mirrors CLI `lazyclaw providers info <name>`).
|
|
46
|
+
// The `name !== 'test'` guard keeps `/providers/test`
|
|
47
|
+
// (parallel batch endpoint) from being intercepted here;
|
|
48
|
+
// switch-case order ensures the literal `GET /providers/test`
|
|
49
|
+
// case runs first anyway, but the guard makes the intent
|
|
50
|
+
// explicit for future readers.
|
|
51
|
+
const name = providerMatch[1];
|
|
52
|
+
const meta = PROVIDER_INFO[name];
|
|
53
|
+
if (!meta) {
|
|
54
|
+
return writeJson(res, 404, {
|
|
55
|
+
error: 'unknown provider',
|
|
56
|
+
name,
|
|
57
|
+
knownProviders: Object.keys(PROVIDERS),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return writeJson(res, 200, meta);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function providersTest(c) {
|
|
64
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
65
|
+
// Mirror of CLI v3.55 `lazyclaw providers test` (no name).
|
|
66
|
+
// A dashboard's "key validity" badge calls this once and
|
|
67
|
+
// gets a per-provider verdict in one round trip. HTTP
|
|
68
|
+
// status mirrors CLI exit code:
|
|
69
|
+
// 200 — every provider returned a non-empty reply
|
|
70
|
+
// 503 — at least one provider failed (Service Unavailable;
|
|
71
|
+
// "the system is partially unhealthy")
|
|
72
|
+
// 503 is the right code because a dashboard observing it
|
|
73
|
+
// can render a yellow status without parsing the body.
|
|
74
|
+
const cfg = ctx.readConfig();
|
|
75
|
+
const apiKey = cfg['api-key'] || '';
|
|
76
|
+
const sharedPrompt = url.searchParams.get('prompt') || 'ping';
|
|
77
|
+
const tAll = Date.now();
|
|
78
|
+
const results = await Promise.all(
|
|
79
|
+
Object.entries(PROVIDERS).map(async ([pid, provider]) => {
|
|
80
|
+
const meta = PROVIDER_INFO[pid] || {};
|
|
81
|
+
const model = url.searchParams.get('model') || cfg.model || meta.defaultModel || 'unknown';
|
|
82
|
+
const t0 = Date.now();
|
|
83
|
+
try {
|
|
84
|
+
let reply = '';
|
|
85
|
+
const stream = provider.sendMessage([{ role: 'user', content: sharedPrompt }], { apiKey, model });
|
|
86
|
+
for await (const chunk of stream) {
|
|
87
|
+
if (typeof chunk === 'string') reply += chunk;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
name: pid, ok: reply.length > 0, model,
|
|
91
|
+
durationMs: Date.now() - t0,
|
|
92
|
+
replyLength: reply.length,
|
|
93
|
+
};
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return {
|
|
96
|
+
name: pid, ok: false, model,
|
|
97
|
+
durationMs: Date.now() - t0,
|
|
98
|
+
error: err?.message || String(err),
|
|
99
|
+
code: err?.code || null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
const allOk = results.every(r => r.ok);
|
|
105
|
+
return writeJson(res, allOk ? 200 : 503, {
|
|
106
|
+
ok: allOk,
|
|
107
|
+
totalDurationMs: Date.now() - tAll,
|
|
108
|
+
results,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function providerTest(c) {
|
|
113
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
114
|
+
// GET /providers/<name>/test — single-provider 1-token reachability
|
|
115
|
+
// probe. Same shape as one entry of GET /providers/test, but the
|
|
116
|
+
// endpoint stops on the first failure and exposes the reply body
|
|
117
|
+
// (truncated) so the dashboard can show a real signal of life.
|
|
118
|
+
const name = providerTestMatch[1];
|
|
119
|
+
const provider = PROVIDERS[name];
|
|
120
|
+
if (!provider) return writeJson(res, 404, { error: `unknown provider: ${name}` });
|
|
121
|
+
const cfg = ctx.readConfig();
|
|
122
|
+
const apiKey = cfg['api-key'] || '';
|
|
123
|
+
const meta = PROVIDER_INFO[name] || {};
|
|
124
|
+
const model = url.searchParams.get('model') || cfg.model || meta.defaultModel || 'unknown';
|
|
125
|
+
const prompt = url.searchParams.get('prompt') || 'ping';
|
|
126
|
+
const t0 = Date.now();
|
|
127
|
+
try {
|
|
128
|
+
let reply = '';
|
|
129
|
+
const stream = provider.sendMessage([{ role: 'user', content: prompt }], { apiKey, model });
|
|
130
|
+
for await (const chunk of stream) {
|
|
131
|
+
if (typeof chunk === 'string') reply += chunk;
|
|
132
|
+
}
|
|
133
|
+
return writeJson(res, reply.length > 0 ? 200 : 503, {
|
|
134
|
+
ok: reply.length > 0,
|
|
135
|
+
name, model,
|
|
136
|
+
durationMs: Date.now() - t0,
|
|
137
|
+
replyLength: reply.length,
|
|
138
|
+
reply: reply.slice(0, 500),
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return writeJson(res, 503, {
|
|
142
|
+
ok: false, name, model,
|
|
143
|
+
durationMs: Date.now() - t0,
|
|
144
|
+
error: err?.message || String(err),
|
|
145
|
+
code: err?.code || null,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function providersCreate(c) {
|
|
151
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
152
|
+
// Register or overwrite a custom OpenAI-compatible provider.
|
|
153
|
+
// Body: { name, baseUrl, apiKey?, defaultModel? }. Persists into
|
|
154
|
+
// cfg.customProviders[] and hot-registers via the registry's
|
|
155
|
+
// registerCustomProviders() so the new entry is callable in this
|
|
156
|
+
// same process. 405 when the daemon was started without
|
|
157
|
+
// writeConfig (read-only mode). The same name as a built-in
|
|
158
|
+
// OpenAI-compat alias is allowed and overrides the built-in.
|
|
159
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
160
|
+
return writeJson(res, 405, { error: 'mutation disabled — daemon was started without writeConfig' });
|
|
161
|
+
}
|
|
162
|
+
let body;
|
|
163
|
+
try { body = await readJson(req); }
|
|
164
|
+
catch (e) { return writeJson(res, 400, { error: e?.message || String(e) }); }
|
|
165
|
+
const reg = await import('../../providers/registry.mjs');
|
|
166
|
+
let name;
|
|
167
|
+
try { name = reg.validateCustomProviderName(body.name); }
|
|
168
|
+
catch (e) { return writeJson(res, 400, { error: e.message }); }
|
|
169
|
+
if (!body.baseUrl || typeof body.baseUrl !== 'string' || !/^https?:\/\//i.test(body.baseUrl)) {
|
|
170
|
+
return writeJson(res, 400, { error: 'baseUrl must be a string starting with http:// or https://' });
|
|
171
|
+
}
|
|
172
|
+
const cfg = ctx.readConfig();
|
|
173
|
+
cfg.customProviders = Array.isArray(cfg.customProviders) ? cfg.customProviders : [];
|
|
174
|
+
const idx = cfg.customProviders.findIndex((p) => p && p.name === name);
|
|
175
|
+
const entry = {
|
|
176
|
+
name,
|
|
177
|
+
baseUrl: String(body.baseUrl).replace(/\/+$/, ''),
|
|
178
|
+
apiKey: body.apiKey || undefined,
|
|
179
|
+
defaultModel: body.defaultModel || undefined,
|
|
180
|
+
};
|
|
181
|
+
if (idx >= 0) cfg.customProviders[idx] = { ...cfg.customProviders[idx], ...entry };
|
|
182
|
+
else cfg.customProviders.push(entry);
|
|
183
|
+
ctx.writeConfig(cfg);
|
|
184
|
+
try { reg.registerCustomProviders(cfg); } catch { /* keep going */ }
|
|
185
|
+
const overridesBuiltin = typeof reg.isBuiltinOpenAICompatName === 'function'
|
|
186
|
+
? reg.isBuiltinOpenAICompatName(name)
|
|
187
|
+
: false;
|
|
188
|
+
return writeJson(res, 200, { ok: true, name, baseUrl: entry.baseUrl, overridesBuiltin });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function providerDelete(c) {
|
|
192
|
+
const { ctx, logger, metrics, gateway, costCap, cachedByName, gwConfigDir, nudgeSuggestionsRing, workflowStateDir, req, res, method, path, route, url, sessionMatch, providerMatch, providerTestMatch, sessionExportMatch, skillMatch, workflowMatch, configKeyMatch, ratesKeyMatch } = c;
|
|
193
|
+
// DELETE /providers/<name> — drop a custom registration. Idempotent:
|
|
194
|
+
// 200 with `removed: false` when the name wasn't a custom entry.
|
|
195
|
+
// Built-in providers can't be deleted; their PROVIDERS row is
|
|
196
|
+
// restored on next process boot if the user previously overrode it.
|
|
197
|
+
if (typeof ctx.writeConfig !== 'function') {
|
|
198
|
+
return writeJson(res, 405, { error: 'mutation disabled' });
|
|
199
|
+
}
|
|
200
|
+
const name = providerMatch[1];
|
|
201
|
+
const cfg = ctx.readConfig();
|
|
202
|
+
if (!Array.isArray(cfg.customProviders) || cfg.customProviders.length === 0) {
|
|
203
|
+
return writeJson(res, 200, { ok: true, name, removed: false });
|
|
204
|
+
}
|
|
205
|
+
const before = cfg.customProviders.length;
|
|
206
|
+
cfg.customProviders = cfg.customProviders.filter((p) => !(p && p.name === name));
|
|
207
|
+
const removed = cfg.customProviders.length < before;
|
|
208
|
+
if (removed) ctx.writeConfig(cfg);
|
|
209
|
+
return writeJson(res, 200, { ok: true, name, removed });
|
|
210
|
+
}
|
|
211
|
+
|