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.
Files changed (50) hide show
  1. package/AGENTS.md +95 -209
  2. package/README.en.md +251 -169
  3. package/README.md +251 -169
  4. package/doc/ARCHITECTURE.md +108 -0
  5. package/doc/CLIENT.md +124 -0
  6. package/doc/DESENSITIZE.md +56 -0
  7. package/doc/DEVELOPMENT.md +139 -0
  8. package/doc/IMPACT-MATRIX.md +36 -0
  9. package/doc/RELEASE.md +93 -0
  10. package/doc/REQUIREMENTS.md +177 -105
  11. package/doc/images/chat-query.png +0 -0
  12. package/doc/images/row-editor.png +0 -0
  13. package/doc/images/settings-page.png +0 -0
  14. package/lib/adhoc.mjs +412 -34
  15. package/lib/capability.mjs +157 -0
  16. package/lib/client.js +1243 -80
  17. package/lib/config.mjs +370 -21
  18. package/lib/i18n.mjs +119 -4
  19. package/lib/index.mjs +401 -43
  20. package/lib/mcp.mjs +496 -391
  21. package/lib/meta.mjs +267 -0
  22. package/lib/options.mjs +25 -5
  23. package/lib/policy.mjs +92 -0
  24. package/lib/toml.mjs +188 -0
  25. package/lib/tools.mjs +1093 -827
  26. package/lib/update.mjs +244 -0
  27. package/package.json +34 -10
  28. package/scripts/desensitize-check.mjs +10 -3
  29. package/scripts/desensitize.config.mjs +255 -237
  30. package/scripts/docs-check.mjs +214 -0
  31. package/scripts/release-preflight.mjs +180 -0
  32. package/scripts/render-check.mjs +1366 -843
  33. package/test/adhoc-toml.test.mjs +365 -0
  34. package/test/bridge.test.mjs +227 -185
  35. package/test/capability.test.mjs +231 -0
  36. package/test/client-format.test.mjs +515 -352
  37. package/test/configure-options.test.mjs +244 -0
  38. package/test/configure-ssh.test.mjs +232 -0
  39. package/test/copy-from.test.mjs +155 -0
  40. package/test/env-options.test.mjs +456 -0
  41. package/test/meta.test.mjs +234 -0
  42. package/test/options.test.mjs +160 -123
  43. package/test/policy.test.mjs +132 -0
  44. package/test/row-cache.test.mjs +237 -138
  45. package/test/ssh-probe.test.mjs +834 -0
  46. package/test/test-op-fastpath.test.mjs +208 -0
  47. package/test/toml.test.mjs +352 -0
  48. package/test/update.test.mjs +270 -0
  49. package/test/util.test.mjs +17 -0
  50. 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 { rows } = await collectSources(ctx, subprocess)
