@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.
@@ -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
- res.json({ hasClaudeMd, skills, mcps, refFiles });
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;