@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.11.1

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.
@@ -0,0 +1,415 @@
1
+ /**
2
+ * Nl2SqlService methods: datasources.
3
+ * Mixed into the service prototype by index.js; `this` is the service.
4
+ */
5
+ import { DIALECTS, createDriver, describeError } from '../drivers.js'
6
+ import { normalizePolicy, policyActive } from '../policy.js'
7
+ import { sanitizeTableName } from '../sqlite-driver.js'
8
+ import { join } from 'node:path'
9
+ import { CREDENTIAL_SCOPE, DEFAULT_SETTINGS, RETENTION_MAX, RETENTION_MIN, SUPPORTED_TYPES, UserError, clampInt, newId, oneOf, pick, requireText } from './shared.js'
10
+
11
+ export const methods = {
12
+ // ------------------------------------------------------------ settings
13
+
14
+ /** Current settings plus `defaults` (for "restore defaults" in the page). */
15
+ getSettings() {
16
+ return { ...this.publicSettings(), defaults: { ...structuredClone(DEFAULT_SETTINGS), larkDefaultRole: DEFAULT_SETTINGS.botDefaultRole } }
17
+ },
18
+
19
+ /** Settings with the bot defaults under both names (botDefault* and the legacy larkDefault* aliases). */
20
+ publicSettings() {
21
+ const s = structuredClone(this.state.settings)
22
+ s.botDefaultRole = s.botDefaultRole ?? s.larkDefaultRole ?? 'none'
23
+ s.larkDefaultRole = s.botDefaultRole
24
+ const ids = s.botDefaultDatasourceIds !== undefined ? s.botDefaultDatasourceIds : s.larkDefaultDatasourceIds
25
+ if (ids !== undefined) { s.botDefaultDatasourceIds = ids; s.larkDefaultDatasourceIds = ids } else { delete s.botDefaultDatasourceIds; delete s.larkDefaultDatasourceIds }
26
+ return s
27
+ },
28
+
29
+ /** Move larkDefaultRole / larkDefaultDatasourceIds to the bot* keys (idempotent). */
30
+ migrateSettings() {
31
+ const s = this.state.settings
32
+ let changed = false
33
+ if (s.larkDefaultRole !== undefined) { if (s.botDefaultRole === undefined || s.botDefaultRole === DEFAULT_SETTINGS.botDefaultRole) s.botDefaultRole = s.larkDefaultRole; delete s.larkDefaultRole; changed = true }
34
+ if (s.larkDefaultDatasourceIds !== undefined) { if (s.botDefaultDatasourceIds === undefined) s.botDefaultDatasourceIds = s.larkDefaultDatasourceIds; delete s.larkDefaultDatasourceIds; changed = true }
35
+ return changed
36
+ },
37
+
38
+ /** Reset the given setting keys (or all) to their defaults. */
39
+ async resetSettings({ keys } = {}) {
40
+ const list = (Array.isArray(keys) && keys.length ? keys : Object.keys(DEFAULT_SETTINGS)).map(k => (k === 'larkDefaultRole' ? 'botDefaultRole' : k))
41
+ const unknown = list.filter(k => !Object.hasOwn(DEFAULT_SETTINGS, k))
42
+ if (unknown.length) throw new UserError('nl2sql/invalid', `未知的设置项:${unknown.join(', ')}`)
43
+ const s = { ...this.state.settings }
44
+ for (const k of list) s[k] = structuredClone(DEFAULT_SETTINGS[k])
45
+ if (!Array.isArray(keys) || !keys.length) {
46
+ // Full reset also clears the optional settings that have no default value.
47
+ for (const k of ['botDefaultDatasourceIds', 'larkDefaultDatasourceIds', 'schemaNotifyChannel', 'schemaCheckDaily']) delete s[k]
48
+ }
49
+ this.state.settings = s
50
+ delete s.larkDefaultRole
51
+ this.records.trim()
52
+ this.auditAdmin('settings.reset', list.join(', '))
53
+ await this.save()
54
+ return this.getSettings()
55
+ },
56
+
57
+ async saveSettings(input) {
58
+ const s = { ...this.state.settings }
59
+ if (input.maxRows !== undefined) s.maxRows = clampInt(input.maxRows, 1, 100000, 'maxRows')
60
+ if (input.timeoutMs !== undefined) s.timeoutMs = clampInt(input.timeoutMs, 1000, 600000, 'timeoutMs')
61
+ if (input.modelRows !== undefined) s.modelRows = clampInt(input.modelRows, 0, 500, 'modelRows')
62
+ if (input.sampleMode !== undefined) s.sampleMode = oneOf(input.sampleMode, ['off', 'enum', 'all'], 'sampleMode')
63
+ if (input.resultToModel !== undefined) s.resultToModel = oneOf(input.resultToModel, ['none', 'summary', 'rows'], 'resultToModel')
64
+ if (input.maskSensitive !== undefined) s.maskSensitive = Boolean(input.maskSensitive)
65
+ if (input.scanGuardRows !== undefined) s.scanGuardRows = clampInt(input.scanGuardRows, 0, 1e12, 'scanGuardRows')
66
+ if (input.cacheTtlSec !== undefined) s.cacheTtlSec = clampInt(input.cacheTtlSec, 0, 86400, 'cacheTtlSec')
67
+ if (input.schemaWatch !== undefined) s.schemaWatch = Boolean(input.schemaWatch)
68
+ // Bot defaults: botDefault* (new) or larkDefault* (legacy alias); stored under the new keys.
69
+ const role = input.botDefaultRole ?? input.larkDefaultRole
70
+ if (role !== undefined) { s.botDefaultRole = oneOf(role, ['none', 'viewer', 'analyst'], 'botDefaultRole'); delete s.larkDefaultRole }
71
+ const dsIds = input.botDefaultDatasourceIds !== undefined ? input.botDefaultDatasourceIds : input.larkDefaultDatasourceIds
72
+ if (dsIds !== undefined) { s.botDefaultDatasourceIds = dsIds === null ? null : (dsIds ?? []).filter(id => this.findDatasource(id)); delete s.larkDefaultDatasourceIds }
73
+ if (input.schemaNotifyChannel !== undefined) s.schemaNotifyChannel = this.state.channels.some(c => c.id === input.schemaNotifyChannel) ? input.schemaNotifyChannel : ''
74
+ if (input.schemaCheckDaily !== undefined) s.schemaCheckDaily = /^\d{2}:\d{2}$/.test(String(input.schemaCheckDaily)) ? input.schemaCheckDaily : ''
75
+ if (input.schemaOverviewMax !== undefined) s.schemaOverviewMax = clampInt(input.schemaOverviewMax, 1, 100000, 'schemaOverviewMax')
76
+ if (input.schemaOverviewTop !== undefined) s.schemaOverviewTop = clampInt(input.schemaOverviewTop, 1, 200, 'schemaOverviewTop')
77
+ let lowered = false
78
+ if (input.retention !== undefined && input.retention !== null) {
79
+ if (typeof input.retention !== 'object') throw new UserError('nl2sql/invalid', 'retention 必须是对象')
80
+ const r = { ...DEFAULT_SETTINGS.retention, ...(s.retention ?? {}) }
81
+ for (const [k, v] of Object.entries(input.retention)) {
82
+ if (!Object.hasOwn(DEFAULT_SETTINGS.retention, k)) throw new UserError('nl2sql/invalid', `未知的日志类型:${k}`)
83
+ const n = Number(v)
84
+ if (!Number.isFinite(n)) throw new UserError('nl2sql/invalid', `retention.${k} 必须是数字`)
85
+ const next = Math.min(RETENTION_MAX, Math.max(RETENTION_MIN, Math.round(n)))
86
+ if (next < r[k]) lowered = true
87
+ r[k] = next
88
+ }
89
+ s.retention = r
90
+ }
91
+ this.state.settings = s
92
+ if (lowered) this.records.trim()
93
+ await this.save()
94
+ return this.publicSettings()
95
+ },
96
+
97
+ settingsFor(ds) {
98
+ return { ...this.state.settings, ...(ds?.privacy ?? {}) }
99
+ },
100
+
101
+ // --------------------------------------------------------- datasources
102
+
103
+ publicDatasource(ds) {
104
+ return {
105
+ ...pick(ds, ['id', 'name', 'type', 'host', 'port', 'database', 'user', 'charset', 'timezone', 'ssl', 'schemas', 'description', 'createdAt', 'updatedAt', 'serverVersion', 'lastTestAt', 'lastTestOk', 'readOnlyGrant', 'privacy']),
106
+ policy: ds.policy ?? normalizePolicy(null),
107
+ type: ds.type ?? 'mysql',
108
+ hasPassword: Boolean(ds.hasPassword),
109
+ hasApiKey: Boolean(ds.hasApiKey),
110
+ ...(ds.needsSecret ? { needsSecret: true } : {}),
111
+ ssh: ds.ssh ? { enabled: Boolean(ds.ssh.enabled), host: ds.ssh.host, port: ds.ssh.port, user: ds.ssh.user, auth: ds.ssh.auth ?? 'password', hasSecret: Boolean(ds.ssh.hasSecret) } : { enabled: false },
112
+ current: this.state.current === ds.id,
113
+ schemaAnalyzedAt: this.schemas.get(ds.id)?.analyzedAt ?? ds.schemaAnalyzedAt ?? null,
114
+ }
115
+ },
116
+
117
+ listDatasources() {
118
+ return this.state.datasources.map(ds => this.publicDatasource(ds))
119
+ },
120
+
121
+ findDatasource(ref) {
122
+ if (ref === undefined || ref === null || ref === '') return undefined
123
+ return this.state.datasources.find(d => d.id === ref) ?? this.state.datasources.find(d => d.name === ref)
124
+ },
125
+
126
+ requireDatasource(ref) {
127
+ const ds = this.findDatasource(ref)
128
+ if (!ds) throw new UserError('nl2sql/unknown-datasource', `数据源不存在:${ref}`)
129
+ return ds
130
+ },
131
+
132
+ credentialKey(id) {
133
+ return `${CREDENTIAL_SCOPE}/${id}`
134
+ },
135
+
136
+ /** All secrets of one data source live in one credential record. */
137
+ async readSecrets(id) {
138
+ const creds = this.ctx.get('credentials')
139
+ if (!creds) return {}
140
+ const record = await creds.readRecord(this.credentialKey(id))
141
+ return record?.kind === 'grant' && record.payload && typeof record.payload === 'object' ? { ...record.payload } : {}
142
+ },
143
+
144
+ async writeSecrets(id, patch) {
145
+ const creds = this.ctx.get('credentials')
146
+ if (!creds) throw new UserError('nl2sql/no-credentials', '当前 Harness 没有可用的凭据服务,无法保存密码')
147
+ const next = { ...(await this.readSecrets(id)) }
148
+ for (const [k, v] of Object.entries(patch)) {
149
+ if (v === null || v === '') delete next[k]
150
+ else if (v !== undefined) next[k] = v
151
+ }
152
+ if (Object.keys(next).length === 0) {
153
+ await creds.deleteRecord(this.credentialKey(id))
154
+ return next
155
+ }
156
+ await creds.modifyRecord(this.credentialKey(id), async () => ({ kind: 'grant', payload: next }))
157
+ return next
158
+ },
159
+
160
+ /** Data source record plus its secrets, as a driver configuration. */
161
+ async driverConfig(ds, overrideSecrets) {
162
+ const secrets = overrideSecrets ?? await this.readSecrets(ds.id)
163
+ return {
164
+ ...ds,
165
+ password: secrets.password ?? '',
166
+ apiKey: secrets.apiKey ?? '',
167
+ ssh: ds.ssh?.enabled ? { ...ds.ssh, password: secrets.sshPassword ?? '', privateKey: secrets.sshPrivateKey ?? '', passphrase: secrets.sshPassphrase ?? '' } : undefined,
168
+ }
169
+ },
170
+
171
+ normalizeDatasourceInput(input, existing, { checkName = true } = {}) {
172
+ const out = existing ? { ...existing } : { id: newId('ds'), type: 'mysql', createdAt: Date.now() }
173
+ if (input.type !== undefined) out.type = oneOf(input.type, SUPPORTED_TYPES, 'type')
174
+ out.type ??= 'mysql'
175
+ if (input.name !== undefined) out.name = requireText(input.name, 'name', 64)
176
+ if (input.host !== undefined) out.host = requireText(input.host, 'host', 255)
177
+ if (input.port !== undefined) out.port = clampInt(input.port, 1, 65535, 'port')
178
+ if (input.database !== undefined) out.database = String(input.database ?? '').trim().slice(0, 255)
179
+ if (input.user !== undefined) out.user = String(input.user ?? '').trim().slice(0, 128)
180
+ if (input.charset !== undefined) out.charset = String(input.charset || 'utf8mb4')
181
+ if (input.timezone !== undefined) out.timezone = String(input.timezone || 'local')
182
+ if (input.schemas !== undefined) out.schemas = String(input.schemas ?? '').trim().slice(0, 500)
183
+ if (input.description !== undefined) out.description = String(input.description ?? '').slice(0, 2000)
184
+ if (input.ssl !== undefined) out.ssl = Boolean(input.ssl)
185
+ if (input.privacy !== undefined) {
186
+ const pv = input.privacy === null ? {} : pick(input.privacy, ['sampleMode', 'resultToModel', 'modelRows', 'maskSensitive'])
187
+ if (pv.sampleMode !== undefined) oneOf(pv.sampleMode, ['off', 'enum', 'all'], 'sampleMode')
188
+ if (pv.resultToModel !== undefined) oneOf(pv.resultToModel, ['none', 'summary', 'rows'], 'resultToModel')
189
+ if (pv.modelRows !== undefined) pv.modelRows = clampInt(pv.modelRows, 0, 500, 'modelRows')
190
+ out.privacy = Object.keys(pv).length ? pv : undefined
191
+ }
192
+ if (input.policy !== undefined) out.policy = policyActive(normalizePolicy(input.policy)) ? normalizePolicy(input.policy) : undefined
193
+ if (input.ssh !== undefined) {
194
+ const sh = input.ssh ?? {}
195
+ out.ssh = sh.enabled
196
+ ? { enabled: true, host: requireText(sh.host, 'ssh.host', 255), port: clampInt(sh.port ?? 22, 1, 65535, 'ssh.port'), user: requireText(sh.user, 'ssh.user', 128), auth: sh.auth === 'key' ? 'key' : 'password', hasSecret: existing?.ssh?.hasSecret ?? false }
197
+ : { enabled: false }
198
+ }
199
+ if (out.type === 'sqlite') {
200
+ out.host = 'local'
201
+ out.port = 0
202
+ out.user = ''
203
+ out.database ||= sanitizeTableName(out.name || 'files')
204
+ out.file = join(this.dir, 'files', `${out.id}.sqlite`)
205
+ delete out.ssh
206
+ }
207
+ out.port ??= DIALECTS[out.type].defaultPort
208
+ if (out.type === 'mysql') out.charset ??= 'utf8mb4'
209
+ out.timezone ??= 'local'
210
+ const required = out.type === 'elasticsearch' ? ['name', 'host'] : out.type === 'sqlite' ? ['name'] : ['name', 'host', 'database', 'user']
211
+ for (const k of required) if (!out[k]) throw new UserError('nl2sql/invalid', `缺少字段:${k}`)
212
+ if (out.type === 'elasticsearch') out.database ||= '*'
213
+ if (checkName && this.state.datasources.some(d => d.name === out.name && d.id !== out.id)) throw new UserError('nl2sql/duplicate', `数据源名称已存在:${out.name}`)
214
+ out.updatedAt = Date.now()
215
+ return out
216
+ },
217
+
218
+ /** Secrets supplied by a form: only non-empty fields replace stored values. */
219
+ secretPatch(input) {
220
+ const patch = {}
221
+ if (typeof input.password === 'string' && (input.password !== '' || input.clearPassword)) patch.password = input.password
222
+ if (typeof input.apiKey === 'string' && (input.apiKey !== '' || input.clearApiKey)) patch.apiKey = input.apiKey
223
+ const sh = input.ssh ?? {}
224
+ if (typeof sh.password === 'string' && sh.password !== '') patch.sshPassword = sh.password
225
+ if (typeof sh.privateKey === 'string' && sh.privateKey.trim() !== '') patch.sshPrivateKey = sh.privateKey
226
+ if (typeof sh.passphrase === 'string' && sh.passphrase !== '') patch.sshPassphrase = sh.passphrase
227
+ return patch
228
+ },
229
+
230
+ async saveDatasource(input) {
231
+ const existing = input.id ? this.requireDatasource(input.id) : undefined
232
+ const ds = this.normalizeDatasourceInput(input, existing)
233
+ const patch = this.secretPatch(input)
234
+ if (Object.keys(patch).length) {
235
+ const secrets = await this.writeSecrets(ds.id, patch)
236
+ ds.hasPassword = Boolean(secrets.password)
237
+ ds.hasApiKey = Boolean(secrets.apiKey)
238
+ if (ds.ssh?.enabled) ds.ssh.hasSecret = Boolean(ds.ssh.auth === 'key' ? secrets.sshPrivateKey : secrets.sshPassword)
239
+ delete ds.needsSecret
240
+ }
241
+ const index = this.state.datasources.findIndex(d => d.id === ds.id)
242
+ if (index >= 0) this.state.datasources[index] = ds
243
+ else this.state.datasources.push(ds)
244
+ if (!this.state.current) this.state.current = ds.id
245
+ await this.closeDriver(ds.id)
246
+ this.schemas.delete(ds.id)
247
+ await this.save()
248
+ return this.publicDatasource(ds)
249
+ },
250
+
251
+ async deleteDatasource(id) {
252
+ const ds = this.requireDatasource(id)
253
+ await this.closeDriver(ds.id)
254
+ this.state.datasources = this.state.datasources.filter(d => d.id !== ds.id)
255
+ if (this.state.current === ds.id) this.state.current = this.state.datasources[0]?.id ?? null
256
+ for (const [sid, dsId] of Object.entries(this.state.sessions)) if (dsId === ds.id) delete this.state.sessions[sid]
257
+ for (const r of this.state.reports.filter(x => x.datasourceId === ds.id)) await this.deleteReport(r.id)
258
+ for (const d of this.state.dashboards) d.widgets = d.widgets.filter(w => w.datasourceId !== ds.id)
259
+ this.syncLegacyDashboard()
260
+ delete this.state.knowledge[ds.id]
261
+ delete this.state.evals?.[ds.id]
262
+ this.state.alerts = (this.state.alerts ?? []).filter(a => a.datasourceId !== ds.id)
263
+ for (const u of this.state.users ?? []) if (Array.isArray(u.datasourceIds)) u.datasourceIds = u.datasourceIds.filter(x => x !== ds.id)
264
+ for (const k of ['botDefaultDatasourceIds', 'larkDefaultDatasourceIds']) if (Array.isArray(this.state.settings[k])) this.state.settings[k] = this.state.settings[k].filter(x => x !== ds.id)
265
+ for (const c of Object.values(this.state.botConversations ?? {})) if (c.datasourceId === ds.id) c.datasourceId = null
266
+ try { const { rm } = await import('node:fs/promises'); await rm(join(this.dir, `schema-${ds.id}.json`), { force: true }) } catch {}
267
+ this.schemas.delete(ds.id)
268
+ if (ds.type === 'sqlite' && ds.file?.startsWith(this.dir)) { try { const { rm } = await import('node:fs/promises'); await rm(ds.file, { force: true }); await rm(`${ds.file}-wal`, { force: true }); await rm(`${ds.file}-shm`, { force: true }) } catch {} }
269
+ try { await this.ctx.get('credentials')?.deleteRecord(this.credentialKey(ds.id)) } catch {}
270
+ await this.save()
271
+ return { deleted: ds.id }
272
+ },
273
+
274
+ async savePolicy({ id, policy }) {
275
+ const ds = this.requireDatasource(id)
276
+ const next = normalizePolicy(policy)
277
+ ds.policy = policyActive(next) ? next : undefined
278
+ ds.updatedAt = Date.now()
279
+ this.audit({ ds, source: 'ui', sql: `-- access policy updated: ${next.mode}, ${next.tables.length} table patterns, ${next.hiddenColumns.length} hidden, ${next.maskedColumns.length} masked`, title: '访问控制变更', status: 'ok' })
280
+ await this.save()
281
+ return this.publicDatasource(ds)
282
+ },
283
+
284
+ async setCurrent(id) {
285
+ const ds = this.requireDatasource(id)
286
+ this.state.current = ds.id
287
+ await this.save()
288
+ return this.publicDatasource(ds)
289
+ },
290
+
291
+ // ------------------------------------------------------------ binding
292
+
293
+ datasourceForSession(sessionId, explicit) {
294
+ const actor = this.actorFor(sessionId)
295
+ if (explicit) { const ds = this.requireDatasource(explicit); this.assertAccess(actor, ds); return ds }
296
+ const bound = sessionId ? this.findDatasource(this.state.sessions[sessionId]) : undefined
297
+ if (bound) { this.assertAccess(actor, bound); return bound }
298
+ if (actor && actor.kind !== 'owner') {
299
+ // Scoped actors never fall back to the page's current data source.
300
+ const first = Array.isArray(actor.datasourceIds) ? this.findDatasource(actor.datasourceIds[0]) : this.findDatasource(actor.defaultDatasourceId)
301
+ if (!first) throw new UserError('nl2sql/no-datasource', '当前成员没有可访问的数据源')
302
+ return first
303
+ }
304
+ const current = this.findDatasource(this.state.current) ?? (this.state.datasources.length === 1 ? this.state.datasources[0] : undefined)
305
+ if (!current) throw new UserError('nl2sql/no-datasource', this.state.datasources.length === 0
306
+ ? '尚未配置任何数据源。请用户在左侧栏「数据问答」页面新建数据源(MySQL / PostgreSQL / Elasticsearch)。'
307
+ : `当前会话未选择数据源。可用数据源:${this.state.datasources.map(d => d.name).join('、')}。调用 nl2sql_datasource action=use 选择一个。`)
308
+ return current
309
+ },
310
+
311
+ async bindSession(sessionId, datasourceId) {
312
+ if (!sessionId) throw new UserError('nl2sql/invalid', '缺少会话')
313
+ if (datasourceId === null) {
314
+ delete this.state.sessions[sessionId]
315
+ } else {
316
+ const ds = this.requireDatasource(datasourceId)
317
+ this.state.sessions[sessionId] = ds.id
318
+ this.state.current = ds.id
319
+ }
320
+ await this.save()
321
+ return this.sessionInfo(sessionId)
322
+ },
323
+
324
+ sessionInfo(sessionId) {
325
+ let preset = null
326
+ try {
327
+ const agent = this.ctx.get('agents')?.get(sessionId)
328
+ const presets = this.ctx.get('agentPresets')
329
+ if (agent && presets) preset = presets.presetForSession(agent.session)
330
+ } catch {}
331
+ const boundId = this.state.sessions[sessionId] ?? null
332
+ const effective = this.findDatasource(boundId) ?? this.findDatasource(this.state.current) ?? null
333
+ return {
334
+ sessionId,
335
+ preset,
336
+ boundDatasourceId: boundId,
337
+ datasourceId: effective?.id ?? null,
338
+ datasources: this.state.datasources.map(d => ({ id: d.id, name: d.name, database: d.database, type: d.type ?? 'mysql' })),
339
+ }
340
+ },
341
+
342
+ // -------------------------------------------------------------- drivers
343
+
344
+ driver(ds) {
345
+ let pending = this.drivers.get(ds.id)
346
+ if (!pending) {
347
+ // Store the promise before awaiting so concurrent callers share one driver.
348
+ pending = this.driverConfig(ds).then(conf => createDriver(conf))
349
+ this.drivers.set(ds.id, pending)
350
+ pending.catch(() => { if (this.drivers.get(ds.id) === pending) this.drivers.delete(ds.id) })
351
+ }
352
+ return pending.then(async (d) => {
353
+ // A dropped SSH tunnel makes every later query fail; rebuild once.
354
+ if (d.tunnel?.broken && this.drivers.get(ds.id) === pending) {
355
+ this.drivers.delete(ds.id)
356
+ d.close().catch(() => {})
357
+ return this.driver(ds)
358
+ }
359
+ return d
360
+ })
361
+ },
362
+
363
+ async closeDriver(id) {
364
+ const pending = this.drivers.get(id)
365
+ this.drivers.delete(id)
366
+ if (pending) await pending.then(d => d.close()).catch(() => {})
367
+ },
368
+
369
+ /** Run a driver operation and convert database failures into user errors. */
370
+ async withDriver(ds, fn) {
371
+ try {
372
+ return await fn(await this.driver(ds))
373
+ } catch (error) {
374
+ if (error instanceof UserError) throw error
375
+ const e = describeError(error)
376
+ throw new UserError(e.code, e.message)
377
+ }
378
+ },
379
+
380
+ async testConnection(input) {
381
+ let ds
382
+ let secrets
383
+ let temporary = false
384
+ if (input.id && !input.host) {
385
+ ds = this.requireDatasource(input.id)
386
+ } else {
387
+ const base = input.id ? this.findDatasource(input.id) : undefined
388
+ ds = this.normalizeDatasourceInput({ ...input, name: input.name || base?.name || 'test' }, base ? { ...base } : undefined, { checkName: false })
389
+ temporary = true
390
+ secrets = { ...(base ? await this.readSecrets(base.id) : {}) }
391
+ const patch = this.secretPatch(input)
392
+ Object.assign(secrets, patch)
393
+ }
394
+ const started = Date.now()
395
+ let driver
396
+ try {
397
+ driver = await createDriver(await this.driverConfig(ds, secrets))
398
+ const info = await driver.test()
399
+ const result = { ok: true, latencyMs: Date.now() - started, ...info, viaSsh: Boolean(ds.ssh?.enabled) }
400
+ if (!temporary) {
401
+ Object.assign(ds, { serverVersion: info.version, lastTestAt: Date.now(), lastTestOk: true, readOnlyGrant: info.readOnlyGrant })
402
+ await this.save()
403
+ }
404
+ return result
405
+ } catch (error) {
406
+ if (!temporary) {
407
+ Object.assign(ds, { lastTestAt: Date.now(), lastTestOk: false })
408
+ await this.save()
409
+ }
410
+ return { ok: false, latencyMs: Date.now() - started, error: describeError(error).message }
411
+ } finally {
412
+ await driver?.close().catch(() => {})
413
+ }
414
+ },
415
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Nl2SqlService methods: evaluation.
3
+ * Mixed into the service prototype by index.js; `this` is the service.
4
+ */
5
+ import { compareResults, scoreSql } from '../analysis.js'
6
+ import { UserError, newId } from './shared.js'
7
+
8
+ export const methods = {
9
+ // ------------------------------------------------------------ evaluation
10
+
11
+ /** Evaluation set = verified examples (+ expected result snapshots). */
12
+ evalSet(datasourceId) {
13
+ const k = this.knowledgeFor(datasourceId)
14
+ const meta = this.state.evals[datasourceId] ?? {}
15
+ return k.examples.map(e => ({ id: e.id, question: e.question, sql: e.sql, enabled: meta[e.id]?.enabled !== false, lastStatus: meta[e.id]?.lastStatus ?? null }))
16
+ },
17
+
18
+ async setEvalCase({ datasourceId, id, enabled }) {
19
+ this.state.evals[datasourceId] ??= {}
20
+ this.state.evals[datasourceId][id] = { ...(this.state.evals[datasourceId][id] ?? {}), enabled: Boolean(enabled) }
21
+ await this.save()
22
+ return this.evalSet(datasourceId)
23
+ },
24
+
25
+ /**
26
+ * Run the evaluation: for each example, ask a fresh nl2sql agent session the
27
+ * question (or only re-run the SQL when mode = 'sql'), then compare the
28
+ * agent's result with the reference SQL's result.
29
+ */
30
+ async runEval({ datasourceId, mode = 'agent', ids, concurrency = 2 }) {
31
+ const ds = this.requireDatasource(datasourceId)
32
+ const cases = this.evalSet(ds.id).filter(c => c.enabled && (!ids?.length || ids.includes(c.id)))
33
+ if (!cases.length) throw new UserError('nl2sql/invalid', '评测集为空:请先在「业务知识 → 样例问答」中保存已验证的问答')
34
+ const run = { id: newId('ev'), datasourceId: ds.id, datasourceName: ds.name, mode, startedAt: Date.now(), status: 'running', total: cases.length, done: 0, passed: 0, cases: [] }
35
+ this.records.put('evalRun', run)
36
+ this.evalLive ??= new Map()
37
+ this.evalLive.set(run.id, run)
38
+ const queue = [...cases]
39
+ const worker = async () => {
40
+ while (queue.length && !this.disposed) {
41
+ const c = queue.shift()
42
+ const item = { id: c.id, question: c.question, expectedSql: c.sql, status: 'error' }
43
+ const started = Date.now()
44
+ try {
45
+ const expected = await this.runQuery({ sql: c.sql, datasourceId: ds.id, title: `评测基准 · ${c.question}`.slice(0, 120), source: 'eval', confirmScan: true, useCache: true })
46
+ let actual
47
+ if (mode === 'sql') {
48
+ actual = await this.runQuery({ sql: c.sql, datasourceId: ds.id, title: `评测 · ${c.question}`.slice(0, 120), source: 'eval', confirmScan: true })
49
+ item.actualSql = c.sql
50
+ } else {
51
+ const got = await this.askAgent({ datasourceId: ds.id, question: c.question, excludeExampleId: c.id })
52
+ item.sessionId = got.sessionId
53
+ item.answer = got.text.slice(0, 2000)
54
+ if (!got.resultIds.length) throw new Error('Agent 没有执行查询')
55
+ // Pick the result that best matches the expected one.
56
+ let best
57
+ for (const rid of got.resultIds) {
58
+ const r = this.lookupResult(rid)
59
+ if (!r) continue
60
+ const cmp = compareResults(expected, r, { tolerance: 1e-6 })
61
+ if (!best || (cmp.match && !best.cmp.match)) best = { r, cmp }
62
+ }
63
+ actual = best?.r
64
+ item.actualSql = actual?.sql
65
+ }
66
+ const cmp = compareResults(expected, actual, { tolerance: 1e-6 })
67
+ item.status = cmp.match ? 'pass' : 'fail'
68
+ item.reason = cmp.reason
69
+ item.similarity = item.actualSql ? Number(scoreSql(c.sql, item.actualSql).toFixed(2)) : null
70
+ item.expectedRows = expected.rowCount
71
+ item.actualRows = actual?.rowCount ?? null
72
+ } catch (error) {
73
+ item.reason = error.message
74
+ }
75
+ item.elapsedMs = Date.now() - started
76
+ run.cases.push(item)
77
+ run.done++
78
+ if (item.status === 'pass') run.passed++
79
+ this.state.evals[ds.id] ??= {}
80
+ this.state.evals[ds.id][c.id] = { ...(this.state.evals[ds.id][c.id] ?? {}), lastStatus: item.status, lastRunAt: Date.now() }
81
+ if (!this.disposed) { this.records.put('evalRun', run); await this.save() }
82
+ }
83
+ }
84
+ const done = Promise.all(Array.from({ length: Math.max(1, Math.min(4, concurrency)) }, worker)).then(async () => {
85
+ run.status = 'done'
86
+ run.finishedAt = Date.now()
87
+ run.accuracy = run.total ? Number((run.passed / run.total).toFixed(3)) : null
88
+ if (!this.disposed) { this.records.put('evalRun', run); await this.save() }
89
+ this.evalLive?.delete(run.id)
90
+ })
91
+ this.evalJobs ??= new Map()
92
+ this.evalJobs.set(run.id, done)
93
+ done.catch(async (error) => { run.status = 'error'; run.error = error.message; this.evalLive?.delete(run.id); if (!this.disposed) { this.records.put('evalRun', run); await this.save() } })
94
+ return run
95
+ },
96
+
97
+ listEvalRuns(datasourceId) {
98
+ return this.records.list('evalRun', { ref: datasourceId, limit: 50 }).map(r => ({ ...(this.evalLive?.get(r.id) ?? r), cases: undefined, caseCount: r.cases.length }))
99
+ },
100
+
101
+ getEvalRun(id) {
102
+ return this.evalLive?.get(id) ?? this.records.get('evalRun', id) ?? null
103
+ },
104
+
105
+ /** Ask a fresh nl2sql session one question and wait for the answer (evaluation). */
106
+ async askAgent({ datasourceId, question, excludeExampleId, timeoutMs = 300000 }) {
107
+ if (!this.ctx.get('sessionController')) throw new UserError('nl2sql/unavailable', '当前 Harness 不支持程序化创建会话,无法用 Agent 模式评测;请改用「仅回放 SQL」模式')
108
+ const requestId = `eval-${newId('q')}`
109
+ const prepare = async (sid) => {
110
+ this.state.sessions[sid] = datasourceId
111
+ this.state.sessionActors ??= {}
112
+ this.state.sessionActors[sid] = { id: 'eval', name: '评测', kind: 'eval' }
113
+ if (excludeExampleId) { this.state.evalExclusions ??= {}; this.state.evalExclusions[sid] = excludeExampleId }
114
+ await this.save()
115
+ }
116
+ const turn = await this.runAgentTurn({ sessionId: null, title: `评测 · ${question.slice(0, 40)}`, requestId, text: `${question}\n\n(这是自动评测:请直接用 nl2sql_query 查询并给出结论,不要向用户提问。)`, prepare, timeoutMs })
117
+ try { await this.ctx.get('workspaceRegistry')?.archiveSession?.(turn.sessionId, { stopActivity: true }) } catch {}
118
+ if (turn.reason !== 'idle') throw new Error(turn.reason === 'timeout' ? `Agent 超过 ${Math.round(timeoutMs / 1000)} 秒未完成` : 'Agent 需要人工确认,已取消')
119
+ return turn
120
+ },
121
+ }