191
- const keyed = rows.map((row, i) => {
192
- const id = sourceIdOf(row)
193
- const title = slugify(row.title)
194
- const env = envSlug(row.env)
195
- const keys = new Set([id.toLowerCase(), title, String(row.wsPath).toLowerCase(), String(i)])
196
- if (row.env !== 'default') {
197
- // Display forms the model may echo back: both the ASCII slug and the RAW
198
- // environment name (a Chinese env must be referable as 线上 as well).
199
- for (const k of [title + '_' + env, title + '_' + row.env, title + ' ' + row.env, title + '.' + row.env, env, String(row.env)]) {
200
- keys.add(String(k).toLowerCase())
201
- }
202
- }
203
- return { row, id, keys }
204
- })
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
-
212
- // ── live connection rows & summaries (metadata only) ──────────────────────
213
-
214
- // Live connection-row cache: refreshed by every collectSources run so the
215
- // settings publish path can mirror the card without an extra walk. `rowCacheReady`
216
- // distinguishes "walked and found nothing" from "not walked yet" — publishing the
217
- // latter as an empty list would blank the card.
218
- let lastRowCache = []
219
- let rowCacheReady = false
220
-
221
- /**
222
- * Masked→metadata connection rows for the card (passwords and usernames never
223
- * leave the Host). Each row carries `conn` = host/port/database label only.
224
- */
225
- export function summarizeRows(rows) {
226
- return rows.map((r) => {
227
- return {
228
- title: r.title,
229
- path: r.wsPath,
230
- env: r.env,
231
- conn: connLabel(r.dsn),
232
- source: r.source,
233
- persisted: !!r.persisted,
234
- srcId: sourceIdOf(r),
235
- }
236
- })
237
- }
238
-
239
- /** The last collected summary set (empty until the first walk). */
240
- export function latestSummaries() {
241
- return summarizeRows(lastRowCache)
242
- }
243
-
244
- /** Whether a discovery walk has completed at least once in this process. */
245
- export function hasRowCache() {
246
- return rowCacheReady
247
- }
248
-
249
- // Row identity for local patches: one row per workspace path × environment.
250
- const rowKey = (wsPath, env) => String(wsPath) + '\u0000' + String(env)
251
-
252
- /**
253
- * Apply ONE local mutation to a row array — the card's own write, applied to the
254
- * cached walk result so the mirror can show it immediately. This exists because
255
- * the authoritative walk re-reads every workspace (spawning `mise env` for each
256
- * one without a persisted `default`), which is where the card's "I clicked delete
257
- * and the row took two seconds to go away" latency came from.
258
- *
259
- * Pure: it never touches the module cache and never mutates its input.
260
- * @param rows - live rows ({wsPath,title,env,dsn,source,persisted}).
261
- * @param patch - `{op:'add'|'remove'|'rename', path, title?, env, dsn?, source?, from?}`.
262
- * @returns a NEW rows array.
263
- */
264
- export function applyRowPatch(rows, patch) {
265
- const list = Array.isArray(rows) ? rows.slice() : []
266
- if (!patch || typeof patch !== 'object') return list
267
- const path = String(patch.path || '')
268
- const env = String(patch.env == null ? '' : patch.env)
269
- if (patch.op === 'remove') return list.filter((r) => rowKey(r.wsPath, r.env) !== rowKey(path, env))
270
- if (patch.op === 'rename') {
271
- const from = rowKey(path, patch.from)
272
- const to = rowKey(path, env)
273
- const moved = list.find((r) => rowKey(r.wsPath, r.env) === from)
274
- // Idempotent: applying the same rename twice must not drop the row. A walk
275
- // that already saw the rename has no `from` row left, and the patched row
276
- // must stay put.
277
- if (!moved) return list
278
- const out = list.filter((r) => {
279
- const k = rowKey(r.wsPath, r.env)
280
- return k !== from && k !== to
281
- })
282
- out.push({ ...moved, env })
283
- return out
284
- }
285
- if (patch.op !== 'add' && patch.op !== 'set') return list
286
- // add / set: the same workspace+env is replaced (that is what the store does)
287
- const known = list.find((r) => String(r.wsPath) === path)
288
- const out = list.filter((r) => rowKey(r.wsPath, r.env) !== rowKey(path, env))
289
- out.push({
290
- wsPath: path,
291
- title: String(patch.title || (known ? known.title : '') || ''),
292
- env,
293
- dsn: String(patch.dsn || ''),
294
- source: 'persisted(' + (patch.source === 'collected' ? 'collected' : 'user') + ')',
295
- persisted: true,
296
- })
297
- return out
298
- }
299
-
300
- /**
301
- * Bookkeeping for the card's own writes while an authoritative walk is in
302
- * flight. Collecting the rows is slow (it spawns `mise env` per auto-discovered
303
- * workspace) and reads the store while it is still changing, so a walk that
304
- * started BEFORE a write can finish AFTER it and publish a list without the row
305
- * the user just saved — the row "appeared and then vanished" and only came back
306
- * on some later walk.
307
- *
308
- * The mirror therefore remembers every patch until a walk that STARTED after it
309
- * lands (that walk necessarily read the updated store), re-applying anything
310
- * that arrived while a walk was running.
311
- * @returns { patch, beginWalk, endWalk } — `patch` returns the fresh summaries.
312
- */
313
- export function createRowMirror() {
314
- let pending = []
315
- return {
316
- /** Apply one patch immediately and hold it until a later walk lands. */
317
- patch(patch) {
318
- pending.push(patch)
319
- lastRowCache = applyRowPatch(lastRowCache, patch)
320
- return latestSummaries()
321
- },
322
- /** Snapshot taken just before a walk starts. */
323
- beginWalk() {
324
- return pending.length
325
- },
326
- /** Re-apply the patches that landed during the walk; returns fresh summaries. */
327
- endWalk(startLength) {
328
- const start = Number.isFinite(startLength) ? startLength : 0
329
- const late = pending.slice(start)
330
- pending = late
331
- for (const patch of late) lastRowCache = applyRowPatch(lastRowCache, patch)
332
- return latestSummaries()
333
- },
334
- /** Whether any patch is still held (diagnostics/tests). */
335
- pendingCount() {
336
- return pending.length
337
- },
338
- }
339
- }
340
-
341
- /**
342
- * Walk every workspace × environment and produce the live connection rows
343
- * (metadata for the card) and the resolved DSNs (host-side only — the tools
344
- * resolve `source` against these rows). Shared by dbhub_list_sources and the
345
- * settings publish path so the card always mirrors what the tools would use.
346
- * Environment naming: the 'default' env keeps the legacy bare source id
347
- * (`<title>_<hash>`); extra environments append `_<envSlug>`.
348
- * @returns `{ rows, sources }` — rows: [{ wsPath, title, env, dsn, source,
349
- * persisted }]; sources: [{ id, dsn }] (dsn kept host-side).
350
- */
351
- export async function collectSources(ctx, subprocess) {
352
- const rows = []
353
- try {
354
- const workspaces = await listWorkspaces(ctx)
355
- const persisted = listWorkspaceEnvironments(store)
356
- const byPath = new Map()
357
- for (const p of persisted) {
358
- if (!byPath.has(p.wsPath)) byPath.set(p.wsPath, [])
359
- byPath.get(p.wsPath).push(p)
360
- }
361
- const pending = []
362
- for (const ws of workspaces) {
363
- const title = ws.title || basename(ws.path)
364
- const persistedEnvs = new Set((byPath.get(ws.path) || []).map((p) => p.env))
365
- pending.push(
366
- resolveWorkspaceEnvs(subprocess, ctx.get('fs'), ws.path, undefined).then((resolved) => {
367
- const out = []
368
- for (const r of resolved) {
369
- out.push({ wsPath: ws.path, title, env: r.env, dsn: r.dsn, source: r.source, persisted: persistedEnvs.has(r.env) })
370
- }
371
- return out
372
- }),
373
- )
374
- }
375
- // Resolve the workspaces CONCURRENTLY: each one may spawn `mise env` (bounded,
376
- // see runMiseEnv), and running them in series made one slow directory delay
377
- // every other workspace — including the model-facing list/execute calls, which
378
- // walk synchronously and used to be interrupted as "hung".
379
- const chunks = await Promise.all(pending)
380
- for (const chunk of chunks) rows.push(...chunk)
381
- } catch (e) {
382
- console.error('[dsh-dbhub-live] source discovery failed: ' + String((e && e.message) || e))
383
- }
384
- lastRowCache = rows
385
- rowCacheReady = true
386
- return { rows }
387
- }
388
-
389
- // Shared "plugin disabled" copy for every dbhub tool (locale-aware).
390
- export function disabledMessage() {
391
- return currentT('result.disabled')
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
  }