@sparkelf/dsh-dataops-managed 0.3.20 → 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.
Files changed (35) hide show
  1. package/README.md +8 -0
  2. package/lib/client.js +3996 -267
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +2355 -181
  5. package/lib/types/api-docs.d.ts +80 -0
  6. package/lib/types/auth-state.d.ts +50 -0
  7. package/lib/types/client/NotificationBell.d.ts +19 -0
  8. package/lib/types/client/PermissionCenterIcon.d.ts +9 -0
  9. package/lib/types/client/PermissionCenterSection.d.ts +15 -0
  10. package/lib/types/client/ReloginPrompt.d.ts +27 -0
  11. package/lib/types/client/ReviewSettingsCard.d.ts +12 -0
  12. package/lib/types/client/SkillPlazaSection.d.ts +3 -5
  13. package/lib/types/client/WanxiangBrand.d.ts +7 -1
  14. package/lib/types/client/WorkspaceUpgradePrompt.d.ts +21 -0
  15. package/lib/types/client/combobox.d.ts +17 -0
  16. package/lib/types/client/index.d.ts +17 -2
  17. package/lib/types/client/locales.d.ts +109 -6
  18. package/lib/types/client/review-client.d.ts +155 -0
  19. package/lib/types/client/review-demo.d.ts +13 -0
  20. package/lib/types/client/review-notification-store.d.ts +23 -0
  21. package/lib/types/client/review-types.d.ts +114 -0
  22. package/lib/types/client/settings-gate.d.ts +21 -0
  23. package/lib/types/client/skill-plaza-client.d.ts +21 -0
  24. package/lib/types/client/workspace-upgrade-client.d.ts +29 -0
  25. package/lib/types/connector-state.d.ts +51 -0
  26. package/lib/types/index.d.ts +36 -0
  27. package/lib/types/review-api.d.ts +32 -0
  28. package/lib/types/skill-plaza.d.ts +31 -0
  29. package/lib/types/tool-manager.d.ts +81 -0
  30. package/lib/types/tool-output.d.ts +25 -0
  31. package/lib/types/tool-scope.d.ts +156 -0
  32. package/lib/types/tools.d.ts +41 -0
  33. package/lib/types/workspace-upgrade-state.d.ts +66 -0
  34. package/lib/types/workspace-upgrade.d.ts +83 -0
  35. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -1,9 +1,64 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rename, rm, 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
+ import { defineTool } from "@deepseek-ai/dsh-tools";
8
+ import { dirname, join } from "node:path";
9
+ import { randomUUID } from "node:crypto";
10
+ //#region src/auth-state.ts
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
+ };
36
+ /**
37
+ * Record that a DataOps call was accepted.
38
+ *
39
+ * Called on any non-401 answer, including ones that carry a refusal for another reason: a 403
40
+ * says the session is fine and the permission is not, so it must not raise the sign-in prompt.
41
+ */
42
+ const noteAccepted = () => {
43
+ setState("valid");
44
+ };
45
+ /** Record that DataOps refused the credential, which means the session is over. */
46
+ const noteExpired = () => {
47
+ setState("expired");
48
+ };
49
+ /**
50
+ * Read the current observation.
51
+ *
52
+ * @returns `unknown` before any call has completed, then what the last one said.
53
+ */
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
+ };
61
+ //#endregion
7
62
  //#region src/model-settings.ts
8
63
  /** Profile entry id whose Config holds the model providers. */
9
64
  const MODEL_SETTINGS_NAMESPACE = "llm-pi-ai";
@@ -191,7 +246,11 @@ var ModelSync = class {
191
246
  method: "POST",
192
247
  headers: { authorization: `Bearer ${token}` }
193
248
  });
194
- if (response.status === 401) return void 0;
249
+ if (response.status === 401) {
250
+ noteExpired();
251
+ return;
252
+ }
253
+ noteAccepted();
195
254
  if (!response.ok) throw new Error(`Shared model read failed with HTTP ${String(response.status)}`);
196
255
  const body = asRecord$3(await response.json());
197
256
  if (body === null) return void 0;
@@ -258,7 +317,11 @@ var ModelSync = class {
258
317
  const token = await this.token();
259
318
  if (token === void 0) return null;
260
319
  const response = await fetch(new URL("/api/ai/shared-models/publication", this.apiBase()), { headers: { authorization: `Bearer ${token}` } });
261
- if (response.status === 401) return null;
320
+ if (response.status === 401) {
321
+ noteExpired();
322
+ return null;
323
+ }
324
+ noteAccepted();
262
325
  if (!response.ok) throw new Error(`Shared model status read failed with HTTP ${String(response.status)}`);
263
326
  const body = asRecord$3(await response.json());
264
327
  return {
@@ -605,7 +668,11 @@ var SkillPlaza = class {
605
668
  ...init.headers
606
669
  }
607
670
  });
608
- if (response.status === 401) return void 0;
671
+ if (response.status === 401) {
672
+ noteExpired();
673
+ return;
674
+ }
675
+ noteAccepted();
609
676
  if (!response.ok) throw new Error(`Skill plaza request failed with HTTP ${String(response.status)}`);
610
677
  return response.status === 204 ? null : await response.json();
611
678
  }
