plugin-agent-orchestrator 1.0.22 → 1.0.23

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 (96) hide show
  1. package/client-v2.d.ts +2 -0
  2. package/client-v2.js +1 -0
  3. package/dist/client/index.js +1 -1
  4. package/dist/client-v2/214.723affb37c13bf7a.js +10 -0
  5. package/dist/client-v2/264.0533912e6c5ea2d7.js +10 -0
  6. package/dist/client-v2/41.1805b2edfaa4afe2.js +10 -0
  7. package/dist/client-v2/418.5ae055abf141820e.js +10 -0
  8. package/dist/client-v2/619.d99d3c9e61c99064.js +10 -0
  9. package/dist/client-v2/70.a15d7fcec7c41768.js +10 -0
  10. package/dist/client-v2/892.72db4161511c8a16.js +10 -0
  11. package/dist/client-v2/926.87f660b670d85bcc.js +10 -0
  12. package/dist/client-v2/index.js +10 -0
  13. package/dist/externalVersion.js +7 -6
  14. package/dist/locale/en-US.json +7 -0
  15. package/dist/locale/vi-VN.json +7 -0
  16. package/dist/locale/zh-CN.json +27 -0
  17. package/dist/server/migrations/20260615000000-normalize-ai-employee-tool-bindings.js +63 -0
  18. package/dist/server/plugin.js +32 -1
  19. package/dist/server/services/AgentHarness.js +52 -27
  20. package/dist/server/services/AgentLoopController.js +8 -2
  21. package/dist/server/services/AgentLoopService.js +1 -1
  22. package/dist/server/services/AgentRegistryService.js +53 -42
  23. package/dist/server/services/CircuitBreaker.js +7 -2
  24. package/dist/server/services/CodeValidator.js +48 -14
  25. package/dist/server/services/SandboxRunner.js +18 -14
  26. package/dist/server/skill-hub/plugin.js +44 -17
  27. package/dist/server/tools/delegate-task.js +7 -2
  28. package/dist/server/tools/skill-execute.js +33 -2
  29. package/dist/server/utils/ai-manager.js +51 -0
  30. package/dist/server/utils/ctx-utils.js +11 -0
  31. package/dist/server/utils/skill-settings.js +122 -0
  32. package/package.json +49 -45
  33. package/src/client/AIEmployeesContext.tsx +51 -14
  34. package/src/client/AgentRunsTab.tsx +767 -764
  35. package/src/client/HarnessProfilesTab.tsx +254 -247
  36. package/src/client/RulesTab.tsx +780 -716
  37. package/src/client/TracingTab.tsx +1 -0
  38. package/src/client/plugin.tsx +34 -27
  39. package/src/client/skill-hub/components/GitSkillImport.tsx +10 -3
  40. package/src/client/skill-hub/components/SkillMetrics.tsx +157 -124
  41. package/src/client/skill-hub/index.tsx +58 -51
  42. package/src/client/skill-hub/tools/InteractionSchemasProvider.tsx +132 -99
  43. package/src/client/skill-hub/tools/registerSkillLoopCards.ts +71 -58
  44. package/src/client/tools/registerOrchestratorCards.ts +17 -7
  45. package/src/client-v2/components/AIEmployeeSelect.tsx +47 -0
  46. package/src/client-v2/components/AIEmployeesContext.tsx +110 -0
  47. package/src/client-v2/components/AgentRunsTab.tsx +767 -0
  48. package/src/client-v2/components/HarnessProfilesTab.tsx +254 -0
  49. package/src/client-v2/components/RulesTab.tsx +782 -0
  50. package/src/client-v2/components/TracingTab.tsx +432 -0
  51. package/src/client-v2/hooks/useApiRequest.ts +114 -0
  52. package/src/client-v2/index.tsx +1 -0
  53. package/src/client-v2/pages/AgentRunsPage.tsx +13 -0
  54. package/src/client-v2/pages/ExecutionHistoryPage.tsx +10 -0
  55. package/src/client-v2/pages/HarnessProfilesPage.tsx +10 -0
  56. package/src/client-v2/pages/LoopSettingsPage.tsx +10 -0
  57. package/src/client-v2/pages/RulesPage.tsx +13 -0
  58. package/src/client-v2/pages/SkillDefinitionsPage.tsx +10 -0
  59. package/src/client-v2/pages/SkillMetricsPage.tsx +10 -0
  60. package/src/client-v2/pages/TracingPage.tsx +13 -0
  61. package/src/client-v2/plugin.tsx +70 -0
  62. package/src/client-v2/skill-hub/components/ExecutionHistory.tsx +196 -0
  63. package/src/client-v2/skill-hub/components/FileLinkList.tsx +37 -0
  64. package/src/client-v2/skill-hub/components/GitSkillImport.tsx +539 -0
  65. package/src/client-v2/skill-hub/components/LoopSettings.tsx +331 -0
  66. package/src/client-v2/skill-hub/components/SkillEditor.tsx +453 -0
  67. package/src/client-v2/skill-hub/components/SkillManager.tsx +174 -0
  68. package/src/client-v2/skill-hub/components/SkillMetrics.tsx +157 -0
  69. package/src/client-v2/skill-hub/components/SkillTestPanel.tsx +135 -0
  70. package/src/client-v2/skill-hub/locale.ts +13 -0
  71. package/src/client-v2/skill-hub/tools/loopTemplates.ts +52 -0
  72. package/src/client-v2/skill-hub/utils/jsonFields.ts +41 -0
  73. package/src/client-v2/utils/jsonFields.ts +41 -0
  74. package/src/locale/en-US.json +7 -0
  75. package/src/locale/vi-VN.json +7 -0
  76. package/src/locale/zh-CN.json +27 -0
  77. package/src/server/__tests__/agent-registry-service.test.ts +147 -0
  78. package/src/server/__tests__/code-validator.test.ts +63 -0
  79. package/src/server/__tests__/skill-execute.test.ts +33 -0
  80. package/src/server/__tests__/skill-settings.test.ts +63 -0
  81. package/src/server/migrations/20260615000000-normalize-ai-employee-tool-bindings.ts +39 -0
  82. package/src/server/plugin.ts +62 -21
  83. package/src/server/services/AgentHarness.ts +49 -22
  84. package/src/server/services/AgentLoopController.ts +17 -6
  85. package/src/server/services/AgentLoopService.ts +1 -1
  86. package/src/server/services/AgentPlannerService.ts +10 -0
  87. package/src/server/services/AgentRegistryService.ts +89 -47
  88. package/src/server/services/CircuitBreaker.ts +10 -0
  89. package/src/server/services/CodeValidator.ts +237 -159
  90. package/src/server/services/SandboxRunner.ts +203 -189
  91. package/src/server/skill-hub/plugin.ts +933 -898
  92. package/src/server/tools/delegate-task.ts +12 -9
  93. package/src/server/tools/skill-execute.ts +194 -160
  94. package/src/server/utils/ai-manager.ts +24 -0
  95. package/src/server/utils/ctx-utils.ts +14 -0
  96. package/src/server/utils/skill-settings.ts +116 -0
