@ian2018cs/agenthub 0.1.76 → 0.1.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-DkNpDSsg.css +32 -0
- package/dist/assets/index-NaMmXkCt.js +197 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/server/claude-sdk.js +7 -152
- package/server/index.js +119 -12
- package/server/routes/agents.js +331 -3
- package/server/services/builtin-tools/background-task-pool.js +316 -0
- package/server/services/builtin-tools/background-task.js +231 -0
- package/server/services/builtin-tools/index.js +146 -0
- package/server/services/builtin-tools/share-project-template.js +124 -0
- package/server/services/system-agent-repo.js +17 -1
- package/server/services/user-directories.js +1 -0
- package/shared/brand.js +4 -0
- package/dist/assets/index-B5imuFpg.js +0 -192
- package/dist/assets/index-oUz7uC99.css +0 -32
package/server/routes/agents.js
CHANGED
|
@@ -159,6 +159,217 @@ function parseYamlMcps(yamlContent) {
|
|
|
159
159
|
return mcps;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Parse git_repos list from agent.yaml content string.
|
|
164
|
+
* Returns array of { name, repo, branch }.
|
|
165
|
+
*/
|
|
166
|
+
function parseYamlGitRepos(yamlContent) {
|
|
167
|
+
const repos = [];
|
|
168
|
+
const section = yamlContent.match(/^git_repos:\s*\n((?:[ \t]+.+\n?)*)/m);
|
|
169
|
+
if (!section) return repos;
|
|
170
|
+
const lines = section[1].split('\n');
|
|
171
|
+
let current = null;
|
|
172
|
+
for (const line of lines) {
|
|
173
|
+
const nameMatch = line.match(/^\s+-\s+name:\s*["']?(.+?)["']?\s*$/);
|
|
174
|
+
if (nameMatch) {
|
|
175
|
+
if (current) repos.push(current);
|
|
176
|
+
current = { name: nameMatch[1].trim(), repo: '', branch: 'main' };
|
|
177
|
+
}
|
|
178
|
+
const repoMatch = line.match(/^\s+repo:\s*["']?(.+?)["']?\s*$/);
|
|
179
|
+
if (repoMatch && current) current.repo = repoMatch[1].trim();
|
|
180
|
+
const branchMatch = line.match(/^\s+branch:\s*["']?(.+?)["']?\s*$/);
|
|
181
|
+
if (branchMatch && current) current.branch = branchMatch[1].trim();
|
|
182
|
+
}
|
|
183
|
+
if (current) repos.push(current);
|
|
184
|
+
return repos;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Run a git command and return stdout (for reading git info).
|
|
189
|
+
*/
|
|
190
|
+
function runGitOutput(args, cwd) {
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
const opts = { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } };
|
|
193
|
+
if (cwd) opts.cwd = cwd;
|
|
194
|
+
const proc = spawn('git', args, opts);
|
|
195
|
+
let stdout = '';
|
|
196
|
+
let stderr = '';
|
|
197
|
+
proc.stdout.on('data', d => { stdout += d.toString(); });
|
|
198
|
+
proc.stderr.on('data', d => { stderr += d.toString(); });
|
|
199
|
+
proc.on('close', code => {
|
|
200
|
+
if (code === 0) resolve(stdout.trim());
|
|
201
|
+
else reject(new Error(stderr || `git ${args[0]} failed with code ${code}`));
|
|
202
|
+
});
|
|
203
|
+
proc.on('error', err => reject(err));
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Scan immediate subdirectories of a project for git repositories.
|
|
209
|
+
* Returns array of { name, repo, branch }.
|
|
210
|
+
*/
|
|
211
|
+
async function scanGitRepos(projectPath) {
|
|
212
|
+
const results = [];
|
|
213
|
+
const SKIP_DIRS = new Set(['node_modules', '.claude', '.git', '.vscode', '__pycache__', '.next', 'dist', 'build']);
|
|
214
|
+
|
|
215
|
+
let entries;
|
|
216
|
+
try {
|
|
217
|
+
entries = await fs.readdir(projectPath, { withFileTypes: true });
|
|
218
|
+
} catch {
|
|
219
|
+
return results;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
224
|
+
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
|
|
225
|
+
|
|
226
|
+
const subdirPath = path.join(projectPath, entry.name);
|
|
227
|
+
|
|
228
|
+
// Check if this is a git repo (has .git directory)
|
|
229
|
+
try {
|
|
230
|
+
await fs.access(path.join(subdirPath, '.git'));
|
|
231
|
+
} catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Get remote URL
|
|
236
|
+
let repoUrl;
|
|
237
|
+
try {
|
|
238
|
+
repoUrl = await runGitOutput(['remote', 'get-url', 'origin'], subdirPath);
|
|
239
|
+
} catch {
|
|
240
|
+
continue; // Skip repos without remote
|
|
241
|
+
}
|
|
242
|
+
if (!repoUrl) continue;
|
|
243
|
+
|
|
244
|
+
// Get current branch
|
|
245
|
+
let branch;
|
|
246
|
+
try {
|
|
247
|
+
branch = await runGitOutput(['branch', '--show-current'], subdirPath);
|
|
248
|
+
} catch {
|
|
249
|
+
try {
|
|
250
|
+
branch = await runGitOutput(['rev-parse', '--abbrev-ref', 'HEAD'], subdirPath);
|
|
251
|
+
} catch {
|
|
252
|
+
branch = 'main';
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (!branch) branch = 'main';
|
|
256
|
+
|
|
257
|
+
results.push({ name: entry.name, repo: repoUrl, branch });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return results;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Get the shared git repo path for a given repo URL and branch.
|
|
265
|
+
* Path structure: data/git-repo/{owner}/{repo}/{branch}/
|
|
266
|
+
*/
|
|
267
|
+
function getSharedGitRepoPath(repoUrl, branch) {
|
|
268
|
+
const parsed = parseGitUrl(repoUrl);
|
|
269
|
+
if (!parsed) return null;
|
|
270
|
+
const publicPaths = getPublicPaths();
|
|
271
|
+
return path.join(publicPaths.gitRepoDir, parsed.owner, parsed.repo, branch);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Ensure a shared git repo exists at data/git-repo/{owner}/{repo}/{branch}/.
|
|
276
|
+
* - If target exists: fetch + checkout + pull (reset on conflict)
|
|
277
|
+
* - If target doesn't exist but same repo other branch exists: copy + fetch + checkout
|
|
278
|
+
* - If completely new: clone + checkout
|
|
279
|
+
* Returns the local path to the shared repo.
|
|
280
|
+
*/
|
|
281
|
+
async function ensureSharedGitRepo(repoUrl, branch) {
|
|
282
|
+
const parsed = parseGitUrl(repoUrl);
|
|
283
|
+
if (!parsed) throw new Error(`Cannot parse git URL: ${repoUrl}`);
|
|
284
|
+
|
|
285
|
+
const publicPaths = getPublicPaths();
|
|
286
|
+
const targetPath = path.join(publicPaths.gitRepoDir, parsed.owner, parsed.repo, branch);
|
|
287
|
+
const repoParentDir = path.join(publicPaths.gitRepoDir, parsed.owner, parsed.repo);
|
|
288
|
+
|
|
289
|
+
// Case 1: Target path already exists — update it
|
|
290
|
+
try {
|
|
291
|
+
await fs.access(targetPath);
|
|
292
|
+
console.log(`[GitRepo] Updating existing shared repo at ${targetPath}`);
|
|
293
|
+
try {
|
|
294
|
+
await runGit(['fetch', 'origin'], targetPath);
|
|
295
|
+
} catch (e) {
|
|
296
|
+
console.warn(`[GitRepo] fetch failed: ${e.message}`);
|
|
297
|
+
}
|
|
298
|
+
await runGit(['checkout', branch], targetPath);
|
|
299
|
+
try {
|
|
300
|
+
await runGit(['pull', 'origin', branch], targetPath);
|
|
301
|
+
} catch {
|
|
302
|
+
// Pull failed (conflict) — reset and retry
|
|
303
|
+
console.warn(`[GitRepo] pull conflict, resetting...`);
|
|
304
|
+
await runGit(['checkout', '.'], targetPath);
|
|
305
|
+
await runGit(['clean', '-fd'], targetPath);
|
|
306
|
+
await runGit(['pull', 'origin', branch], targetPath);
|
|
307
|
+
}
|
|
308
|
+
return targetPath;
|
|
309
|
+
} catch {
|
|
310
|
+
// Target doesn't exist — continue to Case 2/3
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Case 2: Same repo, different branch exists — copy + fetch + checkout
|
|
314
|
+
try {
|
|
315
|
+
await fs.access(repoParentDir);
|
|
316
|
+
const branches = await fs.readdir(repoParentDir, { withFileTypes: true });
|
|
317
|
+
const existingBranch = branches.find(b => b.isDirectory());
|
|
318
|
+
if (existingBranch) {
|
|
319
|
+
const existingPath = path.join(repoParentDir, existingBranch.name);
|
|
320
|
+
console.log(`[GitRepo] Copying from existing branch "${existingBranch.name}" to "${branch}"`);
|
|
321
|
+
await fs.mkdir(targetPath, { recursive: true });
|
|
322
|
+
await fs.cp(existingPath, targetPath, { recursive: true });
|
|
323
|
+
try {
|
|
324
|
+
await runGit(['fetch', 'origin'], targetPath);
|
|
325
|
+
} catch (e) {
|
|
326
|
+
console.warn(`[GitRepo] fetch after copy failed: ${e.message}`);
|
|
327
|
+
}
|
|
328
|
+
await runGit(['checkout', branch], targetPath);
|
|
329
|
+
try {
|
|
330
|
+
await runGit(['pull', 'origin', branch], targetPath);
|
|
331
|
+
} catch {
|
|
332
|
+
await runGit(['checkout', '.'], targetPath);
|
|
333
|
+
await runGit(['clean', '-fd'], targetPath);
|
|
334
|
+
await runGit(['pull', 'origin', branch], targetPath);
|
|
335
|
+
}
|
|
336
|
+
return targetPath;
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
// No existing branches — continue to Case 3
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Case 3: Fresh clone
|
|
343
|
+
console.log(`[GitRepo] Cloning ${repoUrl} branch ${branch} to ${targetPath}`);
|
|
344
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
345
|
+
await runGit(['clone', repoUrl, targetPath]);
|
|
346
|
+
await runGit(['checkout', branch], targetPath);
|
|
347
|
+
return targetPath;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Create a symlink from a project subdirectory to a shared git repo.
|
|
352
|
+
* Removes existing directory or symlink at the target location.
|
|
353
|
+
*/
|
|
354
|
+
async function linkGitRepoToProject(projectDir, subdirName, sharedRepoPath) {
|
|
355
|
+
const linkPath = path.join(projectDir, subdirName);
|
|
356
|
+
|
|
357
|
+
// Remove existing directory or symlink
|
|
358
|
+
try {
|
|
359
|
+
const stat = await fs.lstat(linkPath);
|
|
360
|
+
if (stat.isSymbolicLink()) {
|
|
361
|
+
await fs.unlink(linkPath);
|
|
362
|
+
} else if (stat.isDirectory()) {
|
|
363
|
+
await fs.rm(linkPath, { recursive: true, force: true });
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
// Doesn't exist — fine
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
await fs.symlink(sharedRepoPath, linkPath);
|
|
370
|
+
console.log(`[GitRepo] Created symlink: ${linkPath} → ${sharedRepoPath}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
162
373
|
/**
|
|
163
374
|
* Ensure a skill repo is available for the user. Clone if needed.
|
|
164
375
|
* Returns the local path to the skill directory inside the repo.
|
|
@@ -586,6 +797,20 @@ router.post('/install', async (req, res) => {
|
|
|
586
797
|
}
|
|
587
798
|
}
|
|
588
799
|
|
|
800
|
+
// Install git repo dependencies (clone/update shared repo + create symlink)
|
|
801
|
+
const gitRepoResults = [];
|
|
802
|
+
for (const gitRepo of agent.git_repos || []) {
|
|
803
|
+
if (!gitRepo.name || !gitRepo.repo) continue;
|
|
804
|
+
try {
|
|
805
|
+
const sharedPath = await ensureSharedGitRepo(gitRepo.repo, gitRepo.branch || 'main');
|
|
806
|
+
await linkGitRepoToProject(projectDir, gitRepo.name, sharedPath);
|
|
807
|
+
gitRepoResults.push({ name: gitRepo.name, success: true });
|
|
808
|
+
} catch (err) {
|
|
809
|
+
console.error(`[AgentInstall] Failed to install git repo "${gitRepo.name}":`, err.message);
|
|
810
|
+
gitRepoResults.push({ name: gitRepo.name, success: false, error: err.message });
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
589
814
|
// Mark project as agent in project-config.json
|
|
590
815
|
const config = await loadProjectConfig(userUuid);
|
|
591
816
|
const projectKey = projectDir.replace(/\//g, '-');
|
|
@@ -597,7 +822,10 @@ router.post('/install', async (req, res) => {
|
|
|
597
822
|
installedAt: new Date().toISOString(),
|
|
598
823
|
isAgent: true,
|
|
599
824
|
claudeMdHash,
|
|
600
|
-
installedFiles
|
|
825
|
+
installedFiles,
|
|
826
|
+
gitRepos: (agent.git_repos || []).filter(g => g.name && g.repo).map(g => ({
|
|
827
|
+
name: g.name, repo: g.repo, branch: g.branch || 'main'
|
|
828
|
+
}))
|
|
601
829
|
}
|
|
602
830
|
};
|
|
603
831
|
await saveProjectConfig(config, userUuid);
|
|
@@ -606,7 +834,8 @@ router.post('/install', async (req, res) => {
|
|
|
606
834
|
success: true,
|
|
607
835
|
project: { ...project, agentInfo: config[projectKey].agentInfo },
|
|
608
836
|
skills: skillResults,
|
|
609
|
-
mcps: mcpResults
|
|
837
|
+
mcps: mcpResults,
|
|
838
|
+
gitRepos: gitRepoResults
|
|
610
839
|
});
|
|
611
840
|
} catch (error) {
|
|
612
841
|
console.error('Error installing agent:', error);
|
|
@@ -685,7 +914,10 @@ router.get('/preview', async (req, res) => {
|
|
|
685
914
|
// Scan CLAUDE.md for referenced local files
|
|
686
915
|
const refFiles = await scanClaudeMdRefs(projectPath);
|
|
687
916
|
|
|
688
|
-
|
|
917
|
+
// Scan subdirectories for git repositories
|
|
918
|
+
const gitRepos = await scanGitRepos(projectPath);
|
|
919
|
+
|
|
920
|
+
res.json({ hasClaudeMd, skills, mcps, refFiles, gitRepos });
|
|
689
921
|
} catch (error) {
|
|
690
922
|
console.error('Error getting agent preview:', error);
|
|
691
923
|
res.status(500).json({ error: 'Failed to get preview', details: error.message });
|
|
@@ -737,6 +969,11 @@ router.get('/project-files', async (req, res) => {
|
|
|
737
969
|
|
|
738
970
|
if (entry.isDirectory()) {
|
|
739
971
|
if (AGENT_SKIP_DIRS.has(entry.name)) continue;
|
|
972
|
+
// Skip directories that are git repositories (handled as git_repos dependencies)
|
|
973
|
+
try {
|
|
974
|
+
await fs.access(path.join(fullPath, '.git'));
|
|
975
|
+
continue; // Has .git → skip entire subtree
|
|
976
|
+
} catch {}
|
|
740
977
|
await walk(fullPath, depth + 1);
|
|
741
978
|
} else if (entry.isFile()) {
|
|
742
979
|
if (entry.name === 'CLAUDE.md') continue;
|
|
@@ -1287,6 +1524,49 @@ router.post('/submissions/:id/approve', async (req, res) => {
|
|
|
1287
1524
|
}
|
|
1288
1525
|
}
|
|
1289
1526
|
|
|
1527
|
+
// === C. Handle git repo dependencies ===
|
|
1528
|
+
const yamlGitRepos = agentYamlContent ? parseYamlGitRepos(agentYamlContent) : [];
|
|
1529
|
+
if (yamlGitRepos.length > 0) {
|
|
1530
|
+
for (const gitRepo of yamlGitRepos) {
|
|
1531
|
+
if (!gitRepo.name || !gitRepo.repo) continue;
|
|
1532
|
+
try {
|
|
1533
|
+
// Ensure shared git repo exists
|
|
1534
|
+
const sharedPath = await ensureSharedGitRepo(gitRepo.repo, gitRepo.branch || 'main');
|
|
1535
|
+
console.log(`[AgentApprove] Ensured shared git repo "${gitRepo.name}" at ${sharedPath}`);
|
|
1536
|
+
|
|
1537
|
+
// Replace submitter's original subdirectory with symlink to shared repo
|
|
1538
|
+
if (submitterUuid) {
|
|
1539
|
+
const submitterProjectDir = path.join(getUserPaths(submitterUuid).projectsDir, submission.agent_name);
|
|
1540
|
+
try {
|
|
1541
|
+
await linkGitRepoToProject(submitterProjectDir, gitRepo.name, sharedPath);
|
|
1542
|
+
console.log(`[AgentApprove] Linked git repo "${gitRepo.name}" for submitter`);
|
|
1543
|
+
} catch (e) {
|
|
1544
|
+
console.warn(`[AgentApprove] Could not link git repo "${gitRepo.name}" for submitter:`, e.message);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
} catch (e) {
|
|
1548
|
+
console.error(`[AgentApprove] Failed to setup shared git repo "${gitRepo.name}":`, e.message);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// Update submitter's agentInfo with git repos
|
|
1553
|
+
if (submitterUuid) {
|
|
1554
|
+
try {
|
|
1555
|
+
const submitterConfig = await loadProjectConfig(submitterUuid);
|
|
1556
|
+
const submitterProjectDir = path.join(getUserPaths(submitterUuid).projectsDir, submission.agent_name);
|
|
1557
|
+
const submitterKey = submitterProjectDir.replace(/\//g, '-');
|
|
1558
|
+
if (submitterConfig[submitterKey]?.agentInfo) {
|
|
1559
|
+
submitterConfig[submitterKey].agentInfo.gitRepos = yamlGitRepos.filter(g => g.name && g.repo).map(g => ({
|
|
1560
|
+
name: g.name, repo: g.repo, branch: g.branch || 'main'
|
|
1561
|
+
}));
|
|
1562
|
+
await saveProjectConfig(submitterConfig, submitterUuid);
|
|
1563
|
+
}
|
|
1564
|
+
} catch (e) {
|
|
1565
|
+
console.warn('[AgentApprove] Could not update submitter gitRepos config:', e.message);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1290
1570
|
// Remove temporary migration directories from extractDir before publishing to agent repo
|
|
1291
1571
|
await fs.rm(path.join(extractDir, '_skill_files'), { recursive: true, force: true });
|
|
1292
1572
|
await fs.rm(path.join(extractDir, '_mcp_configs'), { recursive: true, force: true });
|
|
@@ -1390,6 +1670,54 @@ router.post('/submissions/:id/approve', async (req, res) => {
|
|
|
1390
1670
|
entry.agentInfo.installedAt = new Date().toISOString();
|
|
1391
1671
|
entry.agentInfo.claudeMdHash = newClaudeMdHash;
|
|
1392
1672
|
entry.agentInfo.installedFiles = newInstalledFiles;
|
|
1673
|
+
|
|
1674
|
+
// Update git repo dependencies
|
|
1675
|
+
if (publishedAgent) {
|
|
1676
|
+
const newGitRepos = (publishedAgent.git_repos || []).filter(g => g.name && g.repo);
|
|
1677
|
+
const oldGitRepos = entry.agentInfo.gitRepos || [];
|
|
1678
|
+
|
|
1679
|
+
// Build lookup maps
|
|
1680
|
+
const oldByName = new Map(oldGitRepos.map(g => [g.name, g]));
|
|
1681
|
+
const newByName = new Map(newGitRepos.map(g => [g.name, g]));
|
|
1682
|
+
|
|
1683
|
+
// Remove symlinks for deleted git repos
|
|
1684
|
+
for (const old of oldGitRepos) {
|
|
1685
|
+
if (!newByName.has(old.name)) {
|
|
1686
|
+
try {
|
|
1687
|
+
const linkPath = path.join(projectDir, old.name);
|
|
1688
|
+
await fs.unlink(linkPath).catch(() => {});
|
|
1689
|
+
console.log(`[AgentApprove] Removed git repo symlink "${old.name}" for user ${user.uuid}`);
|
|
1690
|
+
} catch {}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// Add/update git repos
|
|
1695
|
+
for (const gitRepo of newGitRepos) {
|
|
1696
|
+
const old = oldByName.get(gitRepo.name);
|
|
1697
|
+
const branchChanged = !old || old.branch !== gitRepo.branch;
|
|
1698
|
+
const repoChanged = !old || old.repo !== gitRepo.repo;
|
|
1699
|
+
|
|
1700
|
+
if (!old || branchChanged || repoChanged) {
|
|
1701
|
+
try {
|
|
1702
|
+
const sharedPath = await ensureSharedGitRepo(gitRepo.repo, gitRepo.branch || 'main');
|
|
1703
|
+
await linkGitRepoToProject(projectDir, gitRepo.name, sharedPath);
|
|
1704
|
+
console.log(`[AgentApprove] Updated git repo "${gitRepo.name}" for user ${user.uuid}`);
|
|
1705
|
+
} catch (e) {
|
|
1706
|
+
console.warn(`[AgentApprove] Failed to update git repo "${gitRepo.name}" for user ${user.uuid}:`, e.message);
|
|
1707
|
+
}
|
|
1708
|
+
} else {
|
|
1709
|
+
// Same repo + branch — just ensure shared repo is up to date
|
|
1710
|
+
try {
|
|
1711
|
+
await ensureSharedGitRepo(gitRepo.repo, gitRepo.branch || 'main');
|
|
1712
|
+
} catch {}
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
entry.agentInfo.gitRepos = newGitRepos.map(g => ({
|
|
1717
|
+
name: g.name, repo: g.repo, branch: g.branch || 'main'
|
|
1718
|
+
}));
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1393
1721
|
userUpdated = true;
|
|
1394
1722
|
}
|
|
1395
1723
|
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BackgroundTaskPool — 后台任务进程池
|
|
3
|
+
*
|
|
4
|
+
* 管理 child_process.spawn 子进程,提供 per-user 和全局并发限制、
|
|
5
|
+
* 超时处理、输出截断,以及 EventEmitter 'task-complete' 事件。
|
|
6
|
+
*
|
|
7
|
+
* PendingResultQueue — 待投递结果队列
|
|
8
|
+
*
|
|
9
|
+
* 保存已完成但尚未投递给 agent 的任务结果,
|
|
10
|
+
* 按 userUuid:sessionId 分组,支持逐条出队。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn } from 'child_process';
|
|
14
|
+
import { EventEmitter } from 'events';
|
|
15
|
+
import crypto from 'crypto';
|
|
16
|
+
|
|
17
|
+
// ─── 配置 ───
|
|
18
|
+
|
|
19
|
+
const PER_USER_LIMIT = parseInt(process.env.BG_TASK_PER_USER_LIMIT, 10) || 3;
|
|
20
|
+
const GLOBAL_LIMIT = parseInt(process.env.BG_TASK_GLOBAL_LIMIT, 10) || 20;
|
|
21
|
+
const DEFAULT_TIMEOUT = parseInt(process.env.BG_TASK_DEFAULT_TIMEOUT, 10) || 600_000; // 10 min
|
|
22
|
+
const MAX_TIMEOUT = parseInt(process.env.BG_TASK_MAX_TIMEOUT, 10) || 1_800_000; // 30 min
|
|
23
|
+
const MAX_OUTPUT_BYTES = parseInt(process.env.BG_TASK_MAX_OUTPUT, 10) || 102_400; // 100 KB
|
|
24
|
+
|
|
25
|
+
// 已完成任务保留时长(30 min),之后自动清理引用
|
|
26
|
+
const COMPLETED_TTL_MS = 30 * 60 * 1000;
|
|
27
|
+
// 清理扫描间隔(5 min)
|
|
28
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
// ─── BackgroundTaskPool ───
|
|
31
|
+
|
|
32
|
+
class BackgroundTaskPool extends EventEmitter {
|
|
33
|
+
constructor() {
|
|
34
|
+
super();
|
|
35
|
+
/** @type {Map<string, Task>} 所有任务(running + completed 未清理) */
|
|
36
|
+
this.tasks = new Map();
|
|
37
|
+
/** @type {Map<string, number>} 每用户当前 running 计数 */
|
|
38
|
+
this.userTaskCount = new Map();
|
|
39
|
+
/** 全局 running 计数 */
|
|
40
|
+
this.runningCount = 0;
|
|
41
|
+
|
|
42
|
+
// 定期清理已完成任务
|
|
43
|
+
this._cleanupTimer = setInterval(() => this._cleanupCompleted(), CLEANUP_INTERVAL_MS);
|
|
44
|
+
if (this._cleanupTimer.unref) this._cleanupTimer.unref();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 提交一个后台任务
|
|
49
|
+
* @returns {Task} 返回任务对象(status='running')
|
|
50
|
+
* @throws {Error} 超出并发限制
|
|
51
|
+
*/
|
|
52
|
+
submit({ userUuid, sessionId, cwd, command, timeout, label }) {
|
|
53
|
+
// 并发检查
|
|
54
|
+
const userCount = this.userTaskCount.get(userUuid) || 0;
|
|
55
|
+
if (userCount >= PER_USER_LIMIT) {
|
|
56
|
+
throw new Error(`每用户最多同时运行 ${PER_USER_LIMIT} 个后台任务(当前 ${userCount} 个)`);
|
|
57
|
+
}
|
|
58
|
+
if (this.runningCount >= GLOBAL_LIMIT) {
|
|
59
|
+
throw new Error(`系统后台任务已满(最多 ${GLOBAL_LIMIT} 个)`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 规范化 timeout
|
|
63
|
+
const timeoutMs = Math.min(Math.max(timeout || DEFAULT_TIMEOUT, 1000), MAX_TIMEOUT);
|
|
64
|
+
|
|
65
|
+
const id = this._genId();
|
|
66
|
+
const task = {
|
|
67
|
+
id,
|
|
68
|
+
userUuid,
|
|
69
|
+
sessionId,
|
|
70
|
+
cwd,
|
|
71
|
+
command,
|
|
72
|
+
label: label || command.slice(0, 80),
|
|
73
|
+
timeout: timeoutMs,
|
|
74
|
+
status: 'running',
|
|
75
|
+
startTime: Date.now(),
|
|
76
|
+
childProcess: null,
|
|
77
|
+
stdout: '',
|
|
78
|
+
stderr: '',
|
|
79
|
+
exitCode: null,
|
|
80
|
+
signal: null,
|
|
81
|
+
truncated: false,
|
|
82
|
+
_stdoutTruncated: false,
|
|
83
|
+
_stderrTruncated: false,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// 启动子进程
|
|
87
|
+
const child = spawn('sh', ['-c', command], {
|
|
88
|
+
cwd,
|
|
89
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
90
|
+
env: { ...process.env },
|
|
91
|
+
});
|
|
92
|
+
task.childProcess = child;
|
|
93
|
+
|
|
94
|
+
// 收集 stdout
|
|
95
|
+
child.stdout.on('data', (chunk) => {
|
|
96
|
+
if (task._stdoutTruncated) return;
|
|
97
|
+
const str = chunk.toString();
|
|
98
|
+
if (task.stdout.length + str.length > MAX_OUTPUT_BYTES) {
|
|
99
|
+
task.stdout += str.slice(0, MAX_OUTPUT_BYTES - task.stdout.length);
|
|
100
|
+
task._stdoutTruncated = true;
|
|
101
|
+
task.truncated = true;
|
|
102
|
+
} else {
|
|
103
|
+
task.stdout += str;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// 收集 stderr
|
|
108
|
+
child.stderr.on('data', (chunk) => {
|
|
109
|
+
if (task._stderrTruncated) return;
|
|
110
|
+
const str = chunk.toString();
|
|
111
|
+
if (task.stderr.length + str.length > MAX_OUTPUT_BYTES) {
|
|
112
|
+
task.stderr += str.slice(0, MAX_OUTPUT_BYTES - task.stderr.length);
|
|
113
|
+
task._stderrTruncated = true;
|
|
114
|
+
task.truncated = true;
|
|
115
|
+
} else {
|
|
116
|
+
task.stderr += str;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// 进程结束
|
|
121
|
+
child.on('close', (code, sig) => {
|
|
122
|
+
if (task.status !== 'running') return; // 已被 timeout/cancel 处理
|
|
123
|
+
task.status = 'completed';
|
|
124
|
+
task.exitCode = code;
|
|
125
|
+
task.signal = sig;
|
|
126
|
+
this._onFinished(task);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
child.on('error', (err) => {
|
|
130
|
+
if (task.status !== 'running') return;
|
|
131
|
+
task.status = 'failed';
|
|
132
|
+
task.stderr += `\n[spawn error] ${err.message}`;
|
|
133
|
+
this._onFinished(task);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// 超时定时器
|
|
137
|
+
task._timeoutTimer = setTimeout(() => {
|
|
138
|
+
if (task.status !== 'running') return;
|
|
139
|
+
task.status = 'timeout';
|
|
140
|
+
// 先 SIGTERM
|
|
141
|
+
try { child.kill('SIGTERM'); } catch (_) { /* ignore */ }
|
|
142
|
+
// 5s 后 SIGKILL
|
|
143
|
+
task._killTimer = setTimeout(() => {
|
|
144
|
+
try { child.kill('SIGKILL'); } catch (_) { /* ignore */ }
|
|
145
|
+
}, 5000);
|
|
146
|
+
this._onFinished(task);
|
|
147
|
+
}, timeoutMs);
|
|
148
|
+
|
|
149
|
+
// 注册
|
|
150
|
+
this.tasks.set(id, task);
|
|
151
|
+
this.userTaskCount.set(userUuid, userCount + 1);
|
|
152
|
+
this.runningCount++;
|
|
153
|
+
|
|
154
|
+
console.log(`[BgTask] Task ${id} started for user ${userUuid}, session ${sessionId}: ${task.label}`);
|
|
155
|
+
return task;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 取消一个任务
|
|
160
|
+
* @returns {boolean} 是否成功取消
|
|
161
|
+
*/
|
|
162
|
+
cancel(taskId, userUuid) {
|
|
163
|
+
const task = this.tasks.get(taskId);
|
|
164
|
+
if (!task) return false;
|
|
165
|
+
if (task.userUuid !== userUuid) return false; // 不允许跨用户取消
|
|
166
|
+
if (task.status !== 'running') return false;
|
|
167
|
+
|
|
168
|
+
task.status = 'killed';
|
|
169
|
+
try { task.childProcess.kill('SIGTERM'); } catch (_) { /* ignore */ }
|
|
170
|
+
setTimeout(() => {
|
|
171
|
+
try { task.childProcess.kill('SIGKILL'); } catch (_) { /* ignore */ }
|
|
172
|
+
}, 3000);
|
|
173
|
+
|
|
174
|
+
this._onFinished(task);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 获取单个任务 */
|
|
179
|
+
getTask(taskId) {
|
|
180
|
+
return this.tasks.get(taskId) || null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 获取用户所有任务 */
|
|
184
|
+
getUserTasks(userUuid) {
|
|
185
|
+
return [...this.tasks.values()].filter(t => t.userUuid === userUuid);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 获取指定 session 的任务 */
|
|
189
|
+
getSessionTasks(userUuid, sessionId) {
|
|
190
|
+
return [...this.tasks.values()].filter(t => t.userUuid === userUuid && t.sessionId === sessionId);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 统计信息 */
|
|
194
|
+
getStats() {
|
|
195
|
+
return {
|
|
196
|
+
running: this.runningCount,
|
|
197
|
+
total: this.tasks.size,
|
|
198
|
+
perUser: Object.fromEntries(this.userTaskCount),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── 内部方法 ───
|
|
203
|
+
|
|
204
|
+
_onFinished(task) {
|
|
205
|
+
// 清理定时器
|
|
206
|
+
if (task._timeoutTimer) { clearTimeout(task._timeoutTimer); task._timeoutTimer = null; }
|
|
207
|
+
if (task._killTimer) { clearTimeout(task._killTimer); task._killTimer = null; }
|
|
208
|
+
|
|
209
|
+
// 更新计数
|
|
210
|
+
const prev = this.userTaskCount.get(task.userUuid) || 1;
|
|
211
|
+
if (prev <= 1) {
|
|
212
|
+
this.userTaskCount.delete(task.userUuid);
|
|
213
|
+
} else {
|
|
214
|
+
this.userTaskCount.set(task.userUuid, prev - 1);
|
|
215
|
+
}
|
|
216
|
+
this.runningCount = Math.max(0, this.runningCount - 1);
|
|
217
|
+
|
|
218
|
+
// 释放进程引用
|
|
219
|
+
task.childProcess = null;
|
|
220
|
+
|
|
221
|
+
// 记录完成时间
|
|
222
|
+
task.endTime = Date.now();
|
|
223
|
+
|
|
224
|
+
console.log(`[BgTask] Task ${task.id} finished: status=${task.status}, exitCode=${task.exitCode}, duration=${task.endTime - task.startTime}ms`);
|
|
225
|
+
|
|
226
|
+
// 发出事件
|
|
227
|
+
this.emit('task-complete', task);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
_cleanupCompleted() {
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
for (const [id, task] of this.tasks) {
|
|
233
|
+
if (task.status !== 'running' && task.endTime && now - task.endTime > COMPLETED_TTL_MS) {
|
|
234
|
+
this.tasks.delete(id);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
_genId() {
|
|
240
|
+
return 'bg_' + crypto.randomBytes(6).toString('hex');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ─── PendingResultQueue ───
|
|
245
|
+
|
|
246
|
+
/** @type {Map<string, object[]>} key = "userUuid:sessionId" */
|
|
247
|
+
const pendingResults = new Map();
|
|
248
|
+
|
|
249
|
+
function _key(userUuid, sessionId) {
|
|
250
|
+
return `${userUuid}:${sessionId}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 将已完成的任务结果加入待投递队列
|
|
255
|
+
*/
|
|
256
|
+
export function enqueueResult(userUuid, sessionId, taskResult) {
|
|
257
|
+
const k = _key(userUuid, sessionId);
|
|
258
|
+
if (!pendingResults.has(k)) pendingResults.set(k, []);
|
|
259
|
+
pendingResults.get(k).push({
|
|
260
|
+
id: taskResult.id,
|
|
261
|
+
command: taskResult.command,
|
|
262
|
+
label: taskResult.label,
|
|
263
|
+
status: taskResult.status,
|
|
264
|
+
exitCode: taskResult.exitCode,
|
|
265
|
+
signal: taskResult.signal,
|
|
266
|
+
stdout: taskResult.stdout,
|
|
267
|
+
stderr: taskResult.stderr,
|
|
268
|
+
truncated: taskResult.truncated,
|
|
269
|
+
startTime: taskResult.startTime,
|
|
270
|
+
endTime: taskResult.endTime,
|
|
271
|
+
cwd: taskResult.cwd,
|
|
272
|
+
sessionId: taskResult.sessionId,
|
|
273
|
+
userUuid: taskResult.userUuid,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 从队列取出一条结果(FIFO)
|
|
279
|
+
*/
|
|
280
|
+
export function dequeueResult(userUuid, sessionId) {
|
|
281
|
+
const k = _key(userUuid, sessionId);
|
|
282
|
+
const q = pendingResults.get(k);
|
|
283
|
+
if (!q || q.length === 0) return null;
|
|
284
|
+
const item = q.shift();
|
|
285
|
+
if (q.length === 0) pendingResults.delete(k);
|
|
286
|
+
return item;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 检查是否有待投递结果
|
|
291
|
+
*/
|
|
292
|
+
export function hasResults(userUuid, sessionId) {
|
|
293
|
+
const k = _key(userUuid, sessionId);
|
|
294
|
+
const q = pendingResults.get(k);
|
|
295
|
+
return q && q.length > 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 获取用户所有 session 的待投递结果
|
|
300
|
+
* @returns {Array<{sessionId: string, results: object[]}>}
|
|
301
|
+
*/
|
|
302
|
+
export function getAllPendingForUser(userUuid) {
|
|
303
|
+
const out = [];
|
|
304
|
+
for (const [k, results] of pendingResults) {
|
|
305
|
+
if (k.startsWith(userUuid + ':')) {
|
|
306
|
+
const sessionId = k.slice(userUuid.length + 1);
|
|
307
|
+
out.push({ sessionId, results: [...results] });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─── 单例导出 ───
|
|
314
|
+
|
|
315
|
+
export const backgroundTaskPool = new BackgroundTaskPool();
|
|
316
|
+
export default backgroundTaskPool;
|