@@ -615,13 +682,30 @@ var SkillPlaza = class {
615
682
  * @returns Entries, newest first; empty when the workspace is not authorized.
616
683
  */
617
684
  async list(options = {}) {
685
+ return (await this.listPage(options)).skills;
686
+ }
687
+ /** List with the server's tab counts, which the plaza shows without re-counting a page. */
688
+ async listPage(options = {}) {
618
689
  const params = new URLSearchParams();
619
690
  if (options.tag !== void 0 && options.tag !== "") params.set("tag", options.tag);
620
691
  if (options.query !== void 0 && options.query !== "") params.set("q", options.query);
692
+ if (options.status !== void 0 && options.status !== "") params.set("status", options.status);
693
+ if (options.scope !== void 0 && options.scope !== "") params.set("scope", options.scope);
694
+ if (options.departmentCode !== void 0 && options.departmentCode !== "") params.set("departmentCode", options.departmentCode);
621
695
  const suffix = params.size === 0 ? "" : `?${params.toString()}`;
622
696
  const body = await this.call(`/api/ai/skill-plaza/skills${suffix}`);
623
- if (!Array.isArray(body)) return [];
624
- return body.flatMap((row) => this.toPlazaSkill(row));
697
+ const record = asRecord$1(body);
698
+ const rows = Array.isArray(body) ? body : Array.isArray(record?.items) ? record.items : [];
699
+ const rawCounts = asRecord$1(record?.counts);
700
+ return {
701
+ skills: rows.flatMap((row) => this.toPlazaSkill(row)),
702
+ ...rawCounts === null ? {} : { counts: {
703
+ pending: Number(rawCounts.pending ?? 0),
704
+ approved: Number(rawCounts.approved ?? 0),
705
+ rejected: Number(rawCounts.rejected ?? 0),
706
+ total: Number(rawCounts.total ?? rows.length)
707
+ } }
708
+ };
625
709
  }
626
710
  /**
627
711
  * Every tag in use, so the browser offers existing labels.
@@ -680,201 +764,1759 @@ var SkillPlaza = class {
680
764
  instruction: typeof row.instruction === "string" ? row.instruction : "",
681
765
  tags: Array.isArray(row.tags) ? row.tags.filter((tag) => typeof tag === "string") : [],
682
766
  sharedByMe: row.sharedByMe === true,
683
- updatedAt: typeof row.updatedAt === "string" ? row.updatedAt : ""
767
+ updatedAt: typeof row.updatedAt === "string" ? row.updatedAt : "",
768
+ reviewStatus: row.reviewStatus === "pending" || row.reviewStatus === "rejected" ? row.reviewStatus : "approved",
769
+ visibility: row.visibility === "department" || row.visibility === "private" ? row.visibility : "global",
770
+ departmentCode: typeof row.departmentCode === "string" ? row.departmentCode : null,
771
+ departmentName: typeof row.departmentName === "string" ? row.departmentName : void 0,
772
+ departmentPath: Array.isArray(row.departmentPath) ? row.departmentPath.flatMap((entry) => {
773
+ const node = asRecord$1(entry);
774
+ return node !== null && typeof node.name === "string" ? [{
775
+ code: typeof node.code === "string" ? node.code : "",
776
+ name: node.name
777
+ }] : [];
778
+ }) : void 0,
779
+ ownerName: typeof row.ownerName === "string" ? row.ownerName : void 0,
780
+ riskReport: row.riskReport ?? null,
781
+ reviewNote: typeof row.reviewNote === "string" ? row.reviewNote : null,
782
+ submittedAt: typeof row.submittedAt === "string" ? row.submittedAt : void 0
684
783
  }];
685
784
  }
686
785
  };
687
786
  //#endregion
688
- //#region src/workspace-limits.ts
689
- /** Narrow a value to a plain object. */
690
- function asRecord(value) {
691
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
692
- }
693
- /** Narrow a value to a positive integer. */
694
- function asByteCount(value) {
695
- return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
696
- }
697
- /**
698
- * A rejection DataOps answered with, carrying its status and message.
699
- *
700
- * Without this the caller can only tell that something failed, and a value DataOps refused —
701
- * which the user can correct — is indistinguishable from a fault in the request path.
702
- */
703
- var WorkspaceLimitsRejection = class extends Error {
704
- status;
705
- constructor(status, message) {
706
- super(message);
707
- this.status = status;
708
- this.name = "WorkspaceLimitsRejection";
709
- }
710
- };
711
- /** Read DataOps' own explanation from a failed response, falling back to the status line. */
712
- async function rejectionMessage(response, fallback) {
713
- try {
714
- const message = asRecord(await response.json())?.message;
715
- if (typeof message === "string" && message.trim() !== "") return message;
716
- if (Array.isArray(message) && typeof message[0] === "string") return message[0];
717
- } catch {}
718
- return `${fallback} with HTTP ${String(response.status)}`;
719
- }
720
- /**
721
- * The workspace limits an administrator sets in DataOps.
722
- *
723
- * The plugin holds the DataOps JWT, so the browser cannot read these itself; this class is the
724
- * only path from the Settings panel to that document.
725
- */
726
- var WorkspaceLimits = class {
787
+ //#region src/review-api.ts
788
+ /** Cookie-free same-origin bridge to the review endpoints. */
789
+ var ReviewApi = class {
727
790
  ctx;
728
791
  config;
729
792
  constructor(ctx, config) {
730
793
  this.ctx = ctx;
731
794
  this.config = config;
732
795
  }
733
- /**
734
- * Read the DataOps JWT this workspace is currently authorized with.
735
- * @returns The token, or undefined when the workspace has no session yet.
736
- */
737
- async token() {
738
- return (await this.ctx.credentials.resolve(credentialRef(this.config.credentialRef)))?.value;
739
- }
740
- /** The DataOps API origin this workspace reports to. */
741
796
  apiBase() {
742
797
  return new URL(this.config.baseUrl).origin;
743
798
  }
744
- /**
745
- * Read the current ceilings.
746
- * @returns The status, or undefined when the workspace is not authorized.
747
- */
748
- async status() {
749
- const token = await this.token();
750
- if (token === void 0) return void 0;
751
- const response = await fetch(new URL("/api/ai/attachment-settings", this.apiBase()), { headers: { authorization: `Bearer ${token}` } });
752
- if (response.status === 401) return void 0;
753
- if (!response.ok) throw new WorkspaceLimitsRejection(response.status, await rejectionMessage(response, "Workspace limits read failed"));
754
- const body = asRecord(await response.json());
755
- const settings = body === null ? null : asRecord(body.settings);
756
- const size = settings === null ? null : asRecord(settings.workspaceSize);
757
- const fileReadMaxBytes = size === null ? null : asByteCount(size.fileReadMaxBytes);
758
- const uploadMaxBytes = size === null ? null : asByteCount(size.uploadMaxBytes);
759
- if (fileReadMaxBytes === null || uploadMaxBytes === null) throw new Error("Workspace limits read returned an unrecognized document");
760
- return {
761
- limits: {
762
- fileReadMaxBytes,
763
- uploadMaxBytes
764
- },
765
- canWrite: true,
766
- readOnlyReason: null
767
- };
799
+ async token() {
800
+ return (await this.ctx.credentials.resolve(credentialRef(this.config.credentialRef)))?.value;
768
801
  }
769
802
  /**
770
- * Write new ceilings.
771
- * @param limits - The ceilings to store.
772
- * @returns The stored status.
803
+ * Call one DataOps endpoint with the workspace JWT.
804
+ * @param path - API path below the DataOps origin.
805
+ * @param init - Method, body and extra headers.
806
+ * @returns The upstream status and parsed body; 503 when this workspace has no session.
773
807
  */
774
- async save(limits) {
808
+ async request(path, init = {}) {
775
809
  const token = await this.token();
776
- if (token === void 0) return void 0;
777
- const response = await fetch(new URL("/api/ai/attachment-settings", this.apiBase()), {
778
- method: "PATCH",
810
+ if (token === void 0) return {
811
+ status: 503,
812
+ body: { error: "DataOps is not connected" }
813
+ };
814
+ const response = await fetch(new URL(path, this.apiBase()), {
815
+ ...init,
779
816
  headers: {
780
817
  authorization: `Bearer ${token}`,
781
- "content-type": "application/json"
782
- },
783
- body: JSON.stringify({ workspaceSize: limits })
818
+ ...init.body === void 0 ? {} : { "content-type": "application/json" },
819
+ ...init.headers
820
+ }
784
821
  });
785
- if (response.status === 401) return void 0;
786
- if (!response.ok) throw new WorkspaceLimitsRejection(response.status, await rejectionMessage(response, "Workspace limits write failed"));
787
- return this.status();
822
+ if (response.status === 401) {
823
+ noteExpired();
824
+ return {
825
+ status: 401,
826
+ body: { error: "DataOps session expired" }
827
+ };
828
+ }
829
+ noteAccepted();
830
+ const text = await response.text();
831
+ let body = null;
832
+ if (text !== "") try {
833
+ body = JSON.parse(text);
834
+ } catch {
835
+ body = { error: text };
836
+ }
837
+ return {
838
+ status: response.status,
839
+ body
840
+ };
788
841
  }
789
842
  };
790
- /** The live instance, so the Settings route can act on it after activation. */
791
- let limitsRef;
792
- /** @param next - Instance to publish, or undefined to clear. */
793
- function setWorkspaceLimits(next) {
794
- limitsRef = next;
843
+ //#endregion
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 }
874
+ };
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;
896
+ const roleSet = (scope) => new Set(scope.roleCodes ?? []);
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;
912
+ /**
913
+ * Whether the caller holds one capability.
914
+ *
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.
917
+ */
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
+ }
924
+ if (isAdmin(scope)) return true;
925
+ if (isDepartmentAdmin(scope)) return key !== "skill:review:global";
926
+ if (roleSet(scope).size === 0) return false;
927
+ return key === "skill:read" || key === "skill:install" || key === "skill:submit";
928
+ };
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;
795
941
  }
796
- /** @returns The live workspace-limits instance, when the plugin activated. */
797
- function currentWorkspaceLimits() {
798
- return limitsRef;
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
+ };
799
979
  }
800
980
  //#endregion
801
- //#region src/index.ts
802
- /** Cordis plugin name for the DataOps-managed integration. */
803
- const name = "dataops-managed";
804
- /** Host services required by managed JWT and MCP ownership. */
805
- const inject = [
806
- "credentials",
807
- "webServer",
808
- "tools",
809
- "settings",
810
- "skills"
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"
811
994
  ];
812
- const MANAGED_AUTH_PATH = "/integrations/dataops/managed-auth";
813
- /** This plugin's profile entry id, which owns its Config and sync state. */
814
- const MANAGED_PLUGIN_ID = "dataops-managed";
815
- /** Same-origin route the workspace settings pages use to read and change model sharing. */
816
- const MODEL_SYNC_PATH = "/integrations/dataops/model-sync";
817
- /** Same-origin route the skill center uses to read and change plaza sharing. */
818
- const SKILL_PLAZA_PATH = "/integrations/dataops/skill-plaza";
819
- /** Same-origin route the Settings panel uses to read and change this workspace's limits. */
820
- const WORKSPACE_LIMITS_PATH = "/integrations/dataops/workspace-limits";
821
- /** Same-origin route the Settings panel uses to read and change the tool-call timeout. */
822
- const TOOL_TIMEOUT_PATH = "/integrations/dataops/tool-timeout";
823
- /** The ends a tool-call timeout may take, in whole seconds. */
824
- const TOOL_TIMEOUT_MIN_SECONDS = 1;
825
- const TOOL_TIMEOUT_MAX_SECONDS = 3600;
826
- /** The live sync instance, so the settings routes can act on it after activation. */
827
- let modelSyncRef;
828
- /** The live plaza instance, so the skill center routes can act on it after activation. */
829
- let skillPlazaRef;
830
- /** The live skill plaza, once this plugin activated. */
831
- function currentSkillPlaza() {
832
- return skillPlazaRef;
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;
833
1016
  }
834
- /** The live model-sync instance, once this plugin activated. */
835
- function currentModelSync() {
836
- return modelSyncRef;
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);
837
1021
  }
838
- /** The live settings-sync instance, once this plugin activated. */
839
- let settingsSyncRef;
840
- /** @returns The live instance, or undefined before this plugin activated. */
841
- function currentSettingsSync() {
842
- return settingsSyncRef;
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);
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;
843
1147
  }
844
1148
  /**
845
- * Read the value behind a possibly-volatile field.
846
- *
847
- * A volatile field always arrives as a reference, but the plain shape is accepted too so a
848
- * caller that validated the config without a settings service still reads its value.
849
- * @param field - The config field as the runtime supplied it.
850
- * @param fallback - Value to use when nothing is stored yet.
851
- * @returns The current value.
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.
852
1154
  */
