@sparkelf/dsh-dataops-managed 0.3.24 → 0.3.25
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/lib/client.js +501 -195
- package/lib/client.js.map +1 -1
- package/lib/index.js +567 -10
- package/lib/types/client/DataOpsResourcesTab.d.ts +26 -0
- package/lib/types/client/OpenResourceBridge.d.ts +34 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/collect-api.d.ts +99 -0
- package/lib/types/index.d.ts +8 -0
- package/lib/types/resource-tab.d.ts +63 -0
- package/lib/types/tool-manager.d.ts +3 -1
- package/lib/types/tool-scope.d.ts +13 -0
- package/lib/types/tools.d.ts +2 -1
- package/package.json +2 -1
- package/skills/dataops-collect/SKILL.md +177 -0
package/lib/index.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
4
4
|
import * as McpClient from "@sparkelf/dsh-plugin-mcp-credentials";
|
|
5
5
|
import { BUNDLED_SKILL_RANK } from "@deepseek-ai/dsh-skill";
|
|
6
6
|
import z from "@deepseek-ai/schemastery";
|
|
7
7
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
8
|
-
import { dirname, join } from "node:path";
|
|
8
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
//#region src/auth-state.ts
|
|
11
11
|
let state = "unknown";
|
|
@@ -811,11 +811,12 @@ var ReviewApi = class {
|
|
|
811
811
|
status: 503,
|
|
812
812
|
body: { error: "DataOps is not connected" }
|
|
813
813
|
};
|
|
814
|
+
const isMultipart = typeof FormData !== "undefined" && init.body instanceof FormData;
|
|
814
815
|
const response = await fetch(new URL(path, this.apiBase()), {
|
|
815
816
|
...init,
|
|
816
817
|
headers: {
|
|
817
818
|
authorization: `Bearer ${token}`,
|
|
818
|
-
...init.body === void 0 ? {} : { "content-type": "application/json" },
|
|
819
|
+
...init.body === void 0 || isMultipart ? {} : { "content-type": "application/json" },
|
|
819
820
|
...init.headers
|
|
820
821
|
}
|
|
821
822
|
});
|
|
@@ -870,7 +871,12 @@ const AI_TOOL_ACCESS = {
|
|
|
870
871
|
},
|
|
871
872
|
review_notifications_list: {},
|
|
872
873
|
review_notifications_read: {},
|
|
873
|
-
dataops_api_docs: { superAdminOnly: true }
|
|
874
|
+
dataops_api_docs: { superAdminOnly: true },
|
|
875
|
+
dataops_collect_preview: { permission: "data:resource:manage" },
|
|
876
|
+
dataops_collect_plan: { permission: "data:resource:manage" },
|
|
877
|
+
dataops_collect_execute: { permission: "data:resource:manage" },
|
|
878
|
+
dataops_collect_resources: { permission: "data:resource:read" },
|
|
879
|
+
dataops_open_resource_tab: {}
|
|
874
880
|
};
|
|
875
881
|
/** Every managed tool name, in the order the manager considers them. */
|
|
876
882
|
const AI_TOOL_NAMES = Object.keys(AI_TOOL_ACCESS);
|
|
@@ -978,6 +984,104 @@ function normalizeScope(body) {
|
|
|
978
984
|
};
|
|
979
985
|
}
|
|
980
986
|
//#endregion
|
|
987
|
+
//#region src/collect-api.ts
|
|
988
|
+
/**
|
|
989
|
+
* DataOps spreadsheet-import transport for the collection skill.
|
|
990
|
+
*
|
|
991
|
+
* The host reads a workspace attachment, uploads it as multipart, asks DataOps for a
|
|
992
|
+
* create-table or existing-table plan, and only executes when the model calls the
|
|
993
|
+
* execute tool after the user's plan confirmation. Nothing here creates a table on
|
|
994
|
+
* preview/plan; execution is the single mutating call.
|
|
995
|
+
*
|
|
996
|
+
* @module collect-api
|
|
997
|
+
*/
|
|
998
|
+
/** Largest spreadsheet the DataOps importer accepts. */
|
|
999
|
+
const COLLECT_FILE_MAX_BYTES = 20971520;
|
|
1000
|
+
/** Extensions the importer understands. */
|
|
1001
|
+
const SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
1002
|
+
".xlsx",
|
|
1003
|
+
".xls",
|
|
1004
|
+
".csv",
|
|
1005
|
+
".tsv"
|
|
1006
|
+
]);
|
|
1007
|
+
const statusError = (result, operation) => {
|
|
1008
|
+
const body = result.body;
|
|
1009
|
+
const reason = typeof body?.error === "string" ? body.error : typeof body?.message === "string" ? body.message : "";
|
|
1010
|
+
if (result.status === 401) return /* @__PURE__ */ new Error("Unauthorized (401): DataOps session expired");
|
|
1011
|
+
if (result.status === 403) return /* @__PURE__ */ new Error("Forbidden (403): " + (reason || "the current role may not import resources"));
|
|
1012
|
+
return /* @__PURE__ */ new Error(`${operation} failed: HTTP ${result.status}${reason ? ": " + reason : ""}`);
|
|
1013
|
+
};
|
|
1014
|
+
const unwrap = (result, operation) => {
|
|
1015
|
+
if (result.status < 200 || result.status >= 300) throw statusError(result, operation);
|
|
1016
|
+
return result.body;
|
|
1017
|
+
};
|
|
1018
|
+
/** Read and validate one spreadsheet attachment from the workspace. */
|
|
1019
|
+
const readCollectFile = async (filePath) => {
|
|
1020
|
+
const trimmed = filePath.trim();
|
|
1021
|
+
if (!trimmed) throw new Error("file_path is required");
|
|
1022
|
+
const extension = extname(trimmed).toLowerCase();
|
|
1023
|
+
if (!SUPPORTED_EXTENSIONS.has(extension)) throw new Error(`unsupported spreadsheet extension "${extension || "(none)"}"; expected .xlsx, .xls, .csv or .tsv`);
|
|
1024
|
+
const info = await stat(trimmed);
|
|
1025
|
+
if (!info.isFile()) throw new Error(`not a regular file: ${trimmed}`);
|
|
1026
|
+
if (info.size <= 0) throw new Error(`empty file: ${trimmed}`);
|
|
1027
|
+
if (info.size > 20971520) throw new Error(`file exceeds the ${(COLLECT_FILE_MAX_BYTES / 1024 / 1024).toFixed(0)}MB import limit`);
|
|
1028
|
+
return {
|
|
1029
|
+
buffer: await readFile(trimmed),
|
|
1030
|
+
fileName: basename(trimmed),
|
|
1031
|
+
sizeBytes: info.size
|
|
1032
|
+
};
|
|
1033
|
+
};
|
|
1034
|
+
/** Spreadsheet import calls bound to one workspace credential. */
|
|
1035
|
+
var CollectApi = class {
|
|
1036
|
+
reviewApi;
|
|
1037
|
+
constructor(reviewApi) {
|
|
1038
|
+
this.reviewApi = reviewApi;
|
|
1039
|
+
}
|
|
1040
|
+
async preview(filePath) {
|
|
1041
|
+
const file = await readCollectFile(filePath);
|
|
1042
|
+
const form = new FormData();
|
|
1043
|
+
form.append("file", new Blob([new Uint8Array(file.buffer)]), file.fileName);
|
|
1044
|
+
const result = await this.reviewApi.request("/api/resources/spreadsheet-import/preview", {
|
|
1045
|
+
method: "POST",
|
|
1046
|
+
body: form
|
|
1047
|
+
});
|
|
1048
|
+
return unwrap(result, "spreadsheet preview");
|
|
1049
|
+
}
|
|
1050
|
+
async dataSources() {
|
|
1051
|
+
const result = await this.reviewApi.request("/api/resources/spreadsheet-import/data-sources", { method: "GET" });
|
|
1052
|
+
const body = unwrap(result, "import data sources");
|
|
1053
|
+
return Array.isArray(body.dataSources) ? body.dataSources : [];
|
|
1054
|
+
}
|
|
1055
|
+
async plan(payload) {
|
|
1056
|
+
const result = await this.reviewApi.request("/api/resources/spreadsheet-import/plan", {
|
|
1057
|
+
method: "POST",
|
|
1058
|
+
body: JSON.stringify(payload)
|
|
1059
|
+
});
|
|
1060
|
+
return unwrap(result, "spreadsheet plan");
|
|
1061
|
+
}
|
|
1062
|
+
async execute(payload) {
|
|
1063
|
+
const result = await this.reviewApi.request("/api/resources/spreadsheet-import/execute", {
|
|
1064
|
+
method: "POST",
|
|
1065
|
+
body: JSON.stringify(payload)
|
|
1066
|
+
});
|
|
1067
|
+
return unwrap(result, "spreadsheet execute");
|
|
1068
|
+
}
|
|
1069
|
+
async listResources(query = "", limit = 20) {
|
|
1070
|
+
const result = await this.reviewApi.request("/api/resources", { method: "GET" });
|
|
1071
|
+
const body = unwrap(result, "resource list");
|
|
1072
|
+
const rows = Array.isArray(body.resources) ? body.resources : [];
|
|
1073
|
+
const keyword = query.trim().toLowerCase();
|
|
1074
|
+
return rows.filter((row) => row.resourceType === void 0 || row.resourceType === "TABLE").filter((row) => {
|
|
1075
|
+
if (!keyword) return true;
|
|
1076
|
+
return [
|
|
1077
|
+
row.chineseName,
|
|
1078
|
+
row.sourceTable,
|
|
1079
|
+
row.urn
|
|
1080
|
+
].filter(Boolean).join(" ").toLowerCase().includes(keyword);
|
|
1081
|
+
}).slice(0, Math.max(1, Math.min(50, limit)));
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
//#endregion
|
|
981
1085
|
//#region src/api-docs.ts
|
|
982
1086
|
/** Raised when the upstream body is not an OpenAPI document this module can search. */
|
|
983
1087
|
var ApiDocsUnavailableError = class extends Error {};
|
|
@@ -1235,7 +1339,8 @@ const forbidden = (message) => /* @__PURE__ */ new Error("Forbidden (403): " + m
|
|
|
1235
1339
|
* @param readScope - Live accessor for the manager's current scope.
|
|
1236
1340
|
* @returns One definition per managed tool name.
|
|
1237
1341
|
*/
|
|
1238
|
-
function buildAiManagementTools(ctx, reviewApi, readScope) {
|
|
1342
|
+
function buildAiManagementTools(ctx, reviewApi, readScope, collectHub) {
|
|
1343
|
+
const collectApi = new CollectApi(reviewApi);
|
|
1239
1344
|
const approve = async (tool, exec, reason) => {
|
|
1240
1345
|
if (exec.agent === void 0) throw new Error(tool + " requires an initiating agent");
|
|
1241
1346
|
const outcome = await ctx.approval.request({
|
|
@@ -1336,6 +1441,28 @@ function buildAiManagementTools(ctx, reviewApi, readScope) {
|
|
|
1336
1441
|
}
|
|
1337
1442
|
});
|
|
1338
1443
|
}
|
|
1444
|
+
const parseJsonObject = (tool, label, value) => {
|
|
1445
|
+
if (value === void 0 || value.trim() === "") return void 0;
|
|
1446
|
+
let parsed;
|
|
1447
|
+
try {
|
|
1448
|
+
parsed = JSON.parse(value);
|
|
1449
|
+
} catch {
|
|
1450
|
+
throw new Error(tool + ": " + label + " must be valid JSON");
|
|
1451
|
+
}
|
|
1452
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(tool + ": " + label + " must be a JSON object");
|
|
1453
|
+
return parsed;
|
|
1454
|
+
};
|
|
1455
|
+
const parseJsonArray = (tool, label, value) => {
|
|
1456
|
+
if (value === void 0 || value.trim() === "") return [];
|
|
1457
|
+
let parsed;
|
|
1458
|
+
try {
|
|
1459
|
+
parsed = JSON.parse(value);
|
|
1460
|
+
} catch {
|
|
1461
|
+
throw new Error(tool + ": " + label + " must be valid JSON");
|
|
1462
|
+
}
|
|
1463
|
+
if (!Array.isArray(parsed)) throw new Error(tool + ": " + label + " must be a JSON array");
|
|
1464
|
+
return parsed;
|
|
1465
|
+
};
|
|
1339
1466
|
return [
|
|
1340
1467
|
{
|
|
1341
1468
|
name: "skill_list",
|
|
@@ -1729,6 +1856,264 @@ function buildAiManagementTools(ctx, reviewApi, readScope) {
|
|
|
1729
1856
|
}
|
|
1730
1857
|
}
|
|
1731
1858
|
})
|
|
1859
|
+
},
|
|
1860
|
+
{
|
|
1861
|
+
name: "dataops_collect_preview",
|
|
1862
|
+
definition: defineTool({
|
|
1863
|
+
name: "dataops_collect_preview",
|
|
1864
|
+
description: "Parse one spreadsheet attachment from the workspace and return DataOps's preview plus the importable database destinations. Read-only: it does not create a table and does not write rows. Use this first in the data-collection skill, then ask the user to choose the destination when more than one is returned.",
|
|
1865
|
+
parameters: {
|
|
1866
|
+
file_path: {
|
|
1867
|
+
type: "string",
|
|
1868
|
+
required: true,
|
|
1869
|
+
description: "Absolute or workspace-relative path to one .xlsx/.xls/.csv/.tsv file."
|
|
1870
|
+
},
|
|
1871
|
+
model_id: {
|
|
1872
|
+
type: "number",
|
|
1873
|
+
description: "Optional shared AI model id for the later plan call."
|
|
1874
|
+
}
|
|
1875
|
+
},
|
|
1876
|
+
output: {
|
|
1877
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
1878
|
+
render: renderJson
|
|
1879
|
+
},
|
|
1880
|
+
execute: async (args, exec) => {
|
|
1881
|
+
const preview = await collectApi.preview(args.file_path);
|
|
1882
|
+
const dataSources = await collectApi.dataSources();
|
|
1883
|
+
audit("dataops_collect_preview", exec, {
|
|
1884
|
+
filePath: args.file_path,
|
|
1885
|
+
sessionId: preview.sessionId,
|
|
1886
|
+
dataSourceCount: dataSources.length
|
|
1887
|
+
});
|
|
1888
|
+
return {
|
|
1889
|
+
sessionId: preview.sessionId,
|
|
1890
|
+
preview,
|
|
1891
|
+
dataSources
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1894
|
+
})
|
|
1895
|
+
},
|
|
1896
|
+
{
|
|
1897
|
+
name: "dataops_collect_plan",
|
|
1898
|
+
definition: defineTool({
|
|
1899
|
+
name: "dataops_collect_plan",
|
|
1900
|
+
description: "Generate a create-table plan or an existing-table field mapping for a parsed spreadsheet session. Read-only: DataOps only plans, it does not execute DDL or write rows. Call this after the user has chosen a data source; pass root_schema + root_table only when appending to an existing table.",
|
|
1901
|
+
parameters: {
|
|
1902
|
+
session_id: {
|
|
1903
|
+
type: "string",
|
|
1904
|
+
required: true,
|
|
1905
|
+
description: "Session id returned by dataops_collect_preview."
|
|
1906
|
+
},
|
|
1907
|
+
data_source_urn: {
|
|
1908
|
+
type: "string",
|
|
1909
|
+
required: true,
|
|
1910
|
+
description: "Destination URN from the preview result."
|
|
1911
|
+
},
|
|
1912
|
+
target_schema: {
|
|
1913
|
+
type: "string",
|
|
1914
|
+
description: "Existing-table schema; required when root_table is set."
|
|
1915
|
+
},
|
|
1916
|
+
target_table: {
|
|
1917
|
+
type: "string",
|
|
1918
|
+
description: "Existing table name; omit to create a new table."
|
|
1919
|
+
},
|
|
1920
|
+
model_id: {
|
|
1921
|
+
type: "number",
|
|
1922
|
+
description: "Optional shared AI model id."
|
|
1923
|
+
}
|
|
1924
|
+
},
|
|
1925
|
+
output: {
|
|
1926
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
1927
|
+
render: renderJson
|
|
1928
|
+
},
|
|
1929
|
+
execute: async (args, exec) => {
|
|
1930
|
+
const plan = await collectApi.plan({
|
|
1931
|
+
sessionId: args.session_id,
|
|
1932
|
+
dataSourceUrn: args.data_source_urn,
|
|
1933
|
+
...args.target_table ? { targetTable: {
|
|
1934
|
+
schema: args.target_schema || "",
|
|
1935
|
+
table: args.target_table
|
|
1936
|
+
} } : {},
|
|
1937
|
+
...args.model_id === void 0 ? {} : { modelId: args.model_id }
|
|
1938
|
+
});
|
|
1939
|
+
audit("dataops_collect_plan", exec, {
|
|
1940
|
+
sessionId: args.session_id,
|
|
1941
|
+
dataSourceUrn: args.data_source_urn,
|
|
1942
|
+
targetTable: args.target_table ?? null
|
|
1943
|
+
});
|
|
1944
|
+
return plan;
|
|
1945
|
+
}
|
|
1946
|
+
})
|
|
1947
|
+
},
|
|
1948
|
+
{
|
|
1949
|
+
name: "dataops_collect_execute",
|
|
1950
|
+
definition: defineTool({
|
|
1951
|
+
name: "dataops_collect_execute",
|
|
1952
|
+
description: "Execute one confirmed spreadsheet import: create a table (or write to an existing table), insert rows, and register the DataOps resource. This is the ONLY collection tool that mutates data. Call it only after the user explicitly clicked the plan's \"开始导入\" action; never call it while the plan is still being edited.",
|
|
1953
|
+
parameters: {
|
|
1954
|
+
session_id: {
|
|
1955
|
+
type: "string",
|
|
1956
|
+
required: true,
|
|
1957
|
+
description: "Session id from dataops_collect_preview."
|
|
1958
|
+
},
|
|
1959
|
+
data_source_urn: {
|
|
1960
|
+
type: "string",
|
|
1961
|
+
required: true,
|
|
1962
|
+
description: "Destination URN."
|
|
1963
|
+
},
|
|
1964
|
+
mode: {
|
|
1965
|
+
type: "string",
|
|
1966
|
+
required: true,
|
|
1967
|
+
description: "CREATE_TABLE or EXISTING_TABLE."
|
|
1968
|
+
},
|
|
1969
|
+
target_schema: {
|
|
1970
|
+
type: "string",
|
|
1971
|
+
description: "Target schema for EXISTING_TABLE."
|
|
1972
|
+
},
|
|
1973
|
+
target_table: {
|
|
1974
|
+
type: "string",
|
|
1975
|
+
description: "Target table for EXISTING_TABLE."
|
|
1976
|
+
},
|
|
1977
|
+
create_plan_json: {
|
|
1978
|
+
type: "string",
|
|
1979
|
+
description: "JSON object returned as plan.createTable, edited by the user."
|
|
1980
|
+
},
|
|
1981
|
+
mappings_json: {
|
|
1982
|
+
type: "string",
|
|
1983
|
+
description: "JSON array of {sourceKey,targetColumn} for EXISTING_TABLE."
|
|
1984
|
+
},
|
|
1985
|
+
resource_name: {
|
|
1986
|
+
type: "string",
|
|
1987
|
+
description: "Resource display name drafted by the plan and confirmed by the user."
|
|
1988
|
+
},
|
|
1989
|
+
resource_description: {
|
|
1990
|
+
type: "string",
|
|
1991
|
+
description: "Optional resource description."
|
|
1992
|
+
},
|
|
1993
|
+
resource_tags: {
|
|
1994
|
+
type: "array",
|
|
1995
|
+
items: { type: "string" },
|
|
1996
|
+
description: "Optional resource tags."
|
|
1997
|
+
},
|
|
1998
|
+
ai_visible: {
|
|
1999
|
+
type: "boolean",
|
|
2000
|
+
description: "Whether AI may retrieve this resource. Defaults to true."
|
|
2001
|
+
},
|
|
2002
|
+
access_level: {
|
|
2003
|
+
type: "string",
|
|
2004
|
+
description: "PUBLIC, PROTECTED or PRIVATE. Defaults to PRIVATE."
|
|
2005
|
+
}
|
|
2006
|
+
},
|
|
2007
|
+
output: {
|
|
2008
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
2009
|
+
render: renderJson
|
|
2010
|
+
},
|
|
2011
|
+
execute: async (args, exec) => {
|
|
2012
|
+
if (args.mode !== "CREATE_TABLE" && args.mode !== "EXISTING_TABLE") throw new Error("dataops_collect_execute: mode must be CREATE_TABLE or EXISTING_TABLE");
|
|
2013
|
+
const createPlan = parseJsonObject("dataops_collect_execute", "create_plan_json", args.create_plan_json);
|
|
2014
|
+
const mappings = parseJsonArray("dataops_collect_execute", "mappings_json", args.mappings_json);
|
|
2015
|
+
if (args.mode === "CREATE_TABLE" && createPlan === void 0) throw new Error("dataops_collect_execute: create_plan_json is required for CREATE_TABLE");
|
|
2016
|
+
if (args.mode === "EXISTING_TABLE" && (!args.target_schema || !args.target_table)) throw new Error("dataops_collect_execute: target_schema and target_table are required for EXISTING_TABLE");
|
|
2017
|
+
const payload = {
|
|
2018
|
+
sessionId: args.session_id,
|
|
2019
|
+
dataSourceUrn: args.data_source_urn,
|
|
2020
|
+
mode: args.mode,
|
|
2021
|
+
mappings,
|
|
2022
|
+
...createPlan === void 0 ? {} : { createPlan },
|
|
2023
|
+
...args.target_schema && args.target_table ? { targetTable: {
|
|
2024
|
+
schema: args.target_schema,
|
|
2025
|
+
table: args.target_table
|
|
2026
|
+
} } : {},
|
|
2027
|
+
...args.resource_name === void 0 ? {} : { resourceName: args.resource_name },
|
|
2028
|
+
...args.resource_description === void 0 ? {} : { resourceDescription: args.resource_description },
|
|
2029
|
+
...args.resource_tags === void 0 ? {} : { resourceTags: args.resource_tags },
|
|
2030
|
+
...args.ai_visible === void 0 ? {} : { aiVisible: args.ai_visible },
|
|
2031
|
+
...args.access_level === void 0 ? {} : { accessLevel: args.access_level }
|
|
2032
|
+
};
|
|
2033
|
+
const result = await collectApi.execute(payload);
|
|
2034
|
+
const resource = result.resource;
|
|
2035
|
+
audit("dataops_collect_execute", exec, {
|
|
2036
|
+
sessionId: args.session_id,
|
|
2037
|
+
mode: args.mode,
|
|
2038
|
+
resourceId: resource?.id ?? null,
|
|
2039
|
+
importedRows: result.importedRows ?? null
|
|
2040
|
+
});
|
|
2041
|
+
return result;
|
|
2042
|
+
}
|
|
2043
|
+
})
|
|
2044
|
+
},
|
|
2045
|
+
{
|
|
2046
|
+
name: "dataops_collect_resources",
|
|
2047
|
+
definition: defineTool({
|
|
2048
|
+
name: "dataops_collect_resources",
|
|
2049
|
+
description: "Search the current DataOps resource catalog for an append target. Read-only and scoped by DataOps resource visibility. Use it when the user chooses \"追加到已有资源\" instead of a remote select control.",
|
|
2050
|
+
parameters: {
|
|
2051
|
+
query: {
|
|
2052
|
+
type: "string",
|
|
2053
|
+
description: "Resource name, table name or URN keyword."
|
|
2054
|
+
},
|
|
2055
|
+
limit: {
|
|
2056
|
+
type: "number",
|
|
2057
|
+
description: "Maximum rows (default 20, max 50)."
|
|
2058
|
+
}
|
|
2059
|
+
},
|
|
2060
|
+
output: {
|
|
2061
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
2062
|
+
render: renderJson
|
|
2063
|
+
},
|
|
2064
|
+
execute: async (args) => {
|
|
2065
|
+
return { resources: await collectApi.listResources(args.query ?? "", args.limit ?? 20) };
|
|
2066
|
+
}
|
|
2067
|
+
})
|
|
2068
|
+
},
|
|
2069
|
+
{
|
|
2070
|
+
name: "dataops_open_resource_tab",
|
|
2071
|
+
definition: defineTool({
|
|
2072
|
+
name: "dataops_open_resource_tab",
|
|
2073
|
+
description: "Open or focus the DataOps resource tab in the right sidebar. This only navigates the browser: it does not grant access or mutate data. Use mode=focus after an import succeeds, grant for 分配权限, detail for 查看详情, and audit for 查看操作记录.",
|
|
2074
|
+
parameters: {
|
|
2075
|
+
resource_id: {
|
|
2076
|
+
type: "number",
|
|
2077
|
+
required: true,
|
|
2078
|
+
description: "DataOps resource id."
|
|
2079
|
+
},
|
|
2080
|
+
resource_name: {
|
|
2081
|
+
type: "string",
|
|
2082
|
+
description: "Display name used as the tab subtitle."
|
|
2083
|
+
},
|
|
2084
|
+
mode: {
|
|
2085
|
+
type: "string",
|
|
2086
|
+
description: "focus | grant | detail | audit. Defaults to focus."
|
|
2087
|
+
}
|
|
2088
|
+
},
|
|
2089
|
+
output: {
|
|
2090
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
2091
|
+
render: renderJson
|
|
2092
|
+
},
|
|
2093
|
+
execute: async (args, exec) => {
|
|
2094
|
+
if (!Number.isSafeInteger(args.resource_id) || args.resource_id <= 0) throw new Error("dataops_open_resource_tab: resource_id must be a positive integer");
|
|
2095
|
+
const requested = args.mode ?? "focus";
|
|
2096
|
+
const mode = requested === "grant" || requested === "detail" || requested === "audit" ? requested : "focus";
|
|
2097
|
+
const payload = collectHub.broadcast({
|
|
2098
|
+
resourceId: args.resource_id,
|
|
2099
|
+
resourceName: args.resource_name ?? null,
|
|
2100
|
+
mode,
|
|
2101
|
+
nonce: Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
|
2102
|
+
});
|
|
2103
|
+
audit("dataops_open_resource_tab", exec, {
|
|
2104
|
+
resourceId: args.resource_id,
|
|
2105
|
+
mode,
|
|
2106
|
+
subscribers: collectHub.size()
|
|
2107
|
+
});
|
|
2108
|
+
return {
|
|
2109
|
+
opened: collectHub.size() > 0,
|
|
2110
|
+
mode,
|
|
2111
|
+
resourceId: args.resource_id,
|
|
2112
|
+
requestId: payload.requestId,
|
|
2113
|
+
subscribers: collectHub.size()
|
|
2114
|
+
};
|
|
2115
|
+
}
|
|
2116
|
+
})
|
|
1732
2117
|
}
|
|
1733
2118
|
];
|
|
1734
2119
|
}
|
|
@@ -1744,6 +2129,7 @@ function buildAiManagementTools(ctx, reviewApi, readScope) {
|
|
|
1744
2129
|
var AiToolManager = class {
|
|
1745
2130
|
ctx;
|
|
1746
2131
|
reviewApi;
|
|
2132
|
+
collectHub;
|
|
1747
2133
|
definitions;
|
|
1748
2134
|
registrations = /* @__PURE__ */ new Map();
|
|
1749
2135
|
unsubscribeExpiry;
|
|
@@ -1752,10 +2138,11 @@ var AiToolManager = class {
|
|
|
1752
2138
|
revision = 0;
|
|
1753
2139
|
inflight;
|
|
1754
2140
|
disposed = false;
|
|
1755
|
-
constructor(ctx, reviewApi) {
|
|
2141
|
+
constructor(ctx, reviewApi, collectHub) {
|
|
1756
2142
|
this.ctx = ctx;
|
|
1757
2143
|
this.reviewApi = reviewApi;
|
|
1758
|
-
this.
|
|
2144
|
+
this.collectHub = collectHub;
|
|
2145
|
+
this.definitions = new Map(buildAiManagementTools(ctx, reviewApi, () => this.scope, this.collectHub).map((tool) => [tool.name, tool.definition]));
|
|
1759
2146
|
this.unsubscribeExpiry = onAuthStateChange((state) => {
|
|
1760
2147
|
if (state === "expired") this.clear();
|
|
1761
2148
|
});
|
|
@@ -1911,6 +2298,119 @@ var AiToolManager = class {
|
|
|
1911
2298
|
}
|
|
1912
2299
|
};
|
|
1913
2300
|
//#endregion
|
|
2301
|
+
//#region src/resource-tab.ts
|
|
2302
|
+
/** SSE route the client subscribes to. */
|
|
2303
|
+
const OPEN_RESOURCE_EVENTS_PATH = "/integrations/dataops/open-resource/events";
|
|
2304
|
+
/** Same-origin route the client uses to learn where the DataOps frontend lives. */
|
|
2305
|
+
const RESOURCE_TAB_CONFIG_PATH = "/integrations/dataops/resource-tab/config";
|
|
2306
|
+
/** Browser tab type registered by the client half. */
|
|
2307
|
+
const RESOURCE_TAB_TYPE = "dataops-resources";
|
|
2308
|
+
/**
|
|
2309
|
+
* Fan-out registry for the workspace's live SSE subscribers.
|
|
2310
|
+
*
|
|
2311
|
+
* A workspace has one browser in the normal case, but a reconnect can briefly leave the
|
|
2312
|
+
* previous response open. Broadcasting to the current set makes the new connection and the
|
|
2313
|
+
* stale one harmless: the browser keeps one tab, and the client dedupes by resource tab id.
|
|
2314
|
+
*/
|
|
2315
|
+
var OpenResourceHub = class {
|
|
2316
|
+
logger;
|
|
2317
|
+
clients = /* @__PURE__ */ new Set();
|
|
2318
|
+
heartbeat;
|
|
2319
|
+
constructor(logger) {
|
|
2320
|
+
this.logger = logger;
|
|
2321
|
+
}
|
|
2322
|
+
/** Accept one SSE subscription and own its response until the client disconnects. */
|
|
2323
|
+
subscribe(request, response) {
|
|
2324
|
+
response.writeHead(200, {
|
|
2325
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
2326
|
+
"cache-control": "no-cache, no-transform",
|
|
2327
|
+
connection: "keep-alive",
|
|
2328
|
+
"x-accel-buffering": "no"
|
|
2329
|
+
});
|
|
2330
|
+
response.write(": connected\n\n");
|
|
2331
|
+
this.clients.add(response);
|
|
2332
|
+
this.ensureHeartbeat();
|
|
2333
|
+
const remove = () => {
|
|
2334
|
+
this.clients.delete(response);
|
|
2335
|
+
if (this.clients.size === 0 && this.heartbeat !== void 0) {
|
|
2336
|
+
clearInterval(this.heartbeat);
|
|
2337
|
+
this.heartbeat = void 0;
|
|
2338
|
+
}
|
|
2339
|
+
};
|
|
2340
|
+
request.on("close", remove);
|
|
2341
|
+
request.on("aborted", remove);
|
|
2342
|
+
response.on("error", (error) => {
|
|
2343
|
+
this.logger.warn("dataops-managed: resource tab SSE response failed: " + String(error));
|
|
2344
|
+
remove();
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
/** Send one navigation event to every connected client. */
|
|
2348
|
+
broadcast(event) {
|
|
2349
|
+
const payload = {
|
|
2350
|
+
type: "open_resource",
|
|
2351
|
+
requestId: event.requestId ?? randomUUID(),
|
|
2352
|
+
resourceId: event.resourceId,
|
|
2353
|
+
resourceName: event.resourceName,
|
|
2354
|
+
mode: event.mode,
|
|
2355
|
+
nonce: event.nonce
|
|
2356
|
+
};
|
|
2357
|
+
const frame = "event: dataops-open-resource\ndata: " + JSON.stringify(payload) + "\n\n";
|
|
2358
|
+
for (const client of this.clients) try {
|
|
2359
|
+
client.write(frame);
|
|
2360
|
+
} catch (error) {
|
|
2361
|
+
this.clients.delete(client);
|
|
2362
|
+
this.logger.warn("dataops-managed: resource tab SSE write failed: " + String(error));
|
|
2363
|
+
}
|
|
2364
|
+
return payload;
|
|
2365
|
+
}
|
|
2366
|
+
/** Number of live subscribers, used by the tool result to explain a missing browser. */
|
|
2367
|
+
size() {
|
|
2368
|
+
return this.clients.size;
|
|
2369
|
+
}
|
|
2370
|
+
/** Close every subscriber; called by the plugin disposer. */
|
|
2371
|
+
dispose() {
|
|
2372
|
+
if (this.heartbeat !== void 0) clearInterval(this.heartbeat);
|
|
2373
|
+
this.heartbeat = void 0;
|
|
2374
|
+
for (const client of this.clients) try {
|
|
2375
|
+
client.end();
|
|
2376
|
+
} catch {}
|
|
2377
|
+
this.clients.clear();
|
|
2378
|
+
}
|
|
2379
|
+
ensureHeartbeat() {
|
|
2380
|
+
if (this.heartbeat !== void 0) return;
|
|
2381
|
+
this.heartbeat = setInterval(() => {
|
|
2382
|
+
for (const client of this.clients) try {
|
|
2383
|
+
client.write(": keep-alive\n\n");
|
|
2384
|
+
} catch {
|
|
2385
|
+
this.clients.delete(client);
|
|
2386
|
+
}
|
|
2387
|
+
}, 25e3);
|
|
2388
|
+
this.heartbeat.unref?.();
|
|
2389
|
+
}
|
|
2390
|
+
};
|
|
2391
|
+
/** Resolve the browser-facing DataOps origin for the iframe. */
|
|
2392
|
+
const resolveResourceTabConfig = (request, configuredWebBaseUrl) => {
|
|
2393
|
+
const header = (name) => {
|
|
2394
|
+
const value = request.headers[name];
|
|
2395
|
+
return Array.isArray(value) ? value[0] : value;
|
|
2396
|
+
};
|
|
2397
|
+
const forwardedProto = header("x-forwarded-proto")?.split(",")[0]?.trim();
|
|
2398
|
+
const host = header("x-forwarded-host")?.split(",")[0]?.trim() || header("host") || "";
|
|
2399
|
+
const requestOrigin = host ? `${forwardedProto || "http"}://${host}` : "";
|
|
2400
|
+
const embedBaseUrl = configuredWebBaseUrl?.trim() || requestOrigin || "http://127.0.0.1:8080";
|
|
2401
|
+
let sameOrigin = false;
|
|
2402
|
+
try {
|
|
2403
|
+
sameOrigin = new URL(embedBaseUrl).origin === requestOrigin;
|
|
2404
|
+
} catch {
|
|
2405
|
+
sameOrigin = false;
|
|
2406
|
+
}
|
|
2407
|
+
return {
|
|
2408
|
+
embedBaseUrl,
|
|
2409
|
+
sameOrigin,
|
|
2410
|
+
tabType: RESOURCE_TAB_TYPE
|
|
2411
|
+
};
|
|
2412
|
+
};
|
|
2413
|
+
//#endregion
|
|
1914
2414
|
//#region src/connector-state.ts
|
|
1915
2415
|
/**
|
|
1916
2416
|
* The DataOps connector's workspace-visible state and skill-center catalogue.
|
|
@@ -2461,8 +2961,7 @@ const inject = [
|
|
|
2461
2961
|
"tools",
|
|
2462
2962
|
"settings",
|
|
2463
2963
|
"skills",
|
|
2464
|
-
"approval"
|
|
2465
|
-
"agents"
|
|
2964
|
+
"approval"
|
|
2466
2965
|
];
|
|
2467
2966
|
const MANAGED_AUTH_PATH = "/integrations/dataops/managed-auth";
|
|
2468
2967
|
/** This plugin's profile entry id, which owns its Config and sync state. */
|
|
@@ -2545,6 +3044,7 @@ const MODEL_SYNC_DEFAULT = {
|
|
|
2545
3044
|
/** Schemastery parser for managed DataOps configuration. */
|
|
2546
3045
|
const Config = z.object({
|
|
2547
3046
|
baseUrl: z.string().default("http://host.docker.internal:3101"),
|
|
3047
|
+
webBaseUrl: z.string().default(""),
|
|
2548
3048
|
serverName: z.string().default("dataops"),
|
|
2549
3049
|
credentialRef: z.string().role("credential-ref").default("DATAOPS_ACCESS_TOKEN"),
|
|
2550
3050
|
/**
|
|
@@ -2645,6 +3145,43 @@ const querySkillProvider = {
|
|
|
2645
3145
|
};
|
|
2646
3146
|
}
|
|
2647
3147
|
};
|
|
3148
|
+
/** The skill this plugin contributes: turn one spreadsheet into a queryable resource. */
|
|
3149
|
+
const COLLECT_SKILL_NAME = "dataops-collect";
|
|
3150
|
+
/** Location of the collection skill document inside the published package. */
|
|
3151
|
+
const COLLECT_SKILL_FILE = new URL("../skills/dataops-collect/SKILL.md", import.meta.url);
|
|
3152
|
+
/**
|
|
3153
|
+
* Describe the collection skill to the catalog without reading it.
|
|
3154
|
+
*
|
|
3155
|
+
* The description carries the natural-language triggers so a request like "把这份表格建成
|
|
3156
|
+
* 能问数的资源" selects this skill before the model sees the body.
|
|
3157
|
+
*/
|
|
3158
|
+
const COLLECT_SKILL = {
|
|
3159
|
+
name: COLLECT_SKILL_NAME,
|
|
3160
|
+
description: "把表格 / Excel / CSV 建成可问数的数据资源,或把新表格追加到已有资源时使用。包含新建与追加的分岔、计划确认、建表写入、权限入口和结果汇报;确认计划前绝不建表。",
|
|
3161
|
+
invocation: {
|
|
3162
|
+
modelInvocable: true,
|
|
3163
|
+
userInvocable: true
|
|
3164
|
+
},
|
|
3165
|
+
provider: COLLECT_SKILL_NAME,
|
|
3166
|
+
source: "bundled",
|
|
3167
|
+
resourceBase: {
|
|
3168
|
+
kind: "directory",
|
|
3169
|
+
path: fileURLToPath(new URL("../", import.meta.url))
|
|
3170
|
+
},
|
|
3171
|
+
rank: BUNDLED_SKILL_RANK,
|
|
3172
|
+
locator: COLLECT_SKILL_FILE
|
|
3173
|
+
};
|
|
3174
|
+
/** Serve the collection skill to the catalog. */
|
|
3175
|
+
const collectSkillProvider = {
|
|
3176
|
+
name: COLLECT_SKILL_NAME,
|
|
3177
|
+
list: () => Promise.resolve([COLLECT_SKILL]),
|
|
3178
|
+
async get() {
|
|
3179
|
+
return {
|
|
3180
|
+
...COLLECT_SKILL,
|
|
3181
|
+
content: await readFile(COLLECT_SKILL_FILE, "utf8")
|
|
3182
|
+
};
|
|
3183
|
+
}
|
|
3184
|
+
};
|
|
2648
3185
|
/**
|
|
2649
3186
|
* Accept the current DataOps JWT and expose DataOps MCP tools through its credential.
|
|
2650
3187
|
* @param ctx - DSH Host context with credentials, Web routes, and tools.
|
|
@@ -2656,6 +3193,7 @@ async function apply(ctx, config) {
|
|
|
2656
3193
|
const accessRef = credentialRef(config.credentialRef);
|
|
2657
3194
|
let mcpFiber;
|
|
2658
3195
|
ctx.skills.registerProvider(() => querySkillProvider);
|
|
3196
|
+
ctx.skills.registerProvider(() => collectSkillProvider);
|
|
2659
3197
|
const ensureMcp = async () => {
|
|
2660
3198
|
if (mcpFiber !== void 0 && mcpFiber.uid !== null) return;
|
|
2661
3199
|
const fiber = ctx.plugin(McpClient, {
|
|
@@ -2775,6 +3313,25 @@ async function apply(ctx, config) {
|
|
|
2775
3313
|
credentialRef: config.credentialRef
|
|
2776
3314
|
});
|
|
2777
3315
|
reviewApiRef = reviewApi;
|
|
3316
|
+
const resourceTabHub = new OpenResourceHub(ctx.logger);
|
|
3317
|
+
ctx.effect(() => () => resourceTabHub.dispose(), "dataops-managed: resource tab SSE clients");
|
|
3318
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3319
|
+
kind: "exact",
|
|
3320
|
+
path: OPEN_RESOURCE_EVENTS_PATH,
|
|
3321
|
+
handler: (request, response) => resourceTabHub.subscribe(request, response)
|
|
3322
|
+
}), "dataops-managed: resource tab SSE route");
|
|
3323
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3324
|
+
kind: "exact",
|
|
3325
|
+
path: RESOURCE_TAB_CONFIG_PATH,
|
|
3326
|
+
handler: (request, response) => {
|
|
3327
|
+
const payload = resolveResourceTabConfig(request, config.webBaseUrl);
|
|
3328
|
+
response.writeHead(200, {
|
|
3329
|
+
"content-type": "application/json; charset=utf-8",
|
|
3330
|
+
"cache-control": "no-store"
|
|
3331
|
+
});
|
|
3332
|
+
response.end(JSON.stringify(payload));
|
|
3333
|
+
}
|
|
3334
|
+
}), "dataops-managed: resource tab config route");
|
|
2778
3335
|
/**
|
|
2779
3336
|
* The managed tool list follows the workspace's DataOps scope.
|
|
2780
3337
|
*
|
|
@@ -2784,7 +3341,7 @@ async function apply(ctx, config) {
|
|
|
2784
3341
|
* publishes this event after the write commits. The manager also observes the review-context
|
|
2785
3342
|
* route's answer, because a role edit does not change the JWT but does change the scope.
|
|
2786
3343
|
*/
|
|
2787
|
-
const aiTools = new AiToolManager(ctx, reviewApi);
|
|
3344
|
+
const aiTools = new AiToolManager(ctx, reviewApi, resourceTabHub);
|
|
2788
3345
|
ctx.effect(() => () => aiTools.dispose(), "dataops-managed: skill and review tool disclosure");
|
|
2789
3346
|
ctx.effect(() => ctx.on("credentials/reference-updated", (ref) => {
|
|
2790
3347
|
if (String(ref) !== config.credentialRef) return;
|