dsh-lh-data 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +332 -0
  2. package/cordis.patch.yml +7 -0
  3. package/lib/admin-contract.d.ts +274 -0
  4. package/lib/admin-http.d.ts +31 -0
  5. package/lib/admin-validate.d.ts +87 -0
  6. package/lib/admin.d.ts +60 -0
  7. package/lib/client.js +2969 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/datasource/columns.d.ts +13 -0
  10. package/lib/datasource/connection.d.ts +33 -0
  11. package/lib/datasource/connector/base.d.ts +21 -0
  12. package/lib/datasource/connector/mysql.d.ts +23 -0
  13. package/lib/datasource/connector/postgresql.d.ts +23 -0
  14. package/lib/datasource/crypto.d.ts +13 -0
  15. package/lib/datasource/driver.d.ts +39 -0
  16. package/lib/datasource/errors.d.ts +25 -0
  17. package/lib/datasource/importer.d.ts +38 -0
  18. package/lib/datasource/source-sql.d.ts +11 -0
  19. package/lib/datasource/source-store.d.ts +28 -0
  20. package/lib/datasource/types.d.ts +119 -0
  21. package/lib/db.d.ts +91 -0
  22. package/lib/http-common.d.ts +31 -0
  23. package/lib/http.d.ts +13 -0
  24. package/lib/index.d.ts +50 -0
  25. package/lib/index.js +5735 -0
  26. package/lib/parse.d.ts +41 -0
  27. package/lib/preview.d.ts +75 -0
  28. package/lib/render.d.ts +100 -0
  29. package/lib/scope-registry.d.ts +47 -0
  30. package/lib/scope.d.ts +56 -0
  31. package/lib/sql.d.ts +133 -0
  32. package/lib/store.d.ts +174 -0
  33. package/lib/table.d.ts +38 -0
  34. package/lib/tooling.d.ts +267 -0
  35. package/lib/tools/datasource.d.ts +16 -0
  36. package/lib/tools/import.d.ts +21 -0
  37. package/lib/tools/read.d.ts +77 -0
  38. package/lib/tools/registry.d.ts +12 -0
  39. package/lib/tools/write.d.ts +37 -0
  40. package/lib/view.d.ts +138 -0
  41. package/package.json +61 -0
  42. package/src/admin-contract.ts +351 -0
  43. package/src/admin-http.ts +254 -0
  44. package/src/admin-validate.ts +393 -0
  45. package/src/admin.ts +558 -0
  46. package/src/client/index.ts +403 -0
  47. package/src/client/settings/CreateForm.tsx +186 -0
  48. package/src/client/settings/DataSourceForm.tsx +278 -0
  49. package/src/client/settings/DataSourcesPanel.tsx +301 -0
  50. package/src/client/settings/DatasetEditor.tsx +245 -0
  51. package/src/client/settings/DatasetTable.tsx +110 -0
  52. package/src/client/settings/DatasetsPanel.tsx +207 -0
  53. package/src/client/settings/RowsPanel.tsx +196 -0
  54. package/src/client/settings/Section.tsx +41 -0
  55. package/src/client/settings/SourceTablesPanel.tsx +226 -0
  56. package/src/client/settings/api.ts +154 -0
  57. package/src/client/settings/styles.ts +125 -0
  58. package/src/datasource/columns.ts +56 -0
  59. package/src/datasource/connection.ts +124 -0
  60. package/src/datasource/connector/base.ts +54 -0
  61. package/src/datasource/connector/mysql.ts +187 -0
  62. package/src/datasource/connector/postgresql.ts +212 -0
  63. package/src/datasource/crypto.ts +61 -0
  64. package/src/datasource/driver.ts +108 -0
  65. package/src/datasource/errors.ts +69 -0
  66. package/src/datasource/importer.ts +262 -0
  67. package/src/datasource/index.ts +62 -0
  68. package/src/datasource/source-sql.ts +64 -0
  69. package/src/datasource/source-store.ts +152 -0
  70. package/src/datasource/types.ts +133 -0
  71. package/src/db.ts +277 -0
  72. package/src/http-common.ts +91 -0
  73. package/src/http.ts +130 -0
  74. package/src/index.ts +486 -0
  75. package/src/parse.ts +213 -0
  76. package/src/preview.ts +198 -0
  77. package/src/render.ts +294 -0
  78. package/src/scope-registry.ts +111 -0
  79. package/src/scope.ts +159 -0
  80. package/src/sql.ts +491 -0
  81. package/src/store.ts +412 -0
  82. package/src/table.ts +160 -0
  83. package/src/tooling.ts +551 -0
  84. package/src/tools/datasource.ts +378 -0
  85. package/src/tools/import.ts +282 -0
  86. package/src/tools/read.ts +536 -0
  87. package/src/tools/registry.ts +56 -0
  88. package/src/tools/write.ts +241 -0
  89. package/src/view.ts +371 -0
