diff --git a/AGENTS.md b/AGENTS.md index 5231554..0802058 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ npm run fmt:check # oxfmt --check —— 必须通过(与 fmt 重 ## 设计底线 - **Facade 完整性**:命令永远不越过 `DatabaseWorkspaceService`。所有委托(`manager`、`history`、`favorites`、`relationGraph`)都是 facade 的私有字段。命令需要新行为时,在 facade 上暴露专用方法——不要穿透到内部。 -- **每种操作单一执行路径**:所有读查询流经 `DatabaseConnectionManager.executeQuery`(只读守卫 + LIMIT)。所有写变更流经 `DatabaseConnectionManager.executeMutation`(`prepareMutationQuery` 拒绝 DDL,人工确认门)。绝不创建新的查询路径或绕过 `sql-policy.ts`。 +- **每种操作单一执行路径**:所有读查询流经 `DatabaseConnectionManager.executeQuery`(只读守卫 + LIMIT)。所有写变更流经 `DatabaseWorkspaceService.executeMutationWithApproval`(facade 内 `prepareMutationQuery` 拒绝 DDL + 注入的人工确认回调)。绝不创建新的查询路径或绕过 `sql-policy.ts`。 - **纯工具函数**:`sql-policy.ts`(只读守卫 + LIMIT 注入 + DML 校验)和 `formatting/result-table.ts` 是纯函数、无副作用。保持这一点——不导入 pi、不做 I/O。当逻辑不需要 pi API 时,抽成纯函数以便用普通值测试。 - **测试接缝**:模块通过构造函数参数接受依赖(`StateStore`、`Database` 句柄、`QueryFn`)。不要引入硬编码路径(如 `homedir()`)或单例——注入接缝。 - **实时 schema,无缓存**:`getTables()` 和 `getTableSchema()` 总是查询实时 DB(`information_schema`)。没有 schema 缓存需要刷新或保持一致——实时查询足够廉价且永不过期。没有实测需求就不要重新引入缓存层。 diff --git a/CLAUDE.md b/CLAUDE.md index 5bde3e2..d6b70f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,7 @@ formatting/ ← formatTableResult —— 自动布局:横向 / 转置 / ### 关键设计模式 - **深度工作空间模块**:`DatabaseWorkspaceService` 将 WorkspaceContext + QueryRunner 吸收进一个类。所有委托(`manager`、`history`、`favorites`、`relationGraph`)都是私有字段——命令通过约 23 个专用方法穿越外部接缝。任何命令都不能越过 facade。 -- **单一执行点**:所有读查询经过 `DatabaseConnectionManager.executeQuery`,它应用只读守卫和 LIMIT 策略(`connection/sql-policy.ts`——纯函数,`READONLY_SQL_RE` 的唯一归属),然后在检出的专用连接上执行(`getConnection → USE → query → release`),这样 USE 与查询不会散落在连接池的不同连接上。无界 SELECT 自动追加 `LIMIT n`(默认 100,connections.yaml 中可配 per-connection `queryLimit`);最终 SQL 通过 `result.sql` 返回,用户可以看到自动追加的 LIMIT。写操作经过 `DatabaseConnectionManager.executeMutation`——没有只读守卫,但由 `prepareMutationQuery`(拒绝 DDL)和强制人工确认对话框把关。命令处理器只在分发时(表名 vs SQL)导入 `READONLY_SQL_RE`,绝不用于执行期校验。 +- **单一执行点**:所有读查询经过 `DatabaseConnectionManager.executeQuery`,它应用只读守卫和 LIMIT 策略(`connection/sql-policy.ts`——纯函数,`READONLY_SQL_RE` 的唯一归属),然后在检出的专用连接上执行(`getConnection → USE → query → release`),这样 USE 与查询不会散落在连接池的不同连接上。无界 SELECT 自动追加 `LIMIT n`(默认 100,connections.yaml 中可配 per-connection `queryLimit`);最终 SQL 通过 `result.sql` 返回,用户可以看到自动追加的 LIMIT。写操作经过 `DatabaseWorkspaceService.executeMutationWithApproval`——唯一写入口,在 facade 内完成 `prepareMutationQuery`(DDL 拒绝,抛 `MutationValidationError`)→ 人工确认(注入的确认回调,生产为 `showMutationConfirm`)→ 执行。命令处理器只在分发时(表名 vs SQL)导入 `READONLY_SQL_RE`,绝不用于执行期校验。 - **实时 schema**:`getTables()` 和 `getTableSchema()` 总是查询 `information_schema`——无缓存、无刷新。实践中足够廉价且永不过期。 - **BFS 自动 JOIN**:`RelationGraph.bfsQuery()` 遍历内存前向图,每跳发出参数化(`IN (?)`)、schema 限定的查询。深度受限(默认 2,最大 5)。它从调用方接收 `QueryFn` 而非 mysql2 连接池——图保持数据库无关,用 stub 测试。 - **懒加载工作空间初始化**:`DatabaseWorkspaceService` 不在扩展工厂中构造(工厂可能运行在从不启动会话的调用中,如 `--list-models` 或 print 模式)。懒 getter 将打开 SQLite / 读取配置推迟到 `session_start`、第一次 `/db` 命令或第一次工具调用。 @@ -76,7 +76,7 @@ formatting/ ← formatTableResult —— 自动布局:横向 / 转置 / `db_query` 和 `db_tables` 默认使用工作空间选择,但接受可选的 `connection` / `database` 覆盖,由 `DatabaseWorkspaceService.resolveTarget` 解析(显式 connection 不带 database 时回退到其 `defaultDatabase`)。`db_list_relations` / `db_relation` 接受可选的 `database` 覆盖。同一 MySQL 实例上的数据库可以用 `db.table` 限定名直接 JOIN——连接池不带默认数据库连接,所以 `USE` 从来不是沙箱。 -只读工具 `db_query` 经过 `DatabaseWorkspaceService.executeQuery`,因此只读守卫和 LIMIT 策略生效。`db_tables` 使用与 `/db` 命令相同的实时 `information_schema` 查询(`getTables` / `getTableSchema`)。`db_discover` 读取本地连接配置并通过 manager 上的 `SHOW DATABASES` 列出数据库。`db_list_relations` / `db_relation` 通过 `RelationGraph` / `RelationStore` 操作本地 SQLite——它们不触碰 MySQL,不需要只读守卫或确认门(register 幂等,delete 按精确列对匹配)。`db_mutate` 使用 `executeMutation`——没有只读守卫,但 `prepareMutationQuery` 拒绝 DDL,每次执行都有确认对话框把关。`db_tools`(loader)只操作激活工具集。 +只读工具 `db_query` 经过 `DatabaseWorkspaceService.executeQuery`,因此只读守卫和 LIMIT 策略生效。`db_tables` 使用与 `/db` 命令相同的实时 `information_schema` 查询(`getTables` / `getTableSchema`)。`db_discover` 读取本地连接配置并通过 manager 上的 `SHOW DATABASES` 列出数据库。`db_list_relations` / `db_relation` 通过 `RelationGraph` / `RelationStore` 操作本地 SQLite——它们不触碰 MySQL,不需要只读守卫或确认门(register 幂等,delete 按精确列对匹配)。`db_mutate` 调用 `executeMutationWithApproval`——校验与确认都发生在 facade 内,工具只注入确认对话框回调并整形结果。`db_tools`(loader)只操作激活工具集。 ### 消息渲染 diff --git a/__tests__/workspace-target.test.ts b/__tests__/workspace-target.test.ts index cc34ba0..b73b0e5 100644 --- a/__tests__/workspace-target.test.ts +++ b/__tests__/workspace-target.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { StateStore } from "../state/state-store"; -import { DatabaseWorkspaceService } from "../state/workspace"; +import { DatabaseWorkspaceService, type MutationApprovalRequest } from "../state/workspace"; +import { MutationValidationError } from "../connection/sql-policy"; const CONNECTIONS_YAML = `connections: main: @@ -256,3 +257,95 @@ describe("DatabaseWorkspaceService target resolution", () => { expect(deletedAgain).toBe(false); }); }); + +describe("DatabaseWorkspaceService.executeMutationWithApproval", () => { + const dirs: string[] = []; + const services: DatabaseWorkspaceService[] = []; + + function makeWorkspace(): DatabaseWorkspaceService { + const dir = mkdtempSync(join(tmpdir(), "ws-mut-test-")); + dirs.push(dir); + writeFileSync(join(dir, "connections.yaml"), CONNECTIONS_YAML); + const ws = new DatabaseWorkspaceService(new StateStore(dir)); + services.push(ws); + return ws; + } + + afterEach(() => { + for (const ws of services.splice(0)) ws.destroy(); + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + it("rejects DDL before invoking the confirm callback", async () => { + const ws = makeWorkspace(); + ws.switchTo("prod", "main", "appdb"); + const confirm = vi.fn<() => Promise>(async () => true); + + await expect(ws.executeMutationWithApproval("DROP TABLE users", {}, confirm)).rejects.toThrow( + MutationValidationError, + ); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("throws before confirm when no target can be resolved", async () => { + const ws = makeWorkspace(); + const confirm = vi.fn<() => Promise>(async () => true); + + await expect( + ws.executeMutationWithApproval("UPDATE t SET a=1 WHERE id=1", {}, confirm), + ).rejects.toThrow(/No database selected/); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("returns rejected when the user declines, without touching the manager", async () => { + const ws = makeWorkspace(); + ws.switchTo("prod", "main", "appdb"); + const confirm = vi.fn<() => Promise>(async () => false); + + const outcome = await ws.executeMutationWithApproval( + "UPDATE t SET a=1 WHERE id=1", + {}, + confirm, + ); + expect(outcome.status).toBe("rejected"); + // 若误触达 manager.executeMutation,测试会因连不上 MySQL 抛错, + // 而不是返回 rejected——因此这行足以证明执行路径未被走。 + }); + + it("passes the merged validation + target request to confirm", async () => { + const ws = makeWorkspace(); + ws.switchTo("prod", "main", "appdb"); + let received: MutationApprovalRequest | undefined; + const confirm = async (req: MutationApprovalRequest) => { + received = req; + return false; // 只验证 request 形状,不真正执行 + }; + + await ws.executeMutationWithApproval( + "UPDATE t SET a=1 WHERE id=1", + { connectionId: "other", database: "stagingdb" }, + confirm, + ); + expect(received).toEqual({ + sql: "UPDATE t SET a=1 WHERE id=1", + operation: "UPDATE", + warning: undefined, + connectionId: "other", + database: "stagingdb", + }); + }); + + it("carries the no-WHERE warning into the request", async () => { + const ws = makeWorkspace(); + ws.switchTo("prod", "main", "appdb"); + let received: MutationApprovalRequest | undefined; + const confirm = async (req: MutationApprovalRequest) => { + received = req; + return false; + }; + + await ws.executeMutationWithApproval("DELETE FROM logs", {}, confirm); + expect(received?.operation).toBe("DELETE"); + expect(received?.warning).toMatch(/WHERE/); + }); +}); diff --git a/commands/mutate-confirm.ts b/commands/mutate-confirm.ts index 6d89b7a..0c1582a 100644 --- a/commands/mutate-confirm.ts +++ b/commands/mutate-confirm.ts @@ -9,14 +9,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { DynamicBorder } from "@earendil-works/pi-coding-agent"; import { Container, Text, Spacer, matchesKey, Key } from "@earendil-works/pi-tui"; - -export interface MutationConfirmParams { - sql: string; - operation: "INSERT" | "UPDATE" | "DELETE" | "REPLACE"; - warning?: string; - connectionId: string; - database: string; -} +import type { MutationApprovalRequest } from "../state/workspace"; type StyleColor = "success" | "warning" | "error"; @@ -38,7 +31,7 @@ const DEFAULT_WARNING = "该操作将永久修改数据,无法撤销"; */ export async function showMutationConfirm( ctx: ExtensionContext, - params: MutationConfirmParams, + params: MutationApprovalRequest, ): Promise { if (ctx.mode !== "tui") { const label = `${OP_STYLE[params.operation]?.icon ?? "⚠️"} ${params.operation}`; diff --git a/connection/sql-policy.ts b/connection/sql-policy.ts index 787c9f2..624d7aa 100644 --- a/connection/sql-policy.ts +++ b/connection/sql-policy.ts @@ -21,6 +21,17 @@ export interface MutationValidation { warning?: string; } +/** + * 变更校验失败(非 DML / DDL / 未识别语句)时抛出的错误类型。 + * 让调用方能区分「SQL 本身不合法」与「执行期失败」。 + */ +export class MutationValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "MutationValidationError"; + } +} + /** 应用于无尾部 LIMIT 的 SELECT 语句的默认行数上限。 */ export const DEFAULT_QUERY_LIMIT = 100; @@ -56,7 +67,7 @@ export function prepareReadOnlyQuery(sql: string, limit: number = DEFAULT_QUERY_ export function prepareMutationQuery(sql: string): MutationValidation { const trimmed = sql.trim(); if (!MUTATION_SQL_RE.test(trimmed)) { - throw new Error( + throw new MutationValidationError( "仅允许 DML 写操作(INSERT、UPDATE、DELETE、REPLACE)。" + "DDL(CREATE、DROP、ALTER、TRUNCATE)被禁止。", ); diff --git a/docs/architecture.md b/docs/architecture.md index 7c7b81b..d5e2ce2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -202,19 +202,23 @@ bfsQuery("orders", rows, maxDepth=2, limit=10) ### 3.7 数据修改工具 — db_mutate -`db_mutate` 是唯一的写路径,设计原则是「AI 提议,人类批准」: +`db_mutate` 是唯一的写路径,设计原则是「AI 提议,人类批准」。仪式(校验 + 人工确认)由 facade 持有: ``` LLM 调用 db_mutate({ sql, connection?, database? }) │ - ├─ 1. prepareMutationQuery(sql) ← DML 校验(DDL 直接拒绝) + ▼ +ws.executeMutationWithApproval(sql, opts, confirm) ← facade 唯一写入口 + ├─ 1. prepareMutationQuery(sql) ← DML 校验(DDL 抛 MutationValidationError) ├─ 2. resolveTarget(opts) ← 目标解析 - ├─ 3. showMutationConfirm() ← TUI overlay 弹窗 + ├─ 3. confirm({ 校验结果 + 目标 }) ← showMutationConfirm TUI overlay │ ├─ Enter → 确认 - │ └─ Esc → 取消(返回给 LLM) + │ └─ Esc → 返回 { status: "rejected" } 给 LLM(非错误) └─ 4. manager.executeMutation() ← 执行,返回 affectedRows ``` +确认回调由调用方注入(生产 = `showMutationConfirm`,测试 = stub),facade 保持 pi-free。 + **安全边界**: - DDL(CREATE/DROP/ALTER/TRUNCATE)硬性拒绝,不弹窗 @@ -319,15 +323,13 @@ MySQL 的 `information_schema.KEY_COLUMN_USAGE` 只能发现已定义的外键 ``` 1. tools/db-tools.ts: db_mutate.execute() - ├─ prepareMutationQuery(sql) ← DML 校验 - ├─ ws.resolveTarget(opts) ← 目标解析 - └─ showMutationConfirm(ctx, ...) ← TUI overlay 确认 - │ - ├─ 用户按 Esc → 返回 rejected 给 LLM + └─ ws.executeMutationWithApproval(sql, opts, + (req) => showMutationConfirm(ctx, req)) ← 注入确认回调 │ - └─ 用户按 Enter ↓ - │ -2. workspace.ts: executeMutation() +2. workspace.ts: executeMutationWithApproval() ← facade 持有仪式 + ├─ prepareMutationQuery(sql) ← DML 校验(DDL 拒绝) + ├─ resolveTarget(opts) ← 目标解析 + ├─ confirm({ 校验结果 + 目标 }) ← 用户 Esc → rejected / Enter → 继续 └─ manager.executeMutation(connId, db, sql) │ 3. db-manager.ts: executeMutation() diff --git a/docs/mutation-tool-design.md b/docs/mutation-tool-design.md index 615e51a..c88c880 100644 --- a/docs/mutation-tool-design.md +++ b/docs/mutation-tool-design.md @@ -1,6 +1,10 @@ # AI 数据修改工具 — 设计方案 > 允许 AI 通过 `db_mutate` 工具发起 INSERT/UPDATE/DELETE/REPLACE,但 **必须经过人工在 TUI 中确认** 后才能执行。 +> +> 状态:已实施(v0.8.x)。后续演进:校验 + 人工确认已收回 facade —— +> 现为 `DatabaseWorkspaceService.executeMutationWithApproval(sql, opts, confirm)` +> 单一写入口(见 §2/§5 更新),工具层只做参数装配与结果整形。 ## 目录 @@ -49,30 +53,22 @@ LLM 调用 db_mutate(sql) │ ▼ -tools/db-tools.ts ──► prepareMutationQuery(sql) ← connection/sql-policy.ts - │ 校验通过? - │ ├─ 否 → 返回错误给 LLM - │ └─ 是 ↓ +tools/db-tools.ts ──► ws.executeMutationWithApproval(sql, opts, confirm) + │ └─ confirm = (req) => showMutationConfirm(ctx, req) ▼ - ctx.ui.custom({ overlay: true }) +state/workspace.ts(facade —— 仪式唯一归属) │ - ▼ - MutationConfirmDialog ← 新组件 (commands/mutate-confirm.ts) - 显示 SQL + 操作类型 + 目标数据库 - 用户:Enter 确认 / Esc 取消 - │ - ├─ 用户取消 → 返回 { confirmed: false } 给 LLM + ├─ 1. prepareMutationQuery(sql) ← connection/sql-policy.ts + │ 校验通过? 否(DDL 等)→ 抛 MutationValidationError → isError 给 LLM + │ 是 ↓ + ├─ 2. resolveTarget(opts) + ├─ 3. confirm({ 校验结果 + 目标 }) —— commands/mutate-confirm.ts + │ ├─ 用户取消 → 返回 { status: "rejected" } → 非 isError 回显给 LLM + │ └─ 用户确认 ↓ + ├─ 4. manager.executeMutation() ← connection/db-manager.ts │ - └─ 用户确认 ↓ - │ - ▼ - ws.executeMutation(sql) ← state/workspace.ts (新方法) - │ - ▼ - manager.executeMutation() ← connection/db-manager.ts (新方法) - │ - ▼ - 返回 affectedRows + elapsed 给 LLM + ▼ + 返回 { status: "executed", affectedRows, elapsed, sql, connectionId, database } ``` --- @@ -183,25 +179,56 @@ async executeMutation( ### 5.1 新增 `state/workspace.ts` ```typescript -/** Execute a data mutation (INSERT/UPDATE/DELETE/REPLACE). */ -async executeMutation( - sql: string, - opts?: { connectionId?: string; database?: string }, -): Promise<{ - affectedRows: number; - elapsed: string; +/** 写操作确认请求:校验结果 + 解析后的目标。 */ +export interface MutationApprovalRequest { sql: string; + operation: "INSERT" | "UPDATE" | "DELETE" | "REPLACE"; + warning?: string; connectionId: string; database: string; -}> { +} + +/** 写操作结果:用户拒绝是正常结果(rejected),非异常。 */ +export type MutationOutcome = + | { status: "rejected"; sql: string } + | { + status: "executed"; + affectedRows: number; + elapsed: string; + sql: string; + connectionId: string; + database: string; + }; + +/** 唯一写入口——持有完整仪式:校验 → 人工确认 → 执行。 */ +async executeMutationWithApproval( + sql: string, + opts: { connectionId?: string; database?: string }, + confirm: (req: MutationApprovalRequest) => Promise, +): Promise { + const validation = prepareMutationQuery(sql); // DDL → 抛 MutationValidationError,不进入确认 const target = this.resolveTarget(opts); + + const approved = await confirm({ + sql: validation.sql, + operation: validation.operation, + warning: validation.warning, + connectionId: target.connectionId, + database: target.database, + }); + if (!approved) return { status: "rejected", sql: validation.sql }; + const result = await this.manager.executeMutation( target.connectionId, target.database, - sql, + validation.sql, ); - this._lastSql = sql; - return { ...result, connectionId: target.connectionId, database: target.database }; + return { + status: "executed", + ...result, + connectionId: target.connectionId, + database: target.database, + }; } ``` diff --git a/state/workspace.ts b/state/workspace.ts index 89fe382..263fcfd 100644 --- a/state/workspace.ts +++ b/state/workspace.ts @@ -15,6 +15,7 @@ import { type ResolvedConnectionConfig, } from "../connection/db-config"; import { DatabaseConnectionManager } from "../connection/db-manager"; +import { prepareMutationQuery } from "../connection/sql-policy"; import { QueryHistoryStore, FavoriteStore, @@ -41,6 +42,27 @@ export interface QueryTarget { database: string; } +/** 写操作确认请求:校验结果 + 解析后的目标,由 facade 合并后交给确认回调。 */ +export interface MutationApprovalRequest { + sql: string; + operation: "INSERT" | "UPDATE" | "DELETE" | "REPLACE"; + warning?: string; + connectionId: string; + database: string; +} + +/** 写操作结果:用户拒绝是正常结果(rejected),非异常。 */ +export type MutationOutcome = + | { status: "rejected"; sql: string } + | { + status: "executed"; + affectedRows: number; + elapsed: string; + sql: string; + connectionId: string; + database: string; + }; + // ====== 持久化辅助 ====== function loadWorkspace(filePath: string): WorkspaceState | null { @@ -351,25 +373,44 @@ export class DatabaseWorkspaceService { return { ...result, related }; } - // ── 变更 ─────────────────────────────────────────────────── + // ── 变更(唯一写入口)──────────────────────────────── /** - * 执行数据变更(INSERT/UPDATE/DELETE/REPLACE)。 - * 绕过只读守卫——调用方必须自行落实批准门。 + * 执行数据变更(INSERT/UPDATE/DELETE/REPLACE)——唯一写路径,持有完整仪式: + * 校验(DDL 直接抛 MutationValidationError,不进入确认)→ 人工确认 → + * 执行。确认回调由调用方注入(生产 = showMutationConfirm,测试 = stub), + * facade 保持 pi-free。用户拒绝是正常结果(status: "rejected"),非异常。 */ - async executeMutation( + async executeMutationWithApproval( sql: string, - opts?: { connectionId?: string; database?: string }, - ): Promise<{ - affectedRows: number; - elapsed: string; - sql: string; - connectionId: string; - database: string; - }> { + opts: { connectionId?: string; database?: string }, + confirm: (req: MutationApprovalRequest) => Promise, + ): Promise { + const validation = prepareMutationQuery(sql); const target = this.resolveTarget(opts); - const result = await this.manager.executeMutation(target.connectionId, target.database, sql); - return { ...result, connectionId: target.connectionId, database: target.database }; + + const approved = await confirm({ + sql: validation.sql, + operation: validation.operation, + warning: validation.warning, + connectionId: target.connectionId, + database: target.database, + }); + if (!approved) { + return { status: "rejected", sql: validation.sql }; + } + + const result = await this.manager.executeMutation( + target.connectionId, + target.database, + validation.sql, + ); + return { + status: "executed", + ...result, + connectionId: target.connectionId, + database: target.database, + }; } // ── 历史 ──────────────────────────────────────────────────── diff --git a/tools/db-tools.ts b/tools/db-tools.ts index c1a3e98..b3b3610 100644 --- a/tools/db-tools.ts +++ b/tools/db-tools.ts @@ -23,7 +23,7 @@ import { StringEnum } from "@earendil-works/pi-ai"; import type { DatabaseWorkspaceService } from "../state/workspace"; import { formatTableCompact } from "../formatting/result-table"; import { formatSchemaMarkdown } from "../formatting/schema-table"; -import { prepareMutationQuery } from "../connection/sql-policy"; +import { MutationValidationError } from "../connection/sql-policy"; import { showMutationConfirm } from "../commands/mutate-confirm"; import { LOADER_TOOL_NAME, LAZY_TOOL_INFO, matchDbTools } from "./db-tool-catalog"; export { applyInitialToolSet } from "./db-tool-catalog"; @@ -338,78 +338,53 @@ export function registerDbTools( ...targetParams, }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - // 1. 校验 SQL 是否为 DML 变更 - let validation: ReturnType; - try { - validation = prepareMutationQuery(params.sql); - } catch (err: any) { - return { - isError: true, - content: [{ type: "text", text: `SQL rejected: ${err.message}` }], - details: { error: err.message }, - }; - } - - // 2. 解析目标 const ws = getWorkspace(); - const target = ws.resolveTarget({ - connectionId: params.connection, - database: params.database, - }); - - // 3. 显示确认对话框 - const confirmed = await showMutationConfirm(ctx, { - sql: validation.sql, - operation: validation.operation, - warning: validation.warning, - connectionId: target.connectionId, - database: target.database, - }); + try { + const outcome = await ws.executeMutationWithApproval( + params.sql, + { + connectionId: params.connection, + database: params.database, + }, + (req) => showMutationConfirm(ctx, req), + ); - if (!confirmed) { - return { - content: [ - { - type: "text", - text: `Mutation rejected by user: ${validation.sql}`, - }, - ], - details: { rejected: true, sql: validation.sql }, - }; - } + // 用户拒绝是正常结果——非 isError,回显被拒语句。 + if (outcome.status === "rejected") { + return { + content: [{ type: "text", text: `Mutation rejected by user: ${outcome.sql}` }], + details: { rejected: true, sql: outcome.sql }, + }; + } - // 4. 执行 - try { - const result = await ws.executeMutation(validation.sql, { - connectionId: params.connection, - database: params.database, - }); return { content: [ { type: "text", text: [ `✅ Mutation executed successfully.`, - `Connection: ${result.connectionId}`, - `Database: ${result.database}`, - `SQL: ${result.sql}`, - `Affected rows: ${result.affectedRows} (${result.elapsed})`, + `Connection: ${outcome.connectionId}`, + `Database: ${outcome.database}`, + `SQL: ${outcome.sql}`, + `Affected rows: ${outcome.affectedRows} (${outcome.elapsed})`, ].join("\n"), }, ], details: { - sql: result.sql, - affectedRows: result.affectedRows, - elapsed: result.elapsed, - connection: result.connectionId, - database: result.database, + sql: outcome.sql, + affectedRows: outcome.affectedRows, + elapsed: outcome.elapsed, + connection: outcome.connectionId, + database: outcome.database, }, }; } catch (err: any) { + // 校验拒绝(DDL 等)与执行失败都从 facade 抛出,用错误类型区分措辞。 + const prefix = err instanceof MutationValidationError ? "SQL rejected" : "Mutation failed"; return { isError: true, - content: [{ type: "text", text: `Mutation failed: ${err.message}` }], - details: { sql: validation.sql, error: err.message }, + content: [{ type: "text", text: `${prefix}: ${err.message}` }], + details: { sql: params.sql, error: err.message }, }; } },