dsh-dbhub-live 5.0.1 → 5.2.0-dev.3
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/AGENTS.md +95 -209
- package/README.en.md +251 -169
- package/README.md +251 -169
- package/doc/ARCHITECTURE.md +108 -0
- package/doc/CLIENT.md +124 -0
- package/doc/DESENSITIZE.md +56 -0
- package/doc/DEVELOPMENT.md +139 -0
- package/doc/IMPACT-MATRIX.md +36 -0
- package/doc/RELEASE.md +93 -0
- package/doc/REQUIREMENTS.md +177 -105
- package/doc/images/chat-query.png +0 -0
- package/doc/images/row-editor.png +0 -0
- package/doc/images/settings-page.png +0 -0
- package/lib/adhoc.mjs +412 -34
- package/lib/capability.mjs +157 -0
- package/lib/client.js +1243 -80
- package/lib/config.mjs +370 -21
- package/lib/i18n.mjs +119 -4
- package/lib/index.mjs +401 -43
- package/lib/mcp.mjs +496 -391
- package/lib/meta.mjs +267 -0
- package/lib/options.mjs +25 -5
- package/lib/policy.mjs +92 -0
- package/lib/toml.mjs +188 -0
- package/lib/tools.mjs +1093 -827
- package/lib/update.mjs +244 -0
- package/package.json +34 -10
- package/scripts/desensitize-check.mjs +10 -3
- package/scripts/desensitize.config.mjs +255 -237
- package/scripts/docs-check.mjs +214 -0
- package/scripts/release-preflight.mjs +180 -0
- package/scripts/render-check.mjs +1366 -843
- package/test/adhoc-toml.test.mjs +365 -0
- package/test/bridge.test.mjs +227 -185
- package/test/capability.test.mjs +231 -0
- package/test/client-format.test.mjs +515 -352
- package/test/configure-options.test.mjs +244 -0
- package/test/configure-ssh.test.mjs +232 -0
- package/test/copy-from.test.mjs +155 -0
- package/test/env-options.test.mjs +456 -0
- package/test/meta.test.mjs +234 -0
- package/test/options.test.mjs +160 -123
- package/test/policy.test.mjs +132 -0
- package/test/row-cache.test.mjs +237 -138
- package/test/ssh-probe.test.mjs +834 -0
- package/test/test-op-fastpath.test.mjs +208 -0
- package/test/toml.test.mjs +352 -0
- package/test/update.test.mjs +270 -0
- package/test/util.test.mjs +17 -0
- package/test/zero-knowledge.test.mjs +120 -78
package/lib/mcp.mjs
CHANGED
|
@@ -1,392 +1,497 @@
|
|
|
1
|
-
// dsh-dbhub-live: MCP client + source discovery + tool registry.
|
|
2
|
-
//
|
|
3
|
-
// Architecture (4.0): NO resident dbhub server. Every tool call spawns its
|
|
4
|
-
// own throwaway `dbhub --transport stdio --dsn <dsn>` process (see
|
|
5
|
-
// adhoc.mjs/runAdhoc), so execution is stateless, concurrent (process-per-
|
|
6
|
-
// call) and fault-isolated — a hung/failing query can never take down another
|
|
7
|
-
// call or another DSH instance, and there is no orphan/port/toml state to
|
|
8
|
-
// fight over. This module owns the MCP JSON-RPC wire client (reused by the
|
|
9
|
-
// ad-hoc executor), the workspace×environment source discovery that the
|
|
10
|
-
// tools resolve `source` handles against, and the constant tool registry
|
|
11
|
-
// (the source of truth for the registered-tool count).
|
|
12
|
-
|
|
13
|
-
import { basename } from 'node:path'
|
|
14
|
-
import {
|
|
15
|
-
store, listWorkspaces, listWorkspaceEnvironments, resolveWorkspaceEnvs,
|
|
16
|
-
slugify, envSlug, shortHash, connLabel,
|
|
17
|
-
} from './config.mjs'
|
|
18
|
-
import { currentT } from './i18n.mjs'
|
|
19
|
-
import * as state from './state.mjs'
|
|
20
|
-
|
|
21
|
-
// ── MCP client (JSON-RPC 2.0 over stdio) ─────────────────────────────────
|
|
22
|
-
|
|
23
|
-
export function createMcpClient(handle, onStderr, onNotification) {
|
|
24
|
-
let buf = ''
|
|
25
|
-
let nextId = 1
|
|
26
|
-
let closed = false
|
|
27
|
-
const pending = new Map()
|
|
28
|
-
const settleAll = (err) => {
|
|
29
|
-
for (const p of pending.values()) p.reject(err)
|
|
30
|
-
pending.clear()
|
|
31
|
-
}
|
|
32
|
-
if (handle.stdout) {
|
|
33
|
-
handle.stdout.on('data', (chunk) => {
|
|
34
|
-
buf += String(chunk)
|
|
35
|
-
let idx
|
|
36
|
-
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
37
|
-
const line = buf.slice(0, idx).trim()
|
|
38
|
-
buf = buf.slice(idx + 1)
|
|
39
|
-
if (!line) continue
|
|
40
|
-
let msg
|
|
41
|
-
try {
|
|
42
|
-
msg = JSON.parse(line)
|
|
43
|
-
} catch (e) {
|
|
44
|
-
continue
|
|
45
|
-
}
|
|
46
|
-
if (msg && typeof msg.id === 'number' && pending.has(msg.id)) {
|
|
47
|
-
const p = pending.get(msg.id)
|
|
48
|
-
pending.delete(msg.id)
|
|
49
|
-
if (msg.error) p.reject(new Error('MCP error: ' + JSON.stringify(msg.error)))
|
|
50
|
-
else p.resolve(msg.result)
|
|
51
|
-
} else if (msg && msg.method && !msg.id && onNotification) {
|
|
52
|
-
try {
|
|
53
|
-
onNotification(msg)
|
|
54
|
-
} catch (e) {
|
|
55
|
-
/* contain listener failures */
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
})
|
|
60
|
-
}
|
|
61
|
-
if (handle.stderr && onStderr) handle.stderr.on('data', onStderr)
|
|
62
|
-
handle.done.then((outcome) => {
|
|
63
|
-
closed = true
|
|
64
|
-
settleAll(
|
|
65
|
-
new Error(
|
|
66
|
-
'dbhub exited (code ' + outcome.exitCode + ')' + (outcome.signal ? ', signal ' + outcome.signal : ''),
|
|
67
|
-
),
|
|
68
|
-
)
|
|
69
|
-
}).catch((err) => {
|
|
70
|
-
closed = true
|
|
71
|
-
settleAll(err instanceof Error ? err : new Error(String(err)))
|
|
72
|
-
})
|
|
73
|
-
return {
|
|
74
|
-
get closed() {
|
|
75
|
-
return closed
|
|
76
|
-
},
|
|
77
|
-
request(method, params) {
|
|
78
|
-
const id = nextId++
|
|
79
|
-
return new Promise((resolve, reject) => {
|
|
80
|
-
if (!handle.stdin) {
|
|
81
|
-
reject(new Error('dbhub stdin unavailable'))
|
|
82
|
-
return
|
|
83
|
-
}
|
|
84
|
-
if (closed) {
|
|
85
|
-
reject(new Error('dbhub process is down'))
|
|
86
|
-
return
|
|
87
|
-
}
|
|
88
|
-
pending.set(id, { resolve, reject })
|
|
89
|
-
try {
|
|
90
|
-
handle.stdin.write(
|
|
91
|
-
JSON.stringify(
|
|
92
|
-
params === undefined
|
|
93
|
-
? { jsonrpc: '2.0', id, method }
|
|
94
|
-
: { jsonrpc: '2.0', id, method, params },
|
|
95
|
-
) + '\n',
|
|
96
|
-
)
|
|
97
|
-
} catch (e) {
|
|
98
|
-
pending.delete(id)
|
|
99
|
-
reject(e)
|
|
100
|
-
}
|
|
101
|
-
})
|
|
102
|
-
},
|
|
103
|
-
notify(method, params) {
|
|
104
|
-
if (!handle.stdin || closed) return
|
|
105
|
-
try {
|
|
106
|
-
handle.stdin.write(
|
|
107
|
-
JSON.stringify(
|
|
108
|
-
params === undefined
|
|
109
|
-
? { jsonrpc: '2.0', method }
|
|
110
|
-
: { jsonrpc: '2.0', method, params },
|
|
111
|
-
) + '\n',
|
|
112
|
-
)
|
|
113
|
-
} catch (e) {
|
|
114
|
-
/* ignore */
|
|
115
|
-
}
|
|
116
|
-
},
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// ── tool registry (single-slot, constant count) ───────────────────────────
|
|
121
|
-
//
|
|
122
|
-
// Context budget: there is exactly ONE set of dbhub tools (4 declarations:
|
|
123
|
-
// configure / list_sources / execute_sql / search_objects), period. Workspaces
|
|
124
|
-
// × environments are NOT expanded into per-source tools; instead the
|
|
125
|
-
// source-parameterized tools dbhub_execute_sql / dbhub_search_objects resolve
|
|
126
|
-
// their `source` argument at call time against the live discovery output.
|
|
127
|
-
const toolDisposers = new Map() // modelName -> disposer (plugin-owned tools)
|
|
128
|
-
const coreDisposers = toolDisposers
|
|
129
|
-
|
|
130
|
-
/** Total registered dbhub tools (constant set: configure/list/execute/search). */
|
|
131
|
-
export function activeToolCount() {
|
|
132
|
-
return coreDisposers.size
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function refreshToolCount() {
|
|
136
|
-
state.setToolCount(coreDisposers.size)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export { coreDisposers, refreshToolCount }
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Canonical source id of one discovered row: `<titleSlug>_<wsPathHash>` for
|
|
143
|
-
* the default environment, plus `_<envSlug>` for a named one. Single source of
|
|
144
|
-
* truth — the list output, the resolver and every tool reply must agree, or
|
|
145
|
-
* the model is handed a handle it cannot resolve back.
|
|
146
|
-
*/
|
|
147
|
-
export function sourceIdOf(row) {
|
|
148
|
-
const env = envSlug(row.env)
|
|
149
|
-
return slugify(row.title) + '_' + shortHash(row.wsPath) + (env ? '_' + env : '')
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// One match wins only when it is the current workspace's row or the sole
|
|
153
|
-
// candidate; anything else is surfaced as an ambiguity instead of guessing.
|
|
154
|
-
function pickBest(candidates, preferredWsPath) {
|
|
155
|
-
if (candidates.length === 1) return candidates[0]
|
|
156
|
-
const local = preferredWsPath ? candidates.filter((k) => k.row.wsPath === preferredWsPath) : []
|
|
157
|
-
if (local.length === 1) return local[0]
|
|
158
|
-
const pool = local.length > 1 ? local : candidates
|
|
159
|
-
return { ambiguous: pool.map((k) => ({ id: k.id, title: k.row.title, path: k.row.wsPath, env: k.row.env })) }
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Resolve a `source` reference (from dbhub_list_sources) to a live source row.
|
|
164
|
-
* Accepts: the exact source id (`<title>_<hash>[_<env>]`), the display forms
|
|
165
|
-
* `<title>` / `<title>_<env>` / `<title> <env>` / `<title>.<env>`, the bare
|
|
166
|
-
* environment name, the workspace path, and the row index.
|
|
167
|
-
*
|
|
168
|
-
* Matching is EXACT-FIRST: a suffix id like `app_ab12_demo` must resolve to the
|
|
169
|
-
* demo row even when the shorter base id `app_ab12` also appears earlier in the
|
|
170
|
-
* list (a prefix substring match must never hijack an exact match — the old
|
|
171
|
-
* `wanted.includes(k.id)` fallback did, sending `…_demo` calls to the default
|
|
172
|
-
* connection). The loose prefix fallback only applies when NO exact candidate
|
|
173
|
-
* exists.
|
|
174
|
-
*
|
|
175
|
-
* Ambiguity is never resolved by luck: a reference that matches rows from
|
|
176
|
-
* several workspaces (same title, or the same environment configured twice)
|
|
177
|
-
* prefers the CURRENT session workspace; when several still match, the result
|
|
178
|
-
* is `{ ambiguous: [...] }` so the caller asks the model for the exact source
|
|
179
|
-
* id rather than silently querying a look-alike connection from another
|
|
180
|
-
* workspace.
|
|
181
|
-
*
|
|
182
|
-
* @param opts.preferredWsPath - current session workspace path (may be '').
|
|
183
|
-
* @returns the matched `{ row, id }`, `{ ambiguous: [{id,title,path,env}] }`,
|
|
184
|
-
* or `undefined` when nothing matches.
|
|
185
|
-
*/
|
|
186
|
-
export async function resolveSource(ctx, subprocess, sourceRef, opts) {
|
|
187
|
-
const wanted = String(sourceRef || '').trim().toLowerCase()
|
|
188
|
-
if (!wanted) return undefined
|
|
189
|
-
const preferredWsPath = opts && typeof opts.preferredWsPath === 'string' ? opts.preferredWsPath : ''
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
export function
|
|
241
|
-
return
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
1
|
+
// dsh-dbhub-live: MCP client + source discovery + tool registry.
|
|
2
|
+
//
|
|
3
|
+
// Architecture (4.0): NO resident dbhub server. Every tool call spawns its
|
|
4
|
+
// own throwaway `dbhub --transport stdio --dsn <dsn>` process (see
|
|
5
|
+
// adhoc.mjs/runAdhoc), so execution is stateless, concurrent (process-per-
|
|
6
|
+
// call) and fault-isolated — a hung/failing query can never take down another
|
|
7
|
+
// call or another DSH instance, and there is no orphan/port/toml state to
|
|
8
|
+
// fight over. This module owns the MCP JSON-RPC wire client (reused by the
|
|
9
|
+
// ad-hoc executor), the workspace×environment source discovery that the
|
|
10
|
+
// tools resolve `source` handles against, and the constant tool registry
|
|
11
|
+
// (the source of truth for the registered-tool count).
|
|
12
|
+
|
|
13
|
+
import { basename } from 'node:path'
|
|
14
|
+
import {
|
|
15
|
+
store, listWorkspaces, listWorkspaceEnvironments, resolveWorkspaceEnvs,
|
|
16
|
+
slugify, envSlug, shortHash, connLabel, describeEnvOptions, normalizeEnvName,
|
|
17
|
+
} from './config.mjs'
|
|
18
|
+
import { currentT } from './i18n.mjs'
|
|
19
|
+
import * as state from './state.mjs'
|
|
20
|
+
|
|
21
|
+
// ── MCP client (JSON-RPC 2.0 over stdio) ─────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export function createMcpClient(handle, onStderr, onNotification) {
|
|
24
|
+
let buf = ''
|
|
25
|
+
let nextId = 1
|
|
26
|
+
let closed = false
|
|
27
|
+
const pending = new Map()
|
|
28
|
+
const settleAll = (err) => {
|
|
29
|
+
for (const p of pending.values()) p.reject(err)
|
|
30
|
+
pending.clear()
|
|
31
|
+
}
|
|
32
|
+
if (handle.stdout) {
|
|
33
|
+
handle.stdout.on('data', (chunk) => {
|
|
34
|
+
buf += String(chunk)
|
|
35
|
+
let idx
|
|
36
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
37
|
+
const line = buf.slice(0, idx).trim()
|
|
38
|
+
buf = buf.slice(idx + 1)
|
|
39
|
+
if (!line) continue
|
|
40
|
+
let msg
|
|
41
|
+
try {
|
|
42
|
+
msg = JSON.parse(line)
|
|
43
|
+
} catch (e) {
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (msg && typeof msg.id === 'number' && pending.has(msg.id)) {
|
|
47
|
+
const p = pending.get(msg.id)
|
|
48
|
+
pending.delete(msg.id)
|
|
49
|
+
if (msg.error) p.reject(new Error('MCP error: ' + JSON.stringify(msg.error)))
|
|
50
|
+
else p.resolve(msg.result)
|
|
51
|
+
} else if (msg && msg.method && !msg.id && onNotification) {
|
|
52
|
+
try {
|
|
53
|
+
onNotification(msg)
|
|
54
|
+
} catch (e) {
|
|
55
|
+
/* contain listener failures */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
if (handle.stderr && onStderr) handle.stderr.on('data', onStderr)
|
|
62
|
+
handle.done.then((outcome) => {
|
|
63
|
+
closed = true
|
|
64
|
+
settleAll(
|
|
65
|
+
new Error(
|
|
66
|
+
'dbhub exited (code ' + outcome.exitCode + ')' + (outcome.signal ? ', signal ' + outcome.signal : ''),
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
}).catch((err) => {
|
|
70
|
+
closed = true
|
|
71
|
+
settleAll(err instanceof Error ? err : new Error(String(err)))
|
|
72
|
+
})
|
|
73
|
+
return {
|
|
74
|
+
get closed() {
|
|
75
|
+
return closed
|
|
76
|
+
},
|
|
77
|
+
request(method, params) {
|
|
78
|
+
const id = nextId++
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
if (!handle.stdin) {
|
|
81
|
+
reject(new Error('dbhub stdin unavailable'))
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
if (closed) {
|
|
85
|
+
reject(new Error('dbhub process is down'))
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
pending.set(id, { resolve, reject })
|
|
89
|
+
try {
|
|
90
|
+
handle.stdin.write(
|
|
91
|
+
JSON.stringify(
|
|
92
|
+
params === undefined
|
|
93
|
+
? { jsonrpc: '2.0', id, method }
|
|
94
|
+
: { jsonrpc: '2.0', id, method, params },
|
|
95
|
+
) + '\n',
|
|
96
|
+
)
|
|
97
|
+
} catch (e) {
|
|
98
|
+
pending.delete(id)
|
|
99
|
+
reject(e)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
},
|
|
103
|
+
notify(method, params) {
|
|
104
|
+
if (!handle.stdin || closed) return
|
|
105
|
+
try {
|
|
106
|
+
handle.stdin.write(
|
|
107
|
+
JSON.stringify(
|
|
108
|
+
params === undefined
|
|
109
|
+
? { jsonrpc: '2.0', method }
|
|
110
|
+
: { jsonrpc: '2.0', method, params },
|
|
111
|
+
) + '\n',
|
|
112
|
+
)
|
|
113
|
+
} catch (e) {
|
|
114
|
+
/* ignore */
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── tool registry (single-slot, constant count) ───────────────────────────
|
|
121
|
+
//
|
|
122
|
+
// Context budget: there is exactly ONE set of dbhub tools (4 declarations:
|
|
123
|
+
// configure / list_sources / execute_sql / search_objects), period. Workspaces
|
|
124
|
+
// × environments are NOT expanded into per-source tools; instead the
|
|
125
|
+
// source-parameterized tools dbhub_execute_sql / dbhub_search_objects resolve
|
|
126
|
+
// their `source` argument at call time against the live discovery output.
|
|
127
|
+
const toolDisposers = new Map() // modelName -> disposer (plugin-owned tools)
|
|
128
|
+
const coreDisposers = toolDisposers
|
|
129
|
+
|
|
130
|
+
/** Total registered dbhub tools (constant set: configure/list/execute/search). */
|
|
131
|
+
export function activeToolCount() {
|
|
132
|
+
return coreDisposers.size
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function refreshToolCount() {
|
|
136
|
+
state.setToolCount(coreDisposers.size)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { coreDisposers, refreshToolCount }
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Canonical source id of one discovered row: `<titleSlug>_<wsPathHash>` for
|
|
143
|
+
* the default environment, plus `_<envSlug>` for a named one. Single source of
|
|
144
|
+
* truth — the list output, the resolver and every tool reply must agree, or
|
|
145
|
+
* the model is handed a handle it cannot resolve back.
|
|
146
|
+
*/
|
|
147
|
+
export function sourceIdOf(row) {
|
|
148
|
+
const env = envSlug(row.env)
|
|
149
|
+
return slugify(row.title) + '_' + shortHash(row.wsPath) + (env ? '_' + env : '')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// One match wins only when it is the current workspace's row or the sole
|
|
153
|
+
// candidate; anything else is surfaced as an ambiguity instead of guessing.
|
|
154
|
+
function pickBest(candidates, preferredWsPath) {
|
|
155
|
+
if (candidates.length === 1) return candidates[0]
|
|
156
|
+
const local = preferredWsPath ? candidates.filter((k) => k.row.wsPath === preferredWsPath) : []
|
|
157
|
+
if (local.length === 1) return local[0]
|
|
158
|
+
const pool = local.length > 1 ? local : candidates
|
|
159
|
+
return { ambiguous: pool.map((k) => ({ id: k.id, title: k.row.title, path: k.row.wsPath, env: k.row.env })) }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve a `source` reference (from dbhub_list_sources) to a live source row.
|
|
164
|
+
* Accepts: the exact source id (`<title>_<hash>[_<env>]`), the display forms
|
|
165
|
+
* `<title>` / `<title>_<env>` / `<title> <env>` / `<title>.<env>`, the bare
|
|
166
|
+
* environment name, the workspace path, and the row index.
|
|
167
|
+
*
|
|
168
|
+
* Matching is EXACT-FIRST: a suffix id like `app_ab12_demo` must resolve to the
|
|
169
|
+
* demo row even when the shorter base id `app_ab12` also appears earlier in the
|
|
170
|
+
* list (a prefix substring match must never hijack an exact match — the old
|
|
171
|
+
* `wanted.includes(k.id)` fallback did, sending `…_demo` calls to the default
|
|
172
|
+
* connection). The loose prefix fallback only applies when NO exact candidate
|
|
173
|
+
* exists.
|
|
174
|
+
*
|
|
175
|
+
* Ambiguity is never resolved by luck: a reference that matches rows from
|
|
176
|
+
* several workspaces (same title, or the same environment configured twice)
|
|
177
|
+
* prefers the CURRENT session workspace; when several still match, the result
|
|
178
|
+
* is `{ ambiguous: [...] }` so the caller asks the model for the exact source
|
|
179
|
+
* id rather than silently querying a look-alike connection from another
|
|
180
|
+
* workspace.
|
|
181
|
+
*
|
|
182
|
+
* @param opts.preferredWsPath - current session workspace path (may be '').
|
|
183
|
+
* @returns the matched `{ row, id }`, `{ ambiguous: [{id,title,path,env}] }`,
|
|
184
|
+
* or `undefined` when nothing matches.
|
|
185
|
+
*/
|
|
186
|
+
export async function resolveSource(ctx, subprocess, sourceRef, opts) {
|
|
187
|
+
const wanted = String(sourceRef || '').trim().toLowerCase()
|
|
188
|
+
if (!wanted) return undefined
|
|
189
|
+
const preferredWsPath = opts && typeof opts.preferredWsPath === 'string' ? opts.preferredWsPath : ''
|
|
190
|
+
const keyedOf = (rows) => rows.map((row, i) => {
|
|
191
|
+
const id = sourceIdOf(row)
|
|
192
|
+
const title = slugify(row.title)
|
|
193
|
+
const env = envSlug(row.env)
|
|
194
|
+
const keys = new Set([id.toLowerCase(), title, String(row.wsPath).toLowerCase(), String(i)])
|
|
195
|
+
if (row.env !== 'default') {
|
|
196
|
+
// Display forms the model may echo back: both the ASCII slug and the RAW
|
|
197
|
+
// environment name (a Chinese env must be referable as 线上 as well).
|
|
198
|
+
for (const k of [title + '_' + env, title + '_' + row.env, title + ' ' + row.env, title + '.' + row.env, env, String(row.env)]) {
|
|
199
|
+
keys.add(String(k).toLowerCase())
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { row, id, keys }
|
|
203
|
+
})
|
|
204
|
+
const match = (keyed) => {
|
|
205
|
+
const exact = keyed.filter((k) => k.id.toLowerCase() === wanted || k.keys.has(wanted))
|
|
206
|
+
if (exact.length > 0) return pickBest(exact, preferredWsPath)
|
|
207
|
+
const prefixed = keyed.filter((k) => wanted.includes(k.id.toLowerCase()))
|
|
208
|
+
if (prefixed.length > 0) return pickBest(prefixed, preferredWsPath)
|
|
209
|
+
return undefined
|
|
210
|
+
}
|
|
211
|
+
// Fast path (T2B.5): the card mirror already holds the rows the model was just
|
|
212
|
+
// shown, so a handle that matches one of them EXACTLY is answered without
|
|
213
|
+
// re-walking every workspace (each walk spawns `mise env` for every workspace
|
|
214
|
+
// without a persisted default). Ambiguity handling is never bypassed, and a
|
|
215
|
+
// handle that misses the cache falls through to a real walk — a connection
|
|
216
|
+
// added since the last walk must still resolve.
|
|
217
|
+
if (hasRowCache()) {
|
|
218
|
+
const hit = match(keyedOf(lastRowCache))
|
|
219
|
+
if (hit) return hit
|
|
220
|
+
}
|
|
221
|
+
const { rows } = await collectSources(ctx, subprocess)
|
|
222
|
+
return match(keyedOf(rows))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── live connection rows & summaries (metadata only) ──────────────────────
|
|
226
|
+
|
|
227
|
+
// Live connection-row cache: refreshed by every collectSources run so the
|
|
228
|
+
// settings publish path can mirror the card without an extra walk. `rowCacheReady`
|
|
229
|
+
// distinguishes "walked and found nothing" from "not walked yet" — publishing the
|
|
230
|
+
// latter as an empty list would blank the card.
|
|
231
|
+
let lastRowCache = []
|
|
232
|
+
let rowCacheReady = false
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Masked→metadata connection rows for the card (passwords and usernames never
|
|
236
|
+
* leave the Host). Each row carries `conn` = host/port/database label only, the
|
|
237
|
+
* read-only flag, and the SECRET-FREE tunnel metadata from
|
|
238
|
+
* `describeEnvOptions` — never the tunnel password, passphrase or key path.
|
|
239
|
+
*/
|
|
240
|
+
export function summarizeRows(rows) {
|
|
241
|
+
return rows.map((r) => {
|
|
242
|
+
const opts = describeEnvOptions({ readOnly: r.ro === true, ssh: r.ssh || null })
|
|
243
|
+
return {
|
|
244
|
+
title: r.title,
|
|
245
|
+
path: r.wsPath,
|
|
246
|
+
env: r.env,
|
|
247
|
+
conn: connLabel(r.dsn),
|
|
248
|
+
source: r.source,
|
|
249
|
+
persisted: !!r.persisted,
|
|
250
|
+
srcId: sourceIdOf(r),
|
|
251
|
+
ro: opts.ro,
|
|
252
|
+
ssh: opts.ssh,
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The last collected summary set (empty until the first walk). */
|
|
258
|
+
export function latestSummaries() {
|
|
259
|
+
return summarizeRows(lastRowCache)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Whether a discovery walk has completed at least once in this process. */
|
|
263
|
+
export function hasRowCache() {
|
|
264
|
+
return rowCacheReady
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Shallow copy of the live row cache (host-side rows WITH their DSNs — never
|
|
269
|
+
* handed to the browser or the model). Used by the connection-test fast path:
|
|
270
|
+
* the row the card is currently showing is exactly what the user asked to test.
|
|
271
|
+
*/
|
|
272
|
+
export function cachedRows() {
|
|
273
|
+
return lastRowCache.slice()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** One cached row by workspace path × environment, or `undefined`. */
|
|
277
|
+
export function cachedRow(wsPath, env) {
|
|
278
|
+
const name = normalizeEnvName(env)
|
|
279
|
+
return lastRowCache.find((r) => String(r.wsPath) === String(wsPath) && r.env === name)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Which connection a `{op:'test'}` should probe, resolved WITHOUT walking the
|
|
284
|
+
* workspaces when that is sound (F4). Decision order:
|
|
285
|
+
* ① a persisted row — the store is authoritative for it, so testing it can
|
|
286
|
+
* never be "less accurate" than a fresh walk;
|
|
287
|
+
* ② a row the card is already displaying — precisely the row the user clicked,
|
|
288
|
+
* so a cold-cache re-discovery could only disagree with the screen;
|
|
289
|
+
* ③ neither → `null`, and the CALLER must `await` a real walk (this is the
|
|
290
|
+
* auto-discovered, never-walked case: only a walk can tell whether a
|
|
291
|
+
* connection exists at all).
|
|
292
|
+
* Pure: takes the three inputs and returns a description, never touches the
|
|
293
|
+
* walk itself.
|
|
294
|
+
* @returns `{ dsn, ro, ssh, from: 'store'|'cache' }` or `null`.
|
|
295
|
+
*/
|
|
296
|
+
export function resolveTestDsn(input) {
|
|
297
|
+
const { storeRows, cachedRows: cached, hasCache } = input || {}
|
|
298
|
+
const wsPath = String((input && input.wsPath) || '')
|
|
299
|
+
const env = normalizeEnvName(input && input.env)
|
|
300
|
+
const pick = (rows, from) => {
|
|
301
|
+
if (!Array.isArray(rows)) return null
|
|
302
|
+
const row = rows.find((r) => String(r.wsPath) === wsPath && normalizeEnvName(r.env) === env)
|
|
303
|
+
if (!row || !row.dsn) return null
|
|
304
|
+
return { dsn: row.dsn, ro: row.ro === true, ssh: row.ssh || null, from }
|
|
305
|
+
}
|
|
306
|
+
return pick(storeRows, 'store') || (hasCache ? pick(cached, 'cache') : null)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Row identity for local patches: one row per workspace path × environment.
|
|
310
|
+
const rowKey = (wsPath, env) => String(wsPath) + '\u0000' + String(env)
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Apply ONE local mutation to a row array — the card's own write, applied to the
|
|
314
|
+
* cached walk result so the mirror can show it immediately. This exists because
|
|
315
|
+
* the authoritative walk re-reads every workspace (spawning `mise env` for each
|
|
316
|
+
* one without a persisted `default`), which is where the card's "I clicked delete
|
|
317
|
+
* and the row took two seconds to go away" latency came from.
|
|
318
|
+
*
|
|
319
|
+
* Pure: it never touches the module cache and never mutates its input.
|
|
320
|
+
* @param rows - live rows ({wsPath,title,env,dsn,source,persisted}).
|
|
321
|
+
* @param patch - `{op:'add'|'remove'|'rename', path, title?, env, dsn?, source?, from?}`.
|
|
322
|
+
* @returns a NEW rows array.
|
|
323
|
+
*/
|
|
324
|
+
export function applyRowPatch(rows, patch) {
|
|
325
|
+
const list = Array.isArray(rows) ? rows.slice() : []
|
|
326
|
+
if (!patch || typeof patch !== 'object') return list
|
|
327
|
+
const path = String(patch.path || '')
|
|
328
|
+
const env = String(patch.env == null ? '' : patch.env)
|
|
329
|
+
if (patch.op === 'remove') return list.filter((r) => rowKey(r.wsPath, r.env) !== rowKey(path, env))
|
|
330
|
+
if (patch.op === 'rename') {
|
|
331
|
+
const from = rowKey(path, patch.from)
|
|
332
|
+
const to = rowKey(path, env)
|
|
333
|
+
const moved = list.find((r) => rowKey(r.wsPath, r.env) === from)
|
|
334
|
+
// Idempotent: applying the same rename twice must not drop the row. A walk
|
|
335
|
+
// that already saw the rename has no `from` row left, and the patched row
|
|
336
|
+
// must stay put.
|
|
337
|
+
if (!moved) return list
|
|
338
|
+
const out = list.filter((r) => {
|
|
339
|
+
const k = rowKey(r.wsPath, r.env)
|
|
340
|
+
return k !== from && k !== to
|
|
341
|
+
})
|
|
342
|
+
out.push({ ...moved, env })
|
|
343
|
+
return out
|
|
344
|
+
}
|
|
345
|
+
if (patch.op !== 'add' && patch.op !== 'set' && patch.op !== 'options') return list
|
|
346
|
+
if (patch.op === 'options') {
|
|
347
|
+
// Options-only write (read-only switch / SSH tunnel): the DSN and the row's
|
|
348
|
+
// identity are untouched. Idempotent by construction — the same patch
|
|
349
|
+
// applied twice yields the same row.
|
|
350
|
+
const at = list.findIndex((r) => rowKey(r.wsPath, r.env) === rowKey(path, env))
|
|
351
|
+
if (at < 0) return list
|
|
352
|
+
const next = { ...list[at] }
|
|
353
|
+
if (patch.ro !== undefined) next.ro = patch.ro === true
|
|
354
|
+
if (patch.ssh !== undefined) next.ssh = patch.ssh || null
|
|
355
|
+
list[at] = next
|
|
356
|
+
return list
|
|
357
|
+
}
|
|
358
|
+
// add / set: the same workspace+env is replaced (that is what the store does)
|
|
359
|
+
const known = list.find((r) => String(r.wsPath) === path)
|
|
360
|
+
const out = list.filter((r) => rowKey(r.wsPath, r.env) !== rowKey(path, env))
|
|
361
|
+
out.push({
|
|
362
|
+
wsPath: path,
|
|
363
|
+
title: String(patch.title || (known ? known.title : '') || ''),
|
|
364
|
+
env,
|
|
365
|
+
dsn: String(patch.dsn || ''),
|
|
366
|
+
source: 'persisted(' + (patch.source === 'collected' ? 'collected' : 'user') + ')',
|
|
367
|
+
persisted: true,
|
|
368
|
+
ro: patch.ro === true,
|
|
369
|
+
ssh: patch.ssh || null,
|
|
370
|
+
})
|
|
371
|
+
return out
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Bookkeeping for the card's own writes while an authoritative walk is in
|
|
376
|
+
* flight. Collecting the rows is slow (it spawns `mise env` per auto-discovered
|
|
377
|
+
* workspace) and reads the store while it is still changing, so a walk that
|
|
378
|
+
* started BEFORE a write can finish AFTER it and publish a list without the row
|
|
379
|
+
* the user just saved — the row "appeared and then vanished" and only came back
|
|
380
|
+
* on some later walk.
|
|
381
|
+
*
|
|
382
|
+
* The mirror therefore remembers every patch until a walk that STARTED after it
|
|
383
|
+
* lands (that walk necessarily read the updated store), re-applying anything
|
|
384
|
+
* that arrived while a walk was running.
|
|
385
|
+
* @returns { patch, beginWalk, endWalk } — `patch` returns the fresh summaries.
|
|
386
|
+
*/
|
|
387
|
+
export function createRowMirror() {
|
|
388
|
+
let pending = []
|
|
389
|
+
return {
|
|
390
|
+
/** Apply one patch immediately and hold it until a later walk lands. */
|
|
391
|
+
patch(patch) {
|
|
392
|
+
pending.push(patch)
|
|
393
|
+
lastRowCache = applyRowPatch(lastRowCache, patch)
|
|
394
|
+
return latestSummaries()
|
|
395
|
+
},
|
|
396
|
+
/** Snapshot taken just before a walk starts. */
|
|
397
|
+
beginWalk() {
|
|
398
|
+
return pending.length
|
|
399
|
+
},
|
|
400
|
+
/** Re-apply the patches that landed during the walk; returns fresh summaries. */
|
|
401
|
+
endWalk(startLength) {
|
|
402
|
+
const start = Number.isFinite(startLength) ? startLength : 0
|
|
403
|
+
const late = pending.slice(start)
|
|
404
|
+
pending = late
|
|
405
|
+
for (const patch of late) lastRowCache = applyRowPatch(lastRowCache, patch)
|
|
406
|
+
return latestSummaries()
|
|
407
|
+
},
|
|
408
|
+
/** Whether any patch is still held (diagnostics/tests). */
|
|
409
|
+
pendingCount() {
|
|
410
|
+
return pending.length
|
|
411
|
+
},
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// One in-flight walk, shared by every concurrent caller (see collectSources).
|
|
416
|
+
let walkInFlight = null
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Walk every workspace × environment and produce the live connection rows
|
|
420
|
+
* (metadata for the card) and the resolved DSNs (host-side only — the tools
|
|
421
|
+
* resolve `source` against these rows). Shared by dbhub_list_sources and the
|
|
422
|
+
* settings publish path so the card always mirrors what the tools would use.
|
|
423
|
+
*
|
|
424
|
+
* A walk is EXPENSIVE (it spawns `mise env` for every workspace without a
|
|
425
|
+
* persisted `default`), so concurrent callers — a card publish, a model list and
|
|
426
|
+
* a connection test can all land in the same second — share ONE in-flight walk
|
|
427
|
+
* instead of each running its own. Pass `{ force: true }` for a deliberate
|
|
428
|
+
* rescan. A failed walk is never cached: the next caller really retries.
|
|
429
|
+
*
|
|
430
|
+
* Environment naming: the 'default' env keeps the legacy bare source id
|
|
431
|
+
* (`<title>_<hash>`); extra environments append `_<envSlug>`.
|
|
432
|
+
* @returns `{ rows }` — rows: [{ wsPath, title, env, dsn, source, persisted, ro, ssh }].
|
|
433
|
+
*/
|
|
434
|
+
export async function collectSources(ctx, subprocess, opts) {
|
|
435
|
+
const force = !!(opts && opts.force)
|
|
436
|
+
const inFlight = walkInFlight
|
|
437
|
+
if (inFlight && !force) return inFlight
|
|
438
|
+
const run = walkSources(ctx, subprocess)
|
|
439
|
+
walkInFlight = run
|
|
440
|
+
try {
|
|
441
|
+
return await run
|
|
442
|
+
} finally {
|
|
443
|
+
if (walkInFlight === run) walkInFlight = null
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function walkSources(ctx, subprocess) {
|
|
448
|
+
const rows = []
|
|
449
|
+
try {
|
|
450
|
+
const workspaces = await listWorkspaces(ctx)
|
|
451
|
+
const persisted = listWorkspaceEnvironments(store)
|
|
452
|
+
const byPath = new Map()
|
|
453
|
+
for (const p of persisted) {
|
|
454
|
+
if (!byPath.has(p.wsPath)) byPath.set(p.wsPath, [])
|
|
455
|
+
byPath.get(p.wsPath).push(p)
|
|
456
|
+
}
|
|
457
|
+
const pending = []
|
|
458
|
+
for (const ws of workspaces) {
|
|
459
|
+
const title = ws.title || basename(ws.path)
|
|
460
|
+
const persistedEnvs = new Set((byPath.get(ws.path) || []).map((p) => p.env))
|
|
461
|
+
pending.push(
|
|
462
|
+
resolveWorkspaceEnvs(subprocess, ctx.get('fs'), ws.path, undefined).then((resolved) => {
|
|
463
|
+
const out = []
|
|
464
|
+
for (const r of resolved) {
|
|
465
|
+
out.push({
|
|
466
|
+
wsPath: ws.path,
|
|
467
|
+
title,
|
|
468
|
+
env: r.env,
|
|
469
|
+
dsn: r.dsn,
|
|
470
|
+
source: r.source,
|
|
471
|
+
persisted: persistedEnvs.has(r.env),
|
|
472
|
+
ro: r.ro === true,
|
|
473
|
+
ssh: r.ssh || null,
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
return out
|
|
477
|
+
}),
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
// Resolve the workspaces CONCURRENTLY: each one may spawn `mise env` (bounded,
|
|
481
|
+
// see runMiseEnv), and running them in series made one slow directory delay
|
|
482
|
+
// every other workspace — including the model-facing list/execute calls, which
|
|
483
|
+
// walk synchronously and used to be interrupted as "hung".
|
|
484
|
+
const chunks = await Promise.all(pending)
|
|
485
|
+
for (const chunk of chunks) rows.push(...chunk)
|
|
486
|
+
} catch (e) {
|
|
487
|
+
console.error('[dsh-dbhub-live] source discovery failed: ' + String((e && e.message) || e))
|
|
488
|
+
}
|
|
489
|
+
lastRowCache = rows
|
|
490
|
+
rowCacheReady = true
|
|
491
|
+
return { rows }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Shared "plugin disabled" copy for every dbhub tool (locale-aware).
|
|
495
|
+
export function disabledMessage() {
|
|
496
|
+
return currentT('result.disabled')
|
|
392
497
|
}
|