853
- function volatileValue(field, fallback) {
854
- if (field === void 0) return fallback;
855
- if (typeof field === "object" && field !== null && "get" in field && typeof field.get === "function") return field.get() ?? fallback;
856
- return field;
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
1219
+ /** Parse one plaza response body into rows without trusting its shape. */
1220
+ function rowsOf(body) {
1221
+ if (Array.isArray(body)) return body.filter((row) => typeof row === "object" && row !== null);
1222
+ if (typeof body === "object" && body !== null && Array.isArray(body.skills)) return body.skills.filter((row) => typeof row === "object" && row !== null);
1223
+ return [];
857
1224
  }
1225
+ const forbidden = (message) => /* @__PURE__ */ new Error("Forbidden (403): " + message);
858
1226
  /**
859
- * Per-tool-call timeout a workspace starts with.
1227
+ * Build every managed tool definition.
860
1228
  *
861
- * Named rather than inlined so the schema default and the read fallback cannot drift: the field is
862
- * volatile, so a workspace that never changed it reads no value at all and both places have to
863
- * answer the same number.
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.
864
1237
  */
865
- const DEFAULT_TOOL_CALL_TIMEOUT_MS = 3e5;
866
- /** Default sync state before this workspace stored anything. */
867
- const MODEL_SYNC_DEFAULT = {
868
- detached: false,
869
- adoptedModelIds: [],
870
- privateModelIds: []
871
- };
872
- /** Schemastery parser for managed DataOps configuration. */
873
- const Config = z.object({
874
- baseUrl: z.string().default("http://host.docker.internal:3101"),
875
- serverName: z.string().default("dataops"),
876
- credentialRef: z.string().role("credential-ref").default("DATAOPS_ACCESS_TOKEN"),
877
- /**
1238
+ function buildAiManagementTools(ctx, reviewApi, readScope) {
1239
+ const approve = async (tool, exec, reason) => {
1240
+ if (exec.agent === void 0) throw new Error(tool + " requires an initiating agent");
1241
+ const outcome = await ctx.approval.request({
1242
+ agent: exec.agent,
1243
+ toolName: tool,
1244
+ callId: exec.callId,
1245
+ signal: exec.signal,
1246
+ reason
1247
+ });
1248
+ if (outcome !== "allowed-once") throw new Error(tool + " approval was " + outcome);
1249
+ };
1250
+ const audit = (tool, exec, detail) => {
1251
+ try {
1252
+ const agent = exec.agent;
1253
+ ctx.logger.info("[audit] " + tool + " " + JSON.stringify({
1254
+ sessionId: agent?.session?.id ?? null,
1255
+ callId: exec.callId,
1256
+ ...detail
1257
+ }));
1258
+ } catch {}
1259
+ };
1260
+ const call = async (path, init = {}) => {
1261
+ const result = await reviewApi.request(path, init);
1262
+ if (result.status === 401) throw new Error("Unauthorized (401): DataOps session expired");
1263
+ const detail = result.body;
1264
+ const reason = typeof detail?.error === "string" ? detail.error : typeof detail?.message === "string" ? detail.message : "";
1265
+ if (result.status === 403) throw forbidden(reason || "the current role may not perform this action");
1266
+ if (result.status === 409) throw new Error("Conflict (409): " + (reason || "the skill still has installations"));
1267
+ if (result.status < 200 || result.status >= 300) throw new Error("DataOps " + path + " failed: HTTP " + result.status.toString() + (reason ? ": " + reason : ""));
1268
+ return result.body;
1269
+ };
1270
+ const listSkills = async (scope, includeLocal) => {
1271
+ const mine = rowsOf(await call("/api/ai/skill-plaza/skills?scope=mine"));
1272
+ const publicRows = rowsOf(await call("/api/ai/skill-plaza/skills"));
1273
+ const admin = isAdmin(scope) ? rowsOf(await call("/api/ai/skill-plaza/admin/skills")) : [];
1274
+ const byId = /* @__PURE__ */ new Map();
1275
+ for (const row of [
1276
+ ...mine,
1277
+ ...publicRows,
1278
+ ...admin
1279
+ ]) if (typeof row.id === "number") byId.set(row.id, {
1280
+ ...byId.get(row.id),
1281
+ ...row
1282
+ });
1283
+ const rows = [...byId.values()];
1284
+ return {
1285
+ catalog: {
1286
+ local: includeLocal ? (await readLocalSkills(ctx)).map((skill) => ({
1287
+ name: skill.name,
1288
+ description: skill.description ?? null,
1289
+ source: "local"
1290
+ })) : [],
1291
+ pending: rows.filter((row) => row.reviewStatus === "pending"),
1292
+ approved: rows.filter((row) => row.reviewStatus === "approved"),
1293
+ rejected: rows.filter((row) => row.reviewStatus === "rejected")
1294
+ },
1295
+ canReview: canReview(scope),
1296
+ isAdmin: isAdmin(scope),
1297
+ isDepartmentAdmin: isDepartmentAdmin(scope),
1298
+ departmentCode: scope.departmentCode ?? null,
1299
+ skills: rows
1300
+ };
1301
+ };
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
+ })
1357
+ },
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
+ })
1378
+ },
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
+ })
1403
+ },
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
+ })
1428
+ },
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
+ })
1457
+ },
1458
+ {
1459
+ name: "skill_enable",
1460
+ definition: defineSkillStatusTool(true)
1461
+ },
1462
+ {
1463
+ name: "skill_disable",
1464
+ definition: defineSkillStatusTool(false)
1465
+ },
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
+ })
1493
+ },
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
+ })
1529
+ },
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
+ })
1561
+ },
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
+ })
1621
+ },
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
+ })
1649
+ },
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
+ })
1685
+ },
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
+ }
1730
+ }
1731
+ })
1732
+ }
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;
1805
+ }
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);
1875
+ }
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
1990
+ }));
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
1999
+ };
2000
+ }
2001
+ /** Whether one capability id is in the fixed catalogue. */
2002
+ function hasCapability(id) {
2003
+ return CAPABILITIES.some((capability) => capability.id === id);
2004
+ }
2005
+ //#endregion
2006
+ //#region src/workspace-limits.ts
2007
+ /** Narrow a value to a plain object. */
2008
+ function asRecord(value) {
2009
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
2010
+ }
2011
+ /** Narrow a value to a positive integer. */
2012
+ function asByteCount(value) {
2013
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : null;
2014
+ }
2015
+ /**
2016
+ * A rejection DataOps answered with, carrying its status and message.
2017
+ *
2018
+ * Without this the caller can only tell that something failed, and a value DataOps refused —
2019
+ * which the user can correct — is indistinguishable from a fault in the request path.
2020
+ */
2021
+ var WorkspaceLimitsRejection = class extends Error {
2022
+ status;
2023
+ constructor(status, message) {
2024
+ super(message);
2025
+ this.status = status;
2026
+ this.name = "WorkspaceLimitsRejection";
2027
+ }
2028
+ };
2029
+ /** Read DataOps' own explanation from a failed response, falling back to the status line. */
2030
+ async function rejectionMessage(response, fallback) {
2031
+ try {
2032
+ const message = asRecord(await response.json())?.message;
2033
+ if (typeof message === "string" && message.trim() !== "") return message;
2034
+ if (Array.isArray(message) && typeof message[0] === "string") return message[0];
2035
+ } catch {}
2036
+ return `${fallback} with HTTP ${String(response.status)}`;
2037
+ }
2038
+ /**
2039
+ * The workspace limits an administrator sets in DataOps.
2040
+ *
2041
+ * The plugin holds the DataOps JWT, so the browser cannot read these itself; this class is the
2042
+ * only path from the Settings panel to that document.
2043
+ */
2044
+ var WorkspaceLimits = class {
2045
+ ctx;
2046
+ config;
2047
+ constructor(ctx, config) {
2048
+ this.ctx = ctx;
2049
+ this.config = config;
2050
+ }
2051
+ /**
2052
+ * Read the DataOps JWT this workspace is currently authorized with.
2053
+ * @returns The token, or undefined when the workspace has no session yet.
2054
+ */
2055
+ async token() {
2056
+ return (await this.ctx.credentials.resolve(credentialRef(this.config.credentialRef)))?.value;
2057
+ }
2058
+ /** The DataOps API origin this workspace reports to. */
2059
+ apiBase() {
2060
+ return new URL(this.config.baseUrl).origin;
2061
+ }
2062
+ /**
2063
+ * Read the current ceilings.
2064
+ * @returns The status, or undefined when the workspace is not authorized.
2065
+ */
2066
+ async status() {
2067
+ const token = await this.token();
2068
+ if (token === void 0) return void 0;
2069
+ const response = await fetch(new URL("/api/ai/attachment-settings", this.apiBase()), { headers: { authorization: `Bearer ${token}` } });
2070
+ if (response.status === 401) {
2071
+ noteExpired();
2072
+ return;
2073
+ }
2074
+ noteAccepted();
2075
+ if (!response.ok) throw new WorkspaceLimitsRejection(response.status, await rejectionMessage(response, "Workspace limits read failed"));
2076
+ const body = asRecord(await response.json());
2077
+ const settings = body === null ? null : asRecord(body.settings);
2078
+ const size = settings === null ? null : asRecord(settings.workspaceSize);
2079
+ const fileReadMaxBytes = size === null ? null : asByteCount(size.fileReadMaxBytes);
2080
+ const uploadMaxBytes = size === null ? null : asByteCount(size.uploadMaxBytes);
2081
+ if (fileReadMaxBytes === null || uploadMaxBytes === null) throw new Error("Workspace limits read returned an unrecognized document");
2082
+ return {
2083
+ limits: {
2084
+ fileReadMaxBytes,
2085
+ uploadMaxBytes
2086
+ },
2087
+ canWrite: true,
2088
+ readOnlyReason: null
2089
+ };
2090
+ }
2091
+ /**
2092
+ * Write new ceilings.
2093
+ * @param limits - The ceilings to store.
2094
+ * @returns The stored status.
2095
+ */
2096
+ async save(limits) {
2097
+ const token = await this.token();
2098
+ if (token === void 0) return void 0;
2099
+ const response = await fetch(new URL("/api/ai/attachment-settings", this.apiBase()), {
2100
+ method: "PATCH",
2101
+ headers: {
2102
+ authorization: `Bearer ${token}`,
2103
+ "content-type": "application/json"
2104
+ },
2105
+ body: JSON.stringify({ workspaceSize: limits })
2106
+ });
2107
+ if (response.status === 401) {
2108
+ noteExpired();
2109
+ return;
2110
+ }
2111
+ noteAccepted();
2112
+ if (!response.ok) throw new WorkspaceLimitsRejection(response.status, await rejectionMessage(response, "Workspace limits write failed"));
2113
+ return this.status();
2114
+ }
2115
+ };
2116
+ /** The live instance, so the Settings route can act on it after activation. */
2117
+ let limitsRef;
2118
+ /** @param next - Instance to publish, or undefined to clear. */
2119
+ function setWorkspaceLimits(next) {
2120
+ limitsRef = next;
2121
+ }
2122
+ /** @returns The live workspace-limits instance, when the plugin activated. */
2123
+ function currentWorkspaceLimits() {
2124
+ return limitsRef;
2125
+ }
2126
+ /** Directory used by this plugin below the DSH home. */
2127
+ const WORKSPACE_UPGRADE_STATE_DIR = "dataops-managed";
2128
+ /** Name of the hand-off file. */
2129
+ const WORKSPACE_UPGRADE_STATE_FILE = "workspace-upgrade.json";
2130
+ /**
2131
+ * Resolve the persistent DSH home used by this workspace.
2132
+ *
2133
+ * The companion currently starts with `DSH_HOME=/workspace/.dataops/dsh`; the fallback
2134
+ * keeps tests and a manually launched host deterministic without reaching outside the
2135
+ * mounted workspace.
2136
+ * @param dshHome - override for tests.
2137
+ * @returns the state directory path.
2138
+ */
2139
+ function workspaceUpgradeStatePath(dshHome = process.env.DSH_HOME ?? "/workspace/.dataops/dsh") {
2140
+ return join(dshHome, WORKSPACE_UPGRADE_STATE_DIR, WORKSPACE_UPGRADE_STATE_FILE);
2141
+ }
2142
+ /** Validate one persisted session entry. */
2143
+ function asSession(value) {
2144
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
2145
+ const record = value;
2146
+ if (typeof record.sessionId !== "string" || record.sessionId.trim() === "") return null;
2147
+ return {
2148
+ sessionId: record.sessionId.trim(),
2149
+ ...typeof record.title === "string" && record.title.trim() !== "" ? { title: record.title.trim() } : {},
2150
+ ...typeof record.cwd === "string" && record.cwd.trim() !== "" ? { cwd: record.cwd.trim() } : {}
2151
+ };
2152
+ }
2153
+ /** Validate a parsed state document. */
2154
+ function asWorkspaceUpgradeState(value) {
2155
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
2156
+ const record = value;
2157
+ if (record.version !== 1) return null;
2158
+ if (typeof record.upgradeId !== "string" || record.upgradeId.trim() === "") return null;
2159
+ if (typeof record.targetImage !== "string" || record.targetImage.trim() === "") return null;
2160
+ if (!Array.isArray(record.sessions)) return null;
2161
+ const sessions = record.sessions.map(asSession).filter((session) => session !== null);
2162
+ return {
2163
+ version: 1,
2164
+ upgradeId: record.upgradeId.trim(),
2165
+ targetImage: record.targetImage.trim(),
2166
+ sessions,
2167
+ createdAt: typeof record.createdAt === "string" ? record.createdAt : (/* @__PURE__ */ new Date()).toISOString()
2168
+ };
2169
+ }
2170
+ /**
2171
+ * Read the pending hand-off record.
2172
+ * @param file - state file path.
2173
+ * @returns the record, or null when none exists or it cannot be parsed.
2174
+ */
2175
+ async function readWorkspaceUpgradeState(file = workspaceUpgradeStatePath()) {
2176
+ try {
2177
+ return asWorkspaceUpgradeState(JSON.parse(await readFile(file, "utf8")));
2178
+ } catch {
2179
+ return null;
2180
+ }
2181
+ }
2182
+ /**
2183
+ * Persist the hand-off record atomically.
2184
+ *
2185
+ * A partially written JSON file would be indistinguishable from "no upgrade pending",
2186
+ * which would silently skip recovery. The temporary file is renamed into place only
2187
+ * after the content is complete.
2188
+ * @param state - record to persist.
2189
+ * @param file - state file path.
2190
+ */
2191
+ async function writeWorkspaceUpgradeState(state, file = workspaceUpgradeStatePath()) {
2192
+ await mkdir(dirname(file), { recursive: true });
2193
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
2194
+ await writeFile(temporary, JSON.stringify(state, null, 2) + "\n", "utf8");
2195
+ await rename(temporary, file);
2196
+ }
2197
+ /**
2198
+ * Remove the hand-off record after recovery has been attempted.
2199
+ * @param file - state file path.
2200
+ */
2201
+ async function clearWorkspaceUpgradeState(file = workspaceUpgradeStatePath()) {
2202
+ await rm(file, { force: true });
2203
+ }
2204
+ //#endregion
2205
+ //#region src/workspace-upgrade.ts
2206
+ /**
2207
+ * Workspace image upgrade orchestration.
2208
+ *
2209
+ * DataOps owns the Docker image comparison and the container restart. This plugin owns
2210
+ * the two facts DSH alone can know: which sessions are running right now, and how to
2211
+ * bring those sessions back after the container has been recreated. A small JSON file
2212
+ * on the shared workspace volume carries that hand-off across the restart.
2213
+ *
2214
+ * @module workspace-upgrade
2215
+ */
2216
+ /** A refusal or transport failure from the DataOps API. */
2217
+ var WorkspaceUpgradeError = class extends Error {
2218
+ status;
2219
+ constructor(status, message) {
2220
+ super(message);
2221
+ this.status = status;
2222
+ this.name = "WorkspaceUpgradeError";
2223
+ }
2224
+ };
2225
+ /** Create the user-message value `agent.followup` accepts. */
2226
+ function userMessage(text) {
2227
+ return {
2228
+ id: randomUUID(),
2229
+ role: "user",
2230
+ content: [{
2231
+ type: "text",
2232
+ text
2233
+ }],
2234
+ source: { kind: "user" }
2235
+ };
2236
+ }
2237
+ /** Extract a useful message from a DataOps error body. */
2238
+ async function responseMessage(response, fallback) {
2239
+ try {
2240
+ const message = (await response.json()).message;
2241
+ if (typeof message === "string" && message.trim() !== "") return message;
2242
+ if (Array.isArray(message) && typeof message[0] === "string") return message[0];
2243
+ } catch {}
2244
+ return `${fallback} (HTTP ${String(response.status)})`;
2245
+ }
2246
+ /** Owns DataOps preview/restart calls and the DSH session hand-off. */
2247
+ var WorkspaceUpgrade = class {
2248
+ ctx;
2249
+ config;
2250
+ statePath;
2251
+ constructor(ctx, config, statePath = workspaceUpgradeStatePath()) {
2252
+ this.ctx = ctx;
2253
+ this.config = config;
2254
+ this.statePath = statePath;
2255
+ }
2256
+ /** Resolve the injected DSH agents service, if the profile provides one. */
2257
+ agents() {
2258
+ return this.ctx.agents;
2259
+ }
2260
+ /** Read the current DataOps credential. */
2261
+ async token() {
2262
+ return (await this.ctx.credentials.resolve(credentialRef(this.config.credentialRef)))?.value;
2263
+ }
2264
+ /** DataOps origin used by this workspace. */
2265
+ apiBase() {
2266
+ return new URL(this.config.baseUrl).origin;
2267
+ }
2268
+ /** Call one DataOps API path with the workspace JWT. */
2269
+ async request(path, init) {
2270
+ const token = await this.token();
2271
+ if (token === void 0) throw new WorkspaceUpgradeError(401, "workspace is not signed in to DataOps");
2272
+ let response;
2273
+ try {
2274
+ response = await fetch(new URL(path, this.apiBase()), {
2275
+ ...init,
2276
+ headers: {
2277
+ authorization: `Bearer ${token}`,
2278
+ ...init?.body === void 0 ? {} : { "content-type": "application/json" },
2279
+ ...init?.headers ?? {}
2280
+ }
2281
+ });
2282
+ } catch (error) {
2283
+ throw new WorkspaceUpgradeError(502, error instanceof Error ? error.message : String(error));
2284
+ }
2285
+ if (response.status === 401) {
2286
+ noteExpired();
2287
+ throw new WorkspaceUpgradeError(401, "DataOps session has ended");
2288
+ }
2289
+ noteAccepted();
2290
+ return response;
2291
+ }
2292
+ /** Read the DataOps restart preview plus this host's running-session snapshot. */
2293
+ async preview() {
2294
+ const response = await this.request("/api/ai/workspace/restart-preview");
2295
+ if (!response.ok) throw new WorkspaceUpgradeError(response.status, await responseMessage(response, "workspace preview failed"));
2296
+ const body = await response.json();
2297
+ const imageStatus = body.workspace?.imageStatus;
2298
+ if (imageStatus !== "CURRENT" && imageStatus !== "UPDATE_AVAILABLE" && imageStatus !== "NOT_RUNNING" && imageStatus !== "UNKNOWN") throw new WorkspaceUpgradeError(502, "workspace preview returned an unrecognized image status");
2299
+ return {
2300
+ imageStatus,
2301
+ configuredImage: typeof body.workspace?.configuredImage === "string" ? body.workspace.configuredImage : "",
2302
+ activeSessions: this.runningSessions()
2303
+ };
2304
+ }
2305
+ /** Snapshot sessions that are executing a turn right now. */
2306
+ runningSessions() {
2307
+ const agents = this.agents();
2308
+ if (agents === void 0) return [];
2309
+ return agents.list().filter((agent) => agent.status === "running").map((agent) => ({
2310
+ sessionId: agent.session.id,
2311
+ ...agent.session.header?.title ? { title: agent.session.header.title } : {},
2312
+ ...agent.session.header?.cwd ? { cwd: agent.session.header.cwd } : {}
2313
+ }));
2314
+ }
2315
+ /** Restart DataOps' workspace container; force is required when sessions are snapshot. */
2316
+ async restart() {
2317
+ const response = await this.request("/api/ai/workspace/restart", {
2318
+ method: "POST",
2319
+ body: JSON.stringify({ force: true })
2320
+ });
2321
+ if (!response.ok) throw new WorkspaceUpgradeError(response.status, await responseMessage(response, "workspace restart failed"));
2322
+ }
2323
+ /**
2324
+ * Persist the running-session snapshot and ask DataOps to recreate the container.
2325
+ * @returns the accepted hand-off record.
2326
+ */
2327
+ async upgrade() {
2328
+ const preview = await this.preview();
2329
+ if (preview.imageStatus !== "UPDATE_AVAILABLE") throw new WorkspaceUpgradeError(409, "workspace image is already current or not running");
2330
+ const state = {
2331
+ version: 1,
2332
+ upgradeId: randomUUID(),
2333
+ targetImage: preview.configuredImage,
2334
+ sessions: preview.activeSessions,
2335
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2336
+ };
2337
+ await writeWorkspaceUpgradeState(state, this.statePath);
2338
+ try {
2339
+ await this.restart();
2340
+ } catch (error) {
2341
+ await clearWorkspaceUpgradeState(this.statePath);
2342
+ throw error;
2343
+ }
2344
+ return state;
2345
+ }
2346
+ /**
2347
+ * Resume every snapshotted session and send one "继续".
2348
+ *
2349
+ * The method deliberately does not wait for an answer: the user's workspace should
2350
+ * become usable immediately, and an in-flight tool call cannot be replayed safely.
2351
+ * `recoverWorkspaceUpgradeAfterBoot` removes the hand-off file after this attempt.
2352
+ */
2353
+ async recover() {
2354
+ const state = await readWorkspaceUpgradeState(this.statePath);
2355
+ if (state === null) return {
2356
+ recovered: 0,
2357
+ skipped: 0
2358
+ };
2359
+ if (this.agents() === void 0) throw new Error("DSH agents service is not ready");
2360
+ let recovered = 0;
2361
+ let skipped = 0;
2362
+ for (const session of state.sessions) try {
2363
+ const agent = await this.ensureAgent(session.sessionId);
2364
+ if (agent === void 0) {
2365
+ skipped += 1;
2366
+ continue;
2367
+ }
2368
+ agent.followup(userMessage("继续"));
2369
+ recovered += 1;
2370
+ } catch (error) {
2371
+ skipped += 1;
2372
+ this.ctx.logger.warn(`dataops-managed: could not resume session ${session.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
2373
+ }
2374
+ return {
2375
+ recovered,
2376
+ skipped
2377
+ };
2378
+ }
2379
+ /** Remove the hand-off record once recovery has been attempted. */
2380
+ async clearRecoveryState() {
2381
+ await clearWorkspaceUpgradeState(this.statePath);
2382
+ }
2383
+ /** Find a live session or resume it from persisted state. */
2384
+ async ensureAgent(sessionId) {
2385
+ const agents = this.agents();
2386
+ if (agents === void 0) return void 0;
2387
+ const live = agents.get(sessionId);
2388
+ if (live !== void 0) return live;
2389
+ return (await agents.resume({ resumeSessionId: sessionId }))?.agent;
2390
+ }
2391
+ };
2392
+ /** Delay helper for boot-time recovery without keeping the plugin apply() pending. */
2393
+ const sleep = (ms) => new Promise((resolve) => {
2394
+ setTimeout(resolve, ms);
2395
+ });
2396
+ /**
2397
+ * Run recovery after the DSH host and its session persistence have had time to boot.
2398
+ *
2399
+ * `apply()` runs before a persisted session necessarily has its live agent; resuming
2400
+ * eagerly can race that boot. A short bounded retry is enough, and a failed attempt
2401
+ * still removes the hand-off file so the next page load cannot send "继续" forever.
2402
+ * @param upgrade - upgrade service.
2403
+ */
2404
+ async function recoverWorkspaceUpgradeAfterBoot(upgrade) {
2405
+ let lastState = null;
2406
+ for (let attempt = 0; attempt < 10; attempt += 1) {
2407
+ const state = await readWorkspaceUpgradeState(upgrade.statePath);
2408
+ if (state === null) return;
2409
+ lastState = state;
2410
+ if (attempt > 0) await sleep(1500);
2411
+ try {
2412
+ if ((await upgrade.recover()).recovered > 0 || state.sessions.length === 0) {
2413
+ await upgrade.clearRecoveryState();
2414
+ return;
2415
+ }
2416
+ } catch (error) {
2417
+ console.warn("[dataops-managed] workspace upgrade recovery failed:", error);
2418
+ }
2419
+ }
2420
+ if (lastState !== null) await upgrade.clearRecoveryState();
2421
+ }
2422
+ //#endregion
2423
+ //#region src/index.ts
2424
+ /** Cordis plugin name for the DataOps-managed integration. */
2425
+ const name = "dataops-managed";
2426
+ /** Host services required by managed JWT and MCP ownership. */
2427
+ const inject = [
2428
+ "credentials",
2429
+ "webServer",
2430
+ "tools",
2431
+ "settings",
2432
+ "skills",
2433
+ "approval",
2434
+ "agents"
2435
+ ];
2436
+ const MANAGED_AUTH_PATH = "/integrations/dataops/managed-auth";
2437
+ /** This plugin's profile entry id, which owns its Config and sync state. */
2438
+ const MANAGED_PLUGIN_ID = "dataops-managed";
2439
+ /** Same-origin route the workspace settings pages use to read and change model sharing. */
2440
+ const MODEL_SYNC_PATH = "/integrations/dataops/model-sync";
2441
+ /** Same-origin route the skill center uses to read and change plaza sharing. */
2442
+ const SKILL_PLAZA_PATH = "/integrations/dataops/skill-plaza";
2443
+ /** Caller role and review capability. */
2444
+ const REVIEW_CONTEXT_PATH = "/integrations/dataops/review-context";
2445
+ /** Notification inbox and read receipts. */
2446
+ const REVIEW_NOTIFICATIONS_PATH = "/integrations/dataops/review-notifications";
2447
+ /** AI scan and the administrator's decision. */
2448
+ const REVIEW_ACTION_PATH = "/integrations/dataops/review";
2449
+ /** Permission-centre reads and writes. */
2450
+ const RBAC_PATH = "/integrations/dataops/rbac";
2451
+ /** The three plaza-review switches. */
2452
+ const REVIEW_SETTINGS_PATH = "/integrations/dataops/review-settings";
2453
+ /** Department CRUD bridge (task-67). */
2454
+ const DEPARTMENTS_PATH = "/integrations/dataops/departments";
2455
+ /** Same-origin route the Settings panel uses to read and change this workspace's limits. */
2456
+ const WORKSPACE_LIMITS_PATH = "/integrations/dataops/workspace-limits";
2457
+ /** Same-origin route the Settings panel uses to read and change the tool-call timeout. */
2458
+ const TOOL_TIMEOUT_PATH = "/integrations/dataops/tool-timeout";
2459
+ /** Same-origin route reporting what the last DataOps answer said about the credential. */
2460
+ const AUTH_STATE_PATH = "/integrations/dataops/auth-state";
2461
+ /** Same-origin route the DSH page uses to detect and start a workspace image upgrade. */
2462
+ const WORKSPACE_UPGRADE_PATH = "/integrations/dataops/workspace-upgrade";
2463
+ /** The ends a tool-call timeout may take, in whole seconds. */
2464
+ const TOOL_TIMEOUT_MIN_SECONDS = 1;
2465
+ const TOOL_TIMEOUT_MAX_SECONDS = 3600;
2466
+ /** The live sync instance, so the settings routes can act on it after activation. */
2467
+ let modelSyncRef;
2468
+ /** The live plaza instance, so the skill center routes can act on it after activation. */
2469
+ let skillPlazaRef;
2470
+ /** The live review API, once this plugin activated. */
2471
+ let reviewApiRef;
2472
+ /** The live skill plaza, once this plugin activated. */
2473
+ function currentSkillPlaza() {
2474
+ return skillPlazaRef;
2475
+ }
2476
+ /** The live model-sync instance, once this plugin activated. */
2477
+ function currentModelSync() {
2478
+ return modelSyncRef;
2479
+ }
2480
+ /** The live settings-sync instance, once this plugin activated. */
2481
+ let settingsSyncRef;
2482
+ /** @returns The live instance, or undefined before this plugin activated. */
2483
+ function currentSettingsSync() {
2484
+ return settingsSyncRef;
2485
+ }
2486
+ /**
2487
+ * Read the value behind a possibly-volatile field.
2488
+ *
2489
+ * A volatile field always arrives as a reference, but the plain shape is accepted too so a
2490
+ * caller that validated the config without a settings service still reads its value.
2491
+ * @param field - The config field as the runtime supplied it.
2492
+ * @param fallback - Value to use when nothing is stored yet.
2493
+ * @returns The current value.
2494
+ */
2495
+ function volatileValue(field, fallback) {
2496
+ if (field === void 0) return fallback;
2497
+ if (typeof field === "object" && field !== null && "get" in field && typeof field.get === "function") return field.get() ?? fallback;
2498
+ return field;
2499
+ }
2500
+ /**
2501
+ * Per-tool-call timeout a workspace starts with.
2502
+ *
2503
+ * Named rather than inlined so the schema default and the read fallback cannot drift: the field is
2504
+ * volatile, so a workspace that never changed it reads no value at all and both places have to
2505
+ * answer the same number.
2506
+ */
2507
+ const DEFAULT_TOOL_CALL_TIMEOUT_MS = 3e5;
2508
+ /** Default sync state before this workspace stored anything. */
2509
+ const MODEL_SYNC_DEFAULT = {
2510
+ detached: false,
2511
+ adoptedModelIds: [],
2512
+ privateModelIds: []
2513
+ };
2514
+ /** Schemastery parser for managed DataOps configuration. */
2515
+ const Config = z.object({
2516
+ baseUrl: z.string().default("http://host.docker.internal:3101"),
2517
+ serverName: z.string().default("dataops"),
2518
+ credentialRef: z.string().role("credential-ref").default("DATAOPS_ACCESS_TOKEN"),
2519
+ /**
878
2520
  * Volatile so the settings service persists it in this workspace's document: the timeout an
879
2521
  * operator needs depends on the query behind a tool, which is deployment knowledge the profile
880
2522
  * that mounts this plugin does not have.
@@ -903,6 +2545,38 @@ function bearerToken(request) {
903
2545
  const token = authorization.slice(7).trim();
904
2546
  return token === "" ? null : token;
905
2547
  }
2548
+ /**
2549
+ * Adopt the JWT the browser gateway attached to one request.
2550
+ *
2551
+ * Every browser request reaches this workspace through the DataOps gateway, which resolves the
2552
+ * caller's session and forwards a fresh `authorization: Bearer <jwt>` — so the current credential
2553
+ * arrives continuously, without any push, handshake or refresh protocol. The routes below are
2554
+ * the only place this plugin can see that header, and reading it here is what keeps the stored
2555
+ * credential from going stale: the value in it was captured once, while the caller's session is
2556
+ * renewed by DataOps for as long as they stay signed in.
2557
+ *
2558
+ * A request without the header is normal rather than an error (same-origin fetches from inside
2559
+ * the workspace do carry it, but a probe or a health check may not), so this only ever improves
2560
+ * the stored value and never rejects the request.
2561
+ *
2562
+ * @param request - the incoming request, whose headers the gateway filled in.
2563
+ * @returns once the credential has been updated, or immediately when there is nothing to adopt.
2564
+ */
2565
+ function adoptRequestJwt(ctx, accessRef, request) {
2566
+ const token = bearerToken(request);
2567
+ if (token === null) return Promise.resolve();
2568
+ return (async () => {
2569
+ if ((await ctx.credentials.resolve(accessRef))?.value === token) {
2570
+ resetAuthState();
2571
+ return;
2572
+ }
2573
+ await ctx.credentials.set(accessRef, token);
2574
+ resetAuthState();
2575
+ })().catch((error) => {
2576
+ ctx.logger.warn("dataops-managed: request JWT could not be adopted");
2577
+ ctx.logger.warn(error);
2578
+ });
2579
+ }
906
2580
  /** The skill this plugin contributes: how to answer a DataOps data question. */
907
2581
  const QUERY_SKILL_NAME = "dataops-query";
908
2582
  /** Location of the skill document inside the published package. */
@@ -986,7 +2660,11 @@ async function apply(ctx, config) {
986
2660
  * a failed connect is reported rather than thrown.
987
2661
  */
988
2662
  const connectExisting = async () => {
989
- if (await ctx.credentials.resolve(accessRef) === void 0) return;
2663
+ if (await ctx.credentials.resolve(accessRef) === void 0) {
2664
+ aiTools.clear();
2665
+ return;
2666
+ }
2667
+ aiTools.refresh();
990
2668
  try {
991
2669
  await ensureMcp();
992
2670
  } catch (error) {
@@ -1061,10 +2739,34 @@ async function apply(ctx, config) {
1061
2739
  baseUrl,
1062
2740
  credentialRef: config.credentialRef
1063
2741
  });
2742
+ const reviewApi = new ReviewApi(ctx, {
2743
+ baseUrl,
2744
+ credentialRef: config.credentialRef
2745
+ });
2746
+ reviewApiRef = reviewApi;
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");
1064
2762
  setWorkspaceLimits(new WorkspaceLimits(ctx, {
1065
2763
  baseUrl,
1066
2764
  credentialRef: config.credentialRef
1067
2765
  }));
2766
+ const workspaceUpgrade = new WorkspaceUpgrade(ctx, {
2767
+ baseUrl,
2768
+ credentialRef: config.credentialRef
2769
+ });
1068
2770
  /**
1069
2771
  * Adopt the publisher's models once the workspace has a session.
1070
2772
  *
@@ -1086,7 +2788,7 @@ async function apply(ctx, config) {
1086
2788
  ctx.logger.warn(error);
1087
2789
  }
1088
2790
  };
1089
- if (await ctx.credentials.resolve(accessRef) !== void 0) await adoptNow();
2791
+ if (await ctx.credentials.resolve(accessRef) !== void 0) adoptNow();
1090
2792
  ctx.effect(() => ctx.webServer.register({
1091
2793
  kind: "exact",
1092
2794
  path: MANAGED_AUTH_PATH,
@@ -1099,6 +2801,7 @@ async function apply(ctx, config) {
1099
2801
  response.end();
1100
2802
  return;
1101
2803
  }
2804
+ await adoptRequestJwt(ctx, accessRef, request);
1102
2805
  const token = bearerToken(request);
1103
2806
  if (token === null) {
1104
2807
  response.writeHead(401, { "content-length": "0" });
@@ -1106,6 +2809,8 @@ async function apply(ctx, config) {
1106
2809
  return;
1107
2810
  }
1108
2811
  await ctx.credentials.set(accessRef, token);
2812
+ resetAuthState();
2813
+ aiTools.refresh(true);
1109
2814
  await ensureMcp();
1110
2815
  await adoptNow();
1111
2816
  response.writeHead(204, { "cache-control": "no-store" });
@@ -1217,12 +2922,18 @@ async function apply(ctx, config) {
1217
2922
  try {
1218
2923
  if (request.method === "GET") {
1219
2924
  const url = new URL(request.url ?? "/", "http://localhost");
2925
+ const mine = url.searchParams.get("mine") === "1";
2926
+ const page = await plaza.listPage({
2927
+ tag: url.searchParams.get("tag") ?? void 0,
2928
+ query: url.searchParams.get("q") ?? void 0,
2929
+ status: url.searchParams.get("status") ?? void 0,
2930
+ scope: mine ? "mine" : url.searchParams.get("scope") ?? void 0,
2931
+ departmentCode: url.searchParams.get("departmentCode") ?? void 0
2932
+ });
1220
2933
  writeJson(response, 200, {
1221
- skills: await plaza.list({
1222
- tag: url.searchParams.get("tag") ?? void 0,
1223
- query: url.searchParams.get("q") ?? void 0
1224
- }),
1225
- tags: await plaza.tags()
2934
+ skills: mine ? page.skills.filter((skill) => skill.sharedByMe) : page.skills,
2935
+ tags: mine ? [] : await plaza.tags(),
2936
+ ...page.counts === void 0 ? {} : { counts: page.counts }
1226
2937
  });
1227
2938
  return;
1228
2939
  }
@@ -1240,17 +2951,52 @@ async function apply(ctx, config) {
1240
2951
  writeJson(response, 200, { skills: await readLocalSkills(ctx) });
1241
2952
  return;
1242
2953
  }
1243
- if (action === "share") {
2954
+ if (action === "share" || action === "submit") {
1244
2955
  const name = typeof body?.name === "string" ? body.name : "";
1245
2956
  const instruction = typeof body?.instruction === "string" ? body.instruction : "";
1246
2957
  const description = typeof body?.description === "string" ? body.description : void 0;
1247
2958
  const tags = Array.isArray(body?.tags) ? body.tags.filter((tag) => typeof tag === "string") : [];
1248
- writeJson(response, 200, { skill: await plaza.share({
1249
- name,
1250
- description,
1251
- instruction,
1252
- tags
1253
- }) });
2959
+ if (name.trim() === "" || instruction.trim() === "") {
2960
+ writeJson(response, 400, { error: "name and instruction are required" });
2961
+ return;
2962
+ }
2963
+ writeJson(response, 200, {
2964
+ ok: true,
2965
+ skill: await plaza.share({
2966
+ name,
2967
+ description,
2968
+ instruction,
2969
+ tags
2970
+ })
2971
+ });
2972
+ return;
2973
+ }
2974
+ if (action === "review") {
2975
+ const api = reviewApiRef;
2976
+ if (api === void 0) {
2977
+ writeJson(response, 503, { error: "review api is not active" });
2978
+ return;
2979
+ }
2980
+ const id = typeof body?.id === "number" ? body.id : void 0;
2981
+ const decision = body?.decision === "reject" ? "reject" : body?.decision === "approve" ? "approve" : null;
2982
+ if (id === void 0 || decision === null) {
2983
+ writeJson(response, 400, { error: "id and decision are required" });
2984
+ return;
2985
+ }
2986
+ const result = await api.request("/api/ai/review/decide", {
2987
+ method: "POST",
2988
+ body: JSON.stringify({
2989
+ skills: [id],
2990
+ workflows: [],
2991
+ decision,
2992
+ note: typeof body?.note === "string" ? body.note : "",
2993
+ authorization: {
2994
+ confirmed: true,
2995
+ reason: "skill-plaza-review"
2996
+ }
2997
+ })
2998
+ });
2999
+ writeJson(response, result.status, result.body);
1254
3000
  return;
1255
3001
  }
1256
3002
  if (action === "unshare") {
@@ -1275,6 +3021,307 @@ async function apply(ctx, config) {
1275
3021
  }
1276
3022
  }), "dataops-managed: skill plaza routes");
1277
3023
  /**
3024
+ * Review context, notifications, AI scan/decide, the permission centre and review settings.
3025
+ *
3026
+ * These are thin same-origin bridges: DataOps owns identity, authorization, the state machine
3027
+ * and the audit trail; this route only attaches the workspace JWT and forwards answers so the
3028
+ * browser can show a precise waiting/refused state instead of flattening both to "empty".
3029
+ */
3030
+ ctx.effect(() => ctx.webServer.register({
3031
+ kind: "exact",
3032
+ path: REVIEW_CONTEXT_PATH,
3033
+ handler: async (request, response) => {
3034
+ if (request.method !== "GET") {
3035
+ response.writeHead(405, {
3036
+ allow: "GET",
3037
+ "content-length": "0"
3038
+ });
3039
+ response.end();
3040
+ return;
3041
+ }
3042
+ const api = reviewApiRef;
3043
+ if (api === void 0) {
3044
+ writeJson(response, 503, { error: "review api is not active" });
3045
+ return;
3046
+ }
3047
+ try {
3048
+ const result = await api.request("/api/ai/review/context");
3049
+ aiTools.observeContext(result);
3050
+ writeJson(response, result.status, result.body);
3051
+ } catch (error) {
3052
+ ctx.logger.warn("dataops-managed: review context failed");
3053
+ writeJson(response, 502, { error: error instanceof Error ? error.message : String(error) });
3054
+ }
3055
+ }
3056
+ }), "dataops-managed: review context route");
3057
+ ctx.effect(() => ctx.webServer.register({
3058
+ kind: "exact",
3059
+ path: REVIEW_NOTIFICATIONS_PATH,
3060
+ handler: async (request, response) => {
3061
+ const api = reviewApiRef;
3062
+ if (api === void 0) {
3063
+ writeJson(response, 503, { error: "review api is not active" });
3064
+ return;
3065
+ }
3066
+ try {
3067
+ if (request.method === "GET") {
3068
+ const url = new URL(request.url ?? "/", "http://localhost");
3069
+ const params = new URLSearchParams();
3070
+ if (url.searchParams.get("unreadOnly") === "true") params.set("unreadOnly", "true");
3071
+ if (url.searchParams.get("limit") !== null) params.set("limit", url.searchParams.get("limit"));
3072
+ const result = await api.request("/api/ai/review-notifications" + (params.size === 0 ? "" : "?" + params.toString()));
3073
+ writeJson(response, result.status, result.body);
3074
+ return;
3075
+ }
3076
+ if (request.method !== "POST") {
3077
+ response.writeHead(405, {
3078
+ allow: "GET, POST",
3079
+ "content-length": "0"
3080
+ });
3081
+ response.end();
3082
+ return;
3083
+ }
3084
+ const body = await readJson(request);
3085
+ const result = await api.request("/api/ai/review-notifications/read", {
3086
+ method: "POST",
3087
+ body: JSON.stringify(body ?? {})
3088
+ });
3089
+ writeJson(response, result.status, result.body);
3090
+ } catch (error) {
3091
+ ctx.logger.warn("dataops-managed: review notifications failed");
3092
+ writeJson(response, 502, { error: error instanceof Error ? error.message : String(error) });
3093
+ }
3094
+ }
3095
+ }), "dataops-managed: review notifications route");
3096
+ ctx.effect(() => ctx.webServer.register({
3097
+ kind: "exact",
3098
+ path: REVIEW_ACTION_PATH,
3099
+ handler: async (request, response) => {
3100
+ if (request.method !== "POST") {
3101
+ response.writeHead(405, {
3102
+ allow: "POST",
3103
+ "content-length": "0"
3104
+ });
3105
+ response.end();
3106
+ return;
3107
+ }
3108
+ const api = reviewApiRef;
3109
+ if (api === void 0) {
3110
+ writeJson(response, 503, { error: "review api is not active" });
3111
+ return;
3112
+ }
3113
+ try {
3114
+ const body = await readJson(request);
3115
+ const action = typeof body?.action === "string" ? body.action : "";
3116
+ const skills = Array.isArray(body?.skills) ? body.skills : [];
3117
+ const workflows = Array.isArray(body?.workflows) ? body.workflows : [];
3118
+ if (action === "scan") {
3119
+ const result = await api.request("/api/ai/review/scan", {
3120
+ method: "POST",
3121
+ body: JSON.stringify({
3122
+ skills,
3123
+ workflows
3124
+ })
3125
+ });
3126
+ writeJson(response, result.status, result.body);
3127
+ return;
3128
+ }
3129
+ if (action === "decide") {
3130
+ const result = await api.request("/api/ai/review/decide", {
3131
+ method: "POST",
3132
+ body: JSON.stringify({
3133
+ skills,
3134
+ workflows,
3135
+ decision: body?.decision,
3136
+ note: body?.note,
3137
+ authorization: body?.authorization
3138
+ })
3139
+ });
3140
+ writeJson(response, result.status, result.body);
3141
+ return;
3142
+ }
3143
+ writeJson(response, 400, { error: "unknown review action" });
3144
+ } catch (error) {
3145
+ ctx.logger.warn("dataops-managed: review action failed");
3146
+ writeJson(response, 502, { error: error instanceof Error ? error.message : String(error) });
3147
+ }
3148
+ }
3149
+ }), "dataops-managed: review action route");
3150
+ ctx.effect(() => ctx.webServer.register({
3151
+ kind: "exact",
3152
+ path: RBAC_PATH,
3153
+ handler: async (request, response) => {
3154
+ const api = reviewApiRef;
3155
+ if (api === void 0) {
3156
+ writeJson(response, 503, { error: "review api is not active" });
3157
+ return;
3158
+ }
3159
+ try {
3160
+ if (request.method === "GET") {
3161
+ const url = new URL(request.url ?? "/", "http://localhost");
3162
+ const section = url.searchParams.get("section") ?? "matrix";
3163
+ const suffix = section === "members" ? "?q=" + encodeURIComponent(url.searchParams.get("q") ?? "") + "&departmentCode=" + encodeURIComponent(url.searchParams.get("departmentCode") ?? "") : section === "resources" ? "?kind=" + encodeURIComponent(url.searchParams.get("kind") ?? "") : "";
3164
+ const target = section === "members" ? "/api/system/permission-center/members" + suffix : section === "departments" ? "/api/system/permission-center/departments" : section === "resources" ? "/api/system/permission-center/resources" + suffix : "/api/system/permission-center/matrix" + (url.searchParams.get("scope") === "dsh" ? "?scope=dsh" : "");
3165
+ const result = await api.request(target);
3166
+ writeJson(response, result.status, result.body);
3167
+ return;
3168
+ }
3169
+ if (request.method !== "PUT") {
3170
+ response.writeHead(405, {
3171
+ allow: "GET, PUT",
3172
+ "content-length": "0"
3173
+ });
3174
+ response.end();
3175
+ return;
3176
+ }
3177
+ const body = await readJson(request);
3178
+ const section = typeof body?.section === "string" ? body.section : "";
3179
+ const value = typeof body?.value === "object" && body.value !== null ? body.value : {};
3180
+ if (section === "roles") {
3181
+ const result = await api.request("/api/system/permission-center/matrix", {
3182
+ method: "PUT",
3183
+ body: JSON.stringify(value)
3184
+ });
3185
+ writeJson(response, result.status, result.body);
3186
+ return;
3187
+ }
3188
+ if (section === "members" && typeof value.userId === "string") {
3189
+ const result = await api.request("/api/system/permission-center/members/" + encodeURIComponent(value.userId) + "/roles", {
3190
+ method: "PUT",
3191
+ body: JSON.stringify({ roleCodes: value.roleCodes })
3192
+ });
3193
+ writeJson(response, result.status, result.body);
3194
+ return;
3195
+ }
3196
+ if (section === "departments" && typeof value.code === "string") {
3197
+ const result = await api.request("/api/system/permission-center/departments/" + encodeURIComponent(value.code) + "/admin", {
3198
+ method: "PUT",
3199
+ body: JSON.stringify({ userIds: value.userIds })
3200
+ });
3201
+ writeJson(response, result.status, result.body);
3202
+ return;
3203
+ }
3204
+ if (section === "resources" && typeof value.kind === "string" && value.id !== void 0) {
3205
+ const result = await api.request("/api/system/permission-center/resources/" + encodeURIComponent(value.kind) + "/" + encodeURIComponent(String(value.id)) + "/scope", {
3206
+ method: "PUT",
3207
+ body: JSON.stringify({
3208
+ departmentCode: value.departmentCode,
3209
+ visibility: value.visibility,
3210
+ ownerId: value.ownerId
3211
+ })
3212
+ });
3213
+ writeJson(response, result.status, result.body);
3214
+ return;
3215
+ }
3216
+ writeJson(response, 400, { error: "unknown rbac section" });
3217
+ } catch (error) {
3218
+ ctx.logger.warn("dataops-managed: permission centre failed");
3219
+ writeJson(response, 502, { error: error instanceof Error ? error.message : String(error) });
3220
+ }
3221
+ }
3222
+ }), "dataops-managed: permission centre route");
3223
+ ctx.effect(() => ctx.webServer.register({
3224
+ kind: "exact",
3225
+ path: DEPARTMENTS_PATH,
3226
+ handler: async (request, response) => {
3227
+ const api = reviewApiRef;
3228
+ if (api === void 0) {
3229
+ writeJson(response, 503, { error: "review api is not active" });
3230
+ return;
3231
+ }
3232
+ try {
3233
+ const body = await readJson(request);
3234
+ if (request.method === "POST") {
3235
+ const result = await api.request("/api/system/departments", {
3236
+ method: "POST",
3237
+ body: JSON.stringify(body ?? {})
3238
+ });
3239
+ writeJson(response, result.status, result.body);
3240
+ return;
3241
+ }
3242
+ if (request.method !== "PATCH" && request.method !== "DELETE") {
3243
+ response.writeHead(405, {
3244
+ allow: "POST, PATCH, DELETE",
3245
+ "content-length": "0"
3246
+ });
3247
+ response.end();
3248
+ return;
3249
+ }
3250
+ const code = typeof body?.code === "string" ? body.code : "";
3251
+ if (code === "") {
3252
+ writeJson(response, 400, { error: "department code is required" });
3253
+ return;
3254
+ }
3255
+ const current = await api.request("/api/system/departments/" + encodeURIComponent(code));
3256
+ const record = typeof current.body === "object" && current.body !== null ? current.body : {};
3257
+ const department = typeof record.department === "object" && record.department !== null ? record.department : {};
3258
+ const updatedAt = typeof body?.updatedAt === "string" ? body.updatedAt : department.updatedAt;
3259
+ if (typeof updatedAt !== "string") {
3260
+ writeJson(response, current.status === 200 ? 409 : current.status, { error: "department not found" });
3261
+ return;
3262
+ }
3263
+ if (request.method === "PATCH") {
3264
+ const result = await api.request("/api/system/departments/" + encodeURIComponent(code), {
3265
+ method: "PATCH",
3266
+ body: JSON.stringify({
3267
+ updatedAt,
3268
+ name: body?.name,
3269
+ parentCode: body?.parentCode,
3270
+ description: body?.description,
3271
+ owner: body?.owner,
3272
+ enabled: body?.enabled
3273
+ })
3274
+ });
3275
+ writeJson(response, result.status, result.body);
3276
+ return;
3277
+ }
3278
+ const result = await api.request("/api/system/departments/" + encodeURIComponent(code), {
3279
+ method: "DELETE",
3280
+ body: JSON.stringify({ updatedAt })
3281
+ });
3282
+ writeJson(response, result.status, result.body);
3283
+ } catch (error) {
3284
+ ctx.logger.warn("dataops-managed: department bridge failed");
3285
+ writeJson(response, 502, { error: error instanceof Error ? error.message : String(error) });
3286
+ }
3287
+ }
3288
+ }), "dataops-managed: department CRUD bridge");
3289
+ ctx.effect(() => ctx.webServer.register({
3290
+ kind: "exact",
3291
+ path: REVIEW_SETTINGS_PATH,
3292
+ handler: async (request, response) => {
3293
+ const api = reviewApiRef;
3294
+ if (api === void 0) {
3295
+ writeJson(response, 503, { error: "review api is not active" });
3296
+ return;
3297
+ }
3298
+ try {
3299
+ if (request.method === "GET") {
3300
+ const result = await api.request("/api/integrations/dataops/review-settings");
3301
+ writeJson(response, result.status, result.body);
3302
+ return;
3303
+ }
3304
+ if (request.method !== "PUT" && request.method !== "POST") {
3305
+ response.writeHead(405, {
3306
+ allow: "GET, POST, PUT",
3307
+ "content-length": "0"
3308
+ });
3309
+ response.end();
3310
+ return;
3311
+ }
3312
+ const body = await readJson(request);
3313
+ const result = await api.request("/api/integrations/dataops/review-settings", {
3314
+ method: "POST",
3315
+ body: JSON.stringify(body ?? {})
3316
+ });
3317
+ writeJson(response, result.status, result.body);
3318
+ } catch (error) {
3319
+ ctx.logger.warn("dataops-managed: review settings failed");
3320
+ writeJson(response, 502, { error: error instanceof Error ? error.message : String(error) });
3321
+ }
3322
+ }
3323
+ }), "dataops-managed: review settings route");
3324
+ /**
1278
3325
  * The workspace limits an administrator sets in DataOps.
1279
3326
  *
1280
3327
  * Read through the plugin because it holds the DataOps JWT; the page cannot reach those settings
@@ -1378,6 +3425,133 @@ async function apply(ctx, config) {
1378
3425
  }
1379
3426
  }
1380
3427
  }), "dataops-managed: tool timeout route");
3428
+ /**
3429
+ * Workspace image upgrade: DataOps owns the image comparison and restart; this
3430
+ * plugin adds the DSH-side running-session snapshot and post-restart recovery.
3431
+ */
3432
+ ctx.effect(() => ctx.webServer.register({
3433
+ kind: "exact",
3434
+ path: WORKSPACE_UPGRADE_PATH,
3435
+ handler: async (request, response) => {
3436
+ try {
3437
+ if (request.method === "GET") {
3438
+ const preview = await workspaceUpgrade.preview();
3439
+ writeJson(response, 200, {
3440
+ imageStatus: preview.imageStatus,
3441
+ configuredImage: preview.configuredImage,
3442
+ activeSessions: preview.activeSessions
3443
+ });
3444
+ return;
3445
+ }
3446
+ if (request.method !== "POST") {
3447
+ response.writeHead(405, {
3448
+ allow: "GET, POST",
3449
+ "content-length": "0"
3450
+ });
3451
+ response.end();
3452
+ return;
3453
+ }
3454
+ const state = await workspaceUpgrade.upgrade();
3455
+ writeJson(response, 202, {
3456
+ accepted: true,
3457
+ upgradeId: state.upgradeId,
3458
+ sessions: state.sessions.length
3459
+ });
3460
+ } catch (error) {
3461
+ writeJson(response, error instanceof WorkspaceUpgradeError ? error.status : 502, { error: error instanceof Error ? error.message : String(error) });
3462
+ }
3463
+ }
3464
+ }), "dataops-managed: workspace upgrade route");
3465
+ recoverWorkspaceUpgradeAfterBoot(workspaceUpgrade);
3466
+ /**
3467
+ * What the last DataOps answer said about this workspace's credential.
3468
+ *
3469
+ * The settings surfaces need this because a 401 is handled deep inside a call that degrades to
3470
+ * "no data", so the panel renders as if the workspace simply has nothing to show. Reading this
3471
+ * route is how the interface learns the reason is an ended session rather than an empty one.
3472
+ * The recorded state is an observation, never an authority: DataOps still decides.
3473
+ */
3474
+ ctx.effect(() => ctx.webServer.register({
3475
+ kind: "exact",
3476
+ path: AUTH_STATE_PATH,
3477
+ handler: async (request, response) => {
3478
+ if (request.method !== "GET") {
3479
+ response.writeHead(405, {
3480
+ allow: "GET",
3481
+ "content-length": "0"
3482
+ });
3483
+ response.end();
3484
+ return;
3485
+ }
3486
+ await adoptRequestJwt(ctx, accessRef, request);
3487
+ writeJson(response, 200, { state: currentAuthState() });
3488
+ }
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");
1381
3555
  await connectExisting();
1382
3556
  }
1383
3557
  /** Read and parse a JSON request body, tolerating an absent one. */
@@ -1402,4 +3576,4 @@ function writeJson(response, status, body) {
1402
3576
  response.end(payload);
1403
3577
  }
1404
3578
  //#endregion
1405
- export { Config, DEFAULT_TOOL_CALL_TIMEOUT_MS, MANAGED_AUTH_PATH, MANAGED_PLUGIN_ID, MODEL_SYNC_PATH, SKILL_PLAZA_PATH, TOOL_TIMEOUT_MAX_SECONDS, TOOL_TIMEOUT_MIN_SECONDS, TOOL_TIMEOUT_PATH, WORKSPACE_LIMITS_PATH, apply, currentModelSync, currentSettingsSync, currentSkillPlaza, currentWorkspaceLimits, inject, name };
3579
+ export { AUTH_STATE_PATH, Config, DEFAULT_TOOL_CALL_TIMEOUT_MS, DEPARTMENTS_PATH, MANAGED_AUTH_PATH, MANAGED_PLUGIN_ID, MODEL_SYNC_PATH, RBAC_PATH, REVIEW_ACTION_PATH, REVIEW_CONTEXT_PATH, REVIEW_NOTIFICATIONS_PATH, REVIEW_SETTINGS_PATH, SKILL_PLAZA_PATH, TOOL_TIMEOUT_MAX_SECONDS, TOOL_TIMEOUT_MIN_SECONDS, TOOL_TIMEOUT_PATH, WORKSPACE_LIMITS_PATH, WORKSPACE_UPGRADE_PATH, adoptRequestJwt, apply, currentModelSync, currentSettingsSync, currentSkillPlaza, currentWorkspaceLimits, inject, name };