bopodev-api 0.1.34 → 0.1.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/app.ts +4 -2
- package/src/assets/starter-packs/customer-support-excellence.zip +0 -0
- package/src/assets/starter-packs/devrel-growth.zip +0 -0
- package/src/assets/starter-packs/product-delivery-trio.zip +0 -0
- package/src/assets/starter-packs/revenue-gtm-b2b.zip +0 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/.bopo.yaml +129 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/COMPANY.md +7 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/README.md +3 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/agents/founder-ceo/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/agents/support-lead/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/agents/support-specialist/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/projects/knowledge-base/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/projects/quality/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/projects/queue/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/skills/kb-article-skeleton/SKILL.md +17 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/skills/ticket-response-playbook/SKILL.md +15 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/tasks/daily-queue-standup/TASK.md +11 -0
- package/src/assets/starter-packs/sources/customer-support-excellence/tasks/kb-gap-sweep/TASK.md +11 -0
- package/src/assets/starter-packs/sources/devrel-growth/.bopo.yaml +128 -0
- package/src/assets/starter-packs/sources/devrel-growth/COMPANY.md +7 -0
- package/src/assets/starter-packs/sources/devrel-growth/README.md +3 -0
- package/src/assets/starter-packs/sources/devrel-growth/agents/content-producer/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/devrel-growth/agents/devrel-lead/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/devrel-growth/agents/founder-ceo/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/devrel-growth/projects/community/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/devrel-growth/projects/docs-education/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/devrel-growth/projects/partners/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/devrel-growth/skills/changelog-to-post/SKILL.md +14 -0
- package/src/assets/starter-packs/sources/devrel-growth/skills/tutorial-outline/SKILL.md +15 -0
- package/src/assets/starter-packs/sources/devrel-growth/tasks/community-health-review/TASK.md +11 -0
- package/src/assets/starter-packs/sources/devrel-growth/tasks/weekly-content-plan/TASK.md +11 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/.bopo.yaml +138 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/COMPANY.md +7 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/README.md +9 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/agents/engineer-ic/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/agents/founder-ceo/HEARTBEAT.md +6 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/agents/product-lead/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/projects/delivery/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/projects/quality/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/projects/strategy/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/skills/issue-triage/SKILL.md +21 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/skills/rca-template/SKILL.md +16 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/tasks/release-hygiene/TASK.md +11 -0
- package/src/assets/starter-packs/sources/product-delivery-trio/tasks/weekly-leadership-sync/TASK.md +11 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/.bopo.yaml +132 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/COMPANY.md +7 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/README.md +3 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/founder-ceo/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/gtm-lead/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/agents/pipeline-owner/HEARTBEAT.md +5 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/customer-success/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/deals/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/projects/pipeline/PROJECT.md +7 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/skills/discovery-call-brief/SKILL.md +14 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/skills/icp-scoring/SKILL.md +20 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/tasks/pipeline-hygiene/TASK.md +11 -0
- package/src/assets/starter-packs/sources/revenue-gtm-b2b/tasks/weekly-revenue-review/TASK.md +11 -0
- package/src/lib/agent-issue-permissions.ts +56 -0
- package/src/lib/builtin-bopo-skills/bopodev-control-plane.md +7 -0
- package/src/lib/instance-paths.ts +5 -0
- package/src/realtime/office-space.ts +7 -0
- package/src/routes/agents.ts +23 -1
- package/src/routes/assistant.ts +40 -1
- package/src/routes/companies.ts +227 -15
- package/src/routes/issues.ts +82 -3
- package/src/routes/observability.ts +222 -0
- package/src/routes/plugins.ts +393 -103
- package/src/routes/{loops.ts → routines.ts} +72 -76
- package/src/scripts/onboard-seed.ts +2 -0
- package/src/server.ts +3 -1
- package/src/services/company-assistant-context-snapshot.ts +4 -2
- package/src/services/company-assistant-service.ts +17 -15
- package/src/services/company-file-archive-service.ts +81 -6
- package/src/services/company-file-import-service.ts +221 -31
- package/src/services/company-knowledge-file-service.ts +361 -0
- package/src/services/company-skill-file-service.ts +151 -2
- package/src/services/governance-service.ts +58 -3
- package/src/services/heartbeat-service/heartbeat-run.ts +7 -0
- package/src/services/plugin-artifact-installer.ts +115 -0
- package/src/services/plugin-artifact-store.ts +28 -0
- package/src/services/plugin-capability-policy.ts +31 -0
- package/src/services/plugin-jobs-service.ts +74 -0
- package/src/services/plugin-manifest-loader.ts +78 -3
- package/src/services/plugin-rpc.ts +102 -0
- package/src/services/plugin-runtime.ts +240 -209
- package/src/services/plugin-worker-host.ts +167 -0
- package/src/services/starter-pack-registry.ts +68 -0
- package/src/services/template-apply-service.ts +3 -1
- package/src/services/template-catalog.ts +29 -0
- package/src/services/work-loop-service/work-loop-service.ts +18 -18
- package/src/shutdown/graceful-shutdown.ts +3 -1
- package/src/validation/issue-routes.ts +19 -2
- package/src/worker/scheduler.ts +21 -1
- package/src/services/company-export-service.ts +0 -63
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { isInsidePath, resolveCompanyKnowledgePath, resolveCompanyProjectsWorkspacePath } from "../lib/instance-paths";
|
|
4
|
+
|
|
5
|
+
const MAX_OBSERVABILITY_FILES = 200;
|
|
6
|
+
const MAX_OBSERVABILITY_FILE_BYTES = 512 * 1024;
|
|
7
|
+
const MAX_PATH_SEGMENTS = 32;
|
|
8
|
+
const TEXT_EXT = new Set([".md", ".yaml", ".yml", ".txt", ".json"]);
|
|
9
|
+
|
|
10
|
+
/** Default file body when POST create omits `content`. Markdown/text start empty (no frontmatter boilerplate). */
|
|
11
|
+
function defaultContentForNewKnowledgeFile(relativePath: string): string {
|
|
12
|
+
const lower = relativePath.toLowerCase();
|
|
13
|
+
if (lower.endsWith(".json")) {
|
|
14
|
+
return "{}\n";
|
|
15
|
+
}
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function assertKnowledgeRelativePath(relativePath: string): string {
|
|
20
|
+
const normalized = relativePath.trim().replace(/\\/g, "/");
|
|
21
|
+
if (!normalized || normalized.startsWith("/") || normalized.includes("..")) {
|
|
22
|
+
throw new Error("Invalid relative path.");
|
|
23
|
+
}
|
|
24
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
25
|
+
if (parts.length === 0 || parts.length > MAX_PATH_SEGMENTS) {
|
|
26
|
+
throw new Error("Invalid relative path.");
|
|
27
|
+
}
|
|
28
|
+
for (const p of parts) {
|
|
29
|
+
if (p === "." || p === ".." || p.startsWith(".")) {
|
|
30
|
+
throw new Error("Invalid relative path.");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const base = parts[parts.length - 1]!;
|
|
34
|
+
const lower = base.toLowerCase();
|
|
35
|
+
const ext = lower.includes(".") ? lower.slice(lower.lastIndexOf(".")) : "";
|
|
36
|
+
if (!TEXT_EXT.has(ext)) {
|
|
37
|
+
throw new Error("Only text knowledge files (.md, .yaml, .yml, .txt, .json) are allowed.");
|
|
38
|
+
}
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function knowledgeRoot(companyId: string) {
|
|
43
|
+
const root = resolveCompanyKnowledgePath(companyId);
|
|
44
|
+
const companyWorkspace = resolveCompanyProjectsWorkspacePath(companyId);
|
|
45
|
+
if (!isInsidePath(companyWorkspace, root)) {
|
|
46
|
+
throw new Error("Invalid knowledge root.");
|
|
47
|
+
}
|
|
48
|
+
return { root };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function listKnowledgeFiles(input: { companyId: string; maxFiles?: number }) {
|
|
52
|
+
const { root } = await knowledgeRoot(input.companyId);
|
|
53
|
+
await mkdir(root, { recursive: true });
|
|
54
|
+
const maxFiles = Math.max(1, Math.min(MAX_OBSERVABILITY_FILES, input.maxFiles ?? MAX_OBSERVABILITY_FILES));
|
|
55
|
+
const relativePaths = await walkKnowledgeTextFiles(root, maxFiles);
|
|
56
|
+
return { root, files: relativePaths.map((relativePath) => ({ relativePath })) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type KnowledgeTreeNode =
|
|
60
|
+
| { type: "file"; name: string; relativePath: string }
|
|
61
|
+
| { type: "dir"; name: string; children: KnowledgeTreeNode[] };
|
|
62
|
+
|
|
63
|
+
/** Nested tree from flat relative paths (trie). */
|
|
64
|
+
export function buildKnowledgeTreeFromPaths(files: { relativePath: string }[]): KnowledgeTreeNode[] {
|
|
65
|
+
type Trie = { dirs: Map<string, Trie>; files: KnowledgeTreeNode[] };
|
|
66
|
+
const root: Trie = { dirs: new Map(), files: [] };
|
|
67
|
+
|
|
68
|
+
for (const { relativePath } of [...files].sort((a, b) => a.relativePath.localeCompare(b.relativePath))) {
|
|
69
|
+
const parts = relativePath.split("/").filter(Boolean);
|
|
70
|
+
if (parts.length === 0) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const fileName = parts.pop()!;
|
|
74
|
+
let node = root;
|
|
75
|
+
for (const segment of parts) {
|
|
76
|
+
if (!node.dirs.has(segment)) {
|
|
77
|
+
node.dirs.set(segment, { dirs: new Map(), files: [] });
|
|
78
|
+
}
|
|
79
|
+
node = node.dirs.get(segment)!;
|
|
80
|
+
}
|
|
81
|
+
node.files.push({ type: "file", name: fileName, relativePath });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function trieToNodes(trie: Trie): KnowledgeTreeNode[] {
|
|
85
|
+
const dirNodes: KnowledgeTreeNode[] = [];
|
|
86
|
+
for (const [name, child] of [...trie.dirs.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
87
|
+
const children = trieToNodes(child);
|
|
88
|
+
dirNodes.push({ type: "dir", name, children });
|
|
89
|
+
}
|
|
90
|
+
const sortedFiles = [...trie.files].sort((a, b) => a.name.localeCompare(b.name));
|
|
91
|
+
return [...dirNodes, ...sortedFiles];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return trieToNodes(root);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function walkKnowledgeTextFiles(knowledgeDir: string, maxFiles: number): Promise<string[]> {
|
|
98
|
+
const collected: string[] = [];
|
|
99
|
+
const queue = [knowledgeDir];
|
|
100
|
+
while (queue.length > 0 && collected.length < maxFiles) {
|
|
101
|
+
const current = queue.shift();
|
|
102
|
+
if (!current) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
let entries: import("node:fs").Dirent[];
|
|
106
|
+
try {
|
|
107
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (collected.length >= maxFiles) {
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
const absolutePath = join(current, entry.name);
|
|
116
|
+
if (entry.isDirectory()) {
|
|
117
|
+
if (!entry.name.startsWith(".")) {
|
|
118
|
+
queue.push(absolutePath);
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (entry.name.startsWith(".")) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const lower = entry.name.toLowerCase();
|
|
126
|
+
const ext = lower.includes(".") ? lower.slice(lower.lastIndexOf(".")) : "";
|
|
127
|
+
if (TEXT_EXT.has(ext)) {
|
|
128
|
+
collected.push(relative(knowledgeDir, absolutePath).replace(/\\/g, "/"));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return collected.sort((a, b) => a.localeCompare(b));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function readKnowledgeFile(input: { companyId: string; relativePath: string }) {
|
|
136
|
+
const { root } = await knowledgeRoot(input.companyId);
|
|
137
|
+
const rel = assertKnowledgeRelativePath(input.relativePath);
|
|
138
|
+
const candidate = resolve(root, rel);
|
|
139
|
+
if (!isInsidePath(root, candidate)) {
|
|
140
|
+
throw new Error("Requested path is outside of knowledge directory.");
|
|
141
|
+
}
|
|
142
|
+
const info = await stat(candidate);
|
|
143
|
+
if (!info.isFile()) {
|
|
144
|
+
throw new Error("Requested path is not a file.");
|
|
145
|
+
}
|
|
146
|
+
if (info.size > MAX_OBSERVABILITY_FILE_BYTES) {
|
|
147
|
+
throw new Error("File exceeds size limit.");
|
|
148
|
+
}
|
|
149
|
+
const content = await readFile(candidate, "utf8");
|
|
150
|
+
return {
|
|
151
|
+
relativePath: rel,
|
|
152
|
+
content,
|
|
153
|
+
sizeBytes: info.size
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function writeKnowledgeFile(input: {
|
|
158
|
+
companyId: string;
|
|
159
|
+
relativePath: string;
|
|
160
|
+
content: string;
|
|
161
|
+
}) {
|
|
162
|
+
const { root } = await knowledgeRoot(input.companyId);
|
|
163
|
+
const rel = assertKnowledgeRelativePath(input.relativePath);
|
|
164
|
+
const candidate = resolve(root, rel);
|
|
165
|
+
if (!isInsidePath(root, candidate)) {
|
|
166
|
+
throw new Error("Requested path is outside of knowledge directory.");
|
|
167
|
+
}
|
|
168
|
+
const bytes = Buffer.byteLength(input.content, "utf8");
|
|
169
|
+
if (bytes > MAX_OBSERVABILITY_FILE_BYTES) {
|
|
170
|
+
throw new Error("Content exceeds size limit.");
|
|
171
|
+
}
|
|
172
|
+
const parent = dirname(candidate);
|
|
173
|
+
if (!isInsidePath(root, parent)) {
|
|
174
|
+
throw new Error("Invalid parent directory.");
|
|
175
|
+
}
|
|
176
|
+
await mkdir(parent, { recursive: true });
|
|
177
|
+
await writeFile(candidate, input.content, { encoding: "utf8" });
|
|
178
|
+
const info = await stat(candidate);
|
|
179
|
+
return {
|
|
180
|
+
relativePath: rel,
|
|
181
|
+
sizeBytes: info.size
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Create a new file; fails if it already exists. */
|
|
186
|
+
export async function createKnowledgeFile(input: {
|
|
187
|
+
companyId: string;
|
|
188
|
+
relativePath: string;
|
|
189
|
+
content?: string;
|
|
190
|
+
}) {
|
|
191
|
+
const { root } = await knowledgeRoot(input.companyId);
|
|
192
|
+
const rel = assertKnowledgeRelativePath(input.relativePath);
|
|
193
|
+
const candidate = resolve(root, rel);
|
|
194
|
+
if (!isInsidePath(root, candidate)) {
|
|
195
|
+
throw new Error("Requested path is outside of knowledge directory.");
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
await stat(candidate);
|
|
199
|
+
throw new Error("A file already exists at this path.");
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (error instanceof Error && error.message === "A file already exists at this path.") {
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
|
|
205
|
+
if (code !== "ENOENT") {
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const body = input.content ?? defaultContentForNewKnowledgeFile(rel);
|
|
210
|
+
const bytes = Buffer.byteLength(body, "utf8");
|
|
211
|
+
if (bytes > MAX_OBSERVABILITY_FILE_BYTES) {
|
|
212
|
+
throw new Error("Content exceeds size limit.");
|
|
213
|
+
}
|
|
214
|
+
const parent = dirname(candidate);
|
|
215
|
+
if (!isInsidePath(root, parent)) {
|
|
216
|
+
throw new Error("Invalid parent directory.");
|
|
217
|
+
}
|
|
218
|
+
await mkdir(parent, { recursive: true });
|
|
219
|
+
await writeFile(candidate, body, { encoding: "utf8" });
|
|
220
|
+
const info = await stat(candidate);
|
|
221
|
+
return {
|
|
222
|
+
relativePath: rel,
|
|
223
|
+
sizeBytes: info.size
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Rename/move a knowledge file within the knowledge root. */
|
|
228
|
+
export async function renameKnowledgeFile(input: {
|
|
229
|
+
companyId: string;
|
|
230
|
+
fromRelativePath: string;
|
|
231
|
+
toRelativePath: string;
|
|
232
|
+
}) {
|
|
233
|
+
const fromRel = assertKnowledgeRelativePath(input.fromRelativePath.trim());
|
|
234
|
+
const toRel = assertKnowledgeRelativePath(input.toRelativePath.trim());
|
|
235
|
+
if (fromRel === toRel) {
|
|
236
|
+
return { relativePath: toRel };
|
|
237
|
+
}
|
|
238
|
+
const { root } = await knowledgeRoot(input.companyId);
|
|
239
|
+
const fromAbs = resolve(root, fromRel);
|
|
240
|
+
const toAbs = resolve(root, toRel);
|
|
241
|
+
if (!isInsidePath(root, fromAbs) || !isInsidePath(root, toAbs)) {
|
|
242
|
+
throw new Error("Requested path is outside of knowledge directory.");
|
|
243
|
+
}
|
|
244
|
+
const fromInfo = await stat(fromAbs);
|
|
245
|
+
if (!fromInfo.isFile()) {
|
|
246
|
+
throw new Error("Source path is not a file.");
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
await stat(toAbs);
|
|
250
|
+
throw new Error("A file already exists at the destination path.");
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error instanceof Error && error.message === "A file already exists at the destination path.") {
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
|
|
256
|
+
if (code !== "ENOENT") {
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const parent = dirname(toAbs);
|
|
261
|
+
if (!isInsidePath(root, parent)) {
|
|
262
|
+
throw new Error("Invalid parent directory.");
|
|
263
|
+
}
|
|
264
|
+
await mkdir(parent, { recursive: true });
|
|
265
|
+
await rename(fromAbs, toAbs);
|
|
266
|
+
return { relativePath: toRel };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Folder path prefix (no trailing slash), e.g. `guides/onboarding`. */
|
|
270
|
+
export function assertKnowledgeFolderPrefix(prefix: string): string {
|
|
271
|
+
const normalized = prefix.trim().replace(/\\/g, "/").replace(/\/+$/g, "");
|
|
272
|
+
if (!normalized) {
|
|
273
|
+
throw new Error("Invalid folder path.");
|
|
274
|
+
}
|
|
275
|
+
if (normalized.startsWith("/") || normalized.includes("..")) {
|
|
276
|
+
throw new Error("Invalid folder path.");
|
|
277
|
+
}
|
|
278
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
279
|
+
if (parts.length === 0 || parts.length > MAX_PATH_SEGMENTS - 1) {
|
|
280
|
+
throw new Error("Invalid folder path.");
|
|
281
|
+
}
|
|
282
|
+
for (const p of parts) {
|
|
283
|
+
if (p === "." || p === ".." || p.startsWith(".")) {
|
|
284
|
+
throw new Error("Invalid folder path.");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return parts.join("/");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Rename a knowledge folder by moving every file under `fromPrefix/` to the same relative paths under `toPrefix`.
|
|
292
|
+
*/
|
|
293
|
+
export async function renameKnowledgeFolderPrefix(input: {
|
|
294
|
+
companyId: string;
|
|
295
|
+
fromPrefix: string;
|
|
296
|
+
toPrefix: string;
|
|
297
|
+
}) {
|
|
298
|
+
const fromP = assertKnowledgeFolderPrefix(input.fromPrefix);
|
|
299
|
+
const toP = assertKnowledgeFolderPrefix(input.toPrefix);
|
|
300
|
+
if (fromP === toP) {
|
|
301
|
+
return { moved: 0, fromPrefix: fromP, toPrefix: toP };
|
|
302
|
+
}
|
|
303
|
+
if (toP.startsWith(`${fromP}/`) || fromP.startsWith(`${toP}/`)) {
|
|
304
|
+
throw new Error("Invalid folder rename: one folder sits inside the other.");
|
|
305
|
+
}
|
|
306
|
+
const { files } = await listKnowledgeFiles({ companyId: input.companyId, maxFiles: MAX_OBSERVABILITY_FILES });
|
|
307
|
+
const paths = files.map((f) => f.relativePath);
|
|
308
|
+
const filePathsToMove = paths.filter((p) => p.startsWith(`${fromP}/`));
|
|
309
|
+
if (filePathsToMove.length === 0) {
|
|
310
|
+
throw new Error("No files found under that folder.");
|
|
311
|
+
}
|
|
312
|
+
const existing = new Set(paths);
|
|
313
|
+
for (const p of filePathsToMove) {
|
|
314
|
+
const np = `${toP}${p.slice(fromP.length)}`;
|
|
315
|
+
if (existing.has(np) && !filePathsToMove.includes(np)) {
|
|
316
|
+
throw new Error(`A file already exists at ${np}.`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const sorted = [...filePathsToMove].sort((a, b) => b.length - a.length);
|
|
320
|
+
for (const p of sorted) {
|
|
321
|
+
const np = `${toP}${p.slice(fromP.length)}`;
|
|
322
|
+
await renameKnowledgeFile({
|
|
323
|
+
companyId: input.companyId,
|
|
324
|
+
fromRelativePath: p,
|
|
325
|
+
toRelativePath: np
|
|
326
|
+
});
|
|
327
|
+
existing.delete(p);
|
|
328
|
+
existing.add(np);
|
|
329
|
+
}
|
|
330
|
+
return { moved: sorted.length, fromPrefix: fromP, toPrefix: toP };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function deleteKnowledgeFile(input: { companyId: string; relativePath: string }) {
|
|
334
|
+
const { root } = await knowledgeRoot(input.companyId);
|
|
335
|
+
const rel = assertKnowledgeRelativePath(input.relativePath);
|
|
336
|
+
const candidate = resolve(root, rel);
|
|
337
|
+
if (!isInsidePath(root, candidate)) {
|
|
338
|
+
throw new Error("Requested path is outside of knowledge directory.");
|
|
339
|
+
}
|
|
340
|
+
const info = await stat(candidate);
|
|
341
|
+
if (!info.isFile()) {
|
|
342
|
+
throw new Error("Requested path is not a file.");
|
|
343
|
+
}
|
|
344
|
+
await rm(candidate, { force: true });
|
|
345
|
+
return { relativePath: rel };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export async function knowledgeFileExists(input: { companyId: string; relativePath: string }): Promise<boolean> {
|
|
349
|
+
try {
|
|
350
|
+
const rel = assertKnowledgeRelativePath(input.relativePath);
|
|
351
|
+
const { root } = await knowledgeRoot(input.companyId);
|
|
352
|
+
const candidate = resolve(root, rel);
|
|
353
|
+
if (!isInsidePath(root, candidate)) {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
const info = await stat(candidate);
|
|
357
|
+
return info.isFile();
|
|
358
|
+
} catch {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import TurndownService from "turndown";
|
|
@@ -8,6 +8,9 @@ const MAX_OBSERVABILITY_FILES = 200;
|
|
|
8
8
|
const MAX_OBSERVABILITY_FILE_BYTES = 512 * 1024;
|
|
9
9
|
const SKILL_MD = "SKILL.md";
|
|
10
10
|
export const SKILL_LINK_BASENAME = ".bopo-skill-link.json";
|
|
11
|
+
/** Optional UI-only label for the skill in Settings (does not rename files or the skill id). */
|
|
12
|
+
export const SKILL_SIDEBAR_TITLE_BASENAME = ".bopo-skill-sidebar-title.json";
|
|
13
|
+
const MAX_SKILL_SIDEBAR_TITLE_CHARS = 200;
|
|
11
14
|
const SKILL_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
12
15
|
const TEXT_EXT = new Set([".md", ".yaml", ".yml", ".txt", ".json"]);
|
|
13
16
|
|
|
@@ -27,6 +30,8 @@ export type CompanySkillPackageListItem = {
|
|
|
27
30
|
skillId: string;
|
|
28
31
|
linkedUrl: string | null;
|
|
29
32
|
linkLastFetchedAt: string | null;
|
|
33
|
+
/** When set, shown in Settings sidebar instead of `skillId`. */
|
|
34
|
+
sidebarTitle: string | null;
|
|
30
35
|
};
|
|
31
36
|
|
|
32
37
|
export function assertCompanySkillId(skillId: string): string {
|
|
@@ -101,6 +106,24 @@ export async function readOptionalSkillLinkUrl(root: string): Promise<string | n
|
|
|
101
106
|
return rec?.url ?? null;
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
export async function readOptionalSkillSidebarTitle(root: string): Promise<string | null> {
|
|
110
|
+
try {
|
|
111
|
+
const raw = await readFile(join(root, SKILL_SIDEBAR_TITLE_BASENAME), "utf8");
|
|
112
|
+
const parsed: unknown = JSON.parse(raw);
|
|
113
|
+
if (!parsed || typeof parsed !== "object") {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const t = (parsed as { sidebarTitle?: unknown }).sidebarTitle;
|
|
117
|
+
if (typeof t !== "string") {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const s = t.trim();
|
|
121
|
+
return s.length > 0 ? s : null;
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
104
127
|
async function writeSkillLinkMetadata(root: string, url: string): Promise<{ lastFetchedAt: string }> {
|
|
105
128
|
const lastFetchedAt = new Date().toISOString();
|
|
106
129
|
await writeFile(
|
|
@@ -131,10 +154,12 @@ export async function listCompanySkillPackages(input: { companyId: string; maxSk
|
|
|
131
154
|
if (!hasMd && !linkedUrl) {
|
|
132
155
|
continue;
|
|
133
156
|
}
|
|
157
|
+
const sidebarTitle = await readOptionalSkillSidebarTitle(skillDir);
|
|
134
158
|
items.push({
|
|
135
159
|
skillId: ent.name,
|
|
136
160
|
linkedUrl,
|
|
137
|
-
linkLastFetchedAt: linkRec?.lastFetchedAt ?? null
|
|
161
|
+
linkLastFetchedAt: linkRec?.lastFetchedAt ?? null,
|
|
162
|
+
sidebarTitle
|
|
138
163
|
});
|
|
139
164
|
if (items.length >= maxSkills) {
|
|
140
165
|
break;
|
|
@@ -227,6 +252,47 @@ export async function readCompanySkillFile(input: {
|
|
|
227
252
|
};
|
|
228
253
|
}
|
|
229
254
|
|
|
255
|
+
function assertSkillSidebarTitle(trimmed: string): string {
|
|
256
|
+
if (!trimmed) {
|
|
257
|
+
throw new Error("Title is empty.");
|
|
258
|
+
}
|
|
259
|
+
if (trimmed.length > MAX_SKILL_SIDEBAR_TITLE_CHARS) {
|
|
260
|
+
throw new Error(`Title must be at most ${MAX_SKILL_SIDEBAR_TITLE_CHARS} characters.`);
|
|
261
|
+
}
|
|
262
|
+
if (/[\r\n]/.test(trimmed)) {
|
|
263
|
+
throw new Error("Title cannot contain line breaks.");
|
|
264
|
+
}
|
|
265
|
+
return trimmed;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Set or clear the Settings-only sidebar label for a company skill package. */
|
|
269
|
+
export async function setCompanySkillSidebarTitle(input: {
|
|
270
|
+
companyId: string;
|
|
271
|
+
skillId: string;
|
|
272
|
+
/** Empty / whitespace removes the custom title (sidebar falls back to skill id). */
|
|
273
|
+
title: string;
|
|
274
|
+
}) {
|
|
275
|
+
const { root, id } = await skillRoot(input.companyId, input.skillId);
|
|
276
|
+
const hasMd = await skillDirHasSkillMd(root);
|
|
277
|
+
const linkedUrl = await readOptionalSkillLinkUrl(root);
|
|
278
|
+
if (!hasMd && !linkedUrl) {
|
|
279
|
+
throw new Error("Skill not found.");
|
|
280
|
+
}
|
|
281
|
+
const metaPath = join(root, SKILL_SIDEBAR_TITLE_BASENAME);
|
|
282
|
+
const trimmedAll = input.title.trim();
|
|
283
|
+
if (!trimmedAll || trimmedAll === id) {
|
|
284
|
+
try {
|
|
285
|
+
await rm(metaPath, { force: true });
|
|
286
|
+
} catch {
|
|
287
|
+
/* noop */
|
|
288
|
+
}
|
|
289
|
+
return { skillId: id, sidebarTitle: null as string | null };
|
|
290
|
+
}
|
|
291
|
+
const t = assertSkillSidebarTitle(trimmedAll);
|
|
292
|
+
await writeFile(metaPath, JSON.stringify({ sidebarTitle: t }, null, 2), "utf8");
|
|
293
|
+
return { skillId: id, sidebarTitle: t };
|
|
294
|
+
}
|
|
295
|
+
|
|
230
296
|
export async function writeCompanySkillFile(input: {
|
|
231
297
|
companyId: string;
|
|
232
298
|
skillId: string;
|
|
@@ -263,6 +329,89 @@ export async function writeCompanySkillFile(input: {
|
|
|
263
329
|
};
|
|
264
330
|
}
|
|
265
331
|
|
|
332
|
+
/** Rename/move a text file within one skill package directory. */
|
|
333
|
+
export async function renameCompanySkillFile(input: {
|
|
334
|
+
companyId: string;
|
|
335
|
+
skillId: string;
|
|
336
|
+
fromRelativePath: string;
|
|
337
|
+
toRelativePath: string;
|
|
338
|
+
}) {
|
|
339
|
+
const { root } = await skillRoot(input.companyId, input.skillId);
|
|
340
|
+
const fromRel = assertSkillRelativePath(input.fromRelativePath.trim());
|
|
341
|
+
const toRel = assertSkillRelativePath(input.toRelativePath.trim());
|
|
342
|
+
if (fromRel === toRel) {
|
|
343
|
+
return { skillId: input.skillId, relativePath: toRel };
|
|
344
|
+
}
|
|
345
|
+
const fromAbs = resolve(root, fromRel);
|
|
346
|
+
const toAbs = resolve(root, toRel);
|
|
347
|
+
if (!isInsidePath(root, fromAbs) || !isInsidePath(root, toAbs)) {
|
|
348
|
+
throw new Error("Requested path is outside of skill directory.");
|
|
349
|
+
}
|
|
350
|
+
const hasMd = await skillDirHasSkillMd(root);
|
|
351
|
+
const linkedUrl = await readOptionalSkillLinkUrl(root);
|
|
352
|
+
if (linkedUrl && !hasMd && fromRel === SKILL_MD) {
|
|
353
|
+
throw new Error("This skill is loaded from a URL only; save a local copy before renaming.");
|
|
354
|
+
}
|
|
355
|
+
const fromInfo = await stat(fromAbs);
|
|
356
|
+
if (!fromInfo.isFile()) {
|
|
357
|
+
throw new Error("Source path is not a file.");
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
await stat(toAbs);
|
|
361
|
+
throw new Error("A file already exists at the destination path.");
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (error instanceof Error && error.message === "A file already exists at the destination path.") {
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
|
|
367
|
+
if (code !== "ENOENT") {
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const parent = dirname(toAbs);
|
|
372
|
+
if (!isInsidePath(root, parent)) {
|
|
373
|
+
throw new Error("Invalid parent directory.");
|
|
374
|
+
}
|
|
375
|
+
await mkdir(parent, { recursive: true });
|
|
376
|
+
await rename(fromAbs, toAbs);
|
|
377
|
+
return { skillId: input.skillId, relativePath: toRel };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Delete one text file from a skill package. Removing sole local SKILL.md with no URL link deletes the package folder. */
|
|
381
|
+
export async function deleteCompanySkillFile(input: {
|
|
382
|
+
companyId: string;
|
|
383
|
+
skillId: string;
|
|
384
|
+
relativePath: string;
|
|
385
|
+
}) {
|
|
386
|
+
const { root } = await skillRoot(input.companyId, input.skillId);
|
|
387
|
+
const rel = assertSkillRelativePath(input.relativePath.trim());
|
|
388
|
+
const candidate = resolve(root, rel);
|
|
389
|
+
if (!isInsidePath(root, candidate)) {
|
|
390
|
+
throw new Error("Requested path is outside of skill directory.");
|
|
391
|
+
}
|
|
392
|
+
const hasMd = await skillDirHasSkillMd(root);
|
|
393
|
+
const linkedUrl = await readOptionalSkillLinkUrl(root);
|
|
394
|
+
if (rel === SKILL_MD && linkedUrl && !hasMd) {
|
|
395
|
+
throw new Error("This skill is loaded from a URL only; delete the whole skill or refresh from URL.");
|
|
396
|
+
}
|
|
397
|
+
const allFiles = await walkSkillTextFiles(root, MAX_OBSERVABILITY_FILES);
|
|
398
|
+
if (rel === SKILL_MD && allFiles.length > 1) {
|
|
399
|
+
throw new Error("Remove other files in this skill before deleting SKILL.md.");
|
|
400
|
+
}
|
|
401
|
+
const info = await stat(candidate);
|
|
402
|
+
if (!info.isFile()) {
|
|
403
|
+
throw new Error("Requested path is not a file.");
|
|
404
|
+
}
|
|
405
|
+
await rm(candidate, { force: true });
|
|
406
|
+
if (rel === SKILL_MD && allFiles.length === 1) {
|
|
407
|
+
const stillLinked = await readOptionalSkillLinkUrl(root);
|
|
408
|
+
if (!stillLinked) {
|
|
409
|
+
await rm(root, { recursive: true, force: true });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return { skillId: input.skillId, relativePath: rel };
|
|
413
|
+
}
|
|
414
|
+
|
|
266
415
|
/** Remove `skills/<id>/` entirely (local files and linked-skill pointer). */
|
|
267
416
|
export async function deleteCompanySkillPackage(input: { companyId: string; skillId: string }) {
|
|
268
417
|
const { root, id } = await skillRoot(input.companyId, input.skillId);
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
AGENT_ROLE_LABELS,
|
|
5
5
|
AgentCreateRequestSchema,
|
|
6
6
|
AgentRoleKeySchema,
|
|
7
|
+
PluginInstallSourceTypeSchema,
|
|
8
|
+
PluginManifestV2Schema,
|
|
7
9
|
TemplateManifestDefault,
|
|
8
10
|
TemplateManifestSchema
|
|
9
11
|
} from "bopodev-contracts";
|
|
@@ -13,6 +15,7 @@ import {
|
|
|
13
15
|
approvalRequests,
|
|
14
16
|
agents,
|
|
15
17
|
appendAuditEvent,
|
|
18
|
+
appendPluginInstall,
|
|
16
19
|
createAgent,
|
|
17
20
|
createGoal,
|
|
18
21
|
createIssue,
|
|
@@ -28,6 +31,7 @@ import {
|
|
|
28
31
|
projects,
|
|
29
32
|
eq,
|
|
30
33
|
updateProjectWorkspace,
|
|
34
|
+
markPluginInstallsSuperseded,
|
|
31
35
|
updatePluginConfig
|
|
32
36
|
} from "bopodev-db";
|
|
33
37
|
import {
|
|
@@ -44,6 +48,8 @@ import {
|
|
|
44
48
|
} from "../lib/instance-paths";
|
|
45
49
|
import { assertRuntimeCwdForCompany, hasText, resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
|
|
46
50
|
import { appendDurableFact } from "./memory-file-service";
|
|
51
|
+
import { writePackagedPluginManifestToFilesystem } from "./plugin-manifest-loader";
|
|
52
|
+
import { registerPluginManifest } from "./plugin-runtime";
|
|
47
53
|
import { applyTemplateManifest } from "./template-apply-service";
|
|
48
54
|
|
|
49
55
|
const approvalGatedActions = new Set([
|
|
@@ -97,7 +103,14 @@ const grantPluginCapabilitiesPayloadSchema = z.object({
|
|
|
97
103
|
enabled: z.boolean().optional(),
|
|
98
104
|
priority: z.number().int().min(0).max(1000).optional(),
|
|
99
105
|
grantedCapabilities: z.array(z.string().min(1)).default([]),
|
|
100
|
-
|
|
106
|
+
capabilityNamespaces: z.array(z.string().min(1)).default([]),
|
|
107
|
+
config: z.record(z.string(), z.unknown()).default({}),
|
|
108
|
+
sourceType: PluginInstallSourceTypeSchema.optional(),
|
|
109
|
+
sourceRef: z.string().optional(),
|
|
110
|
+
integrity: z.string().optional(),
|
|
111
|
+
buildHash: z.string().optional(),
|
|
112
|
+
manifestJson: z.string().optional(),
|
|
113
|
+
install: z.boolean().default(true)
|
|
101
114
|
});
|
|
102
115
|
const applyTemplatePayloadSchema = z.object({
|
|
103
116
|
templateId: z.string().min(1),
|
|
@@ -311,6 +324,8 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
311
324
|
heartbeatCron: parsed.data.heartbeatCron,
|
|
312
325
|
monthlyBudgetUsd: parsed.data.monthlyBudgetUsd.toFixed(4),
|
|
313
326
|
canHireAgents: parsed.data.canHireAgents,
|
|
327
|
+
canAssignAgents: parsed.data.canAssignAgents,
|
|
328
|
+
canCreateIssues: parsed.data.canCreateIssues,
|
|
314
329
|
...runtimeConfigToDb(runtimeConfig),
|
|
315
330
|
initialState: runtimeConfigToStateBlobPatch(runtimeConfig)
|
|
316
331
|
});
|
|
@@ -556,13 +571,52 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
556
571
|
if (!parsed.success) {
|
|
557
572
|
throw new GovernanceError("Approval payload for plugin capability grant is invalid.");
|
|
558
573
|
}
|
|
574
|
+
if (parsed.data.manifestJson) {
|
|
575
|
+
let rawManifest: unknown;
|
|
576
|
+
try {
|
|
577
|
+
rawManifest = JSON.parse(parsed.data.manifestJson);
|
|
578
|
+
} catch {
|
|
579
|
+
throw new GovernanceError("Plugin install manifest JSON is invalid.");
|
|
580
|
+
}
|
|
581
|
+
const manifestParsed = PluginManifestV2Schema.safeParse(rawManifest);
|
|
582
|
+
if (!manifestParsed.success) {
|
|
583
|
+
throw new GovernanceError("Plugin install manifest payload failed validation.");
|
|
584
|
+
}
|
|
585
|
+
await writePackagedPluginManifestToFilesystem(manifestParsed.data, {
|
|
586
|
+
sourceType: parsed.data.sourceType ?? "registry",
|
|
587
|
+
sourceRef: parsed.data.sourceRef,
|
|
588
|
+
integrity: parsed.data.integrity,
|
|
589
|
+
buildHash: parsed.data.buildHash
|
|
590
|
+
});
|
|
591
|
+
await registerPluginManifest(db, manifestParsed.data);
|
|
592
|
+
await markPluginInstallsSuperseded(db, {
|
|
593
|
+
companyId,
|
|
594
|
+
pluginId: parsed.data.pluginId
|
|
595
|
+
});
|
|
596
|
+
await appendPluginInstall(db, {
|
|
597
|
+
companyId,
|
|
598
|
+
pluginId: parsed.data.pluginId,
|
|
599
|
+
pluginVersion: manifestParsed.data.version,
|
|
600
|
+
sourceType: parsed.data.sourceType ?? "registry",
|
|
601
|
+
sourceRef: parsed.data.sourceRef ?? null,
|
|
602
|
+
integrity: parsed.data.integrity ?? null,
|
|
603
|
+
buildHash: parsed.data.buildHash ?? null,
|
|
604
|
+
artifactPath: manifestParsed.data.install?.artifactPath ?? null,
|
|
605
|
+
manifestJson: JSON.stringify(manifestParsed.data),
|
|
606
|
+
status: "active"
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
const configWithNamespaces = {
|
|
610
|
+
...parsed.data.config,
|
|
611
|
+
_grantedCapabilityNamespaces: parsed.data.capabilityNamespaces
|
|
612
|
+
};
|
|
559
613
|
await updatePluginConfig(db, {
|
|
560
614
|
companyId,
|
|
561
615
|
pluginId: parsed.data.pluginId,
|
|
562
616
|
enabled: parsed.data.enabled,
|
|
563
617
|
priority: parsed.data.priority,
|
|
564
618
|
grantedCapabilitiesJson: JSON.stringify(parsed.data.grantedCapabilities),
|
|
565
|
-
configJson: JSON.stringify(
|
|
619
|
+
configJson: JSON.stringify(configWithNamespaces)
|
|
566
620
|
});
|
|
567
621
|
return {
|
|
568
622
|
applied: true,
|
|
@@ -572,7 +626,8 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
|
|
|
572
626
|
pluginId: parsed.data.pluginId,
|
|
573
627
|
enabled: parsed.data.enabled ?? null,
|
|
574
628
|
priority: parsed.data.priority ?? null,
|
|
575
|
-
grantedCapabilities: parsed.data.grantedCapabilities
|
|
629
|
+
grantedCapabilities: parsed.data.grantedCapabilities,
|
|
630
|
+
capabilityNamespaces: parsed.data.capabilityNamespaces
|
|
576
631
|
}
|
|
577
632
|
};
|
|
578
633
|
}
|