package/src/render.ts ADDED
@@ -0,0 +1,294 @@
1
+ /**
2
+ * 模型可见文本的渲染(对齐 `dsh-lh-judge` 的 render.ts 定位)。
3
+ *
4
+ * 约定:`output.render` 只做纯函数渲染,不读状态;物理表名绝不出现在这里。
5
+ */
6
+
7
+ import type { Row } from './db'
8
+ import type { ColumnInfo } from './parse'
9
+ import { truncateCell, type ColumnSummary } from './preview'
10
+
11
+ export interface DatasetListItem {
12
+ datasetId: string
13
+ name: string
14
+ rowCount: number
15
+ columnCount: number
16
+ status: string
17
+ sourcePath: string | null
18
+ createdAt: number
19
+ }
20
+
21
+ export const MAX_RENDER_CELL = 40
22
+ export const MAX_RENDER_ROWS = 50
23
+
24
+ export function truncateText(text: string, max: number): string {
25
+ return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
26
+ }
27
+
28
+ /** 单元格转义:去掉换行与竖线,避免破坏 markdown 表格。 */
29
+ export function formatValue(value: unknown): string {
30
+ if (value === null || value === undefined) return ''
31
+ if (value instanceof Uint8Array) return `<${value.length} bytes>`
32
+ if (typeof value === 'object') {
33
+ try {
34
+ return truncateText(JSON.stringify(value), MAX_RENDER_CELL)
35
+ } catch {
36
+ return '[object]'
37
+ }
38
+ }
39
+ return truncateText(String(value), MAX_RENDER_CELL)
40
+ }
41
+
42
+ function escapeCell(text: string): string {
43
+ return text.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
44
+ }
45
+
46
+ function renderTable(header: string[], rows: string[][]): string {
47
+ if (header.length === 0) return '(无内容)'
48
+ const lines = [
49
+ `| ${header.map(escapeCell).join(' | ')} |`,
50
+ `| ${header.map(() => '---').join(' | ')} |`,
51
+ ...rows.map(row => `| ${row.map(escapeCell).join(' | ')} |`),
52
+ ]
53
+ return lines.join('\n')
54
+ }
55
+
56
+ export function formatTimestamp(epochMs: number): string {
57
+ if (!Number.isFinite(epochMs) || epochMs <= 0) return '-'
58
+ return new Date(epochMs).toISOString().replace('T', ' ').slice(0, 19)
59
+ }
60
+
61
+ export function renderDatasetList(items: DatasetListItem[]): string {
62
+ if (items.length === 0) return '当前工作区还没有数据集。用 `dataset_import` 导入工作区内的 .xlsx / .xls / .csv 文件。'
63
+ const rows = items.map(item => [
64
+ item.datasetId,
65
+ item.name,
66
+ String(item.rowCount),
67
+ String(item.columnCount),
68
+ item.status,
69
+ item.sourcePath ?? '-',
70
+ formatTimestamp(item.createdAt),
71
+ ])
72
+ return `当前工作区共有 ${items.length} 个数据集:\n\n${renderTable(
73
+ ['datasetId', '名称', '行数', '列数', '状态', '来源文件', '创建时间'],
74
+ rows,
75
+ )}`
76
+ }
77
+
78
+ export function renderColumns(columns: ColumnInfo[]): string {
79
+ if (columns.length === 0) return '(该数据集没有列信息)'
80
+ const rows = columns.map(column => [
81
+ column.name,
82
+ column.type,
83
+ column.nullable ? '是' : '否',
84
+ column.sample.map(formatValue).filter(value => value.length > 0).slice(0, 3).join(' / ') || '-',
85
+ column.description ?? '-',
86
+ ])
87
+ return renderTable(['列名', '类型', '可空', '样例', '说明'], rows)
88
+ }
89
+
90
+ export function renderRows(columns: string[], rows: Row[], maxRows: number = MAX_RENDER_ROWS): string {
91
+ if (rows.length === 0) return '(没有匹配的行)'
92
+ const shown = rows.slice(0, maxRows)
93
+ const body = shown.map(row => columns.map(column => formatValue(row[column])))
94
+ const table = renderTable(columns, body)
95
+ return rows.length > shown.length
96
+ ? `${table}\n\n(仅展示前 ${shown.length} 行,共 ${rows.length} 行)`
97
+ : table
98
+ }
99
+
100
+ // ── 查询结果片段(设计文档 §5.3) ────────────────────────────────────────
101
+
102
+ /** 片段渲染的输入(结构化而非 import 工具类型,避免 render → tools 的循环)。 */
103
+ export interface QueryPreviewInput {
104
+ name: string
105
+ datasetId: string
106
+ columnCount: number
107
+ /** 实际命中行数。 */
108
+ matchedRows: number
109
+ /** 本次可服务行数(已按上限收敛)。 */
110
+ totalRows: number
111
+ preview: {
112
+ rows: Row[]
113
+ columns: string[]
114
+ /** 头段行数;`gap` 为真时其后是尾段。 */
115
+ headCount: number
116
+ gap: boolean
117
+ skipped: number
118
+ columnTruncated: boolean
119
+ hiddenColumns: number
120
+ }
121
+ summary: ColumnSummary[]
122
+ /** 有小节被省略(预览只是结果的一部分)。 */
123
+ truncated: boolean
124
+ /** 存在前端视图。 */
125
+ view?: { pageSize: number; stable: boolean }
126
+ }
127
+
128
+ function renderPreviewTable(columns: string[], rows: Row[], maxCell: number): string {
129
+ const body = rows.map(row => columns.map(column => {
130
+ const { text } = truncateCell(row[column], maxCell)
131
+ return text
132
+ }))
133
+ return renderTable(columns, body)
134
+ }
135
+
136
+ function formatSummaryValue(value: number | string | null | undefined): string {
137
+ if (value === null || value === undefined) return '-'
138
+ if (typeof value === 'number') return String(Number(value.toFixed(4)))
139
+ return truncateText(String(value), 24)
140
+ }
141
+
142
+ function renderSummaryLine(summary: ColumnSummary, totalRows: number): string {
143
+ const label = `${summary.name} ${summary.type}`
144
+ if (summary.type === 'boolean') {
145
+ return `- ${label}:真 ${summary.trueCount ?? 0} / 非空 ${summary.count} / 共 ${totalRows}`
146
+ }
147
+ if (summary.type === 'text') {
148
+ const distinct = summary.distinct ?? 0
149
+ const top = summary.top
150
+ if (top !== undefined && top.length > 0) {
151
+ const parts = top.map(entry => `${entry.value} ${entry.count}`).join(' / ')
152
+ return `- ${label}:distinct ${distinct} → ${parts}${distinct > top.length ? ' …' : ''}`
153
+ }
154
+ return `- ${label}:distinct ${distinct}(取值过于分散,已省略明细)/ 非空 ${summary.count}`
155
+ }
156
+ const parts = [`min ${formatSummaryValue(summary.min)}`, `max ${formatSummaryValue(summary.max)}`]
157
+ if (summary.type === 'numeric') {
158
+ parts.push(`avg ${formatSummaryValue(summary.avg)}`, `sum ${formatSummaryValue(summary.sum)}`)
159
+ }
160
+ parts.push(`空 ${Math.max(0, totalRows - summary.count)}`)
161
+ return `- ${label}:${parts.join(' / ')}`
162
+ }
163
+
164
+ /**
165
+ * 渲染查询结果的模型可见片段:行数概览 + 少量预览行 + 类型化摘要 + 视图提示。
166
+ * 目标是把上下文占用压到常量级,同时保留「足以下结论」的统计信息。
167
+ */
168
+ export function renderQueryPreview(input: QueryPreviewInput): string {
169
+ const { preview } = input
170
+ const scope = input.matchedRows === input.totalRows
171
+ ? `共 ${input.totalRows} 行`
172
+ : `命中 ${input.matchedRows} 行(本次可服务 ${input.totalRows} 行)`
173
+ const lines: string[] = [
174
+ `数据集 ${input.name}(${input.datasetId}):${scope} × ${input.columnCount} 列,返回 ${preview.rows.length} 行预览。`,
175
+ '',
176
+ ]
177
+
178
+ if (preview.rows.length === 0) {
179
+ lines.push('(没有匹配的行)')
180
+ } else if (preview.gap && preview.headCount > 0 && preview.headCount < preview.rows.length) {
181
+ lines.push(
182
+ renderPreviewTable(preview.columns, preview.rows.slice(0, preview.headCount), MAX_RENDER_CELL),
183
+ '',
184
+ `(… 省略 ${preview.skipped} 行 …)`,
185
+ '',
186
+ renderPreviewTable(preview.columns, preview.rows.slice(preview.headCount), MAX_RENDER_CELL),
187
+ )
188
+ } else {
189
+ lines.push(renderPreviewTable(preview.columns, preview.rows, MAX_RENDER_CELL))
190
+ }
191
+
192
+ if (preview.columnTruncated) {
193
+ lines.push('', `(仅展示前 ${preview.columns.length} 列,另有 ${preview.hiddenColumns} 列)`)
194
+ }
195
+
196
+ if (input.summary.length > 0) {
197
+ lines.push('', '摘要:', ...input.summary.map(summary => renderSummaryLine(summary, input.totalRows)))
198
+ }
199
+
200
+ if (input.view !== undefined) {
201
+ lines.push(
202
+ '',
203
+ `完整结果(${input.totalRows} 行)已在结果表格中展示${input.view.stable ? '' : '(该查询未声明稳定排序,仅展示首页)'}。`,
204
+ '若需据此下结论,请用更精确的 where 或聚合 SQL 再查一次;不要试图逐页读取全量。',
205
+ )
206
+ } else if (input.truncated) {
207
+ lines.push('', '(结果已按上限截断;需要完整数据请用更精确的 where 或聚合查询)')
208
+ }
209
+
210
+ return lines.join('\n')
211
+ }
212
+
213
+ // ── 数据源(脱敏:不含密码、不含远端凭据) ──────────────────────────────
214
+
215
+ export interface SourceListItem {
216
+ id: string
217
+ name: string
218
+ type: string
219
+ host: string
220
+ port: number
221
+ database: string
222
+ status: string
223
+ lastError: string | null
224
+ lastCheckedAt: number | null
225
+ }
226
+
227
+ export interface SourceTableItem {
228
+ tableName: string
229
+ schemaName: string | null
230
+ rowCount: number
231
+ columnCount: number
232
+ primaryKey: string | null
233
+ }
234
+
235
+ export function renderSourceList(items: SourceListItem[]): string {
236
+ if (items.length === 0) return '还没有登记任何数据源。在「设置 → 数据集 → 数据源」里新建,或用管理接口登记。'
237
+ const rows = items.map(item => [
238
+ item.id,
239
+ item.name,
240
+ item.type,
241
+ `${item.host}:${String(item.port)}`,
242
+ item.database,
243
+ item.status,
244
+ item.lastError ?? '-',
245
+ item.lastCheckedAt === null ? '-' : formatTimestamp(item.lastCheckedAt),
246
+ ])
247
+ return `共 ${items.length} 个数据源:\n\n${renderTable(
248
+ ['sourceId', '名称', '类型', '地址', '库名', '状态', '最近错误', '最近检测'],
249
+ rows,
250
+ )}`
251
+ }
252
+
253
+ export function renderSourceTables(sourceName: string, schema: string | null, items: SourceTableItem[]): string {
254
+ if (items.length === 0) return `数据源「${sourceName}」${schema === null ? '' : `(schema ${schema})`}里没有匹配的表。`
255
+ const rows = items.map(item => [
256
+ item.tableName,
257
+ item.schemaName ?? '-',
258
+ item.rowCount < 0 ? '未知' : String(item.rowCount),
259
+ String(item.columnCount),
260
+ item.primaryKey ?? '-',
261
+ ])
262
+ return `数据源「${sourceName}」共 ${items.length} 张表:\n\n${renderTable(['表名', 'schema', '估计行数', '列数', '主键'], rows)}`
263
+ }
264
+
265
+ export interface ConnectionTestView {
266
+ name: string
267
+ success: boolean
268
+ latency: number
269
+ version: string | null
270
+ error: string | null
271
+ }
272
+
273
+ export function renderConnectionTest(view: ConnectionTestView): string {
274
+ if (view.success) {
275
+ const version = view.version === null ? '' : `,版本 ${view.version}`
276
+ return `数据源「${view.name}」连接成功(${view.latency}ms${version})。`
277
+ }
278
+ return `数据源「${view.name}」连接失败(${view.latency}ms):${view.error ?? '未知错误'}`
279
+ }
280
+
281
+ /** 包装成 dsh 的回注用户消息(带 source 标记,便于去重/追踪)。 */
282
+ export interface UserMessage {
283
+ role: 'user'
284
+ content: { type: 'text'; text: string }[]
285
+ source: { kind: 'plugin'; plugin: string }
286
+ }
287
+
288
+ export function toUserMessage(text: string, plugin: string): UserMessage {
289
+ return {
290
+ role: 'user',
291
+ content: [{ type: 'text', text }],
292
+ source: { kind: 'plugin', plugin },
293
+ }
294
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * 工作区(scope)注册表 —— 设置页「聚合展示全部工作区」的枚举来源。
3
+ *
4
+ * 为什么需要这张表:`perWorkspace=false`(默认)时所有 scope 共用一个库文件,
5
+ * `SELECT DISTINCT scope_key FROM datasets` 就能枚举;但 `perWorkspace=true`
6
+ * 时每个 scope 是独立的 `<hash>.db`,库文件名是 scope 的单向哈希,
7
+ * **没有任何办法反查「这台机器上有哪些工作区」**。
8
+ *
9
+ * 因此把见过的 scope 记在**目录库**里:一个不做 scope 分片的固定库
10
+ * (`perWorkspace=false` 时与业务库是同一个文件,不额外占地方)。
11
+ *
12
+ * 与 `DatasetStore` 分开,是为了让 store 保持单一职责(数据集元数据)并守住
13
+ * 200 行的类体积上限。
14
+ */
15
+
16
+ import { resolveCatalogDatabase, type Database, type DbConfig } from './db'
17
+
18
+ const CATALOG_SQL = `
19
+ CREATE TABLE IF NOT EXISTS dataset_scopes (
20
+ scope_key TEXT PRIMARY KEY,
21
+ first_seen INTEGER NOT NULL,
22
+ last_seen INTEGER NOT NULL
23
+ );
24
+ `
25
+
26
+ /** 把已登记数据集的 scope 补进注册表(升级前遗留的数据靠它进来)。 */
27
+ const BACKFILL_SQL = `
28
+ INSERT OR IGNORE INTO dataset_scopes (scope_key, first_seen, last_seen)
29
+ SELECT DISTINCT scope_key, ?, ? FROM datasets
30
+ `
31
+
32
+ export interface ScopeEntry {
33
+ /** 规范化后的工作区目录(默认模式)或 `ws:<id>`(perWorkspace 模式)。 */
34
+ scopeKey: string
35
+ firstSeen: number
36
+ lastSeen: number
37
+ }
38
+
39
+ export class ScopeRegistry {
40
+ private ready = false
41
+
42
+ constructor(private readonly cfg: DbConfig) {}
43
+
44
+ /**
45
+ * 记录一次 scope 使用:新建数据集时调用,已存在则刷新 `last_seen`。
46
+ * 目录库要么与业务库同一个连接,要么是同目录下的兄弟文件,
47
+ * 因此这里的失败一律向上抛 —— 静默吞掉会让工作区从设置页里凭空消失。
48
+ */
49
+ async record(scopeKey: string): Promise<void> {
50
+ const db = await this.catalog()
51
+ const now = Date.now()
52
+ await db
53
+ .prepare(
54
+ `INSERT INTO dataset_scopes (scope_key, first_seen, last_seen) VALUES (?, ?, ?)
55
+ ON CONFLICT(scope_key) DO UPDATE SET last_seen = excluded.last_seen`,
56
+ )
57
+ .run(scopeKey, now, now)
58
+ }
59
+
60
+ /** 全部已知工作区,按最近使用倒序。 */
61
+ async list(): Promise<ScopeEntry[]> {
62
+ const db = await this.catalog()
63
+ const rows = await db
64
+ .prepare('SELECT scope_key, first_seen, last_seen FROM dataset_scopes ORDER BY last_seen DESC')
65
+ .all()
66
+ return rows.map(row => ({
67
+ scopeKey: String(row.scope_key),
68
+ firstSeen: Number(row.first_seen ?? 0),
69
+ lastSeen: Number(row.last_seen ?? 0),
70
+ }))
71
+ }
72
+
73
+ /**
74
+ * 该 scope 是否已知。写操作的准入校验:设置页的请求带不到会话上下文,
75
+ * 若放行任意字符串,浏览器就能凭空造工作区、造库文件。
76
+ */
77
+ async has(scopeKey: string): Promise<boolean> {
78
+ const db = await this.catalog()
79
+ const row = await db
80
+ .prepare('SELECT 1 AS ok FROM dataset_scopes WHERE scope_key = ? LIMIT 1')
81
+ .get(scopeKey)
82
+ return row !== undefined
83
+ }
84
+
85
+ /** 目录库连接:幂等建表,且只回填一次。 */
86
+ private async catalog(): Promise<Database> {
87
+ const db = resolveCatalogDatabase(this.cfg)
88
+ if (this.ready) return db
89
+ await db.exec(CATALOG_SQL)
90
+ await this.backfill(db)
91
+ this.ready = true
92
+ return db
93
+ }
94
+
95
+ /**
96
+ * 回填:`perWorkspace=true` 时目录库里没有 `datasets` 表,语句会失败 ——
97
+ * 那是预期情况,此时注册表本就只靠 `record()` 累积,静默跳过即可。
98
+ */
99
+ private async backfill(db: Database): Promise<void> {
100
+ const now = Date.now()
101
+ try {
102
+ await db.prepare(BACKFILL_SQL).run(now, now)
103
+ } catch {
104
+ // 目录库里没有 datasets 表:perWorkspace 模式下正常。
105
+ }
106
+ }
107
+ }
108
+
109
+ export function createScopeRegistry(cfg: DbConfig): ScopeRegistry {
110
+ return new ScopeRegistry(cfg)
111
+ }
package/src/scope.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * 作用域与路径解析(设计文档 §2.4 / §7.4)。
3
+ *
4
+ * - scopeKey:默认取调用 agent 的会话工作目录(规范化后的绝对路径),即
5
+ * `exec.agent.session.header.cwd`(`SessionHeader`);`perWorkspace` 时提升为
6
+ * `WorkspaceId`(`ws:<id>`),不可用时回落 cwd。
7
+ * - 拿不到会话 cwd 时 fail-loud(抛 `ScopeError`):不拿 `process.cwd()` 顶替,
8
+ * 否则数据集会静默登记到 dsh 进程的启动目录。
9
+ * - 路径:导入文件必须解析后落在 scope 目录内(防 `../` 穿越)+ 扩展名白名单。
10
+ */
11
+
12
+ import { realpathSync } from 'node:fs'
13
+ import { stat } from 'node:fs/promises'
14
+ import { extname, isAbsolute, relative, resolve } from 'node:path'
15
+ import type { ToolExec } from './tooling'
16
+
17
+ export interface ScopeContext {
18
+ /** 数据集归属键(写入 `datasets.scope_key`)。 */
19
+ scopeKey: string
20
+ /** 解析相对路径用的工作区目录。 */
21
+ cwd: string
22
+ /** `perWorkspace` 模式下的 WorkspaceId。 */
23
+ workspaceId?: string
24
+ }
25
+
26
+ export const FILE_EXTENSIONS = ['.xlsx', '.xls', '.csv'] as const
27
+
28
+ export const DEFAULT_SCOPE = 'default'
29
+
30
+ export class ScopeError extends Error {
31
+ readonly code = 'SCOPE_ERROR'
32
+
33
+ constructor(message: string) {
34
+ super(message)
35
+ this.name = 'ScopeError'
36
+ }
37
+ }
38
+
39
+ /** 规范化目录:解析为绝对路径,并在 Windows 上把盘符小写化(便于比较)。 */
40
+ export function normalizeDirectory(input: string): string {
41
+ const resolved = resolve(input)
42
+ return process.platform === 'win32'
43
+ ? resolved.replace(/^([a-zA-Z]):/, (_match, drive: string) => `${drive.toLowerCase()}:`)
44
+ : resolved
45
+ }
46
+
47
+ /**
48
+ * 目录的文件系统身份:`realpath` 后再规范化。
49
+ *
50
+ * Windows 的 junction / 符号链接、macOS 的 `/var` → `/private/var` 都会让纯词法
51
+ * 比较误判归属,因此 scope 目录与导入目标一律按文件系统身份比较。与 dsh 的
52
+ * `canonicalPath`(packages/sandbox/sandbox/src/roots.ts)同思路:用
53
+ * `realpathSync.native` 逐段跟随链接;路径不存在时原样返回,由后续的存在性校验
54
+ * (`assertReadableFile`)给出真正的错误。
55
+ */
56
+ export function canonicalDirectory(input: string): string {
57
+ try {
58
+ return normalizeDirectory(realpathSync.native(input))
59
+ } catch {
60
+ return normalizeDirectory(input)
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 取会话工作目录。
66
+ *
67
+ * 真实 dsh 运行时挂在 `exec.agent.session.header.cwd`(`Agent.session` 是 `Session`,
68
+ * cwd 属于它的 `SessionHeader`);`session.cwd` 是早期约定的扁平形状,仅作
69
+ * headless / 自测兼容位保留。
70
+ */
71
+ function sessionCwdOf(exec: ToolExec): string | undefined {
72
+ const session: unknown = exec?.agent?.session
73
+ if (typeof session !== 'object' || session === null) return undefined
74
+ const header: unknown = (session as { header?: unknown }).header
75
+ const fromHeader: unknown = (header as { cwd?: unknown } | null | undefined)?.cwd
76
+ if (typeof fromHeader === 'string' && fromHeader.trim().length > 0) return fromHeader
77
+ const flat: unknown = (session as { cwd?: unknown }).cwd
78
+ return typeof flat === 'string' && flat.trim().length > 0 ? flat : undefined
79
+ }
80
+
81
+ /** 可选能力:把 cwd 提升为 WorkspaceId(duck-typed,缺失即回落)。 */
82
+ async function lookupWorkspaceId(ctx: unknown, cwd: string): Promise<string | undefined> {
83
+ const registry: unknown = (ctx as { workspaceRegistry?: unknown } | null)?.workspaceRegistry
84
+ if (typeof registry !== 'object' || registry === null) return undefined
85
+ const resolveByPath: unknown = (registry as { resolveByPath?: unknown }).resolveByPath
86
+ if (typeof resolveByPath !== 'function') return undefined
87
+ try {
88
+ // `WorkspaceRegistry.resolveByPath()` 是异步的,返回 `Promise<Workspace | undefined>`。
89
+ const found: unknown = await (resolveByPath as (path: string) => Promise<unknown>).call(registry, cwd)
90
+ if (typeof found === 'string') return found.length > 0 ? found : undefined
91
+ if (typeof found === 'object' && found !== null) {
92
+ const record = found as { id?: unknown; workspaceId?: unknown }
93
+ const id = record.id ?? record.workspaceId
94
+ if (typeof id === 'string' && id.length > 0) return id
95
+ }
96
+ } catch {
97
+ return undefined
98
+ }
99
+ return undefined
100
+ }
101
+
102
+ /**
103
+ * 从一次工具执行解析 scope。
104
+ *
105
+ * 拿不到会话 cwd 时 fail-loud(抛 `ScopeError`):宁可让这次调用失败,也不能静默用
106
+ * `process.cwd()` 顶替 —— 后者是 dsh 进程的启动目录,数据集会登记到错误的工作区
107
+ * (换回正确 cwd 后 `dataset_list` 就看不见它们)。
108
+ * `perWorkspace` 且拿不到 WorkspaceId 时仍用 cwd。
109
+ */
110
+ export async function resolveScope(
111
+ ctx: unknown,
112
+ cfg: { perWorkspace: boolean },
113
+ exec: ToolExec,
114
+ ): Promise<ScopeContext> {
115
+ const raw = sessionCwdOf(exec)
116
+ if (raw === undefined) {
117
+ throw new ScopeError('无法解析工作区目录:exec.agent.session.header.cwd 缺失(本次调用没有 agent 会话?)')
118
+ }
119
+ const cwd = canonicalDirectory(raw)
120
+ if (!cfg.perWorkspace) return { scopeKey: cwd, cwd }
121
+ const workspaceId = await lookupWorkspaceId(ctx, cwd)
122
+ return workspaceId === undefined ? { scopeKey: cwd, cwd } : { scopeKey: `ws:${workspaceId}`, cwd, workspaceId }
123
+ }
124
+
125
+ /** target 是否位于 baseDir 之内(含子目录)。 */
126
+ export function isInside(baseDir: string, target: string): boolean {
127
+ const rel = relative(baseDir, target)
128
+ if (rel.length === 0 || rel.startsWith('..')) return false
129
+ return !isAbsolute(rel)
130
+ }
131
+
132
+ /** 解析导入路径:相对路径以 cwd 为基,越界或扩展名不在白名单即拒绝。 */
133
+ export function resolveInputFile(cwd: string, input: string): string {
134
+ const raw = typeof input === 'string' ? input.trim() : ''
135
+ if (raw.length === 0) throw new ScopeError('path 不能为空')
136
+ // 两侧都取文件系统身份,保证与 canonical 化的 scope 目录可比。
137
+ const base = canonicalDirectory(cwd)
138
+ const target = canonicalDirectory(isAbsolute(raw) ? raw : resolve(base, raw))
139
+ if (!isInside(base, target)) throw new ScopeError(`文件必须在工作区目录内:${raw}`)
140
+ const ext = extname(target).toLowerCase()
141
+ if (!(FILE_EXTENSIONS as readonly string[]).includes(ext)) {
142
+ throw new ScopeError(`不支持的文件类型:${ext || '(无扩展名)'},仅支持 ${FILE_EXTENSIONS.join(' / ')}`)
143
+ }
144
+ return target
145
+ }
146
+
147
+ /** 读取前校验:必须是普通文件、非空且不超过 `maxBytes`。 */
148
+ export async function assertReadableFile(path: string, maxBytes: number): Promise<{ size: number }> {
149
+ let info
150
+ try {
151
+ info = await stat(path)
152
+ } catch {
153
+ throw new ScopeError(`文件不存在或不可读取:${path}`)
154
+ }
155
+ if (!info.isFile()) throw new ScopeError(`不是文件:${path}`)
156
+ if (info.size === 0) throw new ScopeError(`文件为空:${path}`)
157
+ if (info.size > maxBytes) throw new ScopeError(`文件过大:${info.size} 字节,上限 ${maxBytes} 字节`)
158
+ return { size: info.size }
159
+ }