@@ -1,898 +1,933 @@
1
- import { resolve } from 'path';
2
- import { createReadStream, createWriteStream, unlinkSync } from 'fs';
3
- import * as os from 'os';
4
- import { SandboxRunner } from '../services/SandboxRunner';
5
- import { FileManager } from '../services/FileManager';
6
- import { SkillManager } from '../services/SkillManager';
7
- import { WorkerEnvManager } from '../services/WorkerEnvManager';
8
- import { SkillExecutionTask } from './tasks/SkillExecutionTask';
9
- import { createSkillExecuteTool } from '../tools/skill-execute';
10
- import { McpController } from './mcp/McpController';
11
- import { SkillRepositoryService } from '../services/SkillRepositoryService';
12
- import { gitListSkills, gitSyncSkills } from './actions/git-import';
13
- import { parseJsonText, stringifyJsonText, parseJsonLike } from './utils/json-fields';
14
- import { getOrchestratorTraceContext } from '../services/ExecutionSpanService';
15
-
16
- /**
17
- * Simple in-memory rate limiter per user.
18
- * Tracks execution counts within a sliding time window.
19
- */
20
- class RateLimiter {
21
- private userExecutions = new Map<string, number[]>();
22
-
23
- constructor(
24
- private readonly maxExecutions: number = 10,
25
- private readonly windowMs: number = 60 * 1000, // 1 minute
26
- ) {}
27
-
28
- /**
29
- * Check if the user is allowed to execute.
30
- * @returns true if allowed, false if rate limited.
31
- */
32
- check(userId: string): boolean {
33
- const now = Date.now();
34
- const executions = this.userExecutions.get(userId) || [];
35
-
36
- // Remove expired entries
37
- const valid = executions.filter((t) => now - t < this.windowMs);
38
- this.userExecutions.set(userId, valid);
39
-
40
- if (valid.length >= this.maxExecutions) {
41
- return false;
42
- }
43
-
44
- valid.push(now);
45
- return true;
46
- }
47
-
48
- /** Get remaining executions for a user */
49
- remaining(userId: string): number {
50
- const now = Date.now();
51
- const executions = (this.userExecutions.get(userId) || []).filter((t) => now - t < this.windowMs);
52
- return Math.max(0, this.maxExecutions - executions.length);
53
- }
54
-
55
- /** Periodically clean up expired entries (call from interval) */
56
- cleanup() {
57
- const now = Date.now();
58
- for (const [userId, executions] of this.userExecutions) {
59
- const valid = executions.filter((t) => now - t < this.windowMs);
60
- if (valid.length === 0) {
61
- this.userExecutions.delete(userId);
62
- } else {
63
- this.userExecutions.set(userId, valid);
64
- }
65
- }
66
- }
67
- }
68
-
69
- export class SkillHubSubFeature {
70
- sandboxRunner: SandboxRunner;
71
- fileManager: FileManager;
72
- skillManager: SkillManager;
73
- workerEnvManager: WorkerEnvManager;
74
- private cleanupInterval: ReturnType<typeof setInterval> | null = null;
75
- private initEnvDoneCallback: any = null;
76
- private initEnvProgressCallback: any = null;
77
- private mcpController: McpController;
78
- private skillRepoService: SkillRepositoryService;
79
- private rateLimiter = new RateLimiter(
80
- parseInt(process.env.SKILL_HUB_RATE_LIMIT_MAX || '10', 10),
81
- parseInt(process.env.SKILL_HUB_RATE_LIMIT_WINDOW_MS || '60000', 10),
82
- );
83
- private skillTemplates = new Map<string, any>();
84
-
85
- constructor(private plugin: any) {}
86
-
87
- get app() {
88
- return this.plugin.app;
89
- }
90
- get db() {
91
- return this.plugin.db;
92
- }
93
- get name() {
94
- return this.plugin.name;
95
- }
96
-
97
- async load() {
98
- // 1. Collections and migrations are now handled by parent orchestrator plugin
99
-
100
- // 2. Init services
101
- const storagePath = resolve(process.cwd(), 'storage', 'plugin-skill-hub'); // Keep old storage path for backwards compatibility
102
- this.fileManager = new FileManager(storagePath);
103
- this.sandboxRunner = new SandboxRunner(this.fileManager, (this as any).app.logger, storagePath);
104
- this.skillManager = new SkillManager((this as any).db);
105
- this.workerEnvManager = new WorkerEnvManager((this as any).app, (this as any).db, storagePath);
106
- this.skillRepoService = new SkillRepositoryService(storagePath);
107
- this.mcpController = new McpController(this);
108
-
109
- // 3. Register REST actions
110
- (this as any).app.resourceManager.define({
111
- name: 'skillHub',
112
- actions: {
113
- download: this.handleDownload.bind(this),
114
- test: this.handleTest.bind(this),
115
- initEnv: this.handleInitEnv.bind(this),
116
- clearStorage: this.handleClearStorage.bind(this),
117
- mcpListTools: this.mcpController.listTools.bind(this.mcpController),
118
- mcpCallTool: this.mcpController.callTool.bind(this.mcpController),
119
- listTemplates: this.handleListTemplates.bind(this),
120
- gitListSkills,
121
- gitSyncSkills,
122
- },
123
- });
124
-
125
- // 4.5. Register DB hooks for automatic storage physical cleanup
126
- (this as any).db.on('skillExecutions.afterDestroy', async (model, options) => {
127
- const execId = model.get('id');
128
- try {
129
- const dir = this.fileManager.getExecDir(String(execId));
130
- if (require('fs').existsSync(dir)) {
131
- require('fs').rmSync(dir, { recursive: true, force: true });
132
- }
133
- } catch (err) {
134
- (this as any).app.logger.error(`[skill-hub] Failed to cleanup physical storage for execId ${execId}`, {
135
- error: err,
136
- });
137
- }
138
- });
139
-
140
- (this as any).db.on('skillDefinitions.afterSave', async (model, options) => {
141
- // If a zip file was uploaded, extract it and update the skill record
142
- if (model.changed('fileId') && model.get('fileId')) {
143
- try {
144
- const attachment = await (this as any).db.getRepository('attachments').findOne({
145
- filter: { id: model.get('fileId') },
146
- transaction: options.transaction,
147
- });
148
-
149
- if (attachment) {
150
- const fileManager = (this as any).app.pm.get('@nocobase/plugin-file-manager') as any;
151
- if (!fileManager) {
152
- (this as any).app.logger.warn('[skill-hub] plugin-file-manager not found, cannot extract skill package');
153
- return;
154
- }
155
-
156
- const rawStorageId = attachment.get('storageId') || attachment.storageId;
157
- let matchedKey = null;
158
- if (rawStorageId) {
159
- const strId = String(rawStorageId);
160
- for (const key of fileManager.storagesCache.keys()) {
161
- if (String(key) === strId) {
162
- matchedKey = key;
163
- break;
164
- }
165
- }
166
- }
167
-
168
- const attachmentObj = typeof attachment.toJSON === 'function' ? attachment.toJSON() : { ...attachment };
169
- if (matchedKey !== null) {
170
- attachmentObj.storageId = matchedKey;
171
- }
172
-
173
- const streamData = await fileManager.getFileStream(attachmentObj);
174
- if (!streamData || !streamData.stream) {
175
- (this as any).app.logger.warn(
176
- `[skill-hub] Could not get file stream for attachment ${attachment.get('id')}`,
177
- );
178
- return;
179
- }
180
-
181
- const tempZipPath = resolve(os.tmpdir(), `skill_${Date.now()}_${model.get('id')}.zip`);
182
-
183
- await new Promise((resolve, reject) => {
184
- const writeStream = createWriteStream(tempZipPath);
185
- streamData.stream.pipe(writeStream);
186
- writeStream.on('finish', resolve);
187
- writeStream.on('error', reject);
188
- streamData.stream.on('error', reject);
189
- });
190
-
191
- if (require('fs').existsSync(tempZipPath)) {
192
- const skillName = model.get('name');
193
- const { metadata, instructions } = await this.skillRepoService.extractSkillPackage(
194
- skillName,
195
- tempZipPath,
196
- );
197
- const code = this.skillRepoService.getSkillCode(skillName);
198
-
199
- const updateValues: any = {
200
- storageType: attachment.get('storageId') ? `storage-${attachment.get('storageId')}` : 'local',
201
- };
202
- if (code) updateValues.codeTemplate = code;
203
- if (metadata.description) updateValues.description = metadata.description;
204
- if (metadata.title) updateValues.title = metadata.title;
205
- if (metadata.language) updateValues.language = metadata.language;
206
- if (metadata.inputSchema)
207
- updateValues.inputSchema = stringifyJsonText(parseJsonLike(metadata.inputSchema, null));
208
- if (metadata.interactionSchema)
209
- updateValues.interactionSchema = stringifyJsonText(parseJsonLike(metadata.interactionSchema, null));
210
- if (metadata.packages)
211
- updateValues.packages = stringifyJsonText(parseJsonLike(metadata.packages, []), []);
212
- if (metadata.timeoutSeconds) updateValues.timeoutSeconds = metadata.timeoutSeconds;
213
- if (instructions) updateValues.instructions = instructions;
214
-
215
- await (this as any).db.getRepository('skillDefinitions').update({
216
- filter: { id: model.get('id') },
217
- values: updateValues,
218
- transaction: options.transaction,
219
- });
220
-
221
- unlinkSync(tempZipPath);
222
- (this as any).app.logger.info(`[skill-hub] Successfully extracted zip and updated skill: ${skillName}`);
223
- }
224
- }
225
- } catch (err) {
226
- (this as any).app.logger.error(`[skill-hub] Failed to unpack skill zip`, { error: err });
227
- }
228
- }
229
- });
230
-
231
- // 5. Subscribe PubSub — worker processes skill execution tasks
232
- (this as any).app.pubSubManager.subscribe('skill-hub.task', async (payload: any) => {
233
- if (process.env.SKILL_HUB_SANDBOX === 'false') return;
234
- await this.onQueueTask(payload);
235
- });
236
-
237
- // 5b. Subscribe PubSub worker processes init-env tasks
238
- (this as any).app.pubSubManager.subscribe('skill-hub.init-env', async (payload: any) => {
239
- if (process.env.SKILL_HUB_SANDBOX === 'false') return;
240
- await this.workerEnvManager.executeInit(payload);
241
- });
242
-
243
- // 6. Register AI tools + subscriptions (deferred — after all plugins loaded)
244
- (this as any).app.on('afterStart', async () => {
245
- this.registerAITools();
246
- this.startCleanupInterval();
247
- await this.subscribeInitEnvDone();
248
- // Ensure any newly added built-in skills are seeded automatically on upgrade/restart
249
- await this.skillManager.seedDefaults().catch((e) => {
250
- (this as any).app.logger.error(`[skill-hub] Failed to seed default skills: ${e.message}`);
251
- });
252
- });
253
- }
254
-
255
- private async onQueueTask(message: { id: string }) {
256
- (this as any).app.logger.info(`[skill-hub] Worker received queue task: ${message.id}`);
257
- const execution = await (this as any).db.getRepository('skillExecutions').findOne({
258
- filter: { id: message.id },
259
- appends: ['skill'],
260
- });
261
- if (!execution) {
262
- (this as any).app.logger.warn(`[skill-hub] Task ${message.id} ignored: execution record not found.`);
263
- return;
264
- }
265
-
266
- const task = new SkillExecutionTask(
267
- execution,
268
- this.sandboxRunner,
269
- this.fileManager,
270
- this.skillRepoService,
271
- (this as any).app,
272
- );
273
- await task.run();
274
- }
275
-
276
- /**
277
- * Execute skill — called by both AI tool and REST test endpoint.
278
- * Dispatches to worker via EventQueue, waits for result via PubSub.
279
- * Pushes progress to SSE via runtime.writer (if within AI tool context).
280
- * Includes rate limiting and graceful abort propagation.
281
- */
282
- async executeSkill(skill: any, inputArgs: Record<string, any>, ctx?: any): Promise<any> {
283
- // ── Rate limiting ──
284
- const userId = ctx?.state?.currentUser?.id;
285
- if (userId) {
286
- if (!this.rateLimiter.check(String(userId))) {
287
- const remaining = this.rateLimiter.remaining(String(userId));
288
- throw new Error(
289
- `Rate limit exceeded. You can execute up to ${this.rateLimiter['maxExecutions']} ` +
290
- `skills per minute. Remaining: ${remaining}. Please wait and try again.`,
291
- );
292
- }
293
- }
294
-
295
- const traceContext = getOrchestratorTraceContext(ctx);
296
- const execution = await (this as any).db.getRepository('skillExecutions').create({
297
- values: {
298
- skillId: skill.id,
299
- status: 'pending',
300
- inputArgs: stringifyJsonText(inputArgs, {}),
301
- sessionId: ctx?.state?.sessionId,
302
- orchestratorRootRunId: traceContext?.rootRunId,
303
- orchestratorSpanId: traceContext?.spanId,
304
- orchestratorParentSpanId: traceContext?.parentSpanId,
305
- orchestratorToolCallId: traceContext?.toolCallId,
306
- agentLoopRunId: traceContext?.agentLoopRunId,
307
- agentLoopStepId: traceContext?.agentLoopStepId,
308
- triggeredById: ctx?.state?.currentUser?.id,
309
- },
310
- });
311
-
312
- const execId = String(execution.id);
313
-
314
- (this as any).app.logger.info(
315
- `[skill-hub] Queued execution ${execId}: skill=${skill.get ? skill.get('name') : skill.name}, ` +
316
- `user=${userId || 'system'}`,
317
- );
318
-
319
- // Track PubSub subscriptions for cleanup
320
- const cleanups: Array<{ channel: string; callback: any }> = [];
321
-
322
- // Define callbacks with references for unsubscribe
323
- const progressChannel = `skill-hub.progress.${execId}`;
324
- const doneChannel = `skill-hub.done.${execId}`;
325
- const abortChannel = `skill-hub.abort.${execId}`;
326
-
327
- const progressCallback = async (progress: any) => {
328
- try {
329
- ctx?.runtime?.writer?.({
330
- action: 'skillProgress',
331
- body: { execId, skillName: skill.name || skill.get?.('name'), ...progress },
332
- });
333
- } catch {
334
- // Ignore SSE write errors (connection may have closed)
335
- }
336
- };
337
-
338
- // Wait for result via PubSub (progress streaming + completion)
339
- let result: any;
340
- try {
341
- let resolvePromise: any;
342
- let rejectPromise: any;
343
-
344
- const resultPromise = new Promise<any>((resolve, reject) => {
345
- resolvePromise = resolve;
346
- rejectPromise = reject;
347
- });
348
-
349
- const timeoutMs = ((skill.timeoutSeconds || skill.get?.('timeoutSeconds') || 60) + 15) * 1000;
350
- const timeout = setTimeout(() => {
351
- rejectPromise(new Error(`Skill execution timeout after ${skill.timeoutSeconds || 60}s`));
352
- }, timeoutMs);
353
-
354
- const doneCallback = async (data: any) => {
355
- clearTimeout(timeout);
356
- resolvePromise(data);
357
- };
358
-
359
- // Subscribe progress and completion FIRST (before dispatching)
360
- await (this as any).app.pubSubManager.subscribe(progressChannel, progressCallback);
361
- cleanups.push({ channel: progressChannel, callback: progressCallback });
362
-
363
- await (this as any).app.pubSubManager.subscribe(doneChannel, doneCallback);
364
- cleanups.push({ channel: doneChannel, callback: doneCallback });
365
-
366
- // Handle user abort (cancel chat) → propagate to worker
367
- if (ctx?.req?.signal || ctx?.signal) {
368
- const signal = ctx.req?.signal || ctx.signal;
369
- signal.addEventListener?.('abort', () => {
370
- clearTimeout(timeout);
371
- // Publish abort to worker via PubSub
372
- (this as any).app.pubSubManager.publish(abortChannel, { reason: 'user_cancel' }).catch(() => {});
373
- // Also update the execution status
374
- (this as any).db
375
- .getRepository('skillExecutions')
376
- .update({
377
- filter: { id: execId },
378
- values: { status: 'canceled' },
379
- })
380
- .catch(() => {});
381
- rejectPromise(new Error('Canceled by user'));
382
- });
383
- }
384
-
385
- // NOW Dispatch to worker via EventQueue
386
- await (this as any).app.pubSubManager.publish('skill-hub.task', { id: execId });
387
-
388
- // Wait for completion
389
- result = await resultPromise;
390
- } finally {
391
- // Cleanup all PubSub subscriptions
392
- for (const { channel, callback } of cleanups) {
393
- try {
394
- await (this as any).app.pubSubManager.unsubscribe(channel, callback);
395
- } catch {
396
- // ignore cleanup errors
397
- }
398
- }
399
- }
400
-
401
- // Build download URLs for output files (use base64 filename to prevent Markdown link breaks if LLM decodes spaces)
402
- const filesWithUrls = (result.files || []).map((f: any) => {
403
- const b64name = Buffer.from(f.name).toString('base64url');
404
- return {
405
- ...f,
406
- downloadUrl: `/api/skillHub:download?execId=${execId}&f=${b64name}`,
407
- };
408
- });
409
-
410
- return {
411
- ...result,
412
- files: filesWithUrls,
413
- execId,
414
- agentLoopRunId: traceContext?.agentLoopRunId,
415
- agentLoopStepId: traceContext?.agentLoopStepId,
416
- };
417
- }
418
-
419
- private async handleDownload(ctx: any, next: any) {
420
- const { execId, filename, f } = ctx.action.params;
421
- let targetFile = filename;
422
- if (f) {
423
- targetFile = Buffer.from(f, 'base64url').toString('utf8');
424
- }
425
-
426
- if (!execId || !targetFile) {
427
- ctx.throw(400, 'Missing execId or filename');
428
- }
429
-
430
- const currentUser = ctx?.state?.currentUser;
431
- if (!currentUser) {
432
- ctx.throw(401, 'Unauthorized');
433
- }
434
-
435
- const execution = await (this as any).db.getRepository('skillExecutions').findOne({
436
- filter: { id: execId },
437
- });
438
-
439
- if (!execution) {
440
- ctx.throw(404, 'Execution not found');
441
- }
442
-
443
- const isOwner = execution.triggeredById === currentUser.id;
444
- const isAdmin = currentUser.roles?.some((r: any) => r.name === 'root' || r === 'root' || r.name === 'admin');
445
-
446
- if (!isOwner && !isAdmin) {
447
- ctx.throw(403, 'Permission denied: you cannot view files from this execution');
448
- }
449
-
450
- const filePath = this.fileManager.getOutputFilePath(execId, targetFile);
451
- if (!filePath) {
452
- ctx.throw(404, 'File not found');
453
- }
454
-
455
- ctx.attachment(targetFile);
456
- ctx.body = createReadStream(filePath);
457
- await next();
458
- }
459
-
460
- private async handleTest(ctx: any, next: any) {
461
- const { skillId, input } = ctx.action.params.values || {};
462
- if (!skillId) {
463
- ctx.throw(400, 'Missing skillId');
464
- }
465
-
466
- const skill = await (this as any).db.getRepository('skillDefinitions').findOne({
467
- filter: { id: skillId },
468
- });
469
- if (!skill) {
470
- ctx.throw(404, 'Skill not found');
471
- }
472
-
473
- const result = await this.executeSkill(skill, input || {}, ctx);
474
- ctx.body = result;
475
- await next();
476
- }
477
-
478
- /**
479
- * Handle Init Environment request from admin UI.
480
- * Dispatches init task to all workers via EventQueue.
481
- */
482
- private async handleInitEnv(ctx: any, next: any) {
483
- const config = await this.workerEnvManager.getOrCreateConfig();
484
- const customPackages = parseJsonText(config.get?.('customPackages') ?? config.customPackages, {
485
- python: [],
486
- node: [],
487
- });
488
- const message = await this.workerEnvManager.initEnvironment(
489
- config.get
490
- ? {
491
- npmRegistryUrl: config.get('npmRegistryUrl'),
492
- npmAuthToken: config.get('npmAuthToken'),
493
- pypiIndexUrl: config.get('pypiIndexUrl'),
494
- pypiTrustedHost: config.get('pypiTrustedHost'),
495
- aptMirrorUrl: config.get('aptMirrorUrl'),
496
- aptGpgKeyUrl: config.get('aptGpgKeyUrl'),
497
- customPackages,
498
- }
499
- : config,
500
- );
501
- ctx.body = { message };
502
- await next();
503
- }
504
-
505
- /**
506
- * Subscribe to init-env done AND progress PubSub channels.
507
- * When a worker finishes init, auto-update the DB with status.
508
- */
509
- private async subscribeInitEnvDone() {
510
- this.initEnvDoneCallback = async (data: any) => {
511
- try {
512
- const values: any = {
513
- initStatus: data.status,
514
- lastInitLog: data.log,
515
- };
516
- if (data.status === 'succeeded' && data.whitelist) {
517
- values.packageWhitelist = stringifyJsonText(data.whitelist, { python: [], node: [], apt: [] });
518
- }
519
- await (this as any).db.getRepository('skillWorkerConfigs').update({
520
- filter: {},
521
- values,
522
- forceUpdate: true,
523
- });
524
- (this as any).app.logger.info(`[skill-hub] Init env ${data.status}`);
525
- } catch (err) {
526
- (this as any).app.logger.warn('[skill-hub] Failed to update init env status:', err);
527
- }
528
- };
529
-
530
- this.initEnvProgressCallback = async (data: any) => {
531
- try {
532
- await (this as any).db.getRepository('skillWorkerConfigs').update({
533
- filter: {},
534
- values: {
535
- initProgressPercent: data.percent,
536
- initProgressLog: data.log,
537
- },
538
- forceUpdate: true,
539
- });
540
- } catch (err) {
541
- // ignore progress update errors
542
- }
543
- };
544
-
545
- await (this as any).app.pubSubManager.subscribe('skill-hub.init-env.done', this.initEnvDoneCallback);
546
- await (this as any).app.pubSubManager.subscribe('skill-hub.init-env.progress', this.initEnvProgressCallback);
547
- }
548
-
549
- private getSkillRecordId(skill: any) {
550
- return String(skill.get ? skill.get('id') : skill.id);
551
- }
552
-
553
- private resolveLoopInteractionSchema(loopConfig: any) {
554
- if (!loopConfig) return null;
555
-
556
- const schema = parseJsonText(loopConfig.get ? loopConfig.get('schema') : loopConfig.schema, null);
557
- const prompt = loopConfig.get ? loopConfig.get('prompt') : loopConfig.prompt;
558
-
559
- if (schema && typeof schema === 'object') {
560
- return prompt && !schema.prompt ? { ...schema, prompt } : schema;
561
- }
562
-
563
- if (prompt) {
564
- return {
565
- type: 'confirm',
566
- prompt,
567
- };
568
- }
569
-
570
- return null;
571
- }
572
-
573
- private async getLoopConfigsBySkillId(skillIds: Array<string | number>) {
574
- const ids = Array.from(new Set(skillIds.map((id) => String(id)).filter(Boolean)));
575
- const configsBySkillId = new Map<string, any>();
576
- if (!ids.length) return configsBySkillId;
577
-
578
- try {
579
- const configs = await (this as any).db.getRepository('skillLoopConfigs').find({
580
- filter: {
581
- enabled: true,
582
- skillId: {
583
- $in: ids,
584
- },
585
- },
586
- sort: ['-updatedAt'],
587
- });
588
-
589
- for (const config of configs || []) {
590
- const skillId = String(config.get ? config.get('skillId') : config.skillId);
591
- if (!configsBySkillId.has(skillId)) {
592
- configsBySkillId.set(skillId, config);
593
- }
594
- }
595
- } catch (err) {
596
- (this as any).app.logger.warn('[skill-hub] Failed to load loop configs', err);
597
- }
598
-
599
- return configsBySkillId;
600
- }
601
-
602
- private registerAITools() {
603
- try {
604
- const aiPlugin = (this as any).app.pm.get('@nocobase/plugin-ai') as any;
605
- if (!aiPlugin?.ai?.toolsManager) {
606
- (this as any).app.logger.warn('[skill-hub] plugin-ai not available, skip AI tool registration.');
607
- return;
608
- }
609
-
610
- // 1. General tool (list + execute)
611
- aiPlugin.ai.toolsManager.registerTools(createSkillExecuteTool(this));
612
-
613
- // 2. Dynamic tools — each enabled skill becomes a separate AI tool.
614
- aiPlugin.ai.toolsManager.registerDynamicTools(async (register: { registerTools: (options: any) => void }) => {
615
- try {
616
- const skills = await (this as any).db.getRepository('skillDefinitions').find({
617
- filter: { enabled: true },
618
- });
619
-
620
- if (!skills || skills.length === 0) return;
621
-
622
- const loopConfigsBySkillId = await this.getLoopConfigsBySkillId(
623
- skills.map((skill: any) => this.getSkillRecordId(skill)),
624
- );
625
-
626
- const tools = await Promise.all(
627
- skills.map(async (skill: any) => {
628
- const sanitizedToolName = skill
629
- .get('name')
630
- .toLowerCase()
631
- .replace(/[^a-z0-9_]/g, '_')
632
- .replace(/_+/g, '_')
633
- .replace(/^_|_$/g, '');
634
- const autoCall = !!skill.get('autoCall');
635
- const loopConfig = loopConfigsBySkillId.get(this.getSkillRecordId(skill));
636
- const loopInteractionSchema = this.resolveLoopInteractionSchema(loopConfig);
637
- const skillInteractionSchema = parseJsonText(skill.get('interactionSchema'), null);
638
- const interactionSchema = loopInteractionSchema || skillInteractionSchema;
639
- const requiresHumanReview = !!interactionSchema;
640
- const fullDescription = await this.getSkillDescriptionForAI(skill);
641
- const baseDescription = `${fullDescription || skill.get('description')}\nLanguage: ${skill.get(
642
- 'language',
643
- )}`;
644
- const description = requiresHumanReview
645
- ? `${baseDescription}\n\nIMPORTANT: This skill is bound to a Skill Hub human-in-the-loop review template. Pass best-effort args; the user can approve, edit, or reject them before execution.`
646
- : baseDescription;
647
- return {
648
- scope: 'CUSTOM' as const,
649
- execution: 'backend' as const,
650
- defaultPermission: (requiresHumanReview ? 'ASK' : autoCall ? 'ALLOW' : 'ASK') as 'ALLOW' | 'ASK',
651
- introduction: {
652
- title: `Skill Hub: ${skill.get('title')}`,
653
- about: skill.get('description') || `Thực thi kỹ năng ${skill.get('title')}`,
654
- },
655
- definition: {
656
- name: `skill_hub_${sanitizedToolName}`,
657
- description,
658
- schema: parseJsonText(skill.get('inputSchema'), { type: 'object', properties: {} }),
659
- },
660
- invoke: async (toolCtx: any, args: any) => {
661
- // Re-fetch skill to get latest version (hot-reload support)
662
- const latestSkill = await (this as any).db.getRepository('skillDefinitions').findOne({
663
- filter: { id: skill.get('id'), enabled: true },
664
- });
665
- if (!latestSkill) {
666
- return { status: 'error', content: `Skill "${skill.get('name')}" is no longer available` };
667
- }
668
- const result = await this.executeSkill(latestSkill, args, toolCtx);
669
- return {
670
- status: result.status === 'succeeded' ? 'success' : 'error',
671
- result: result, // Attach raw result
672
- };
673
- },
674
- };
675
- }),
676
- );
677
-
678
- register.registerTools(tools);
679
- } catch (err) {
680
- (this as any).app.logger.warn('[skill-hub] Failed to provide dynamic tools', err);
681
- }
682
- });
683
-
684
- (this as any).app.logger.info('[skill-hub] AI tools registered (dynamic provider + general tool).');
685
- } catch (error) {
686
- (this as any).app.logger.warn('[skill-hub] Failed to register AI tools:', error);
687
- }
688
- }
689
-
690
- private startCleanupInterval() {
691
- // Check old execution files every hour, rate limiter every 5 minutes
692
- const CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour
693
-
694
- this.cleanupInterval = setInterval(async () => {
695
- // 1. Storage Retention Cleanup
696
- try {
697
- const config = await (this as any).db.getRepository('skillWorkerConfigs').findOne();
698
- const hours = config ? config.get('retentionHours') : 24;
699
-
700
- if (hours && hours > 0) {
701
- const MAX_AGE_MS = hours * 60 * 60 * 1000;
702
- const cutoff = new Date(Date.now() - MAX_AGE_MS);
703
- const repo = (this as any).db.getRepository('skillExecutions');
704
-
705
- const outdated = await repo.find({
706
- filter: { createdAt: { $lt: cutoff } },
707
- });
708
-
709
- if (outdated.length > 0) {
710
- for (const record of outdated) {
711
- await record.destroy(); // Fires afterDestroy hook which removes physical folder
712
- }
713
- (this as any).app.logger.info(`[skill-hub] Auto-cleaned up ${outdated.length} expired execution records`);
714
- }
715
- }
716
- } catch (err) {
717
- (this as any).app.logger.warn('[skill-hub] Auto Cleanup error:', err);
718
- }
719
-
720
- // 2. Cleanup rate limiter stale entries
721
- this.rateLimiter.cleanup();
722
- }, CLEANUP_INTERVAL);
723
- }
724
-
725
- async beforeStop() {
726
- // Unsubscribe PubSub
727
- if (this.initEnvDoneCallback) {
728
- try {
729
- await (this as any).app.pubSubManager.unsubscribe('skill-hub.init-env.done', this.initEnvDoneCallback);
730
- } catch {
731
- /* ignore */
732
- }
733
- }
734
- if (this.initEnvProgressCallback) {
735
- try {
736
- await (this as any).app.pubSubManager.unsubscribe('skill-hub.init-env.progress', this.initEnvProgressCallback);
737
- } catch {
738
- /* ignore */
739
- }
740
- }
741
-
742
- // Clear cleanup interval
743
- if (this.cleanupInterval) {
744
- clearInterval(this.cleanupInterval);
745
- this.cleanupInterval = null;
746
- }
747
- }
748
-
749
- // --- Handlers ---
750
- private async handleClearStorage(ctx: any, next: () => Promise<any>) {
751
- const { type } = ctx.request.body || ctx.action.params.values;
752
- const repo = (this as any).db.getRepository('skillExecutions');
753
- let count = 0;
754
-
755
- if (type === 'all') {
756
- const results = await repo.find({ fields: ['id'] });
757
- for (const rec of results) {
758
- await rec.destroy();
759
- }
760
- count = results.length;
761
- } else if (type === 'expired') {
762
- const config = await (this as any).db.getRepository('skillWorkerConfigs').findOne();
763
- const hours = config ? config.get('retentionHours') : 24;
764
- if (hours > 0) {
765
- const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000);
766
- const results = await repo.find({ filter: { createdAt: { $lt: cutoff } }, fields: ['id'] });
767
- for (const rec of results) {
768
- await rec.destroy();
769
- }
770
- count = results.length;
771
- }
772
- }
773
-
774
- ctx.body = { count };
775
- await next();
776
- }
777
-
778
- private async handleListTemplates(ctx: any, next: () => Promise<any>) {
779
- // Dynamic Pull: discover templates from all active plugins in the system
780
- try {
781
- const allPlugins = (this as any).app.pm.getPlugins();
782
- for (const [, pluginInstance] of allPlugins) {
783
- if (typeof (pluginInstance as any).getSkillTemplates === 'function') {
784
- const pluginSkills = (pluginInstance as any).getSkillTemplates();
785
- if (Array.isArray(pluginSkills)) {
786
- for (const s of pluginSkills) {
787
- if (!this.skillTemplates.has(s.name)) {
788
- this.skillTemplates.set(s.name, this.hydrateSkillTemplate(pluginInstance.name, s));
789
- }
790
- }
791
- }
792
- }
793
- }
794
- } catch (e) {
795
- (this as any).app.logger.warn(`[skill-hub] Failed to discover some plugin skills: ${e.message}`);
796
- }
797
-
798
- ctx.body = { data: Array.from(this.skillTemplates.values()) };
799
- await next();
800
- }
801
-
802
- // ─── Extension API: other plugins register/unregister skills ───
803
-
804
- /**
805
- * Register a skill template into memory for UI importing.
806
- */
807
- registerSkillTemplate(pluginName: string, skillDef: any) {
808
- this.skillTemplates.set(skillDef.name, this.hydrateSkillTemplate(pluginName, skillDef));
809
- (this as any).app.logger.info(
810
- `[skill-hub] Registered skill template "${skillDef.name}" from plugin "${pluginName}"`,
811
- );
812
- }
813
-
814
- resolveSkillTemplate(templateName: string) {
815
- if (!templateName) return null;
816
- const cached = this.skillTemplates.get(templateName);
817
- if (cached) return cached;
818
-
819
- try {
820
- const allPlugins = (this as any).app.pm.getPlugins();
821
- for (const [, pluginInstance] of allPlugins) {
822
- if (typeof (pluginInstance as any).getSkillTemplates !== 'function') continue;
823
- const pluginSkills = (pluginInstance as any).getSkillTemplates();
824
- if (!Array.isArray(pluginSkills)) continue;
825
- const found = pluginSkills.find((s: any) => s?.name === templateName);
826
- if (found) {
827
- const hydrated = this.hydrateSkillTemplate(pluginInstance.name, found);
828
- this.skillTemplates.set(templateName, hydrated);
829
- return hydrated;
830
- }
831
- }
832
- } catch (e: any) {
833
- (this as any).app.logger.warn(`[skill-hub] Failed to resolve plugin skill "${templateName}": ${e.message}`);
834
- }
835
-
836
- return null;
837
- }
838
-
839
- async getSkillDescriptionForAI(skill: any) {
840
- const description = skill.get ? skill.get('description') : skill.description;
841
- const instructions = await this.getSkillInstructions(skill);
842
- const maxInlineInstructionChars = 24000;
843
- const inlineInstructions =
844
- instructions && instructions.length > maxInlineInstructionChars
845
- ? `${instructions.slice(
846
- 0,
847
- maxInlineInstructionChars,
848
- )}\n\n[Instructions truncated in tool description. Call skill_hub_execute with action="describe" and this skillName to load the complete workflow.]`
849
- : instructions;
850
- return [description, inlineInstructions ? `Instructions:\n${inlineInstructions}` : ''].filter(Boolean).join('\n\n');
851
- }
852
-
853
- async getSkillInstructions(skill: any) {
854
- const storedInstructions = skill.get ? skill.get('instructions') : skill.instructions;
855
- if (storedInstructions) return storedInstructions;
856
-
857
- const storageType = skill.get ? skill.get('storageType') : skill.storageType;
858
- if (storageType !== 'plugin') return '';
859
-
860
- const templateName =
861
- (skill.get ? skill.get('pluginSource') : skill.pluginSource) || (skill.get ? skill.get('name') : skill.name);
862
- const template = this.resolveSkillTemplate(templateName);
863
- return template?.instructions || '';
864
- }
865
-
866
- private hydrateSkillTemplate(pluginName: string, skillDef: any) {
867
- const skillName = skillDef.name;
868
- const packageRoot = skillDef.skillPackage?.rootDir;
869
- let packageInfo: any = null;
870
-
871
- if (packageRoot && this.skillRepoService) {
872
- packageInfo = this.skillRepoService.readSkillPackage(packageRoot);
873
- }
874
-
875
- const metadata = packageInfo?.metadata || {};
876
- const packageInstructions = packageInfo?.instructions;
877
-
878
- return {
879
- ...skillDef,
880
- title: skillDef.title || metadata.title || skillName,
881
- description: skillDef.description || metadata.description || '',
882
- instructions: [skillDef.instructions, packageInstructions].filter(Boolean).join('\n\n').trim(),
883
- language: skillDef.language || metadata.language || 'python',
884
- codeTemplate: skillDef.codeTemplate || packageInfo?.code || '',
885
- storageType: 'plugin',
886
- storageUrl: skillDef.storageUrl || `plugin://${pluginName}/${skillName}`,
887
- // Keep pluginSource as the skill template name because existing DB records use it for lookup.
888
- pluginSource: skillName,
889
- pluginName,
890
- };
891
- }
892
-
893
- async install() {
894
- await this.skillManager.seedDefaults();
895
- }
896
- }
897
-
898
- export default SkillHubSubFeature;
1
+ import { resolve } from 'path';
2
+ import { createReadStream, createWriteStream, unlinkSync } from 'fs';
3
+ import * as os from 'os';
4
+ import { SandboxRunner } from '../services/SandboxRunner';
5
+ import { FileManager } from '../services/FileManager';
6
+ import { SkillManager } from '../services/SkillManager';
7
+ import { WorkerEnvManager } from '../services/WorkerEnvManager';
8
+ import { SkillExecutionTask } from './tasks/SkillExecutionTask';
9
+ import { createSkillExecuteTool } from '../tools/skill-execute';
10
+ import { McpController } from './mcp/McpController';
11
+ import { SkillRepositoryService } from '../services/SkillRepositoryService';
12
+ import { gitListSkills, gitSyncSkills } from './actions/git-import';
13
+ import { parseJsonText, stringifyJsonText, parseJsonLike } from './utils/json-fields';
14
+ import { getOrchestratorTraceContext } from '../services/ExecutionSpanService';
15
+ import { tryGetAIToolsManager } from '../utils/ai-manager';
16
+ import type { ToolsRuntime } from '@nocobase/ai';
17
+
18
+ type ToolRuntimeInput = string | ToolsRuntime | undefined;
19
+
20
+ function normalizeToolRuntime(runtime: ToolRuntimeInput): ToolsRuntime | undefined {
21
+ if (!runtime) return undefined;
22
+ if (typeof runtime === 'string') {
23
+ return { toolCallId: runtime, writer: () => {} };
24
+ }
25
+ return runtime;
26
+ }
27
+
28
+ function assignToolRuntime(ctx: any, runtime: ToolRuntimeInput) {
29
+ const normalizedRuntime = normalizeToolRuntime(runtime);
30
+ if (!ctx || !normalizedRuntime) return () => {};
31
+ const previousRuntime = ctx.runtime;
32
+ ctx.runtime = normalizedRuntime;
33
+ return () => {
34
+ if (previousRuntime === undefined) {
35
+ delete ctx.runtime;
36
+ } else {
37
+ ctx.runtime = previousRuntime;
38
+ }
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Simple in-memory rate limiter per user.
44
+ * Tracks execution counts within a sliding time window.
45
+ */
46
+ class RateLimiter {
47
+ private userExecutions = new Map<string, number[]>();
48
+
49
+ constructor(
50
+ private readonly maxExecutions: number = 10,
51
+ private readonly windowMs: number = 60 * 1000, // 1 minute
52
+ ) {}
53
+
54
+ /**
55
+ * Check if the user is allowed to execute.
56
+ * @returns true if allowed, false if rate limited.
57
+ */
58
+ check(userId: string): boolean {
59
+ const now = Date.now();
60
+ const executions = this.userExecutions.get(userId) || [];
61
+
62
+ // Remove expired entries
63
+ const valid = executions.filter((t) => now - t < this.windowMs);
64
+ this.userExecutions.set(userId, valid);
65
+
66
+ if (valid.length >= this.maxExecutions) {
67
+ return false;
68
+ }
69
+
70
+ valid.push(now);
71
+ return true;
72
+ }
73
+
74
+ /** Get remaining executions for a user */
75
+ remaining(userId: string): number {
76
+ const now = Date.now();
77
+ const executions = (this.userExecutions.get(userId) || []).filter((t) => now - t < this.windowMs);
78
+ return Math.max(0, this.maxExecutions - executions.length);
79
+ }
80
+
81
+ /** Periodically clean up expired entries (call from interval) */
82
+ cleanup() {
83
+ const now = Date.now();
84
+ for (const [userId, executions] of this.userExecutions) {
85
+ const valid = executions.filter((t) => now - t < this.windowMs);
86
+ if (valid.length === 0) {
87
+ this.userExecutions.delete(userId);
88
+ } else {
89
+ this.userExecutions.set(userId, valid);
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ export class SkillHubSubFeature {
96
+ sandboxRunner: SandboxRunner;
97
+ fileManager: FileManager;
98
+ skillManager: SkillManager;
99
+ workerEnvManager: WorkerEnvManager;
100
+ private cleanupInterval: ReturnType<typeof setInterval> | null = null;
101
+ private initEnvDoneCallback: any = null;
102
+ private initEnvProgressCallback: any = null;
103
+ private mcpController: McpController;
104
+ private skillRepoService: SkillRepositoryService;
105
+ private rateLimiter = new RateLimiter(
106
+ parseInt(process.env.SKILL_HUB_RATE_LIMIT_MAX || '10', 10),
107
+ parseInt(process.env.SKILL_HUB_RATE_LIMIT_WINDOW_MS || '60000', 10),
108
+ );
109
+ private skillTemplates = new Map<string, any>();
110
+
111
+ constructor(private plugin: any) {}
112
+
113
+ get app() {
114
+ return this.plugin.app;
115
+ }
116
+ get db() {
117
+ return this.plugin.db;
118
+ }
119
+ get name() {
120
+ return this.plugin.name;
121
+ }
122
+
123
+ async load() {
124
+ // 1. Collections and migrations are now handled by parent orchestrator plugin
125
+
126
+ // 2. Init services
127
+ const storagePath = resolve(process.cwd(), 'storage', 'plugin-skill-hub'); // Keep old storage path for backwards compatibility
128
+ this.fileManager = new FileManager(storagePath);
129
+ this.sandboxRunner = new SandboxRunner(this.fileManager, (this as any).app.logger, storagePath);
130
+ this.skillManager = new SkillManager((this as any).db);
131
+ this.workerEnvManager = new WorkerEnvManager((this as any).app, (this as any).db, storagePath);
132
+ this.skillRepoService = new SkillRepositoryService(storagePath);
133
+ this.mcpController = new McpController(this);
134
+
135
+ // 3. Register REST actions
136
+ (this as any).app.resourceManager.define({
137
+ name: 'skillHub',
138
+ actions: {
139
+ download: this.handleDownload.bind(this),
140
+ test: this.handleTest.bind(this),
141
+ initEnv: this.handleInitEnv.bind(this),
142
+ clearStorage: this.handleClearStorage.bind(this),
143
+ mcpListTools: this.mcpController.listTools.bind(this.mcpController),
144
+ mcpCallTool: this.mcpController.callTool.bind(this.mcpController),
145
+ listTemplates: this.handleListTemplates.bind(this),
146
+ gitListSkills,
147
+ gitSyncSkills,
148
+ },
149
+ });
150
+
151
+ // 4.5. Register DB hooks for automatic storage physical cleanup
152
+ (this as any).db.on('skillExecutions.afterDestroy', async (model, options) => {
153
+ const execId = model.get('id');
154
+ try {
155
+ const dir = this.fileManager.getExecDir(String(execId));
156
+ if (require('fs').existsSync(dir)) {
157
+ require('fs').rmSync(dir, { recursive: true, force: true });
158
+ }
159
+ } catch (err) {
160
+ (this as any).app.logger.error(`[skill-hub] Failed to cleanup physical storage for execId ${execId}`, {
161
+ error: err,
162
+ });
163
+ }
164
+ });
165
+
166
+ (this as any).db.on('skillDefinitions.afterSave', async (model, options) => {
167
+ // If a zip file was uploaded, extract it and update the skill record
168
+ if (model.changed('fileId') && model.get('fileId')) {
169
+ try {
170
+ const attachment = await (this as any).db.getRepository('attachments').findOne({
171
+ filter: { id: model.get('fileId') },
172
+ transaction: options.transaction,
173
+ });
174
+
175
+ if (attachment) {
176
+ const fileManager = (this as any).app.pm.get('@nocobase/plugin-file-manager') as any;
177
+ if (!fileManager) {
178
+ (this as any).app.logger.warn('[skill-hub] plugin-file-manager not found, cannot extract skill package');
179
+ return;
180
+ }
181
+
182
+ const rawStorageId = attachment.get('storageId') || attachment.storageId;
183
+ let matchedKey = null;
184
+ if (rawStorageId) {
185
+ const strId = String(rawStorageId);
186
+ for (const key of fileManager.storagesCache.keys()) {
187
+ if (String(key) === strId) {
188
+ matchedKey = key;
189
+ break;
190
+ }
191
+ }
192
+ }
193
+
194
+ const attachmentObj = typeof attachment.toJSON === 'function' ? attachment.toJSON() : { ...attachment };
195
+ if (matchedKey !== null) {
196
+ attachmentObj.storageId = matchedKey;
197
+ }
198
+
199
+ const streamData = await fileManager.getFileStream(attachmentObj);
200
+ if (!streamData || !streamData.stream) {
201
+ (this as any).app.logger.warn(
202
+ `[skill-hub] Could not get file stream for attachment ${attachment.get('id')}`,
203
+ );
204
+ return;
205
+ }
206
+
207
+ const tempZipPath = resolve(os.tmpdir(), `skill_${Date.now()}_${model.get('id')}.zip`);
208
+
209
+ await new Promise((resolve, reject) => {
210
+ const writeStream = createWriteStream(tempZipPath);
211
+ streamData.stream.pipe(writeStream);
212
+ writeStream.on('finish', resolve);
213
+ writeStream.on('error', reject);
214
+ streamData.stream.on('error', reject);
215
+ });
216
+
217
+ if (require('fs').existsSync(tempZipPath)) {
218
+ const skillName = model.get('name');
219
+ const { metadata, instructions } = await this.skillRepoService.extractSkillPackage(
220
+ skillName,
221
+ tempZipPath,
222
+ );
223
+ const code = this.skillRepoService.getSkillCode(skillName);
224
+
225
+ const updateValues: any = {
226
+ storageType: attachment.get('storageId') ? `storage-${attachment.get('storageId')}` : 'local',
227
+ };
228
+ if (code) updateValues.codeTemplate = code;
229
+ if (metadata.description) updateValues.description = metadata.description;
230
+ if (metadata.title) updateValues.title = metadata.title;
231
+ if (metadata.language) updateValues.language = metadata.language;
232
+ if (metadata.inputSchema)
233
+ updateValues.inputSchema = stringifyJsonText(parseJsonLike(metadata.inputSchema, null));
234
+ if (metadata.interactionSchema)
235
+ updateValues.interactionSchema = stringifyJsonText(parseJsonLike(metadata.interactionSchema, null));
236
+ if (metadata.packages)
237
+ updateValues.packages = stringifyJsonText(parseJsonLike(metadata.packages, []), []);
238
+ if (metadata.timeoutSeconds) updateValues.timeoutSeconds = metadata.timeoutSeconds;
239
+ if (instructions) updateValues.instructions = instructions;
240
+
241
+ await (this as any).db.getRepository('skillDefinitions').update({
242
+ filter: { id: model.get('id') },
243
+ values: updateValues,
244
+ transaction: options.transaction,
245
+ });
246
+
247
+ unlinkSync(tempZipPath);
248
+ (this as any).app.logger.info(`[skill-hub] Successfully extracted zip and updated skill: ${skillName}`);
249
+ }
250
+ }
251
+ } catch (err) {
252
+ (this as any).app.logger.error(`[skill-hub] Failed to unpack skill zip`, { error: err });
253
+ }
254
+ }
255
+ });
256
+
257
+ // 5. Subscribe PubSub worker processes skill execution tasks
258
+ (this as any).app.pubSubManager.subscribe('skill-hub.task', async (payload: any) => {
259
+ if (process.env.SKILL_HUB_SANDBOX === 'false') return;
260
+ await this.onQueueTask(payload);
261
+ });
262
+
263
+ // 5b. Subscribe PubSub — worker processes init-env tasks
264
+ (this as any).app.pubSubManager.subscribe('skill-hub.init-env', async (payload: any) => {
265
+ if (process.env.SKILL_HUB_SANDBOX === 'false') return;
266
+ await this.workerEnvManager.executeInit(payload);
267
+ });
268
+
269
+ // 6. Register AI tools + subscriptions (deferred — after all plugins loaded)
270
+ (this as any).app.on('afterStart', async () => {
271
+ this.registerAITools();
272
+ this.startCleanupInterval();
273
+ await this.subscribeInitEnvDone();
274
+ // Ensure any newly added built-in skills are seeded automatically on upgrade/restart
275
+ await this.skillManager.seedDefaults().catch((e) => {
276
+ (this as any).app.logger.error(`[skill-hub] Failed to seed default skills: ${e.message}`);
277
+ });
278
+ });
279
+ }
280
+
281
+ private async onQueueTask(message: { id: string }) {
282
+ (this as any).app.logger.info(`[skill-hub] Worker received queue task: ${message.id}`);
283
+ const execution = await (this as any).db.getRepository('skillExecutions').findOne({
284
+ filter: { id: message.id },
285
+ appends: ['skill'],
286
+ });
287
+ if (!execution) {
288
+ (this as any).app.logger.warn(`[skill-hub] Task ${message.id} ignored: execution record not found.`);
289
+ return;
290
+ }
291
+
292
+ const task = new SkillExecutionTask(
293
+ execution,
294
+ this.sandboxRunner,
295
+ this.fileManager,
296
+ this.skillRepoService,
297
+ (this as any).app,
298
+ );
299
+ await task.run();
300
+ }
301
+
302
+ /**
303
+ * Execute skill — called by both AI tool and REST test endpoint.
304
+ * Dispatches to worker via EventQueue, waits for result via PubSub.
305
+ * Pushes progress to SSE via runtime.writer (if within AI tool context).
306
+ * Includes rate limiting and graceful abort propagation.
307
+ */
308
+ async executeSkill(skill: any, inputArgs: Record<string, any>, ctx?: any): Promise<any> {
309
+ // ── Rate limiting ──
310
+ const userId = ctx?.state?.currentUser?.id;
311
+ if (userId) {
312
+ if (!this.rateLimiter.check(String(userId))) {
313
+ const remaining = this.rateLimiter.remaining(String(userId));
314
+ throw new Error(
315
+ `Rate limit exceeded. You can execute up to ${this.rateLimiter['maxExecutions']} ` +
316
+ `skills per minute. Remaining: ${remaining}. Please wait and try again.`,
317
+ );
318
+ }
319
+ }
320
+
321
+ const traceContext = getOrchestratorTraceContext(ctx);
322
+ const execution = await (this as any).db.getRepository('skillExecutions').create({
323
+ values: {
324
+ skillId: skill.id,
325
+ status: 'pending',
326
+ inputArgs: stringifyJsonText(inputArgs, {}),
327
+ sessionId: ctx?.state?.sessionId,
328
+ orchestratorRootRunId: traceContext?.rootRunId,
329
+ orchestratorSpanId: traceContext?.spanId,
330
+ orchestratorParentSpanId: traceContext?.parentSpanId,
331
+ orchestratorToolCallId: traceContext?.toolCallId,
332
+ agentLoopRunId: traceContext?.agentLoopRunId,
333
+ agentLoopStepId: traceContext?.agentLoopStepId,
334
+ triggeredById: ctx?.state?.currentUser?.id,
335
+ },
336
+ });
337
+
338
+ const execId = String(execution.id);
339
+
340
+ (this as any).app.logger.info(
341
+ `[skill-hub] Queued execution ${execId}: skill=${skill.get ? skill.get('name') : skill.name}, ` +
342
+ `user=${userId || 'system'}`,
343
+ );
344
+
345
+ // Track PubSub subscriptions for cleanup
346
+ const cleanups: Array<{ channel: string; callback: any }> = [];
347
+
348
+ // Define callbacks with references for unsubscribe
349
+ const progressChannel = `skill-hub.progress.${execId}`;
350
+ const doneChannel = `skill-hub.done.${execId}`;
351
+ const abortChannel = `skill-hub.abort.${execId}`;
352
+
353
+ const progressCallback = async (progress: any) => {
354
+ try {
355
+ ctx?.runtime?.writer?.({
356
+ action: 'skillProgress',
357
+ body: { execId, skillName: skill.name || skill.get?.('name'), ...progress },
358
+ });
359
+ } catch {
360
+ // Ignore SSE write errors (connection may have closed)
361
+ }
362
+ };
363
+
364
+ // Wait for result via PubSub (progress streaming + completion)
365
+ let result: any;
366
+ try {
367
+ let resolvePromise: any;
368
+ let rejectPromise: any;
369
+
370
+ const resultPromise = new Promise<any>((resolve, reject) => {
371
+ resolvePromise = resolve;
372
+ rejectPromise = reject;
373
+ });
374
+
375
+ const timeoutMs = ((skill.timeoutSeconds || skill.get?.('timeoutSeconds') || 60) + 15) * 1000;
376
+ const timeout = setTimeout(() => {
377
+ rejectPromise(new Error(`Skill execution timeout after ${skill.timeoutSeconds || 60}s`));
378
+ }, timeoutMs);
379
+
380
+ const doneCallback = async (data: any) => {
381
+ clearTimeout(timeout);
382
+ resolvePromise(data);
383
+ };
384
+
385
+ // Subscribe progress and completion FIRST (before dispatching)
386
+ await (this as any).app.pubSubManager.subscribe(progressChannel, progressCallback);
387
+ cleanups.push({ channel: progressChannel, callback: progressCallback });
388
+
389
+ await (this as any).app.pubSubManager.subscribe(doneChannel, doneCallback);
390
+ cleanups.push({ channel: doneChannel, callback: doneCallback });
391
+
392
+ // Handle user abort (cancel chat) propagate to worker
393
+ if (ctx?.req?.signal || ctx?.signal) {
394
+ const signal = ctx.req?.signal || ctx.signal;
395
+ signal.addEventListener?.('abort', () => {
396
+ clearTimeout(timeout);
397
+ // Publish abort to worker via PubSub
398
+ (this as any).app.pubSubManager.publish(abortChannel, { reason: 'user_cancel' }).catch(() => {});
399
+ // Also update the execution status
400
+ (this as any).db
401
+ .getRepository('skillExecutions')
402
+ .update({
403
+ filter: { id: execId },
404
+ values: { status: 'canceled' },
405
+ })
406
+ .catch(() => {});
407
+ rejectPromise(new Error('Canceled by user'));
408
+ });
409
+ }
410
+
411
+ // NOW Dispatch to worker via EventQueue
412
+ await (this as any).app.pubSubManager.publish('skill-hub.task', { id: execId });
413
+
414
+ // Wait for completion
415
+ result = await resultPromise;
416
+ } finally {
417
+ // Cleanup all PubSub subscriptions
418
+ for (const { channel, callback } of cleanups) {
419
+ try {
420
+ await (this as any).app.pubSubManager.unsubscribe(channel, callback);
421
+ } catch {
422
+ // ignore cleanup errors
423
+ }
424
+ }
425
+ }
426
+
427
+ // Build download URLs for output files (use base64 filename to prevent Markdown link breaks if LLM decodes spaces)
428
+ const filesWithUrls = (result.files || []).map((f: any) => {
429
+ const b64name = Buffer.from(f.name).toString('base64url');
430
+ return {
431
+ ...f,
432
+ downloadUrl: `/api/skillHub:download?execId=${execId}&f=${b64name}`,
433
+ };
434
+ });
435
+
436
+ return {
437
+ ...result,
438
+ files: filesWithUrls,
439
+ execId,
440
+ agentLoopRunId: traceContext?.agentLoopRunId,
441
+ agentLoopStepId: traceContext?.agentLoopStepId,
442
+ };
443
+ }
444
+
445
+ private async handleDownload(ctx: any, next: any) {
446
+ const { execId, filename, f } = ctx.action.params;
447
+ let targetFile = filename;
448
+ if (f) {
449
+ targetFile = Buffer.from(f, 'base64url').toString('utf8');
450
+ }
451
+
452
+ if (!execId || !targetFile) {
453
+ ctx.throw(400, 'Missing execId or filename');
454
+ }
455
+
456
+ const currentUser = ctx?.state?.currentUser;
457
+ if (!currentUser) {
458
+ ctx.throw(401, 'Unauthorized');
459
+ }
460
+
461
+ const execution = await (this as any).db.getRepository('skillExecutions').findOne({
462
+ filter: { id: execId },
463
+ });
464
+
465
+ if (!execution) {
466
+ ctx.throw(404, 'Execution not found');
467
+ }
468
+
469
+ const isOwner = execution.triggeredById === currentUser.id;
470
+ const isAdmin = currentUser.roles?.some((r: any) => r.name === 'root' || r === 'root' || r.name === 'admin');
471
+
472
+ if (!isOwner && !isAdmin) {
473
+ ctx.throw(403, 'Permission denied: you cannot view files from this execution');
474
+ }
475
+
476
+ const filePath = this.fileManager.getOutputFilePath(execId, targetFile);
477
+ if (!filePath) {
478
+ ctx.throw(404, 'File not found');
479
+ }
480
+
481
+ ctx.attachment(targetFile);
482
+ ctx.body = createReadStream(filePath);
483
+ await next();
484
+ }
485
+
486
+ private async handleTest(ctx: any, next: any) {
487
+ const { skillId, input } = ctx.action.params.values || {};
488
+ if (!skillId) {
489
+ ctx.throw(400, 'Missing skillId');
490
+ }
491
+
492
+ const skill = await (this as any).db.getRepository('skillDefinitions').findOne({
493
+ filter: { id: skillId },
494
+ });
495
+ if (!skill) {
496
+ ctx.throw(404, 'Skill not found');
497
+ }
498
+
499
+ const result = await this.executeSkill(skill, input || {}, ctx);
500
+ ctx.body = result;
501
+ await next();
502
+ }
503
+
504
+ /**
505
+ * Handle Init Environment request from admin UI.
506
+ * Dispatches init task to all workers via EventQueue.
507
+ */
508
+ private async handleInitEnv(ctx: any, next: any) {
509
+ const config = await this.workerEnvManager.getOrCreateConfig();
510
+ const customPackages = parseJsonText(config.get?.('customPackages') ?? config.customPackages, {
511
+ python: [],
512
+ node: [],
513
+ });
514
+ const message = await this.workerEnvManager.initEnvironment(
515
+ config.get
516
+ ? {
517
+ npmRegistryUrl: config.get('npmRegistryUrl'),
518
+ npmAuthToken: config.get('npmAuthToken'),
519
+ pypiIndexUrl: config.get('pypiIndexUrl'),
520
+ pypiTrustedHost: config.get('pypiTrustedHost'),
521
+ aptMirrorUrl: config.get('aptMirrorUrl'),
522
+ aptGpgKeyUrl: config.get('aptGpgKeyUrl'),
523
+ customPackages,
524
+ }
525
+ : config,
526
+ );
527
+ ctx.body = { message };
528
+ await next();
529
+ }
530
+
531
+ /**
532
+ * Subscribe to init-env done AND progress PubSub channels.
533
+ * When a worker finishes init, auto-update the DB with status.
534
+ */
535
+ private async subscribeInitEnvDone() {
536
+ this.initEnvDoneCallback = async (data: any) => {
537
+ try {
538
+ const values: any = {
539
+ initStatus: data.status,
540
+ lastInitLog: data.log,
541
+ };
542
+ if (data.status === 'succeeded' && data.whitelist) {
543
+ values.packageWhitelist = stringifyJsonText(data.whitelist, { python: [], node: [], apt: [] });
544
+ }
545
+ await (this as any).db.getRepository('skillWorkerConfigs').update({
546
+ filter: {},
547
+ values,
548
+ forceUpdate: true,
549
+ });
550
+ (this as any).app.logger.info(`[skill-hub] Init env ${data.status}`);
551
+ } catch (err) {
552
+ (this as any).app.logger.warn('[skill-hub] Failed to update init env status:', err);
553
+ }
554
+ };
555
+
556
+ this.initEnvProgressCallback = async (data: any) => {
557
+ try {
558
+ await (this as any).db.getRepository('skillWorkerConfigs').update({
559
+ filter: {},
560
+ values: {
561
+ initProgressPercent: data.percent,
562
+ initProgressLog: data.log,
563
+ },
564
+ forceUpdate: true,
565
+ });
566
+ } catch (err) {
567
+ // ignore progress update errors
568
+ }
569
+ };
570
+
571
+ await (this as any).app.pubSubManager.subscribe('skill-hub.init-env.done', this.initEnvDoneCallback);
572
+ await (this as any).app.pubSubManager.subscribe('skill-hub.init-env.progress', this.initEnvProgressCallback);
573
+ }
574
+
575
+ private getSkillRecordId(skill: any) {
576
+ return String(skill.get ? skill.get('id') : skill.id);
577
+ }
578
+
579
+ private resolveLoopInteractionSchema(loopConfig: any) {
580
+ if (!loopConfig) return null;
581
+
582
+ const schema = parseJsonText(loopConfig.get ? loopConfig.get('schema') : loopConfig.schema, null);
583
+ const prompt = loopConfig.get ? loopConfig.get('prompt') : loopConfig.prompt;
584
+
585
+ if (schema && typeof schema === 'object') {
586
+ return prompt && !schema.prompt ? { ...schema, prompt } : schema;
587
+ }
588
+
589
+ if (prompt) {
590
+ return {
591
+ type: 'confirm',
592
+ prompt,
593
+ };
594
+ }
595
+
596
+ return null;
597
+ }
598
+
599
+ private async getLoopConfigsBySkillId(skillIds: Array<string | number>) {
600
+ const ids = Array.from(new Set(skillIds.map((id) => String(id)).filter(Boolean)));
601
+ const configsBySkillId = new Map<string, any>();
602
+ if (!ids.length) return configsBySkillId;
603
+
604
+ try {
605
+ const configs = await (this as any).db.getRepository('skillLoopConfigs').find({
606
+ filter: {
607
+ enabled: true,
608
+ skillId: {
609
+ $in: ids,
610
+ },
611
+ },
612
+ sort: ['-updatedAt'],
613
+ });
614
+
615
+ for (const config of configs || []) {
616
+ const skillId = String(config.get ? config.get('skillId') : config.skillId);
617
+ if (!configsBySkillId.has(skillId)) {
618
+ configsBySkillId.set(skillId, config);
619
+ }
620
+ }
621
+ } catch (err) {
622
+ (this as any).app.logger.warn('[skill-hub] Failed to load loop configs', err);
623
+ }
624
+
625
+ return configsBySkillId;
626
+ }
627
+
628
+ private registerAITools() {
629
+ try {
630
+ // Register on the SAME tools manager the harness resolves from
631
+ // (app.aiManager.toolsManager). Using pluginAI.ai.toolsManager here while
632
+ // the harness reads app.aiManager.toolsManager would register skill tools
633
+ // on one object and look them up on another if the wiring ever diverges.
634
+ const toolsManager = tryGetAIToolsManager((this as any).app);
635
+ if (!toolsManager) {
636
+ (this as any).app.logger.warn('[skill-hub] plugin-ai not available, skip AI tool registration.');
637
+ return;
638
+ }
639
+
640
+ // 1. General tool (list + execute)
641
+ toolsManager.registerTools(createSkillExecuteTool(this));
642
+
643
+ // 2. Dynamic tools — each enabled skill becomes a separate AI tool.
644
+ toolsManager.registerDynamicTools(async (register: { registerTools: (options: any) => void }) => {
645
+ try {
646
+ const skills = await (this as any).db.getRepository('skillDefinitions').find({
647
+ filter: { enabled: true },
648
+ });
649
+
650
+ if (!skills || skills.length === 0) return;
651
+
652
+ const loopConfigsBySkillId = await this.getLoopConfigsBySkillId(
653
+ skills.map((skill: any) => this.getSkillRecordId(skill)),
654
+ );
655
+
656
+ const tools = await Promise.all(
657
+ skills.map(async (skill: any) => {
658
+ const sanitizedToolName = skill
659
+ .get('name')
660
+ .toLowerCase()
661
+ .replace(/[^a-z0-9_]/g, '_')
662
+ .replace(/_+/g, '_')
663
+ .replace(/^_|_$/g, '');
664
+ const autoCall = !!skill.get('autoCall');
665
+ const loopConfig = loopConfigsBySkillId.get(this.getSkillRecordId(skill));
666
+ const loopInteractionSchema = this.resolveLoopInteractionSchema(loopConfig);
667
+ const skillInteractionSchema = parseJsonText(skill.get('interactionSchema'), null);
668
+ const interactionSchema = loopInteractionSchema || skillInteractionSchema;
669
+ const requiresHumanReview = !!interactionSchema;
670
+ const fullDescription = await this.getSkillDescriptionForAI(skill);
671
+ const baseDescription = `${fullDescription || skill.get('description')}\nLanguage: ${skill.get(
672
+ 'language',
673
+ )}`;
674
+ const description = requiresHumanReview
675
+ ? `${baseDescription}\n\nIMPORTANT: This skill is bound to a Skill Hub human-in-the-loop review template. Pass best-effort args; the user can approve, edit, or reject them before execution.`
676
+ : baseDescription;
677
+ return {
678
+ scope: 'CUSTOM' as const,
679
+ execution: 'backend' as const,
680
+ defaultPermission: (requiresHumanReview ? 'ASK' : autoCall ? 'ALLOW' : 'ASK') as 'ALLOW' | 'ASK',
681
+ introduction: {
682
+ title: `Skill Hub: ${skill.get('title')}`,
683
+ about: skill.get('description') || `Thực thi kỹ năng ${skill.get('title')}`,
684
+ },
685
+ definition: {
686
+ name: `skill_hub_${sanitizedToolName}`,
687
+ description,
688
+ schema: parseJsonText(skill.get('inputSchema'), { type: 'object', properties: {} }),
689
+ },
690
+ invoke: async (toolCtx: any, args: any, runtime?: ToolRuntimeInput) => {
691
+ const restoreRuntime = assignToolRuntime(toolCtx, runtime);
692
+ try {
693
+ // Re-fetch skill to get latest version (hot-reload support)
694
+ const latestSkill = await (this as any).db.getRepository('skillDefinitions').findOne({
695
+ filter: { id: skill.get('id'), enabled: true },
696
+ });
697
+ if (!latestSkill) {
698
+ return { status: 'error', content: `Skill "${skill.get('name')}" is no longer available` };
699
+ }
700
+ const result = await this.executeSkill(latestSkill, args, toolCtx);
701
+ return {
702
+ status: result.status === 'succeeded' ? 'success' : 'error',
703
+ result: result, // Attach raw result
704
+ };
705
+ } finally {
706
+ restoreRuntime();
707
+ }
708
+ },
709
+ };
710
+ }),
711
+ );
712
+
713
+ register.registerTools(tools);
714
+ } catch (err) {
715
+ (this as any).app.logger.warn('[skill-hub] Failed to provide dynamic tools', err);
716
+ }
717
+ });
718
+
719
+ (this as any).app.logger.info('[skill-hub] AI tools registered (dynamic provider + general tool).');
720
+ } catch (error) {
721
+ (this as any).app.logger.warn('[skill-hub] Failed to register AI tools:', error);
722
+ }
723
+ }
724
+
725
+ private startCleanupInterval() {
726
+ // Check old execution files every hour, rate limiter every 5 minutes
727
+ const CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour
728
+
729
+ this.cleanupInterval = setInterval(async () => {
730
+ // 1. Storage Retention Cleanup
731
+ try {
732
+ const config = await (this as any).db.getRepository('skillWorkerConfigs').findOne();
733
+ const hours = config ? config.get('retentionHours') : 24;
734
+
735
+ if (hours && hours > 0) {
736
+ const MAX_AGE_MS = hours * 60 * 60 * 1000;
737
+ const cutoff = new Date(Date.now() - MAX_AGE_MS);
738
+ const repo = (this as any).db.getRepository('skillExecutions');
739
+
740
+ const outdated = await repo.find({
741
+ filter: { createdAt: { $lt: cutoff } },
742
+ });
743
+
744
+ if (outdated.length > 0) {
745
+ for (const record of outdated) {
746
+ await record.destroy(); // Fires afterDestroy hook which removes physical folder
747
+ }
748
+ (this as any).app.logger.info(`[skill-hub] Auto-cleaned up ${outdated.length} expired execution records`);
749
+ }
750
+ }
751
+ } catch (err) {
752
+ (this as any).app.logger.warn('[skill-hub] Auto Cleanup error:', err);
753
+ }
754
+
755
+ // 2. Cleanup rate limiter stale entries
756
+ this.rateLimiter.cleanup();
757
+ }, CLEANUP_INTERVAL);
758
+ }
759
+
760
+ async beforeStop() {
761
+ // Unsubscribe PubSub
762
+ if (this.initEnvDoneCallback) {
763
+ try {
764
+ await (this as any).app.pubSubManager.unsubscribe('skill-hub.init-env.done', this.initEnvDoneCallback);
765
+ } catch {
766
+ /* ignore */
767
+ }
768
+ }
769
+ if (this.initEnvProgressCallback) {
770
+ try {
771
+ await (this as any).app.pubSubManager.unsubscribe('skill-hub.init-env.progress', this.initEnvProgressCallback);
772
+ } catch {
773
+ /* ignore */
774
+ }
775
+ }
776
+
777
+ // Clear cleanup interval
778
+ if (this.cleanupInterval) {
779
+ clearInterval(this.cleanupInterval);
780
+ this.cleanupInterval = null;
781
+ }
782
+ }
783
+
784
+ // --- Handlers ---
785
+ private async handleClearStorage(ctx: any, next: () => Promise<any>) {
786
+ const { type } = ctx.request.body || ctx.action.params.values;
787
+ const repo = (this as any).db.getRepository('skillExecutions');
788
+ let count = 0;
789
+
790
+ if (type === 'all') {
791
+ const results = await repo.find({ fields: ['id'] });
792
+ for (const rec of results) {
793
+ await rec.destroy();
794
+ }
795
+ count = results.length;
796
+ } else if (type === 'expired') {
797
+ const config = await (this as any).db.getRepository('skillWorkerConfigs').findOne();
798
+ const hours = config ? config.get('retentionHours') : 24;
799
+ if (hours > 0) {
800
+ const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000);
801
+ const results = await repo.find({ filter: { createdAt: { $lt: cutoff } }, fields: ['id'] });
802
+ for (const rec of results) {
803
+ await rec.destroy();
804
+ }
805
+ count = results.length;
806
+ }
807
+ }
808
+
809
+ ctx.body = { count };
810
+ await next();
811
+ }
812
+
813
+ private async handleListTemplates(ctx: any, next: () => Promise<any>) {
814
+ // Dynamic Pull: discover templates from all active plugins in the system
815
+ try {
816
+ const allPlugins = (this as any).app.pm.getPlugins();
817
+ for (const [, pluginInstance] of allPlugins) {
818
+ if (typeof (pluginInstance as any).getSkillTemplates === 'function') {
819
+ const pluginSkills = (pluginInstance as any).getSkillTemplates();
820
+ if (Array.isArray(pluginSkills)) {
821
+ for (const s of pluginSkills) {
822
+ if (!this.skillTemplates.has(s.name)) {
823
+ this.skillTemplates.set(s.name, this.hydrateSkillTemplate(pluginInstance.name, s));
824
+ }
825
+ }
826
+ }
827
+ }
828
+ }
829
+ } catch (e) {
830
+ (this as any).app.logger.warn(`[skill-hub] Failed to discover some plugin skills: ${e.message}`);
831
+ }
832
+
833
+ ctx.body = { data: Array.from(this.skillTemplates.values()) };
834
+ await next();
835
+ }
836
+
837
+ // ─── Extension API: other plugins register/unregister skills ───
838
+
839
+ /**
840
+ * Register a skill template into memory for UI importing.
841
+ */
842
+ registerSkillTemplate(pluginName: string, skillDef: any) {
843
+ this.skillTemplates.set(skillDef.name, this.hydrateSkillTemplate(pluginName, skillDef));
844
+ (this as any).app.logger.info(
845
+ `[skill-hub] Registered skill template "${skillDef.name}" from plugin "${pluginName}"`,
846
+ );
847
+ }
848
+
849
+ resolveSkillTemplate(templateName: string) {
850
+ if (!templateName) return null;
851
+ const cached = this.skillTemplates.get(templateName);
852
+ if (cached) return cached;
853
+
854
+ try {
855
+ const allPlugins = (this as any).app.pm.getPlugins();
856
+ for (const [, pluginInstance] of allPlugins) {
857
+ if (typeof (pluginInstance as any).getSkillTemplates !== 'function') continue;
858
+ const pluginSkills = (pluginInstance as any).getSkillTemplates();
859
+ if (!Array.isArray(pluginSkills)) continue;
860
+ const found = pluginSkills.find((s: any) => s?.name === templateName);
861
+ if (found) {
862
+ const hydrated = this.hydrateSkillTemplate(pluginInstance.name, found);
863
+ this.skillTemplates.set(templateName, hydrated);
864
+ return hydrated;
865
+ }
866
+ }
867
+ } catch (e: any) {
868
+ (this as any).app.logger.warn(`[skill-hub] Failed to resolve plugin skill "${templateName}": ${e.message}`);
869
+ }
870
+
871
+ return null;
872
+ }
873
+
874
+ async getSkillDescriptionForAI(skill: any) {
875
+ const description = skill.get ? skill.get('description') : skill.description;
876
+ const instructions = await this.getSkillInstructions(skill);
877
+ const maxInlineInstructionChars = 24000;
878
+ const inlineInstructions =
879
+ instructions && instructions.length > maxInlineInstructionChars
880
+ ? `${instructions.slice(
881
+ 0,
882
+ maxInlineInstructionChars,
883
+ )}\n\n[Instructions truncated in tool description. Call skill_hub_execute with action="describe" and this skillName to load the complete workflow.]`
884
+ : instructions;
885
+ return [description, inlineInstructions ? `Instructions:\n${inlineInstructions}` : ''].filter(Boolean).join('\n\n');
886
+ }
887
+
888
+ async getSkillInstructions(skill: any) {
889
+ const storedInstructions = skill.get ? skill.get('instructions') : skill.instructions;
890
+ if (storedInstructions) return storedInstructions;
891
+
892
+ const storageType = skill.get ? skill.get('storageType') : skill.storageType;
893
+ if (storageType !== 'plugin') return '';
894
+
895
+ const templateName =
896
+ (skill.get ? skill.get('pluginSource') : skill.pluginSource) || (skill.get ? skill.get('name') : skill.name);
897
+ const template = this.resolveSkillTemplate(templateName);
898
+ return template?.instructions || '';
899
+ }
900
+
901
+ private hydrateSkillTemplate(pluginName: string, skillDef: any) {
902
+ const skillName = skillDef.name;
903
+ const packageRoot = skillDef.skillPackage?.rootDir;
904
+ let packageInfo: any = null;
905
+
906
+ if (packageRoot && this.skillRepoService) {
907
+ packageInfo = this.skillRepoService.readSkillPackage(packageRoot);
908
+ }
909
+
910
+ const metadata = packageInfo?.metadata || {};
911
+ const packageInstructions = packageInfo?.instructions;
912
+
913
+ return {
914
+ ...skillDef,
915
+ title: skillDef.title || metadata.title || skillName,
916
+ description: skillDef.description || metadata.description || '',
917
+ instructions: [skillDef.instructions, packageInstructions].filter(Boolean).join('\n\n').trim(),
918
+ language: skillDef.language || metadata.language || 'python',
919
+ codeTemplate: skillDef.codeTemplate || packageInfo?.code || '',
920
+ storageType: 'plugin',
921
+ storageUrl: skillDef.storageUrl || `plugin://${pluginName}/${skillName}`,
922
+ // Keep pluginSource as the skill template name because existing DB records use it for lookup.
923
+ pluginSource: skillName,
924
+ pluginName,
925
+ };
926
+ }
927
+
928
+ async install() {
929
+ await this.skillManager.seedDefaults();
930
+ }
931
+ }
932
+
933
+ export default SkillHubSubFeature;