@sparkelf/dsh-dataops-managed 0.3.23 → 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 +443 -137
- package/lib/client.js.map +1 -1
- package/lib/index.js +619 -31
- 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.
|
|
@@ -1930,40 +2430,67 @@ const CONNECTORS_PATH = "/api/dsh-skill-center/connectors";
|
|
|
1930
2430
|
const DATAOPS_CONNECTOR_ID = "dataops-managed";
|
|
1931
2431
|
/** Resolve the persisted state file without depending on a global DSH path. */
|
|
1932
2432
|
const CONNECTOR_STATE_PATH = join(process.env.DSH_HOME?.trim() || "/workspace/.dataops/dsh", "dataops-managed", "connector-state.json");
|
|
1933
|
-
/**
|
|
2433
|
+
/**
|
|
2434
|
+
* Capability definitions before permission filtering.
|
|
2435
|
+
*
|
|
2436
|
+
* The set and its order follow `dataops/docs/dataops-connector-development-spec.md` §2.3,
|
|
2437
|
+
* whose credential `requiredFor` list is `approval.push`, `data.query`, and `admin.manage`.
|
|
2438
|
+
* Skill plaza and review queue are governed by `skill:*` permissions and stay out of the
|
|
2439
|
+
* connector's capability switches.
|
|
2440
|
+
*/
|
|
1934
2441
|
const CAPABILITIES = [
|
|
1935
|
-
{
|
|
1936
|
-
id: "skill.plaza",
|
|
1937
|
-
name: "技能广场",
|
|
1938
|
-
description: "读取、安装和管理当前账号可见的技能",
|
|
1939
|
-
allowed: (scope) => hasPermission(scope, "skill:read")
|
|
1940
|
-
},
|
|
1941
|
-
{
|
|
1942
|
-
id: "review.queue",
|
|
1943
|
-
name: "审核队列",
|
|
1944
|
-
description: "查看待审技能、扫描风险并给出审核结论",
|
|
1945
|
-
allowed: (scope) => canReview(scope)
|
|
1946
|
-
},
|
|
1947
2442
|
{
|
|
1948
2443
|
id: "data.query",
|
|
1949
2444
|
name: "数据查询",
|
|
1950
2445
|
description: "通过 DataOps 数据查询能力回答业务问题",
|
|
2446
|
+
defaultEnabled: true,
|
|
1951
2447
|
allowed: () => true
|
|
1952
2448
|
},
|
|
1953
2449
|
{
|
|
1954
|
-
id: "
|
|
1955
|
-
name: "
|
|
1956
|
-
description: "
|
|
1957
|
-
|
|
2450
|
+
id: "approval.push",
|
|
2451
|
+
name: "审批资源",
|
|
2452
|
+
description: "读取/办理万相中台的审批任务,并把状态回写",
|
|
2453
|
+
defaultEnabled: true,
|
|
2454
|
+
allowed: (scope) => canReview(scope)
|
|
2455
|
+
},
|
|
2456
|
+
{
|
|
2457
|
+
id: "admin.manage",
|
|
2458
|
+
name: "万相中台管理",
|
|
2459
|
+
description: "登录万相中台后台执行管理动作",
|
|
2460
|
+
defaultEnabled: true,
|
|
2461
|
+
allowed: (scope) => isAdmin(scope)
|
|
1958
2462
|
}
|
|
1959
2463
|
];
|
|
2464
|
+
/** The ids the catalogue accepts; any other persisted key is a legacy value. */
|
|
2465
|
+
const CAPABILITY_IDS = CAPABILITIES.map((capability) => capability.id);
|
|
2466
|
+
/**
|
|
2467
|
+
* Keep only boolean values for known capability ids.
|
|
2468
|
+
*
|
|
2469
|
+
* A state file written before the catalogue changed may still hold retired ids. Those keys are
|
|
2470
|
+
* ignored rather than treated as a corrupt file; a missing new key takes the capability's
|
|
2471
|
+
* `defaultEnabled`.
|
|
2472
|
+
* @param value - the `capabilities` member of a parsed state file.
|
|
2473
|
+
* @returns the readable toggles, keyed by current capability id.
|
|
2474
|
+
*/
|
|
2475
|
+
function migrateCapabilities(value) {
|
|
2476
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
|
|
2477
|
+
const stored = value;
|
|
2478
|
+
const capabilities = {};
|
|
2479
|
+
for (const id of CAPABILITY_IDS) if (typeof stored[id] === "boolean") capabilities[id] = stored[id];
|
|
2480
|
+
return capabilities;
|
|
2481
|
+
}
|
|
1960
2482
|
/** Read state; a missing or corrupt file is a safe disabled state. */
|
|
1961
2483
|
async function readConnectorState(path = CONNECTOR_STATE_PATH) {
|
|
1962
2484
|
try {
|
|
1963
2485
|
const payload = JSON.parse(await readFile(path, "utf8"));
|
|
2486
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return {
|
|
2487
|
+
enabled: false,
|
|
2488
|
+
capabilities: {}
|
|
2489
|
+
};
|
|
2490
|
+
const record = payload;
|
|
1964
2491
|
return {
|
|
1965
|
-
enabled:
|
|
1966
|
-
capabilities:
|
|
2492
|
+
enabled: record.enabled === true,
|
|
2493
|
+
capabilities: migrateCapabilities(record.capabilities)
|
|
1967
2494
|
};
|
|
1968
2495
|
} catch {
|
|
1969
2496
|
return {
|
|
@@ -1976,7 +2503,11 @@ async function readConnectorState(path = CONNECTOR_STATE_PATH) {
|
|
|
1976
2503
|
async function writeConnectorState(state, path = CONNECTOR_STATE_PATH) {
|
|
1977
2504
|
await mkdir(dirname(path), { recursive: true });
|
|
1978
2505
|
const temporary = path + ".tmp-" + process.pid.toString();
|
|
1979
|
-
|
|
2506
|
+
const payload = {
|
|
2507
|
+
enabled: state.enabled,
|
|
2508
|
+
capabilities: migrateCapabilities(state.capabilities)
|
|
2509
|
+
};
|
|
2510
|
+
await writeFile(temporary, JSON.stringify(payload, null, 2), "utf8");
|
|
1980
2511
|
await rename(temporary, path);
|
|
1981
2512
|
}
|
|
1982
2513
|
/** Build the connector row for one current scope. */
|
|
@@ -1985,7 +2516,7 @@ function buildConnectorView(scope, state) {
|
|
|
1985
2516
|
id: capability.id,
|
|
1986
2517
|
name: capability.name,
|
|
1987
2518
|
description: capability.description,
|
|
1988
|
-
enabled: state.capabilities[capability.id]
|
|
2519
|
+
enabled: state.capabilities[capability.id] ?? capability.defaultEnabled,
|
|
1989
2520
|
permitted: true
|
|
1990
2521
|
}));
|
|
1991
2522
|
return {
|
|
@@ -2430,8 +2961,7 @@ const inject = [
|
|
|
2430
2961
|
"tools",
|
|
2431
2962
|
"settings",
|
|
2432
2963
|
"skills",
|
|
2433
|
-
"approval"
|
|
2434
|
-
"agents"
|
|
2964
|
+
"approval"
|
|
2435
2965
|
];
|
|
2436
2966
|
const MANAGED_AUTH_PATH = "/integrations/dataops/managed-auth";
|
|
2437
2967
|
/** This plugin's profile entry id, which owns its Config and sync state. */
|
|
@@ -2514,6 +3044,7 @@ const MODEL_SYNC_DEFAULT = {
|
|
|
2514
3044
|
/** Schemastery parser for managed DataOps configuration. */
|
|
2515
3045
|
const Config = z.object({
|
|
2516
3046
|
baseUrl: z.string().default("http://host.docker.internal:3101"),
|
|
3047
|
+
webBaseUrl: z.string().default(""),
|
|
2517
3048
|
serverName: z.string().default("dataops"),
|
|
2518
3049
|
credentialRef: z.string().role("credential-ref").default("DATAOPS_ACCESS_TOKEN"),
|
|
2519
3050
|
/**
|
|
@@ -2614,6 +3145,43 @@ const querySkillProvider = {
|
|
|
2614
3145
|
};
|
|
2615
3146
|
}
|
|
2616
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
|
+
};
|
|
2617
3185
|
/**
|
|
2618
3186
|
* Accept the current DataOps JWT and expose DataOps MCP tools through its credential.
|
|
2619
3187
|
* @param ctx - DSH Host context with credentials, Web routes, and tools.
|
|
@@ -2625,6 +3193,7 @@ async function apply(ctx, config) {
|
|
|
2625
3193
|
const accessRef = credentialRef(config.credentialRef);
|
|
2626
3194
|
let mcpFiber;
|
|
2627
3195
|
ctx.skills.registerProvider(() => querySkillProvider);
|
|
3196
|
+
ctx.skills.registerProvider(() => collectSkillProvider);
|
|
2628
3197
|
const ensureMcp = async () => {
|
|
2629
3198
|
if (mcpFiber !== void 0 && mcpFiber.uid !== null) return;
|
|
2630
3199
|
const fiber = ctx.plugin(McpClient, {
|
|
@@ -2744,6 +3313,25 @@ async function apply(ctx, config) {
|
|
|
2744
3313
|
credentialRef: config.credentialRef
|
|
2745
3314
|
});
|
|
2746
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");
|
|
2747
3335
|
/**
|
|
2748
3336
|
* The managed tool list follows the workspace's DataOps scope.
|
|
2749
3337
|
*
|
|
@@ -2753,7 +3341,7 @@ async function apply(ctx, config) {
|
|
|
2753
3341
|
* publishes this event after the write commits. The manager also observes the review-context
|
|
2754
3342
|
* route's answer, because a role edit does not change the JWT but does change the scope.
|
|
2755
3343
|
*/
|
|
2756
|
-
const aiTools = new AiToolManager(ctx, reviewApi);
|
|
3344
|
+
const aiTools = new AiToolManager(ctx, reviewApi, resourceTabHub);
|
|
2757
3345
|
ctx.effect(() => () => aiTools.dispose(), "dataops-managed: skill and review tool disclosure");
|
|
2758
3346
|
ctx.effect(() => ctx.on("credentials/reference-updated", (ref) => {
|
|
2759
3347
|
if (String(ref) !== config.credentialRef) return;
|