@sparkelf/dsh-dataops-managed 0.3.21 → 0.3.22
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/README.md +8 -0
- package/lib/client.js +170 -160
- package/lib/client.js.map +1 -1
- package/lib/index.js +1190 -407
- package/lib/types/api-docs.d.ts +80 -0
- package/lib/types/auth-state.d.ts +13 -0
- package/lib/types/client/ReloginPrompt.d.ts +3 -5
- package/lib/types/client/locales.d.ts +9 -9
- package/lib/types/connector-state.d.ts +51 -0
- package/lib/types/tool-manager.d.ts +81 -0
- package/lib/types/tool-output.d.ts +25 -0
- package/lib/types/tool-scope.d.ts +156 -0
- package/lib/types/tools.d.ts +27 -7
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -5,10 +5,34 @@ 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 { randomUUID } from "node:crypto";
|
|
9
8
|
import { dirname, join } from "node:path";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
10
|
//#region src/auth-state.ts
|
|
11
11
|
let state = "unknown";
|
|
12
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
13
|
+
/**
|
|
14
|
+
* Observe every recorded transition.
|
|
15
|
+
*
|
|
16
|
+
* The tool manager uses this to drop its disclosure as soon as any DataOps call reports a 401:
|
|
17
|
+
* the scope that produced the tool list can no longer be trusted, and waiting for another
|
|
18
|
+
* explicit sync would leave tools offered against an ended session. Listener failures are
|
|
19
|
+
* contained so a broken observer cannot turn a credential observation into a failed call.
|
|
20
|
+
* @param listener - Called with the new state after it is recorded.
|
|
21
|
+
* @returns The exact unsubscriber.
|
|
22
|
+
*/
|
|
23
|
+
const onAuthStateChange = (listener) => {
|
|
24
|
+
listeners.add(listener);
|
|
25
|
+
return () => {
|
|
26
|
+
listeners.delete(listener);
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/** Record one observation and notify observers. */
|
|
30
|
+
const setState = (next) => {
|
|
31
|
+
state = next;
|
|
32
|
+
for (const listener of listeners) try {
|
|
33
|
+
listener(next);
|
|
34
|
+
} catch {}
|
|
35
|
+
};
|
|
12
36
|
/**
|
|
13
37
|
* Record that a DataOps call was accepted.
|
|
14
38
|
*
|
|
@@ -16,11 +40,11 @@ let state = "unknown";
|
|
|
16
40
|
* says the session is fine and the permission is not, so it must not raise the sign-in prompt.
|
|
17
41
|
*/
|
|
18
42
|
const noteAccepted = () => {
|
|
19
|
-
|
|
43
|
+
setState("valid");
|
|
20
44
|
};
|
|
21
45
|
/** Record that DataOps refused the credential, which means the session is over. */
|
|
22
46
|
const noteExpired = () => {
|
|
23
|
-
|
|
47
|
+
setState("expired");
|
|
24
48
|
};
|
|
25
49
|
/**
|
|
26
50
|
* Read the current observation.
|
|
@@ -28,6 +52,12 @@ const noteExpired = () => {
|
|
|
28
52
|
* @returns `unknown` before any call has completed, then what the last one said.
|
|
29
53
|
*/
|
|
30
54
|
const currentAuthState = () => state;
|
|
55
|
+
/**
|
|
56
|
+
* Test-only reset, so one case cannot leak its observation into the next.
|
|
57
|
+
*/
|
|
58
|
+
const resetAuthState = () => {
|
|
59
|
+
setState("unknown");
|
|
60
|
+
};
|
|
31
61
|
//#endregion
|
|
32
62
|
//#region src/model-settings.ts
|
|
33
63
|
/** Profile entry id whose Config holds the model providers. */
|
|
@@ -811,63 +841,401 @@ var ReviewApi = class {
|
|
|
811
841
|
}
|
|
812
842
|
};
|
|
813
843
|
//#endregion
|
|
814
|
-
//#region src/
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
844
|
+
//#region src/tool-scope.ts
|
|
845
|
+
/**
|
|
846
|
+
* Tool-to-capability disclosure map.
|
|
847
|
+
*
|
|
848
|
+
* `review_notifications_list/read` intentionally carry no policy: every authenticated principal
|
|
849
|
+
* owns their own notification inbox. `dataops_api_docs` is gated by the role itself because the
|
|
850
|
+
* backend grants the OpenAPI reader to 超级管理员 by default; an administrator who happens to hold
|
|
851
|
+
* `developer:openapi:read` is still outside that role and does not receive the tool.
|
|
852
|
+
*/
|
|
853
|
+
const AI_TOOL_ACCESS = {
|
|
854
|
+
skill_list: { permission: "skill:read" },
|
|
855
|
+
skill_get: { permission: "skill:read" },
|
|
856
|
+
skill_submit: { permission: "skill:submit" },
|
|
857
|
+
skill_install: { permission: "skill:install" },
|
|
858
|
+
skill_uninstall: { permission: "skill:install" },
|
|
859
|
+
skill_enable: { permission: "skill:manage" },
|
|
860
|
+
skill_disable: { permission: "skill:manage" },
|
|
861
|
+
skill_unshare: { permission: "skill:manage" },
|
|
862
|
+
skill_delete: { permission: "skill:manage" },
|
|
863
|
+
review_scan: {
|
|
864
|
+
permission: "skill:read",
|
|
865
|
+
reviewer: true
|
|
866
|
+
},
|
|
867
|
+
review_decide: {
|
|
868
|
+
permission: "skill:review:decide",
|
|
869
|
+
reviewer: true
|
|
870
|
+
},
|
|
871
|
+
review_notifications_list: {},
|
|
872
|
+
review_notifications_read: {},
|
|
873
|
+
dataops_api_docs: { superAdminOnly: true }
|
|
818
874
|
};
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
875
|
+
/** Every managed tool name, in the order the manager considers them. */
|
|
876
|
+
const AI_TOOL_NAMES = Object.keys(AI_TOOL_ACCESS);
|
|
877
|
+
/**
|
|
878
|
+
* Backend permission aliases.
|
|
879
|
+
*
|
|
880
|
+
* The DataOps role matrix stores the capability family as `skill:capability:*`; this task's
|
|
881
|
+
* disclosure contract names them `skill:*`. Accepting both keeps the plugin working against
|
|
882
|
+
* either spelling without asking the backend to rename a published key.
|
|
883
|
+
*/
|
|
884
|
+
const PERMISSION_ALIASES = {
|
|
885
|
+
"skill:read": ["skill:read", "skill:capability:read"],
|
|
886
|
+
"skill:submit": ["skill:submit", "skill:capability:submit"],
|
|
887
|
+
"skill:install": ["skill:install", "skill:capability:install"],
|
|
888
|
+
"skill:manage": ["skill:manage", "skill:capability:manage"],
|
|
889
|
+
"skill:review:decide": ["skill:review:decide"]
|
|
890
|
+
};
|
|
891
|
+
const SUPER_ADMIN = "超级管理员";
|
|
892
|
+
const ADMIN = "管理员";
|
|
893
|
+
const DEPARTMENT_ADMIN = "部门管理员";
|
|
894
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
895
|
+
const strings$1 = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : void 0;
|
|
823
896
|
const roleSet = (scope) => new Set(scope.roleCodes ?? []);
|
|
824
|
-
|
|
825
|
-
const
|
|
826
|
-
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
897
|
+
/** Whether the context carries the stable super-administrator role. */
|
|
898
|
+
const isSuperAdmin = (scope) => roleSet(scope).has(SUPER_ADMIN) || scope.primaryRole === SUPER_ADMIN || (scope.systemKeys ?? []).includes("SUPER_ADMIN");
|
|
899
|
+
/** Whether the context carries an administrator or super-administrator role. */
|
|
900
|
+
const isAdmin = (scope) => isSuperAdmin(scope) || roleSet(scope).has(ADMIN) || scope.primaryRole === ADMIN || (scope.systemKeys ?? []).includes("ADMIN");
|
|
901
|
+
/** Whether the context carries a department-administrator role. */
|
|
902
|
+
const isDepartmentAdmin = (scope) => roleSet(scope).has(DEPARTMENT_ADMIN) || scope.primaryRole === DEPARTMENT_ADMIN || (scope.systemKeys ?? []).includes("DEPT_ADMIN");
|
|
903
|
+
/**
|
|
904
|
+
* Whether the caller may run review operations.
|
|
905
|
+
*
|
|
906
|
+
* The current backend returns `canReview` at the top level; the nested `review` shape is
|
|
907
|
+
* accepted for older contexts. Role membership is a fallback, not an override.
|
|
908
|
+
*/
|
|
909
|
+
const canReview = (scope) => scope.canReview === true || scope.review?.canReview === true || isAdmin(scope) || isDepartmentAdmin(scope);
|
|
910
|
+
/** The effective permission list, or `undefined` for a context that predates permission keys. */
|
|
911
|
+
const permissionList = (scope) => scope.permissions ?? scope.permissionKeys;
|
|
831
912
|
/**
|
|
832
|
-
* Whether the caller holds one
|
|
913
|
+
* Whether the caller holds one capability.
|
|
833
914
|
*
|
|
834
|
-
*
|
|
835
|
-
*
|
|
836
|
-
* a denial, not a reason to fall back: an authenticated user with no keys must not inherit
|
|
837
|
-
* administrator powers from their role label alone.
|
|
915
|
+
* A context that carries a permission list is authoritative. Before the list existed, the
|
|
916
|
+
* four-role fallback kept the old behavior; it is only used when neither spelling is present.
|
|
838
917
|
*/
|
|
839
|
-
const
|
|
840
|
-
const permissions =
|
|
841
|
-
if (
|
|
918
|
+
const hasPermission = (scope, key) => {
|
|
919
|
+
const permissions = permissionList(scope);
|
|
920
|
+
if (permissions !== void 0) {
|
|
921
|
+
const set = new Set(permissions);
|
|
922
|
+
return (PERMISSION_ALIASES[key] ?? [key]).some((alias) => set.has(alias));
|
|
923
|
+
}
|
|
842
924
|
if (isAdmin(scope)) return true;
|
|
843
|
-
if (
|
|
925
|
+
if (isDepartmentAdmin(scope)) return key !== "skill:review:global";
|
|
926
|
+
if (roleSet(scope).size === 0) return false;
|
|
844
927
|
return key === "skill:read" || key === "skill:install" || key === "skill:submit";
|
|
845
928
|
};
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
if (
|
|
855
|
-
|
|
856
|
-
if (
|
|
857
|
-
return
|
|
929
|
+
/**
|
|
930
|
+
* Whether one tool should be registered for this scope.
|
|
931
|
+
*
|
|
932
|
+
* Every check is disclosure metadata, not an execution gate: a visible tool still lets DataOps
|
|
933
|
+
* answer with 403 when the underlying grant changed between a scope sync and the call.
|
|
934
|
+
*/
|
|
935
|
+
function isToolVisible(name, scope) {
|
|
936
|
+
const policy = AI_TOOL_ACCESS[name];
|
|
937
|
+
if (policy.superAdminOnly === true && !isSuperAdmin(scope)) return false;
|
|
938
|
+
if (policy.reviewer === true && !canReview(scope)) return false;
|
|
939
|
+
if (policy.permission !== void 0 && !hasPermission(scope, policy.permission)) return false;
|
|
940
|
+
return true;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Validate one `/api/ai/review/context` body.
|
|
944
|
+
*
|
|
945
|
+
* A body without `roleCodes` is not a usable scope: treating it as "no roles" would let the
|
|
946
|
+
* role-name fallback hand an ordinary capability set to an answer this plugin cannot interpret.
|
|
947
|
+
* @param body - Parsed JSON from the context endpoint.
|
|
948
|
+
* @returns The normalized scope, or undefined when the answer is not a context document.
|
|
949
|
+
*/
|
|
950
|
+
function normalizeScope(body) {
|
|
951
|
+
if (!isRecord$1(body)) return void 0;
|
|
952
|
+
const roleCodes = strings$1(body.roleCodes);
|
|
953
|
+
if (roleCodes === void 0) return void 0;
|
|
954
|
+
const roleSystemKeys = (Array.isArray(body.roles) ? body.roles : []).flatMap((entry) => {
|
|
955
|
+
if (!isRecord$1(entry)) return [];
|
|
956
|
+
return typeof entry.systemKey === "string" ? [entry.systemKey] : [];
|
|
957
|
+
});
|
|
958
|
+
const explicitSystemKeys = strings$1(body.systemKeys) ?? [];
|
|
959
|
+
const systemKeys = Array.from(/* @__PURE__ */ new Set([...explicitSystemKeys, ...roleSystemKeys]));
|
|
960
|
+
const permissions = strings$1(body.permissions) ?? (Array.isArray(body.permissionKeys) ? strings$1(body.permissionKeys) : void 0);
|
|
961
|
+
const review = isRecord$1(body.review) ? {
|
|
962
|
+
...typeof body.review.canReview === "boolean" ? { canReview: body.review.canReview } : {},
|
|
963
|
+
...typeof body.review.canReviewGlobal === "boolean" ? { canReviewGlobal: body.review.canReviewGlobal } : {},
|
|
964
|
+
...typeof body.review.canManagePermissions === "boolean" ? { canManagePermissions: body.review.canManagePermissions } : {}
|
|
965
|
+
} : void 0;
|
|
966
|
+
return {
|
|
967
|
+
...typeof body.userId === "string" ? { userId: body.userId } : {},
|
|
968
|
+
...typeof body.username === "string" ? { username: body.username } : {},
|
|
969
|
+
roleCodes,
|
|
970
|
+
...typeof body.primaryRole === "string" ? { primaryRole: body.primaryRole } : {},
|
|
971
|
+
...systemKeys.length === 0 ? {} : { systemKeys },
|
|
972
|
+
...typeof body.departmentCode === "string" || body.departmentCode === null ? { departmentCode: body.departmentCode } : {},
|
|
973
|
+
...permissions === void 0 ? {} : { permissions },
|
|
974
|
+
...isRecord$1(body.dataScope) ? { dataScope: body.dataScope } : {},
|
|
975
|
+
...review === void 0 ? {} : { review },
|
|
976
|
+
...typeof body.canReview === "boolean" ? { canReview: body.canReview } : {},
|
|
977
|
+
...typeof body.canReviewGlobal === "boolean" ? { canReviewGlobal: body.canReviewGlobal } : {}
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
//#endregion
|
|
981
|
+
//#region src/api-docs.ts
|
|
982
|
+
/** Raised when the upstream body is not an OpenAPI document this module can search. */
|
|
983
|
+
var ApiDocsUnavailableError = class extends Error {};
|
|
984
|
+
/** HTTP methods a path item may declare, in a stable presentation order. */
|
|
985
|
+
const HTTP_METHODS = [
|
|
986
|
+
"get",
|
|
987
|
+
"post",
|
|
988
|
+
"put",
|
|
989
|
+
"patch",
|
|
990
|
+
"delete",
|
|
991
|
+
"head",
|
|
992
|
+
"options",
|
|
993
|
+
"trace"
|
|
994
|
+
];
|
|
995
|
+
/** Cap on returned operations, so one broad query cannot flood a model turn. */
|
|
996
|
+
const MAX_RESULTS = 50;
|
|
997
|
+
/** Cap on projected schema properties. */
|
|
998
|
+
const MAX_PROPERTIES = 25;
|
|
999
|
+
/** Cap on projected parameters. */
|
|
1000
|
+
const MAX_PARAMETERS = 25;
|
|
1001
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1002
|
+
const strings = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : void 0;
|
|
1003
|
+
const normalized = (value) => (value ?? "").trim().toLowerCase();
|
|
1004
|
+
/** Split a query into whitespace-separated keywords; an empty query matches everything. */
|
|
1005
|
+
const keywords = (query) => normalized(query).split(/\s+/u).filter((token) => token !== "");
|
|
1006
|
+
/** Resolve one local JSON pointer such as `#/components/schemas/ReviewQuery`. */
|
|
1007
|
+
function resolvePointer(document, ref) {
|
|
1008
|
+
if (!ref.startsWith("#/")) return void 0;
|
|
1009
|
+
let current = document;
|
|
1010
|
+
for (const raw of ref.slice(2).split("/")) {
|
|
1011
|
+
if (!isRecord(current)) return void 0;
|
|
1012
|
+
const key = decodeURIComponent(raw).replace(/~1/gu, "/").replace(/~0/gu, "~");
|
|
1013
|
+
current = current[key];
|
|
1014
|
+
}
|
|
1015
|
+
return current;
|
|
1016
|
+
}
|
|
1017
|
+
/** Resolve one `$ref` object one level, leaving a non-reference value unchanged. */
|
|
1018
|
+
function dereference(value, document, depth = 0) {
|
|
1019
|
+
if (!isRecord(value) || typeof value.$ref !== "string" || depth >= 4) return value;
|
|
1020
|
+
return dereference(resolvePointer(document, value.$ref), document, depth + 1);
|
|
1021
|
+
}
|
|
1022
|
+
/** The final segment of a local reference, used as a stable type label. */
|
|
1023
|
+
function refName(ref) {
|
|
1024
|
+
const segment = ref.split("/").pop() ?? ref;
|
|
1025
|
+
return decodeURIComponent(segment).replace(/~1/gu, "/").replace(/~0/gu, "~");
|
|
1026
|
+
}
|
|
1027
|
+
/** A short type label for one schema, resolving one local reference chain when useful. */
|
|
1028
|
+
function schemaType(schema, document, depth = 0) {
|
|
1029
|
+
const resolved = dereference(schema, document, depth);
|
|
1030
|
+
if (!isRecord(resolved)) return "unknown";
|
|
1031
|
+
if (typeof resolved.type === "string") {
|
|
1032
|
+
if (resolved.type === "array" && isRecord(resolved.items)) return schemaType(resolved.items, document, depth + 1) + "[]";
|
|
1033
|
+
return resolved.type;
|
|
1034
|
+
}
|
|
1035
|
+
if (isRecord(resolved.properties)) return "object";
|
|
1036
|
+
if (resolved.items !== void 0) return "array";
|
|
1037
|
+
if (typeof resolved.$ref === "string") return refName(resolved.$ref);
|
|
1038
|
+
return "unknown";
|
|
1039
|
+
}
|
|
1040
|
+
/** Project one schema into a bounded summary without following cycles forever. */
|
|
1041
|
+
function summarizeSchema(schema, document, depth = 0) {
|
|
1042
|
+
if (!isRecord(schema)) return null;
|
|
1043
|
+
const ref = typeof schema.$ref === "string" ? refName(schema.$ref) : void 0;
|
|
1044
|
+
const resolved = dereference(schema, document, depth);
|
|
1045
|
+
if (!isRecord(resolved)) return ref === void 0 ? null : { ref };
|
|
1046
|
+
const required = strings(resolved.required) ?? [];
|
|
1047
|
+
const requiredSet = new Set(required);
|
|
1048
|
+
const properties = isRecord(resolved.properties) ? Object.entries(resolved.properties).slice(0, MAX_PROPERTIES).map(([name, property]) => ({
|
|
1049
|
+
name,
|
|
1050
|
+
type: schemaType(property, document, depth + 1),
|
|
1051
|
+
required: requiredSet.has(name)
|
|
1052
|
+
})) : void 0;
|
|
1053
|
+
const type = typeof resolved.type === "string" ? resolved.type : isRecord(resolved.properties) ? "object" : resolved.items === void 0 ? void 0 : "array";
|
|
1054
|
+
return {
|
|
1055
|
+
...type === void 0 ? {} : { type },
|
|
1056
|
+
...ref === void 0 ? {} : { ref },
|
|
1057
|
+
...required.length === 0 ? {} : { required },
|
|
1058
|
+
...properties === void 0 ? {} : { properties }
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
/** Merge path-level and operation-level parameters; the operation wins on name+location. */
|
|
1062
|
+
function summarizeParameters(pathItem, operation, document) {
|
|
1063
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1064
|
+
for (const source of [pathItem.parameters, operation.parameters]) {
|
|
1065
|
+
if (!Array.isArray(source)) continue;
|
|
1066
|
+
for (const raw of source) {
|
|
1067
|
+
const parameter = dereference(raw, document);
|
|
1068
|
+
if (!isRecord(parameter)) continue;
|
|
1069
|
+
if (typeof parameter.name !== "string" || typeof parameter.in !== "string") continue;
|
|
1070
|
+
const schema = parameter.schema !== void 0 ? parameter.schema : parameter;
|
|
1071
|
+
merged.set(parameter.in + ":" + parameter.name, {
|
|
1072
|
+
name: parameter.name,
|
|
1073
|
+
in: parameter.in,
|
|
1074
|
+
required: parameter.required === true,
|
|
1075
|
+
type: schemaType(schema, document)
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return [...merged.values()].slice(0, MAX_PARAMETERS);
|
|
1080
|
+
}
|
|
1081
|
+
/** Describe the request body's chosen media type and bounded schema fields. */
|
|
1082
|
+
function summarizeRequestBody(operation, document) {
|
|
1083
|
+
const body = dereference(operation.requestBody, document);
|
|
1084
|
+
if (!isRecord(body) || !isRecord(body.content)) return null;
|
|
1085
|
+
const contentTypes = Object.keys(body.content);
|
|
1086
|
+
if (contentTypes.length === 0) return null;
|
|
1087
|
+
const preferred = contentTypes.includes("application/json") ? "application/json" : contentTypes[0];
|
|
1088
|
+
const media = body.content[preferred];
|
|
1089
|
+
const schema = isRecord(media) ? summarizeSchema(media.schema, document) : null;
|
|
1090
|
+
return {
|
|
1091
|
+
required: body.required === true,
|
|
1092
|
+
contentTypes,
|
|
1093
|
+
schema
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
/** The security requirement list that applies to one operation. */
|
|
1097
|
+
function summarizeSecurity(operation, document) {
|
|
1098
|
+
return (Array.isArray(operation.security) ? operation.security : Array.isArray(document.security) ? document.security : []).filter(isRecord);
|
|
858
1099
|
}
|
|
1100
|
+
/** Read one operation into its compact projection. */
|
|
1101
|
+
function compactOperation(method, path, pathItem, operation, document) {
|
|
1102
|
+
return {
|
|
1103
|
+
method: method.toUpperCase(),
|
|
1104
|
+
path,
|
|
1105
|
+
operationId: typeof operation.operationId === "string" ? operation.operationId : null,
|
|
1106
|
+
summary: typeof operation.summary === "string" ? operation.summary : null,
|
|
1107
|
+
tags: strings(operation.tags) ?? [],
|
|
1108
|
+
parameters: summarizeParameters(pathItem, operation, document),
|
|
1109
|
+
requestBody: summarizeRequestBody(operation, document),
|
|
1110
|
+
security: summarizeSecurity(operation, document)
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
/** Whether one operation passes every supplied filter. */
|
|
1114
|
+
function matches(operation, filters, tokens) {
|
|
1115
|
+
if (filters.tag !== "" && !operation.tags.some((tag) => tag.toLowerCase() === filters.tag)) return false;
|
|
1116
|
+
if (filters.path !== "" && !operation.path.toLowerCase().includes(filters.path)) return false;
|
|
1117
|
+
if (filters.operationId !== "" && !(operation.operationId ?? "").toLowerCase().includes(filters.operationId)) return false;
|
|
1118
|
+
if (filters.method !== "" && operation.method !== filters.method) return false;
|
|
1119
|
+
if (tokens.length === 0) return true;
|
|
1120
|
+
const haystack = [
|
|
1121
|
+
operation.path,
|
|
1122
|
+
operation.operationId ?? "",
|
|
1123
|
+
operation.summary ?? "",
|
|
1124
|
+
operation.description,
|
|
1125
|
+
operation.tags.join(" ")
|
|
1126
|
+
].join(" ").toLowerCase();
|
|
1127
|
+
return tokens.every((token) => haystack.includes(token));
|
|
1128
|
+
}
|
|
1129
|
+
/** Relevance used only to order matches; equal scores keep the document order. */
|
|
1130
|
+
function scoreOf(operation, tokens) {
|
|
1131
|
+
if (tokens.length === 0) return 0;
|
|
1132
|
+
let score = 0;
|
|
1133
|
+
const operationId = (operation.operationId ?? "").toLowerCase();
|
|
1134
|
+
const path = operation.path.toLowerCase();
|
|
1135
|
+
const summary = (operation.summary ?? "").toLowerCase();
|
|
1136
|
+
const tags = operation.tags.map((tag) => tag.toLowerCase());
|
|
1137
|
+
const description = operation.description.toLowerCase();
|
|
1138
|
+
for (const token of tokens) {
|
|
1139
|
+
if (operationId === token) score += 5;
|
|
1140
|
+
else if (operationId.includes(token)) score += 3;
|
|
1141
|
+
if (path.includes(token)) score += 2;
|
|
1142
|
+
if (summary.includes(token)) score += 1;
|
|
1143
|
+
if (tags.some((tag) => tag.includes(token))) score += 1;
|
|
1144
|
+
if (description.includes(token)) score += 2;
|
|
1145
|
+
}
|
|
1146
|
+
return score;
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Search one parsed OpenAPI document.
|
|
1150
|
+
* @param document - Parsed `/api/openapi.json` body.
|
|
1151
|
+
* @param filters - Keyword, tag, path, operationId and method filters.
|
|
1152
|
+
* @returns Matching operations, bounded and ordered by relevance.
|
|
1153
|
+
* @throws ApiDocsUnavailableError when the document has no searchable `paths` object.
|
|
1154
|
+
*/
|
|
1155
|
+
function searchOpenApiDocument(document, filters = {}) {
|
|
1156
|
+
if (!isRecord(document)) throw new ApiDocsUnavailableError("DataOps API docs are unavailable: the response is not an OpenAPI document");
|
|
1157
|
+
if (!isRecord(document.paths)) throw new ApiDocsUnavailableError("DataOps API docs are unavailable: the document has no paths object");
|
|
1158
|
+
const wanted = {
|
|
1159
|
+
tag: normalized(filters.tag),
|
|
1160
|
+
path: normalized(filters.path),
|
|
1161
|
+
operationId: normalized(filters.operation_id),
|
|
1162
|
+
method: normalized(filters.method)
|
|
1163
|
+
};
|
|
1164
|
+
const tokens = keywords(filters.query);
|
|
1165
|
+
const matched = [];
|
|
1166
|
+
let index = 0;
|
|
1167
|
+
for (const [path, rawPathItem] of Object.entries(document.paths)) {
|
|
1168
|
+
const pathItem = dereference(rawPathItem, document);
|
|
1169
|
+
if (!isRecord(pathItem)) continue;
|
|
1170
|
+
for (const method of HTTP_METHODS) {
|
|
1171
|
+
const operation = pathItem[method];
|
|
1172
|
+
if (!isRecord(operation)) continue;
|
|
1173
|
+
const candidate = {
|
|
1174
|
+
method,
|
|
1175
|
+
path,
|
|
1176
|
+
operationId: typeof operation.operationId === "string" ? operation.operationId : null,
|
|
1177
|
+
summary: typeof operation.summary === "string" ? operation.summary : null,
|
|
1178
|
+
tags: strings(operation.tags) ?? [],
|
|
1179
|
+
description: typeof operation.description === "string" ? operation.description : "",
|
|
1180
|
+
pathItem,
|
|
1181
|
+
operation,
|
|
1182
|
+
index
|
|
1183
|
+
};
|
|
1184
|
+
index += 1;
|
|
1185
|
+
if (!matches(candidate, wanted, tokens)) continue;
|
|
1186
|
+
matched.push({
|
|
1187
|
+
operation: candidate,
|
|
1188
|
+
score: scoreOf(candidate, tokens)
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
matched.sort((left, right) => right.score - left.score || left.operation.index - right.operation.index);
|
|
1193
|
+
const results = matched.slice(0, MAX_RESULTS).map(({ operation }) => compactOperation(operation.method, operation.path, operation.pathItem, operation.operation, document));
|
|
1194
|
+
const info = isRecord(document.info) ? document.info : {};
|
|
1195
|
+
return {
|
|
1196
|
+
title: typeof info.title === "string" ? info.title : null,
|
|
1197
|
+
version: typeof info.version === "string" ? info.version : null,
|
|
1198
|
+
openapi: typeof document.openapi === "string" ? document.openapi : typeof document.swagger === "string" ? "swagger " + document.swagger : null,
|
|
1199
|
+
total: matched.length,
|
|
1200
|
+
returned: results.length,
|
|
1201
|
+
truncated: matched.length > results.length,
|
|
1202
|
+
results
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
//#endregion
|
|
1206
|
+
//#region src/tool-output.ts
|
|
1207
|
+
/** An open object schema: tools declare their canonical shape in code and docs. */
|
|
1208
|
+
const OPEN_OBJECT_SCHEMA = {
|
|
1209
|
+
type: "object",
|
|
1210
|
+
additionalProperties: true
|
|
1211
|
+
};
|
|
1212
|
+
/** Render one canonical value as pretty JSON text. */
|
|
1213
|
+
const renderJson = (_args, value) => [{
|
|
1214
|
+
type: "text",
|
|
1215
|
+
text: JSON.stringify(value, null, 2)
|
|
1216
|
+
}];
|
|
1217
|
+
//#endregion
|
|
1218
|
+
//#region src/tools.ts
|
|
859
1219
|
/** Parse one plaza response body into rows without trusting its shape. */
|
|
860
1220
|
function rowsOf(body) {
|
|
861
1221
|
if (Array.isArray(body)) return body.filter((row) => typeof row === "object" && row !== null);
|
|
862
1222
|
if (typeof body === "object" && body !== null && Array.isArray(body.skills)) return body.skills.filter((row) => typeof row === "object" && row !== null);
|
|
863
1223
|
return [];
|
|
864
1224
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
1225
|
+
const forbidden = (message) => /* @__PURE__ */ new Error("Forbidden (403): " + message);
|
|
1226
|
+
/**
|
|
1227
|
+
* Build every managed tool definition.
|
|
1228
|
+
*
|
|
1229
|
+
* The definitions are built once; `readScope` is a live getter so a tool that survives a role
|
|
1230
|
+
* change reports the scope the manager currently holds instead of the one it was registered
|
|
1231
|
+
* with. An undefined scope can only be observed by an in-flight call during disposal, where the
|
|
1232
|
+
* safe behavior is to show only the public catalogue.
|
|
1233
|
+
* @param ctx - Host context used for local skills, approval and audit.
|
|
1234
|
+
* @param reviewApi - Same-origin bridge holding the workspace JWT.
|
|
1235
|
+
* @param readScope - Live accessor for the manager's current scope.
|
|
1236
|
+
* @returns One definition per managed tool name.
|
|
1237
|
+
*/
|
|
1238
|
+
function buildAiManagementTools(ctx, reviewApi, readScope) {
|
|
871
1239
|
const approve = async (tool, exec, reason) => {
|
|
872
1240
|
if (exec.agent === void 0) throw new Error(tool + " requires an initiating agent");
|
|
873
1241
|
const outcome = await ctx.approval.request({
|
|
@@ -900,7 +1268,6 @@ function registerAiManagementTools(ctx, reviewApi) {
|
|
|
900
1268
|
return result.body;
|
|
901
1269
|
};
|
|
902
1270
|
const listSkills = async (scope, includeLocal) => {
|
|
903
|
-
requireSkillPermission(scope, "skill:read");
|
|
904
1271
|
const mine = rowsOf(await call("/api/ai/skill-plaza/skills?scope=mine"));
|
|
905
1272
|
const publicRows = rowsOf(await call("/api/ai/skill-plaza/skills"));
|
|
906
1273
|
const admin = isAdmin(scope) ? rowsOf(await call("/api/ai/skill-plaza/admin/skills")) : [];
|
|
@@ -927,389 +1294,714 @@ function registerAiManagementTools(ctx, reviewApi) {
|
|
|
927
1294
|
},
|
|
928
1295
|
canReview: canReview(scope),
|
|
929
1296
|
isAdmin: isAdmin(scope),
|
|
930
|
-
isDepartmentAdmin:
|
|
1297
|
+
isDepartmentAdmin: isDepartmentAdmin(scope),
|
|
931
1298
|
departmentCode: scope.departmentCode ?? null,
|
|
932
1299
|
skills: rows
|
|
933
1300
|
};
|
|
934
1301
|
};
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1302
|
+
/**
|
|
1303
|
+
* Build the enable/disable pair.
|
|
1304
|
+
*
|
|
1305
|
+
* The two tools share one body so their approval reason, audit detail and endpoint stay in
|
|
1306
|
+
* step; only the verb and the boolean they send differ.
|
|
1307
|
+
*/
|
|
1308
|
+
function defineSkillStatusTool(enabled) {
|
|
1309
|
+
const name = enabled ? "skill_enable" : "skill_disable";
|
|
1310
|
+
return defineTool({
|
|
1311
|
+
name,
|
|
1312
|
+
description: (enabled ? "Enable" : "Disable") + " one plaza skill for distribution. Administrator only. Requires user approval; audited.",
|
|
1313
|
+
parameters: { skill_id: {
|
|
1314
|
+
type: "number",
|
|
1315
|
+
required: true,
|
|
1316
|
+
description: "Plaza skill id."
|
|
1317
|
+
} },
|
|
1318
|
+
output: {
|
|
1319
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
1320
|
+
render: renderJson
|
|
1321
|
+
},
|
|
1322
|
+
execute: async (args, exec) => {
|
|
1323
|
+
await approve(name, exec, (enabled ? "Enable" : "Disable") + " skill " + args.skill_id.toString());
|
|
1324
|
+
await call("/api/ai/skill-plaza/admin/skills/" + args.skill_id.toString() + "/status", {
|
|
1325
|
+
method: "PATCH",
|
|
1326
|
+
body: JSON.stringify({ enabled })
|
|
1327
|
+
});
|
|
1328
|
+
audit(name, exec, {
|
|
1329
|
+
skillId: args.skill_id,
|
|
1330
|
+
enabled
|
|
1331
|
+
});
|
|
1332
|
+
return {
|
|
1333
|
+
skillId: args.skill_id,
|
|
1334
|
+
enabled
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
return [
|
|
1340
|
+
{
|
|
1341
|
+
name: "skill_list",
|
|
1342
|
+
definition: defineTool({
|
|
1343
|
+
name: "skill_list",
|
|
1344
|
+
description: "List skills in the DataOps skill centre: local workspace skills plus the caller's pending/rejected submissions and the approved plaza entries they may install. Scope follows the caller role (department admins see their department; ordinary users see their own and approved entries). Read-only.",
|
|
1345
|
+
parameters: { include_local: {
|
|
1346
|
+
type: "boolean",
|
|
1347
|
+
description: "Include local workspace skills (default true)."
|
|
1348
|
+
} },
|
|
1349
|
+
output: {
|
|
1350
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
1351
|
+
render: renderJson
|
|
1352
|
+
},
|
|
1353
|
+
execute: async (args) => {
|
|
1354
|
+
return await listSkills(readScope() ?? {}, args.include_local !== false);
|
|
1355
|
+
}
|
|
1356
|
+
})
|
|
945
1357
|
},
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1358
|
+
{
|
|
1359
|
+
name: "skill_get",
|
|
1360
|
+
definition: defineTool({
|
|
1361
|
+
name: "skill_get",
|
|
1362
|
+
description: "Read one skill-centre entry by id: description, instruction, review status, review note, risk report and visibility. Only entries visible to the caller can be read.",
|
|
1363
|
+
parameters: { skill_id: {
|
|
1364
|
+
type: "number",
|
|
1365
|
+
required: true,
|
|
1366
|
+
description: "Skill/plaza id from skill_list."
|
|
1367
|
+
} },
|
|
1368
|
+
output: {
|
|
1369
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
1370
|
+
render: renderJson
|
|
1371
|
+
},
|
|
1372
|
+
execute: async (args) => {
|
|
1373
|
+
const found = (await listSkills(readScope() ?? {}, false)).skills.find((row) => row.id === args.skill_id);
|
|
1374
|
+
if (found === void 0) throw new Error("Skill not found or not visible: " + args.skill_id.toString());
|
|
1375
|
+
return found;
|
|
1376
|
+
}
|
|
1377
|
+
})
|
|
962
1378
|
},
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1379
|
+
{
|
|
1380
|
+
name: "skill_submit",
|
|
1381
|
+
definition: defineTool({
|
|
1382
|
+
name: "skill_submit",
|
|
1383
|
+
description: "Submit the caller's own skill for plaza review. When the workspace review switch is off DataOps approves immediately; otherwise the entry becomes pending. Requires user approval; audited.",
|
|
1384
|
+
parameters: { skill_id: {
|
|
1385
|
+
type: "number",
|
|
1386
|
+
required: true,
|
|
1387
|
+
description: "Own skill/plaza id."
|
|
1388
|
+
} },
|
|
1389
|
+
output: {
|
|
1390
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
1391
|
+
render: renderJson
|
|
1392
|
+
},
|
|
1393
|
+
execute: async (args, exec) => {
|
|
1394
|
+
await approve("skill_submit", exec, "Submit skill " + args.skill_id.toString() + " for review");
|
|
1395
|
+
const value = await call("/api/ai/skill-plaza/skills/" + args.skill_id.toString() + "/submit", {
|
|
1396
|
+
method: "POST",
|
|
1397
|
+
body: "{}"
|
|
1398
|
+
});
|
|
1399
|
+
audit("skill_submit", exec, { skillId: args.skill_id });
|
|
1400
|
+
return value;
|
|
1401
|
+
}
|
|
1402
|
+
})
|
|
981
1403
|
},
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1404
|
+
{
|
|
1405
|
+
name: "skill_install",
|
|
1406
|
+
definition: defineTool({
|
|
1407
|
+
name: "skill_install",
|
|
1408
|
+
description: "Install one approved, visible plaza skill into the caller's workspace. Requires user approval; audited. The secret-free result reports the local skill key.",
|
|
1409
|
+
parameters: { skill_id: {
|
|
1410
|
+
type: "number",
|
|
1411
|
+
required: true,
|
|
1412
|
+
description: "Plaza skill id."
|
|
1413
|
+
} },
|
|
1414
|
+
output: {
|
|
1415
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
1416
|
+
render: renderJson
|
|
1417
|
+
},
|
|
1418
|
+
execute: async (args, exec) => {
|
|
1419
|
+
await approve("skill_install", exec, "Install skill " + args.skill_id.toString());
|
|
1420
|
+
const value = await call("/api/ai/skill-plaza/install", {
|
|
1421
|
+
method: "POST",
|
|
1422
|
+
body: JSON.stringify({ skillId: args.skill_id })
|
|
1423
|
+
});
|
|
1424
|
+
audit("skill_install", exec, { skillId: args.skill_id });
|
|
1425
|
+
return value;
|
|
1426
|
+
}
|
|
1427
|
+
})
|
|
1005
1428
|
},
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1429
|
+
{
|
|
1430
|
+
name: "skill_uninstall",
|
|
1431
|
+
definition: defineTool({
|
|
1432
|
+
name: "skill_uninstall",
|
|
1433
|
+
description: "Uninstall one skill from the caller's own workspace only: deletes the current user's installation record. The plaza entry, its review status, visibility and enabled flag are unchanged and no “unpublished” notification is sent. Requires user approval; audited.",
|
|
1434
|
+
parameters: { skill_id: {
|
|
1435
|
+
type: "number",
|
|
1436
|
+
required: true,
|
|
1437
|
+
description: "Plaza skill id."
|
|
1438
|
+
} },
|
|
1439
|
+
output: {
|
|
1440
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
1441
|
+
render: renderJson
|
|
1442
|
+
},
|
|
1443
|
+
execute: async (args, exec) => {
|
|
1444
|
+
await approve("skill_uninstall", exec, "Uninstall skill " + args.skill_id.toString() + " from the current workspace");
|
|
1445
|
+
const result = await call("/api/ai/skill-plaza/skills/" + args.skill_id.toString() + "/install", { method: "DELETE" });
|
|
1446
|
+
audit("skill_uninstall", exec, {
|
|
1447
|
+
skillId: args.skill_id,
|
|
1448
|
+
scope: "installation-only",
|
|
1449
|
+
removed: result.removed ?? null
|
|
1450
|
+
});
|
|
1451
|
+
return {
|
|
1452
|
+
uninstalled: result.uninstalled === true,
|
|
1453
|
+
removed: typeof result.removed === "number" ? result.removed : 0
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
})
|
|
1029
1457
|
},
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
await approve("skill_uninstall", exec, "Uninstall skill " + args.skill_id.toString() + " from the current workspace");
|
|
1034
|
-
const result = await call("/api/ai/skill-plaza/skills/" + args.skill_id.toString() + "/install", { method: "DELETE" });
|
|
1035
|
-
audit("skill_uninstall", exec, {
|
|
1036
|
-
skillId: args.skill_id,
|
|
1037
|
-
scope: "installation-only",
|
|
1038
|
-
removed: result.removed ?? null
|
|
1039
|
-
});
|
|
1040
|
-
return {
|
|
1041
|
-
uninstalled: result.uninstalled === true,
|
|
1042
|
-
removed: typeof result.removed === "number" ? result.removed : 0
|
|
1043
|
-
};
|
|
1044
|
-
}
|
|
1045
|
-
}));
|
|
1046
|
-
const setEnabled = (enabled) => defineTool({
|
|
1047
|
-
name: enabled ? "skill_enable" : "skill_disable",
|
|
1048
|
-
description: (enabled ? "Enable" : "Disable") + " one plaza skill for distribution. Administrator only. Requires user approval; audited.",
|
|
1049
|
-
parameters: { skill_id: {
|
|
1050
|
-
type: "number",
|
|
1051
|
-
required: true,
|
|
1052
|
-
description: "Plaza skill id."
|
|
1053
|
-
} },
|
|
1054
|
-
output: {
|
|
1055
|
-
schema: open,
|
|
1056
|
-
render
|
|
1458
|
+
{
|
|
1459
|
+
name: "skill_enable",
|
|
1460
|
+
definition: defineSkillStatusTool(true)
|
|
1057
1461
|
},
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
await approve(enabled ? "skill_enable" : "skill_disable", exec, (enabled ? "Enable" : "Disable") + " skill " + args.skill_id.toString());
|
|
1062
|
-
await call("/api/ai/skill-plaza/admin/skills/" + args.skill_id.toString() + "/status", {
|
|
1063
|
-
method: "PATCH",
|
|
1064
|
-
body: JSON.stringify({ enabled })
|
|
1065
|
-
});
|
|
1066
|
-
audit(enabled ? "skill_enable" : "skill_disable", exec, {
|
|
1067
|
-
skillId: args.skill_id,
|
|
1068
|
-
enabled
|
|
1069
|
-
});
|
|
1070
|
-
return {
|
|
1071
|
-
skillId: args.skill_id,
|
|
1072
|
-
enabled
|
|
1073
|
-
};
|
|
1074
|
-
}
|
|
1075
|
-
});
|
|
1076
|
-
register(setEnabled(true));
|
|
1077
|
-
register(setEnabled(false));
|
|
1078
|
-
register(defineTool({
|
|
1079
|
-
name: "skill_unshare",
|
|
1080
|
-
description: "Stop sharing one plaza skill (soft unpublish): the entry and its installations remain, review status/visibility are kept, and DataOps sends an “unpublished” notification. Owners may unshare their own entries; 超级管理员/管理员 any entry; 部门管理员 their department. Requires user approval; audited.",
|
|
1081
|
-
parameters: { skill_id: {
|
|
1082
|
-
type: "number",
|
|
1083
|
-
required: true,
|
|
1084
|
-
description: "Plaza skill id."
|
|
1085
|
-
} },
|
|
1086
|
-
output: {
|
|
1087
|
-
schema: open,
|
|
1088
|
-
render
|
|
1462
|
+
{
|
|
1463
|
+
name: "skill_disable",
|
|
1464
|
+
definition: defineSkillStatusTool(false)
|
|
1089
1465
|
},
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
},
|
|
1118
|
-
confirm: {
|
|
1119
|
-
type: "boolean",
|
|
1120
|
-
required: true,
|
|
1121
|
-
description: "Must be true: explicitly confirms permanent deletion."
|
|
1122
|
-
}
|
|
1466
|
+
{
|
|
1467
|
+
name: "skill_unshare",
|
|
1468
|
+
definition: defineTool({
|
|
1469
|
+
name: "skill_unshare",
|
|
1470
|
+
description: "Stop sharing one plaza skill (soft unpublish): the entry and its installations remain, review status/visibility are kept, and DataOps sends an “unpublished” notification. Owners may unshare their own entries; 超级管理员/管理员 any entry; 部门管理员 their department. Requires user approval; audited.",
|
|
1471
|
+
parameters: { skill_id: {
|
|
1472
|
+
type: "number",
|
|
1473
|
+
required: true,
|
|
1474
|
+
description: "Plaza skill id."
|
|
1475
|
+
} },
|
|
1476
|
+
output: {
|
|
1477
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
1478
|
+
render: renderJson
|
|
1479
|
+
},
|
|
1480
|
+
execute: async (args, exec) => {
|
|
1481
|
+
await approve("skill_unshare", exec, "Unshare skill " + args.skill_id.toString());
|
|
1482
|
+
await call("/api/ai/skill-plaza/skills/" + args.skill_id.toString(), { method: "DELETE" });
|
|
1483
|
+
audit("skill_unshare", exec, {
|
|
1484
|
+
skillId: args.skill_id,
|
|
1485
|
+
effect: "soft-unpublish"
|
|
1486
|
+
});
|
|
1487
|
+
return {
|
|
1488
|
+
unshared: args.skill_id,
|
|
1489
|
+
effect: "plaza-sharing-disabled"
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
})
|
|
1123
1493
|
},
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1494
|
+
{
|
|
1495
|
+
name: "skill_delete",
|
|
1496
|
+
definition: defineTool({
|
|
1497
|
+
name: "skill_delete",
|
|
1498
|
+
description: "Permanently delete one plaza skill. This is not unshare: the record is removed, DataOps sends a “deleted” notification (not “unpublished”), and the audit entry records the permanent action. Requires confirm=true as a second confirmation; owners may delete their own entries, 超级管理员/管理员 any entry, 部门管理员 their department. If installations still exist the backend returns 409 and the error is passed through. Requires user approval; audited.",
|
|
1499
|
+
parameters: {
|
|
1500
|
+
skill_id: {
|
|
1501
|
+
type: "number",
|
|
1502
|
+
required: true,
|
|
1503
|
+
description: "Plaza skill id."
|
|
1504
|
+
},
|
|
1505
|
+
confirm: {
|
|
1506
|
+
type: "boolean",
|
|
1507
|
+
required: true,
|
|
1508
|
+
description: "Must be true: explicitly confirms permanent deletion."
|
|
1509
|
+
}
|
|
1510
|
+
},
|
|
1511
|
+
output: {
|
|
1512
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
1513
|
+
render: renderJson
|
|
1514
|
+
},
|
|
1515
|
+
execute: async (args, exec) => {
|
|
1516
|
+
if (args.confirm !== true) throw new Error("skill_delete requires confirm=true (permanent deletion)");
|
|
1517
|
+
await approve("skill_delete", exec, "Permanently delete skill " + args.skill_id.toString());
|
|
1518
|
+
await call("/api/ai/skill-plaza/skills/" + args.skill_id.toString() + "/permanent", { method: "DELETE" });
|
|
1519
|
+
audit("skill_delete", exec, {
|
|
1520
|
+
skillId: args.skill_id,
|
|
1521
|
+
permanent: true
|
|
1522
|
+
});
|
|
1523
|
+
return {
|
|
1524
|
+
deleted: args.skill_id,
|
|
1525
|
+
permanent: true
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
})
|
|
1127
1529
|
},
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
items: { type: "string" },
|
|
1160
|
-
description: "Workflow ids to scan."
|
|
1161
|
-
}
|
|
1530
|
+
{
|
|
1531
|
+
name: "review_scan",
|
|
1532
|
+
definition: defineTool({
|
|
1533
|
+
name: "review_scan",
|
|
1534
|
+
description: "Run the DataOps AI risk scan for skill and/or workflow ids. Reviewers only; the tool is disclosed to principals with review capability and skill read access, and DataOps still enforces the permission on every call. Read-only.",
|
|
1535
|
+
parameters: {
|
|
1536
|
+
skills: {
|
|
1537
|
+
type: "array",
|
|
1538
|
+
items: { type: "number" },
|
|
1539
|
+
description: "Skill ids to scan."
|
|
1540
|
+
},
|
|
1541
|
+
workflows: {
|
|
1542
|
+
type: "array",
|
|
1543
|
+
items: { type: "string" },
|
|
1544
|
+
description: "Workflow ids to scan."
|
|
1545
|
+
}
|
|
1546
|
+
},
|
|
1547
|
+
output: {
|
|
1548
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
1549
|
+
render: renderJson
|
|
1550
|
+
},
|
|
1551
|
+
execute: async (args) => {
|
|
1552
|
+
return await call("/api/ai/review/scan", {
|
|
1553
|
+
method: "POST",
|
|
1554
|
+
body: JSON.stringify({
|
|
1555
|
+
skills: args.skills ?? [],
|
|
1556
|
+
workflows: args.workflows ?? []
|
|
1557
|
+
})
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
})
|
|
1162
1561
|
},
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1562
|
+
{
|
|
1563
|
+
name: "review_decide",
|
|
1564
|
+
definition: defineTool({
|
|
1565
|
+
name: "review_decide",
|
|
1566
|
+
description: "Approve or reject skills/workflows. Requires authorization.confirmed=true (the administrator explicitly confirmed), a reviewer role, resource scope and the review-decide capability; DataOps enforces all of them on the call. Requires a second user approval at tool level and writes an audit entry.",
|
|
1567
|
+
parameters: {
|
|
1568
|
+
skills: {
|
|
1569
|
+
type: "array",
|
|
1570
|
+
items: { type: "number" },
|
|
1571
|
+
description: "Skill ids."
|
|
1572
|
+
},
|
|
1573
|
+
workflows: {
|
|
1574
|
+
type: "array",
|
|
1575
|
+
items: { type: "string" },
|
|
1576
|
+
description: "Workflow ids."
|
|
1577
|
+
},
|
|
1578
|
+
decision: {
|
|
1579
|
+
type: "string",
|
|
1580
|
+
required: true,
|
|
1581
|
+
description: "approve or reject."
|
|
1582
|
+
},
|
|
1583
|
+
note: {
|
|
1584
|
+
type: "string",
|
|
1585
|
+
description: "Review note (max 2000 chars)."
|
|
1586
|
+
},
|
|
1587
|
+
confirmed: {
|
|
1588
|
+
type: "boolean",
|
|
1589
|
+
required: true,
|
|
1590
|
+
description: "Must be true: the administrator confirmed this decision."
|
|
1591
|
+
}
|
|
1592
|
+
},
|
|
1593
|
+
output: {
|
|
1594
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
1595
|
+
render: renderJson
|
|
1596
|
+
},
|
|
1597
|
+
execute: async (args, exec) => {
|
|
1598
|
+
if (args.confirmed !== true) throw new Error("authorization.confirmed=true is required for review_decide");
|
|
1599
|
+
if (args.decision !== "approve" && args.decision !== "reject") throw new Error("decision must be approve or reject");
|
|
1600
|
+
if ((args.skills ?? []).length === 0 && (args.workflows ?? []).length === 0) throw new Error("at least one skill or workflow id is required");
|
|
1601
|
+
await approve("review_decide", exec, "Review " + args.decision + " for " + (args.skills ?? []).length.toString() + " skill(s) / " + (args.workflows ?? []).length.toString() + " workflow(s)");
|
|
1602
|
+
const value = await call("/api/ai/review/decide", {
|
|
1603
|
+
method: "POST",
|
|
1604
|
+
body: JSON.stringify({
|
|
1605
|
+
skills: args.skills ?? [],
|
|
1606
|
+
workflows: args.workflows ?? [],
|
|
1607
|
+
decision: args.decision,
|
|
1608
|
+
note: args.note ?? "",
|
|
1609
|
+
authorization: { confirmed: true }
|
|
1610
|
+
})
|
|
1611
|
+
});
|
|
1612
|
+
audit("review_decide", exec, {
|
|
1613
|
+
decision: args.decision,
|
|
1614
|
+
skills: args.skills ?? [],
|
|
1615
|
+
workflows: args.workflows ?? [],
|
|
1616
|
+
confirmed: true
|
|
1617
|
+
});
|
|
1618
|
+
return value;
|
|
1619
|
+
}
|
|
1620
|
+
})
|
|
1166
1621
|
},
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
}
|
|
1194
|
-
decision: {
|
|
1195
|
-
type: "string",
|
|
1196
|
-
required: true,
|
|
1197
|
-
description: "approve or reject."
|
|
1198
|
-
},
|
|
1199
|
-
note: {
|
|
1200
|
-
type: "string",
|
|
1201
|
-
description: "Review note (max 2000 chars)."
|
|
1202
|
-
},
|
|
1203
|
-
confirmed: {
|
|
1204
|
-
type: "boolean",
|
|
1205
|
-
required: true,
|
|
1206
|
-
description: "Must be true: the administrator confirmed this decision."
|
|
1207
|
-
}
|
|
1622
|
+
{
|
|
1623
|
+
name: "review_notifications_list",
|
|
1624
|
+
definition: defineTool({
|
|
1625
|
+
name: "review_notifications_list",
|
|
1626
|
+
description: "List the caller's review notifications and unread count. Read-only and always scoped to the current DataOps user.",
|
|
1627
|
+
parameters: {
|
|
1628
|
+
unread_only: {
|
|
1629
|
+
type: "boolean",
|
|
1630
|
+
description: "Only unread notifications."
|
|
1631
|
+
},
|
|
1632
|
+
limit: {
|
|
1633
|
+
type: "number",
|
|
1634
|
+
description: "Maximum rows (default 20)."
|
|
1635
|
+
}
|
|
1636
|
+
},
|
|
1637
|
+
output: {
|
|
1638
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
1639
|
+
render: renderJson
|
|
1640
|
+
},
|
|
1641
|
+
execute: async (args) => {
|
|
1642
|
+
const query = new URLSearchParams({
|
|
1643
|
+
unreadOnly: args.unread_only === true ? "true" : "false",
|
|
1644
|
+
limit: String(args.limit ?? 20)
|
|
1645
|
+
});
|
|
1646
|
+
return await call("/api/ai/review-notifications?" + query.toString());
|
|
1647
|
+
}
|
|
1648
|
+
})
|
|
1208
1649
|
},
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1650
|
+
{
|
|
1651
|
+
name: "review_notifications_read",
|
|
1652
|
+
definition: defineTool({
|
|
1653
|
+
name: "review_notifications_read",
|
|
1654
|
+
description: "Mark the caller's review notifications as read, either selected ids or all. Scoped to the current DataOps user; no secret is involved.",
|
|
1655
|
+
parameters: {
|
|
1656
|
+
ids: {
|
|
1657
|
+
type: "array",
|
|
1658
|
+
items: { type: "string" },
|
|
1659
|
+
description: "Notification ids to mark read."
|
|
1660
|
+
},
|
|
1661
|
+
all: {
|
|
1662
|
+
type: "boolean",
|
|
1663
|
+
description: "Mark every notification read."
|
|
1664
|
+
}
|
|
1665
|
+
},
|
|
1666
|
+
output: {
|
|
1667
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
1668
|
+
render: renderJson
|
|
1669
|
+
},
|
|
1670
|
+
execute: async (args, exec) => {
|
|
1671
|
+
const value = await call("/api/ai/review-notifications/read", {
|
|
1672
|
+
method: "POST",
|
|
1673
|
+
body: JSON.stringify({
|
|
1674
|
+
ids: args.ids ?? [],
|
|
1675
|
+
all: args.all === true
|
|
1676
|
+
})
|
|
1677
|
+
});
|
|
1678
|
+
audit("review_notifications_read", exec, {
|
|
1679
|
+
count: args.ids?.length ?? 0,
|
|
1680
|
+
all: args.all === true
|
|
1681
|
+
});
|
|
1682
|
+
return value;
|
|
1683
|
+
}
|
|
1684
|
+
})
|
|
1212
1685
|
},
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1686
|
+
{
|
|
1687
|
+
name: "dataops_api_docs",
|
|
1688
|
+
definition: defineTool({
|
|
1689
|
+
name: "dataops_api_docs",
|
|
1690
|
+
description: "Search the DataOps HTTP API reference (OpenAPI 3) for endpoint contracts. Super administrators only; read-only. Returns compact operations — method, path, operationId, summary, tags, parameters, request-body fields and security — filtered by keyword, tag, path, operationId or HTTP method. It only reads the fixed /api/openapi.json document and cannot execute arbitrary requests.",
|
|
1691
|
+
parameters: {
|
|
1692
|
+
query: {
|
|
1693
|
+
type: "string",
|
|
1694
|
+
description: "Case-insensitive keywords matched against path, operationId, summary, tags and description. All keywords must match."
|
|
1695
|
+
},
|
|
1696
|
+
tag: {
|
|
1697
|
+
type: "string",
|
|
1698
|
+
description: "Exact OpenAPI tag (case-insensitive)."
|
|
1699
|
+
},
|
|
1700
|
+
path: {
|
|
1701
|
+
type: "string",
|
|
1702
|
+
description: "Case-insensitive substring of the operation path."
|
|
1703
|
+
},
|
|
1704
|
+
operation_id: {
|
|
1705
|
+
type: "string",
|
|
1706
|
+
description: "Case-insensitive substring of the operationId."
|
|
1707
|
+
},
|
|
1708
|
+
method: {
|
|
1709
|
+
type: "string",
|
|
1710
|
+
description: "HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS or TRACE."
|
|
1711
|
+
}
|
|
1712
|
+
},
|
|
1713
|
+
output: {
|
|
1714
|
+
schema: OPEN_OBJECT_SCHEMA,
|
|
1715
|
+
render: renderJson
|
|
1716
|
+
},
|
|
1717
|
+
execute: async (args) => {
|
|
1718
|
+
const result = await reviewApi.request("/api/openapi.json");
|
|
1719
|
+
if (result.status === 401) throw new Error("Unauthorized (401): DataOps session expired; sign in again to read the API reference");
|
|
1720
|
+
if (result.status === 403) throw new Error("Forbidden (403): the DataOps OpenAPI document is not available to this role");
|
|
1721
|
+
if (result.status === 404) throw new Error("DataOps API docs are unavailable: /api/openapi.json is not configured (DATAOPS_OPENAPI_UNAVAILABLE)");
|
|
1722
|
+
if (result.status === 503) throw new Error("DataOps API docs are unavailable: this workspace has no DataOps session");
|
|
1723
|
+
if (result.status < 200 || result.status >= 300) throw new Error("DataOps API docs request failed: HTTP " + result.status.toString());
|
|
1724
|
+
try {
|
|
1725
|
+
return searchOpenApiDocument(result.body, args);
|
|
1726
|
+
} catch (error) {
|
|
1727
|
+
if (error instanceof ApiDocsUnavailableError) throw new Error(error.message);
|
|
1728
|
+
throw error;
|
|
1729
|
+
}
|
|
1227
1730
|
}
|
|
1228
|
-
}
|
|
1229
|
-
await approve("review_decide", exec, "Review " + args.decision + " for " + (args.skills ?? []).length.toString() + " skill(s) / " + (args.workflows ?? []).length.toString() + " workflow(s)");
|
|
1230
|
-
const value = await call("/api/ai/review/decide", {
|
|
1231
|
-
method: "POST",
|
|
1232
|
-
body: JSON.stringify({
|
|
1233
|
-
skills: args.skills ?? [],
|
|
1234
|
-
workflows: args.workflows ?? [],
|
|
1235
|
-
decision: args.decision,
|
|
1236
|
-
note: args.note ?? "",
|
|
1237
|
-
authorization: { confirmed: true }
|
|
1238
|
-
})
|
|
1239
|
-
});
|
|
1240
|
-
audit("review_decide", exec, {
|
|
1241
|
-
decision: args.decision,
|
|
1242
|
-
skills: args.skills ?? [],
|
|
1243
|
-
workflows: args.workflows ?? [],
|
|
1244
|
-
confirmed: true
|
|
1245
|
-
});
|
|
1246
|
-
return value;
|
|
1731
|
+
})
|
|
1247
1732
|
}
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1733
|
+
];
|
|
1734
|
+
}
|
|
1735
|
+
//#endregion
|
|
1736
|
+
//#region src/tool-manager.ts
|
|
1737
|
+
/**
|
|
1738
|
+
* Reconcile the managed tools with the current DataOps scope.
|
|
1739
|
+
*
|
|
1740
|
+
* Construct once per plugin activation. `refresh` is idempotent and coalesces concurrent calls;
|
|
1741
|
+
* the constructor also subscribes to the credential observation so an expired session removes
|
|
1742
|
+
* the tools without waiting for another explicit sync.
|
|
1743
|
+
*/
|
|
1744
|
+
var AiToolManager = class {
|
|
1745
|
+
ctx;
|
|
1746
|
+
reviewApi;
|
|
1747
|
+
definitions;
|
|
1748
|
+
registrations = /* @__PURE__ */ new Map();
|
|
1749
|
+
unsubscribeExpiry;
|
|
1750
|
+
disposeViewer;
|
|
1751
|
+
scope;
|
|
1752
|
+
revision = 0;
|
|
1753
|
+
inflight;
|
|
1754
|
+
disposed = false;
|
|
1755
|
+
constructor(ctx, reviewApi) {
|
|
1756
|
+
this.ctx = ctx;
|
|
1757
|
+
this.reviewApi = reviewApi;
|
|
1758
|
+
this.definitions = new Map(buildAiManagementTools(ctx, reviewApi, () => this.scope).map((tool) => [tool.name, tool.definition]));
|
|
1759
|
+
this.unsubscribeExpiry = onAuthStateChange((state) => {
|
|
1760
|
+
if (state === "expired") this.clear();
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
/** The scope the current registration set was derived from, when one has been read. */
|
|
1764
|
+
currentScope() {
|
|
1765
|
+
return this.scope;
|
|
1766
|
+
}
|
|
1767
|
+
/** The tool names currently registered, for diagnostics and tests. */
|
|
1768
|
+
registeredToolNames() {
|
|
1769
|
+
return AI_TOOL_NAMES.filter((name) => this.registrations.has(name));
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Re-read the workspace's DataOps scope and reconcile the tool list.
|
|
1773
|
+
*
|
|
1774
|
+
* Definitive denials clear the list; transient failures (network, 5xx) keep what the last good
|
|
1775
|
+
* observation produced. The method never throws: sync happens in the background of a browser
|
|
1776
|
+
* request and must not fail that request.
|
|
1777
|
+
* @param force - Start a new read even when one is already in flight. Credential adoption uses
|
|
1778
|
+
* this because an older read may have captured the previous token; its answer must not win
|
|
1779
|
+
* over the scope the new token produces.
|
|
1780
|
+
*/
|
|
1781
|
+
async refresh(force = false) {
|
|
1782
|
+
if (this.disposed) return;
|
|
1783
|
+
if (this.inflight !== void 0 && !force) return this.inflight;
|
|
1784
|
+
const revision = ++this.revision;
|
|
1785
|
+
const run = this.loadScope(revision).catch((error) => {
|
|
1786
|
+
this.ctx.logger.warn("dataops-managed: tool disclosure refresh failed");
|
|
1787
|
+
this.ctx.logger.warn(error);
|
|
1788
|
+
}).finally(() => {
|
|
1789
|
+
if (this.inflight === run) this.inflight = void 0;
|
|
1790
|
+
});
|
|
1791
|
+
this.inflight = run;
|
|
1792
|
+
return run;
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Apply a context answer already fetched by a same-origin route.
|
|
1796
|
+
*
|
|
1797
|
+
* The review-context route is how the UI learns about a role change, so its answer is also the
|
|
1798
|
+
* freshest scope this plugin will see. Reusing it avoids a second fetch on a page that polls.
|
|
1799
|
+
* @param result - The upstream status and parsed body.
|
|
1800
|
+
*/
|
|
1801
|
+
observeContext(result) {
|
|
1802
|
+
if (result.status === 200) {
|
|
1803
|
+
this.applyContext(result.body);
|
|
1804
|
+
return;
|
|
1273
1805
|
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1806
|
+
if (result.status === 401 || result.status === 403 || result.status === 404 || result.status === 503) this.clear();
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Apply one parsed context body without contacting DataOps.
|
|
1810
|
+
* @param body - Parsed `/api/ai/review/context` body.
|
|
1811
|
+
*/
|
|
1812
|
+
applyContext(body) {
|
|
1813
|
+
if (this.disposed) return;
|
|
1814
|
+
this.revision += 1;
|
|
1815
|
+
const scope = normalizeScope(body);
|
|
1816
|
+
this.applyScope(scope);
|
|
1817
|
+
}
|
|
1818
|
+
/** Drop every managed tool because no usable DataOps scope is known. */
|
|
1819
|
+
clear() {
|
|
1820
|
+
if (this.disposed) return;
|
|
1821
|
+
this.revision += 1;
|
|
1822
|
+
this.applyScope(void 0);
|
|
1823
|
+
}
|
|
1824
|
+
/** Dispose every tool this manager registered and stop observing auth state. */
|
|
1825
|
+
dispose() {
|
|
1826
|
+
if (this.disposed) return;
|
|
1827
|
+
this.disposed = true;
|
|
1828
|
+
this.revision += 1;
|
|
1829
|
+
this.unsubscribeExpiry();
|
|
1830
|
+
this.disposeRegistrations();
|
|
1831
|
+
this.disposeViewer?.();
|
|
1832
|
+
this.disposeViewer = void 0;
|
|
1833
|
+
this.scope = void 0;
|
|
1834
|
+
}
|
|
1835
|
+
async loadScope(revision) {
|
|
1836
|
+
let result;
|
|
1837
|
+
try {
|
|
1838
|
+
result = await this.reviewApi.request("/api/ai/review/context");
|
|
1839
|
+
} catch (error) {
|
|
1840
|
+
this.ctx.logger.warn("dataops-managed: tool disclosure could not read the DataOps scope");
|
|
1841
|
+
this.ctx.logger.warn(error);
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
if (this.disposed || revision !== this.revision) return;
|
|
1845
|
+
if (result.status === 200) {
|
|
1846
|
+
this.applyScope(normalizeScope(result.body));
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
if (result.status === 401 || result.status === 403 || result.status === 404 || result.status === 503) {
|
|
1850
|
+
this.applyScope(void 0);
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
this.ctx.logger.warn("dataops-managed: tool disclosure scope read failed with HTTP " + result.status.toString());
|
|
1854
|
+
}
|
|
1855
|
+
/** Store the scope and make the registry match it. */
|
|
1856
|
+
applyScope(scope) {
|
|
1857
|
+
this.scope = scope;
|
|
1858
|
+
if (this.disposed) return;
|
|
1859
|
+
this.applyViewer(scope);
|
|
1860
|
+
const desired = new Set(scope === void 0 ? [] : AI_TOOL_NAMES.filter((name) => isToolVisible(name, scope)));
|
|
1861
|
+
for (const [name, registration] of this.registrations) {
|
|
1862
|
+
if (desired.has(name)) continue;
|
|
1863
|
+
this.disposeRegistration(name, registration);
|
|
1864
|
+
}
|
|
1865
|
+
for (const name of AI_TOOL_NAMES) {
|
|
1866
|
+
if (!desired.has(name) || this.registrations.has(name)) continue;
|
|
1867
|
+
const definition = this.definitions.get(name);
|
|
1868
|
+
if (definition === void 0) continue;
|
|
1869
|
+
try {
|
|
1870
|
+
const dispose = this.ctx.tools.register(definition);
|
|
1871
|
+
this.registrations.set(name, { dispose });
|
|
1872
|
+
} catch (error) {
|
|
1873
|
+
this.ctx.logger.warn("dataops-managed: could not register tool " + name);
|
|
1874
|
+
this.ctx.logger.warn(error);
|
|
1287
1875
|
}
|
|
1288
|
-
},
|
|
1289
|
-
output: {
|
|
1290
|
-
schema: open,
|
|
1291
|
-
render
|
|
1292
|
-
},
|
|
1293
|
-
execute: async (args, exec) => {
|
|
1294
|
-
await loadScope(reviewApi);
|
|
1295
|
-
const value = await call("/api/ai/review-notifications/read", {
|
|
1296
|
-
method: "POST",
|
|
1297
|
-
body: JSON.stringify({
|
|
1298
|
-
ids: args.ids ?? [],
|
|
1299
|
-
all: args.all === true
|
|
1300
|
-
})
|
|
1301
|
-
});
|
|
1302
|
-
audit("review_notifications_read", exec, {
|
|
1303
|
-
count: args.ids?.length ?? 0,
|
|
1304
|
-
all: args.all === true
|
|
1305
|
-
});
|
|
1306
|
-
return value;
|
|
1307
1876
|
}
|
|
1877
|
+
}
|
|
1878
|
+
/** Map one DataOps scope to the skill registry's audience filter. */
|
|
1879
|
+
applyViewer(scope) {
|
|
1880
|
+
this.disposeViewer?.();
|
|
1881
|
+
this.disposeViewer = void 0;
|
|
1882
|
+
const viewer = scope === void 0 ? {
|
|
1883
|
+
role: "user",
|
|
1884
|
+
permissions: []
|
|
1885
|
+
} : {
|
|
1886
|
+
role: isSuperAdmin(scope) ? "super-admin" : isAdmin(scope) || isDepartmentAdmin(scope) ? "admin" : "user",
|
|
1887
|
+
permissions: [...scope.permissions ?? scope.permissionKeys ?? []]
|
|
1888
|
+
};
|
|
1889
|
+
try {
|
|
1890
|
+
const skills = this.ctx.skills;
|
|
1891
|
+
if (typeof skills.useViewer !== "function") return;
|
|
1892
|
+
this.disposeViewer = skills.useViewer(viewer);
|
|
1893
|
+
} catch (error) {
|
|
1894
|
+
this.ctx.logger.warn("dataops-managed: could not apply the skill viewer");
|
|
1895
|
+
this.ctx.logger.warn(error);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
/** Dispose one registration, tolerating a disposer that has already run. */
|
|
1899
|
+
disposeRegistration(name, registration) {
|
|
1900
|
+
this.registrations.delete(name);
|
|
1901
|
+
try {
|
|
1902
|
+
registration.dispose();
|
|
1903
|
+
} catch (error) {
|
|
1904
|
+
this.ctx.logger.warn("dataops-managed: could not dispose tool " + name);
|
|
1905
|
+
this.ctx.logger.warn(error);
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
/** Dispose the whole current set. */
|
|
1909
|
+
disposeRegistrations() {
|
|
1910
|
+
for (const [name, registration] of [...this.registrations]) this.disposeRegistration(name, registration);
|
|
1911
|
+
}
|
|
1912
|
+
};
|
|
1913
|
+
//#endregion
|
|
1914
|
+
//#region src/connector-state.ts
|
|
1915
|
+
/**
|
|
1916
|
+
* The DataOps connector's workspace-visible state and skill-center catalogue.
|
|
1917
|
+
*
|
|
1918
|
+
* The skill center is skill-first, with connectors as its fourth tab. DataOps contributes one
|
|
1919
|
+
* connector whose capability rows are derived from the same scope that drives tool disclosure;
|
|
1920
|
+
* a capability the account cannot use is omitted rather than rendered as a disabled placeholder.
|
|
1921
|
+
*
|
|
1922
|
+
* This module owns only state and presentation. It never authorizes a tool call or a DataOps
|
|
1923
|
+
* request: the backend remains the final boundary.
|
|
1924
|
+
*
|
|
1925
|
+
* @module connector-state
|
|
1926
|
+
*/
|
|
1927
|
+
/** Same-origin route the skill center's connector tab reads and writes. */
|
|
1928
|
+
const CONNECTORS_PATH = "/api/dsh-skill-center/connectors";
|
|
1929
|
+
/** Connector id shown in the skill center. */
|
|
1930
|
+
const DATAOPS_CONNECTOR_ID = "dataops-managed";
|
|
1931
|
+
/** Resolve the persisted state file without depending on a global DSH path. */
|
|
1932
|
+
const CONNECTOR_STATE_PATH = join(process.env.DSH_HOME?.trim() || "/workspace/.dataops/dsh", "dataops-managed", "connector-state.json");
|
|
1933
|
+
/** Capability definitions before permission filtering. */
|
|
1934
|
+
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
|
+
{
|
|
1948
|
+
id: "data.query",
|
|
1949
|
+
name: "数据查询",
|
|
1950
|
+
description: "通过 DataOps 数据查询能力回答业务问题",
|
|
1951
|
+
allowed: () => true
|
|
1952
|
+
},
|
|
1953
|
+
{
|
|
1954
|
+
id: "api.docs",
|
|
1955
|
+
name: "管理接口文档",
|
|
1956
|
+
description: "仅超级管理员可查询 DataOps OpenAPI 接口文档",
|
|
1957
|
+
allowed: (scope) => isSuperAdmin(scope)
|
|
1958
|
+
}
|
|
1959
|
+
];
|
|
1960
|
+
/** Read state; a missing or corrupt file is a safe disabled state. */
|
|
1961
|
+
async function readConnectorState(path = CONNECTOR_STATE_PATH) {
|
|
1962
|
+
try {
|
|
1963
|
+
const payload = JSON.parse(await readFile(path, "utf8"));
|
|
1964
|
+
return {
|
|
1965
|
+
enabled: payload.enabled === true,
|
|
1966
|
+
capabilities: typeof payload.capabilities === "object" && payload.capabilities !== null ? { ...payload.capabilities } : {}
|
|
1967
|
+
};
|
|
1968
|
+
} catch {
|
|
1969
|
+
return {
|
|
1970
|
+
enabled: false,
|
|
1971
|
+
capabilities: {}
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
/** Persist state atomically. */
|
|
1976
|
+
async function writeConnectorState(state, path = CONNECTOR_STATE_PATH) {
|
|
1977
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1978
|
+
const temporary = path + ".tmp-" + process.pid.toString();
|
|
1979
|
+
await writeFile(temporary, JSON.stringify(state, null, 2), "utf8");
|
|
1980
|
+
await rename(temporary, path);
|
|
1981
|
+
}
|
|
1982
|
+
/** Build the connector row for one current scope. */
|
|
1983
|
+
function buildConnectorView(scope, state) {
|
|
1984
|
+
const capabilities = scope === void 0 ? [] : CAPABILITIES.filter((capability) => capability.allowed(scope)).map((capability) => ({
|
|
1985
|
+
id: capability.id,
|
|
1986
|
+
name: capability.name,
|
|
1987
|
+
description: capability.description,
|
|
1988
|
+
enabled: state.capabilities[capability.id] !== false,
|
|
1989
|
+
permitted: true
|
|
1308
1990
|
}));
|
|
1309
|
-
return
|
|
1310
|
-
|
|
1991
|
+
return {
|
|
1992
|
+
id: DATAOPS_CONNECTOR_ID,
|
|
1993
|
+
name: "万相AI中台连接器",
|
|
1994
|
+
vendor: "DataOps",
|
|
1995
|
+
description: "把万相AI中台的技能、审核和数据能力接入当前工作区。",
|
|
1996
|
+
enabled: state.enabled,
|
|
1997
|
+
connected: scope !== void 0,
|
|
1998
|
+
capabilities
|
|
1311
1999
|
};
|
|
1312
2000
|
}
|
|
2001
|
+
/** Whether one capability id is in the fixed catalogue. */
|
|
2002
|
+
function hasCapability(id) {
|
|
2003
|
+
return CAPABILITIES.some((capability) => capability.id === id);
|
|
2004
|
+
}
|
|
1313
2005
|
//#endregion
|
|
1314
2006
|
//#region src/workspace-limits.ts
|
|
1315
2007
|
/** Narrow a value to a plain object. */
|
|
@@ -1874,8 +2566,12 @@ function adoptRequestJwt(ctx, accessRef, request) {
|
|
|
1874
2566
|
const token = bearerToken(request);
|
|
1875
2567
|
if (token === null) return Promise.resolve();
|
|
1876
2568
|
return (async () => {
|
|
1877
|
-
if ((await ctx.credentials.resolve(accessRef))?.value === token)
|
|
2569
|
+
if ((await ctx.credentials.resolve(accessRef))?.value === token) {
|
|
2570
|
+
resetAuthState();
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
1878
2573
|
await ctx.credentials.set(accessRef, token);
|
|
2574
|
+
resetAuthState();
|
|
1879
2575
|
})().catch((error) => {
|
|
1880
2576
|
ctx.logger.warn("dataops-managed: request JWT could not be adopted");
|
|
1881
2577
|
ctx.logger.warn(error);
|
|
@@ -1964,7 +2660,11 @@ async function apply(ctx, config) {
|
|
|
1964
2660
|
* a failed connect is reported rather than thrown.
|
|
1965
2661
|
*/
|
|
1966
2662
|
const connectExisting = async () => {
|
|
1967
|
-
if (await ctx.credentials.resolve(accessRef) === void 0)
|
|
2663
|
+
if (await ctx.credentials.resolve(accessRef) === void 0) {
|
|
2664
|
+
aiTools.clear();
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
aiTools.refresh();
|
|
1968
2668
|
try {
|
|
1969
2669
|
await ensureMcp();
|
|
1970
2670
|
} catch (error) {
|
|
@@ -2044,7 +2744,21 @@ async function apply(ctx, config) {
|
|
|
2044
2744
|
credentialRef: config.credentialRef
|
|
2045
2745
|
});
|
|
2046
2746
|
reviewApiRef = reviewApi;
|
|
2047
|
-
|
|
2747
|
+
/**
|
|
2748
|
+
* The managed tool list follows the workspace's DataOps scope.
|
|
2749
|
+
*
|
|
2750
|
+
* `AiToolManager` registers only the skill/review tools the current principal can use and
|
|
2751
|
+
* disposes them when the role changes. The credential event below is the existing auth-sync
|
|
2752
|
+
* path: JWT adoption and the managed-auth route both go through `credentials.set`, which
|
|
2753
|
+
* publishes this event after the write commits. The manager also observes the review-context
|
|
2754
|
+
* route's answer, because a role edit does not change the JWT but does change the scope.
|
|
2755
|
+
*/
|
|
2756
|
+
const aiTools = new AiToolManager(ctx, reviewApi);
|
|
2757
|
+
ctx.effect(() => () => aiTools.dispose(), "dataops-managed: skill and review tool disclosure");
|
|
2758
|
+
ctx.effect(() => ctx.on("credentials/reference-updated", (ref) => {
|
|
2759
|
+
if (String(ref) !== config.credentialRef) return;
|
|
2760
|
+
aiTools.refresh(true);
|
|
2761
|
+
}), "dataops-managed: tool disclosure follows the DataOps credential");
|
|
2048
2762
|
setWorkspaceLimits(new WorkspaceLimits(ctx, {
|
|
2049
2763
|
baseUrl,
|
|
2050
2764
|
credentialRef: config.credentialRef
|
|
@@ -2095,6 +2809,8 @@ async function apply(ctx, config) {
|
|
|
2095
2809
|
return;
|
|
2096
2810
|
}
|
|
2097
2811
|
await ctx.credentials.set(accessRef, token);
|
|
2812
|
+
resetAuthState();
|
|
2813
|
+
aiTools.refresh(true);
|
|
2098
2814
|
await ensureMcp();
|
|
2099
2815
|
await adoptNow();
|
|
2100
2816
|
response.writeHead(204, { "cache-control": "no-store" });
|
|
@@ -2330,6 +3046,7 @@ async function apply(ctx, config) {
|
|
|
2330
3046
|
}
|
|
2331
3047
|
try {
|
|
2332
3048
|
const result = await api.request("/api/ai/review/context");
|
|
3049
|
+
aiTools.observeContext(result);
|
|
2333
3050
|
writeJson(response, result.status, result.body);
|
|
2334
3051
|
} catch (error) {
|
|
2335
3052
|
ctx.logger.warn("dataops-managed: review context failed");
|
|
@@ -2766,9 +3483,75 @@ async function apply(ctx, config) {
|
|
|
2766
3483
|
response.end();
|
|
2767
3484
|
return;
|
|
2768
3485
|
}
|
|
3486
|
+
await adoptRequestJwt(ctx, accessRef, request);
|
|
2769
3487
|
writeJson(response, 200, { state: currentAuthState() });
|
|
2770
3488
|
}
|
|
2771
3489
|
}), "dataops-managed: auth state route");
|
|
3490
|
+
/**
|
|
3491
|
+
* The skill center's connector tab. The route is a same-origin presentation bridge: reads
|
|
3492
|
+
* return only capabilities the current scope can use, and writes persist the connector's own
|
|
3493
|
+
* enable state. It never substitutes for a DataOps authorization decision.
|
|
3494
|
+
*/
|
|
3495
|
+
ctx.effect(() => ctx.webServer.register({
|
|
3496
|
+
kind: "exact",
|
|
3497
|
+
path: CONNECTORS_PATH,
|
|
3498
|
+
handler: async (request, response) => {
|
|
3499
|
+
try {
|
|
3500
|
+
if (request.method === "GET") {
|
|
3501
|
+
if (aiTools.currentScope() === void 0) await aiTools.refresh();
|
|
3502
|
+
const state = await readConnectorState();
|
|
3503
|
+
writeJson(response, 200, { connectors: [buildConnectorView(aiTools.currentScope(), state)] });
|
|
3504
|
+
return;
|
|
3505
|
+
}
|
|
3506
|
+
if (request.method === "POST") {
|
|
3507
|
+
const body = await readJson(request);
|
|
3508
|
+
if (body === null) {
|
|
3509
|
+
writeJson(response, 400, { error: "connector state requires a JSON body" });
|
|
3510
|
+
return;
|
|
3511
|
+
}
|
|
3512
|
+
if (body.connectorId !== "dataops-managed") {
|
|
3513
|
+
writeJson(response, 404, { error: "unknown connector" });
|
|
3514
|
+
return;
|
|
3515
|
+
}
|
|
3516
|
+
const state = await readConnectorState();
|
|
3517
|
+
const capabilityId = typeof body.capabilityId === "string" ? body.capabilityId : void 0;
|
|
3518
|
+
if (capabilityId !== void 0) {
|
|
3519
|
+
if (!hasCapability(capabilityId)) {
|
|
3520
|
+
writeJson(response, 400, { error: "unknown capability" });
|
|
3521
|
+
return;
|
|
3522
|
+
}
|
|
3523
|
+
if (!buildConnectorView(aiTools.currentScope(), state).capabilities.some((row) => row.id === capabilityId)) {
|
|
3524
|
+
writeJson(response, 403, { error: "capability is not available to this account" });
|
|
3525
|
+
return;
|
|
3526
|
+
}
|
|
3527
|
+
if (typeof body.enabled !== "boolean") {
|
|
3528
|
+
writeJson(response, 400, { error: "capability state requires enabled:boolean" });
|
|
3529
|
+
return;
|
|
3530
|
+
}
|
|
3531
|
+
state.capabilities[capabilityId] = body.enabled;
|
|
3532
|
+
} else {
|
|
3533
|
+
if (typeof body.enabled !== "boolean") {
|
|
3534
|
+
writeJson(response, 400, { error: "connector state requires enabled:boolean" });
|
|
3535
|
+
return;
|
|
3536
|
+
}
|
|
3537
|
+
state.enabled = body.enabled;
|
|
3538
|
+
}
|
|
3539
|
+
await writeConnectorState(state);
|
|
3540
|
+
writeJson(response, 200, { connectors: [buildConnectorView(aiTools.currentScope(), state)] });
|
|
3541
|
+
return;
|
|
3542
|
+
}
|
|
3543
|
+
response.writeHead(405, {
|
|
3544
|
+
allow: "GET, POST",
|
|
3545
|
+
"content-length": "0"
|
|
3546
|
+
});
|
|
3547
|
+
response.end();
|
|
3548
|
+
} catch (error) {
|
|
3549
|
+
ctx.logger.warn("dataops-managed: connector catalogue route failed");
|
|
3550
|
+
ctx.logger.warn(error);
|
|
3551
|
+
writeJson(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
3554
|
+
}), "dataops-managed: skill-center connector catalogue");
|
|
2772
3555
|
await connectExisting();
|
|
2773
3556
|
}
|
|
2774
3557
|
/** Read and parse a JSON request body, tolerating an absent one. */
|