@hedeqiang/dsh-nl2sql 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@hedeqiang/dsh-nl2sql",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Ask MySQL, PostgreSQL, Elasticsearch and CSV/Excel in plain language inside DeepSeek Harness: charts, dashboards, alerts, and IM push and bots for Lark/Feishu, Telegram, WeCom and DingTalk.",
5
5
  "type": "module",
6
+ "bin": {
7
+ "nl2sql-mcp": "bin/nl2sql-mcp.mjs"
8
+ },
6
9
  "exports": {
7
10
  ".": "./index.js",
8
11
  "./client": "./lib/client.js",
@@ -10,6 +13,8 @@
10
13
  "./icon": "./icon.svg"
11
14
  },
12
15
  "files": [
16
+ "bin",
17
+ "docs/mcp.md",
13
18
  "index.js",
14
19
  "service/*.js",
15
20
  "channels/*.js",
package/service/alerts.js CHANGED
@@ -6,6 +6,9 @@ import { evaluateAlert } from '../analysis.js'
6
6
  import { admitSql } from '../sql-guard.js'
7
7
  import { UserError, clampInt, newId, oneOf, requireText, upsert } from './shared.js'
8
8
 
9
+ /** Fields written by checks (not by the edit form). */
10
+ const ALERT_RUNTIME = ['state', 'lastCheckedAt', 'lastValue', 'lastResultId', 'lastFiredAt', 'lastError']
11
+
9
12
  export const methods = {
10
13
  // ---------------------------------------------------------------- alerts
11
14
 
@@ -53,6 +56,10 @@ export const methods = {
53
56
 
54
57
  async saveAlert(input) {
55
58
  const a = await this.normalizeAlert(input)
59
+ // A check may have finished between normalize and now: keep its runtime fields
60
+ // (last check / fire time, state) from the stored object, never the stale copy.
61
+ const current = (this.state.alerts ?? []).find(x => x.id === a.id)
62
+ if (current) for (const k of ALERT_RUNTIME) { if (current[k] === undefined) delete a[k]; else a[k] = current[k] }
56
63
  this.state.alerts = upsert(this.state.alerts ?? [], a)
57
64
  await this.save()
58
65
  this.scheduleAlerts()
@@ -73,33 +80,37 @@ export const methods = {
73
80
 
74
81
  /** Evaluate an unsaved alert definition (form "dry run"): nothing is stored or pushed. */
75
82
  async previewAlert(input) {
76
- const tmpId = `preview-${newId('al')}`
77
- const saved = this.state.alerts
78
- try {
79
- const draft = await this.normalizeAlert({ ...input, id: undefined }, tmpId)
80
- this.state.alerts = [...(saved ?? []), draft]
81
- return await this.checkAlertInner(tmpId, { dryRun: true })
82
- } finally {
83
- this.state.alerts = saved
84
- }
83
+ // The draft is evaluated on its own: state.alerts is never touched, so concurrent
84
+ // saves are kept and the draft is never persisted.
85
+ const draft = await this.normalizeAlert({ ...input, id: undefined }, `preview-${newId('al')}`)
86
+ return this.checkAlertInner(draft.id, { dryRun: true, draft })
85
87
  },
86
88
 
87
- async checkAlertInner(id, { dryRun = false, force = false } = {}) {
88
- const a = (this.state.alerts ?? []).find(x => x.id === id)
89
- if (!a) throw new UserError('nl2sql/invalid', '告警不存在')
89
+ async checkAlertInner(id, { dryRun = false, force = false, draft } = {}) {
90
+ const found = draft ?? (this.state.alerts ?? []).find(x => x.id === id)
91
+ if (!found) throw new UserError('nl2sql/invalid', '告警不存在')
92
+ // Evaluate a snapshot; runtime fields are applied below to the object stored
93
+ // *after* the query (saveAlert may have replaced it meanwhile).
94
+ let a = draft ? structuredClone(draft) : { ...found }
90
95
  const ds = this.requireDatasource(a.datasourceId)
91
96
  let result
97
+ // Chat-created alerts (service/subscriptions.js) run as their creator, re-resolved now.
98
+ const actor = a.chatTarget && this.chatAlertActor ? await this.chatAlertActor(a) : undefined
92
99
  if (a.source === 'metric') {
93
100
  const range = a.grain === 'hour' ? 'last7d' : a.grain === 'month' ? 'thisYear' : a.grain === 'week' ? 'last90d' : 'last30d'
94
- result = await this.queryMetric({ datasourceId: ds.id, metric: a.metric, grain: a.grain, range, filters: a.filters, title: `告警 · ${a.name}`, source: 'alert' })
101
+ result = await this.queryMetric({ datasourceId: ds.id, metric: a.metric, grain: a.grain, range, filters: a.filters, title: `告警 · ${a.name}`, source: 'alert', actor })
95
102
  } else {
96
- result = await this.runQuery({ sql: a.sql, datasourceId: ds.id, title: `告警 · ${a.name}`, source: 'alert', confirmScan: true })
103
+ result = await this.runQuery({ sql: a.sql, datasourceId: ds.id, title: `告警 · ${a.name}`, source: 'alert', confirmScan: true, actor })
97
104
  }
98
105
  // Series: first column = time/category, last numeric column = value.
99
106
  const vi = [...result.columns.keys()].reverse().find(i => result.columns[i].kind === 'number')
100
107
  const series = vi === undefined ? [] : result.rows.map(r => ({ t: r[0], v: r[vi] === null ? null : Number(r[vi]) }))
101
108
  if (a.condition.type !== 'missing' && result.columns.length === 1 && vi === 0) series.splice(0, series.length, ...result.rows.map((r, i) => ({ t: i, v: Number(r[0]) })))
102
109
  const ev = evaluateAlert(a.condition, series, { now: Date.now() })
110
+ if (!draft) {
111
+ const current = (this.state.alerts ?? []).find(x => x.id === id)
112
+ if (current) a = current
113
+ }
103
114
  a.lastCheckedAt = Date.now()
104
115
  a.lastValue = ev.value ?? null
105
116
  a.lastResultId = result.resultId
@@ -111,9 +122,12 @@ export const methods = {
111
122
  a.lastFiredAt = Date.now()
112
123
  const event = { id: newId('ae'), alertId: a.id, name: a.name, at: Date.now(), value: ev.value ?? null, message: ev.message, resultId: result.resultId }
113
124
  this.records.put('alertEvent', event)
114
- if (a.channelId) {
125
+ if (a.channelId || (a.chatTarget && this.deliverAlertToChat)) {
115
126
  try {
116
- await this.sendToChannel({ channelId: a.channelId, title: `⚠️ 数据告警:${a.name}`, text: `${a.mention ? '<at id=all></at> ' : ''}**${ev.message}**\n\n<font color='grey'>数据源:${ds.name} · 检查时间:${new Date().toLocaleString('zh-CN', { hour12: false })}</font>`, resultIds: [result.resultId], source: 'alert' })
127
+ // Optional extra Markdown from feature modules (e.g. change attribution, service/proactive.js).
128
+ const extra = await Promise.resolve(this.beforeAlertPush?.(a, result)).catch(() => null)
129
+ const msg = { channelId: a.channelId, title: `⚠️ 数据告警:${a.name}`, text: `${a.mention ? '<at id=all></at> ' : ''}**${ev.message}**${extra ? `\n\n${extra}` : ''}\n\n<font color='grey'>数据源:${ds.name} · 检查时间:${new Date().toLocaleString('zh-CN', { hour12: false })}</font>`, resultIds: [result.resultId], source: 'alert' }
130
+ if (a.chatTarget && this.deliverAlertToChat) await this.deliverAlertToChat(a, msg); else await this.sendToChannel(msg)
117
131
  pushed = true
118
132
  event.pushed = true
119
133
  } catch (error) {
@@ -124,8 +138,9 @@ export const methods = {
124
138
  }
125
139
  } else if (a.state === 'firing') {
126
140
  a.state = 'ok'
127
- if (!dryRun && a.channelId && a.notifyRecovery !== false) {
128
- try { await this.sendToChannel({ channelId: a.channelId, title: `✅ 告警恢复:${a.name}`, text: ev.message, source: 'alert' }) } catch {}
141
+ if (!dryRun && (a.channelId || a.chatTarget) && a.notifyRecovery !== false) {
142
+ const msg = { channelId: a.channelId, title: `✅ 告警恢复:${a.name}`, text: ev.message, source: 'alert' }
143
+ try { if (a.chatTarget && this.deliverAlertToChat) await this.deliverAlertToChat(a, msg); else await this.sendToChannel(msg) } catch {}
129
144
  }
130
145
  } else {
131
146
  a.state = 'ok'
@@ -163,12 +178,20 @@ export const methods = {
163
178
  const last = a.lastCheckedAt ?? 0
164
179
  if (a.every === '15m') return last + 15 * 60000
165
180
  if (a.every === 'hour') return last + 60 * 60000
166
- // daily at HH:MM local time: the most recent slot not yet covered by lastCheckedAt.
181
+ // daily at HH:MM local wall time: the most recent slot not yet covered by lastCheckedAt.
182
+ // DST (same rule as the Harness schedule service): a slot that does not exist
183
+ // that day (spring forward) is skipped; a repeated one (fall back) is the earlier instant.
167
184
  const [hh, mm] = String(a.at ?? '09:00').split(':').map(Number)
168
- const slot = new Date(now)
169
- slot.setHours(hh, mm, 0, 0)
170
- if (slot.getTime() > now) slot.setDate(slot.getDate() - 1)
171
- return last < slot.getTime() ? slot.getTime() : (() => { const n = new Date(slot); n.setDate(n.getDate() + 1); return n.getTime() })()
185
+ const today = new Date(now)
186
+ const slotOn = (offset) => {
187
+ const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset, hh, mm, 0, 0)
188
+ return d.getHours() === hh && d.getMinutes() === mm ? d.getTime() : undefined
189
+ }
190
+ let prev
191
+ for (let k = 0; k >= -3 && prev === undefined; k--) { const s = slotOn(k); if (s !== undefined && s <= now) prev = s }
192
+ if (prev !== undefined && last < prev) return prev
193
+ for (let k = 0; k <= 3; k++) { const s = slotOn(k); if (s !== undefined && s > now && s > last) return s }
194
+ return now + 86400000
172
195
  },
173
196
 
174
197
  async withJobLock(key, fn) {
@@ -192,15 +215,26 @@ export const methods = {
192
215
  this.checkAlert(a.id),
193
216
  new Promise((_, rej) => { const t = setTimeout(() => rej(new Error(`检查超过 ${Math.round(timeoutMs / 1000)} 秒`)), timeoutMs); t.unref?.() }),
194
217
  ])
195
- a.lastError = undefined
218
+ // The stored object may have been replaced by an edit meanwhile.
219
+ const cur = (this.state.alerts ?? []).find(x => x.id === a.id)
220
+ if (cur) cur.lastError = undefined
196
221
  ran.push({ id: a.id, ok: true })
197
222
  } catch (error) {
198
223
  if (error.code === 'nl2sql/busy') return
199
- a.lastError = error.message
200
- a.lastCheckedAt = Date.now()
224
+ const cur = (this.state.alerts ?? []).find(x => x.id === a.id) ?? a
225
+ cur.lastError = error.message
226
+ cur.lastCheckedAt = Date.now()
201
227
  ran.push({ id: a.id, ok: false, error: error.message })
202
228
  }
203
229
  }))
230
+ // Jobs registered by other modules (digest, reconciliation, freshness, quality, goals…):
231
+ // service.schedulerJobs() returns [{ id, due(now: Date) -> boolean, run() -> Promise }].
232
+ for (const job of (typeof this.schedulerJobs === 'function' ? this.schedulerJobs(now) : [])) {
233
+ let isDue = false
234
+ try { isDue = Boolean(job.due(now)) } catch {}
235
+ if (!isDue) continue
236
+ try { await this.withJobLock(`job:${job.id}`, () => job.run()); ran.push({ id: job.id, ok: true }) } catch (error) { if (error.code !== 'nl2sql/busy') ran.push({ id: job.id, ok: false, error: error.message }) }
237
+ }
204
238
  // Daily schema watch (catches up once after downtime).
205
239
  const at = this.state.settings.schemaCheckDaily
206
240
  if (at) {
package/service/bots.js CHANGED
@@ -35,6 +35,29 @@ const toast = (type, content) => ({ toast: { type, content } })
35
35
  const label = (platform) => PLATFORM_LABEL[platform] ?? platform ?? 'IM'
36
36
  const SOURCE = (platform) => `${platform ?? 'im'}bot`
37
37
 
38
+ const BARE_COMMANDS = {
39
+ 帮助: 'help', help: 'help', start: 'help',
40
+ 数据源: 'sources', sources: 'sources', datasources: 'sources',
41
+ 自动: 'auto', auto: 'auto',
42
+ 新对话: 'reset', new: 'reset', reset: 'reset',
43
+ }
44
+
45
+ /**
46
+ * A bot command, or null for a question. Argument-less commands must be the whole
47
+ * message (optionally `/`-prefixed, trailing punctuation ignored), so questions like
48
+ * 「自动续费用户有多少」 reach the agent. `切换` / `切换数据源` / `switch` / `use` take an
49
+ * argument after whitespace; alone they ask for usage.
50
+ * → { verb: 'help'|'sources'|'auto'|'reset'|'switch', arg? }
51
+ */
52
+ export function parseBotCommand(text) {
53
+ const s = String(text ?? '').trim().replace(/^\//, '').replace(/[\s??。.!!~~]+$/u, '')
54
+ const bare = BARE_COMMANDS[s.toLowerCase()]
55
+ if (bare) return { verb: bare }
56
+ const m = /^(切换数据源|切换|switch|use)(?:\s+([\s\S]+))?$/i.exec(s)
57
+ if (m) return { verb: 'switch', arg: (m[2] ?? '').trim() }
58
+ return null
59
+ }
60
+
38
61
  function hasAggregate(r, dialect) {
39
62
  const parsed = parseSelect(r.baseSql ?? r.sql, { dialect })
40
63
  return Boolean(parsed?.select?.some(i => i.isAggregate))
@@ -286,8 +309,8 @@ export const methods = {
286
309
  if (m.chatType === 'group' && m.chatId) this.noteSeenIdentity({ platform, kind: 'chat', id: String(m.chatId), name: chatName, chatName, channelId: m.channelId, refused: Boolean(resolved.refused) })
287
310
  if (resolved.refused) { await this.save(); await reply(resolved.refused); return }
288
311
  const actor = resolved.actor
289
- // Commands: 帮助 / 数据源 / 切换 <名称> / 新对话
290
- const cmd = text.match(/^\/?(帮助|help|数据源|切换数据源|切换|自动|auto|新对话|reset|start)\s*(.*)$/i)
312
+ // Commands: 帮助 / 数据源 / 切换 <名称> / 自动 / 新对话 (and English aliases).
313
+ const cmd = parseBotCommand(text)
291
314
  // Members with different scopes must not share a conversation: per-user sessions
292
315
  // whenever the sender's effective scope or rules differ from the chat's.
293
316
  const scopeSig = JSON.stringify([actor.datasourceIds, actor.role, actor.rowRules ?? []])
@@ -301,14 +324,15 @@ export const methods = {
301
324
  const allowedDs = this.state.datasources.filter(d => !Array.isArray(actor.datasourceIds) || actor.datasourceIds.includes(d.id))
302
325
  if (conv.datasourceId && !allowedDs.some(d => d.id === conv.datasourceId)) conv.datasourceId = allowedDs[0]?.id ?? null
303
326
  if (cmd) {
304
- const verb = cmd[1].toLowerCase()
305
- if (verb === '帮助' || verb === 'help' || verb === 'start') { await reply(`直接${m.chatType === 'group' ? ' @我 ' : ''}提问即可,例如「上周每天的订单量」。\n命令:数据源(查看可用数据源)、切换 <名称>、自动(按问题自动选择数据源)、新对话。\n当前数据源:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${conv.datasourcePinned ? '(已指定)' : allowedDs.length > 1 ? '(按问题自动选择)' : ''}${actor.role === 'viewer' ? '\n你是只读成员:可以查询指标库中的指标、已验证的常见问题和看板。' : ''}`); return }
306
- if (verb === '数据源') { await reply(`可用数据源:\n${allowedDs.map(d => `· ${d.name}${d.description ? ` — ${d.description}` : ''}`).join('\n') || '无'}\n当前:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${conv.datasourcePinned ? '(已指定,发送「自动」恢复按问题自动选择)' : allowedDs.length > 1 ? '(按问题自动选择;发送「切换 <名称>」固定)' : ''}`); return }
307
- if (verb === '自动' || verb === 'auto') { delete conv.datasourcePinned; convs[key] = conv; await this.save(); await reply(`已改为按问题自动选择数据源(可用:${allowedDs.map(d => d.name).join('、')})。`); return }
308
- if (verb === '新对话' || verb === 'reset') { conv.sessionId = null; convs[key] = conv; await this.save(); await reply('已开始新对话。'); return }
309
- const want = cmd[2].trim().toLowerCase()
310
- const target = allowedDs.find(d => d.name.toLowerCase() === want || d.id === cmd[2].trim()) ?? (want ? allowedDs.filter(d => d.name.toLowerCase().includes(want) || (d.database ?? '').toLowerCase() === want) : []).find((_, __, arr) => arr.length === 1)
311
- if (!target) { await reply(`找不到可用的数据源「${cmd[2]}」。可用:${allowedDs.map(d => d.name).join('、')}`); return }
327
+ const { verb } = cmd
328
+ if (verb === 'help') { await reply(`直接${m.chatType === 'group' ? ' @我 ' : ''}提问即可,例如「上周每天的订单量」。\n命令(单独发送):\n· 数据源 / sources — 查看可用数据源\n· 切换 <名称> / switch <name> — 固定使用某个数据源\n· 自动 / auto — 按问题自动选择数据源\n· 新对话 / new — 开始新对话\n· 帮助 / help — 显示本说明\n当前数据源:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${conv.datasourcePinned ? '(已指定)' : allowedDs.length > 1 ? '(按问题自动选择)' : ''}${actor.role === 'viewer' ? '\n你是只读成员:可以查询指标库中的指标、已验证的常见问题和看板。' : ''}`); return }
329
+ if (verb === 'sources') { await reply(`可用数据源:\n${allowedDs.map(d => `· ${d.name}${d.description ? ` — ${d.description}` : ''}`).join('\n') || '无'}\n当前:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${conv.datasourcePinned ? '(已指定,发送「自动」恢复按问题自动选择)' : allowedDs.length > 1 ? '(按问题自动选择;发送「切换 <名称>」固定)' : ''}`); return }
330
+ if (verb === 'auto') { delete conv.datasourcePinned; convs[key] = conv; await this.save(); await reply(`已改为按问题自动选择数据源(可用:${allowedDs.map(d => d.name).join('、')})。`); return }
331
+ if (verb === 'reset') { conv.sessionId = null; convs[key] = conv; await this.save(); await reply('已开始新对话。'); return }
332
+ if (!cmd.arg) { await reply(`用法:切换 <数据源名称>(或 switch <name>)。可用:${allowedDs.map(d => d.name).join('、') || '无'}`); return }
333
+ const want = cmd.arg.toLowerCase()
334
+ const target = allowedDs.find(d => d.name.toLowerCase() === want || d.id === cmd.arg) ?? allowedDs.filter(d => d.name.toLowerCase().includes(want) || (d.database ?? '').toLowerCase() === want).find((_, __, arr) => arr.length === 1)
335
+ if (!target) { await reply(`找不到可用的数据源「${cmd.arg}」。可用:${allowedDs.map(d => d.name).join('、')}`); return }
312
336
  conv.datasourceId = target.id
313
337
  conv.datasourcePinned = true
314
338
  if (conv.sessionId) { this.state.sessions[conv.sessionId] = target.id }
@@ -417,7 +441,8 @@ export const methods = {
417
441
  for (const d of list) {
418
442
  let tables = ''
419
443
  try {
420
- const schema = this.schemas.get(d.id) ?? await this.readSchemaFile(d.id)
444
+ // Policy-filtered, like nl2sql_schema: hidden tables never reach the bot prompt.
445
+ const schema = await this.cachedVisibleSchema(d)
421
446
  if (schema?.tables?.length) {
422
447
  const rank = { fact: 0, dimension: 1, view: 2, other: 3, log: 4, config: 5 }
423
448
  const top = [...schema.tables].sort((a, b) => (rank[a.role] ?? 3) - (rank[b.role] ?? 3) || (b.rows ?? 0) - (a.rows ?? 0)).slice(0, 12)
@@ -475,7 +500,8 @@ export const methods = {
475
500
  }
476
501
  const waited = await controller.wait({ sessionId: sid, timeoutMs, maxMessages: 1 }, new AbortController().signal)
477
502
  const reason = waited?.reason ?? 'idle'
478
- if (reason !== 'idle') { try { controller.cancel({ sessionId: sid }) } catch {} }
503
+ // cancel() is async on DSH controllers: a rejection must not become unhandled.
504
+ if (reason !== 'idle') void Promise.resolve().then(() => controller.cancel({ sessionId: sid })).catch(() => {})
479
505
  const answer = await this.collectTurnByRequest(sid, requestId)
480
506
  return { sessionId: sid, reason, ...answer }
481
507
  },
@@ -19,6 +19,15 @@ import { UserError, newId, pick, requireText, upsert } from './shared.js'
19
19
  const LARK_LEGACY = ['domain', 'appId', 'receiveIdType', 'receiveId']
20
20
  const BOT_DEFAULTS = { enabled: false, allowP2p: true, sessionScope: 'chat' }
21
21
 
22
+ /** Truncate to `max` UTF-16 units with an ellipsis, without splitting a surrogate pair. */
23
+ export function clip(s, max) {
24
+ const str = String(s ?? '')
25
+ if (str.length <= max) return str
26
+ let cut = max - 1
27
+ if (/[\uD800-\uDBFF]/.test(str[cut - 1] ?? '')) cut--
28
+ return `${str.slice(0, cut)}…`
29
+ }
30
+
22
31
  /** ChannelError (or legacy ImError) → UserError with the same code and message. */
23
32
  export function channelUserError(error, code) {
24
33
  if (!(error instanceof ChannelError || (error?.code && /^(im|channel)\//.test(error.code)))) return error
@@ -172,8 +181,10 @@ export const methods = {
172
181
  c.platform = adapter.id
173
182
  c.description = String(input.description ?? c.description ?? '').slice(0, 300)
174
183
  const { config: configIn, secrets: changed } = splitPayload(input, { ...kind, platform: adapter.id })
175
- const stored = existing && !typeChanged ? await this.channelSecrets(existing) : {}
176
184
  const keys = secretKeys(kind)
185
+ // A type change keeps stored secrets whose key the new kind also has (e.g. webhookUrl, token).
186
+ const stored = existing ? await this.channelSecrets(existing) : {}
187
+ if (typeChanged) for (const k of Object.keys(stored)) if (!keys.includes(k)) delete stored[k]
177
188
  const merged = {}
178
189
  for (const k of keys) {
179
190
  if (Object.hasOwn(changed, k)) { if (changed[k] !== null) merged[k] = changed[k] } else if (stored[k] !== undefined) merged[k] = stored[k]
@@ -224,9 +235,19 @@ export const methods = {
224
235
  await this.stopBot(id)
225
236
  this.state.channels = this.state.channels.filter(x => x.id !== id)
226
237
  if (c.isDefault && this.state.channels[0]) this.state.channels[0].isDefault = true
227
- for (const r of this.state.reports) if (r.channelId === id) delete r.channelId
238
+ // Drop every reference to the channel: alerts, schema notifications, bot conversations, reports.
239
+ for (const a of this.state.alerts ?? []) if (a.channelId === id) a.channelId = null
240
+ if (this.state.settings?.schemaNotifyChannel === id) this.state.settings.schemaNotifyChannel = null
241
+ for (const [k, conv] of Object.entries(this.state.botConversations ?? {})) if (conv?.channelId === id) delete this.state.botConversations[k]
242
+ const reports = (this.state.reports ?? []).filter(r => r.channelId === id)
243
+ for (const r of reports) delete r.channelId
228
244
  try { await this.ctx.get('credentials')?.deleteRecord(this.channelSecretKey(id)) } catch {}
229
245
  await this.save()
246
+ // Scheduled report prompts name the channel: rewrite them through the normal report save.
247
+ for (const r of reports) {
248
+ if (!r.scheduleId || !this.ctx.get('schedule')) continue
249
+ try { await this.saveReport({ ...r, channelId: undefined }) } catch (error) { console.warn('nl2sql: cannot update report %s after channel delete: %s', r.id, error.message) }
250
+ }
230
251
  return { deleted: id }
231
252
  },
232
253
 
@@ -311,10 +332,10 @@ export const methods = {
311
332
  const modes = chartModesOf(found.kind)
312
333
  const body = text ? String(text).slice(0, 20000) : ''
313
334
  const message = {
314
- title: requireText(title || resolved[0]?.title || '数据问答', 'title', 100),
335
+ title: clip(String(title || resolved[0]?.title || '').trim() || '数据问答', 100),
315
336
  text: body,
316
337
  chartMode: modes.includes(chartMode) ? chartMode : modes.includes(c.chartMode) ? c.chartMode : modes[0],
317
- results: resolved.map(r => pick(r, ['title', 'columns', 'rows', 'rowCount', 'truncated', 'chart', 'sql', 'filters', 'error', ...(found.kind.capabilities?.buttons ? ['actions'] : [])])),
338
+ results: resolved.map(r => ({ ...pick(r, ['title', 'columns', 'rows', 'rowCount', 'truncated', 'chart', 'sql', 'filters', 'error', ...(found.kind.capabilities?.buttons ? ['actions'] : [])]), ...(r.title ? { title: clip(String(r.title), 100) } : {}) })),
318
339
  includeSql: Boolean(includeSql),
319
340
  mentionAll: mentionAll ?? /<at\s+id\s*=\s*["']?all["']?\s*>/i.test(body),
320
341
  ...(template ? { template } : {}),
@@ -328,7 +349,7 @@ export const methods = {
328
349
  if (caps.files && wantAttach) {
329
350
  const idx = message.results.map((r, i) => (needsAttachment(r, shown) ? i : -1)).filter(i => i >= 0)
330
351
  if (idx.length) {
331
- const sheets = idx.map(i => ({ name: (message.results[i].title || `结果${i + 1}`).slice(0, 31), columns: message.results[i].columns, rows: message.results[i].rows }))
352
+ const sheets = idx.map(i => ({ name: message.results[i].title || `结果${i + 1}`, columns: message.results[i].columns, rows: message.results[i].rows }))
332
353
  if (includeSql) sheets.push({ name: 'SQL', columns: [{ name: '结果', kind: 'text' }, { name: 'SQL', kind: 'text' }], rows: idx.map(i => [message.results[i].title ?? '', message.results[i].sql ?? '']) })
333
354
  const buffer = writeXlsx(sheets, { creator: 'DeepSeek Harness 数据问答' })
334
355
  const safe = String(message.title).replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 60) || '数据'
@@ -296,8 +296,11 @@ export const methods = {
296
296
  // --------------------------------------------------------- auto overview
297
297
 
298
298
  /** Build overview widgets for one table from its column roles; runs each query and returns results. */
299
- async tableOverview({ datasourceId, table, pin = false }) {
299
+ async tableOverview({ datasourceId, table, pin = false, actor, sessionId, source = 'ui' }) {
300
300
  const ds = this.requireDatasource(datasourceId)
301
+ // Agent / bot callers run as their actor: scope, row-level rules, viewer limits and audit attribution apply.
302
+ actor ??= sessionId ? this.actorFor(sessionId) : undefined
303
+ if (actor) this.assertAccess(actor, ds)
301
304
  const schema = await this.visibleSchema(ds)
302
305
  const t = schema.tables.find(x => x.name === table)
303
306
  if (!t) throw new UserError('nl2sql/unknown-table', `表不存在或无权访问:${table}`)
@@ -305,7 +308,7 @@ export const methods = {
305
308
  const results = []
306
309
  for (const plan of plans) {
307
310
  try {
308
- const r = await this.runQuery({ sql: plan.sql, datasourceId: ds.id, title: plan.title, chart: plan.chart, source: 'ui', confirmScan: true })
311
+ const r = await this.runQuery({ sql: plan.sql, datasourceId: ds.id, sessionId, actor, title: plan.title, chart: plan.chart, source, confirmScan: true })
309
312
  results.push(r)
310
313
  if (pin) await this.pinWidget({ title: plan.title, sql: plan.sql, datasourceId: ds.id, chart: r.chart, width: plan.width })
311
314
  } catch (error) {
@@ -3,7 +3,7 @@
3
3
  * Mixed into the service prototype by index.js; `this` is the service.
4
4
  */
5
5
  import { DIALECTS, createDriver, describeError } from '../drivers.js'
6
- import { normalizePolicy, policyActive } from '../policy.js'
6
+ import { filterSchema, normalizePolicy, policyActive } from '../policy.js'
7
7
  import { sanitizeTableName } from '../sqlite-driver.js'
8
8
  import { join } from 'node:path'
9
9
  import { CREDENTIAL_SCOPE, DEFAULT_SETTINGS, RETENTION_MAX, RETENTION_MIN, SUPPORTED_TYPES, UserError, clampInt, newId, oneOf, pick, requireText } from './shared.js'
@@ -308,14 +308,27 @@ export const methods = {
308
308
  return current
309
309
  },
310
310
 
311
- async bindSession(sessionId, datasourceId) {
311
+ /**
312
+ * The already-analyzed schema (memory or file, no introspection) restricted by the
313
+ * access policy — the same view nl2sql_schema gives the model. Undefined when not analyzed.
314
+ */
315
+ async cachedVisibleSchema(ds) {
316
+ const schema = this.schemas.get(ds.id) ?? await this.readSchemaFile(ds.id)
317
+ return schema ? filterSchema(schema, this.policyFor(ds)) : undefined
318
+ },
319
+
320
+ /**
321
+ * Bind a session to a data source. The page RPC also makes it the page's current data source
322
+ * (default); agent tools (including bot sessions) pass setCurrent: false so they never move it.
323
+ */
324
+ async bindSession(sessionId, datasourceId, { setCurrent = true } = {}) {
312
325
  if (!sessionId) throw new UserError('nl2sql/invalid', '缺少会话')
313
326
  if (datasourceId === null) {
314
327
  delete this.state.sessions[sessionId]
315
328
  } else {
316
329
  const ds = this.requireDatasource(datasourceId)
317
330
  this.state.sessions[sessionId] = ds.id
318
- this.state.current = ds.id
331
+ if (setCurrent) this.state.current = ds.id
319
332
  }
320
333
  await this.save()
321
334
  return this.sessionInfo(sessionId)
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Feature modules. Each entry: { name, methods, rpc?(service), tools?(ctx, service, helpers), start?(ctx, service) }.
3
+ * - methods: mixed into the service prototype (`this` = service). Methods named `jobs_<name>(now)` return
4
+ * scheduler jobs [{ id, due(now), run() }] run by the in-process scheduler (service/alerts.js).
5
+ * - rpc: page RPC methods; tools: agent tools; start: called after apply (e.g. servers).
6
+ * New features add a file under service/ and one line here, so they do not edit index.js.
7
+ */
8
+ import { FEATURE as subscriptions } from './subscriptions.js'
9
+ import { FEATURE as reconcile } from './reconcile.js'
10
+ import { FEATURE as present } from './present.js'
11
+ import { FEATURE as mcp } from './mcp.js'
12
+ import { FEATURE as proactive } from './proactive.js'
13
+ import { FEATURE as freshness } from './freshness.js'
14
+ export const FEATURES = [
15
+ present,
16
+ mcp,
17
+ reconcile,
18
+ subscriptions,
19
+ proactive,
20
+ freshness,
21
+ ]