plugin-agent-orchestrator 1.0.28 → 1.0.32

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 (108) hide show
  1. package/README.md +9 -7
  2. package/dist/client/index.js +1 -1
  3. package/dist/client-v2/{214.723affb37c13bf7a.js → 214.79650a549273f163.js} +1 -1
  4. package/dist/client-v2/264.718a107e43fc163c.js +10 -0
  5. package/dist/client-v2/373.f5d5292e53c4e832.js +10 -0
  6. package/dist/client-v2/{41.1805b2edfaa4afe2.js → 41.ba6e080cc0488143.js} +1 -1
  7. package/dist/client-v2/418.29e713f79131eece.js +10 -0
  8. package/dist/client-v2/619.bd3c5698b40705c3.js +10 -0
  9. package/dist/client-v2/677.a991ce0250ff5c77.js +10 -0
  10. package/dist/client-v2/{70.a15d7fcec7c41768.js → 70.bda9518881c05360.js} +1 -1
  11. package/dist/client-v2/925.f5370de8f6632d65.js +10 -0
  12. package/dist/client-v2/index.js +1 -1
  13. package/dist/externalVersion.js +7 -10
  14. package/dist/locale/en-US.json +94 -25
  15. package/dist/locale/vi-VN.json +94 -25
  16. package/dist/locale/zh-CN.json +94 -25
  17. package/dist/server/collections/agent-execution-spans.js +37 -0
  18. package/dist/server/collections/agent-harness-profiles.js +2 -2
  19. package/dist/server/collections/agent-memory-contexts.js +125 -0
  20. package/dist/server/collections/orchestrator-logs.js +2 -2
  21. package/dist/server/migrations/20260425000000-add-interaction-schema.js +3 -1
  22. package/dist/server/migrations/20260427000000-change-packages-to-text.js +3 -1
  23. package/dist/server/migrations/20260427000001-change-other-json-to-text.js +6 -2
  24. package/dist/server/migrations/20260524001000-add-plan-approval-and-harness-profiles.js +21 -19
  25. package/dist/server/migrations/20260621000000-native-policy-profile-defaults.js +193 -0
  26. package/dist/server/plugin.js +128 -74
  27. package/dist/server/resources/agent-monitor.js +454 -0
  28. package/dist/server/services/AgentHarness.js +24 -499
  29. package/dist/server/services/AgentMemoryContextService.js +216 -0
  30. package/dist/server/services/ExecutionSpanService.js +2 -2
  31. package/dist/server/services/NativeSubAgentObserver.js +413 -0
  32. package/dist/server/skill-hub/plugin.js +81 -5
  33. package/dist/server/skill-hub/tasks/SkillExecutionTask.js +9 -3
  34. package/dist/server/tools/delegate-task.js +11 -589
  35. package/dist/server/utils/skill-settings.js +18 -1
  36. package/package.json +47 -49
  37. package/src/client/AIEmployeesContext.tsx +5 -18
  38. package/src/client/AgentRunsTab.tsx +2 -771
  39. package/src/client/HarnessProfilesTab.tsx +2 -257
  40. package/src/client/OrchestratorSettings.tsx +97 -106
  41. package/src/client/RulesTab.tsx +2 -788
  42. package/src/client/plugin.tsx +0 -2
  43. package/src/client/skill-hub/components/ExecutionHistory.tsx +200 -202
  44. package/src/client/skill-hub/components/ExecutionProgress.tsx +51 -55
  45. package/src/client/skill-hub/components/LoopSettings.tsx +331 -331
  46. package/src/client/skill-hub/components/SkillEditor.tsx +43 -39
  47. package/src/client/skill-hub/components/SkillManager.tsx +194 -181
  48. package/src/client/skill-hub/components/SkillTestPanel.tsx +141 -145
  49. package/src/client/skill-hub/locale.ts +16 -16
  50. package/src/client/skill-hub/tools/SkillHubCard.tsx +104 -109
  51. package/src/client/skill-hub/tools/loopTemplates.ts +52 -52
  52. package/src/client/skill-hub/utils/jsonFields.ts +7 -3
  53. package/src/client-v2/components/AIEmployeesContext.tsx +3 -16
  54. package/src/client-v2/components/AgentRunsTab.tsx +182 -455
  55. package/src/client-v2/components/HarnessProfilesTab.tsx +34 -31
  56. package/src/client-v2/components/RulesTab.tsx +2 -782
  57. package/src/client-v2/components/TracingTab.tsx +1 -1
  58. package/src/client-v2/hooks/useApiRequest.ts +8 -1
  59. package/src/client-v2/pages/RulesPage.tsx +2 -2
  60. package/src/client-v2/plugin.tsx +3 -3
  61. package/src/locale/en-US.json +94 -25
  62. package/src/locale/vi-VN.json +94 -25
  63. package/src/locale/zh-CN.json +94 -25
  64. package/src/server/__tests__/native-sub-agent-observer.test.ts +246 -0
  65. package/src/server/__tests__/skill-settings.test.ts +6 -6
  66. package/src/server/__tests__/smoke.test.ts +1 -0
  67. package/src/server/collections/agent-execution-spans.ts +37 -0
  68. package/src/server/collections/agent-harness-profiles.ts +59 -59
  69. package/src/server/collections/agent-loop-events.ts +71 -71
  70. package/src/server/collections/agent-loop-steps.ts +144 -144
  71. package/src/server/collections/agent-memory-contexts.ts +95 -0
  72. package/src/server/collections/orchestrator-logs.ts +4 -4
  73. package/src/server/collections/skill-definitions.ts +111 -111
  74. package/src/server/collections/skill-executions.ts +106 -106
  75. package/src/server/collections/skill-loop-configs.ts +65 -65
  76. package/src/server/migrations/20260423000000-add-progress-fields.ts +14 -14
  77. package/src/server/migrations/20260425000000-add-interaction-schema.ts +3 -1
  78. package/src/server/migrations/20260427000000-change-packages-to-text.ts +4 -2
  79. package/src/server/migrations/20260427000001-change-other-json-to-text.ts +9 -5
  80. package/src/server/migrations/20260524000000-add-agent-loop-fields-to-skill-executions.ts +30 -30
  81. package/src/server/migrations/20260524001000-add-plan-approval-and-harness-profiles.ts +145 -142
  82. package/src/server/migrations/20260615000000-normalize-ai-employee-tool-bindings.ts +2 -2
  83. package/src/server/migrations/20260621000000-native-policy-profile-defaults.ts +193 -0
  84. package/src/server/plugin.ts +151 -94
  85. package/src/server/resources/agent-monitor.ts +482 -0
  86. package/src/server/services/AgentHarness.ts +38 -623
  87. package/src/server/services/AgentMemoryContextService.ts +256 -0
  88. package/src/server/services/AgentPlanValidator.ts +73 -73
  89. package/src/server/services/ExecutionSpanService.ts +6 -2
  90. package/src/server/services/FileManager.ts +144 -144
  91. package/src/server/services/NativeSubAgentObserver.ts +507 -0
  92. package/src/server/services/SkillManager.ts +583 -583
  93. package/src/server/services/SkillRepositoryService.ts +5 -7
  94. package/src/server/services/TokenTracker.ts +3 -3
  95. package/src/server/services/WorkerEnvManager.ts +1 -2
  96. package/src/server/skill-hub/actions/git-import.ts +5 -7
  97. package/src/server/skill-hub/plugin.ts +89 -6
  98. package/src/server/skill-hub/tasks/SkillExecutionTask.ts +470 -460
  99. package/src/server/skill-hub/utils/json-fields.ts +1 -1
  100. package/src/server/tools/delegate-task.ts +13 -847
  101. package/src/server/utils/skill-settings.ts +24 -6
  102. package/dist/client-v2/264.0533912e6c5ea2d7.js +0 -10
  103. package/dist/client-v2/418.5ae055abf141820e.js +0 -10
  104. package/dist/client-v2/619.d99d3c9e61c99064.js +0 -10
  105. package/dist/client-v2/892.72db4161511c8a16.js +0 -10
  106. package/dist/client-v2/926.87f660b670d85bcc.js +0 -10
  107. package/src/client/tools/PlanApprovalCard.tsx +0 -176
  108. package/src/client/tools/registerOrchestratorCards.ts +0 -17
@@ -1,460 +1,470 @@
1
- import Application from '@nocobase/server';
2
- import { cpSync, existsSync, readFileSync, rmSync } from 'fs';
3
- import { resolve, sep } from 'path';
4
- import { SandboxRunner } from '../../services/SandboxRunner';
5
- import { FileManager } from '../../services/FileManager';
6
- import { SkillRepositoryService } from '../../services/SkillRepositoryService';
7
- import { CodeValidator } from '../../services/CodeValidator';
8
- import { parseJsonText, stringifyJsonText } from '../utils/json-fields';
9
-
10
- /**
11
- * Lightweight abort controller compatible with the SandboxRunner signal interface.
12
- * Worker-side: subscribes to PubSub abort channel and triggers signal.
13
- */
14
- class TaskAbortController {
15
- private listeners: Array<() => void> = [];
16
- private _aborted = false;
17
-
18
- get aborted() {
19
- return this._aborted;
20
- }
21
-
22
- get signal() {
23
- return {
24
- addEventListener: (_event: string, listener: () => void) => {
25
- if (this._aborted) {
26
- listener();
27
- } else {
28
- this.listeners.push(listener);
29
- }
30
- },
31
- };
32
- }
33
-
34
- abort() {
35
- if (this._aborted) return;
36
- this._aborted = true;
37
- for (const listener of this.listeners) {
38
- try {
39
- listener();
40
- } catch {
41
- // ignore
42
- }
43
- }
44
- this.listeners = [];
45
- }
46
- }
47
-
48
- export class SkillExecutionTask {
49
- constructor(
50
- private execution: any,
51
- private sandboxRunner: SandboxRunner,
52
- private fileManager: FileManager,
53
- private skillRepoService: SkillRepositoryService,
54
- private app: Application,
55
- ) {}
56
-
57
- async run() {
58
- const skill = this.execution.get('skill') || this.execution.skill;
59
- const execId = String(this.execution.get('id'));
60
-
61
- await this.execution.update({ status: 'running' });
62
-
63
- // Set up abort controller — listens for cancel from main server via PubSub
64
- const abortController = new TaskAbortController();
65
- const abortChannel = `skill-hub.abort.${execId}`;
66
- const abortCallback = async () => {
67
- (this as any).app.logger.info(`[skill-hub] Task ${execId}: received abort signal`);
68
- abortController.abort();
69
- };
70
-
71
- try {
72
- // Subscribe to abort channel before starting execution
73
- await (this as any).app.pubSubManager.subscribe(abortChannel, abortCallback);
74
-
75
- // Render code template with input args
76
- const inputArgs = parseJsonText(this.execution.get('inputArgs'), {});
77
- const storageType = skill.get ? skill.get('storageType') : skill.storageType;
78
-
79
- let rawCodeTemplate = skill.get ? skill.get('codeTemplate') : skill.codeTemplate;
80
- let language = skill.get ? skill.get('language') : skill.language;
81
- let timeoutSeconds = skill.get ? skill.get('timeoutSeconds') : skill.timeoutSeconds;
82
- let maxOutputSizeMb = skill.get ? skill.get('maxOutputSizeMb') : skill.maxOutputSizeMb;
83
- const skillName = skill.get ? skill.get('name') : skill.name;
84
- const workDir = this.fileManager.createExecDir(execId);
85
- let skillDir: string | undefined;
86
-
87
- if (storageType === 'plugin') {
88
- const pluginSkillName = (skill.get ? skill.get('pluginSource') : skill.pluginSource) || skillName;
89
- const orchestratorPlugin = (this as any).app.pm.get('plugin-agent-orchestrator') as any;
90
- const skillHub = orchestratorPlugin?.skillHub;
91
- let pluginTemplate = typeof skillHub?.resolveSkillTemplate === 'function'
92
- ? skillHub.resolveSkillTemplate(pluginSkillName)
93
- : skillHub?.skillTemplates?.get(pluginSkillName);
94
-
95
- // Fallback: discover dynamically if not cached (e.g. executed in worker before UI was loaded)
96
- if (!pluginTemplate && skillHub) {
97
- const allPlugins = (this as any).app.pm.getPlugins();
98
- for (const [, pInstance] of allPlugins) {
99
- if (typeof (pInstance as any).getSkillTemplates === 'function') {
100
- const pluginSkills = (pInstance as any).getSkillTemplates();
101
- if (Array.isArray(pluginSkills)) {
102
- for (const s of pluginSkills) {
103
- if (s.name === pluginSkillName) {
104
- pluginTemplate = typeof skillHub?.hydrateSkillTemplate === 'function'
105
- ? skillHub.hydrateSkillTemplate(pInstance.name, s)
106
- : { ...s, pluginSource: s.name, pluginName: pInstance.name };
107
- skillHub.skillTemplates.set(s.name, pluginTemplate);
108
- break;
109
- }
110
- }
111
- }
112
- }
113
- if (pluginTemplate) break;
114
- }
115
- }
116
-
117
- if (pluginTemplate) {
118
- rawCodeTemplate = pluginTemplate.codeTemplate;
119
- language = pluginTemplate.language;
120
- if (pluginTemplate.timeoutSeconds) timeoutSeconds = pluginTemplate.timeoutSeconds;
121
- if (pluginTemplate.maxOutputSizeMb) maxOutputSizeMb = pluginTemplate.maxOutputSizeMb;
122
- const packageRoot = pluginTemplate.skillPackage?.rootDir;
123
- if (packageRoot) {
124
- const mountMode = pluginTemplate.skillPackage?.mountMode || 'reference';
125
- if (mountMode === 'copy') {
126
- skillDir = resolve(workDir, 'skill');
127
- this.skillRepoService.copyDirectoryTo(packageRoot, skillDir);
128
- } else {
129
- skillDir = packageRoot;
130
- }
131
- }
132
- } else {
133
- throw new Error(`Plugin skill "${pluginSkillName}" not found. Is the parent plugin enabled?`);
134
- }
135
- }
136
-
137
- if (!rawCodeTemplate) {
138
- throw new Error(
139
- `Skill "${skillName}" has no codeTemplate. Add a code file, inline codeTemplate, or bind it to an installed plugin skill.`,
140
- );
141
- }
142
-
143
- if (storageType !== 'plugin') {
144
- skillDir = workDir;
145
- }
146
-
147
- const code = this.renderTemplate(rawCodeTemplate, inputArgs, execId, skillDir);
148
- await this.execution.update({ executedCode: code });
149
-
150
- // Load package whitelist for import validation
151
- let packageWhitelist: string[] = [];
152
- try {
153
- const workerConfig = await (this as any).app.db.getRepository('skillWorkerConfigs').findOne();
154
- if (workerConfig) {
155
- const wl = parseJsonText(
156
- workerConfig.get ? workerConfig.get('packageWhitelist') : workerConfig.packageWhitelist,
157
- { python: [], node: [], apt: [] },
158
- );
159
- if (wl) {
160
- packageWhitelist = (language === 'node' ? wl.node : wl.python) || [];
161
- }
162
- }
163
- } catch {
164
- // Skip whitelist validation if config not available
165
- }
166
-
167
- // Pre-hydrate execution workspace with package contents (multi-file support)
168
- const fileId = skill.get ? skill.get('fileId') : skill.fileId;
169
-
170
- // In multi-node setups, local cache might be missing on this specific worker node. Re-download from S3 if needed.
171
- if (!require('fs').existsSync(this.skillRepoService.getSkillPath(skillName)) && fileId) {
172
- const fmPlugin = (this as any).app.pm.get('@nocobase/plugin-file-manager') as any;
173
- const attachment = await (this as any).app.db.getRepository('attachments').findOne({ filter: { id: fileId } });
174
- if (fmPlugin && attachment) {
175
- const rawStorageId = attachment.get('storageId') || attachment.storageId;
176
- let matchedKey = null;
177
- if (rawStorageId) {
178
- const strId = String(rawStorageId);
179
- for (const key of fmPlugin.storagesCache.keys()) {
180
- if (String(key) === strId) {
181
- matchedKey = key;
182
- break;
183
- }
184
- }
185
- }
186
-
187
- const attachmentObj = typeof attachment.toJSON === 'function' ? attachment.toJSON() : { ...attachment };
188
- if (matchedKey !== null) {
189
- attachmentObj.storageId = matchedKey;
190
- }
191
-
192
- try {
193
- const streamData = await fmPlugin.getFileStream(attachmentObj);
194
- if (streamData?.stream) {
195
- const tempZipPath = require('path').resolve(require('os').tmpdir(), `skill_${Date.now()}_exec.zip`);
196
- await new Promise((resolve, reject) => {
197
- const writeStream = require('fs').createWriteStream(tempZipPath);
198
- streamData.stream.pipe(writeStream);
199
- writeStream.on('finish', resolve);
200
- writeStream.on('error', reject);
201
- streamData.stream.on('error', reject);
202
- });
203
- await this.skillRepoService.extractSkillPackage(skillName, tempZipPath);
204
- require('fs').unlinkSync(tempZipPath);
205
- (this as any).app.logger.info(
206
- `[skill-hub] Task ${execId}: Auto-restored skill package ${skillName} from S3/Storage`,
207
- );
208
- }
209
- } catch (fetchErr) {
210
- (this as any).app.logger.warn(
211
- `[skill-hub] Task ${execId}: Failed to fetch skill package ${skillName} from storage`,
212
- { error: fetchErr },
213
- );
214
- }
215
- }
216
- }
217
-
218
- if (storageType !== 'plugin') {
219
- this.skillRepoService.copySkillPackageTo(skillName, workDir);
220
- skillDir = workDir;
221
- }
222
-
223
- const result = await this.sandboxRunner.execute({
224
- language,
225
- code,
226
- execId,
227
- timeoutSeconds: timeoutSeconds || 60,
228
- maxOutputSizeMb: maxOutputSizeMb || 50,
229
- skillDir,
230
- signal: abortController.signal,
231
- packageWhitelist,
232
- onProgress: (progress) => {
233
- // Worker → PubSub → Main Server → runtime.writer → SSE → Client
234
- (this as any).app.pubSubManager.publish(`skill-hub.progress.${execId}`, progress);
235
- },
236
- });
237
-
238
- if (result.success) {
239
- const installMessage = await this.installGeneratedSkillIfRequested(execId);
240
- if (installMessage) {
241
- result.stdout = [result.stdout, installMessage].filter(Boolean).join('\n');
242
- }
243
- }
244
-
245
- // Determine final status
246
- let status: string;
247
- if (result.canceled) {
248
- status = 'canceled';
249
- } else if (result.timedOut) {
250
- status = 'timeout';
251
- } else {
252
- status = result.success ? 'succeeded' : 'failed';
253
- }
254
-
255
- await this.execution.update({
256
- status,
257
- stdout: result.stdout,
258
- stderr: result.stderr,
259
- outputFiles: stringifyJsonText(result.files, []),
260
- durationMs: result.durationMs,
261
- });
262
-
263
- // Notify main server: task completed
264
- await (this as any).app.pubSubManager.publish(`skill-hub.done.${execId}`, {
265
- status,
266
- stdout: result.stdout?.slice(0, 3000),
267
- stderr: result.stderr?.slice(0, 1000),
268
- files: result.files,
269
- durationMs: result.durationMs,
270
- });
271
-
272
- // Log execution metrics
273
- (this as any).app.logger.info(
274
- `[skill-hub] Execution ${execId} ${status}: ` +
275
- `skill=${skill.get ? skill.get('name') : skill.name}, ` +
276
- `language=${language}, ` +
277
- `duration=${result.durationMs}ms, ` +
278
- `files=${result.files.length}, ` +
279
- `outputSize=${this.fileManager.getTotalOutputSize(execId)}bytes`,
280
- );
281
- } catch (error) {
282
- const errorMessage = error instanceof Error ? error.message : String(error);
283
-
284
- await this.execution.update({
285
- status: 'failed',
286
- stderr: errorMessage,
287
- });
288
-
289
- await (this as any).app.pubSubManager.publish(`skill-hub.done.${execId}`, {
290
- status: 'failed',
291
- stderr: errorMessage,
292
- files: [],
293
- durationMs: 0,
294
- });
295
-
296
- (this as any).app.logger.error(`[skill-hub] Execution ${execId} error: ${errorMessage}`);
297
- } finally {
298
- // Always cleanup abort subscription
299
- try {
300
- await (this as any).app.pubSubManager.unsubscribe(abortChannel, abortCallback);
301
- } catch {
302
- // ignore cleanup errors
303
- }
304
- }
305
- }
306
-
307
- private renderTemplate(template: string, args: Record<string, any>, execId: string, skillDir?: string): string {
308
- let code = template;
309
- for (const [key, value] of Object.entries(args)) {
310
- const serialized = typeof value === 'string' ? value : JSON.stringify(value);
311
- // Raw injection use for simple scalar values (canvas_format, transition, etc.)
312
- code = code.replaceAll(`{{${key}}}`, serialized);
313
- // Safe base64 injection — use for fields that may contain quotes/newlines (slides_svg, title, etc.)
314
- // Template placeholder: {{key_b64}}
315
- const b64 = Buffer.from(serialized, 'utf-8').toString('base64');
316
- code = code.replaceAll(`{{${key}_b64}}`, b64);
317
- }
318
- // Inject outputDir so code templates can use {{outputDir}}
319
- code = code.replaceAll('{{outputDir}}', this.fileManager.getOutputDir(execId).replace(/\\/g, '/'));
320
- code = code.replaceAll('{{skillDir}}', (skillDir || '').replace(/\\/g, '/'));
321
- return code;
322
- }
323
-
324
- private async installGeneratedSkillIfRequested(execId: string): Promise<string | null> {
325
- const manifestPath = this.fileManager.getOutputFilePath(execId, 'skill-hub-install.json');
326
- if (!manifestPath) return null;
327
-
328
- let manifest: any;
329
- try {
330
- manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
331
- } catch (error) {
332
- throw new Error(`Generated skill install manifest is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
333
- }
334
-
335
- if (!manifest?.autoInstall) return null;
336
-
337
- const skill = manifest.skill || {};
338
- const name = String(skill.name || '').trim();
339
- if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
340
- throw new Error(`Generated skill name "${name}" is invalid. Use lowercase letters, numbers, and hyphens.`);
341
- }
342
-
343
- const language = skill.language;
344
- if (language !== 'python' && language !== 'node') {
345
- throw new Error(`Generated skill "${name}" has unsupported language "${language}".`);
346
- }
347
-
348
- if (!skill.codeTemplate) {
349
- throw new Error(`Generated skill "${name}" is missing codeTemplate.`);
350
- }
351
-
352
- const validator = new CodeValidator();
353
- validator.validate(skill.codeTemplate, language);
354
- await this.validateGeneratedSkillPackages(name, language, skill.packages);
355
-
356
- const outputDir = this.fileManager.getOutputDir(execId);
357
- const outputRoot = resolve(outputDir);
358
- const packageDirName = String(manifest.packageDir || name);
359
- const packageDir = resolve(outputRoot, packageDirName);
360
- if ((packageDir !== outputRoot && !packageDir.startsWith(outputRoot + sep)) || !existsSync(packageDir)) {
361
- throw new Error(`Generated skill package directory "${packageDirName}" was not found in output.`);
362
- }
363
-
364
- if (manifest.testInput && Object.keys(manifest.testInput).length > 0) {
365
- const verifyExecId = `${execId}-verify-${name}`;
366
- const verifyCode = this.renderTemplate(skill.codeTemplate, manifest.testInput, verifyExecId, packageDir);
367
- const verifyResult = await this.sandboxRunner.execute({
368
- language,
369
- code: verifyCode,
370
- execId: verifyExecId,
371
- timeoutSeconds: Math.min(Number(skill.timeoutSeconds || 60), 30),
372
- maxOutputSizeMb: Math.min(Number(skill.maxOutputSizeMb || 50), 10),
373
- skillDir: packageDir,
374
- });
375
-
376
- if (!verifyResult.success) {
377
- throw new Error(
378
- `Generated skill "${name}" failed smoke verification: ${verifyResult.stderr || verifyResult.stdout || 'unknown error'}`,
379
- );
380
- }
381
- }
382
-
383
- const skillRepoDir = this.skillRepoService.getSkillPath(name);
384
- if (existsSync(skillRepoDir)) {
385
- rmSync(skillRepoDir, { recursive: true, force: true });
386
- }
387
- cpSync(packageDir, skillRepoDir, {
388
- recursive: true,
389
- force: true,
390
- filter: (src) => {
391
- const leaf = src.split(/[\\/]/).pop();
392
- return !['node_modules', '.git', '__pycache__'].includes(leaf || '') && !src.endsWith('.pyc');
393
- },
394
- });
395
-
396
- const values: any = {
397
- name,
398
- title: skill.title || name,
399
- description: skill.description || '',
400
- instructions: skill.instructions || '',
401
- language,
402
- codeTemplate: skill.codeTemplate,
403
- inputSchema: stringifyJsonText(skill.inputSchema || { type: 'object', properties: {} }),
404
- packages: stringifyJsonText(skill.packages || [], []),
405
- timeoutSeconds: skill.timeoutSeconds || 60,
406
- maxOutputSizeMb: skill.maxOutputSizeMb || 50,
407
- enabled: skill.enabled !== false,
408
- toolScope: skill.toolScope || 'CUSTOM',
409
- autoCall: !!skill.autoCall,
410
- storageType: 'local',
411
- storageUrl: `local://generated/${name}`,
412
- };
413
-
414
- if (skill.interactionSchema) {
415
- values.interactionSchema = stringifyJsonText(skill.interactionSchema);
416
- }
417
-
418
- const repo = (this as any).app.db.getRepository('skillDefinitions');
419
- const existing = await repo.findOne({ filter: { name } });
420
- if (existing) {
421
- if (manifest.overwrite === false) {
422
- throw new Error(`Skill "${name}" already exists and overwrite=false.`);
423
- }
424
- await repo.update({ filter: { name }, values });
425
- return `[skill-hub] Updated generated skill "${name}" in Skill Hub.`;
426
- }
427
-
428
- await repo.create({ values });
429
- return `[skill-hub] Installed generated skill "${name}" in Skill Hub.`;
430
- }
431
-
432
- private async validateGeneratedSkillPackages(name: string, language: 'python' | 'node', packages: any) {
433
- if (!Array.isArray(packages) || packages.length === 0) return;
434
- if (!packages.every((pkg) => typeof pkg === 'string' && pkg.trim())) {
435
- throw new Error(`Generated skill "${name}" has invalid packages. Use an array of package names.`);
436
- }
437
-
438
- const workerConfig = await (this as any).app.db.getRepository('skillWorkerConfigs').findOne();
439
- if (!workerConfig) return;
440
-
441
- const whitelist = parseJsonText(
442
- workerConfig.get ? workerConfig.get('packageWhitelist') : workerConfig.packageWhitelist,
443
- null,
444
- );
445
- const allowed = whitelist?.[language];
446
- if (!Array.isArray(allowed) || allowed.length === 0) return;
447
-
448
- const allowedSet = new Set(allowed.map((pkg: string) => pkg.toLowerCase()));
449
- const missing = packages
450
- .map((pkg: string) => pkg.trim())
451
- .filter((pkg: string) => !allowedSet.has(pkg.toLowerCase()));
452
-
453
- if (missing.length > 0) {
454
- throw new Error(
455
- `Generated skill "${name}" requires ${language} package(s) not available in the Skill Hub worker environment: ` +
456
- `${missing.join(', ')}. Add them to the worker environment and refresh/init Skill Hub before installing this skill.`,
457
- );
458
- }
459
- }
460
- }
1
+ import Application from '@nocobase/server';
2
+ import { cpSync, existsSync, readFileSync, rmSync } from 'fs';
3
+ import { resolve, sep } from 'path';
4
+ import { SandboxRunner } from '../../services/SandboxRunner';
5
+ import { FileManager } from '../../services/FileManager';
6
+ import { SkillRepositoryService } from '../../services/SkillRepositoryService';
7
+ import { CodeValidator } from '../../services/CodeValidator';
8
+ import { parseJsonText, stringifyJsonText } from '../utils/json-fields';
9
+
10
+ /**
11
+ * Lightweight abort controller compatible with the SandboxRunner signal interface.
12
+ * Worker-side: subscribes to PubSub abort channel and triggers signal.
13
+ */
14
+ class TaskAbortController {
15
+ private listeners: Array<() => void> = [];
16
+ private _aborted = false;
17
+
18
+ get aborted() {
19
+ return this._aborted;
20
+ }
21
+
22
+ get signal() {
23
+ return {
24
+ addEventListener: (_event: string, listener: () => void) => {
25
+ if (this._aborted) {
26
+ listener();
27
+ } else {
28
+ this.listeners.push(listener);
29
+ }
30
+ },
31
+ };
32
+ }
33
+
34
+ abort() {
35
+ if (this._aborted) return;
36
+ this._aborted = true;
37
+ for (const listener of this.listeners) {
38
+ try {
39
+ listener();
40
+ } catch {
41
+ // ignore
42
+ }
43
+ }
44
+ this.listeners = [];
45
+ }
46
+ }
47
+
48
+ export class SkillExecutionTask {
49
+ constructor(
50
+ private execution: any,
51
+ private sandboxRunner: SandboxRunner,
52
+ private fileManager: FileManager,
53
+ private skillRepoService: SkillRepositoryService,
54
+ private app: Application,
55
+ ) {}
56
+
57
+ async run() {
58
+ const skill = this.execution.get('skill') || this.execution.skill;
59
+ const execId = String(this.execution.get('id'));
60
+
61
+ if (this.execution.get('status') !== 'running') {
62
+ await this.execution.update({ status: 'running' });
63
+ }
64
+
65
+ // Set up abort controller — listens for cancel from main server via PubSub
66
+ const abortController = new TaskAbortController();
67
+ const abortChannel = `skill-hub.abort.${execId}`;
68
+ const abortCallback = async () => {
69
+ (this as any).app.logger.info(`[skill-hub] Task ${execId}: received abort signal`);
70
+ abortController.abort();
71
+ };
72
+
73
+ try {
74
+ // Subscribe to abort channel before starting execution
75
+ await (this as any).app.pubSubManager.subscribe(abortChannel, abortCallback);
76
+
77
+ // Render code template with input args
78
+ const inputArgs = parseJsonText(this.execution.get('inputArgs'), {});
79
+ const storageType = skill.get ? skill.get('storageType') : skill.storageType;
80
+
81
+ let rawCodeTemplate = skill.get ? skill.get('codeTemplate') : skill.codeTemplate;
82
+ let language = skill.get ? skill.get('language') : skill.language;
83
+ let timeoutSeconds = skill.get ? skill.get('timeoutSeconds') : skill.timeoutSeconds;
84
+ let maxOutputSizeMb = skill.get ? skill.get('maxOutputSizeMb') : skill.maxOutputSizeMb;
85
+ const skillName = skill.get ? skill.get('name') : skill.name;
86
+ const workDir = this.fileManager.createExecDir(execId);
87
+ let skillDir: string | undefined;
88
+
89
+ if (storageType === 'plugin') {
90
+ const pluginSkillName = (skill.get ? skill.get('pluginSource') : skill.pluginSource) || skillName;
91
+ const orchestratorPlugin = (this as any).app.pm.get('plugin-agent-orchestrator') as any;
92
+ const skillHub = orchestratorPlugin?.skillHub;
93
+ let pluginTemplate =
94
+ typeof skillHub?.resolveSkillTemplate === 'function'
95
+ ? skillHub.resolveSkillTemplate(pluginSkillName)
96
+ : skillHub?.skillTemplates?.get(pluginSkillName);
97
+
98
+ // Fallback: discover dynamically if not cached (e.g. executed in worker before UI was loaded)
99
+ if (!pluginTemplate && skillHub) {
100
+ const allPlugins = (this as any).app.pm.getPlugins();
101
+ for (const [, pInstance] of allPlugins) {
102
+ if (typeof (pInstance as any).getSkillTemplates === 'function') {
103
+ const pluginSkills = (pInstance as any).getSkillTemplates();
104
+ if (Array.isArray(pluginSkills)) {
105
+ for (const s of pluginSkills) {
106
+ if (s.name === pluginSkillName) {
107
+ pluginTemplate =
108
+ typeof skillHub?.hydrateSkillTemplate === 'function'
109
+ ? skillHub.hydrateSkillTemplate(pInstance.name, s)
110
+ : { ...s, pluginSource: s.name, pluginName: pInstance.name };
111
+ skillHub.skillTemplates.set(s.name, pluginTemplate);
112
+ break;
113
+ }
114
+ }
115
+ }
116
+ }
117
+ if (pluginTemplate) break;
118
+ }
119
+ }
120
+
121
+ if (pluginTemplate) {
122
+ rawCodeTemplate = pluginTemplate.codeTemplate;
123
+ language = pluginTemplate.language;
124
+ if (pluginTemplate.timeoutSeconds) timeoutSeconds = pluginTemplate.timeoutSeconds;
125
+ if (pluginTemplate.maxOutputSizeMb) maxOutputSizeMb = pluginTemplate.maxOutputSizeMb;
126
+ const packageRoot = pluginTemplate.skillPackage?.rootDir;
127
+ if (packageRoot) {
128
+ const mountMode = pluginTemplate.skillPackage?.mountMode || 'reference';
129
+ if (mountMode === 'copy') {
130
+ skillDir = resolve(workDir, 'skill');
131
+ this.skillRepoService.copyDirectoryTo(packageRoot, skillDir);
132
+ } else {
133
+ skillDir = packageRoot;
134
+ }
135
+ }
136
+ } else {
137
+ throw new Error(`Plugin skill "${pluginSkillName}" not found. Is the parent plugin enabled?`);
138
+ }
139
+ }
140
+
141
+ if (!rawCodeTemplate) {
142
+ throw new Error(
143
+ `Skill "${skillName}" has no codeTemplate. Add a code file, inline codeTemplate, or bind it to an installed plugin skill.`,
144
+ );
145
+ }
146
+
147
+ if (storageType !== 'plugin') {
148
+ skillDir = workDir;
149
+ }
150
+
151
+ const code = this.renderTemplate(rawCodeTemplate, inputArgs, execId, skillDir);
152
+ await this.execution.update({ executedCode: code });
153
+
154
+ // Load package whitelist for import validation
155
+ let packageWhitelist: string[] = [];
156
+ try {
157
+ const workerConfig = await (this as any).app.db.getRepository('skillWorkerConfigs').findOne();
158
+ if (workerConfig) {
159
+ const wl = parseJsonText(
160
+ workerConfig.get ? workerConfig.get('packageWhitelist') : workerConfig.packageWhitelist,
161
+ { python: [], node: [], apt: [] },
162
+ );
163
+ if (wl) {
164
+ packageWhitelist = (language === 'node' ? wl.node : wl.python) || [];
165
+ }
166
+ }
167
+ } catch {
168
+ // Skip whitelist validation if config not available
169
+ }
170
+
171
+ // Pre-hydrate execution workspace with package contents (multi-file support)
172
+ const fileId = skill.get ? skill.get('fileId') : skill.fileId;
173
+
174
+ // In multi-node setups, local cache might be missing on this specific worker node. Re-download from S3 if needed.
175
+ if (!require('fs').existsSync(this.skillRepoService.getSkillPath(skillName)) && fileId) {
176
+ const fmPlugin = (this as any).app.pm.get('@nocobase/plugin-file-manager') as any;
177
+ const attachment = await (this as any).app.db.getRepository('attachments').findOne({ filter: { id: fileId } });
178
+ if (fmPlugin && attachment) {
179
+ const rawStorageId = attachment.get('storageId') || attachment.storageId;
180
+ let matchedKey = null;
181
+ if (rawStorageId) {
182
+ const strId = String(rawStorageId);
183
+ for (const key of fmPlugin.storagesCache.keys()) {
184
+ if (String(key) === strId) {
185
+ matchedKey = key;
186
+ break;
187
+ }
188
+ }
189
+ }
190
+
191
+ const attachmentObj = typeof attachment.toJSON === 'function' ? attachment.toJSON() : { ...attachment };
192
+ if (matchedKey !== null) {
193
+ attachmentObj.storageId = matchedKey;
194
+ }
195
+
196
+ try {
197
+ const streamData = await fmPlugin.getFileStream(attachmentObj);
198
+ if (streamData?.stream) {
199
+ const tempZipPath = require('path').resolve(require('os').tmpdir(), `skill_${Date.now()}_exec.zip`);
200
+ await new Promise((resolve, reject) => {
201
+ const writeStream = require('fs').createWriteStream(tempZipPath);
202
+ streamData.stream.pipe(writeStream);
203
+ writeStream.on('finish', resolve);
204
+ writeStream.on('error', reject);
205
+ streamData.stream.on('error', reject);
206
+ });
207
+ await this.skillRepoService.extractSkillPackage(skillName, tempZipPath);
208
+ require('fs').unlinkSync(tempZipPath);
209
+ (this as any).app.logger.info(
210
+ `[skill-hub] Task ${execId}: Auto-restored skill package ${skillName} from S3/Storage`,
211
+ );
212
+ }
213
+ } catch (fetchErr) {
214
+ (this as any).app.logger.warn(
215
+ `[skill-hub] Task ${execId}: Failed to fetch skill package ${skillName} from storage`,
216
+ { error: fetchErr },
217
+ );
218
+ }
219
+ }
220
+ }
221
+
222
+ if (storageType !== 'plugin') {
223
+ this.skillRepoService.copySkillPackageTo(skillName, workDir);
224
+ skillDir = workDir;
225
+ }
226
+
227
+ const result = await this.sandboxRunner.execute({
228
+ language,
229
+ code,
230
+ execId,
231
+ timeoutSeconds: timeoutSeconds || 60,
232
+ maxOutputSizeMb: maxOutputSizeMb || 50,
233
+ skillDir,
234
+ signal: abortController.signal,
235
+ packageWhitelist,
236
+ onProgress: (progress) => {
237
+ // Worker → PubSub → Main Server → runtime.writer → SSE → Client
238
+ (this as any).app.pubSubManager.publish(`skill-hub.progress.${execId}`, progress);
239
+ },
240
+ });
241
+
242
+ if (result.success) {
243
+ const installMessage = await this.installGeneratedSkillIfRequested(execId);
244
+ if (installMessage) {
245
+ result.stdout = [result.stdout, installMessage].filter(Boolean).join('\n');
246
+ }
247
+ }
248
+
249
+ // Determine final status
250
+ let status: string;
251
+ if (result.canceled) {
252
+ status = 'canceled';
253
+ } else if (result.timedOut) {
254
+ status = 'timeout';
255
+ } else {
256
+ status = result.success ? 'succeeded' : 'failed';
257
+ }
258
+
259
+ await this.execution.update({
260
+ status,
261
+ stdout: result.stdout,
262
+ stderr: result.stderr,
263
+ outputFiles: stringifyJsonText(result.files, []),
264
+ durationMs: result.durationMs,
265
+ });
266
+
267
+ // Notify main server: task completed
268
+ await (this as any).app.pubSubManager.publish(`skill-hub.done.${execId}`, {
269
+ status,
270
+ stdout: result.stdout?.slice(0, 3000),
271
+ stderr: result.stderr?.slice(0, 1000),
272
+ files: result.files,
273
+ durationMs: result.durationMs,
274
+ });
275
+
276
+ // Log execution metrics
277
+ (this as any).app.logger.info(
278
+ `[skill-hub] Execution ${execId} ${status}: ` +
279
+ `skill=${skill.get ? skill.get('name') : skill.name}, ` +
280
+ `language=${language}, ` +
281
+ `duration=${result.durationMs}ms, ` +
282
+ `files=${result.files.length}, ` +
283
+ `outputSize=${this.fileManager.getTotalOutputSize(execId)}bytes`,
284
+ );
285
+ } catch (error) {
286
+ const errorMessage = error instanceof Error ? error.message : String(error);
287
+
288
+ await this.execution.update({
289
+ status: 'failed',
290
+ stderr: errorMessage,
291
+ });
292
+
293
+ await (this as any).app.pubSubManager.publish(`skill-hub.done.${execId}`, {
294
+ status: 'failed',
295
+ stderr: errorMessage,
296
+ files: [],
297
+ durationMs: 0,
298
+ });
299
+
300
+ (this as any).app.logger.error(`[skill-hub] Execution ${execId} error: ${errorMessage}`);
301
+ } finally {
302
+ // Always cleanup abort subscription
303
+ try {
304
+ await (this as any).app.pubSubManager.unsubscribe(abortChannel, abortCallback);
305
+ } catch {
306
+ // ignore cleanup errors
307
+ }
308
+ }
309
+ }
310
+
311
+ private renderTemplate(template: string, args: Record<string, any>, execId: string, skillDir?: string): string {
312
+ let code = template;
313
+ for (const [key, value] of Object.entries(args)) {
314
+ const serialized = typeof value === 'string' ? value : JSON.stringify(value);
315
+ // Raw injection — use for simple scalar values (canvas_format, transition, etc.)
316
+ code = code.replaceAll(`{{${key}}}`, serialized);
317
+ // Safe base64 injection — use for fields that may contain quotes/newlines (slides_svg, title, etc.)
318
+ // Template placeholder: {{key_b64}}
319
+ const b64 = Buffer.from(serialized, 'utf-8').toString('base64');
320
+ code = code.replaceAll(`{{${key}_b64}}`, b64);
321
+ }
322
+ // Inject outputDir so code templates can use {{outputDir}}
323
+ code = code.replaceAll('{{outputDir}}', this.fileManager.getOutputDir(execId).replace(/\\/g, '/'));
324
+ code = code.replaceAll('{{skillDir}}', (skillDir || '').replace(/\\/g, '/'));
325
+ return code;
326
+ }
327
+
328
+ private async installGeneratedSkillIfRequested(execId: string): Promise<string | null> {
329
+ const manifestPath = this.fileManager.getOutputFilePath(execId, 'skill-hub-install.json');
330
+ if (!manifestPath) return null;
331
+
332
+ let manifest: any;
333
+ try {
334
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
335
+ } catch (error) {
336
+ throw new Error(
337
+ `Generated skill install manifest is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
338
+ );
339
+ }
340
+
341
+ if (!manifest?.autoInstall) return null;
342
+
343
+ const skill = manifest.skill || {};
344
+ const name = String(skill.name || '').trim();
345
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
346
+ throw new Error(`Generated skill name "${name}" is invalid. Use lowercase letters, numbers, and hyphens.`);
347
+ }
348
+
349
+ const language = skill.language;
350
+ if (language !== 'python' && language !== 'node') {
351
+ throw new Error(`Generated skill "${name}" has unsupported language "${language}".`);
352
+ }
353
+
354
+ if (!skill.codeTemplate) {
355
+ throw new Error(`Generated skill "${name}" is missing codeTemplate.`);
356
+ }
357
+
358
+ const validator = new CodeValidator();
359
+ validator.validate(skill.codeTemplate, language);
360
+ await this.validateGeneratedSkillPackages(name, language, skill.packages);
361
+
362
+ const outputDir = this.fileManager.getOutputDir(execId);
363
+ const outputRoot = resolve(outputDir);
364
+ const packageDirName = String(manifest.packageDir || name);
365
+ const packageDir = resolve(outputRoot, packageDirName);
366
+ if ((packageDir !== outputRoot && !packageDir.startsWith(outputRoot + sep)) || !existsSync(packageDir)) {
367
+ throw new Error(`Generated skill package directory "${packageDirName}" was not found in output.`);
368
+ }
369
+
370
+ if (manifest.testInput && Object.keys(manifest.testInput).length > 0) {
371
+ const verifyExecId = `${execId}-verify-${name}`;
372
+ const verifyCode = this.renderTemplate(skill.codeTemplate, manifest.testInput, verifyExecId, packageDir);
373
+ const verifyResult = await this.sandboxRunner.execute({
374
+ language,
375
+ code: verifyCode,
376
+ execId: verifyExecId,
377
+ timeoutSeconds: Math.min(Number(skill.timeoutSeconds || 60), 30),
378
+ maxOutputSizeMb: Math.min(Number(skill.maxOutputSizeMb || 50), 10),
379
+ skillDir: packageDir,
380
+ });
381
+
382
+ if (!verifyResult.success) {
383
+ throw new Error(
384
+ `Generated skill "${name}" failed smoke verification: ${
385
+ verifyResult.stderr || verifyResult.stdout || 'unknown error'
386
+ }`,
387
+ );
388
+ }
389
+ }
390
+
391
+ const skillRepoDir = this.skillRepoService.getSkillPath(name);
392
+ if (existsSync(skillRepoDir)) {
393
+ rmSync(skillRepoDir, { recursive: true, force: true });
394
+ }
395
+ cpSync(packageDir, skillRepoDir, {
396
+ recursive: true,
397
+ force: true,
398
+ filter: (src) => {
399
+ const leaf = src.split(/[\\/]/).pop();
400
+ return !['node_modules', '.git', '__pycache__'].includes(leaf || '') && !src.endsWith('.pyc');
401
+ },
402
+ });
403
+
404
+ const values: any = {
405
+ name,
406
+ title: skill.title || name,
407
+ description: skill.description || '',
408
+ instructions: skill.instructions || '',
409
+ language,
410
+ codeTemplate: skill.codeTemplate,
411
+ inputSchema: stringifyJsonText(skill.inputSchema || { type: 'object', properties: {} }),
412
+ packages: stringifyJsonText(skill.packages || [], []),
413
+ timeoutSeconds: skill.timeoutSeconds || 60,
414
+ maxOutputSizeMb: skill.maxOutputSizeMb || 50,
415
+ enabled: skill.enabled !== false,
416
+ toolScope: skill.toolScope || 'CUSTOM',
417
+ autoCall: !!skill.autoCall,
418
+ storageType: 'local',
419
+ storageUrl: `local://generated/${name}`,
420
+ };
421
+
422
+ if (skill.interactionSchema) {
423
+ values.interactionSchema = stringifyJsonText(skill.interactionSchema);
424
+ }
425
+
426
+ const repo = (this as any).app.db.getRepository('skillDefinitions');
427
+ const existing = await repo.findOne({ filter: { name } });
428
+ if (existing) {
429
+ if (manifest.overwrite === false) {
430
+ throw new Error(`Skill "${name}" already exists and overwrite=false.`);
431
+ }
432
+ await repo.update({ filter: { name }, values });
433
+ return `[skill-hub] Updated generated skill "${name}" in Skill Hub.`;
434
+ }
435
+
436
+ await repo.create({ values });
437
+ return `[skill-hub] Installed generated skill "${name}" in Skill Hub.`;
438
+ }
439
+
440
+ private async validateGeneratedSkillPackages(name: string, language: 'python' | 'node', packages: any) {
441
+ if (!Array.isArray(packages) || packages.length === 0) return;
442
+ if (!packages.every((pkg) => typeof pkg === 'string' && pkg.trim())) {
443
+ throw new Error(`Generated skill "${name}" has invalid packages. Use an array of package names.`);
444
+ }
445
+
446
+ const workerConfig = await (this as any).app.db.getRepository('skillWorkerConfigs').findOne();
447
+ if (!workerConfig) return;
448
+
449
+ const whitelist = parseJsonText(
450
+ workerConfig.get ? workerConfig.get('packageWhitelist') : workerConfig.packageWhitelist,
451
+ null,
452
+ );
453
+ const allowed = whitelist?.[language];
454
+ if (!Array.isArray(allowed) || allowed.length === 0) return;
455
+
456
+ const allowedSet = new Set(allowed.map((pkg: string) => pkg.toLowerCase()));
457
+ const missing = packages
458
+ .map((pkg: string) => pkg.trim())
459
+ .filter((pkg: string) => !allowedSet.has(pkg.toLowerCase()));
460
+
461
+ if (missing.length > 0) {
462
+ throw new Error(
463
+ `Generated skill "${name}" requires ${language} package(s) not available in the Skill Hub worker environment: ` +
464
+ `${missing.join(
465
+ ', ',
466
+ )}. Add them to the worker environment and refresh/init Skill Hub before installing this skill.`,
467
+ );
468
+ }
469
+ }
470
+ }