commit
35da03c25f
331 changed files with 37918 additions and 8273 deletions
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
|
|
@ -5,7 +5,11 @@ on:
|
|||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'agent-teams-controller/**'
|
||||
- 'mcp-server/**'
|
||||
- 'test/**'
|
||||
- '.github/workflows/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'tsconfig*.json'
|
||||
|
|
@ -17,7 +21,11 @@ on:
|
|||
branches: [main]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'agent-teams-controller/**'
|
||||
- 'mcp-server/**'
|
||||
- 'test/**'
|
||||
- '.github/workflows/**'
|
||||
- 'pnpm-workspace.yaml'
|
||||
- 'package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'tsconfig*.json'
|
||||
|
|
@ -54,14 +62,8 @@ jobs:
|
|||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
- name: Validate workspace truth gate
|
||||
run: pnpm check
|
||||
|
||||
test:
|
||||
strategy:
|
||||
|
|
@ -87,4 +89,4 @@ jobs:
|
|||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
run: pnpm test:workspace
|
||||
|
|
|
|||
|
|
@ -15,22 +15,24 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>100% free, open source. No API keys. No configuration. Runs entirely locally.</sub>
|
||||
<sub>100% free, open source. No API keys. No configuration. Runs entirely locally. Not just coding agents.</sub>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
## What is this
|
||||
|
||||
A new approach to task management with AI agents.
|
||||
A new approach to task management with AI agent teams.
|
||||
|
||||
- **Assemble your team** — create agent teams with different roles that work autonomously in parallel
|
||||
- **Agents talk to each other** — they communicate, create and manage their own tasks, and leave comments
|
||||
- **Agents talk to each other** — they communicate, create and manage their own tasks, review, leave comments
|
||||
- **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams
|
||||
- **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own
|
||||
- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
|
||||
- **Full tool visibility** — inspect exactly which tools an agent used to complete each task
|
||||
- **Live process section** — see which agents are running processes and open URLs directly in the browser
|
||||
- **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work
|
||||
- **Solo mode** — one-member team: a single agent that creates its own tasks and shows live progress. Saves tokens; can expand to a full team anytime
|
||||
|
||||
<details>
|
||||
<summary><strong>More features</strong></summary>
|
||||
|
|
@ -38,7 +40,6 @@ A new approach to task management with AI agents.
|
|||
<br />
|
||||
|
||||
- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses
|
||||
- **Solo mode** — one-member team: a single agent that creates its own tasks and shows live progress. Saves tokens; can expand to a full team anytime
|
||||
- **Smart task-to-log/changes matching** — automatically links Claude session logs/changes to specific tasks
|
||||
- **Advanced context monitoring system** — comprehensive breakdown of what consumes tokens at every step: user messages, Claude.md instructions, tool outputs, thinking text, and team coordination. Token usage, percentage of context window, and session cost are displayed for each category, with detailed views by category or size.
|
||||
- **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place
|
||||
|
|
|
|||
1
agent-teams-controller/.gitignore
vendored
Normal file
1
agent-teams-controller/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
dist/
|
||||
19
agent-teams-controller/package.json
Normal file
19
agent-teams-controller/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "agent-teams-controller",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Controller package for Claude agent teams operations",
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node ./scripts/build.mjs",
|
||||
"test": "vitest run --config vitest.config.js",
|
||||
"test:watch": "vitest --config vitest.config.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
31
agent-teams-controller/scripts/build.mjs
Normal file
31
agent-teams-controller/scripts/build.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { copyFile, mkdir, readdir, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageRoot = path.resolve(__dirname, '..');
|
||||
const srcDir = path.join(packageRoot, 'src');
|
||||
const distDir = path.join(packageRoot, 'dist');
|
||||
|
||||
async function copyRecursive(sourceDir, targetDir) {
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
const entries = await readdir(sourceDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(sourceDir, entry.name);
|
||||
const targetPath = path.join(targetDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyRecursive(sourcePath, targetPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile()) {
|
||||
await copyFile(sourcePath, targetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await rm(distDir, { recursive: true, force: true });
|
||||
await mkdir(distDir, { recursive: true });
|
||||
await copyRecursive(srcDir, distDir);
|
||||
44
agent-teams-controller/src/controller.js
Normal file
44
agent-teams-controller/src/controller.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
const { createControllerContext } = require('./internal/context.js');
|
||||
const tasks = require('./internal/tasks.js');
|
||||
const kanban = require('./internal/kanban.js');
|
||||
const review = require('./internal/review.js');
|
||||
const messages = require('./internal/messages.js');
|
||||
const processes = require('./internal/processes.js');
|
||||
const maintenance = require('./internal/maintenance.js');
|
||||
const crossTeam = require('./internal/crossTeam.js');
|
||||
|
||||
function bindModule(context, moduleApi) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(moduleApi).map(([name, fn]) => [
|
||||
name,
|
||||
(...args) => fn(context, ...args),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function createController(options) {
|
||||
const context = createControllerContext(options);
|
||||
|
||||
return {
|
||||
context,
|
||||
tasks: bindModule(context, tasks),
|
||||
kanban: bindModule(context, kanban),
|
||||
review: bindModule(context, review),
|
||||
messages: bindModule(context, messages),
|
||||
processes: bindModule(context, processes),
|
||||
maintenance: bindModule(context, maintenance),
|
||||
crossTeam: bindModule(context, crossTeam),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createController,
|
||||
createControllerContext,
|
||||
tasks,
|
||||
kanban,
|
||||
review,
|
||||
messages,
|
||||
processes,
|
||||
maintenance,
|
||||
crossTeam,
|
||||
};
|
||||
5
agent-teams-controller/src/index.js
Normal file
5
agent-teams-controller/src/index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const controller = require('./controller.js');
|
||||
|
||||
module.exports = {
|
||||
...controller,
|
||||
};
|
||||
18
agent-teams-controller/src/internal/agentBlocks.js
Normal file
18
agent-teams-controller/src/internal/agentBlocks.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
const AGENT_BLOCK_TAG = 'info_for_agent';
|
||||
const AGENT_BLOCK_OPEN = `<${AGENT_BLOCK_TAG}>`;
|
||||
const AGENT_BLOCK_CLOSE = `</${AGENT_BLOCK_TAG}>`;
|
||||
|
||||
function wrapAgentBlock(text) {
|
||||
const trimmed = typeof text === 'string' ? text.trim() : '';
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AGENT_BLOCK_TAG,
|
||||
AGENT_BLOCK_OPEN,
|
||||
AGENT_BLOCK_CLOSE,
|
||||
wrapAgentBlock,
|
||||
};
|
||||
31
agent-teams-controller/src/internal/capture.js
Normal file
31
agent-teams-controller/src/internal/capture.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
function captureStreamOutput(stream, fn) {
|
||||
let output = '';
|
||||
const originalWrite = stream.write.bind(stream);
|
||||
|
||||
stream.write = ((chunk, encoding, callback) => {
|
||||
output += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(encoding || 'utf8');
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
try {
|
||||
const result = fn();
|
||||
if (result && typeof result.then === 'function') {
|
||||
return result.finally(() => {
|
||||
stream.write = originalWrite;
|
||||
}).then((value) => ({ value, output }));
|
||||
}
|
||||
|
||||
stream.write = originalWrite;
|
||||
return { value: result, output };
|
||||
} catch (error) {
|
||||
stream.write = originalWrite;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
captureStreamOutput,
|
||||
};
|
||||
59
agent-teams-controller/src/internal/cascadeGuard.js
Normal file
59
agent-teams-controller/src/internal/cascadeGuard.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
const MAX_PER_MINUTE = 10;
|
||||
const PAIR_COOLDOWN_MS = 3000;
|
||||
const MAX_CHAIN_DEPTH = 5;
|
||||
const WINDOW_MS = 60000;
|
||||
|
||||
const teamCounters = new Map();
|
||||
const pairTimestamps = new Map();
|
||||
|
||||
function cleanup(now) {
|
||||
for (const [team, timestamps] of teamCounters) {
|
||||
const fresh = timestamps.filter((ts) => ts > now - WINDOW_MS);
|
||||
if (fresh.length === 0) {
|
||||
teamCounters.delete(team);
|
||||
} else {
|
||||
teamCounters.set(team, fresh);
|
||||
}
|
||||
}
|
||||
for (const [key, ts] of pairTimestamps) {
|
||||
if (now - ts > WINDOW_MS) {
|
||||
pairTimestamps.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function check(fromTeam, toTeam, chainDepth) {
|
||||
if (chainDepth >= MAX_CHAIN_DEPTH) {
|
||||
throw new Error(`Cross-team chain depth limit exceeded (max ${MAX_CHAIN_DEPTH})`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
cleanup(now);
|
||||
|
||||
const counts = teamCounters.get(fromTeam) || [];
|
||||
const recentCount = counts.filter((ts) => ts > now - WINDOW_MS).length;
|
||||
if (recentCount >= MAX_PER_MINUTE) {
|
||||
throw new Error(`Cross-team rate limit exceeded for ${fromTeam} (max ${MAX_PER_MINUTE}/min)`);
|
||||
}
|
||||
|
||||
const pairKey = `${fromTeam}\u2192${toTeam}`;
|
||||
const lastPairTs = pairTimestamps.get(pairKey);
|
||||
if (lastPairTs !== undefined && now - lastPairTs < PAIR_COOLDOWN_MS) {
|
||||
throw new Error(`Cross-team pair cooldown active: ${fromTeam} \u2192 ${toTeam}`);
|
||||
}
|
||||
}
|
||||
|
||||
function record(fromTeam, toTeam) {
|
||||
const now = Date.now();
|
||||
const counts = teamCounters.get(fromTeam) || [];
|
||||
counts.push(now);
|
||||
teamCounters.set(fromTeam, counts);
|
||||
pairTimestamps.set(`${fromTeam}\u2192${toTeam}`, now);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
teamCounters.clear();
|
||||
pairTimestamps.clear();
|
||||
}
|
||||
|
||||
module.exports = { check, record, reset };
|
||||
24
agent-teams-controller/src/internal/context.js
Normal file
24
agent-teams-controller/src/internal/context.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||
|
||||
function createControllerContext(options = {}) {
|
||||
const teamName = String(options.teamName || '').trim();
|
||||
if (!teamName) {
|
||||
throw new Error('Missing teamName');
|
||||
}
|
||||
|
||||
const flags = {};
|
||||
if (typeof options.claudeDir === 'string' && options.claudeDir.trim()) {
|
||||
flags['claude-dir'] = options.claudeDir.trim();
|
||||
}
|
||||
|
||||
const paths = runtimeHelpers.getPaths(flags, teamName);
|
||||
return {
|
||||
teamName,
|
||||
claudeDir: paths.claudeDir,
|
||||
paths,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createControllerContext,
|
||||
};
|
||||
301
agent-teams-controller/src/internal/crossTeam.js
Normal file
301
agent-teams-controller/src/internal/crossTeam.js
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { createControllerContext } = require('./context.js');
|
||||
const { withFileLockSync } = require('./fileLock.js');
|
||||
const cascadeGuard = require('./cascadeGuard.js');
|
||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||
const { formatCrossTeamText, CROSS_TEAM_SOURCE } = require('./crossTeamProtocol.js');
|
||||
|
||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
const CROSS_TEAM_DEDUPE_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
function readJson(filePath, fallbackValue) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return fallbackValue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
|
||||
fs.renameSync(tempPath, filePath);
|
||||
}
|
||||
|
||||
function normalizeMetaMembers(rawMembers) {
|
||||
if (!Array.isArray(rawMembers)) return [];
|
||||
const deduped = new Map();
|
||||
for (const m of rawMembers) {
|
||||
if (!m || typeof m !== 'object') continue;
|
||||
const name = typeof m.name === 'string' ? m.name.trim() : '';
|
||||
if (!name) continue;
|
||||
deduped.set(name, {
|
||||
name,
|
||||
agentType: typeof m.agentType === 'string' ? m.agentType.trim() || undefined : undefined,
|
||||
role: typeof m.role === 'string' ? m.role.trim() || undefined : undefined,
|
||||
});
|
||||
}
|
||||
return Array.from(deduped.values());
|
||||
}
|
||||
|
||||
function resolveTargetLead(paths, config) {
|
||||
// 1. config.members — agentType check
|
||||
if (config && config.members && config.members.length) {
|
||||
const lead = config.members.find((m) => m && m.agentType === 'team-lead');
|
||||
if (lead && lead.name) return String(lead.name).trim();
|
||||
|
||||
// 2. config.members — name check
|
||||
const namedLead = config.members.find((m) => m && m.name === 'team-lead');
|
||||
if (namedLead && namedLead.name) return String(namedLead.name).trim();
|
||||
}
|
||||
|
||||
// 3. members.meta.json — WITH normalization (trim + dedup)
|
||||
const metaPath = path.join(paths.teamDir, 'members.meta.json');
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
||||
const members = normalizeMetaMembers(raw && raw.members);
|
||||
if (members.length > 0) {
|
||||
const metaLead = members.find(
|
||||
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
|
||||
);
|
||||
if (metaLead && metaLead.name) return metaLead.name;
|
||||
return members[0].name;
|
||||
}
|
||||
} catch {
|
||||
/* ENOENT or parse error */
|
||||
}
|
||||
|
||||
// 4. role-based (legacy compat)
|
||||
if (config && config.members && config.members.length) {
|
||||
const roleLead = config.members.find(
|
||||
(m) => m && m.role && String(m.role).toLowerCase().includes('lead')
|
||||
);
|
||||
if (roleLead && roleLead.name) return String(roleLead.name).trim();
|
||||
// 5. First member
|
||||
if (config.members[0] && config.members[0].name) return String(config.members[0].name).trim();
|
||||
}
|
||||
|
||||
return 'team-lead';
|
||||
}
|
||||
|
||||
function createTargetContext(sourceContext, toTeam) {
|
||||
return createControllerContext({
|
||||
teamName: toTeam,
|
||||
claudeDir: sourceContext.claudeDir,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeForDedupe(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary) {
|
||||
return [
|
||||
normalizeForDedupe(fromTeam),
|
||||
normalizeForDedupe(fromMember),
|
||||
normalizeForDedupe(toTeam),
|
||||
normalizeForDedupe(summary),
|
||||
normalizeForDedupe(text),
|
||||
].join('||');
|
||||
}
|
||||
|
||||
function getCrossTeamMessageDedupeKey(message) {
|
||||
if (!message || typeof message !== 'object') return '';
|
||||
return buildCrossTeamDedupeKey(
|
||||
message.fromTeam,
|
||||
message.fromMember,
|
||||
message.toTeam,
|
||||
message.text,
|
||||
message.summary
|
||||
);
|
||||
}
|
||||
|
||||
function findRecentDuplicate(outboxList, dedupeKey) {
|
||||
if (!Array.isArray(outboxList) || !dedupeKey) return null;
|
||||
const cutoff = Date.now() - CROSS_TEAM_DEDUPE_WINDOW_MS;
|
||||
for (let i = outboxList.length - 1; i >= 0; i -= 1) {
|
||||
const entry = outboxList[i];
|
||||
const ts = Date.parse(entry && entry.timestamp ? entry.timestamp : '');
|
||||
if (!Number.isFinite(ts) || ts < cutoff) {
|
||||
break;
|
||||
}
|
||||
if (getCrossTeamMessageDedupeKey(entry) === dedupeKey) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sendCrossTeamMessage(context, flags) {
|
||||
const fromTeam = context.teamName;
|
||||
const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : '';
|
||||
const fromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : 'team-lead';
|
||||
const replyToConversationId =
|
||||
typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : '';
|
||||
const conversationId =
|
||||
typeof flags.conversationId === 'string' && flags.conversationId.trim()
|
||||
? flags.conversationId.trim()
|
||||
: replyToConversationId || '';
|
||||
const text = typeof flags.text === 'string' ? flags.text : '';
|
||||
const summary = typeof flags.summary === 'string' ? flags.summary.trim() : undefined;
|
||||
const chainDepth = typeof flags.chainDepth === 'number' ? flags.chainDepth : 0;
|
||||
|
||||
// Validate
|
||||
if (!TEAM_NAME_PATTERN.test(fromTeam)) {
|
||||
throw new Error(`Invalid fromTeam: ${fromTeam}`);
|
||||
}
|
||||
if (!TEAM_NAME_PATTERN.test(toTeam)) {
|
||||
throw new Error(`Invalid toTeam: ${toTeam}`);
|
||||
}
|
||||
if (fromTeam === toTeam) {
|
||||
throw new Error('Cannot send cross-team message to the same team');
|
||||
}
|
||||
if (!text || text.trim().length === 0) {
|
||||
throw new Error('Message text is required');
|
||||
}
|
||||
|
||||
// Target context + config
|
||||
const targetContext = createTargetContext(context, toTeam);
|
||||
const targetConfig = runtimeHelpers.readTeamConfig(targetContext.paths);
|
||||
if (!targetConfig || targetConfig.deletedAt) {
|
||||
throw new Error(`Target team not found: ${toTeam}`);
|
||||
}
|
||||
|
||||
// Resolve lead
|
||||
const leadName = resolveTargetLead(targetContext.paths, targetConfig);
|
||||
|
||||
// Format
|
||||
const from = `${fromTeam}.${fromMember}`;
|
||||
const resolvedConversationId =
|
||||
conversationId || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`);
|
||||
const formattedText = formatCrossTeamText(from, chainDepth, text, {
|
||||
conversationId: resolvedConversationId,
|
||||
replyToConversationId: replyToConversationId || undefined,
|
||||
});
|
||||
const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||
const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary);
|
||||
|
||||
const inboxPath = path.join(targetContext.paths.teamDir, 'inboxes', `${leadName}.json`);
|
||||
const outboxPath = path.join(context.paths.teamDir, 'sent-cross-team.json');
|
||||
let duplicate = null;
|
||||
withFileLockSync(outboxPath, () => {
|
||||
const outbox = readJson(outboxPath, []);
|
||||
const outList = Array.isArray(outbox) ? outbox : [];
|
||||
duplicate = findRecentDuplicate(outList, dedupeKey);
|
||||
if (duplicate) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cascade check only for real new deliveries.
|
||||
cascadeGuard.check(fromTeam, toTeam, chainDepth);
|
||||
cascadeGuard.record(fromTeam, toTeam);
|
||||
|
||||
// Cross-process safe inbox write
|
||||
withFileLockSync(inboxPath, () => {
|
||||
fs.mkdirSync(path.dirname(inboxPath), { recursive: true });
|
||||
const current = readJson(inboxPath, []);
|
||||
const list = Array.isArray(current) ? current : [];
|
||||
list.push({
|
||||
from,
|
||||
to: leadName,
|
||||
text: formattedText,
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false,
|
||||
summary: summary || `Cross-team message from ${fromTeam}`,
|
||||
messageId,
|
||||
source: CROSS_TEAM_SOURCE,
|
||||
conversationId: resolvedConversationId,
|
||||
replyToConversationId: replyToConversationId || undefined,
|
||||
});
|
||||
writeJson(inboxPath, list);
|
||||
});
|
||||
|
||||
// Verify while still inside dedupe lock so duplicate callers
|
||||
// cannot append the same request to outbox concurrently.
|
||||
const inbox = readJson(inboxPath, []);
|
||||
if (!inbox.some((m) => m.messageId === messageId)) {
|
||||
throw new Error('Cross-team inbox write verification failed');
|
||||
}
|
||||
|
||||
outList.push({
|
||||
messageId,
|
||||
fromTeam,
|
||||
fromMember,
|
||||
toTeam,
|
||||
conversationId: resolvedConversationId,
|
||||
replyToConversationId: replyToConversationId || undefined,
|
||||
text,
|
||||
summary,
|
||||
chainDepth,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
writeJson(outboxPath, outList);
|
||||
});
|
||||
|
||||
if (duplicate) {
|
||||
return {
|
||||
messageId: duplicate.messageId,
|
||||
deliveredToInbox: true,
|
||||
deduplicated: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { messageId, deliveredToInbox: true };
|
||||
}
|
||||
|
||||
function listCrossTeamTargets(context, flags) {
|
||||
const excludeTeam =
|
||||
typeof flags === 'object' && flags && typeof flags.excludeTeam === 'string'
|
||||
? flags.excludeTeam
|
||||
: context.teamName;
|
||||
|
||||
const teamsDir = path.dirname(context.paths.teamDir);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(teamsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targets = [];
|
||||
for (const entry of entries) {
|
||||
if (entry === excludeTeam) continue;
|
||||
if (!TEAM_NAME_PATTERN.test(entry)) continue;
|
||||
|
||||
const entryTeamDir = path.join(teamsDir, entry);
|
||||
const configPath = path.join(entryTeamDir, 'config.json');
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!config || config.deletedAt) continue;
|
||||
|
||||
targets.push({
|
||||
teamName: entry,
|
||||
displayName: config.name || entry,
|
||||
description: config.description || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
function getCrossTeamOutbox(context) {
|
||||
const outboxPath = path.join(context.paths.teamDir, 'sent-cross-team.json');
|
||||
return readJson(outboxPath, []);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendCrossTeamMessage,
|
||||
listCrossTeamTargets,
|
||||
getCrossTeamOutbox,
|
||||
};
|
||||
48
agent-teams-controller/src/internal/crossTeamProtocol.js
Normal file
48
agent-teams-controller/src/internal/crossTeamProtocol.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Cross-team message protocol constants.
|
||||
// Mirror of src/shared/constants/crossTeam.ts — keep in sync.
|
||||
|
||||
const CROSS_TEAM_TAG_NAME = 'cross-team';
|
||||
const CROSS_TEAM_ATTR_FROM = 'from';
|
||||
const CROSS_TEAM_ATTR_DEPTH = 'depth';
|
||||
const CROSS_TEAM_ATTR_CONVERSATION_ID = 'conversationId';
|
||||
const CROSS_TEAM_ATTR_REPLY_TO_CONVERSATION_ID = 'replyToConversationId';
|
||||
const CROSS_TEAM_SOURCE = 'cross_team';
|
||||
const CROSS_TEAM_SENT_SOURCE = 'cross_team_sent';
|
||||
|
||||
function escapeCrossTeamAttribute(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function formatCrossTeamPrefix(from, chainDepth, meta) {
|
||||
const attrs = [
|
||||
`${CROSS_TEAM_ATTR_FROM}="${escapeCrossTeamAttribute(from)}"`,
|
||||
`${CROSS_TEAM_ATTR_DEPTH}="${String(chainDepth)}"`,
|
||||
];
|
||||
if (meta && meta.conversationId) {
|
||||
attrs.push(
|
||||
`${CROSS_TEAM_ATTR_CONVERSATION_ID}="${escapeCrossTeamAttribute(meta.conversationId)}"`
|
||||
);
|
||||
}
|
||||
if (meta && meta.replyToConversationId) {
|
||||
attrs.push(
|
||||
`${CROSS_TEAM_ATTR_REPLY_TO_CONVERSATION_ID}="${escapeCrossTeamAttribute(meta.replyToConversationId)}"`
|
||||
);
|
||||
}
|
||||
return `<${CROSS_TEAM_TAG_NAME} ${attrs.join(' ')} />`;
|
||||
}
|
||||
|
||||
function formatCrossTeamText(from, chainDepth, text, meta) {
|
||||
return `${formatCrossTeamPrefix(from, chainDepth, meta)}\n${text}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CROSS_TEAM_TAG_NAME,
|
||||
CROSS_TEAM_SOURCE,
|
||||
CROSS_TEAM_SENT_SOURCE,
|
||||
formatCrossTeamPrefix,
|
||||
formatCrossTeamText,
|
||||
};
|
||||
81
agent-teams-controller/src/internal/fileLock.js
Normal file
81
agent-teams-controller/src/internal/fileLock.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const STALE_TIMEOUT_MS = 30000;
|
||||
const ACQUIRE_TIMEOUT_MS = 5000;
|
||||
const SPIN_INTERVAL_MS = 20;
|
||||
|
||||
function readLockAge(lockPath) {
|
||||
try {
|
||||
const content = fs.readFileSync(lockPath, 'utf8');
|
||||
const ts = parseInt(content.split('\n')[1] || '', 10);
|
||||
if (Number.isFinite(ts)) return Date.now() - ts;
|
||||
} catch {
|
||||
/* lock may have been released concurrently */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryAcquire(lockPath) {
|
||||
try {
|
||||
const dir = path.dirname(lockPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const fd = fs.openSync(lockPath, 'wx');
|
||||
fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
|
||||
fs.closeSync(fd);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err && err.code === 'EEXIST') {
|
||||
const age = readLockAge(lockPath);
|
||||
if (age !== null && age > STALE_TIMEOUT_MS) {
|
||||
try {
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch {
|
||||
/* another process may have cleaned it */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseLock(lockPath) {
|
||||
try {
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch {
|
||||
/* already released or cleaned up */
|
||||
}
|
||||
}
|
||||
|
||||
function spinWait(deadlineMs) {
|
||||
while (Date.now() < deadlineMs) {
|
||||
const waitUntil = Date.now() + SPIN_INTERVAL_MS;
|
||||
while (Date.now() < waitUntil) {
|
||||
/* busy-wait — intentionally synchronous */
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function withFileLockSync(filePath, fn) {
|
||||
const lockPath = `${filePath}.lock`;
|
||||
const deadline = Date.now() + ACQUIRE_TIMEOUT_MS;
|
||||
|
||||
while (!tryAcquire(lockPath)) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`File lock timeout: ${filePath}`);
|
||||
}
|
||||
spinWait(Math.min(Date.now() + SPIN_INTERVAL_MS, deadline));
|
||||
}
|
||||
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
releaseLock(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { withFileLockSync };
|
||||
58
agent-teams-controller/src/internal/kanban.js
Normal file
58
agent-teams-controller/src/internal/kanban.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
const kanbanStore = require('./kanbanStore.js');
|
||||
const tasks = require('./tasks.js');
|
||||
|
||||
function getKanbanState(context) {
|
||||
return kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||
}
|
||||
|
||||
function setKanbanColumn(context, taskId, column) {
|
||||
const canonicalTaskId = tasks.resolveTaskId(context, taskId);
|
||||
kanbanStore.setKanbanColumn(context.paths, context.teamName, canonicalTaskId, String(column));
|
||||
return getKanbanState(context);
|
||||
}
|
||||
|
||||
function clearKanban(context, taskId, options) {
|
||||
const canonicalTaskId = tasks.resolveTaskId(context, taskId);
|
||||
kanbanStore.clearKanban(context.paths, context.teamName, canonicalTaskId, options);
|
||||
return getKanbanState(context);
|
||||
}
|
||||
|
||||
function listReviewers(context) {
|
||||
return getKanbanState(context).reviewers;
|
||||
}
|
||||
|
||||
function addReviewer(context, reviewer) {
|
||||
const state = getKanbanState(context);
|
||||
const next = new Set(state.reviewers);
|
||||
next.add(String(reviewer));
|
||||
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||
...state,
|
||||
reviewers: [...next],
|
||||
});
|
||||
return listReviewers(context);
|
||||
}
|
||||
|
||||
function removeReviewer(context, reviewer) {
|
||||
const state = getKanbanState(context);
|
||||
const next = state.reviewers.filter((entry) => entry !== reviewer);
|
||||
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||
...state,
|
||||
reviewers: next,
|
||||
});
|
||||
return listReviewers(context);
|
||||
}
|
||||
|
||||
function updateColumnOrder(context, columnId, orderedTaskIds) {
|
||||
const canonicalIds = orderedTaskIds.map((taskId) => tasks.resolveTaskId(context, taskId));
|
||||
return kanbanStore.updateColumnOrder(context.paths, context.teamName, columnId, canonicalIds);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getKanbanState,
|
||||
setKanbanColumn,
|
||||
clearKanban,
|
||||
listReviewers,
|
||||
addReviewer,
|
||||
removeReviewer,
|
||||
updateColumnOrder,
|
||||
};
|
||||
164
agent-teams-controller/src/internal/kanbanStore.js
Normal file
164
agent-teams-controller/src/internal/kanbanStore.js
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const taskStore = require('./taskStore.js');
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function readJson(filePath, fallbackValue) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
|
||||
fs.renameSync(tempPath, filePath);
|
||||
}
|
||||
|
||||
function getDefaultState(teamName) {
|
||||
return {
|
||||
teamName,
|
||||
reviewers: [],
|
||||
tasks: {},
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeState(teamName, rawState) {
|
||||
const state = rawState && typeof rawState === 'object' ? rawState : {};
|
||||
const tasks = {};
|
||||
if (state.tasks && typeof state.tasks === 'object') {
|
||||
for (const [taskId, entry] of Object.entries(state.tasks)) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
if (entry.column !== 'review' && entry.column !== 'approved') continue;
|
||||
if (typeof entry.movedAt !== 'string') continue;
|
||||
tasks[String(taskId)] = {
|
||||
column: entry.column,
|
||||
movedAt: entry.movedAt,
|
||||
...(entry.reviewer === null || typeof entry.reviewer === 'string'
|
||||
? { reviewer: entry.reviewer }
|
||||
: {}),
|
||||
...(typeof entry.errorDescription === 'string'
|
||||
? { errorDescription: entry.errorDescription }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
teamName,
|
||||
reviewers: Array.isArray(state.reviewers)
|
||||
? state.reviewers.filter((entry) => typeof entry === 'string' && entry.trim())
|
||||
: [],
|
||||
tasks,
|
||||
...(state.columnOrder && typeof state.columnOrder === 'object'
|
||||
? { columnOrder: state.columnOrder }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readKanbanState(paths, teamName) {
|
||||
return sanitizeState(teamName, readJson(paths.kanbanPath, getDefaultState(teamName)));
|
||||
}
|
||||
|
||||
function writeKanbanState(paths, teamName, state) {
|
||||
writeJson(paths.kanbanPath, sanitizeState(teamName, state));
|
||||
}
|
||||
|
||||
function setKanbanColumn(paths, teamName, taskId, column) {
|
||||
if (column !== 'review' && column !== 'approved') {
|
||||
throw new Error(`Invalid kanban column: ${String(column)}`);
|
||||
}
|
||||
|
||||
const state = readKanbanState(paths, teamName);
|
||||
state.tasks[String(taskId)] =
|
||||
column === 'review'
|
||||
? { column: 'review', reviewer: null, movedAt: nowIso() }
|
||||
: { column: 'approved', movedAt: nowIso() };
|
||||
writeKanbanState(paths, teamName, state);
|
||||
taskStore.updateTask(paths, String(taskId), (task) => ({
|
||||
...task,
|
||||
reviewState: column,
|
||||
}));
|
||||
return state;
|
||||
}
|
||||
|
||||
function clearKanban(paths, teamName, taskId, options = {}) {
|
||||
const state = readKanbanState(paths, teamName);
|
||||
delete state.tasks[String(taskId)];
|
||||
writeKanbanState(paths, teamName, state);
|
||||
const nextReviewState =
|
||||
typeof options.nextReviewState === 'string' ? options.nextReviewState : 'none';
|
||||
taskStore.updateTask(paths, String(taskId), (task) => ({
|
||||
...task,
|
||||
reviewState: nextReviewState,
|
||||
}));
|
||||
return state;
|
||||
}
|
||||
|
||||
function updateColumnOrder(paths, teamName, columnId, orderedTaskIds) {
|
||||
const state = readKanbanState(paths, teamName);
|
||||
const nextColumnOrder = { ...(state.columnOrder || {}) };
|
||||
if (Array.isArray(orderedTaskIds) && orderedTaskIds.length > 0) {
|
||||
nextColumnOrder[columnId] = orderedTaskIds.map((entry) => String(entry));
|
||||
} else {
|
||||
delete nextColumnOrder[columnId];
|
||||
}
|
||||
state.columnOrder = Object.keys(nextColumnOrder).length > 0 ? nextColumnOrder : undefined;
|
||||
writeKanbanState(paths, teamName, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function garbageCollect(paths, teamName, validTaskIds) {
|
||||
const state = readKanbanState(paths, teamName);
|
||||
let staleKanbanEntriesRemoved = 0;
|
||||
let staleColumnOrderRefsRemoved = 0;
|
||||
|
||||
for (const taskId of Object.keys(state.tasks)) {
|
||||
if (!validTaskIds.has(taskId)) {
|
||||
delete state.tasks[taskId];
|
||||
staleKanbanEntriesRemoved += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.columnOrder && typeof state.columnOrder === 'object') {
|
||||
const cleaned = {};
|
||||
for (const [columnId, orderedTaskIds] of Object.entries(state.columnOrder)) {
|
||||
if (!Array.isArray(orderedTaskIds)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const validIds = orderedTaskIds.filter((taskId) => validTaskIds.has(String(taskId)));
|
||||
staleColumnOrderRefsRemoved += orderedTaskIds.length - validIds.length;
|
||||
if (validIds.length > 0) {
|
||||
cleaned[columnId] = validIds;
|
||||
}
|
||||
}
|
||||
|
||||
state.columnOrder = Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
||||
}
|
||||
|
||||
if (staleKanbanEntriesRemoved > 0 || staleColumnOrderRefsRemoved > 0) {
|
||||
writeKanbanState(paths, teamName, state);
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
staleKanbanEntriesRemoved,
|
||||
staleColumnOrderRefsRemoved,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clearKanban,
|
||||
garbageCollect,
|
||||
readKanbanState,
|
||||
setKanbanColumn,
|
||||
updateColumnOrder,
|
||||
writeKanbanState,
|
||||
};
|
||||
170
agent-teams-controller/src/internal/maintenance.js
Normal file
170
agent-teams-controller/src/internal/maintenance.js
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const kanbanStore = require('./kanbanStore.js');
|
||||
const taskStore = require('./taskStore.js');
|
||||
|
||||
function listInboxNames(paths) {
|
||||
const inboxDir = path.join(paths.teamDir, 'inboxes');
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(inboxDir);
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return entries
|
||||
.filter((name) => name.endsWith('.json') && !name.startsWith('.'))
|
||||
.map((name) => name.replace(/\.json$/, ''));
|
||||
}
|
||||
|
||||
function readInboxMessages(paths) {
|
||||
const messages = [];
|
||||
|
||||
for (const member of listInboxNames(paths)) {
|
||||
const inboxPath = path.join(paths.teamDir, 'inboxes', `${member}.json`);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const item of parsed) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof item.from !== 'string' ||
|
||||
typeof item.text !== 'string' ||
|
||||
typeof item.timestamp !== 'string'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
from: item.from,
|
||||
to: typeof item.to === 'string' ? item.to : member,
|
||||
text: item.text,
|
||||
timestamp: item.timestamp,
|
||||
summary: typeof item.summary === 'string' ? item.summary : undefined,
|
||||
messageId: typeof item.messageId === 'string' ? item.messageId : undefined,
|
||||
source: typeof item.source === 'string' ? item.source : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
messages.sort((a, b) => {
|
||||
const bt = Date.parse(b.timestamp);
|
||||
const at = Date.parse(a.timestamp);
|
||||
if (Number.isNaN(bt) || Number.isNaN(at)) {
|
||||
return 0;
|
||||
}
|
||||
return bt - at;
|
||||
});
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function isAutomatedCommentNotification(message) {
|
||||
const summary = typeof message.summary === 'string' ? message.summary : '';
|
||||
if (!/^Comment on #[A-Za-z0-9-]+/.test(summary)) return false;
|
||||
|
||||
const text = typeof message.text === 'string' ? message.text : '';
|
||||
if (!text) return false;
|
||||
|
||||
if (text.includes('Reply to this comment using:')) return true;
|
||||
if (text.startsWith('Comment on task #')) return true;
|
||||
if (text.startsWith('New comment from user on your task #')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function syncLinkedComments(paths, tasks, messages) {
|
||||
const taskIdPattern = /#([A-Za-z0-9-]+)/g;
|
||||
const tasksById = new Map();
|
||||
const processedTexts = new Set();
|
||||
let linkedCommentsCreated = 0;
|
||||
|
||||
for (const task of tasks) {
|
||||
tasksById.set(task.id, task);
|
||||
if (task.displayId) {
|
||||
tasksById.set(task.displayId, task);
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message.messageId || !message.summary || message.from === 'user') continue;
|
||||
if (message.source === 'lead_session' || message.source === 'lead_process') continue;
|
||||
if (message.source === 'system_notification') continue;
|
||||
if (isAutomatedCommentNotification(message)) continue;
|
||||
|
||||
const textKey = `${message.from}\0${message.text}`;
|
||||
if (processedTexts.has(textKey)) continue;
|
||||
processedTexts.add(textKey);
|
||||
|
||||
const taskRefs = new Set();
|
||||
for (const match of message.summary.matchAll(taskIdPattern)) {
|
||||
taskRefs.add(match[1]);
|
||||
}
|
||||
|
||||
for (const taskRef of taskRefs) {
|
||||
const task = tasksById.get(taskRef);
|
||||
if (!task) continue;
|
||||
|
||||
const commentId = `msg-${message.messageId}`;
|
||||
const existingComments = Array.isArray(task.comments) ? task.comments : [];
|
||||
if (existingComments.some((comment) => comment.id === commentId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
taskStore.addTaskComment(paths, task.id, message.text, {
|
||||
id: commentId,
|
||||
author: message.from,
|
||||
createdAt: message.timestamp,
|
||||
});
|
||||
linkedCommentsCreated += 1;
|
||||
} catch {
|
||||
// Best-effort: reconcile should not fail on individual comment sync writes.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return linkedCommentsCreated;
|
||||
}
|
||||
|
||||
function reconcileArtifacts(context, options = {}) {
|
||||
const garbageCollectKanban = options.garbageCollectKanban !== false;
|
||||
const shouldSyncLinkedComments = options.syncLinkedComments !== false;
|
||||
const tasks = taskStore.listTasks(context.paths);
|
||||
|
||||
const gcResult = garbageCollectKanban
|
||||
? kanbanStore.garbageCollect(
|
||||
context.paths,
|
||||
context.teamName,
|
||||
new Set(tasks.map((task) => task.id))
|
||||
)
|
||||
: { staleKanbanEntriesRemoved: 0, staleColumnOrderRefsRemoved: 0 };
|
||||
|
||||
const linkedCommentsCreated = shouldSyncLinkedComments
|
||||
? syncLinkedComments(context.paths, tasks, readInboxMessages(context.paths))
|
||||
: 0;
|
||||
|
||||
return {
|
||||
staleKanbanEntriesRemoved: gcResult.staleKanbanEntriesRemoved,
|
||||
staleColumnOrderRefsRemoved: gcResult.staleColumnOrderRefsRemoved,
|
||||
linkedCommentsCreated,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
reconcileArtifacts,
|
||||
};
|
||||
150
agent-teams-controller/src/internal/messageStore.js
Normal file
150
agent-teams-controller/src/internal/messageStore.js
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function readJson(filePath, fallbackValue) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
ensureDir(path.dirname(filePath));
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
|
||||
fs.renameSync(tempPath, filePath);
|
||||
}
|
||||
|
||||
function getInboxPath(paths, memberName) {
|
||||
return path.join(paths.teamDir, 'inboxes', `${String(memberName).trim()}.json`);
|
||||
}
|
||||
|
||||
function getSentMessagesPath(paths) {
|
||||
return path.join(paths.teamDir, 'sentMessages.json');
|
||||
}
|
||||
|
||||
function normalizeAttachments(attachments) {
|
||||
if (!Array.isArray(attachments) || attachments.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = attachments
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => ({
|
||||
id: String(item.id || '').trim(),
|
||||
filename: String(item.filename || '').trim(),
|
||||
mimeType: String(item.mimeType || '').trim(),
|
||||
size: Number(item.size || 0),
|
||||
}))
|
||||
.filter((item) => item.id && item.filename && item.mimeType && Number.isFinite(item.size));
|
||||
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function buildMessage(flags, defaults) {
|
||||
const timestamp =
|
||||
typeof flags.timestamp === 'string' && flags.timestamp.trim() ? flags.timestamp.trim() : nowIso();
|
||||
const messageId =
|
||||
typeof flags.messageId === 'string' && flags.messageId.trim()
|
||||
? flags.messageId.trim()
|
||||
: crypto.randomUUID();
|
||||
const attachments = normalizeAttachments(flags.attachments);
|
||||
|
||||
return {
|
||||
from:
|
||||
typeof flags.from === 'string' && flags.from.trim()
|
||||
? flags.from.trim()
|
||||
: defaults.from || 'user',
|
||||
...(defaults.to ? { to: defaults.to } : {}),
|
||||
text: String(flags.text || ''),
|
||||
timestamp,
|
||||
read: defaults.read,
|
||||
...(typeof flags.summary === 'string' && flags.summary.trim()
|
||||
? { summary: flags.summary.trim() }
|
||||
: {}),
|
||||
...(typeof flags.source === 'string' && flags.source.trim() ? { source: flags.source.trim() } : {}),
|
||||
...(typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()
|
||||
? { leadSessionId: flags.leadSessionId.trim() }
|
||||
: {}),
|
||||
...(typeof flags.conversationId === 'string' && flags.conversationId.trim()
|
||||
? { conversationId: flags.conversationId.trim() }
|
||||
: {}),
|
||||
...(typeof flags.replyToConversationId === 'string' && flags.replyToConversationId.trim()
|
||||
? { replyToConversationId: flags.replyToConversationId.trim() }
|
||||
: {}),
|
||||
...(typeof flags.color === 'string' && flags.color.trim() ? { color: flags.color.trim() } : {}),
|
||||
...(typeof flags.toolSummary === 'string' && flags.toolSummary.trim()
|
||||
? { toolSummary: flags.toolSummary.trim() }
|
||||
: {}),
|
||||
...(Array.isArray(flags.toolCalls) && flags.toolCalls.length > 0
|
||||
? {
|
||||
toolCalls: flags.toolCalls
|
||||
.filter((item) => item && typeof item === 'object' && typeof item.name === 'string')
|
||||
.map((item) => ({
|
||||
name: item.name,
|
||||
...(typeof item.preview === 'string' ? { preview: item.preview } : {}),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(attachments ? { attachments } : {}),
|
||||
messageId,
|
||||
};
|
||||
}
|
||||
|
||||
function appendRow(filePath, row) {
|
||||
const current = readJson(filePath, []);
|
||||
const list = Array.isArray(current) ? current : [];
|
||||
list.push(row);
|
||||
writeJson(filePath, list);
|
||||
return row;
|
||||
}
|
||||
|
||||
function sendInboxMessage(paths, flags) {
|
||||
const memberName =
|
||||
typeof flags.member === 'string' && flags.member.trim()
|
||||
? flags.member.trim()
|
||||
: typeof flags.to === 'string' && flags.to.trim()
|
||||
? flags.to.trim()
|
||||
: '';
|
||||
if (!memberName) {
|
||||
throw new Error('Missing recipient');
|
||||
}
|
||||
|
||||
const payload = buildMessage(flags, {
|
||||
from: 'user',
|
||||
to: memberName,
|
||||
read: false,
|
||||
});
|
||||
appendRow(getInboxPath(paths, memberName), payload);
|
||||
return {
|
||||
deliveredToInbox: true,
|
||||
messageId: payload.messageId,
|
||||
message: payload,
|
||||
};
|
||||
}
|
||||
|
||||
function appendSentMessage(paths, flags) {
|
||||
const payload = buildMessage(flags, {
|
||||
from: 'team-lead',
|
||||
to: typeof flags.to === 'string' && flags.to.trim() ? flags.to.trim() : undefined,
|
||||
read: true,
|
||||
});
|
||||
appendRow(getSentMessagesPath(paths), payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
appendSentMessage,
|
||||
sendInboxMessage,
|
||||
};
|
||||
|
||||
14
agent-teams-controller/src/internal/messages.js
Normal file
14
agent-teams-controller/src/internal/messages.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
const messageStore = require('./messageStore.js');
|
||||
|
||||
function sendMessage(context, flags) {
|
||||
return messageStore.sendInboxMessage(context.paths, flags);
|
||||
}
|
||||
|
||||
function appendSentMessage(context, flags) {
|
||||
return messageStore.appendSentMessage(context.paths, flags);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
appendSentMessage,
|
||||
sendMessage,
|
||||
};
|
||||
168
agent-teams-controller/src/internal/processStore.js
Normal file
168
agent-teams-controller/src/internal/processStore.js
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function readJson(filePath, fallbackValue) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
|
||||
fs.renameSync(tempPath, filePath);
|
||||
}
|
||||
|
||||
function readProcesses(paths) {
|
||||
const rows = readJson(paths.processesPath, []);
|
||||
if (!Array.isArray(rows)) return [];
|
||||
return rows.filter((entry) => entry && typeof entry === 'object' && Number.isInteger(entry.pid));
|
||||
}
|
||||
|
||||
function writeProcesses(paths, processes) {
|
||||
writeJson(paths.processesPath, processes);
|
||||
}
|
||||
|
||||
function listProcesses(paths) {
|
||||
const existing = readProcesses(paths);
|
||||
const processes = existing.map((entry) => {
|
||||
const alive =
|
||||
!entry.stoppedAt &&
|
||||
Number.isFinite(Number(entry.pid)) &&
|
||||
runtimeHelpers.isProcessAlive(Number(entry.pid));
|
||||
|
||||
if (!alive && !entry.stoppedAt) {
|
||||
return {
|
||||
...entry,
|
||||
stoppedAt: nowIso(),
|
||||
alive: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
alive,
|
||||
};
|
||||
});
|
||||
|
||||
const changed = processes.some((entry, index) => entry.stoppedAt !== existing[index]?.stoppedAt);
|
||||
if (changed) {
|
||||
writeProcesses(
|
||||
paths,
|
||||
processes.map(({ alive, ...rest }) => rest)
|
||||
);
|
||||
}
|
||||
|
||||
return processes;
|
||||
}
|
||||
|
||||
function registerProcess(paths, flags) {
|
||||
const pid = Number(flags.pid);
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
throw new Error('Invalid pid');
|
||||
}
|
||||
|
||||
const label = typeof flags.label === 'string' && flags.label.trim() ? flags.label.trim() : '';
|
||||
if (!label) {
|
||||
throw new Error('Missing label');
|
||||
}
|
||||
|
||||
const list = readProcesses(paths);
|
||||
const existingActiveIndex = list.findIndex((entry) => entry.pid === pid && !entry.stoppedAt);
|
||||
const existingActive =
|
||||
existingActiveIndex >= 0
|
||||
? {
|
||||
...list[existingActiveIndex],
|
||||
...(runtimeHelpers.isProcessAlive(pid) ? {} : { stoppedAt: nowIso() }),
|
||||
}
|
||||
: null;
|
||||
if (existingActiveIndex >= 0 && existingActive && existingActive.stoppedAt) {
|
||||
list[existingActiveIndex] = existingActive;
|
||||
}
|
||||
const now = nowIso();
|
||||
const entry = {
|
||||
id: existingActive && !existingActive.stoppedAt ? existingActive.id : crypto.randomUUID(),
|
||||
label,
|
||||
pid,
|
||||
...(flags.port != null ? { port: Number(flags.port) } : {}),
|
||||
...(typeof flags.url === 'string' && flags.url.trim() ? { url: flags.url.trim() } : {}),
|
||||
...(typeof flags['claude-process-id'] === 'string' && flags['claude-process-id'].trim()
|
||||
? { claudeProcessId: flags['claude-process-id'].trim() }
|
||||
: {}),
|
||||
...(typeof flags.from === 'string' && flags.from.trim() ? { registeredBy: flags.from.trim() } : {}),
|
||||
...(typeof flags.command === 'string' && flags.command.trim()
|
||||
? { command: flags.command.trim() }
|
||||
: {}),
|
||||
registeredAt: existingActive && !existingActive.stoppedAt ? existingActive.registeredAt : now,
|
||||
};
|
||||
|
||||
if (existingActiveIndex >= 0 && existingActive && !existingActive.stoppedAt) {
|
||||
list[existingActiveIndex] = entry;
|
||||
} else {
|
||||
list.push(entry);
|
||||
}
|
||||
|
||||
writeProcesses(paths, list);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function stopProcess(paths, flags) {
|
||||
const pid = flags.pid != null ? Number(flags.pid) : null;
|
||||
const id =
|
||||
typeof flags.id === 'string' && flags.id.trim().length > 0 ? flags.id.trim() : null;
|
||||
if (!pid && !id) {
|
||||
throw new Error('Missing pid or id');
|
||||
}
|
||||
|
||||
const list = readProcesses(paths);
|
||||
const index = list.findIndex((entry) => {
|
||||
if (pid) return entry.pid === pid && !entry.stoppedAt;
|
||||
return entry.id === id && !entry.stoppedAt;
|
||||
});
|
||||
if (index < 0) {
|
||||
throw new Error('Process not found');
|
||||
}
|
||||
|
||||
list[index] = {
|
||||
...list[index],
|
||||
stoppedAt: list[index].stoppedAt || nowIso(),
|
||||
};
|
||||
writeProcesses(paths, list);
|
||||
return list[index];
|
||||
}
|
||||
|
||||
function unregisterProcess(paths, flags) {
|
||||
const pid = flags.pid != null ? Number(flags.pid) : null;
|
||||
const id =
|
||||
typeof flags.id === 'string' && flags.id.trim().length > 0 ? flags.id.trim() : null;
|
||||
if (!pid && !id) {
|
||||
throw new Error('Missing pid or id');
|
||||
}
|
||||
|
||||
const list = readProcesses(paths);
|
||||
const next = list.filter((entry) => {
|
||||
if (pid) return entry.pid !== pid;
|
||||
return entry.id !== id;
|
||||
});
|
||||
writeProcesses(paths, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listProcesses,
|
||||
readProcesses,
|
||||
registerProcess,
|
||||
stopProcess,
|
||||
unregisterProcess,
|
||||
writeProcesses,
|
||||
};
|
||||
25
agent-teams-controller/src/internal/processes.js
Normal file
25
agent-teams-controller/src/internal/processes.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
const processStore = require('./processStore.js');
|
||||
|
||||
function registerProcess(context, flags) {
|
||||
return processStore.registerProcess(context.paths, flags);
|
||||
}
|
||||
|
||||
function unregisterProcess(context, flags) {
|
||||
processStore.unregisterProcess(context.paths, flags);
|
||||
return listProcesses(context);
|
||||
}
|
||||
|
||||
function listProcesses(context) {
|
||||
return processStore.listProcesses(context.paths);
|
||||
}
|
||||
|
||||
function stopProcess(context, flags) {
|
||||
return processStore.stopProcess(context.paths, flags);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerProcess,
|
||||
stopProcess,
|
||||
unregisterProcess,
|
||||
listProcesses,
|
||||
};
|
||||
208
agent-teams-controller/src/internal/review.js
Normal file
208
agent-teams-controller/src/internal/review.js
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const kanban = require('./kanban.js');
|
||||
const messages = require('./messages.js');
|
||||
const tasks = require('./tasks.js');
|
||||
const { wrapAgentBlock } = require('./agentBlocks.js');
|
||||
|
||||
function getReviewer(context, flags) {
|
||||
if (typeof flags.reviewer === 'string' && flags.reviewer.trim()) {
|
||||
return flags.reviewer.trim();
|
||||
}
|
||||
const state = kanban.getKanbanState(context);
|
||||
return typeof state.reviewers[0] === 'string' && state.reviewers[0].trim()
|
||||
? state.reviewers[0].trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveLeadSessionId(context, flags) {
|
||||
if (typeof flags.leadSessionId === 'string' && flags.leadSessionId.trim()) {
|
||||
return flags.leadSessionId.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const configPath = path.join(context.paths.teamDir, 'config.json');
|
||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
return typeof parsed.leadSessionId === 'string' && parsed.leadSessionId.trim()
|
||||
? parsed.leadSessionId.trim()
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentReviewState(task) {
|
||||
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
if (e.type === 'review_requested' || e.type === 'review_changes_requested' || e.type === 'review_approved') {
|
||||
return e.to;
|
||||
}
|
||||
if (e.type === 'status_changed' && e.to === 'in_progress') {
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
function requestReview(context, taskId, flags = {}) {
|
||||
const task = tasks.getTask(context, taskId);
|
||||
if (task.status !== 'completed') {
|
||||
throw new Error(`Task #${task.displayId || task.id} must be completed before review`);
|
||||
}
|
||||
|
||||
const from =
|
||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||
const reviewer = getReviewer(context, flags);
|
||||
const leadSessionId = resolveLeadSessionId(context, flags);
|
||||
const prevReviewState = getCurrentReviewState(task);
|
||||
|
||||
try {
|
||||
kanban.setKanbanColumn(context, task.id, 'review');
|
||||
|
||||
// Append review_requested event
|
||||
tasks.updateTask(context, task.id, (t) => {
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_requested',
|
||||
from: prevReviewState,
|
||||
to: 'review',
|
||||
...(reviewer ? { reviewer } : {}),
|
||||
actor: from,
|
||||
});
|
||||
t.reviewState = 'review';
|
||||
return t;
|
||||
});
|
||||
|
||||
if (!reviewer) {
|
||||
return tasks.getTask(context, task.id);
|
||||
}
|
||||
|
||||
messages.sendMessage(context, {
|
||||
to: reviewer,
|
||||
from,
|
||||
text:
|
||||
`Please review task #${task.displayId || task.id}.\n\n` +
|
||||
wrapAgentBlock(
|
||||
`When approved, use MCP tool review_approve:\n` +
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", notifyOwner: true }\n\n` +
|
||||
`If changes are needed, use MCP tool review_request_changes:\n` +
|
||||
`{ teamName: "${context.teamName}", taskId: "${task.id}", comment: "..." }`
|
||||
),
|
||||
summary: `Review request for #${task.displayId || task.id}`,
|
||||
source: 'system_notification',
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
});
|
||||
return tasks.getTask(context, task.id);
|
||||
} catch (error) {
|
||||
try {
|
||||
kanban.clearKanban(context, task.id);
|
||||
} catch {
|
||||
// Best-effort rollback: keep the original error.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function approveReview(context, taskId, flags = {}) {
|
||||
const task = tasks.getTask(context, taskId);
|
||||
const from =
|
||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||
const note = typeof flags.note === 'string' && flags.note.trim() ? flags.note.trim() : 'Approved';
|
||||
const leadSessionId = resolveLeadSessionId(context, flags);
|
||||
const prevReviewState = getCurrentReviewState(task);
|
||||
|
||||
kanban.setKanbanColumn(context, task.id, 'approved');
|
||||
|
||||
// Append review_approved event
|
||||
tasks.updateTask(context, task.id, (t) => {
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_approved',
|
||||
from: prevReviewState,
|
||||
to: 'approved',
|
||||
...(note ? { note } : {}),
|
||||
actor: from,
|
||||
});
|
||||
t.reviewState = 'approved';
|
||||
return t;
|
||||
});
|
||||
|
||||
tasks.addTaskComment(context, task.id, {
|
||||
text: note,
|
||||
from,
|
||||
type: 'review_approved',
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
if ((flags.notify === true || flags['notify-owner'] === true) && task.owner) {
|
||||
messages.sendMessage(context, {
|
||||
to: task.owner,
|
||||
from,
|
||||
text:
|
||||
note && note !== 'Approved'
|
||||
? `Task #${task.displayId || task.id} approved.\n\n${note}`
|
||||
: `Task #${task.displayId || task.id} approved.`,
|
||||
summary: `Approved #${task.displayId || task.id}`,
|
||||
source: 'system_notification',
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return tasks.getTask(context, task.id);
|
||||
}
|
||||
|
||||
function requestChanges(context, taskId, flags = {}) {
|
||||
const task = tasks.getTask(context, taskId);
|
||||
if (!task.owner) {
|
||||
throw new Error(`No owner found for task ${String(taskId)}`);
|
||||
}
|
||||
|
||||
const from =
|
||||
typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'team-lead';
|
||||
const comment =
|
||||
typeof flags.comment === 'string' && flags.comment.trim()
|
||||
? flags.comment.trim()
|
||||
: 'Reviewer requested changes.';
|
||||
const leadSessionId = resolveLeadSessionId(context, flags);
|
||||
const prevReviewState = getCurrentReviewState(task);
|
||||
|
||||
// Append review_changes_requested event before status change
|
||||
tasks.updateTask(context, task.id, (t) => {
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_changes_requested',
|
||||
from: prevReviewState,
|
||||
to: 'needsFix',
|
||||
...(comment ? { note: comment } : {}),
|
||||
actor: from,
|
||||
});
|
||||
t.reviewState = 'needsFix';
|
||||
return t;
|
||||
});
|
||||
|
||||
kanban.clearKanban(context, task.id, { nextReviewState: 'needsFix' });
|
||||
tasks.setTaskStatus(context, task.id, 'pending', from);
|
||||
tasks.addTaskComment(context, task.id, {
|
||||
text: comment,
|
||||
from,
|
||||
type: 'review_request',
|
||||
notifyOwner: false,
|
||||
});
|
||||
messages.sendMessage(context, {
|
||||
to: task.owner,
|
||||
from,
|
||||
text:
|
||||
`Task #${task.displayId || task.id} needs fixes.\n\n${comment}\n\n` +
|
||||
'The task has been moved back to pending. When you are ready to resume, review the task context, start it explicitly, implement the fixes, mark it completed, and request review again.',
|
||||
summary: `Fix request for #${task.displayId || task.id}`,
|
||||
source: 'system_notification',
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
});
|
||||
|
||||
return tasks.getTask(context, task.id);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
approveReview,
|
||||
requestReview,
|
||||
requestChanges,
|
||||
};
|
||||
308
agent-teams-controller/src/internal/runtimeHelpers.js
Normal file
308
agent-teams-controller/src/internal/runtimeHelpers.js
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
||||
const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function makeId() {
|
||||
return crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function readJson(filePath, fallbackValue) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
return fallbackValue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isSafePathSegment(value) {
|
||||
const normalized = String(value == null ? '' : value);
|
||||
if (normalized.length === 0 || normalized.trim().length === 0) return false;
|
||||
if (normalized === '.' || normalized === '..') return false;
|
||||
if (normalized.includes('/') || normalized.includes('\\')) return false;
|
||||
if (normalized.includes('..')) return false;
|
||||
if (normalized.includes('\0')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function assertSafePathSegment(label, value) {
|
||||
const normalized = String(value == null ? '' : value);
|
||||
if (!isSafePathSegment(normalized)) {
|
||||
throw new Error(`Invalid ${String(label)}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getHomeDir() {
|
||||
if (process.env.HOME) return process.env.HOME;
|
||||
if (process.env.USERPROFILE) return process.env.USERPROFILE;
|
||||
if (process.env.HOMEDRIVE && process.env.HOMEPATH) {
|
||||
return process.env.HOMEDRIVE + process.env.HOMEPATH;
|
||||
}
|
||||
try {
|
||||
return require('os').homedir();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getClaudeDir(flags) {
|
||||
const explicit =
|
||||
(typeof flags['claude-dir'] === 'string' && flags['claude-dir']) ||
|
||||
(typeof flags.claudeDir === 'string' && flags.claudeDir) ||
|
||||
(typeof flags.claude_path === 'string' && flags.claude_path) ||
|
||||
'';
|
||||
if (explicit) {
|
||||
return path.resolve(explicit);
|
||||
}
|
||||
const home = getHomeDir();
|
||||
if (!home) {
|
||||
throw new Error('HOME/USERPROFILE is not set');
|
||||
}
|
||||
return path.join(home, '.claude');
|
||||
}
|
||||
|
||||
function getPaths(flags, teamName) {
|
||||
const claudeDir = getClaudeDir(flags);
|
||||
const safeTeam = assertSafePathSegment('team', teamName);
|
||||
const teamDir = path.join(claudeDir, 'teams', safeTeam);
|
||||
const tasksDir = path.join(claudeDir, 'tasks', safeTeam);
|
||||
const kanbanPath = path.join(teamDir, 'kanban-state.json');
|
||||
const processesPath = path.join(teamDir, 'processes.json');
|
||||
return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath };
|
||||
}
|
||||
|
||||
function inferLeadName(paths) {
|
||||
const config = readTeamConfig(paths);
|
||||
if (!config || !Array.isArray(config.members)) {
|
||||
return 'team-lead';
|
||||
}
|
||||
const lead = config.members.find(
|
||||
(member) => member && member.role && String(member.role).toLowerCase().includes('lead')
|
||||
);
|
||||
if (lead) {
|
||||
return String(lead.name);
|
||||
}
|
||||
return config.members[0] ? String(config.members[0].name) : 'team-lead';
|
||||
}
|
||||
|
||||
function readTeamConfig(paths) {
|
||||
return readJson(path.join(paths.teamDir, 'config.json'), null);
|
||||
}
|
||||
|
||||
function resolveLeadSessionId(paths) {
|
||||
const config = readTeamConfig(paths);
|
||||
return config && typeof config.leadSessionId === 'string' && config.leadSessionId.trim()
|
||||
? config.leadSessionId.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'EPERM') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeFilename(original) {
|
||||
const raw = String(original == null ? '' : original).trim();
|
||||
const parts = raw.split(/[\\/]/);
|
||||
const base = (parts.length ? parts[parts.length - 1] : raw).trim();
|
||||
const cleaned = base
|
||||
.replace(/\0/g, '')
|
||||
.replace(/[\r\n\t]/g, ' ')
|
||||
.replace(/[\\/]/g, '_')
|
||||
.trim();
|
||||
if (!cleaned) return 'attachment';
|
||||
return cleaned.length > 180 ? cleaned.slice(0, 180) : cleaned;
|
||||
}
|
||||
|
||||
function readFileHeader(filePath, maxBytes) {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(maxBytes);
|
||||
const bytes = fs.readSync(fd, buffer, 0, maxBytes, 0);
|
||||
return buffer.slice(0, bytes);
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(fd);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startsWithBytes(buffer, bytes) {
|
||||
if (!buffer || buffer.length < bytes.length) return false;
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
if (buffer[i] !== bytes[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function detectMimeTypeFromPathAndHeader(filePath, filename) {
|
||||
const name = String(filename || '').toLowerCase();
|
||||
const ext = path.extname(name);
|
||||
|
||||
if (ext === '.png') return 'image/png';
|
||||
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
||||
if (ext === '.gif') return 'image/gif';
|
||||
if (ext === '.webp') return 'image/webp';
|
||||
if (ext === '.pdf') return 'application/pdf';
|
||||
if (ext === '.txt') return 'text/plain';
|
||||
if (ext === '.md') return 'text/markdown';
|
||||
if (ext === '.json') return 'application/json';
|
||||
if (ext === '.zip') return 'application/zip';
|
||||
|
||||
let header;
|
||||
try {
|
||||
header = readFileHeader(filePath, 16);
|
||||
} catch {
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
if (startsWithBytes(header, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png';
|
||||
if (startsWithBytes(header, [0xff, 0xd8, 0xff])) return 'image/jpeg';
|
||||
if (header.length >= 6) {
|
||||
const signature6 = header.slice(0, 6).toString('ascii');
|
||||
if (signature6 === 'GIF87a' || signature6 === 'GIF89a') return 'image/gif';
|
||||
}
|
||||
if (header.length >= 12) {
|
||||
const riff = header.slice(0, 4).toString('ascii');
|
||||
const webp = header.slice(8, 12).toString('ascii');
|
||||
if (riff === 'RIFF' && webp === 'WEBP') return 'image/webp';
|
||||
}
|
||||
if (header.length >= 5 && header.slice(0, 5).toString('ascii') === '%PDF-') {
|
||||
return 'application/pdf';
|
||||
}
|
||||
if (startsWithBytes(header, [0x50, 0x4b, 0x03, 0x04])) return 'application/zip';
|
||||
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function getTaskAttachmentsDir(paths, taskId) {
|
||||
const safeTaskId = assertSafePathSegment('taskId', taskId);
|
||||
return path.join(paths.teamDir, TASK_ATTACHMENTS_DIR, safeTaskId);
|
||||
}
|
||||
|
||||
function getStoredAttachmentPath(paths, taskId, attachmentId, filename) {
|
||||
const safeFilename = sanitizeFilename(filename);
|
||||
return path.join(
|
||||
getTaskAttachmentsDir(paths, taskId),
|
||||
`${String(attachmentId)}--${safeFilename}`
|
||||
);
|
||||
}
|
||||
|
||||
function ensureSourceFileReadable(srcPath) {
|
||||
const stats = fs.statSync(srcPath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Not a file: ${String(srcPath)}`);
|
||||
}
|
||||
if (stats.size > MAX_TASK_ATTACHMENT_BYTES) {
|
||||
throw new Error(
|
||||
`Attachment too large: ${(stats.size / (1024 * 1024)).toFixed(1)} MB (max ${String(
|
||||
MAX_TASK_ATTACHMENT_BYTES / (1024 * 1024)
|
||||
)} MB)`
|
||||
);
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
function copyOrLinkFile(srcPath, destPath, mode, allowFallback) {
|
||||
const normalizedMode = String(mode || 'copy').toLowerCase();
|
||||
if (normalizedMode === 'link') {
|
||||
try {
|
||||
fs.linkSync(srcPath, destPath);
|
||||
return { mode: 'link', fallbackUsed: false };
|
||||
} catch (error) {
|
||||
if (!allowFallback) throw error;
|
||||
try {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
return { mode: 'copy', fallbackUsed: true };
|
||||
} catch (copyError) {
|
||||
throw copyError || error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
return { mode: 'copy', fallbackUsed: false };
|
||||
}
|
||||
|
||||
function saveTaskAttachmentFile(paths, taskId, flags) {
|
||||
const rawFile =
|
||||
(typeof flags.file === 'string' && flags.file.trim()) ||
|
||||
(typeof flags.path === 'string' && flags.path.trim()) ||
|
||||
'';
|
||||
if (!rawFile) {
|
||||
throw new Error('Missing --file <path>');
|
||||
}
|
||||
|
||||
const srcPath = path.resolve(rawFile);
|
||||
ensureSourceFileReadable(srcPath);
|
||||
|
||||
const filename =
|
||||
(typeof flags.filename === 'string' && flags.filename.trim()) || path.basename(srcPath);
|
||||
const mimeType =
|
||||
(typeof flags['mime-type'] === 'string' && flags['mime-type'].trim()) ||
|
||||
(typeof flags.mimeType === 'string' && flags.mimeType.trim()) ||
|
||||
detectMimeTypeFromPathAndHeader(srcPath, filename);
|
||||
|
||||
const attachmentId = makeId();
|
||||
const dir = getTaskAttachmentsDir(paths, taskId);
|
||||
ensureDir(dir);
|
||||
const destPath = getStoredAttachmentPath(paths, taskId, attachmentId, filename);
|
||||
const allowFallback = !(flags['no-fallback'] === true);
|
||||
|
||||
if (fs.existsSync(destPath)) {
|
||||
throw new Error('Attachment destination already exists');
|
||||
}
|
||||
|
||||
const result = copyOrLinkFile(srcPath, destPath, flags.mode, allowFallback);
|
||||
const stats = fs.statSync(destPath);
|
||||
if (!stats.isFile() || stats.size < 0) {
|
||||
throw new Error('Attachment write verification failed');
|
||||
}
|
||||
|
||||
const meta = {
|
||||
id: attachmentId,
|
||||
filename,
|
||||
mimeType,
|
||||
size: stats.size,
|
||||
addedAt: nowIso(),
|
||||
};
|
||||
|
||||
return {
|
||||
meta,
|
||||
storedPath: destPath,
|
||||
storageMode: result.mode,
|
||||
fallbackUsed: result.fallbackUsed,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPaths,
|
||||
inferLeadName,
|
||||
isProcessAlive,
|
||||
readTeamConfig,
|
||||
resolveLeadSessionId,
|
||||
saveTaskAttachmentFile,
|
||||
};
|
||||
782
agent-teams-controller/src/internal/taskStore.js
Normal file
782
agent-teams-controller/src/internal/taskStore.js
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const TASK_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
|
||||
const REVIEW_STATES = new Set(['none', 'review', 'needsFix', 'approved']);
|
||||
const UUID_TASK_ID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function readJson(filePath, fallbackValue) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
ensureDir(path.dirname(filePath));
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
|
||||
fs.renameSync(tempPath, filePath);
|
||||
}
|
||||
|
||||
function getTaskPath(paths, taskId) {
|
||||
return path.join(paths.tasksDir, `${String(taskId)}.json`);
|
||||
}
|
||||
|
||||
function looksLikeCanonicalTaskId(taskId) {
|
||||
return UUID_TASK_ID_PATTERN.test(String(taskId || '').trim());
|
||||
}
|
||||
|
||||
function deriveDisplayId(taskId) {
|
||||
const normalized = String(taskId || '').trim();
|
||||
if (!normalized) return normalized;
|
||||
return looksLikeCanonicalTaskId(normalized) ? normalized.slice(0, 8).toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function normalizeTask(rawTask, filePath) {
|
||||
if (!rawTask || typeof rawTask !== 'object') {
|
||||
throw new Error(`Invalid task payload${filePath ? `: ${filePath}` : ''}`);
|
||||
}
|
||||
|
||||
const id =
|
||||
typeof rawTask.id === 'string' || typeof rawTask.id === 'number' ? String(rawTask.id) : '';
|
||||
if (!id) {
|
||||
throw new Error(`Task is missing id${filePath ? `: ${filePath}` : ''}`);
|
||||
}
|
||||
|
||||
const task = {
|
||||
...rawTask,
|
||||
id,
|
||||
displayId:
|
||||
typeof rawTask.displayId === 'string' && rawTask.displayId.trim()
|
||||
? rawTask.displayId.trim()
|
||||
: deriveDisplayId(id),
|
||||
reviewState: normalizeTaskReviewState(rawTask.reviewState),
|
||||
};
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
function normalizeTaskReviewState(value) {
|
||||
return REVIEW_STATES.has(String(value || '').trim()) ? String(value).trim() : 'none';
|
||||
}
|
||||
|
||||
function listRawTasks(paths) {
|
||||
ensureDir(paths.tasksDir);
|
||||
const entries = fs.readdirSync(paths.tasksDir);
|
||||
const out = [];
|
||||
|
||||
for (const fileName of entries) {
|
||||
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
|
||||
const filePath = path.join(paths.tasksDir, fileName);
|
||||
const rawTask = readJson(filePath, null);
|
||||
if (!rawTask) continue;
|
||||
if (rawTask.metadata && rawTask.metadata._internal === true) continue;
|
||||
try {
|
||||
out.push(normalizeTask(rawTask, filePath));
|
||||
} catch {
|
||||
// Skip unreadable task rows.
|
||||
}
|
||||
}
|
||||
|
||||
out.sort((a, b) => {
|
||||
const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
if (byDisplay !== 0) return byDisplay;
|
||||
return String(a.id).localeCompare(String(b.id), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function listTasks(paths, options = {}) {
|
||||
const includeDeleted = options.includeDeleted === true;
|
||||
return listRawTasks(paths).filter((task) => includeDeleted || task.status !== 'deleted');
|
||||
}
|
||||
|
||||
function resolveTaskRef(paths, taskRef, options = {}) {
|
||||
const normalizedRef = String(taskRef || '').trim().replace(/^#/, '');
|
||||
if (!normalizedRef) {
|
||||
throw new Error('Missing taskId');
|
||||
}
|
||||
|
||||
const includeDeleted = options.includeDeleted === true;
|
||||
const tasks = listRawTasks(paths);
|
||||
const exact = tasks.find((task) => task.id === normalizedRef);
|
||||
if (exact && (includeDeleted || exact.status !== 'deleted')) {
|
||||
return exact.id;
|
||||
}
|
||||
|
||||
const byDisplay = tasks.find(
|
||||
(task) =>
|
||||
task.displayId === normalizedRef &&
|
||||
(includeDeleted || task.status !== 'deleted')
|
||||
);
|
||||
if (byDisplay) {
|
||||
return byDisplay.id;
|
||||
}
|
||||
|
||||
throw new Error(`Task not found: ${normalizedRef}`);
|
||||
}
|
||||
|
||||
function readTask(paths, taskRef, options = {}) {
|
||||
const taskId = resolveTaskRef(paths, taskRef, options);
|
||||
const taskPath = getTaskPath(paths, taskId);
|
||||
const rawTask = readJson(taskPath, null);
|
||||
if (!rawTask) {
|
||||
throw new Error(`Task not found: ${String(taskRef)}`);
|
||||
}
|
||||
return normalizeTask(rawTask, taskPath);
|
||||
}
|
||||
|
||||
function appendHistoryEvent(events, event) {
|
||||
const list = Array.isArray(events) ? [...events] : [];
|
||||
list.push({ id: crypto.randomUUID(), timestamp: nowIso(), ...event });
|
||||
return list;
|
||||
}
|
||||
|
||||
function normalizeStatus(status) {
|
||||
const normalized = String(status || '').trim();
|
||||
return TASK_STATUSES.has(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function parseRelationshipList(paths, value) {
|
||||
const rawValues = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? value.split(',').map((entry) => entry.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return rawValues.map((entry) => resolveTaskRef(paths, entry));
|
||||
}
|
||||
|
||||
function computeInitialStatus(paths, input, owner, blockedByIds) {
|
||||
const explicit = normalizeStatus(input.status);
|
||||
if (explicit) return explicit;
|
||||
if (blockedByIds.length > 0) return 'pending';
|
||||
if (owner && input.startImmediately === true) return 'in_progress';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function pickTaskId(input) {
|
||||
if (typeof input.id === 'string' && input.id.trim()) {
|
||||
return input.id.trim();
|
||||
}
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function pickUniqueDisplayId(paths, canonicalId, explicitDisplayId) {
|
||||
const preferred =
|
||||
typeof explicitDisplayId === 'string' && explicitDisplayId.trim()
|
||||
? explicitDisplayId.trim()
|
||||
: deriveDisplayId(canonicalId);
|
||||
|
||||
const existing = new Set(listRawTasks(paths).map((task) => task.displayId || deriveDisplayId(task.id)));
|
||||
if (!existing.has(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
let length = Math.max(preferred.length, 8);
|
||||
while (length < canonicalId.length) {
|
||||
const candidate = canonicalId.slice(0, length).toLowerCase();
|
||||
if (!existing.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
length += 1;
|
||||
}
|
||||
|
||||
return canonicalId.toLowerCase();
|
||||
}
|
||||
|
||||
function wouldCreateBlockCycle(paths, sourceId, targetId) {
|
||||
const visited = new Set();
|
||||
const stack = [targetId];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const currentId = stack.pop();
|
||||
if (!currentId || visited.has(currentId)) continue;
|
||||
if (currentId === sourceId) return true;
|
||||
visited.add(currentId);
|
||||
try {
|
||||
const currentTask = readTask(paths, currentId, { includeDeleted: true });
|
||||
for (const depId of currentTask.blockedBy || []) {
|
||||
stack.push(depId);
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable dependency rows during cycle probe.
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function writeTask(paths, task) {
|
||||
writeJson(getTaskPath(paths, task.id), task);
|
||||
}
|
||||
|
||||
function createTask(paths, input = {}) {
|
||||
ensureDir(paths.tasksDir);
|
||||
|
||||
const canonicalId = pickTaskId(input);
|
||||
if (fs.existsSync(getTaskPath(paths, canonicalId))) {
|
||||
throw new Error(`Task already exists: ${canonicalId}`);
|
||||
}
|
||||
|
||||
const blockedByIds = parseRelationshipList(paths, input['blocked-by'] ?? input.blockedBy);
|
||||
const relatedIds = parseRelationshipList(paths, input.related);
|
||||
const owner =
|
||||
typeof input.owner === 'string' && input.owner.trim() ? input.owner.trim() : undefined;
|
||||
const createdBy =
|
||||
typeof input.from === 'string' && input.from.trim()
|
||||
? input.from.trim()
|
||||
: typeof input.createdBy === 'string' && input.createdBy.trim()
|
||||
? input.createdBy.trim()
|
||||
: undefined;
|
||||
const createdAt =
|
||||
typeof input.createdAt === 'string' && input.createdAt.trim() ? input.createdAt.trim() : nowIso();
|
||||
const status = computeInitialStatus(paths, input, owner, blockedByIds);
|
||||
const displayId = pickUniqueDisplayId(paths, canonicalId, input.displayId);
|
||||
|
||||
for (const depId of blockedByIds) {
|
||||
if (wouldCreateBlockCycle(paths, canonicalId, depId)) {
|
||||
throw new Error(`Circular dependency: ${depId} already depends on ${canonicalId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const task = normalizeTask({
|
||||
id: canonicalId,
|
||||
displayId,
|
||||
subject:
|
||||
typeof input.subject === 'string' && input.subject.trim()
|
||||
? input.subject.trim()
|
||||
: String(input.subject || '').trim(),
|
||||
description:
|
||||
typeof input.description === 'string' && input.description.length > 0
|
||||
? input.description
|
||||
: String(input.subject || '').trim(),
|
||||
activeForm:
|
||||
typeof input.activeForm === 'string'
|
||||
? input.activeForm
|
||||
: typeof input['active-form'] === 'string'
|
||||
? input['active-form']
|
||||
: undefined,
|
||||
owner,
|
||||
createdBy,
|
||||
status,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
workIntervals:
|
||||
status === 'in_progress'
|
||||
? [{ startedAt: createdAt }]
|
||||
: Array.isArray(input.workIntervals)
|
||||
? input.workIntervals
|
||||
: undefined,
|
||||
historyEvents: appendHistoryEvent(undefined, {
|
||||
type: 'task_created',
|
||||
status,
|
||||
...(createdBy ? { actor: createdBy } : {}),
|
||||
timestamp: createdAt,
|
||||
}),
|
||||
blocks: Array.isArray(input.blocks) ? [...input.blocks] : [],
|
||||
blockedBy: blockedByIds,
|
||||
related: relatedIds.length > 0 ? relatedIds : undefined,
|
||||
projectPath:
|
||||
typeof input.projectPath === 'string' && input.projectPath.trim()
|
||||
? input.projectPath.trim()
|
||||
: undefined,
|
||||
comments: Array.isArray(input.comments) ? input.comments : undefined,
|
||||
needsClarification:
|
||||
input.needsClarification === 'lead' || input.needsClarification === 'user'
|
||||
? input.needsClarification
|
||||
: undefined,
|
||||
reviewState: normalizeTaskReviewState(input.reviewState),
|
||||
deletedAt:
|
||||
status === 'deleted' && typeof input.deletedAt === 'string' ? input.deletedAt : undefined,
|
||||
attachments: Array.isArray(input.attachments) ? input.attachments : undefined,
|
||||
});
|
||||
|
||||
if (!task.subject) {
|
||||
throw new Error('Missing subject');
|
||||
}
|
||||
|
||||
writeTask(paths, task);
|
||||
|
||||
for (const depId of blockedByIds) {
|
||||
const dependencyTask = readTask(paths, depId, { includeDeleted: true });
|
||||
const dependencyBlocks = Array.isArray(dependencyTask.blocks) ? dependencyTask.blocks : [];
|
||||
if (!dependencyBlocks.includes(task.id)) {
|
||||
dependencyTask.blocks = dependencyBlocks.concat([task.id]);
|
||||
dependencyTask.updatedAt = nowIso();
|
||||
writeTask(paths, dependencyTask);
|
||||
}
|
||||
}
|
||||
|
||||
for (const relatedId of relatedIds) {
|
||||
const relatedTask = readTask(paths, relatedId, { includeDeleted: true });
|
||||
const existingRelated = Array.isArray(relatedTask.related) ? relatedTask.related : [];
|
||||
if (!existingRelated.includes(task.id)) {
|
||||
relatedTask.related = existingRelated.concat([task.id]);
|
||||
relatedTask.updatedAt = nowIso();
|
||||
writeTask(paths, relatedTask);
|
||||
}
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
function updateTask(paths, taskRef, updater, options = {}) {
|
||||
const existingTask = readTask(paths, taskRef, { includeDeleted: true });
|
||||
const nextTask = normalizeTask(updater({ ...existingTask }) || existingTask);
|
||||
nextTask.updatedAt = nowIso();
|
||||
writeTask(paths, nextTask);
|
||||
return nextTask;
|
||||
}
|
||||
|
||||
function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
||||
const status = normalizeStatus(nextStatus);
|
||||
if (!status) {
|
||||
throw new Error(`Invalid status: ${String(nextStatus)}`);
|
||||
}
|
||||
|
||||
return updateTask(paths, taskRef, (task) => {
|
||||
if (task.status === status) return task;
|
||||
const timestamp = nowIso();
|
||||
const workIntervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : [];
|
||||
const lastInterval = workIntervals.length > 0 ? workIntervals[workIntervals.length - 1] : null;
|
||||
|
||||
if (task.status !== 'in_progress' && status === 'in_progress') {
|
||||
if (!lastInterval || typeof lastInterval.completedAt === 'string') {
|
||||
workIntervals.push({ startedAt: timestamp });
|
||||
}
|
||||
} else if (task.status === 'in_progress' && status !== 'in_progress') {
|
||||
if (lastInterval && lastInterval.completedAt === undefined) {
|
||||
lastInterval.completedAt = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
task.workIntervals = workIntervals.length > 0 ? workIntervals : undefined;
|
||||
task.historyEvents = appendHistoryEvent(task.historyEvents, {
|
||||
type: 'status_changed',
|
||||
from: task.status,
|
||||
to: status,
|
||||
...(actor ? { actor } : {}),
|
||||
timestamp,
|
||||
});
|
||||
task.status = status;
|
||||
|
||||
if (status === 'deleted') {
|
||||
task.deletedAt = timestamp;
|
||||
} else if (task.deletedAt) {
|
||||
delete task.deletedAt;
|
||||
}
|
||||
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
function setTaskOwner(paths, taskRef, owner) {
|
||||
return updateTask(paths, taskRef, (task) => {
|
||||
if (owner == null || owner === 'clear' || owner === 'none') {
|
||||
delete task.owner;
|
||||
} else {
|
||||
task.owner = String(owner).trim();
|
||||
}
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
function updateTaskFields(paths, taskRef, fields) {
|
||||
return updateTask(paths, taskRef, (task) => {
|
||||
if (fields.subject !== undefined) {
|
||||
task.subject = fields.subject;
|
||||
}
|
||||
if (fields.description !== undefined) {
|
||||
task.description = fields.description;
|
||||
}
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMemberName(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
function addTaskComment(paths, taskRef, text, options = {}) {
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
throw new Error('Missing comment text');
|
||||
}
|
||||
|
||||
const comment = {
|
||||
id: options.id || crypto.randomUUID(),
|
||||
author:
|
||||
typeof options.author === 'string' && options.author.trim()
|
||||
? options.author.trim()
|
||||
: 'user',
|
||||
text,
|
||||
createdAt:
|
||||
typeof options.createdAt === 'string' && options.createdAt.trim()
|
||||
? options.createdAt.trim()
|
||||
: nowIso(),
|
||||
type: options.type || 'regular',
|
||||
...(Array.isArray(options.attachments) && options.attachments.length > 0
|
||||
? { attachments: options.attachments }
|
||||
: {}),
|
||||
};
|
||||
|
||||
let inserted = false;
|
||||
let clarificationCleared = false;
|
||||
const task = updateTask(paths, taskRef, (currentTask) => {
|
||||
const comments = Array.isArray(currentTask.comments) ? currentTask.comments : [];
|
||||
if (comments.some((entry) => entry.id === comment.id)) {
|
||||
return currentTask;
|
||||
}
|
||||
|
||||
const authorName = normalizeMemberName(comment.author);
|
||||
const ownerName = normalizeMemberName(currentTask.owner);
|
||||
if (currentTask.needsClarification === 'lead' && authorName && authorName !== ownerName) {
|
||||
delete currentTask.needsClarification;
|
||||
clarificationCleared = true;
|
||||
}
|
||||
if (currentTask.needsClarification === 'user' && authorName === 'user') {
|
||||
delete currentTask.needsClarification;
|
||||
clarificationCleared = true;
|
||||
}
|
||||
|
||||
currentTask.comments = comments.concat([comment]);
|
||||
inserted = true;
|
||||
return currentTask;
|
||||
});
|
||||
|
||||
return { comment, task, inserted, clarificationCleared };
|
||||
}
|
||||
|
||||
function setNeedsClarification(paths, taskRef, value) {
|
||||
return updateTask(paths, taskRef, (task) => {
|
||||
if (value === null || value === 'clear') {
|
||||
delete task.needsClarification;
|
||||
} else if (value === 'lead' || value === 'user') {
|
||||
task.needsClarification = value;
|
||||
} else {
|
||||
throw new Error(`Invalid clarification value: ${String(value)}`);
|
||||
}
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
function addTaskAttachmentMeta(paths, taskRef, meta) {
|
||||
return updateTask(paths, taskRef, (task) => {
|
||||
const attachments = Array.isArray(task.attachments) ? task.attachments : [];
|
||||
if (!attachments.some((entry) => entry.id === meta.id)) {
|
||||
task.attachments = attachments.concat([meta]);
|
||||
}
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
function removeTaskAttachment(paths, taskRef, attachmentId) {
|
||||
return updateTask(paths, taskRef, (task) => {
|
||||
const attachments = Array.isArray(task.attachments) ? task.attachments : [];
|
||||
const filtered = attachments.filter((entry) => entry.id !== attachmentId);
|
||||
if (filtered.length > 0) task.attachments = filtered;
|
||||
else delete task.attachments;
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
function addCommentAttachmentMeta(paths, taskRef, commentRef, meta) {
|
||||
return updateTask(paths, taskRef, (task) => {
|
||||
const comments = Array.isArray(task.comments) ? [...task.comments] : [];
|
||||
const commentIndex = comments.findIndex((entry) => String(entry.id) === String(commentRef));
|
||||
if (commentIndex < 0) {
|
||||
throw new Error(`Comment not found: ${String(commentRef)}`);
|
||||
}
|
||||
const comment = { ...comments[commentIndex] };
|
||||
const attachments = Array.isArray(comment.attachments) ? comment.attachments : [];
|
||||
if (!attachments.some((entry) => entry.id === meta.id)) {
|
||||
comment.attachments = attachments.concat([meta]);
|
||||
}
|
||||
comments[commentIndex] = comment;
|
||||
task.comments = comments;
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
function linkTask(paths, taskRef, targetRef, relationship) {
|
||||
const sourceId = resolveTaskRef(paths, taskRef);
|
||||
const targetId = resolveTaskRef(paths, targetRef);
|
||||
if (sourceId === targetId) {
|
||||
throw new Error('Cannot link a task to itself');
|
||||
}
|
||||
|
||||
if (relationship === 'blocks') {
|
||||
return linkTask(paths, targetId, sourceId, 'blocked-by');
|
||||
}
|
||||
|
||||
if (relationship === 'blocked-by') {
|
||||
if (wouldCreateBlockCycle(paths, sourceId, targetId)) {
|
||||
throw new Error(`Circular dependency: ${targetId} already depends on ${sourceId}`);
|
||||
}
|
||||
|
||||
const sourceTask = readTask(paths, sourceId, { includeDeleted: true });
|
||||
const targetTask = readTask(paths, targetId, { includeDeleted: true });
|
||||
if (!(sourceTask.blockedBy || []).includes(targetId)) {
|
||||
sourceTask.blockedBy = [...(sourceTask.blockedBy || []), targetId];
|
||||
writeTask(paths, sourceTask);
|
||||
}
|
||||
if (!(targetTask.blocks || []).includes(sourceId)) {
|
||||
targetTask.blocks = [...(targetTask.blocks || []), sourceId];
|
||||
writeTask(paths, targetTask);
|
||||
}
|
||||
return readTask(paths, sourceId, { includeDeleted: true });
|
||||
}
|
||||
|
||||
if (relationship !== 'related') {
|
||||
throw new Error(`Unsupported relationship: ${String(relationship)}`);
|
||||
}
|
||||
|
||||
const sourceTask = readTask(paths, sourceId, { includeDeleted: true });
|
||||
const targetTask = readTask(paths, targetId, { includeDeleted: true });
|
||||
if (!(sourceTask.related || []).includes(targetId)) {
|
||||
sourceTask.related = [...(sourceTask.related || []), targetId];
|
||||
writeTask(paths, sourceTask);
|
||||
}
|
||||
if (!(targetTask.related || []).includes(sourceId)) {
|
||||
targetTask.related = [...(targetTask.related || []), sourceId];
|
||||
writeTask(paths, targetTask);
|
||||
}
|
||||
return readTask(paths, sourceId, { includeDeleted: true });
|
||||
}
|
||||
|
||||
function unlinkTask(paths, taskRef, targetRef, relationship) {
|
||||
const sourceId = resolveTaskRef(paths, taskRef, { includeDeleted: true });
|
||||
const targetId = resolveTaskRef(paths, targetRef, { includeDeleted: true });
|
||||
|
||||
if (relationship === 'blocks') {
|
||||
return unlinkTask(paths, targetId, sourceId, 'blocked-by');
|
||||
}
|
||||
|
||||
const sourceTask = readTask(paths, sourceId, { includeDeleted: true });
|
||||
if (relationship === 'blocked-by') {
|
||||
sourceTask.blockedBy = (sourceTask.blockedBy || []).filter((entry) => entry !== targetId);
|
||||
writeTask(paths, sourceTask);
|
||||
try {
|
||||
const targetTask = readTask(paths, targetId, { includeDeleted: true });
|
||||
targetTask.blocks = (targetTask.blocks || []).filter((entry) => entry !== sourceId);
|
||||
writeTask(paths, targetTask);
|
||||
} catch {
|
||||
// Ignore missing reverse link target.
|
||||
}
|
||||
return readTask(paths, sourceId, { includeDeleted: true });
|
||||
}
|
||||
|
||||
if (relationship !== 'related') {
|
||||
throw new Error(`Unsupported relationship: ${String(relationship)}`);
|
||||
}
|
||||
|
||||
sourceTask.related = (sourceTask.related || []).filter((entry) => entry !== targetId);
|
||||
writeTask(paths, sourceTask);
|
||||
try {
|
||||
const targetTask = readTask(paths, targetId, { includeDeleted: true });
|
||||
targetTask.related = (targetTask.related || []).filter((entry) => entry !== sourceId);
|
||||
writeTask(paths, targetTask);
|
||||
} catch {
|
||||
// Ignore missing reverse link target.
|
||||
}
|
||||
return readTask(paths, sourceId, { includeDeleted: true });
|
||||
}
|
||||
|
||||
function buildTaskReference(task) {
|
||||
return `#${task.displayId || deriveDisplayId(task.id)} (taskId: ${task.id})`;
|
||||
}
|
||||
|
||||
function getTaskFreshness(task) {
|
||||
const updated = Date.parse(String(task.updatedAt || ''));
|
||||
if (Number.isFinite(updated) && updated > 0) return updated;
|
||||
const created = Date.parse(String(task.createdAt || ''));
|
||||
if (Number.isFinite(created) && created > 0) return created;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function compareTasksByFreshness(a, b) {
|
||||
const freshnessDiff = getTaskFreshness(b) - getTaskFreshness(a);
|
||||
if (freshnessDiff !== 0) return freshnessDiff;
|
||||
const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
if (byDisplay !== 0) return byDisplay;
|
||||
return String(a.id).localeCompare(String(b.id), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
}
|
||||
|
||||
function getEffectiveReviewState(kanbanEntry, task) {
|
||||
// Derive from historyEvents if available
|
||||
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
if (e.type === 'review_requested' || e.type === 'review_changes_requested' || e.type === 'review_approved') {
|
||||
return e.to;
|
||||
}
|
||||
if (e.type === 'status_changed' && e.to === 'in_progress') {
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
// Fallback to persisted reviewState or kanban
|
||||
if (normalizeTaskReviewState(task.reviewState) !== 'none') {
|
||||
return normalizeTaskReviewState(task.reviewState);
|
||||
}
|
||||
return kanbanEntry && kanbanEntry.column ? String(kanbanEntry.column) : 'none';
|
||||
}
|
||||
|
||||
function formatBriefTaskLine(task, reviewState) {
|
||||
const reviewSuffix = reviewState !== 'none' ? `, review=${reviewState}` : '';
|
||||
return `- #${task.displayId || deriveDisplayId(task.id)} [status=${task.status}${reviewSuffix}] ${task.subject}`;
|
||||
}
|
||||
|
||||
function formatCommentLine(comment) {
|
||||
const author =
|
||||
typeof comment.author === 'string' && comment.author.trim() ? comment.author.trim() : 'unknown';
|
||||
const text = typeof comment.text === 'string' ? comment.text.trim() : '';
|
||||
return ` - ${author}: ${text || '(empty comment)'}`;
|
||||
}
|
||||
|
||||
function formatTaskBriefing(paths, teamName, memberName) {
|
||||
const kanbanState = readJson(path.join(paths.teamDir, 'kanban-state.json'), {
|
||||
teamName,
|
||||
reviewers: [],
|
||||
tasks: {},
|
||||
});
|
||||
const activeTasks = listTasks(paths)
|
||||
.filter((task) => task.owner === memberName && task.status !== 'deleted')
|
||||
.sort(compareTasksByFreshness);
|
||||
|
||||
if (activeTasks.length === 0) {
|
||||
return `No assigned tasks for ${memberName}.`;
|
||||
}
|
||||
|
||||
const groups = {
|
||||
in_progress: activeTasks.filter((task) => task.status === 'in_progress'),
|
||||
needs_fix: activeTasks.filter((task) => {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
return task.status !== 'in_progress' && getEffectiveReviewState(kanbanEntry, task) === 'needsFix';
|
||||
}),
|
||||
pending: activeTasks.filter((task) => {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
return task.status === 'pending' && getEffectiveReviewState(kanbanEntry, task) === 'none';
|
||||
}),
|
||||
review: activeTasks.filter((task) => {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
return getEffectiveReviewState(kanbanEntry, task) === 'review';
|
||||
}),
|
||||
completed: activeTasks.filter((task) => {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
return task.status === 'completed' && getEffectiveReviewState(kanbanEntry, task) === 'none';
|
||||
}),
|
||||
approved: activeTasks.filter((task) => {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
return getEffectiveReviewState(kanbanEntry, task) === 'approved';
|
||||
}),
|
||||
};
|
||||
|
||||
const lines = [`Task briefing for ${memberName}:`];
|
||||
|
||||
if (groups.in_progress.length > 0) {
|
||||
lines.push('', 'In progress:');
|
||||
for (const task of groups.in_progress) {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
const reviewState = getEffectiveReviewState(kanbanEntry, task);
|
||||
lines.push(formatBriefTaskLine(task, reviewState));
|
||||
if (task.description) {
|
||||
lines.push(` Description: ${task.description}`);
|
||||
}
|
||||
if (Array.isArray(task.comments) && task.comments.length > 0) {
|
||||
lines.push(' Comments:');
|
||||
for (const comment of task.comments) {
|
||||
lines.push(formatCommentLine(comment));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (groups.needs_fix.length > 0) {
|
||||
lines.push('', 'Needs fixes after review:');
|
||||
for (const task of groups.needs_fix) {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
|
||||
}
|
||||
}
|
||||
|
||||
if (groups.pending.length > 0) {
|
||||
lines.push('', 'Pending:');
|
||||
for (const task of groups.pending) {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
|
||||
}
|
||||
}
|
||||
|
||||
if (groups.review.length > 0) {
|
||||
lines.push('', 'Review:');
|
||||
for (const task of groups.review) {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
|
||||
}
|
||||
}
|
||||
|
||||
if (groups.completed.length > 0) {
|
||||
lines.push('', 'Completed:');
|
||||
for (const task of groups.completed) {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
|
||||
}
|
||||
}
|
||||
|
||||
if (groups.approved.length > 0) {
|
||||
lines.push('', 'Approved (last 10):');
|
||||
for (const task of groups.approved.slice(0, 10)) {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
lines.push(formatBriefTaskLine(task, getEffectiveReviewState(kanbanEntry, task)));
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addCommentAttachmentMeta,
|
||||
addTaskAttachmentMeta,
|
||||
addTaskComment,
|
||||
appendHistoryEvent,
|
||||
buildTaskReference,
|
||||
createTask,
|
||||
deriveDisplayId,
|
||||
formatTaskBriefing,
|
||||
linkTask,
|
||||
listTasks,
|
||||
readTask,
|
||||
removeTaskAttachment,
|
||||
resolveTaskRef,
|
||||
setNeedsClarification,
|
||||
setTaskOwner,
|
||||
setTaskStatus,
|
||||
unlinkTask,
|
||||
updateTask,
|
||||
updateTaskFields,
|
||||
};
|
||||
320
agent-teams-controller/src/internal/tasks.js
Normal file
320
agent-teams-controller/src/internal/tasks.js
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
const taskStore = require('./taskStore.js');
|
||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||
const messages = require('./messages.js');
|
||||
const { wrapAgentBlock } = require('./agentBlocks.js');
|
||||
|
||||
function normalizeActorName(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isSameMember(left, right) {
|
||||
return normalizeActorName(left).toLowerCase() === normalizeActorName(right).toLowerCase();
|
||||
}
|
||||
|
||||
function isSameTaskMember(left, right, leadName) {
|
||||
const normalizedLeft = normalizeActorName(left).toLowerCase();
|
||||
const normalizedRight = normalizeActorName(right).toLowerCase();
|
||||
const normalizedLead = normalizeActorName(leadName).toLowerCase();
|
||||
if (!normalizedLeft || !normalizedRight) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedLeft === normalizedRight) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
(normalizedLeft === 'team-lead' && normalizedRight === normalizedLead) ||
|
||||
(normalizedRight === 'team-lead' && normalizedLeft === normalizedLead)
|
||||
);
|
||||
}
|
||||
|
||||
function buildAssignmentMessage(context, task, options = {}) {
|
||||
const description =
|
||||
typeof options.description === 'string' && options.description.trim()
|
||||
? options.description.trim()
|
||||
: typeof task.description === 'string' && task.description.trim()
|
||||
? task.description.trim()
|
||||
: '';
|
||||
const prompt =
|
||||
typeof options.prompt === 'string' && options.prompt.trim() ? options.prompt.trim() : '';
|
||||
const taskLabel = `#${task.displayId || task.id}`;
|
||||
const lines = [`New task assigned to you: ${taskLabel} "${task.subject}".`];
|
||||
|
||||
if (description) {
|
||||
lines.push(``, `Description:`, description);
|
||||
}
|
||||
|
||||
if (prompt) {
|
||||
lines.push(``, `Instructions:`, prompt);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
``,
|
||||
wrapAgentBlock(`Use the board MCP tools to work this task correctly:
|
||||
1. Check the latest full context before starting:
|
||||
task_get { teamName: "${context.teamName}", taskId: "${task.id}" }
|
||||
2. When you actually begin work, mark it started:
|
||||
task_start { teamName: "${context.teamName}", taskId: "${task.id}" }
|
||||
3. When the work is done, mark it completed:
|
||||
task_complete { teamName: "${context.teamName}", taskId: "${task.id}" }`)
|
||||
);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildCommentNotificationMessage(context, task, comment) {
|
||||
const taskLabel = `#${task.displayId || task.id}`;
|
||||
return [
|
||||
`Comment on task ${taskLabel} "${task.subject}":`,
|
||||
``,
|
||||
comment.text,
|
||||
``,
|
||||
wrapAgentBlock(`Reply to this comment using MCP tool task_add_comment:
|
||||
{ teamName: "${context.teamName}", taskId: "${task.id}", text: "<your reply>", from: "<your-name>" }`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function maybeNotifyAssignedOwner(context, task, options = {}) {
|
||||
const owner = normalizeActorName(task.owner);
|
||||
if (!owner || task.status === 'deleted') {
|
||||
return;
|
||||
}
|
||||
|
||||
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
||||
const sender = normalizeActorName(options.from) || leadName;
|
||||
const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
|
||||
if (isSameMember(owner, leadName) || isSameMember(owner, sender)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const summary = options.summary || `New task #${task.displayId || task.id} assigned`;
|
||||
messages.sendMessage(context, {
|
||||
member: owner,
|
||||
from: sender,
|
||||
text: buildAssignmentMessage(context, task, options),
|
||||
summary,
|
||||
source: 'system_notification',
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
|
||||
if (!options.inserted || options.notifyOwner === false) {
|
||||
return;
|
||||
}
|
||||
if (!task || task.status === 'deleted') {
|
||||
return;
|
||||
}
|
||||
if (comment.type && comment.type !== 'regular') {
|
||||
return;
|
||||
}
|
||||
|
||||
const owner = normalizeActorName(task.owner);
|
||||
if (!owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leadName = runtimeHelpers.inferLeadName(context.paths);
|
||||
if (isSameTaskMember(owner, comment.author, leadName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths);
|
||||
messages.sendMessage(context, {
|
||||
member: owner,
|
||||
from: normalizeActorName(comment.author) || leadName,
|
||||
text: buildCommentNotificationMessage(context, task, comment),
|
||||
summary: `Comment on #${task.displayId || task.id}`,
|
||||
source: 'system_notification',
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function createTask(context, input) {
|
||||
const task = taskStore.createTask(context.paths, input);
|
||||
if (input && input.notifyOwner !== false) {
|
||||
maybeNotifyAssignedOwner(context, task, {
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
from: input.from,
|
||||
});
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
function getTask(context, taskId) {
|
||||
return taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
}
|
||||
|
||||
function listTasks(context) {
|
||||
return taskStore.listTasks(context.paths);
|
||||
}
|
||||
|
||||
function listDeletedTasks(context) {
|
||||
return taskStore.listTasks(context.paths, { includeDeleted: true }).filter(
|
||||
(task) => task.status === 'deleted'
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTaskId(context, taskRef) {
|
||||
return taskStore.resolveTaskRef(context.paths, taskRef, { includeDeleted: true });
|
||||
}
|
||||
|
||||
function setTaskStatus(context, taskId, status, actor) {
|
||||
return taskStore.setTaskStatus(context.paths, taskId, status, actor);
|
||||
}
|
||||
|
||||
function startTask(context, taskId, actor) {
|
||||
const task = setTaskStatus(context, taskId, 'in_progress', actor);
|
||||
// Clear stale kanban entry (e.g. 'approved' or 'review') when task is reopened
|
||||
try {
|
||||
const kanbanStore = require('./kanbanStore.js');
|
||||
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||
if (state.tasks[task.id]) {
|
||||
delete state.tasks[task.id];
|
||||
kanbanStore.writeKanbanState(context.paths, context.teamName, state);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: task status already updated, kanban cleanup failure is non-fatal
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
function completeTask(context, taskId, actor) {
|
||||
return setTaskStatus(context, taskId, 'completed', actor);
|
||||
}
|
||||
|
||||
function softDeleteTask(context, taskId, actor) {
|
||||
return setTaskStatus(context, taskId, 'deleted', actor);
|
||||
}
|
||||
|
||||
function restoreTask(context, taskId, actor) {
|
||||
return setTaskStatus(context, taskId, 'pending', actor || 'user');
|
||||
}
|
||||
|
||||
function setTaskOwner(context, taskId, owner) {
|
||||
const previousTask = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
const updatedTask = taskStore.setTaskOwner(context.paths, taskId, owner);
|
||||
|
||||
if (
|
||||
owner != null &&
|
||||
normalizeActorName(updatedTask.owner) &&
|
||||
!isSameMember(previousTask.owner, updatedTask.owner)
|
||||
) {
|
||||
maybeNotifyAssignedOwner(context, updatedTask, {
|
||||
summary: `Task #${updatedTask.displayId || updatedTask.id} assigned`,
|
||||
});
|
||||
}
|
||||
|
||||
return updatedTask;
|
||||
}
|
||||
|
||||
function updateTaskFields(context, taskId, fields) {
|
||||
return taskStore.updateTaskFields(context.paths, taskId, fields);
|
||||
}
|
||||
|
||||
function addTaskComment(context, taskId, flags) {
|
||||
const result = taskStore.addTaskComment(context.paths, taskId, flags.text, {
|
||||
author:
|
||||
typeof flags.from === 'string' && flags.from.trim()
|
||||
? flags.from.trim()
|
||||
: runtimeHelpers.inferLeadName(context.paths),
|
||||
...(flags.id ? { id: flags.id } : {}),
|
||||
...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
|
||||
...(flags.type ? { type: flags.type } : {}),
|
||||
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, {
|
||||
inserted: result.inserted,
|
||||
notifyOwner: flags.notifyOwner,
|
||||
});
|
||||
} catch (notifyError) {
|
||||
// Best-effort: comment is already persisted, notification failure must not fail the call
|
||||
if (typeof console !== 'undefined' && console.warn) {
|
||||
console.warn(
|
||||
`[tasks] owner notification failed for task ${taskId}: ${String(notifyError)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
commentId: result.comment.id,
|
||||
taskId: result.task.id,
|
||||
subject: result.task.subject,
|
||||
owner: result.task.owner,
|
||||
task: result.task,
|
||||
comment: result.comment,
|
||||
};
|
||||
}
|
||||
|
||||
function attachTaskFile(context, taskId, flags) {
|
||||
const canonicalTaskId = resolveTaskId(context, taskId);
|
||||
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
||||
const task = taskStore.addTaskAttachmentMeta(context.paths, canonicalTaskId, saved.meta);
|
||||
return {
|
||||
...saved.meta,
|
||||
task,
|
||||
};
|
||||
}
|
||||
|
||||
function attachCommentFile(context, taskId, commentId, flags) {
|
||||
const canonicalTaskId = resolveTaskId(context, taskId);
|
||||
const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags);
|
||||
const task = taskStore.addCommentAttachmentMeta(context.paths, canonicalTaskId, commentId, saved.meta);
|
||||
return {
|
||||
...saved.meta,
|
||||
task,
|
||||
};
|
||||
}
|
||||
|
||||
function addTaskAttachmentMeta(context, taskId, meta) {
|
||||
return taskStore.addTaskAttachmentMeta(context.paths, taskId, meta);
|
||||
}
|
||||
|
||||
function removeTaskAttachment(context, taskId, attachmentId) {
|
||||
return taskStore.removeTaskAttachment(context.paths, taskId, attachmentId);
|
||||
}
|
||||
|
||||
function setNeedsClarification(context, taskId, value) {
|
||||
return taskStore.setNeedsClarification(context.paths, taskId, value == null ? 'clear' : String(value));
|
||||
}
|
||||
|
||||
function linkTask(context, taskId, targetId, linkType) {
|
||||
return taskStore.linkTask(context.paths, taskId, targetId, String(linkType));
|
||||
}
|
||||
|
||||
function unlinkTask(context, taskId, targetId, linkType) {
|
||||
return taskStore.unlinkTask(context.paths, taskId, targetId, String(linkType));
|
||||
}
|
||||
|
||||
async function taskBriefing(context, memberName) {
|
||||
return taskStore.formatTaskBriefing(context.paths, context.teamName, String(memberName));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addTaskAttachmentMeta,
|
||||
addTaskComment,
|
||||
appendHistoryEvent: taskStore.appendHistoryEvent,
|
||||
attachTaskFile,
|
||||
attachCommentFile,
|
||||
completeTask,
|
||||
createTask,
|
||||
getTask,
|
||||
linkTask,
|
||||
listDeletedTasks,
|
||||
listTasks,
|
||||
removeTaskAttachment,
|
||||
resolveTaskId,
|
||||
restoreTask,
|
||||
setNeedsClarification,
|
||||
setTaskOwner,
|
||||
setTaskStatus,
|
||||
softDeleteTask,
|
||||
startTask,
|
||||
taskBriefing,
|
||||
unlinkTask,
|
||||
updateTask: (context, taskRef, updater) =>
|
||||
taskStore.updateTask(context.paths, taskRef, updater),
|
||||
updateTaskFields,
|
||||
};
|
||||
58
agent-teams-controller/test/cascadeGuard.test.js
Normal file
58
agent-teams-controller/test/cascadeGuard.test.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
const cascadeGuard = require('../src/internal/cascadeGuard.js');
|
||||
|
||||
describe('cascadeGuard', () => {
|
||||
beforeEach(() => {
|
||||
cascadeGuard.reset();
|
||||
});
|
||||
|
||||
describe('rate limit', () => {
|
||||
it('allows up to 10 messages per minute', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
cascadeGuard.check('team-a', `team-${i}`, 0);
|
||||
cascadeGuard.record('team-a', `team-${i}`);
|
||||
}
|
||||
expect(() => cascadeGuard.check('team-a', 'team-x', 0)).toThrow('rate limit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chain depth', () => {
|
||||
it('allows depth 0 through 4', () => {
|
||||
for (let d = 0; d < 5; d++) {
|
||||
expect(() => cascadeGuard.check('team-a', 'team-b', d)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects depth >= 5', () => {
|
||||
expect(() => cascadeGuard.check('team-a', 'team-b', 5)).toThrow('chain depth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pair cooldown', () => {
|
||||
it('rejects same pair within 3s', () => {
|
||||
cascadeGuard.check('team-a', 'team-b', 0);
|
||||
cascadeGuard.record('team-a', 'team-b');
|
||||
|
||||
expect(() => cascadeGuard.check('team-a', 'team-b', 0)).toThrow('cooldown');
|
||||
});
|
||||
|
||||
it('allows different pairs simultaneously', () => {
|
||||
cascadeGuard.check('team-a', 'team-b', 0);
|
||||
cascadeGuard.record('team-a', 'team-b');
|
||||
|
||||
expect(() => cascadeGuard.check('team-a', 'team-c', 0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('clears all state', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
cascadeGuard.check('team-a', `team-${i}`, 0);
|
||||
cascadeGuard.record('team-a', `team-${i}`);
|
||||
}
|
||||
|
||||
cascadeGuard.reset();
|
||||
|
||||
expect(() => cascadeGuard.check('team-a', 'team-0', 0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
563
agent-teams-controller/test/controller.test.js
Normal file
563
agent-teams-controller/test/controller.test.js
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { createController } = require('../src/index.js');
|
||||
|
||||
describe('agent-teams-controller API', () => {
|
||||
function makeClaudeDir() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-controller-'));
|
||||
fs.mkdirSync(path.join(dir, 'teams', 'my-team'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'tasks', 'my-team'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'teams', 'my-team', 'config.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'my-team',
|
||||
leadSessionId: 'lead-session-1',
|
||||
members: [
|
||||
{ name: 'alice', role: 'team-lead' },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return dir;
|
||||
}
|
||||
|
||||
it('creates tasks and exposes grouped controller modules', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const base = controller.tasks.createTask({ subject: 'Base task' });
|
||||
const dependency = controller.tasks.createTask({ subject: 'Dependency task' });
|
||||
const created = controller.tasks.createTask({
|
||||
subject: 'Blocked task',
|
||||
owner: 'bob',
|
||||
'blocked-by': `${base.displayId},${dependency.displayId}`,
|
||||
related: base.displayId,
|
||||
});
|
||||
|
||||
expect(created.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
);
|
||||
expect(created.displayId).toHaveLength(8);
|
||||
expect(created.status).toBe('pending');
|
||||
expect(created.reviewState).toBe('none');
|
||||
expect(controller.tasks.getTask(base.id).blocks).toEqual([created.id]);
|
||||
expect(controller.tasks.getTask(created.displayId).blockedBy).toEqual([base.id, dependency.id]);
|
||||
|
||||
controller.kanban.addReviewer('alice');
|
||||
controller.tasks.completeTask(created.id, 'bob');
|
||||
controller.review.requestReview(created.id, { from: 'alice' });
|
||||
controller.review.approveReview(created.id, { 'notify-owner': true, from: 'alice' });
|
||||
|
||||
const kanbanState = controller.kanban.getKanbanState();
|
||||
expect(kanbanState.reviewers).toEqual(['alice']);
|
||||
expect(kanbanState.tasks[created.id].column).toBe('approved');
|
||||
expect(controller.tasks.getTask(created.id).reviewState).toBe('approved');
|
||||
|
||||
const sent = controller.messages.appendSentMessage({
|
||||
from: 'team-lead',
|
||||
to: 'user',
|
||||
text: 'All good',
|
||||
leadSessionId: 'session-1',
|
||||
source: 'lead_process',
|
||||
attachments: [{ id: 'a1', filename: 'diff.txt', mimeType: 'text/plain', size: 12 }],
|
||||
});
|
||||
expect(sent.leadSessionId).toBe('session-1');
|
||||
|
||||
const ownerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
||||
expect(ownerInbox.at(-1).summary).toContain('Approved');
|
||||
expect(ownerInbox.at(-1).leadSessionId).toBe('lead-session-1');
|
||||
|
||||
const proc = controller.processes.registerProcess({
|
||||
pid: process.pid,
|
||||
label: 'dev-server',
|
||||
port: '3000',
|
||||
});
|
||||
expect(proc.port).toBe(3000);
|
||||
expect(controller.processes.listProcesses()).toHaveLength(1);
|
||||
const stopped = controller.processes.stopProcess({ pid: process.pid });
|
||||
expect(typeof stopped.stoppedAt).toBe('string');
|
||||
});
|
||||
|
||||
it('creates a fresh registry entry when an old pid was recycled without stoppedAt', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const processesPath = path.join(claudeDir, 'teams', 'my-team', 'processes.json');
|
||||
|
||||
fs.writeFileSync(
|
||||
processesPath,
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
id: 'old-entry',
|
||||
pid: 999999,
|
||||
label: 'stale',
|
||||
registeredAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const registered = controller.processes.registerProcess({
|
||||
pid: 999999,
|
||||
label: 'fresh',
|
||||
});
|
||||
|
||||
expect(registered.id).not.toBe('old-entry');
|
||||
const rows = JSON.parse(fs.readFileSync(processesPath, 'utf8'));
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].stoppedAt).toBeTruthy();
|
||||
expect(rows[1].id).toBe(registered.id);
|
||||
});
|
||||
|
||||
it('keeps assigned tasks pending by default, supports explicit immediate start, notifies owners, and groups briefing by review-aware sections', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const pendingTask = controller.tasks.createTask({
|
||||
subject: 'Queued task',
|
||||
description: 'Do this later',
|
||||
owner: 'bob',
|
||||
prompt: 'Check the migration plan first.',
|
||||
});
|
||||
const activeTask = controller.tasks.createTask({
|
||||
subject: 'Active task',
|
||||
description: 'Resume immediately',
|
||||
owner: 'bob',
|
||||
startImmediately: true,
|
||||
});
|
||||
const completedTask = controller.tasks.createTask({
|
||||
subject: 'Already done',
|
||||
description: 'Completed task description should stay out of compact rows',
|
||||
owner: 'bob',
|
||||
});
|
||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||
controller.tasks.addTaskComment(activeTask.id, { from: 'bob', text: 'Resumed work with latest context.' });
|
||||
const needsFixTask = controller.tasks.createTask({
|
||||
subject: 'Fix after review',
|
||||
owner: 'bob',
|
||||
status: 'pending',
|
||||
reviewState: 'needsFix',
|
||||
createdAt: '2026-01-02T00:00:00.000Z',
|
||||
notifyOwner: false,
|
||||
});
|
||||
const reviewTask = controller.tasks.createTask({
|
||||
subject: 'Waiting for review',
|
||||
owner: 'bob',
|
||||
status: 'completed',
|
||||
reviewState: 'review',
|
||||
createdAt: '2026-01-03T00:00:00.000Z',
|
||||
notifyOwner: false,
|
||||
});
|
||||
const approvedTask = controller.tasks.createTask({
|
||||
subject: 'Approved work',
|
||||
owner: 'bob',
|
||||
status: 'completed',
|
||||
reviewState: 'approved',
|
||||
createdAt: '2026-01-04T00:00:00.000Z',
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
const reassignedTask = controller.tasks.createTask({ subject: 'Reassigned later' });
|
||||
controller.tasks.setTaskOwner(reassignedTask.id, 'bob');
|
||||
|
||||
expect(pendingTask.status).toBe('pending');
|
||||
expect(activeTask.status).toBe('in_progress');
|
||||
|
||||
const ownerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
||||
expect(ownerInbox).toHaveLength(4);
|
||||
expect(ownerInbox[0].summary).toContain(`#${pendingTask.displayId}`);
|
||||
expect(ownerInbox[0].text).toContain('task_get');
|
||||
expect(ownerInbox[0].text).toContain('task_start');
|
||||
expect(ownerInbox[0].leadSessionId).toBe('lead-session-1');
|
||||
expect(ownerInbox[3].summary).toContain(`#${reassignedTask.displayId}`);
|
||||
|
||||
const briefing = await controller.tasks.taskBriefing('bob');
|
||||
expect(briefing).toContain('In progress:');
|
||||
expect(briefing).toContain(`#${activeTask.displayId}`);
|
||||
expect(briefing).toContain('Description: Resume immediately');
|
||||
expect(briefing).toContain('Resumed work with latest context.');
|
||||
expect(briefing).toContain('Needs fixes after review:');
|
||||
expect(briefing).toContain(`#${needsFixTask.displayId}`);
|
||||
expect(briefing).toContain('Pending:');
|
||||
expect(briefing).toContain(`#${pendingTask.displayId}`);
|
||||
expect(briefing).not.toContain('Description: Do this later');
|
||||
expect(briefing).toContain('Review:');
|
||||
expect(briefing).toContain(`#${reviewTask.displayId}`);
|
||||
expect(briefing).toContain('Completed:');
|
||||
expect(briefing).toContain(`#${completedTask.displayId}`);
|
||||
expect(briefing).not.toContain(
|
||||
'Completed task description should stay out of compact rows'
|
||||
);
|
||||
expect(briefing).toContain('Approved (last 10):');
|
||||
expect(briefing).toContain(`#${approvedTask.displayId}`);
|
||||
});
|
||||
|
||||
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Ship migration',
|
||||
owner: 'bob',
|
||||
});
|
||||
|
||||
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
|
||||
fs.writeFileSync(
|
||||
kanbanPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
teamName: 'my-team',
|
||||
reviewers: [],
|
||||
tasks: {
|
||||
[task.id]: { column: 'review', movedAt: '2026-01-01T00:00:00.000Z', reviewer: null },
|
||||
staleTask: { column: 'approved', movedAt: '2026-01-01T00:00:00.000Z' },
|
||||
},
|
||||
columnOrder: {
|
||||
review: [task.id, 'staleTask'],
|
||||
approved: ['staleTask'],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(inboxDir, 'bob.json'),
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
from: 'alice',
|
||||
to: 'bob',
|
||||
summary: `Please revisit #${task.displayId}`,
|
||||
messageId: 'm-1',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: false,
|
||||
text: 'Need one more verification pass.',
|
||||
},
|
||||
{
|
||||
from: 'team-lead',
|
||||
to: 'bob',
|
||||
summary: `Comment on #${task.displayId}`,
|
||||
messageId: 'm-2',
|
||||
timestamp: '2026-02-23T11:00:00.000Z',
|
||||
read: false,
|
||||
text:
|
||||
`Comment on task #${task.displayId} "Ship migration":\n\nHeads up\n\n` +
|
||||
'<agent-block>\nReply to this comment using:\nnode "tool.js" --team my-team task comment 1 --text "..." --from "bob"\n</agent-block>',
|
||||
},
|
||||
],
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const first = controller.maintenance.reconcileArtifacts({ reason: 'manual' });
|
||||
expect(first.staleKanbanEntriesRemoved).toBe(1);
|
||||
expect(first.staleColumnOrderRefsRemoved).toBe(2);
|
||||
expect(first.linkedCommentsCreated).toBe(1);
|
||||
|
||||
const reloaded = controller.tasks.getTask(task.id);
|
||||
expect(reloaded.comments).toHaveLength(1);
|
||||
expect(reloaded.comments[0].id).toBe('msg-m-1');
|
||||
expect(reloaded.comments[0].text).toBe('Need one more verification pass.');
|
||||
|
||||
const cleanedKanban = JSON.parse(fs.readFileSync(kanbanPath, 'utf8'));
|
||||
expect(cleanedKanban.tasks.staleTask).toBeUndefined();
|
||||
expect(cleanedKanban.columnOrder.review).toEqual([task.id]);
|
||||
expect(cleanedKanban.columnOrder.approved).toBeUndefined();
|
||||
|
||||
const second = controller.maintenance.reconcileArtifacts({ reason: 'manual' });
|
||||
expect(second.staleKanbanEntriesRemoved).toBe(0);
|
||||
expect(second.staleColumnOrderRefsRemoved).toBe(0);
|
||||
expect(second.linkedCommentsCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('tracks lifecycle history and intervals without duplicate same-status transitions', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Lifecycle task' });
|
||||
|
||||
expect(task.status).toBe('pending');
|
||||
expect(task.historyEvents).toHaveLength(1);
|
||||
expect(task.workIntervals).toBeUndefined();
|
||||
|
||||
const started = controller.tasks.startTask(task.id, 'bob');
|
||||
const startedAgain = controller.tasks.startTask(task.id, 'bob');
|
||||
const completed = controller.tasks.completeTask(task.id, 'bob');
|
||||
const completedAgain = controller.tasks.completeTask(task.id, 'bob');
|
||||
const deleted = controller.tasks.softDeleteTask(task.id, 'bob');
|
||||
const restored = controller.tasks.restoreTask(task.id, 'bob');
|
||||
|
||||
expect(started.status).toBe('in_progress');
|
||||
expect(startedAgain.historyEvents).toHaveLength(2);
|
||||
expect(startedAgain.workIntervals).toHaveLength(1);
|
||||
expect(startedAgain.workIntervals[0].startedAt).toBeTruthy();
|
||||
|
||||
expect(completed.status).toBe('completed');
|
||||
expect(completedAgain.historyEvents).toHaveLength(3);
|
||||
expect(completedAgain.workIntervals).toHaveLength(1);
|
||||
expect(completedAgain.workIntervals[0].completedAt).toBeTruthy();
|
||||
|
||||
expect(deleted.status).toBe('deleted');
|
||||
expect(deleted.deletedAt).toBeTruthy();
|
||||
expect(restored.status).toBe('pending');
|
||||
expect(restored.deletedAt).toBeUndefined();
|
||||
expect(restored.historyEvents).toHaveLength(5);
|
||||
|
||||
// Verify the event sequence: task_created, then 4 status_changed events
|
||||
const types = restored.historyEvents.map((e) => e.type);
|
||||
expect(types).toEqual([
|
||||
'task_created',
|
||||
'status_changed',
|
||||
'status_changed',
|
||||
'status_changed',
|
||||
'status_changed',
|
||||
]);
|
||||
|
||||
// Verify the status flow: pending -> in_progress -> completed -> deleted -> pending
|
||||
const firstEvent = restored.historyEvents[0];
|
||||
expect(firstEvent.status).toBe('pending');
|
||||
const statusChanges = restored.historyEvents.slice(1).map((e) => e.to);
|
||||
expect(statusChanges).toEqual([
|
||||
'in_progress',
|
||||
'completed',
|
||||
'deleted',
|
||||
'pending',
|
||||
]);
|
||||
});
|
||||
|
||||
it('wraps review instructions in the canonical agent block format used by the UI', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' });
|
||||
|
||||
controller.kanban.addReviewer('alice');
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'team-lead' });
|
||||
|
||||
const reviewerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
|
||||
const inbox = JSON.parse(fs.readFileSync(reviewerInboxPath, 'utf8'));
|
||||
|
||||
expect(inbox).toHaveLength(1);
|
||||
expect(inbox[0].text).toContain('<info_for_agent>');
|
||||
expect(inbox[0].text).toContain('review_approve');
|
||||
expect(inbox[0].text).not.toContain('<agent-block>');
|
||||
expect(inbox[0].leadSessionId).toBe('lead-session-1');
|
||||
});
|
||||
|
||||
it('persists full inbox metadata through controller messages.sendMessage', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const sent = controller.messages.sendMessage({
|
||||
to: 'bob',
|
||||
from: 'team-lead',
|
||||
text: 'Need your review',
|
||||
summary: 'Review request',
|
||||
source: 'system_notification',
|
||||
leadSessionId: 'session-42',
|
||||
attachments: [{ id: 'a1', filename: 'note.txt', mimeType: 'text/plain', size: 7 }],
|
||||
});
|
||||
|
||||
expect(sent.deliveredToInbox).toBe(true);
|
||||
expect(sent.messageId).toBeTruthy();
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].source).toBe('system_notification');
|
||||
expect(rows[0].leadSessionId).toBe('session-42');
|
||||
expect(rows[0].attachments[0].filename).toBe('note.txt');
|
||||
});
|
||||
|
||||
it('wakes task owner on regular comment from another member', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Investigate', owner: 'bob', notifyOwner: false });
|
||||
|
||||
const commented = controller.tasks.addTaskComment(task.id, {
|
||||
from: 'alice',
|
||||
text: 'I found the root cause.',
|
||||
});
|
||||
|
||||
expect(commented.task.comments.at(-1).text).toBe('I found the root cause.');
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].summary).toContain(`#${task.displayId}`);
|
||||
expect(rows[0].text).toContain('I found the root cause.');
|
||||
expect(rows[0].leadSessionId).toBe('lead-session-1');
|
||||
});
|
||||
|
||||
it('does not wake owner for self-comments and clears user clarification when user replies', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Need product input',
|
||||
owner: 'bob',
|
||||
needsClarification: 'user',
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
controller.tasks.addTaskComment(task.id, {
|
||||
from: 'bob',
|
||||
text: 'Starting to investigate.',
|
||||
});
|
||||
|
||||
const ownerInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||
expect(fs.existsSync(ownerInboxPath)).toBe(false);
|
||||
|
||||
const replied = controller.tasks.addTaskComment(task.id, {
|
||||
from: 'user',
|
||||
text: 'Please use the safer option.',
|
||||
});
|
||||
|
||||
expect(replied.task.needsClarification).toBeUndefined();
|
||||
const reloaded = controller.tasks.getTask(task.id);
|
||||
expect(reloaded.needsClarification).toBeUndefined();
|
||||
const rows = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].text).toContain('Please use the safer option.');
|
||||
});
|
||||
|
||||
it('wakes lead owner on comment from another member', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Lead-owned task',
|
||||
owner: 'team-lead',
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
controller.tasks.addTaskComment(task.id, {
|
||||
from: 'alice',
|
||||
text: 'Need your decision here.',
|
||||
});
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json');
|
||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].from).toBe('alice');
|
||||
expect(rows[0].text).toContain('Need your decision here.');
|
||||
});
|
||||
|
||||
it('moves review back to pending+needsFix and notifies owner on requestChanges', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Needs revision', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||
const updated = controller.review.requestChanges(task.id, {
|
||||
from: 'alice',
|
||||
comment: 'Please address review feedback.',
|
||||
});
|
||||
|
||||
expect(updated.status).toBe('pending');
|
||||
expect(updated.reviewState).toBe('needsFix');
|
||||
expect(updated.comments.at(-1).type).toBe('review_request');
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json');
|
||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows.at(-1).source).toBe('system_notification');
|
||||
expect(rows.at(-1).summary).toContain('Fix request');
|
||||
expect(rows.at(-1).text).toContain('moved back to pending');
|
||||
expect(rows.at(-1).text).toContain('request review again');
|
||||
expect(rows.at(-1).leadSessionId).toBe('lead-session-1');
|
||||
});
|
||||
|
||||
it('limits approved briefing section to the latest 10 tasks by freshness', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const approvedTasks = Array.from({ length: 12 }, (_, index) =>
|
||||
controller.tasks.createTask({
|
||||
subject: `Approved ${index + 1}`,
|
||||
owner: 'bob',
|
||||
status: 'completed',
|
||||
reviewState: 'approved',
|
||||
createdAt: `2026-01-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`,
|
||||
})
|
||||
);
|
||||
|
||||
const briefing = await controller.tasks.taskBriefing('bob');
|
||||
expect(briefing).toContain('Approved (last 10):');
|
||||
expect(briefing).toContain(`#${approvedTasks[11].displayId}`);
|
||||
expect(briefing).toContain(`#${approvedTasks[2].displayId}`);
|
||||
expect(briefing).not.toContain(`#${approvedTasks[1].displayId}`);
|
||||
expect(briefing).not.toContain(`#${approvedTasks[0].displayId}`);
|
||||
});
|
||||
|
||||
it('marks stale processes stopped during listing and supports unregister', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const processesPath = path.join(claudeDir, 'teams', 'my-team', 'processes.json');
|
||||
|
||||
fs.writeFileSync(
|
||||
processesPath,
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
id: 'stale-entry',
|
||||
pid: 999999,
|
||||
label: 'stale',
|
||||
registeredAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const listed = controller.processes.listProcesses();
|
||||
expect(listed).toHaveLength(1);
|
||||
expect(listed[0].alive).toBe(false);
|
||||
expect(listed[0].stoppedAt).toBeTruthy();
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(processesPath, 'utf8'));
|
||||
expect(persisted[0].stoppedAt).toBeTruthy();
|
||||
|
||||
controller.processes.unregisterProcess({ id: 'stale-entry' });
|
||||
expect(controller.processes.listProcesses()).toEqual([]);
|
||||
});
|
||||
|
||||
it('task_add_comment succeeds even when owner notification write fails', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Comment resilience',
|
||||
owner: 'bob',
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
// Make inboxes directory read-only to force notification write failure
|
||||
const inboxDir = path.join(claudeDir, 'teams', 'my-team', 'inboxes');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
// Write a broken file that will cause JSON parse failure on append
|
||||
fs.writeFileSync(path.join(inboxDir, 'bob.json'), 'NOT VALID JSON');
|
||||
|
||||
// Comment should still succeed despite notification failure
|
||||
const commented = controller.tasks.addTaskComment(task.id, {
|
||||
from: 'alice',
|
||||
text: 'This should persist despite notification failure.',
|
||||
});
|
||||
|
||||
expect(commented.commentId).toBeTruthy();
|
||||
expect(commented.task.comments).toHaveLength(1);
|
||||
expect(commented.task.comments[0].text).toBe(
|
||||
'This should persist despite notification failure.'
|
||||
);
|
||||
});
|
||||
});
|
||||
391
agent-teams-controller/test/crossTeam.test.js
Normal file
391
agent-teams-controller/test/crossTeam.test.js
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { createController } = require('../src/index.js');
|
||||
const { CROSS_TEAM_SOURCE, CROSS_TEAM_PREFIX_TAG } = require('../src/internal/crossTeamProtocol.js');
|
||||
|
||||
describe('crossTeam module', () => {
|
||||
function makeClaudeDir(teams = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crossteam-test-'));
|
||||
|
||||
for (const [teamName, config] of Object.entries(teams)) {
|
||||
const teamDir = path.join(dir, 'teams', teamName);
|
||||
const taskDir = path.join(dir, 'tasks', teamName);
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.mkdirSync(taskDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(teamDir, 'inboxes'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify(config, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// Reset cascade guard between tests
|
||||
const cascadeGuard = require('../src/internal/cascadeGuard.js');
|
||||
cascadeGuard.reset();
|
||||
});
|
||||
|
||||
describe('sendCrossTeamMessage', () => {
|
||||
it('delivers message to target team inbox', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
const result = controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
fromMember: 'lead',
|
||||
text: 'Hello from team-a',
|
||||
summary: 'Test message',
|
||||
});
|
||||
|
||||
expect(result.deliveredToInbox).toBe(true);
|
||||
expect(result.messageId).toBeDefined();
|
||||
|
||||
// Verify inbox was written
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
|
||||
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(inbox).toHaveLength(1);
|
||||
expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE);
|
||||
expect(inbox[0].from).toBe('team-a.lead');
|
||||
expect(inbox[0].text).toContain(`[${CROSS_TEAM_PREFIX_TAG} team-a.lead | depth:0`);
|
||||
expect(inbox[0].conversationId).toBeTruthy();
|
||||
expect(inbox[0].text).toContain(`conversation:${inbox[0].conversationId}`);
|
||||
});
|
||||
|
||||
it('records outbox entry', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
const outbox = controller.crossTeam.getCrossTeamOutbox();
|
||||
expect(outbox).toHaveLength(1);
|
||||
expect(outbox[0].toTeam).toBe('team-b');
|
||||
expect(outbox[0].conversationId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('preserves reply conversation metadata for explicit replies', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Answering the open question',
|
||||
replyToConversationId: 'conv-123',
|
||||
});
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
|
||||
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(inbox[0].conversationId).toBe('conv-123');
|
||||
expect(inbox[0].replyToConversationId).toBe('conv-123');
|
||||
expect(inbox[0].text).toContain('conversation:conv-123');
|
||||
expect(inbox[0].text).toContain('replyTo:conv-123');
|
||||
});
|
||||
|
||||
it('deduplicates the same recent cross-team request', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
const first = controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
fromMember: 'lead',
|
||||
text: 'Please review the API contract',
|
||||
summary: 'Review request',
|
||||
});
|
||||
const second = controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
fromMember: 'lead',
|
||||
text: 'Please review the API contract',
|
||||
summary: ' Review request ',
|
||||
});
|
||||
|
||||
expect(second.deliveredToInbox).toBe(true);
|
||||
expect(second.deduplicated).toBe(true);
|
||||
expect(second.messageId).toBe(first.messageId);
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
|
||||
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(inbox).toHaveLength(1);
|
||||
|
||||
const outbox = controller.crossTeam.getCrossTeamOutbox();
|
||||
expect(outbox).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('allows resending after dedupe window expires', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
const originalNow = Date.now;
|
||||
let now = originalNow();
|
||||
Date.now = () => now;
|
||||
try {
|
||||
const first = controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Need a decision on the schema',
|
||||
summary: 'Schema decision',
|
||||
});
|
||||
|
||||
now += 6 * 60 * 1000;
|
||||
|
||||
const second = controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Need a decision on the schema',
|
||||
summary: 'Schema decision',
|
||||
});
|
||||
|
||||
expect(second.deduplicated).toBeUndefined();
|
||||
expect(second.messageId).not.toBe(first.messageId);
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
|
||||
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(inbox).toHaveLength(2);
|
||||
|
||||
const updatedOutbox = controller.crossTeam.getCrossTeamOutbox();
|
||||
expect(updatedOutbox).toHaveLength(2);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects self-send', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
expect(() =>
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-a',
|
||||
text: 'Self',
|
||||
})
|
||||
).toThrow('same team');
|
||||
});
|
||||
|
||||
it('rejects when target not found', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
expect(() =>
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-nonexistent',
|
||||
text: 'Hello',
|
||||
})
|
||||
).toThrow('Target team not found');
|
||||
});
|
||||
|
||||
it('rejects when target is deleted', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
deletedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
expect(() =>
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
})
|
||||
).toThrow('Target team not found');
|
||||
});
|
||||
|
||||
it('rejects excessive chain depth', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
expect(() =>
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
chainDepth: 5,
|
||||
})
|
||||
).toThrow('chain depth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTargetLead', () => {
|
||||
it('resolves lead by agentType', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'alpha-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'beta-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'beta-lead.json');
|
||||
expect(fs.existsSync(inboxPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves lead by name fallback', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
|
||||
expect(fs.existsSync(inboxPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves lead from members.meta.json with normalization', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Write meta with dirty data (leading spaces, duplicates)
|
||||
const metaPath = path.join(claudeDir, 'teams', 'team-b', 'members.meta.json');
|
||||
fs.writeFileSync(
|
||||
metaPath,
|
||||
JSON.stringify({
|
||||
members: [
|
||||
{ name: ' meta-lead ', agentType: 'team-lead' },
|
||||
{ name: ' meta-lead ', agentType: 'team-lead' },
|
||||
{ name: 'worker', agentType: 'worker' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'meta-lead.json');
|
||||
expect(fs.existsSync(inboxPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listCrossTeamTargets', () => {
|
||||
it('lists valid teams excluding current', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': { name: 'Team A' },
|
||||
'team-b': { name: 'Team B', description: 'B desc' },
|
||||
'team-c': { name: 'Team C', deletedAt: '2024-01-01' },
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
const targets = controller.crossTeam.listCrossTeamTargets();
|
||||
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0].teamName).toBe('team-b');
|
||||
expect(targets[0].displayName).toBe('Team B');
|
||||
expect(targets[0].description).toBe('B desc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCrossTeamOutbox', () => {
|
||||
it('returns empty for non-existent outbox', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': { name: 'Team A' },
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
const outbox = controller.crossTeam.getCrossTeamOutbox();
|
||||
expect(outbox).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
10
agent-teams-controller/vitest.config.js
Normal file
10
agent-teams-controller/vitest.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
const { defineConfig } = require('vitest/config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.js'],
|
||||
testTimeout: 15_000,
|
||||
},
|
||||
});
|
||||
307
docs/extensions/adr-001-contract-spike.md
Normal file
307
docs/extensions/adr-001-contract-spike.md
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
# ADR-001: Extension Store Contract Spike
|
||||
|
||||
**Date**: 2026-03-07
|
||||
**Status**: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Extension Store нуждается в точных внешних контрактах перед написанием парсеров. Этот ADR фиксирует результаты contract spike.
|
||||
|
||||
---
|
||||
|
||||
## 1. Plugin CLI Contracts
|
||||
|
||||
### Verified CLI Flags
|
||||
|
||||
| Command | Syntax | Default scope |
|
||||
|---------|--------|---------------|
|
||||
| install | `claude plugin install [-s scope] <plugin>` | `user` |
|
||||
| uninstall | `claude plugin uninstall [-s scope] <plugin>` | `user` |
|
||||
| list | `claude plugin list [--json] [--available]` | — |
|
||||
| enable | `claude plugin enable [-s scope] <plugin>` | — |
|
||||
| disable | `claude plugin disable [plugin]` | — |
|
||||
|
||||
**Scope flag**: `-s, --scope <scope>` — values: `user`, `project`, `local`
|
||||
|
||||
**qualifiedName format**: `<name>@<marketplace>` (e.g. `context7@claude-plugins-official`)
|
||||
|
||||
### Installed State — Source of Truth
|
||||
|
||||
**File**: `~/.claude/plugins/installed_plugins.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"plugins": {
|
||||
"<qualifiedName>": [
|
||||
{
|
||||
"scope": "user",
|
||||
"installPath": "/Users/.../.claude/plugins/cache/<marketplace>/<name>/<version>",
|
||||
"version": "1.0.0",
|
||||
"installedAt": "2026-03-01T11:14:21.926Z",
|
||||
"lastUpdated": "2026-03-01T11:14:21.926Z",
|
||||
"gitCommitSha": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Key = `qualifiedName` = `<pluginName>@<marketplaceName>`
|
||||
- Value = array (one entry per scope installation)
|
||||
- `scope`: `"user"` | `"project"` | `"local"`
|
||||
- **pluginId** for V1 = `qualifiedName` (globally unique)
|
||||
|
||||
### Install Counts
|
||||
|
||||
**File**: `~/.claude/plugins/install-counts-cache.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"<pluginName>": <count> // NOT qualifiedName, just name
|
||||
}
|
||||
```
|
||||
|
||||
- Key = plugin `name` (without marketplace suffix)
|
||||
- 157 entries in current cache
|
||||
|
||||
### Marketplaces
|
||||
|
||||
**File**: `~/.claude/plugins/known_marketplaces.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"<marketplace-name>": {
|
||||
"source": { "source": "github", "repo": "<owner>/<repo>" },
|
||||
"installLocation": "...",
|
||||
"lastUpdated": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- V1: we read only `claude-plugins-official` marketplace
|
||||
- Marketplace manifest: `raw.githubusercontent.com/<owner>/<repo>/main/.claude-plugin/marketplace.json`
|
||||
- Supports ETag/If-None-Match → 304 Not Modified
|
||||
|
||||
### `claude plugin list --json`
|
||||
|
||||
- Supports `--json` flag
|
||||
- Supports `--available` flag (requires `--json`)
|
||||
- Output format: TBD (не тестировали мутирующе, но флаг существует)
|
||||
|
||||
---
|
||||
|
||||
## 2. MCP CLI Contracts
|
||||
|
||||
### Verified CLI Flags
|
||||
|
||||
| Command | Syntax |
|
||||
|---------|--------|
|
||||
| add (stdio) | `claude mcp add [-s scope] [-e KEY=val...] <name> -- <command> [args...]` |
|
||||
| add (http) | `claude mcp add [-s scope] -t http [-H "Header: val"...] <name> <url>` |
|
||||
| add (sse) | `claude mcp add [-s scope] -t sse [-H "Header: val"...] <name> <url>` |
|
||||
| remove | `claude mcp remove [-s scope] <name>` |
|
||||
| list | `claude mcp list` |
|
||||
| get | `claude mcp get <name>` |
|
||||
|
||||
**Scope flag**: `-s, --scope <scope>` — values: `local` (default), `user`, `project`
|
||||
|
||||
**Transport flag**: `-t, --transport <transport>` — values: `stdio` (default), `sse`, `http`
|
||||
|
||||
**Env flag**: `-e, --env <env...>` — format: `KEY=value`
|
||||
|
||||
**Header flag**: `-H, --header <header...>` — format: `"Key: value"` — YES, SUPPORTED!
|
||||
|
||||
**OAuth**: `--client-id`, `--client-secret`, `--callback-port` — not needed for V1
|
||||
|
||||
### Installed State — Source of Truth
|
||||
|
||||
**User scope**: `~/.claude.json` → `mcpServers` key
|
||||
**Project scope**: `.mcp.json` in project root
|
||||
**Local scope**: определяется в Phase 0 (likely `~/.claude.json`)
|
||||
|
||||
---
|
||||
|
||||
## 3. Official MCP Registry API
|
||||
|
||||
**Base URL**: `https://registry.modelcontextprotocol.io/v0.1/servers`
|
||||
|
||||
**Pagination**: cursor-based via `metadata.nextCursor`
|
||||
|
||||
**Query params**:
|
||||
- `limit=N` — items per page
|
||||
- `search=<query>` — text search
|
||||
- `cursor=<nextCursor>` — pagination
|
||||
|
||||
**Response structure**:
|
||||
```json
|
||||
{
|
||||
"servers": [
|
||||
{
|
||||
"server": {
|
||||
"name": "io.github.upstash/context7", // reverse-DNS ID
|
||||
"description": "...",
|
||||
"title": "Context7", // display name (optional)
|
||||
"version": "1.0.30",
|
||||
"repository": { "url": "...", "source": "github" },
|
||||
"packages": [{ // npm install info (optional)
|
||||
"registryType": "npm",
|
||||
"identifier": "@upstash/context7-mcp",
|
||||
"version": "1.0.30",
|
||||
"transport": { "type": "stdio" },
|
||||
"environmentVariables": [{
|
||||
"name": "CONTEXT7_API_KEY",
|
||||
"description": "...",
|
||||
"isSecret": true,
|
||||
"format": "string"
|
||||
}]
|
||||
}],
|
||||
"remotes": [{ // HTTP/SSE install info (optional)
|
||||
"type": "streamable-http",
|
||||
"url": "https://...",
|
||||
"headers": [{ // auth headers (optional)
|
||||
"name": "Authorization",
|
||||
"description": "...",
|
||||
"isRequired": true,
|
||||
"isSecret": true,
|
||||
"value": "Bearer {key}" // template (optional)
|
||||
}]
|
||||
}]
|
||||
},
|
||||
"_meta": {
|
||||
"io.modelcontextprotocol.registry/official": {
|
||||
"status": "active",
|
||||
"isLatest": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"nextCursor": "...",
|
||||
"count": N
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key fields for install**:
|
||||
- `packages[0].identifier` → npm package name
|
||||
- `packages[0].version` → npm version
|
||||
- `packages[0].transport.type` → `"stdio"`
|
||||
- `packages[0].environmentVariables` → env var definitions
|
||||
- `remotes[0].type` → `"streamable-http"` | `"sse"`
|
||||
- `remotes[0].url` → HTTP endpoint
|
||||
- `remotes[0].headers` → auth headers (with `isSecret`, `isRequired`)
|
||||
|
||||
**Version handling**: Multiple versions of same server returned separately. Use `_meta.isLatest: true` to pick latest.
|
||||
|
||||
**Auth**: No authentication required.
|
||||
|
||||
---
|
||||
|
||||
## 4. Glama API
|
||||
|
||||
**Base URL**: `https://glama.ai/api/mcp/v1/servers`
|
||||
|
||||
**Pagination**: cursor-based via `pageInfo.endCursor`
|
||||
|
||||
**Query params**:
|
||||
- `first=N` — items per page
|
||||
- `search=<query>` — text search
|
||||
- `after=<cursor>` — pagination cursor
|
||||
|
||||
**Response structure**:
|
||||
```json
|
||||
{
|
||||
"pageInfo": {
|
||||
"endCursor": "...",
|
||||
"hasNextPage": true,
|
||||
"hasPreviousPage": false,
|
||||
"startCursor": "..."
|
||||
},
|
||||
"servers": [{
|
||||
"id": "iu27vfrji2",
|
||||
"name": "clelp-mcp-server",
|
||||
"namespace": "oscarsterling",
|
||||
"description": "...",
|
||||
"slug": "clelp-mcp-server",
|
||||
"url": "https://glama.ai/mcp/servers/iu27vfrji2",
|
||||
"repository": { "url": "https://github.com/..." },
|
||||
"spdxLicense": { "name": "MIT License", "url": "..." },
|
||||
"tools": [],
|
||||
"attributes": [],
|
||||
"environmentVariablesJsonSchema": null
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Key differences from Official Registry**:
|
||||
- NO install info (no `packages`, no `remotes`) → can't auto-install Glama-only servers
|
||||
- Has `spdxLicense` → enrichment data
|
||||
- Has `tools[]` → enrichment data
|
||||
- Has `url` → link to Glama page
|
||||
- Pagination: `after` param (not `cursor`)
|
||||
|
||||
**Auth**: No authentication required.
|
||||
|
||||
---
|
||||
|
||||
## 5. Marketplace JSON Schema
|
||||
|
||||
**URL**: `https://raw.githubusercontent.com/anthropics/claude-plugins-official/main/.claude-plugin/marketplace.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "claude-plugins-official",
|
||||
"description": "...",
|
||||
"owner": { "name": "Anthropic", "email": "..." },
|
||||
"plugins": [
|
||||
{
|
||||
"name": "typescript-lsp",
|
||||
"description": "...",
|
||||
"version": "1.0.0",
|
||||
"author": { "name": "Anthropic", "email": "..." },
|
||||
"source": "./plugins/typescript-lsp", // local path OR object with URL
|
||||
"category": "development",
|
||||
"strict": false,
|
||||
"lspServers": { ... } // optional
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Plugin fields**:
|
||||
- `name`: display/install name
|
||||
- `source`: string (local path) or `{ source: "url", url: "..." }` (external)
|
||||
- `category`: open-ended string
|
||||
- `lspServers`: optional dict → hasLspServers capability
|
||||
- No `mcpServers`, `agents`, `commands`, `hooks` found in current V1 marketplace
|
||||
- `homepage`: optional string (external plugins)
|
||||
- `tags`: NOT present in marketplace.json (will be empty in V1)
|
||||
|
||||
**Categories found**: database, deployment, design, development, learning, monitoring, productivity, security, testing
|
||||
|
||||
**ETag support**: Yes — `If-None-Match` → 304 Not Modified
|
||||
|
||||
---
|
||||
|
||||
## 6. Decisions
|
||||
|
||||
| Decision | Value |
|
||||
|----------|-------|
|
||||
| Scope | Electron-only V1 |
|
||||
| Plugin identity key (`pluginId`) | `<name>@<marketplace>` = qualifiedName |
|
||||
| Plugin install target | `qualifiedName` resolved by main from catalog |
|
||||
| Plugin scope flag | `-s, --scope` (short) / `--scope` (long) |
|
||||
| MCP scope flag | `-s, --scope` |
|
||||
| MCP transport flag | `-t, --transport` |
|
||||
| MCP env flag | `-e, --env KEY=val` |
|
||||
| MCP header flag | `-H, --header "Key: val"` — **SUPPORTED** |
|
||||
| MCP default scope | `local` |
|
||||
| Plugin default scope | `user` |
|
||||
| Official Registry API version | `v0.1` |
|
||||
| Official Registry pagination | cursor-based, `cursor` param |
|
||||
| Glama pagination | cursor-based, `after` param |
|
||||
| Latest version filter | `_meta.isLatest: true` |
|
||||
| Capability flags V1 | `hasLspServers` only (others not in current marketplace) |
|
||||
| Install counts key | plugin `name` (without `@marketplace`) |
|
||||
|
|
@ -135,7 +135,7 @@ export class ChangeExtractorService {
|
|||
2. Парсить файлы, ища маркеры `TaskUpdate` tool_use:
|
||||
- `input.taskId === taskId && input.status === 'in_progress'` → начало
|
||||
- `input.taskId === taskId && input.status === 'completed'` → конец
|
||||
3. Альтернативно: Bash teamctl `task start|complete <id>` (regex)
|
||||
3. Альтернативно: исторические Bash teamctl логи `task start|complete <id>` (regex)
|
||||
4. Все tool_use Edit/Write между start и end маркерами = изменения задачи
|
||||
5. Если 86% кейс (1 задача в сессии): вся сессия = задача
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export interface TaskBoundary {
|
|||
/** ISO timestamp из JSONL entry */
|
||||
timestamp: string;
|
||||
/** Каким механизмом обнаружено */
|
||||
mechanism: 'TaskUpdate' | 'teamctl';
|
||||
mechanism: 'TaskUpdate' | 'teamctl'; // historical legacy mechanism
|
||||
/** tool_use id (для link к конкретному блоку) */
|
||||
toolUseId?: string;
|
||||
}
|
||||
|
|
@ -61,7 +61,7 @@ export interface TaskBoundariesResult {
|
|||
/** True если сессия работала только с одной задачей */
|
||||
isSingleTaskSession: boolean;
|
||||
/** Механизм обнаружения (один на сессию — никогда не смешиваются!) */
|
||||
detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none';
|
||||
detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none'; // historical legacy mechanism in this design note
|
||||
}
|
||||
|
||||
/** Расширенный TaskChangeSet с confidence деталями.
|
||||
|
|
@ -77,7 +77,7 @@ export interface TaskChangeSetV2 extends TaskChangeSet {
|
|||
|
||||
### 2. Сервис: `src/main/services/team/TaskBoundaryParser.ts` (NEW)
|
||||
|
||||
**Задача**: Парсить JSONL файлы субагентов для извлечения `TaskUpdate` и `teamctl` маркеров задач.
|
||||
**Задача**: Парсить JSONL файлы субагентов для извлечения `TaskUpdate` и исторических `teamctl` маркеров задач.
|
||||
|
||||
**Ключевой факт**: Механизмы НИКОГДА не смешиваются в одной сессии (0 из 351 проверенных). Это означает один pass по JSONL для определения механизма + extraction.
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ export class TaskBoundaryParser {
|
|||
private readonly CACHE_TTL = 60 * 1000; // 1 мин (не 3 — JSONL файлы меняются часто при активной работе)
|
||||
|
||||
/**
|
||||
* Парсит JSONL файл и извлекает все TaskUpdate/teamctl маркеры.
|
||||
* Парсит JSONL файл и извлекает все TaskUpdate/исторические teamctl маркеры.
|
||||
*
|
||||
* Один проход по файлу, O(n) по количеству строк.
|
||||
*/
|
||||
|
|
@ -101,8 +101,8 @@ export class TaskBoundaryParser {
|
|||
*
|
||||
* Алгоритм:
|
||||
* 1. Найти все TaskBoundary для taskId
|
||||
* 2. Start boundary = TaskUpdate(in_progress) или teamctl(start)
|
||||
* 3. End boundary = TaskUpdate(completed) или teamctl(complete)
|
||||
* 2. Start boundary = TaskUpdate(in_progress) или historical teamctl(start)
|
||||
* 3. End boundary = TaskUpdate(completed) или historical teamctl(complete)
|
||||
* 4. Scope = все tool_use между start.lineNumber и end.lineNumber
|
||||
* 5. Если single-task session: scope = весь файл
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -559,7 +559,7 @@ EditorView.theme({
|
|||
- 100% парсируемо — `input.taskId` + `input.status`
|
||||
- Tool result: `"Updated task #1 status"` (текст)
|
||||
|
||||
**Механизм B: Bash `teamctl.js`** (44 сессии)
|
||||
**Механизм B: исторический Bash `teamctl.js`** (44 сессии, legacy)
|
||||
```bash
|
||||
node "$HOME/.claude/tools/teamctl.js" --team "<team>" task start|complete|set-status <id>
|
||||
```
|
||||
|
|
@ -600,7 +600,7 @@ parseTaskBoundaries(sessionJsonl) → Map<taskId, tool_use_ids[]>
|
|||
- status == "in_progress" → TASK_START(taskId, line)
|
||||
- status == "completed" → TASK_END(taskId, line)
|
||||
|
||||
2. Детектировать Bash teamctl:
|
||||
2. Детектировать исторические Bash teamctl вызовы:
|
||||
- "task start <id>" → TASK_START(taskId, line)
|
||||
- "task complete <id>" → TASK_END(taskId, line)
|
||||
|
||||
|
|
@ -625,7 +625,7 @@ parseTaskBoundaries(sessionJsonl) → Map<taskId, tool_use_ids[]>
|
|||
|
||||
### Как достичь 95%+
|
||||
1. **Добавить парсинг `TaskUpdate` tool_use** (name == "TaskUpdate", input.taskId, input.status)
|
||||
2. **Сохранить Bash teamctl regex** для остальных 12.5%
|
||||
2. **Сохранить regex для исторических Bash teamctl логов** для остальных 12.5%
|
||||
3. Для single-task сессий (86%): вся сессия = задача (100%)
|
||||
4. Для multi-task: маркеры start/end как границы сегментов
|
||||
|
||||
|
|
@ -644,7 +644,7 @@ parseTaskBoundaries(sessionJsonl) → Map<taskId, tool_use_ids[]>
|
|||
- Решает проблему subagent'ов без `toolUseResult`
|
||||
|
||||
### Per-Task Scoping: Структурные маркеры = 95%+
|
||||
- `TaskUpdate` tool_use + Bash teamctl = 100% парсируемые маркеры
|
||||
- `TaskUpdate` tool_use + исторические Bash teamctl логи = 100% парсируемые маркеры
|
||||
- 86% сессий = 1 задача → 100% надёжность
|
||||
- Улучшение с ~85% (text search) до 95%+ (структурный парсинг)
|
||||
|
||||
|
|
|
|||
163
docs/research/electron-decoupling.md
Normal file
163
docs/research/electron-decoupling.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# Electron Decoupling Audit
|
||||
|
||||
> Дата аудита: 2026-03-08
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Кодовая база **на 68% независима от Electron**. Только 30 из 692 файлов имеют прямые Electron импорты. Миграция оценивается в 4-6 недель, в основном механический рефакторинг.
|
||||
|
||||
## Структура кодовой базы
|
||||
|
||||
| Категория | Файлов | % | Electron-зависимость |
|
||||
|-----------|:------:|:-:|---------------------|
|
||||
| Renderer (React) | 473 | 68% | Нет — pure React, работает в браузере |
|
||||
| Main (Services) | ~140 | 20% | Минимальная — pure Node.js |
|
||||
| Main (IPC handlers) | 22 | 3% | Да — `ipcMain.handle()` |
|
||||
| Main (Electron APIs) | 8 | 1% | Да — BrowserWindow, app, dialog, shell |
|
||||
| Preload | 2 | 0.3% | Да — contextBridge |
|
||||
| Shared (types/utils) | 45 | 6.5% | Нет — полностью agnostic |
|
||||
|
||||
## Уже реализованная инфраструктура отвязки
|
||||
|
||||
### 1. HTTP Server (Fastify)
|
||||
- **Файл**: `src/main/services/infrastructure/HttpServer.ts` (179 LOC)
|
||||
- Работает на `127.0.0.1:3456`
|
||||
- Раздаёт статику + API роуты
|
||||
- CORS настроен для standalone режима
|
||||
|
||||
### 2. HTTP Routes (80% покрытие)
|
||||
- **Директория**: `src/main/http/`
|
||||
- 13 файлов роутов, дублируют IPC handlers:
|
||||
- projects, sessions, search, subagents
|
||||
- config, notifications, utility, validation
|
||||
- ssh, updater, events, schedule
|
||||
|
||||
### 3. HttpAPIClient
|
||||
- **Файл**: `src/renderer/api/httpClient.ts` (400+ LOC)
|
||||
- Полная имплементация `ElectronAPI` интерфейса
|
||||
- EventSource (SSE) для real-time событий
|
||||
- Fetch для request/response
|
||||
|
||||
### 4. Unified API Proxy
|
||||
- **Файл**: `src/renderer/api/index.ts`
|
||||
- Автоматически переключается между:
|
||||
- `window.electronAPI` (Electron mode)
|
||||
- `HttpAPIClient` (browser mode)
|
||||
- Прозрачно для всех компонентов
|
||||
|
||||
### 5. Standalone Entry Point
|
||||
- **Файл**: `src/main/standalone.ts`
|
||||
- Запускает HTTP сервер без Electron
|
||||
- Стабит UpdaterService, SshConnectionManager
|
||||
- Протестирован и работает
|
||||
|
||||
## 30 файлов с Electron импортами
|
||||
|
||||
### ipcMain (22 файла)
|
||||
```
|
||||
src/main/ipc/handlers.ts — оркестратор
|
||||
src/main/ipc/cliInstaller.ts
|
||||
src/main/ipc/config.ts — + dialog, BrowserWindow
|
||||
src/main/ipc/context.ts
|
||||
src/main/ipc/editor.ts — + BrowserWindow
|
||||
src/main/ipc/extensions.ts
|
||||
src/main/ipc/httpServer.ts
|
||||
src/main/ipc/notifications.ts
|
||||
src/main/ipc/projects.ts
|
||||
src/main/ipc/rendererLogs.ts
|
||||
src/main/ipc/review.ts
|
||||
src/main/ipc/schedule.ts
|
||||
src/main/ipc/search.ts
|
||||
src/main/ipc/sessions.ts
|
||||
src/main/ipc/ssh.ts
|
||||
src/main/ipc/subagents.ts
|
||||
src/main/ipc/teams.ts — + BrowserWindow, Notification
|
||||
src/main/ipc/terminal.ts
|
||||
src/main/ipc/updater.ts
|
||||
src/main/ipc/utility.ts — + shell
|
||||
src/main/ipc/validation.ts
|
||||
src/main/ipc/window.ts — + app, BrowserWindow
|
||||
```
|
||||
|
||||
### Другие Electron API
|
||||
```
|
||||
src/main/index.ts — app, BrowserWindow, ipcMain
|
||||
src/main/utils/pathDecoder.ts — app.getPath('home')
|
||||
src/main/services/infrastructure/UpdaterService.ts — BrowserWindow, electron-updater
|
||||
src/main/services/infrastructure/NotificationManager.ts — Notification
|
||||
src/preload/index.ts — contextBridge, ipcRenderer
|
||||
```
|
||||
|
||||
## Замена каждого Electron API
|
||||
|
||||
| API | Файлов | Замена | Усилия |
|
||||
|-----|:------:|--------|:------:|
|
||||
| `ipcMain.handle()` | 22 | HTTP роуты (80% уже есть) | 3-4ч |
|
||||
| `BrowserWindow` | 6 | Убрать (браузер сам управляет) | 1-2ч |
|
||||
| `app` lifecycle | 2 | Прямой запуск Node.js сервера | 4-5ч |
|
||||
| `dialog.showOpenDialog()` | 1 | HTML `<input type="file">` / env var | 3ч |
|
||||
| `shell.openExternal/Path` | 1 | `window.open()` / убрать | 1ч |
|
||||
| `Notification` | 2 | Browser Notification API | 2ч |
|
||||
| `electron-updater` | 1 | GitHub releases redirect | 2ч |
|
||||
| `contextBridge` | 1 | Убрать полностью | 1ч |
|
||||
|
||||
## Оценка миграции
|
||||
|
||||
| Метрика | Значение |
|
||||
|---------|----------|
|
||||
| Сложность | 6/10 — механический рефакторинг |
|
||||
| Объём работы | 4-6 недель |
|
||||
| Вероятность успеха | 95% |
|
||||
| Уверенность в оценке | 9/10 |
|
||||
|
||||
## Фазы миграции
|
||||
|
||||
### Phase 1: Setup (2-3 дня)
|
||||
- Рефакторинг `src/main/index.ts` в pure HTTP server bootstrap
|
||||
- Удаление Electron app lifecycle
|
||||
- HTTP server как primary entry point
|
||||
|
||||
### Phase 2: IPC → HTTP (3-4 дня)
|
||||
- Завершить оставшиеся HTTP роуты (~20%)
|
||||
- Удалить все `ipcMain.handle()` вызовы
|
||||
- SSE для event delivery
|
||||
|
||||
### Phase 3: Desktop-Only Features (2-3 дня)
|
||||
- Убрать auto-updater → version check API
|
||||
- Убрать dialog → env var / HTML input
|
||||
- Убрать shell → client-side links
|
||||
- Browser Notification API
|
||||
|
||||
### Phase 4: Build System (2-3 дня)
|
||||
- `electron-vite` → стандартный Vite
|
||||
- Убрать preload bundling
|
||||
- Docker build config
|
||||
|
||||
### Phase 5: Testing (2-3 дня)
|
||||
- HTTP endpoint coverage
|
||||
- Browser compatibility
|
||||
- Docker deployment
|
||||
|
||||
## Что нельзя заменить (Electron-only)
|
||||
- Auto-update бинарных патчей → GitHub releases
|
||||
- System tray → убрать
|
||||
- Native menu → web context menus
|
||||
- System hotkeys → browser keyboard events
|
||||
- Native file dialogs → HTML file input
|
||||
|
||||
## Build изменения
|
||||
|
||||
**Текущий:**
|
||||
```json
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"dist": "electron-builder --mac --win --linux"
|
||||
```
|
||||
|
||||
**После миграции:**
|
||||
```json
|
||||
"dev": "tsx src/main/standalone.ts & vite",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
"start": "node dist/main/index.cjs",
|
||||
"docker": "docker build -t claude-teams ."
|
||||
```
|
||||
89
docs/research/lead-thought-msg-scroll.md
Normal file
89
docs/research/lead-thought-msg-scroll.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
## Findings: реверс порядка lead thoughts (свежие вверху)
|
||||
|
||||
### Затронутые файлы
|
||||
|
||||
| Файл | Что менять |
|
||||
|------|------------|
|
||||
| `LeadThoughtsGroup.tsx` | Основной компонент. Строка 471: `chronologicalThoughts = [...thoughts].reverse()` — убрать reverse (thoughts уже newest-first). Логика автоскрола внутри `LeadThoughtsGroupRow` (строки 570-658). |
|
||||
| `ActivityTimeline.tsx` | Pinned thought group (строка 322). Порядок messages — newest-first (desc), thoughts группируются через `groupTimelineItems()`. |
|
||||
| `collapseState.ts` | `findNewestMessageIndex()` ищет первый `type: 'message'` — при реверсе внутри группы не затронут. |
|
||||
| `AnimatedHeightReveal.tsx` | Не требует изменений — анимирует высоту, а не направление. |
|
||||
|
||||
### Архитектура текущего скрола thoughts
|
||||
|
||||
**LeadThoughtsGroupRow** имеет свой собственный скрол-контейнер (строки 780-807):
|
||||
- `scrollRef` — div с `maxHeight: 200px`, `overflowY: auto`
|
||||
- `contentRef` — внутренний div с thoughts
|
||||
- Автоскрол к **низу** (`queueScrollSync('bottom')`) — потому что newest внизу
|
||||
- `isUserScrolledUpRef` — трекает, отскроллил ли юзер вверх
|
||||
- `distanceFromBottomRef` — сохраняет позицию для `preserve` mode
|
||||
- `handleScroll` (строка 652) — обновляет `isUserScrolledUpRef` через `AUTO_SCROLL_THRESHOLD = 30px`
|
||||
- `handleCollapse` (строка 661) — при Show Less скроллит к `scrollHeight` (к низу)
|
||||
- `syncScrollableBody` (строка 598) — оркестрирует: force bottom / preserve / noop
|
||||
|
||||
**Рендеринг** (строка 795): `chronologicalThoughts.map()` — oldest-first, newest в конце. Анимация `shouldAnimate` только для последнего (newest).
|
||||
|
||||
### Что нужно изменить для реверса
|
||||
|
||||
1. **Убрать `.reverse()` на строке 471** — thoughts уже newest-first, рендерить как есть
|
||||
2. **Перевернуть автоскрол**: вместо scroll-to-bottom → scroll-to-top (`scrollTop = 0`)
|
||||
3. **Анимация нового thought**: `shouldAnimate` для `idx === 0` (вместо `idx === chronologicalThoughts.length - 1`)
|
||||
4. **`isUserScrolledUpRef`** → переименовать в `isUserScrolledDownRef`, проверять расстояние от **верха** (`scrollTop > threshold`)
|
||||
5. **`queueScrollSync`** — `mode: 'top'` вместо `'bottom'` (`scrollTop = 0`), preserve mode: `scrollTop = distanceFromTopRef.current`
|
||||
6. **Show More кнопка** — сейчас внизу (ChevronDown). При реверсе: newest вверху, кнопка "Show more" нужна внизу для загрузки старых thoughts — по сути остаётся на месте
|
||||
7. **Show Less** (`handleCollapse`) — скроллит к верху (`scrollTop = 0`) вместо `scrollHeight`
|
||||
8. **Divider timestamps** (строка 355-360): `showDivider` при `idx > 0` — работает корректно и при реверсе
|
||||
|
||||
### Edge Cases
|
||||
|
||||
1. **Новый thought приходит во время скрола вниз к старым** — если юзер прокрутил вниз (к старым), новый thought добавляется вверху. Нужно сохранить `scrollTop` позицию. Решение: `preserve` mode отслеживает `distanceFromTop` вместо `distanceFromBottom`.
|
||||
|
||||
2. **Первый thought в пустой группе** — скролл не нужен (нет overflow), анимация fade-in для первого элемента.
|
||||
|
||||
3. **Переход collapsed → expanded** — `setExpanded(true)` убирает maxHeight. При реверсе scrollTop=0 уже наверху, без скачков. Проблем нет.
|
||||
|
||||
4. **Expanded → collapsed (Show Less)** — сейчас `handleCollapse` скроллит к scrollHeight. При реверсе → скроллить к 0. Плюс `scrollIntoView` на контейнер — остаётся как есть.
|
||||
|
||||
5. **Real-time streaming** — thoughts приходят через InboxMessage live updates. Каждый новый thought prepend-ится в начало массива `thoughts[]` (newest-first). При реверсе рендера: новый появляется вверху, анимируется LeadThoughtItem с `shouldAnimate`. Ключевой риск: `AnimatedHeightReveal` wrapper расширяется вниз (grid-template-rows: 0fr→1fr). При insert-е вверху контейнер сдвигает всё вниз → **layout shift**. Mitigation: CSS `overflow-anchor: none` уже стоит (строка 790). Но для items внутри scroll-контейнера нужно `overflow-anchor: auto` на последнем видимом элементе. **Это главный технический риск.**
|
||||
|
||||
6. **`getThoughtGroupKey`** (строка 67-70) — использует oldest thought для стабильного ключа. При реверсе rendering порядок меняется, но key остаётся тот же. Проблем нет.
|
||||
|
||||
7. **ResizeObserver** (строка 632-637) — наблюдает за contentRef. При реверсе content растёт вверху → ResizeObserver сработает, вызовет syncScrollableBody. Нужно убедиться что `preserve` mode корректно считает offset от верха.
|
||||
|
||||
8. **`isBodyVisible` toggle** (collapse mode из ActivityTimeline) — скрывает/показывает body. При реверсе: после re-show нужно скроллить к top (не bottom). Затронуто useLayoutEffect на строке 625-638.
|
||||
|
||||
### Аналоги в проекте для референса
|
||||
|
||||
- `DisplayItemList.tsx:107` — использует `flex-col-reverse` для newest-first. Простой подход, но не подходит для нашего случая (у нас свой scroll container с автоскролом).
|
||||
- `CliLogsRichView.tsx:376` — `[...entries].reverse()` для newest-first порядка.
|
||||
|
||||
### Предложенный план реализации
|
||||
|
||||
1. В `LeadThoughtsGroupRow`: убрать `chronologicalThoughts` reverse, рендерить `thoughts` напрямую (newest-first)
|
||||
2. Перевернуть scroll-sync логику: auto-scroll к `scrollTop=0`, preserve через `distanceFromTop`
|
||||
3. Обновить `shouldAnimate` для `idx === 0`
|
||||
4. Обновить `handleCollapse` → `scrollTop = 0`
|
||||
5. Show More/Show Less кнопки: Show More внизу (для старых thoughts) — без изменений. Show Less — без изменений.
|
||||
6. Тестировать layout shift при live streaming
|
||||
|
||||
### Оценки
|
||||
|
||||
**Сложность реализации: 5/10**
|
||||
- Основная логика сосредоточена в одном файле (LeadThoughtsGroup.tsx)
|
||||
- Scroll-sync перевернуть — умеренно сложно (6 точек изменения: queueScrollSync, handleScroll, handleCollapse, syncScrollableBody, useLayoutEffect, shouldAnimate)
|
||||
- Нет зависимости от внешнего useAutoScrollBottom — LeadThoughtsGroupRow имеет свой собственный scroll management
|
||||
- Не нужно трогать groupTimelineItems() или ActivityTimeline
|
||||
|
||||
**Уверенность в оценке: 8/10**
|
||||
- Код хорошо изолирован — весь scroll management внутри одного компонента
|
||||
- Единственный серьёзный риск — layout shift при real-time prepend (edge case #5)
|
||||
- CSS overflow-anchor может не работать идеально для insert-вверх в scroll container
|
||||
|
||||
### Оценка рисков
|
||||
|
||||
| Риск | Вероятность | Последствие | Mitigation |
|
||||
|------|-------------|-------------|------------|
|
||||
| Layout shift при live streaming | Средняя | Визуальные скачки | overflow-anchor + manual scrollTop adjustment |
|
||||
| Broken Show More/Less | Низкая | UX деградация | Простой фикс scrollTop |
|
||||
| Regression в collapsed mode | Низкая | Thoughts не видны | Не затрагивает collapse logic |
|
||||
| Browser inconsistency overflow-anchor | Низкая | Скачки в Safari | Fallback: manual scroll compensation |
|
||||
|
|
@ -96,8 +96,8 @@ From `MarkdownViewer.tsx` (lines 561-571):
|
|||
|
||||
### Identified scenarios
|
||||
|
||||
1. **Task descriptions from CLI tooling (teamctl.js / Claude agents)**
|
||||
Task descriptions are set via `node teamctl.js task create` or `TaskCreate` tool calls. Agents typically write plain text descriptions, not markdown. The description content itself lacks formatting — MarkdownViewer renders it correctly, but there's nothing to format.
|
||||
1. **Task descriptions from task tooling / Claude agents**
|
||||
Historically this included `teamctl.js`; the current architecture uses controller/MCP-based task operations. In both cases, agents typically write plain text descriptions, not markdown. The description content itself lacks formatting — MarkdownViewer renders it correctly, but there's nothing to format.
|
||||
|
||||
2. **Task comments from agents**
|
||||
Same issue — `task comment --text "..."` passes plain text. However, TaskCommentsSection.tsx (line 246) correctly uses `<MarkdownViewer content={displayText} bare />`.
|
||||
|
|
@ -125,7 +125,7 @@ From `MarkdownViewer.tsx` (lines 561-571):
|
|||
The pipeline is correct. All text display surfaces that show long-form content already use MarkdownViewer.
|
||||
|
||||
### Fix 2: If specific content appears unformatted, the fix is upstream
|
||||
Ensure that agents/tooling that create tasks or comments use markdown formatting in their text. For example, the `teamctl.js` task create command could document that `--description` supports markdown.
|
||||
Ensure that agents/tooling that create tasks or comments use markdown formatting in their text. In the current architecture, this guidance applies to controller/MCP-backed task creation rather than the removed `teamctl.js` CLI.
|
||||
|
||||
### Fix 3 (Optional): ReplyQuoteBlock markdown support
|
||||
`ReplyQuoteBlock` renders the reply body. If it currently shows plain text, wrap body content in `<MarkdownViewer bare />` for consistency. (Needs verification — separate from this research scope.)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const prodDeps = Object.keys(pkg.dependencies || {})
|
|||
|
||||
// node-pty is a native addon that cannot be bundled by Rollup.
|
||||
// It must remain external and be loaded at runtime via require().
|
||||
const bundledDeps = prodDeps.filter(d => d !== 'node-pty')
|
||||
const bundledDeps = prodDeps.filter(d => d !== 'node-pty' && d !== 'agent-teams-controller')
|
||||
|
||||
// Rollup plugin: stub out native .node addon imports with empty modules.
|
||||
// ssh2 and cpu-features use optional native bindings that can't be bundled,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "agent-teams-mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for managing Claude Agent Teams kanban board and tasks via teamctl CLI",
|
||||
"description": "MCP server for managing Claude Agent Teams through the agent-teams-controller API",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
|
|
@ -27,12 +27,16 @@
|
|||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsx src/index.ts",
|
||||
"lint": "eslint \"src/**/*.ts\"",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "pnpm build && vitest run test/stdio.e2e.test.ts",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
|
||||
"prepublishOnly": "pnpm build"
|
||||
},
|
||||
"dependencies": {
|
||||
"agent-teams-controller": "workspace:*",
|
||||
"fastmcp": "^3.34.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
|
|
|||
80
mcp-server/src/agent-teams-controller.d.ts
vendored
Normal file
80
mcp-server/src/agent-teams-controller.d.ts
vendored
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
declare module 'agent-teams-controller' {
|
||||
export interface ControllerContextOptions {
|
||||
teamName: string;
|
||||
claudeDir?: string;
|
||||
}
|
||||
|
||||
export interface ControllerTaskApi {
|
||||
createTask(flags: Record<string, unknown>): unknown;
|
||||
getTask(taskId: string): unknown;
|
||||
listTasks(): unknown[];
|
||||
listDeletedTasks(): unknown[];
|
||||
resolveTaskId(taskRef: string): string;
|
||||
setTaskStatus(taskId: string, status: string, actor?: string): unknown;
|
||||
startTask(taskId: string, actor?: string): unknown;
|
||||
completeTask(taskId: string, actor?: string): unknown;
|
||||
softDeleteTask(taskId: string, actor?: string): unknown;
|
||||
restoreTask(taskId: string, actor?: string): unknown;
|
||||
setTaskOwner(taskId: string, owner: string | null): unknown;
|
||||
updateTaskFields(taskId: string, fields: { subject?: string; description?: string }): unknown;
|
||||
addTaskComment(taskId: string, flags: Record<string, unknown>): unknown;
|
||||
attachTaskFile(taskId: string, flags: Record<string, unknown>): unknown;
|
||||
attachCommentFile(taskId: string, commentId: string, flags: Record<string, unknown>): unknown;
|
||||
addTaskAttachmentMeta(taskId: string, meta: Record<string, unknown>): unknown;
|
||||
removeTaskAttachment(taskId: string, attachmentId: string): unknown;
|
||||
setNeedsClarification(taskId: string, value: string | null): unknown;
|
||||
linkTask(taskId: string, targetId: string, linkType: string): unknown;
|
||||
unlinkTask(taskId: string, targetId: string, linkType: string): unknown;
|
||||
taskBriefing(memberName: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface ControllerKanbanApi {
|
||||
getKanbanState(): unknown;
|
||||
setKanbanColumn(taskId: string, column: string): unknown;
|
||||
clearKanban(taskId: string): unknown;
|
||||
listReviewers(): string[];
|
||||
addReviewer(reviewer: string): string[];
|
||||
removeReviewer(reviewer: string): string[];
|
||||
updateColumnOrder(columnId: string, orderedTaskIds: string[]): unknown;
|
||||
}
|
||||
|
||||
export interface ControllerReviewApi {
|
||||
requestReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||
approveReview(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||
requestChanges(taskId: string, flags?: Record<string, unknown>): unknown;
|
||||
}
|
||||
|
||||
export interface ControllerMessageApi {
|
||||
appendSentMessage(flags: Record<string, unknown>): unknown;
|
||||
sendMessage(flags: Record<string, unknown>): unknown;
|
||||
}
|
||||
|
||||
export interface ControllerProcessApi {
|
||||
registerProcess(flags: Record<string, unknown>): unknown;
|
||||
stopProcess(flags: Record<string, unknown>): unknown;
|
||||
unregisterProcess(flags: Record<string, unknown>): unknown;
|
||||
listProcesses(): unknown[];
|
||||
}
|
||||
|
||||
export interface ControllerMaintenanceApi {
|
||||
reconcileArtifacts(flags?: Record<string, unknown>): unknown;
|
||||
}
|
||||
|
||||
export interface ControllerCrossTeamApi {
|
||||
sendCrossTeamMessage(flags: Record<string, unknown>): unknown;
|
||||
listCrossTeamTargets(flags?: Record<string, unknown>): unknown;
|
||||
getCrossTeamOutbox(): unknown;
|
||||
}
|
||||
|
||||
export interface AgentTeamsController {
|
||||
tasks: ControllerTaskApi;
|
||||
kanban: ControllerKanbanApi;
|
||||
review: ControllerReviewApi;
|
||||
messages: ControllerMessageApi;
|
||||
processes: ControllerProcessApi;
|
||||
maintenance: ControllerMaintenanceApi;
|
||||
crossTeam: ControllerCrossTeamApi;
|
||||
}
|
||||
|
||||
export function createController(options: ControllerContextOptions): AgentTeamsController;
|
||||
}
|
||||
16
mcp-server/src/controller.ts
Normal file
16
mcp-server/src/controller.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||
|
||||
type ControllerModule = typeof import('agent-teams-controller') & {
|
||||
default?: typeof import('agent-teams-controller');
|
||||
};
|
||||
|
||||
const controllerModule =
|
||||
(agentTeamsControllerModule as ControllerModule).default ?? agentTeamsControllerModule;
|
||||
const { createController } = controllerModule;
|
||||
|
||||
export function getController(teamName: string, claudeDir?: string) {
|
||||
return createController({
|
||||
teamName,
|
||||
...(claudeDir ? { claudeDir } : {}),
|
||||
});
|
||||
}
|
||||
24
mcp-server/src/index.ts
Normal file
24
mcp-server/src/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env node
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { FastMCP } from 'fastmcp';
|
||||
|
||||
import { registerTools } from './tools';
|
||||
|
||||
export function createServer() {
|
||||
const server = new FastMCP({
|
||||
name: 'agent-teams-mcp',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
registerTools(server);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const server = createServer();
|
||||
void server.start({
|
||||
transportType: 'stdio',
|
||||
});
|
||||
}
|
||||
81
mcp-server/src/tools/crossTeamTools.ts
Normal file
81
mcp-server/src/tools/crossTeamTools.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
claudeDir: z.string().min(1).optional(),
|
||||
};
|
||||
|
||||
export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'cross_team_send',
|
||||
description:
|
||||
'Send a message to another team. The message is delivered to the target team lead inbox.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
toTeam: z.string().min(1),
|
||||
text: z.string().min(1),
|
||||
fromMember: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
conversationId: z.string().optional(),
|
||||
replyToConversationId: z.string().optional(),
|
||||
chainDepth: z.number().int().nonnegative().optional(),
|
||||
}),
|
||||
execute: async ({
|
||||
teamName,
|
||||
claudeDir,
|
||||
toTeam,
|
||||
text,
|
||||
fromMember,
|
||||
summary,
|
||||
conversationId,
|
||||
replyToConversationId,
|
||||
chainDepth,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
|
||||
toTeam,
|
||||
text,
|
||||
...(fromMember ? { fromMember } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(conversationId ? { conversationId } : {}),
|
||||
...(replyToConversationId ? { replyToConversationId } : {}),
|
||||
...(chainDepth !== undefined ? { chainDepth } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'cross_team_list_targets',
|
||||
description: 'List available teams that can receive cross-team messages.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
excludeTeam: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, excludeTeam }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).crossTeam.listCrossTeamTargets({
|
||||
...(excludeTeam ? { excludeTeam } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'cross_team_get_outbox',
|
||||
description: 'Get sent cross-team messages for the current team.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).crossTeam.getCrossTeamOutbox())
|
||||
),
|
||||
});
|
||||
}
|
||||
17
mcp-server/src/tools/index.ts
Normal file
17
mcp-server/src/tools/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
|
||||
import { registerCrossTeamTools } from './crossTeamTools';
|
||||
import { registerKanbanTools } from './kanbanTools';
|
||||
import { registerMessageTools } from './messageTools';
|
||||
import { registerProcessTools } from './processTools';
|
||||
import { registerReviewTools } from './reviewTools';
|
||||
import { registerTaskTools } from './taskTools';
|
||||
|
||||
export function registerTools(server: FastMCP) {
|
||||
registerTaskTools(server);
|
||||
registerKanbanTools(server);
|
||||
registerReviewTools(server);
|
||||
registerMessageTools(server);
|
||||
registerProcessTools(server);
|
||||
registerCrossTeamTools(server);
|
||||
}
|
||||
81
mcp-server/src/tools/kanbanTools.ts
Normal file
81
mcp-server/src/tools/kanbanTools.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
claudeDir: z.string().min(1).optional(),
|
||||
};
|
||||
|
||||
export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'kanban_get',
|
||||
description: 'Get current kanban state',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState())),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'kanban_set_column',
|
||||
description: 'Move task to review or approved column',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
column: z.enum(['review', 'approved']),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, column }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.setKanbanColumn(taskId, column))
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'kanban_clear',
|
||||
description: 'Remove task from kanban board',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId))),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'kanban_list_reviewers',
|
||||
description: 'List configured review participants',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers())),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'kanban_add_reviewer',
|
||||
description: 'Add a reviewer to kanban configuration',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
reviewer: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, reviewer }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer))),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'kanban_remove_reviewer',
|
||||
description: 'Remove reviewer from kanban configuration',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
reviewer: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, reviewer }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.removeReviewer(reviewer))
|
||||
),
|
||||
});
|
||||
}
|
||||
60
mcp-server/src/tools/messageTools.ts
Normal file
60
mcp-server/src/tools/messageTools.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
claudeDir: z.string().min(1).optional(),
|
||||
};
|
||||
|
||||
export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'message_send',
|
||||
description: 'Send a message into team inbox',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
to: z.string().min(1),
|
||||
text: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
leadSessionId: z.string().optional(),
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
filename: z.string().min(1),
|
||||
mimeType: z.string().min(1),
|
||||
size: z.number().nonnegative(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
execute: async ({
|
||||
teamName,
|
||||
claudeDir,
|
||||
to,
|
||||
text,
|
||||
from,
|
||||
summary,
|
||||
source,
|
||||
leadSessionId,
|
||||
attachments,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).messages.sendMessage({
|
||||
to,
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(source ? { source } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
...(attachments?.length ? { attachments } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
89
mcp-server/src/tools/processTools.ts
Normal file
89
mcp-server/src/tools/processTools.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
claudeDir: z.string().min(1).optional(),
|
||||
};
|
||||
|
||||
export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'process_register',
|
||||
description: 'Register a running process for a team member',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
label: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
command: z.string().min(1).optional(),
|
||||
port: z.number().int().min(1).max(65535).optional(),
|
||||
url: z.string().min(1).optional(),
|
||||
claudeProcessId: z.string().min(1).optional(),
|
||||
}),
|
||||
execute: async ({
|
||||
teamName,
|
||||
claudeDir,
|
||||
pid,
|
||||
label,
|
||||
from,
|
||||
command,
|
||||
port,
|
||||
url,
|
||||
claudeProcessId,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).processes.registerProcess({
|
||||
pid,
|
||||
label,
|
||||
...(from ? { from } : {}),
|
||||
...(command ? { command } : {}),
|
||||
...(port ? { port } : {}),
|
||||
...(url ? { url } : {}),
|
||||
...(claudeProcessId ? { 'claude-process-id': claudeProcessId } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'process_list',
|
||||
description: 'List registered team processes',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.listProcesses())
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'process_unregister',
|
||||
description: 'Unregister a previously registered process',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, pid }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid }))
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'process_stop',
|
||||
description: 'Mark a registered process as stopped while preserving history',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, pid }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid }))
|
||||
),
|
||||
});
|
||||
}
|
||||
80
mcp-server/src/tools/reviewTools.ts
Normal file
80
mcp-server/src/tools/reviewTools.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
claudeDir: z.string().min(1).optional(),
|
||||
};
|
||||
|
||||
export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'review_request',
|
||||
description: 'Move a completed task into review and notify reviewer',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
reviewer: z.string().optional(),
|
||||
leadSessionId: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.requestReview(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
...(reviewer ? { reviewer } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'review_approve',
|
||||
description: 'Approve task review and move kanban state accordingly',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
notifyOwner: z.boolean().optional(),
|
||||
leadSessionId: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.approveReview(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
...(note ? { note } : {}),
|
||||
...(notifyOwner !== false ? { 'notify-owner': true } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'review_request_changes',
|
||||
description: 'Request changes on a task under review',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
comment: z.string().optional(),
|
||||
leadSessionId: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.requestChanges(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
...(comment ? { comment } : {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
281
mcp-server/src/tools/taskTools.ts
Normal file
281
mcp-server/src/tools/taskTools.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
claudeDir: z.string().min(1).optional(),
|
||||
};
|
||||
|
||||
const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']);
|
||||
|
||||
export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'task_create',
|
||||
description: 'Create a team task',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
subject: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
owner: z.string().optional(),
|
||||
blockedBy: z.array(z.string().min(1)).optional(),
|
||||
related: z.array(z.string().min(1)).optional(),
|
||||
prompt: z.string().optional(),
|
||||
startImmediately: z.boolean().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, subject, description, owner, blockedBy, related, prompt, startImmediately }) => {
|
||||
const controller = getController(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
controller.tasks.createTask({
|
||||
subject,
|
||||
...(description ? { description } : {}),
|
||||
...(owner ? { owner } : {}),
|
||||
...(blockedBy?.length ? { 'blocked-by': blockedBy.join(',') } : {}),
|
||||
...(related?.length ? { related: related.join(',') } : {}),
|
||||
...(prompt ? { prompt } : {}),
|
||||
...(startImmediately !== undefined ? { startImmediately } : {}),
|
||||
})
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_get',
|
||||
description: 'Get a task by id',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_list',
|
||||
description: 'List tasks for a team',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.listTasks())),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_set_status',
|
||||
description: 'Set task work status',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
status: z.enum(['pending', 'in_progress', 'completed', 'deleted']),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, status, actor }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor))
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_start',
|
||||
description: 'Mark task as in progress',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.startTask(taskId, actor))),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_complete',
|
||||
description: 'Mark task as completed',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.completeTask(taskId, actor))
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_set_owner',
|
||||
description: 'Assign or clear task owner',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
owner: z.string().nullable(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, owner }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner))
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_add_comment',
|
||||
description: 'Add task comment',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
text: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, text, from }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_attach_file',
|
||||
description: 'Attach a file to a task',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
filePath: z.string().min(1),
|
||||
mode: z.enum(['copy', 'link']).optional(),
|
||||
filename: z.string().optional(),
|
||||
mimeType: z.string().optional(),
|
||||
noFallback: z.boolean().optional(),
|
||||
}),
|
||||
execute: async ({
|
||||
teamName,
|
||||
claudeDir,
|
||||
taskId,
|
||||
filePath,
|
||||
mode,
|
||||
filename,
|
||||
mimeType,
|
||||
noFallback,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_attach_comment_file',
|
||||
description: 'Attach a file to a task comment',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
commentId: z.string().min(1),
|
||||
filePath: z.string().min(1),
|
||||
mode: z.enum(['copy', 'link']).optional(),
|
||||
filename: z.string().optional(),
|
||||
mimeType: z.string().optional(),
|
||||
noFallback: z.boolean().optional(),
|
||||
}),
|
||||
execute: async ({
|
||||
teamName,
|
||||
claudeDir,
|
||||
taskId,
|
||||
commentId,
|
||||
filePath,
|
||||
mode,
|
||||
filename,
|
||||
mimeType,
|
||||
noFallback,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_set_clarification',
|
||||
description: 'Set or clear task clarification state',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
value: z.enum(['lead', 'user', 'clear']),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, value }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.setNeedsClarification(
|
||||
taskId,
|
||||
value === 'clear' ? null : value
|
||||
)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_link',
|
||||
description: 'Link tasks by blockedBy, blocks, or related relationship',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
targetId: z.string().min(1),
|
||||
relationship: relationshipTypeSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship))
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_unlink',
|
||||
description: 'Remove task relationship link',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
targetId: z.string().min(1),
|
||||
relationship: relationshipTypeSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.unlinkTask(taskId, targetId, relationship)
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_briefing',
|
||||
description: 'Get formatted task briefing for a member',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
memberName: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName }) => ({
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
10
mcp-server/src/utils/format.ts
Normal file
10
mcp-server/src/utils/format.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export function jsonTextContent(value: unknown): { content: { type: 'text'; text: string }[] } {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(value, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
154
mcp-server/test/stdio.e2e.test.ts
Normal file
154
mcp-server/test/stdio.e2e.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
function parseJsonToolResult(result: unknown) {
|
||||
const text = (result as { content?: Array<{ text?: string }> }).content?.[0]?.text;
|
||||
return JSON.parse(text ?? 'null');
|
||||
}
|
||||
|
||||
class McpStdIoClient {
|
||||
private readonly child: ChildProcessWithoutNullStreams;
|
||||
private stdoutBuffer = '';
|
||||
|
||||
constructor(serverPath: string, cwd: string) {
|
||||
this.child = spawn('node', [serverPath], {
|
||||
cwd,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
this.child.stdout.setEncoding('utf8');
|
||||
this.child.stdout.on('data', (chunk: string) => {
|
||||
this.stdoutBuffer += chunk;
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
const response = await this.request(1, 'initialize', {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'vitest-e2e', version: '1.0.0' },
|
||||
});
|
||||
|
||||
this.notify('notifications/initialized');
|
||||
return response;
|
||||
}
|
||||
|
||||
async listTools() {
|
||||
return this.request(2, 'tools/list', {});
|
||||
}
|
||||
|
||||
async callTool(name: string, args: Record<string, unknown>, id = 3) {
|
||||
return this.request(id, 'tools/call', { name, arguments: args });
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.child.kill('SIGTERM');
|
||||
await new Promise<void>((resolve) => {
|
||||
this.child.once('exit', () => resolve());
|
||||
setTimeout(() => resolve(), 1000).unref();
|
||||
});
|
||||
}
|
||||
|
||||
private notify(method: string, params?: Record<string, unknown>) {
|
||||
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, ...(params ? { params } : {}) })}\n`);
|
||||
}
|
||||
|
||||
private async request(id: number, method: string, params: Record<string, unknown>) {
|
||||
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
|
||||
return this.readMessage(id);
|
||||
}
|
||||
|
||||
private async readMessage(expectedId: number) {
|
||||
const deadline = Date.now() + 5000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const newlineIndex = this.stdoutBuffer.indexOf('\n');
|
||||
if (newlineIndex !== -1) {
|
||||
const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
|
||||
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
||||
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(line) as { id?: number };
|
||||
if (parsed.id === expectedId) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for MCP response ${expectedId}`);
|
||||
}
|
||||
}
|
||||
|
||||
describe('agent-teams-mcp stdio e2e', () => {
|
||||
const serverPath = fileURLToPath(new URL('../dist/index.js', import.meta.url));
|
||||
const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url));
|
||||
|
||||
let claudeDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
claudeDir = await mkdtemp(path.join(os.tmpdir(), 'agent-teams-mcp-e2e-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(claudeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('boots over stdio, lists task tools, and executes task lifecycle calls', async () => {
|
||||
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
||||
|
||||
try {
|
||||
const init = await client.initialize();
|
||||
expect(init).toHaveProperty('result');
|
||||
|
||||
const tools = (await client.listTools()) as {
|
||||
result?: { tools?: Array<{ name: string }> };
|
||||
};
|
||||
const toolNames = (tools.result?.tools ?? []).map((tool) => tool.name);
|
||||
|
||||
expect(toolNames).toContain('task_create');
|
||||
expect(toolNames).toContain('task_start');
|
||||
expect(toolNames).toContain('review_approve');
|
||||
|
||||
const createResult = await client.callTool(
|
||||
'task_create',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'e2e-team',
|
||||
subject: 'Smoke task',
|
||||
owner: 'alice',
|
||||
},
|
||||
3
|
||||
);
|
||||
const createdTask = parseJsonToolResult((createResult as { result: unknown }).result);
|
||||
|
||||
expect(createdTask.subject).toBe('Smoke task');
|
||||
expect(createdTask.owner).toBe('alice');
|
||||
expect(typeof createdTask.id).toBe('string');
|
||||
|
||||
const startResult = await client.callTool(
|
||||
'task_start',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'e2e-team',
|
||||
taskId: createdTask.id,
|
||||
actor: 'alice',
|
||||
},
|
||||
4
|
||||
);
|
||||
const startedTask = parseJsonToolResult((startResult as { result: unknown }).result);
|
||||
|
||||
expect(startedTask.status).toBe('in_progress');
|
||||
expect(startedTask.id).toBe(createdTask.id);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
613
mcp-server/test/tools.test.ts
Normal file
613
mcp-server/test/tools.test.ts
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { registerTools } from '../src/tools';
|
||||
|
||||
type RegisteredTool = {
|
||||
name: string;
|
||||
parameters?: { safeParse: (value: unknown) => { success: boolean } };
|
||||
execute: (args: Record<string, unknown>) => Promise<unknown> | unknown;
|
||||
};
|
||||
|
||||
function collectTools() {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
|
||||
registerTools({
|
||||
addTool(config: RegisteredTool) {
|
||||
tools.set(config.name, config);
|
||||
},
|
||||
} as never);
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
function parseJsonToolResult(result: unknown) {
|
||||
const text = (result as { content: Array<{ text: string }> }).content[0]?.text;
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
describe('agent-teams-mcp tools', () => {
|
||||
const tools = collectTools();
|
||||
const expectedToolNames = [
|
||||
'cross_team_get_outbox',
|
||||
'cross_team_list_targets',
|
||||
'cross_team_send',
|
||||
'kanban_add_reviewer',
|
||||
'kanban_clear',
|
||||
'kanban_get',
|
||||
'kanban_list_reviewers',
|
||||
'kanban_remove_reviewer',
|
||||
'kanban_set_column',
|
||||
'message_send',
|
||||
'process_list',
|
||||
'process_register',
|
||||
'process_stop',
|
||||
'process_unregister',
|
||||
'review_approve',
|
||||
'review_request',
|
||||
'review_request_changes',
|
||||
'task_add_comment',
|
||||
'task_attach_comment_file',
|
||||
'task_attach_file',
|
||||
'task_briefing',
|
||||
'task_complete',
|
||||
'task_create',
|
||||
'task_get',
|
||||
'task_link',
|
||||
'task_list',
|
||||
'task_set_clarification',
|
||||
'task_set_owner',
|
||||
'task_set_status',
|
||||
'task_start',
|
||||
'task_unlink',
|
||||
] as const;
|
||||
|
||||
function getTool(name: string) {
|
||||
const tool = tools.get(name);
|
||||
expect(tool).toBeDefined();
|
||||
return tool!;
|
||||
}
|
||||
|
||||
function makeClaudeDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-'));
|
||||
}
|
||||
|
||||
it('registers the full expected MCP tool surface', () => {
|
||||
expect([...tools.keys()].sort()).toEqual([...expectedToolNames]);
|
||||
});
|
||||
|
||||
it('accepts explicit conversation threading fields for cross_team_send', () => {
|
||||
const parsed = getTool('cross_team_send').parameters?.safeParse({
|
||||
teamName: 'alpha',
|
||||
toTeam: 'beta',
|
||||
text: 'Reply',
|
||||
conversationId: 'conv-1',
|
||||
replyToConversationId: 'conv-1',
|
||||
});
|
||||
|
||||
expect(parsed?.success).toBe(true);
|
||||
});
|
||||
|
||||
it('covers task lifecycle, attachments, relationships, kanban, and review flows', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'alpha';
|
||||
const attachmentPath = path.join(claudeDir, 'note.txt');
|
||||
fs.writeFileSync(attachmentPath, 'ship it');
|
||||
|
||||
const dependencyTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Dependency',
|
||||
})
|
||||
);
|
||||
|
||||
const createdTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Review MCP adapter',
|
||||
owner: 'alice',
|
||||
})
|
||||
);
|
||||
expect(createdTask.status).toBe('pending');
|
||||
|
||||
const listedTasks = parseJsonToolResult(
|
||||
await getTool('task_list').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
})
|
||||
);
|
||||
expect(listedTasks).toHaveLength(2);
|
||||
|
||||
const linked = parseJsonToolResult(
|
||||
await getTool('task_link').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
targetId: dependencyTask.id,
|
||||
relationship: 'blocked-by',
|
||||
})
|
||||
);
|
||||
expect(linked.blockedBy).toContain(dependencyTask.id);
|
||||
|
||||
const unlinked = parseJsonToolResult(
|
||||
await getTool('task_unlink').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
targetId: dependencyTask.id,
|
||||
relationship: 'blocked-by',
|
||||
})
|
||||
);
|
||||
expect(unlinked.blockedBy ?? []).not.toContain(dependencyTask.id);
|
||||
|
||||
const owned = parseJsonToolResult(
|
||||
await getTool('task_set_owner').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
owner: 'alice',
|
||||
})
|
||||
);
|
||||
expect(owned.owner).toBe('alice');
|
||||
|
||||
const commented = parseJsonToolResult(
|
||||
await getTool('task_add_comment').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
text: 'Need one more check',
|
||||
from: 'lead',
|
||||
})
|
||||
);
|
||||
|
||||
const commentId = commented.commentId;
|
||||
expect(commentId).toBeTruthy();
|
||||
|
||||
const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
|
||||
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
||||
expect(ownerInbox.at(-1).summary).toContain(`#${createdTask.displayId}`);
|
||||
expect(ownerInbox.at(-1).text).toContain('Need one more check');
|
||||
|
||||
const attachment = parseJsonToolResult(
|
||||
await getTool('task_attach_comment_file').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
commentId,
|
||||
filePath: attachmentPath,
|
||||
mode: 'copy',
|
||||
})
|
||||
);
|
||||
|
||||
expect(attachment.filename).toBe('note.txt');
|
||||
|
||||
const taskAttachment = parseJsonToolResult(
|
||||
await getTool('task_attach_file').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
filePath: attachmentPath,
|
||||
mode: 'copy',
|
||||
})
|
||||
);
|
||||
expect(taskAttachment.filename).toBe('note.txt');
|
||||
|
||||
await getTool('task_set_clarification').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
value: 'user',
|
||||
});
|
||||
|
||||
const loadedTask = parseJsonToolResult(
|
||||
await getTool('task_get').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
})
|
||||
);
|
||||
|
||||
expect(loadedTask.needsClarification).toBe('user');
|
||||
expect(loadedTask.comments).toHaveLength(1);
|
||||
expect(loadedTask.comments[0].attachments).toHaveLength(1);
|
||||
expect(loadedTask.attachments).toHaveLength(1);
|
||||
|
||||
const started = parseJsonToolResult(
|
||||
await getTool('task_start').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
actor: 'alice',
|
||||
})
|
||||
);
|
||||
expect(started.status).toBe('in_progress');
|
||||
|
||||
await getTool('task_set_status').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
parseJsonToolResult(
|
||||
await getTool('kanban_add_reviewer').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
reviewer: 'alice',
|
||||
})
|
||||
);
|
||||
const reviewers = parseJsonToolResult(
|
||||
await getTool('kanban_list_reviewers').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
})
|
||||
);
|
||||
expect(reviewers).toEqual(['alice']);
|
||||
|
||||
const reviewRequested = parseJsonToolResult(
|
||||
await getTool('review_request').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
from: 'lead',
|
||||
reviewer: 'alice',
|
||||
leadSessionId: 'session-review-1',
|
||||
})
|
||||
);
|
||||
|
||||
expect(reviewRequested.reviewState).toBe('review');
|
||||
const reviewerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
|
||||
const reviewerInbox = JSON.parse(fs.readFileSync(reviewerInboxPath, 'utf8'));
|
||||
expect(reviewerInbox.at(-1).leadSessionId).toBe('session-review-1');
|
||||
|
||||
const approved = parseJsonToolResult(
|
||||
await getTool('review_approve').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
from: 'lead',
|
||||
note: 'Looks good',
|
||||
notifyOwner: true,
|
||||
leadSessionId: 'session-review-1',
|
||||
})
|
||||
);
|
||||
expect(approved.reviewState).toBe('approved');
|
||||
{
|
||||
const approvedInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
|
||||
const approvedInbox = JSON.parse(fs.readFileSync(approvedInboxPath, 'utf8'));
|
||||
expect(approvedInbox.at(-1).leadSessionId).toBe('session-review-1');
|
||||
}
|
||||
|
||||
const kanbanState = parseJsonToolResult(
|
||||
await getTool('kanban_get').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
})
|
||||
);
|
||||
expect(kanbanState.tasks[createdTask.id].column).toBe('approved');
|
||||
|
||||
const briefing = await getTool('task_briefing').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
memberName: 'alice',
|
||||
});
|
||||
expect((briefing as { content: Array<{ text: string }> }).content[0]?.text).toContain(
|
||||
'Review MCP adapter'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps owner-backed MCP tasks pending by default, supports explicit startImmediately, sends owner notifications, and returns compact task_briefing output', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'gamma';
|
||||
|
||||
const queuedTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Queued work',
|
||||
description: 'Pending description should stay out of briefing details',
|
||||
owner: 'alice',
|
||||
prompt: 'Read the plan before starting.',
|
||||
})
|
||||
);
|
||||
expect(queuedTask.status).toBe('pending');
|
||||
|
||||
const activeTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Active work',
|
||||
description: 'This one is already in progress',
|
||||
owner: 'alice',
|
||||
startImmediately: true,
|
||||
})
|
||||
);
|
||||
expect(activeTask.status).toBe('in_progress');
|
||||
|
||||
await getTool('task_add_comment').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: activeTask.id,
|
||||
text: 'Investigating the active task now.',
|
||||
from: 'alice',
|
||||
});
|
||||
|
||||
const completedTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Done work',
|
||||
description: 'Completed description should also stay compact',
|
||||
owner: 'alice',
|
||||
})
|
||||
);
|
||||
await getTool('task_complete').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: completedTask.id,
|
||||
actor: 'alice',
|
||||
});
|
||||
|
||||
const unassignedTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Assign later',
|
||||
})
|
||||
);
|
||||
await getTool('task_set_owner').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: unassignedTask.id,
|
||||
owner: 'alice',
|
||||
});
|
||||
|
||||
const queuedByHashRef = parseJsonToolResult(
|
||||
await getTool('task_get').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: `#${queuedTask.displayId}`,
|
||||
})
|
||||
);
|
||||
expect(queuedByHashRef.id).toBe(queuedTask.id);
|
||||
|
||||
const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
|
||||
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
||||
expect(ownerInbox).toHaveLength(4);
|
||||
expect(ownerInbox[0].summary).toContain(`#${queuedTask.displayId}`);
|
||||
expect(ownerInbox[0].text).toContain('task_get');
|
||||
expect(ownerInbox[0].text).toContain('task_start');
|
||||
expect(ownerInbox[0].text).toContain('Read the plan before starting.');
|
||||
expect(ownerInbox[3].summary).toContain(`#${unassignedTask.displayId}`);
|
||||
|
||||
const briefing = (await getTool('task_briefing').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
memberName: 'alice',
|
||||
})) as { content: Array<{ text: string }> };
|
||||
const briefingText = briefing.content[0]?.text ?? '';
|
||||
expect(briefingText).toContain('In progress:');
|
||||
expect(briefingText).toContain(`#${activeTask.displayId}`);
|
||||
expect(briefingText).toContain('Description: This one is already in progress');
|
||||
expect(briefingText).toContain('Investigating the active task now.');
|
||||
expect(briefingText).toContain('Pending:');
|
||||
expect(briefingText).toContain(`#${queuedTask.displayId}`);
|
||||
expect(briefingText).not.toContain('Pending description should stay out of briefing details');
|
||||
expect(briefingText).toContain('Completed:');
|
||||
expect(briefingText).toContain(`#${completedTask.displayId}`);
|
||||
expect(briefingText).not.toContain('Completed description should also stay compact');
|
||||
});
|
||||
|
||||
it('covers review_request_changes and full process lifecycle tools', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'beta';
|
||||
|
||||
const createdTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Needs revision',
|
||||
owner: 'bob',
|
||||
})
|
||||
);
|
||||
|
||||
await getTool('task_complete').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
actor: 'bob',
|
||||
});
|
||||
|
||||
await getTool('review_request').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
from: 'lead',
|
||||
reviewer: 'alice',
|
||||
leadSessionId: 'session-review-2',
|
||||
});
|
||||
|
||||
const changesRequested = parseJsonToolResult(
|
||||
await getTool('review_request_changes').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
from: 'alice',
|
||||
comment: 'Please revise this section.',
|
||||
leadSessionId: 'session-review-2',
|
||||
})
|
||||
);
|
||||
|
||||
expect(changesRequested.status).toBe('pending');
|
||||
expect(changesRequested.reviewState).toBe('needsFix');
|
||||
const ownerInboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'bob.json');
|
||||
const ownerInbox = JSON.parse(fs.readFileSync(ownerInboxPath, 'utf8'));
|
||||
expect(ownerInbox.at(-1).leadSessionId).toBe('session-review-2');
|
||||
expect(ownerInbox.at(-1).text).toContain('moved back to pending');
|
||||
|
||||
const taskByHashRef = parseJsonToolResult(
|
||||
await getTool('task_get').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: `#${createdTask.displayId}`,
|
||||
})
|
||||
);
|
||||
expect(taskByHashRef.reviewState).toBe('needsFix');
|
||||
|
||||
const listedTasks = parseJsonToolResult(
|
||||
await getTool('task_list').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
})
|
||||
);
|
||||
expect(listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState).toBe(
|
||||
'needsFix'
|
||||
);
|
||||
|
||||
const kanbanCleared = parseJsonToolResult(
|
||||
await getTool('kanban_clear').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: createdTask.id,
|
||||
})
|
||||
);
|
||||
expect(kanbanCleared.tasks[createdTask.id]).toBeUndefined();
|
||||
|
||||
const pid = process.pid;
|
||||
|
||||
const registered = parseJsonToolResult(
|
||||
await getTool('process_register').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
pid,
|
||||
label: 'vite',
|
||||
command: 'pnpm dev',
|
||||
from: 'lead',
|
||||
port: 3000,
|
||||
})
|
||||
);
|
||||
|
||||
expect(registered.pid).toBe(pid);
|
||||
expect(registered.label).toBe('vite');
|
||||
|
||||
const listed = parseJsonToolResult(
|
||||
await getTool('process_list').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
})
|
||||
);
|
||||
|
||||
expect(listed).toHaveLength(1);
|
||||
expect(listed[0].pid).toBe(pid);
|
||||
|
||||
const stopped = parseJsonToolResult(
|
||||
await getTool('process_stop').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
pid,
|
||||
})
|
||||
);
|
||||
|
||||
expect(stopped.pid).toBe(pid);
|
||||
expect(typeof stopped.stoppedAt).toBe('string');
|
||||
|
||||
const unregistered = parseJsonToolResult(
|
||||
await getTool('process_unregister').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
pid,
|
||||
})
|
||||
);
|
||||
expect(unregistered).toEqual([]);
|
||||
});
|
||||
|
||||
it('persists full message metadata through message_send', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'gamma';
|
||||
|
||||
const sent = parseJsonToolResult(
|
||||
await getTool('message_send').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
to: 'alice',
|
||||
text: 'Check this',
|
||||
from: 'lead',
|
||||
summary: 'Metadata test',
|
||||
source: 'system_notification',
|
||||
leadSessionId: 'session-42',
|
||||
attachments: [{ id: 'att-1', filename: 'note.txt', mimeType: 'text/plain', size: 4 }],
|
||||
})
|
||||
);
|
||||
|
||||
expect(sent.deliveredToInbox).toBe(true);
|
||||
const inboxPath = path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json');
|
||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows[0].source).toBe('system_notification');
|
||||
expect(rows[0].leadSessionId).toBe('session-42');
|
||||
expect(rows[0].attachments[0].filename).toBe('note.txt');
|
||||
});
|
||||
|
||||
it('exposes zod schemas that reject obviously invalid payloads', () => {
|
||||
expect(
|
||||
getTool('task_create').parameters?.safeParse({
|
||||
teamName: 'demo',
|
||||
claudeDir: '/tmp/demo',
|
||||
}).success
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
getTool('process_register').parameters?.safeParse({
|
||||
teamName: 'demo',
|
||||
pid: 0,
|
||||
label: '',
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('task_add_comment succeeds even when owner inbox write fails', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'resilience';
|
||||
|
||||
const task = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
subject: 'Comment resilience test',
|
||||
owner: 'alice',
|
||||
notifyOwner: false,
|
||||
})
|
||||
);
|
||||
|
||||
// Corrupt the inbox file to force notification failure
|
||||
const inboxDir = path.join(claudeDir, 'teams', teamName, 'inboxes');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(inboxDir, 'alice.json'), 'BROKEN JSON');
|
||||
|
||||
const commented = parseJsonToolResult(
|
||||
await getTool('task_add_comment').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: task.id,
|
||||
text: 'Comment should persist despite broken inbox',
|
||||
from: 'bob',
|
||||
})
|
||||
);
|
||||
|
||||
expect(commented.commentId).toBeTruthy();
|
||||
|
||||
// Verify the comment is actually persisted on the task
|
||||
const reloaded = parseJsonToolResult(
|
||||
await getTool('task_get').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: task.id,
|
||||
})
|
||||
);
|
||||
|
||||
expect(reloaded.comments).toHaveLength(1);
|
||||
expect(reloaded.comments[0].text).toBe('Comment should persist despite broken inbox');
|
||||
});
|
||||
});
|
||||
10
mcp-server/tsconfig.test.json
Normal file
10
mcp-server/tsconfig.test.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -8,7 +8,4 @@ export default defineConfig({
|
|||
clean: true,
|
||||
sourcemap: true,
|
||||
dts: false,
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
11
package.json
11
package.json
|
|
@ -30,11 +30,16 @@
|
|||
"dist:linux": "electron-builder --linux",
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:workspace": "pnpm typecheck && pnpm --filter agent-teams-mcp typecheck && pnpm --filter agent-teams-mcp typecheck:test",
|
||||
"lint": "eslint src/ --cache --cache-location .eslintcache --cache-strategy content",
|
||||
"lint:mcp": "pnpm --filter agent-teams-mcp lint",
|
||||
"lint:fix": "eslint src/ --fix --cache --cache-location .eslintcache --cache-strategy content",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
|
||||
"check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build",
|
||||
"build:workspace": "pnpm build && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
||||
"test:workspace": "pnpm test && pnpm --filter agent-teams-controller test && pnpm --filter agent-teams-mcp test",
|
||||
"check:workspace": "pnpm typecheck:workspace && pnpm test:workspace && pnpm build:workspace && pnpm --filter agent-teams-mcp test:e2e",
|
||||
"check": "pnpm check:workspace && pnpm lint && pnpm lint:mcp",
|
||||
"fix": "pnpm lint:fix && pnpm format",
|
||||
"quality": "pnpm check && pnpm format:check && npx knip",
|
||||
"test:chunks": "tsx test/test-chunk-building.ts",
|
||||
|
|
@ -95,6 +100,7 @@
|
|||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
|
|
@ -105,10 +111,13 @@
|
|||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"agent-teams-controller": "workspace:*",
|
||||
"chokidar": "^4.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"croner": "^10.0.1",
|
||||
"cronstrue": "^3.13.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"diff": "^8.0.3",
|
||||
"dompurify": "^3.3.1",
|
||||
|
|
|
|||
|
|
@ -113,6 +113,9 @@ importers:
|
|||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-hover-card':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.8
|
||||
version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
|
@ -143,6 +146,9 @@ importers:
|
|||
'@xterm/xterm':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
agent-teams-controller:
|
||||
specifier: workspace:*
|
||||
version: link:agent-teams-controller
|
||||
chokidar:
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3
|
||||
|
|
@ -155,6 +161,12 @@ importers:
|
|||
cmdk:
|
||||
specifier: 1.0.4
|
||||
version: 1.0.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
croner:
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1
|
||||
cronstrue:
|
||||
specifier: ^3.13.0
|
||||
version: 3.13.0
|
||||
date-fns:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
|
|
@ -373,8 +385,13 @@ importers:
|
|||
specifier: ^3.1.4
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.0.2)(terser@5.46.0)
|
||||
|
||||
agent-teams-controller: {}
|
||||
|
||||
mcp-server:
|
||||
dependencies:
|
||||
agent-teams-controller:
|
||||
specifier: workspace:*
|
||||
version: link:../agent-teams-controller
|
||||
fastmcp:
|
||||
specifier: ^3.34.0
|
||||
version: 3.34.0
|
||||
|
|
@ -1538,6 +1555,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-hover-card@1.1.15':
|
||||
resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-id@1.1.1':
|
||||
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
||||
peerDependencies:
|
||||
|
|
@ -3047,6 +3077,14 @@ packages:
|
|||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
croner@10.0.1:
|
||||
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
|
||||
engines: {node: '>=18.0'}
|
||||
|
||||
cronstrue@3.13.0:
|
||||
resolution: {integrity: sha512-M06cKwRIN46AyuM8BOmF1HUkBTkd3/h7uYImnrH1T3wtRKBGOibVo3jZ42VheEvx8LtgZbG/4GI35vfIxYxMug==}
|
||||
hasBin: true
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -8061,6 +8099,23 @@ snapshots:
|
|||
'@types/react': 18.3.27
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.27)
|
||||
|
||||
'@radix-ui/react-hover-card@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.27
|
||||
'@types/react-dom': 18.3.7(@types/react@18.3.27)
|
||||
|
||||
'@radix-ui/react-id@1.1.1(@types/react@18.3.27)(react@18.3.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.27)(react@18.3.1)
|
||||
|
|
@ -9754,6 +9809,10 @@ snapshots:
|
|||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
croner@10.0.1: {}
|
||||
|
||||
cronstrue@3.13.0: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
packages:
|
||||
- agent-teams-controller
|
||||
- mcp-server
|
||||
ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
|
|
|
|||
|
|
@ -16,12 +16,19 @@
|
|||
// On Windows this saturates all threads, blocking the event loop.
|
||||
process.env.UV_THREADPOOL_SIZE ??= '16';
|
||||
|
||||
import { CrossTeamService } from '@main/services/team/CrossTeamService';
|
||||
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
|
||||
import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
|
||||
import { FileContentResolver } from '@main/services/team/FileContentResolver';
|
||||
import { GitDiffFallback } from '@main/services/team/GitDiffFallback';
|
||||
import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
|
||||
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
|
||||
import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor';
|
||||
import { SchedulerService } from '@main/services/schedule/SchedulerService';
|
||||
import {
|
||||
CONTEXT_CHANGED,
|
||||
SCHEDULE_CHANGE,
|
||||
SSH_STATUS,
|
||||
TEAM_CHANGE,
|
||||
TEAM_TOOL_APPROVAL_EVENT,
|
||||
|
|
@ -60,12 +67,23 @@ import {
|
|||
ServiceContextRegistry,
|
||||
SshConnectionManager,
|
||||
TaskBoundaryParser,
|
||||
TeamAgentToolsInstaller,
|
||||
TeamDataService,
|
||||
TeamMemberLogsFinder,
|
||||
TeamProvisioningService,
|
||||
UpdaterService,
|
||||
} from './services';
|
||||
import {
|
||||
ApiKeyService,
|
||||
ExtensionFacadeService,
|
||||
GlamaMcpEnrichmentService,
|
||||
McpCatalogAggregator,
|
||||
McpInstallationStateService,
|
||||
McpInstallService,
|
||||
OfficialMcpRegistryService,
|
||||
PluginCatalogService,
|
||||
PluginInstallationStateService,
|
||||
PluginInstallService,
|
||||
} from './services/extensions';
|
||||
|
||||
import type { FileChangeEvent } from '@main/types';
|
||||
import type { TeamChangeEvent } from '@shared/types';
|
||||
|
|
@ -267,6 +285,7 @@ async function notifyNewSentMessages(teamName: string): Promise<void> {
|
|||
|
||||
for (let i = 0; i < newMessages.length; i++) {
|
||||
const msg = newMessages[i];
|
||||
if ((msg.to ?? '').trim() !== 'user') continue;
|
||||
// Skip messages sent from our own UI
|
||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||
// Skip internal coordination noise
|
||||
|
|
@ -318,6 +337,7 @@ let teamProvisioningService: TeamProvisioningService;
|
|||
let cliInstallerService: CliInstallerService;
|
||||
let ptyTerminalService: PtyTerminalService;
|
||||
let httpServer: HttpServer;
|
||||
let schedulerService: SchedulerService;
|
||||
|
||||
// File watcher event cleanup functions
|
||||
let fileChangeCleanup: (() => void) | null = null;
|
||||
|
|
@ -431,17 +451,31 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
|
||||
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||
if (row.type === 'inbox') {
|
||||
// Auto-relay ONLY lead-inbox changes into the live lead process.
|
||||
// (Relaying on *any* inbox change causes the lead to process irrelevant status noise.)
|
||||
if (teamDataService) {
|
||||
void teamDataService
|
||||
.reconcileTeamArtifacts(teamName)
|
||||
.catch((e: unknown) =>
|
||||
logger.warn(`[FileWatcher] reconcile failed for ${teamName}: ${String(e)}`)
|
||||
);
|
||||
}
|
||||
|
||||
// Relay inbox changes into active runtime recipients.
|
||||
if (teamProvisioningService.isTeamAlive(teamName) && detail.startsWith('inboxes/')) {
|
||||
const match = /^inboxes\/(.+)\.json$/.exec(detail);
|
||||
if (match && teamDataService) {
|
||||
const inboxName = match[1];
|
||||
|
||||
// Mark member as online when their first inbox message arrives (spawn tracking).
|
||||
teamProvisioningService.markMemberOnlineFromInbox(teamName, inboxName);
|
||||
|
||||
void teamDataService
|
||||
.getLeadMemberName(teamName)
|
||||
.then((leadName) => {
|
||||
if (!leadName || inboxName !== leadName) return;
|
||||
return teamProvisioningService.relayLeadInboxMessages(teamName);
|
||||
if (!leadName) return;
|
||||
if (inboxName === leadName) {
|
||||
return teamProvisioningService.relayLeadInboxMessages(teamName);
|
||||
}
|
||||
return teamProvisioningService.relayMemberInboxMessages(teamName, inboxName);
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
logger.warn(`[FileWatcher] relay failed for ${teamName}: ${String(e)}`)
|
||||
|
|
@ -480,6 +514,12 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
|
||||
// --- Task change events: notify lead when teammate starts a task via CLI ---
|
||||
if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
|
||||
void teamDataService
|
||||
.reconcileTeamArtifacts(teamName)
|
||||
.catch((e: unknown) =>
|
||||
logger.warn(`[FileWatcher] task reconcile failed for ${teamName}: ${String(e)}`)
|
||||
);
|
||||
|
||||
const taskId = detail.replace('.json', '');
|
||||
void teamDataService
|
||||
.notifyLeadOnTeammateTaskStart(teamName, taskId)
|
||||
|
|
@ -631,6 +671,18 @@ function initializeServices(): void {
|
|||
ptyTerminalService = new PtyTerminalService();
|
||||
teamDataService = new TeamDataService();
|
||||
teamProvisioningService = new TeamProvisioningService();
|
||||
|
||||
// Cross-team communication service
|
||||
const crossTeamConfigReader = new TeamConfigReader();
|
||||
const crossTeamInboxWriter = new TeamInboxWriter();
|
||||
const crossTeamService = new CrossTeamService(
|
||||
crossTeamConfigReader,
|
||||
teamDataService,
|
||||
crossTeamInboxWriter,
|
||||
teamProvisioningService
|
||||
);
|
||||
teamProvisioningService.setCrossTeamSender((request) => crossTeamService.send(request));
|
||||
|
||||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
|
||||
const taskBoundaryParser = new TaskBoundaryParser();
|
||||
|
|
@ -639,6 +691,37 @@ function initializeServices(): void {
|
|||
const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback);
|
||||
const reviewApplier = new ReviewApplierService();
|
||||
|
||||
// Create SchedulerService for cron-based task execution
|
||||
const scheduleRepository = new JsonScheduleRepository();
|
||||
const scheduledTaskExecutor = new ScheduledTaskExecutor();
|
||||
schedulerService = new SchedulerService(
|
||||
scheduleRepository,
|
||||
scheduledTaskExecutor,
|
||||
async (cwd: string) => {
|
||||
const result = await teamProvisioningService.prepareForProvisioning(cwd, {
|
||||
forceFresh: true,
|
||||
});
|
||||
return { ready: result.ready, message: result.message };
|
||||
}
|
||||
);
|
||||
// Extension Store services
|
||||
const pluginCatalogService = new PluginCatalogService();
|
||||
const pluginStateService = new PluginInstallationStateService();
|
||||
const officialMcpRegistry = new OfficialMcpRegistryService();
|
||||
const glamaMcpService = new GlamaMcpEnrichmentService();
|
||||
const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService);
|
||||
const mcpStateService = new McpInstallationStateService();
|
||||
const extensionFacadeService = new ExtensionFacadeService(
|
||||
pluginCatalogService,
|
||||
pluginStateService,
|
||||
mcpAggregator,
|
||||
mcpStateService
|
||||
);
|
||||
|
||||
// Install services — pass null for binary (uses PATH lookup via execCli fallback)
|
||||
const pluginInstallService = new PluginInstallService(null, pluginCatalogService);
|
||||
const mcpInstallService = new McpInstallService(null, mcpAggregator);
|
||||
const apiKeyService = new ApiKeyService();
|
||||
// warmup() and ensureInstalled() are deferred to after window creation
|
||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||
httpServer = new HttpServer();
|
||||
|
|
@ -652,6 +735,13 @@ function initializeServices(): void {
|
|||
};
|
||||
teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter);
|
||||
|
||||
// Allow SchedulerService to push schedule events to renderer
|
||||
schedulerService.setChangeEmitter((event) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(SCHEDULE_CHANGE, event);
|
||||
}
|
||||
});
|
||||
|
||||
teamProvisioningService.setToolApprovalEventEmitter((event) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(TEAM_TOOL_APPROVAL_EVENT, event);
|
||||
|
|
@ -675,6 +765,7 @@ function initializeServices(): void {
|
|||
full: onContextSwitched,
|
||||
onClaudeRootPathUpdated: (_claudeRootPath: string | null) => {
|
||||
reconfigureLocalContextForClaudeRoot();
|
||||
void schedulerService?.reloadForClaudeRootChange();
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -686,7 +777,13 @@ function initializeServices(): void {
|
|||
reviewApplier,
|
||||
gitDiffFallback,
|
||||
cliInstallerService,
|
||||
ptyTerminalService
|
||||
ptyTerminalService,
|
||||
schedulerService,
|
||||
extensionFacadeService,
|
||||
pluginInstallService,
|
||||
mcpInstallService,
|
||||
apiKeyService,
|
||||
crossTeamService
|
||||
);
|
||||
|
||||
// Forward SSH state changes to renderer and HTTP SSE clients
|
||||
|
|
@ -795,6 +892,11 @@ function shutdownServices(): void {
|
|||
teamDataService.stopProcessHealthPolling();
|
||||
}
|
||||
|
||||
// Stop scheduled task execution and croner jobs
|
||||
if (schedulerService) {
|
||||
void schedulerService.stop();
|
||||
}
|
||||
|
||||
// Kill all PTY processes
|
||||
if (ptyTerminalService) {
|
||||
ptyTerminalService.killAll();
|
||||
|
|
@ -941,8 +1043,8 @@ function createWindow(): void {
|
|||
// The window is now visible and responsive; these run in the background.
|
||||
setTimeout(() => {
|
||||
void teamProvisioningService.warmup();
|
||||
void new TeamAgentToolsInstaller().ensureInstalled();
|
||||
teamDataService.startProcessHealthPolling();
|
||||
void schedulerService?.start();
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
101
src/main/ipc/crossTeam.ts
Normal file
101
src/main/ipc/crossTeam.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import {
|
||||
CROSS_TEAM_GET_OUTBOX,
|
||||
CROSS_TEAM_LIST_TARGETS,
|
||||
CROSS_TEAM_SEND,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { isAgentActionMode } from '../services/team/actionModeInstructions';
|
||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
import type { IpcResult } from '@shared/types';
|
||||
|
||||
const logger = createLogger('IPC:crossTeam');
|
||||
|
||||
let crossTeamService: CrossTeamService | null = null;
|
||||
|
||||
export function initializeCrossTeamHandlers(service: CrossTeamService): void {
|
||||
crossTeamService = service;
|
||||
}
|
||||
|
||||
function getService(): CrossTeamService {
|
||||
if (!crossTeamService) {
|
||||
throw new Error('CrossTeamService not initialized');
|
||||
}
|
||||
return crossTeamService;
|
||||
}
|
||||
|
||||
async function wrapCrossTeamHandler<T>(
|
||||
operation: string,
|
||||
handler: () => Promise<T>
|
||||
): Promise<IpcResult<T>> {
|
||||
try {
|
||||
const data = await handler();
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`[crossTeam:${operation}] ${message}`);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend(
|
||||
_event: IpcMainInvokeEvent,
|
||||
request: unknown
|
||||
): Promise<IpcResult<unknown>> {
|
||||
return wrapCrossTeamHandler('send', () => {
|
||||
if (!request || typeof request !== 'object') {
|
||||
throw new Error('Invalid request');
|
||||
}
|
||||
const req = request as Record<string, unknown>;
|
||||
if (req.actionMode !== undefined && !isAgentActionMode(req.actionMode)) {
|
||||
throw new Error('actionMode must be one of: do, ask, delegate');
|
||||
}
|
||||
return getService().send({
|
||||
fromTeam: String(req.fromTeam ?? ''),
|
||||
fromMember: String(req.fromMember ?? ''),
|
||||
toTeam: String(req.toTeam ?? ''),
|
||||
conversationId: typeof req.conversationId === 'string' ? req.conversationId : undefined,
|
||||
replyToConversationId:
|
||||
typeof req.replyToConversationId === 'string' ? req.replyToConversationId : undefined,
|
||||
text: String(req.text ?? ''),
|
||||
actionMode: isAgentActionMode(req.actionMode) ? req.actionMode : undefined,
|
||||
summary: typeof req.summary === 'string' ? req.summary : undefined,
|
||||
chainDepth: typeof req.chainDepth === 'number' ? req.chainDepth : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleListTargets(
|
||||
_event: IpcMainInvokeEvent,
|
||||
excludeTeam?: string
|
||||
): Promise<IpcResult<unknown>> {
|
||||
return wrapCrossTeamHandler('listTargets', () =>
|
||||
getService().listAvailableTargets(typeof excludeTeam === 'string' ? excludeTeam : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGetOutbox(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: string
|
||||
): Promise<IpcResult<unknown>> {
|
||||
return wrapCrossTeamHandler('getOutbox', () => {
|
||||
if (typeof teamName !== 'string' || !teamName.trim()) {
|
||||
throw new Error('teamName is required');
|
||||
}
|
||||
return getService().getOutbox(teamName);
|
||||
});
|
||||
}
|
||||
|
||||
export function registerCrossTeamHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.handle(CROSS_TEAM_SEND, handleSend);
|
||||
ipcMain.handle(CROSS_TEAM_LIST_TARGETS, handleListTargets);
|
||||
ipcMain.handle(CROSS_TEAM_GET_OUTBOX, handleGetOutbox);
|
||||
}
|
||||
|
||||
export function removeCrossTeamHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(CROSS_TEAM_SEND);
|
||||
ipcMain.removeHandler(CROSS_TEAM_LIST_TARGETS);
|
||||
ipcMain.removeHandler(CROSS_TEAM_GET_OUTBOX);
|
||||
}
|
||||
411
src/main/ipc/extensions.ts
Normal file
411
src/main/ipc/extensions.ts
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
/**
|
||||
* IPC handlers for Extension Store (plugin catalog + MCP registry).
|
||||
*
|
||||
* Phase 2: read-only plugin catalog (getAll, getReadme).
|
||||
* Phase 3: read-only MCP registry (search, browse, getById, getInstalled).
|
||||
* Phase 5: install/uninstall mutations.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type {
|
||||
ApiKeyEntry,
|
||||
ApiKeyLookupResult,
|
||||
ApiKeySaveRequest,
|
||||
ApiKeyStorageStatus,
|
||||
EnrichedPlugin,
|
||||
InstalledMcpEntry,
|
||||
McpCatalogItem,
|
||||
McpCustomInstallRequest,
|
||||
McpInstallRequest,
|
||||
McpSearchResult,
|
||||
OperationResult,
|
||||
PluginInstallRequest,
|
||||
} from '@shared/types/extensions';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
||||
|
||||
import {
|
||||
API_KEYS_DELETE,
|
||||
API_KEYS_LIST,
|
||||
API_KEYS_LOOKUP,
|
||||
API_KEYS_SAVE,
|
||||
API_KEYS_STORAGE_STATUS,
|
||||
MCP_GITHUB_STARS,
|
||||
MCP_REGISTRY_BROWSE,
|
||||
MCP_REGISTRY_GET_BY_ID,
|
||||
MCP_REGISTRY_GET_INSTALLED,
|
||||
MCP_REGISTRY_INSTALL,
|
||||
MCP_REGISTRY_INSTALL_CUSTOM,
|
||||
MCP_REGISTRY_SEARCH,
|
||||
MCP_REGISTRY_UNINSTALL,
|
||||
PLUGIN_GET_ALL,
|
||||
PLUGIN_GET_README,
|
||||
PLUGIN_INSTALL,
|
||||
PLUGIN_UNINSTALL,
|
||||
} from '@preload/constants/ipcChannels';
|
||||
|
||||
import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService';
|
||||
|
||||
const logger = createLogger('IPC:extensions');
|
||||
|
||||
/** Allowed scope values */
|
||||
const VALID_SCOPES = new Set(['local', 'user', 'project']);
|
||||
|
||||
// ── Module state ───────────────────────────────────────────────────────────
|
||||
|
||||
const starsService = new GitHubStarsService();
|
||||
|
||||
let extensionFacade: ExtensionFacadeService | null = null;
|
||||
let pluginInstaller: PluginInstallService | null = null;
|
||||
let mcpInstaller: McpInstallService | null = null;
|
||||
let apiKeyService: ApiKeyService | null = null;
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function initializeExtensionHandlers(
|
||||
facade: ExtensionFacadeService,
|
||||
pluginInstall?: PluginInstallService,
|
||||
mcpInstall?: McpInstallService,
|
||||
apiKeys?: ApiKeyService
|
||||
): void {
|
||||
extensionFacade = facade;
|
||||
pluginInstaller = pluginInstall ?? null;
|
||||
mcpInstaller = mcpInstall ?? null;
|
||||
apiKeyService = apiKeys ?? null;
|
||||
}
|
||||
|
||||
export function registerExtensionHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.handle(PLUGIN_GET_ALL, handleGetAll);
|
||||
ipcMain.handle(PLUGIN_GET_README, handleGetReadme);
|
||||
ipcMain.handle(PLUGIN_INSTALL, handlePluginInstall);
|
||||
ipcMain.handle(PLUGIN_UNINSTALL, handlePluginUninstall);
|
||||
ipcMain.handle(MCP_REGISTRY_SEARCH, handleMcpSearch);
|
||||
ipcMain.handle(MCP_REGISTRY_BROWSE, handleMcpBrowse);
|
||||
ipcMain.handle(MCP_REGISTRY_GET_BY_ID, handleMcpGetById);
|
||||
ipcMain.handle(MCP_REGISTRY_GET_INSTALLED, handleMcpGetInstalled);
|
||||
ipcMain.handle(MCP_REGISTRY_INSTALL, handleMcpInstall);
|
||||
ipcMain.handle(MCP_REGISTRY_INSTALL_CUSTOM, handleMcpInstallCustom);
|
||||
ipcMain.handle(MCP_REGISTRY_UNINSTALL, handleMcpUninstall);
|
||||
ipcMain.handle(API_KEYS_LIST, handleApiKeysList);
|
||||
ipcMain.handle(API_KEYS_SAVE, handleApiKeysSave);
|
||||
ipcMain.handle(API_KEYS_DELETE, handleApiKeysDelete);
|
||||
ipcMain.handle(API_KEYS_LOOKUP, handleApiKeysLookup);
|
||||
ipcMain.handle(API_KEYS_STORAGE_STATUS, handleApiKeysStorageStatus);
|
||||
ipcMain.handle(MCP_GITHUB_STARS, handleMcpGitHubStars);
|
||||
}
|
||||
|
||||
export function removeExtensionHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(PLUGIN_GET_ALL);
|
||||
ipcMain.removeHandler(PLUGIN_GET_README);
|
||||
ipcMain.removeHandler(PLUGIN_INSTALL);
|
||||
ipcMain.removeHandler(PLUGIN_UNINSTALL);
|
||||
ipcMain.removeHandler(MCP_REGISTRY_SEARCH);
|
||||
ipcMain.removeHandler(MCP_REGISTRY_BROWSE);
|
||||
ipcMain.removeHandler(MCP_REGISTRY_GET_BY_ID);
|
||||
ipcMain.removeHandler(MCP_REGISTRY_GET_INSTALLED);
|
||||
ipcMain.removeHandler(MCP_REGISTRY_INSTALL);
|
||||
ipcMain.removeHandler(MCP_REGISTRY_INSTALL_CUSTOM);
|
||||
ipcMain.removeHandler(MCP_REGISTRY_UNINSTALL);
|
||||
ipcMain.removeHandler(API_KEYS_LIST);
|
||||
ipcMain.removeHandler(API_KEYS_SAVE);
|
||||
ipcMain.removeHandler(API_KEYS_DELETE);
|
||||
ipcMain.removeHandler(API_KEYS_LOOKUP);
|
||||
ipcMain.removeHandler(API_KEYS_STORAGE_STATUS);
|
||||
ipcMain.removeHandler(MCP_GITHUB_STARS);
|
||||
}
|
||||
|
||||
// ── Service guard ──────────────────────────────────────────────────────────
|
||||
|
||||
function getFacade(): ExtensionFacadeService {
|
||||
if (!extensionFacade) {
|
||||
throw new Error('Extension handlers are not initialized');
|
||||
}
|
||||
return extensionFacade;
|
||||
}
|
||||
|
||||
// ── Error wrapper ──────────────────────────────────────────────────────────
|
||||
|
||||
interface IpcResult<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function wrapHandler<T>(operation: string, handler: () => Promise<T>): Promise<IpcResult<T>> {
|
||||
try {
|
||||
const data = await handler();
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`[extensions:${operation}] ${message}`);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plugin Handlers ────────────────────────────────────────────────────────
|
||||
|
||||
async function handleGetAll(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectPath?: string,
|
||||
forceRefresh?: boolean
|
||||
): Promise<IpcResult<EnrichedPlugin[]>> {
|
||||
return wrapHandler('getAll', () =>
|
||||
getFacade().getEnrichedPlugins(
|
||||
typeof projectPath === 'string' ? projectPath : undefined,
|
||||
typeof forceRefresh === 'boolean' ? forceRefresh : false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGetReadme(
|
||||
_event: IpcMainInvokeEvent,
|
||||
pluginId?: string
|
||||
): Promise<IpcResult<string | null>> {
|
||||
return wrapHandler('getReadme', () => {
|
||||
if (typeof pluginId !== 'string' || !pluginId) {
|
||||
throw new Error('pluginId is required');
|
||||
}
|
||||
return getFacade().getPluginReadme(pluginId);
|
||||
});
|
||||
}
|
||||
|
||||
// ── MCP Handlers ───────────────────────────────────────────────────────────
|
||||
|
||||
async function handleMcpSearch(
|
||||
_event: IpcMainInvokeEvent,
|
||||
query?: string,
|
||||
limit?: number
|
||||
): Promise<IpcResult<McpSearchResult>> {
|
||||
return wrapHandler('mcpSearch', () =>
|
||||
getFacade().searchMcp(
|
||||
typeof query === 'string' ? query : '',
|
||||
typeof limit === 'number' ? limit : undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleMcpBrowse(
|
||||
_event: IpcMainInvokeEvent,
|
||||
cursor?: string,
|
||||
limit?: number
|
||||
): Promise<IpcResult<{ servers: McpCatalogItem[]; nextCursor?: string }>> {
|
||||
return wrapHandler('mcpBrowse', () =>
|
||||
getFacade().browseMcp(
|
||||
typeof cursor === 'string' ? cursor : undefined,
|
||||
typeof limit === 'number' ? limit : undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleMcpGetById(
|
||||
_event: IpcMainInvokeEvent,
|
||||
registryId?: string
|
||||
): Promise<IpcResult<McpCatalogItem | null>> {
|
||||
return wrapHandler('mcpGetById', () => {
|
||||
if (typeof registryId !== 'string' || !registryId) {
|
||||
throw new Error('registryId is required');
|
||||
}
|
||||
return getFacade().getMcpById(registryId);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMcpGetInstalled(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectPath?: string
|
||||
): Promise<IpcResult<InstalledMcpEntry[]>> {
|
||||
return wrapHandler('mcpGetInstalled', () =>
|
||||
getFacade().getInstalledMcp(typeof projectPath === 'string' ? projectPath : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Install/Uninstall Handlers ────────────────────────────────────────────
|
||||
|
||||
function getPluginInstaller(): PluginInstallService {
|
||||
if (!pluginInstaller) {
|
||||
throw new Error('Plugin installer not initialized');
|
||||
}
|
||||
return pluginInstaller;
|
||||
}
|
||||
|
||||
function getMcpInstaller(): McpInstallService {
|
||||
if (!mcpInstaller) {
|
||||
throw new Error('MCP installer not initialized');
|
||||
}
|
||||
return mcpInstaller;
|
||||
}
|
||||
|
||||
async function handlePluginInstall(
|
||||
_event: IpcMainInvokeEvent,
|
||||
request?: PluginInstallRequest
|
||||
): Promise<IpcResult<OperationResult>> {
|
||||
return wrapHandler('pluginInstall', async () => {
|
||||
if (!request || typeof request.pluginId !== 'string' || !request.pluginId) {
|
||||
throw new Error('Invalid install request: pluginId is required');
|
||||
}
|
||||
if (request.scope && !VALID_SCOPES.has(request.scope)) {
|
||||
throw new Error(`Invalid scope: "${request.scope}"`);
|
||||
}
|
||||
const result = await getPluginInstaller().install(request);
|
||||
if (result.state === 'success') {
|
||||
getFacade().invalidateInstalledCache();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePluginUninstall(
|
||||
_event: IpcMainInvokeEvent,
|
||||
pluginId?: string,
|
||||
scope?: string,
|
||||
projectPath?: string
|
||||
): Promise<IpcResult<OperationResult>> {
|
||||
return wrapHandler('pluginUninstall', async () => {
|
||||
if (typeof pluginId !== 'string' || !pluginId) {
|
||||
throw new Error('pluginId is required');
|
||||
}
|
||||
if (scope && !VALID_SCOPES.has(scope)) {
|
||||
throw new Error(`Invalid scope: "${scope}"`);
|
||||
}
|
||||
const result = await getPluginInstaller().uninstall(
|
||||
pluginId,
|
||||
typeof scope === 'string' ? scope : undefined,
|
||||
typeof projectPath === 'string' ? projectPath : undefined
|
||||
);
|
||||
if (result.state === 'success') {
|
||||
getFacade().invalidateInstalledCache();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMcpInstall(
|
||||
_event: IpcMainInvokeEvent,
|
||||
request?: McpInstallRequest
|
||||
): Promise<IpcResult<OperationResult>> {
|
||||
return wrapHandler('mcpInstall', async () => {
|
||||
if (!request || typeof request.registryId !== 'string' || !request.registryId) {
|
||||
throw new Error('Invalid install request: registryId is required');
|
||||
}
|
||||
if (typeof request.serverName !== 'string' || !request.serverName) {
|
||||
throw new Error('Invalid install request: serverName is required');
|
||||
}
|
||||
if (request.scope && !VALID_SCOPES.has(request.scope)) {
|
||||
throw new Error(`Invalid scope: "${request.scope}"`);
|
||||
}
|
||||
const result = await getMcpInstaller().install(request);
|
||||
if (result.state === 'success') {
|
||||
getFacade().invalidateInstalledCache();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMcpInstallCustom(
|
||||
_event: IpcMainInvokeEvent,
|
||||
request?: McpCustomInstallRequest
|
||||
): Promise<IpcResult<OperationResult>> {
|
||||
return wrapHandler('mcpInstallCustom', async () => {
|
||||
if (!request || typeof request.serverName !== 'string' || !request.serverName) {
|
||||
throw new Error('Invalid custom install request: serverName is required');
|
||||
}
|
||||
if (!request.installSpec) {
|
||||
throw new Error('Invalid custom install request: installSpec is required');
|
||||
}
|
||||
if (request.scope && !VALID_SCOPES.has(request.scope)) {
|
||||
throw new Error(`Invalid scope: "${request.scope}"`);
|
||||
}
|
||||
const result = await getMcpInstaller().installCustom(request);
|
||||
if (result.state === 'success') {
|
||||
getFacade().invalidateInstalledCache();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMcpUninstall(
|
||||
_event: IpcMainInvokeEvent,
|
||||
name?: string,
|
||||
scope?: string,
|
||||
projectPath?: string
|
||||
): Promise<IpcResult<OperationResult>> {
|
||||
return wrapHandler('mcpUninstall', async () => {
|
||||
if (typeof name !== 'string' || !name) {
|
||||
throw new Error('Server name is required');
|
||||
}
|
||||
if (scope && !VALID_SCOPES.has(scope)) {
|
||||
throw new Error(`Invalid scope: "${scope}"`);
|
||||
}
|
||||
const result = await getMcpInstaller().uninstall(
|
||||
name,
|
||||
typeof scope === 'string' ? scope : undefined,
|
||||
typeof projectPath === 'string' ? projectPath : undefined
|
||||
);
|
||||
if (result.state === 'success') {
|
||||
getFacade().invalidateInstalledCache();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
// ── API Keys Guard ─────────────────────────────────────────────────────────
|
||||
|
||||
function getApiKeyService(): ApiKeyService {
|
||||
if (!apiKeyService) throw new Error('API key service not initialized');
|
||||
return apiKeyService;
|
||||
}
|
||||
|
||||
// ── API Keys Handlers ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleApiKeysList(): Promise<IpcResult<ApiKeyEntry[]>> {
|
||||
return wrapHandler('apiKeysList', () => getApiKeyService().list());
|
||||
}
|
||||
|
||||
async function handleApiKeysSave(
|
||||
_event: IpcMainInvokeEvent,
|
||||
request?: ApiKeySaveRequest
|
||||
): Promise<IpcResult<ApiKeyEntry>> {
|
||||
return wrapHandler('apiKeysSave', () => {
|
||||
if (!request) throw new Error('Request is required');
|
||||
return getApiKeyService().save(request);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleApiKeysDelete(
|
||||
_event: IpcMainInvokeEvent,
|
||||
id?: string
|
||||
): Promise<IpcResult<void>> {
|
||||
return wrapHandler('apiKeysDelete', () => {
|
||||
if (typeof id !== 'string' || !id) throw new Error('Key ID is required');
|
||||
return getApiKeyService().delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleApiKeysLookup(
|
||||
_event: IpcMainInvokeEvent,
|
||||
envVarNames?: string[]
|
||||
): Promise<IpcResult<ApiKeyLookupResult[]>> {
|
||||
return wrapHandler('apiKeysLookup', () => {
|
||||
if (!Array.isArray(envVarNames)) throw new Error('envVarNames array is required');
|
||||
return getApiKeyService().lookup(envVarNames);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleApiKeysStorageStatus(): Promise<IpcResult<ApiKeyStorageStatus>> {
|
||||
return wrapHandler('apiKeysStorageStatus', () => getApiKeyService().getStorageStatus());
|
||||
}
|
||||
|
||||
// ── GitHub Stars Handler ──────────────────────────────────────────────────
|
||||
|
||||
async function handleMcpGitHubStars(
|
||||
_event: IpcMainInvokeEvent,
|
||||
repositoryUrls?: string[]
|
||||
): Promise<IpcResult<Record<string, number>>> {
|
||||
return wrapHandler('mcpGitHubStars', () => {
|
||||
if (!Array.isArray(repositoryUrls)) throw new Error('repositoryUrls array is required');
|
||||
return starsService.fetchStars(repositoryUrls);
|
||||
});
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ const SUBAGENT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|||
const NOTIFICATION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
const TRIGGER_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
const TASK_ID_PATTERN = /^\d{1,10}$/;
|
||||
const TASK_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9-]{0,63}$/;
|
||||
const MEMBER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
const FROM_FIELD_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
||||
|
||||
|
|
@ -130,13 +130,13 @@ export function validateTeamName(teamName: unknown): ValidationResult<string> {
|
|||
}
|
||||
|
||||
export function validateTaskId(taskId: unknown): ValidationResult<string> {
|
||||
const basic = validateString(taskId, 'taskId', 16);
|
||||
const basic = validateString(taskId, 'taskId', 64);
|
||||
if (!basic.valid) {
|
||||
return basic;
|
||||
}
|
||||
|
||||
if (!TASK_ID_PATTERN.test(basic.value!)) {
|
||||
return { valid: false, error: 'taskId must contain only digits' };
|
||||
return { valid: false, error: 'taskId contains invalid characters' };
|
||||
}
|
||||
|
||||
return { valid: true, value: basic.value };
|
||||
|
|
|
|||
|
|
@ -28,7 +28,17 @@ import {
|
|||
registerContextHandlers,
|
||||
removeContextHandlers,
|
||||
} from './context';
|
||||
import {
|
||||
initializeCrossTeamHandlers,
|
||||
registerCrossTeamHandlers,
|
||||
removeCrossTeamHandlers,
|
||||
} from './crossTeam';
|
||||
import { initializeEditorHandlers, registerEditorHandlers, removeEditorHandlers } from './editor';
|
||||
import {
|
||||
initializeExtensionHandlers,
|
||||
registerExtensionHandlers,
|
||||
removeExtensionHandlers,
|
||||
} from './extensions';
|
||||
import {
|
||||
initializeHttpServerHandlers,
|
||||
registerHttpServerHandlers,
|
||||
|
|
@ -44,6 +54,11 @@ import {
|
|||
} from './projects';
|
||||
import { registerRendererLogHandlers, removeRendererLogHandlers } from './rendererLogs';
|
||||
import { initializeReviewHandlers, registerReviewHandlers, removeReviewHandlers } from './review';
|
||||
import {
|
||||
initializeScheduleHandlers,
|
||||
registerScheduleHandlers,
|
||||
removeScheduleHandlers,
|
||||
} from './schedule';
|
||||
import { initializeSearchHandlers, registerSearchHandlers, removeSearchHandlers } from './search';
|
||||
import {
|
||||
initializeSessionHandlers,
|
||||
|
|
@ -88,6 +103,12 @@ import type {
|
|||
UpdaterService,
|
||||
} from '../services';
|
||||
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
||||
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
||||
import type { SchedulerService } from '../services/schedule/SchedulerService';
|
||||
|
||||
/**
|
||||
* Initializes IPC handlers with service registry.
|
||||
|
|
@ -114,7 +135,13 @@ export function initializeIpcHandlers(
|
|||
reviewApplier?: ReviewApplierService,
|
||||
gitDiffFallback?: GitDiffFallback,
|
||||
cliInstaller?: CliInstallerService,
|
||||
ptyTerminal?: PtyTerminalService
|
||||
ptyTerminal?: PtyTerminalService,
|
||||
schedulerService?: SchedulerService,
|
||||
extensionFacade?: ExtensionFacadeService,
|
||||
pluginInstaller?: PluginInstallService,
|
||||
mcpInstaller?: McpInstallService,
|
||||
apiKeyService?: ApiKeyService,
|
||||
crossTeamService?: CrossTeamService
|
||||
): void {
|
||||
// Initialize domain handlers with registry
|
||||
initializeProjectHandlers(registry);
|
||||
|
|
@ -146,6 +173,15 @@ export function initializeIpcHandlers(
|
|||
initializeTerminalHandlers(ptyTerminal);
|
||||
}
|
||||
initializeEditorHandlers();
|
||||
if (schedulerService) {
|
||||
initializeScheduleHandlers(schedulerService);
|
||||
}
|
||||
if (extensionFacade) {
|
||||
initializeExtensionHandlers(extensionFacade, pluginInstaller, mcpInstaller, apiKeyService);
|
||||
}
|
||||
if (crossTeamService) {
|
||||
initializeCrossTeamHandlers(crossTeamService);
|
||||
}
|
||||
|
||||
if (changeExtractor) {
|
||||
initializeReviewHandlers({
|
||||
|
|
@ -173,6 +209,7 @@ export function initializeIpcHandlers(
|
|||
registerEditorHandlers(ipcMain);
|
||||
registerWindowHandlers(ipcMain);
|
||||
registerRendererLogHandlers(ipcMain);
|
||||
registerScheduleHandlers(ipcMain);
|
||||
if (cliInstaller) {
|
||||
registerCliInstallerHandlers(ipcMain);
|
||||
}
|
||||
|
|
@ -182,6 +219,12 @@ export function initializeIpcHandlers(
|
|||
if (httpServerDeps) {
|
||||
registerHttpServerHandlers(ipcMain);
|
||||
}
|
||||
if (extensionFacade) {
|
||||
registerExtensionHandlers(ipcMain);
|
||||
}
|
||||
if (crossTeamService) {
|
||||
registerCrossTeamHandlers(ipcMain);
|
||||
}
|
||||
|
||||
logger.info('All handlers registered');
|
||||
}
|
||||
|
|
@ -207,9 +250,12 @@ export function removeIpcHandlers(): void {
|
|||
removeEditorHandlers(ipcMain);
|
||||
removeWindowHandlers(ipcMain);
|
||||
removeRendererLogHandlers(ipcMain);
|
||||
removeScheduleHandlers(ipcMain);
|
||||
removeCliInstallerHandlers(ipcMain);
|
||||
removeTerminalHandlers(ipcMain);
|
||||
removeHttpServerHandlers(ipcMain);
|
||||
removeExtensionHandlers(ipcMain);
|
||||
removeCrossTeamHandlers(ipcMain);
|
||||
|
||||
logger.info('All handlers removed');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ async function handleGetTaskChanges(
|
|||
typeof (i as Record<string, unknown>).completedAt === 'string')
|
||||
) as { startedAt: string; completedAt?: string }[])
|
||||
: undefined,
|
||||
summaryOnly: (options as Record<string, unknown>).summaryOnly === true,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
|
|
|
|||
204
src/main/ipc/schedule.ts
Normal file
204
src/main/ipc/schedule.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
/**
|
||||
* IPC handlers for scheduled tasks.
|
||||
*
|
||||
* Pattern: initializeScheduleHandlers(service) → registerScheduleHandlers(ipcMain)
|
||||
* → removeScheduleHandlers(ipcMain)
|
||||
*/
|
||||
|
||||
import {
|
||||
SCHEDULE_CREATE,
|
||||
SCHEDULE_DELETE,
|
||||
SCHEDULE_GET,
|
||||
SCHEDULE_GET_RUN_LOGS,
|
||||
SCHEDULE_GET_RUNS,
|
||||
SCHEDULE_LIST,
|
||||
SCHEDULE_PAUSE,
|
||||
SCHEDULE_RESUME,
|
||||
SCHEDULE_TRIGGER_NOW,
|
||||
SCHEDULE_UPDATE,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { SchedulerService } from '../services/schedule/SchedulerService';
|
||||
import type {
|
||||
CreateScheduleInput,
|
||||
IpcResult,
|
||||
Schedule,
|
||||
ScheduleRun,
|
||||
UpdateSchedulePatch,
|
||||
} from '@shared/types';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
|
||||
const logger = createLogger('IPC:schedule');
|
||||
|
||||
let schedulerService: SchedulerService | null = null;
|
||||
|
||||
function getService(): SchedulerService {
|
||||
if (!schedulerService) {
|
||||
throw new Error('SchedulerService not initialized');
|
||||
}
|
||||
return schedulerService;
|
||||
}
|
||||
|
||||
async function wrapScheduleHandler<T>(
|
||||
operation: string,
|
||||
handler: () => Promise<T>
|
||||
): Promise<IpcResult<T>> {
|
||||
try {
|
||||
const data = await handler();
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`[schedule:${operation}] ${message}`);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Handlers
|
||||
// =============================================================================
|
||||
|
||||
async function handleList(_event: IpcMainInvokeEvent): Promise<IpcResult<Schedule[]>> {
|
||||
return wrapScheduleHandler('list', () => getService().listSchedules());
|
||||
}
|
||||
|
||||
async function handleGet(
|
||||
_event: IpcMainInvokeEvent,
|
||||
id: unknown
|
||||
): Promise<IpcResult<Schedule | null>> {
|
||||
if (typeof id !== 'string' || !id.trim()) {
|
||||
return { success: false, error: 'id must be a non-empty string' };
|
||||
}
|
||||
return wrapScheduleHandler('get', () => getService().getSchedule(id));
|
||||
}
|
||||
|
||||
async function handleCreate(
|
||||
_event: IpcMainInvokeEvent,
|
||||
input: unknown
|
||||
): Promise<IpcResult<Schedule>> {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return { success: false, error: 'input must be an object' };
|
||||
}
|
||||
const inp = input as CreateScheduleInput;
|
||||
if (!inp.teamName || !inp.cronExpression || !inp.timezone || !inp.launchConfig) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Missing required fields: teamName, cronExpression, timezone, launchConfig',
|
||||
};
|
||||
}
|
||||
if (!inp.launchConfig.cwd || !inp.launchConfig.prompt) {
|
||||
return { success: false, error: 'launchConfig requires cwd and prompt' };
|
||||
}
|
||||
return wrapScheduleHandler('create', () => getService().createSchedule(inp));
|
||||
}
|
||||
|
||||
async function handleUpdate(
|
||||
_event: IpcMainInvokeEvent,
|
||||
id: unknown,
|
||||
patch: unknown
|
||||
): Promise<IpcResult<Schedule>> {
|
||||
if (typeof id !== 'string' || !id.trim()) {
|
||||
return { success: false, error: 'id must be a non-empty string' };
|
||||
}
|
||||
if (!patch || typeof patch !== 'object') {
|
||||
return { success: false, error: 'patch must be an object' };
|
||||
}
|
||||
return wrapScheduleHandler('update', () =>
|
||||
getService().updateSchedule(id, patch as UpdateSchedulePatch)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDelete(_event: IpcMainInvokeEvent, id: unknown): Promise<IpcResult<void>> {
|
||||
if (typeof id !== 'string' || !id.trim()) {
|
||||
return { success: false, error: 'id must be a non-empty string' };
|
||||
}
|
||||
return wrapScheduleHandler('delete', () => getService().deleteSchedule(id));
|
||||
}
|
||||
|
||||
async function handlePause(_event: IpcMainInvokeEvent, id: unknown): Promise<IpcResult<void>> {
|
||||
if (typeof id !== 'string' || !id.trim()) {
|
||||
return { success: false, error: 'id must be a non-empty string' };
|
||||
}
|
||||
return wrapScheduleHandler('pause', () => getService().pauseSchedule(id));
|
||||
}
|
||||
|
||||
async function handleResume(_event: IpcMainInvokeEvent, id: unknown): Promise<IpcResult<void>> {
|
||||
if (typeof id !== 'string' || !id.trim()) {
|
||||
return { success: false, error: 'id must be a non-empty string' };
|
||||
}
|
||||
return wrapScheduleHandler('resume', () => getService().resumeSchedule(id));
|
||||
}
|
||||
|
||||
async function handleTriggerNow(
|
||||
_event: IpcMainInvokeEvent,
|
||||
id: unknown
|
||||
): Promise<IpcResult<ScheduleRun>> {
|
||||
if (typeof id !== 'string' || !id.trim()) {
|
||||
return { success: false, error: 'id must be a non-empty string' };
|
||||
}
|
||||
return wrapScheduleHandler('triggerNow', () => getService().triggerNow(id));
|
||||
}
|
||||
|
||||
async function handleGetRuns(
|
||||
_event: IpcMainInvokeEvent,
|
||||
scheduleId: unknown,
|
||||
opts?: unknown
|
||||
): Promise<IpcResult<ScheduleRun[]>> {
|
||||
if (typeof scheduleId !== 'string' || !scheduleId.trim()) {
|
||||
return { success: false, error: 'scheduleId must be a non-empty string' };
|
||||
}
|
||||
const parsedOpts =
|
||||
opts && typeof opts === 'object' ? (opts as { limit?: number; offset?: number }) : undefined;
|
||||
return wrapScheduleHandler('getRuns', () => getService().getRuns(scheduleId, parsedOpts));
|
||||
}
|
||||
|
||||
async function handleGetRunLogs(
|
||||
_event: IpcMainInvokeEvent,
|
||||
scheduleId: unknown,
|
||||
runId: unknown
|
||||
): Promise<IpcResult<{ stdout: string; stderr: string }>> {
|
||||
if (typeof scheduleId !== 'string' || !scheduleId.trim()) {
|
||||
return { success: false, error: 'scheduleId must be a non-empty string' };
|
||||
}
|
||||
if (typeof runId !== 'string' || !runId.trim()) {
|
||||
return { success: false, error: 'runId must be a non-empty string' };
|
||||
}
|
||||
return wrapScheduleHandler('getRunLogs', () => getService().getRunLogs(scheduleId, runId));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Lifecycle
|
||||
// =============================================================================
|
||||
|
||||
export function initializeScheduleHandlers(service: SchedulerService): void {
|
||||
schedulerService = service;
|
||||
}
|
||||
|
||||
export function registerScheduleHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.handle(SCHEDULE_LIST, handleList);
|
||||
ipcMain.handle(SCHEDULE_GET, handleGet);
|
||||
ipcMain.handle(SCHEDULE_CREATE, handleCreate);
|
||||
ipcMain.handle(SCHEDULE_UPDATE, handleUpdate);
|
||||
ipcMain.handle(SCHEDULE_DELETE, handleDelete);
|
||||
ipcMain.handle(SCHEDULE_PAUSE, handlePause);
|
||||
ipcMain.handle(SCHEDULE_RESUME, handleResume);
|
||||
ipcMain.handle(SCHEDULE_TRIGGER_NOW, handleTriggerNow);
|
||||
ipcMain.handle(SCHEDULE_GET_RUNS, handleGetRuns);
|
||||
ipcMain.handle(SCHEDULE_GET_RUN_LOGS, handleGetRunLogs);
|
||||
logger.info('Schedule handlers registered');
|
||||
}
|
||||
|
||||
export function removeScheduleHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(SCHEDULE_LIST);
|
||||
ipcMain.removeHandler(SCHEDULE_GET);
|
||||
ipcMain.removeHandler(SCHEDULE_CREATE);
|
||||
ipcMain.removeHandler(SCHEDULE_UPDATE);
|
||||
ipcMain.removeHandler(SCHEDULE_DELETE);
|
||||
ipcMain.removeHandler(SCHEDULE_PAUSE);
|
||||
ipcMain.removeHandler(SCHEDULE_RESUME);
|
||||
ipcMain.removeHandler(SCHEDULE_TRIGGER_NOW);
|
||||
ipcMain.removeHandler(SCHEDULE_GET_RUNS);
|
||||
ipcMain.removeHandler(SCHEDULE_GET_RUN_LOGS);
|
||||
logger.info('Schedule handlers removed');
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
TEAM_LEAD_CONTEXT,
|
||||
TEAM_MEMBER_SPAWN_STATUSES,
|
||||
TEAM_LIST,
|
||||
TEAM_PERMANENTLY_DELETE,
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
|
|
@ -47,6 +48,7 @@ import {
|
|||
TEAM_START_TASK,
|
||||
TEAM_STOP,
|
||||
TEAM_TOOL_APPROVAL_RESPOND,
|
||||
TEAM_TOOL_APPROVAL_SETTINGS,
|
||||
TEAM_UPDATE_CONFIG,
|
||||
TEAM_UPDATE_KANBAN,
|
||||
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||
|
|
@ -54,11 +56,17 @@ import {
|
|||
TEAM_UPDATE_TASK_FIELDS,
|
||||
TEAM_UPDATE_TASK_OWNER,
|
||||
TEAM_UPDATE_TASK_STATUS,
|
||||
TEAM_VALIDATE_CLI_ARGS,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
|
||||
import {
|
||||
extractFlagsFromHelp,
|
||||
extractUserFlags,
|
||||
PROTECTED_CLI_FLAGS,
|
||||
} from '@shared/utils/cliArgsParser';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
||||
|
|
@ -67,6 +75,10 @@ import * as path from 'path';
|
|||
|
||||
import { ConfigManager } from '../services/infrastructure/ConfigManager';
|
||||
import { NotificationManager } from '../services/infrastructure/NotificationManager';
|
||||
import {
|
||||
buildActionModeAgentBlock,
|
||||
isAgentActionMode,
|
||||
} from '../services/team/actionModeInstructions';
|
||||
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
|
||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||
|
|
@ -86,6 +98,7 @@ import type {
|
|||
TeamProvisioningService,
|
||||
} from '../services';
|
||||
import type {
|
||||
AgentActionMode,
|
||||
AttachmentFileData,
|
||||
AttachmentMeta,
|
||||
AttachmentPayload,
|
||||
|
|
@ -97,6 +110,7 @@ import type {
|
|||
LeadContextUsage,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
MemberSpawnStatusEntry,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskAttachmentMeta,
|
||||
|
|
@ -117,8 +131,10 @@ import type {
|
|||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamUpdateConfigRequest,
|
||||
ToolApprovalSettings,
|
||||
UpdateKanbanPatch,
|
||||
} from '@shared/types';
|
||||
import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
|
||||
|
||||
const logger = createLogger('IPC:teams');
|
||||
|
||||
|
|
@ -239,6 +255,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess);
|
||||
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
|
||||
ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext);
|
||||
ipcMain.handle(TEAM_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses);
|
||||
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
||||
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
|
||||
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
||||
|
|
@ -250,6 +267,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_GET_TASK_ATTACHMENT, handleGetTaskAttachment);
|
||||
ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment);
|
||||
ipcMain.handle(TEAM_TOOL_APPROVAL_RESPOND, handleToolApprovalRespond);
|
||||
ipcMain.handle(TEAM_VALIDATE_CLI_ARGS, handleValidateCliArgs);
|
||||
ipcMain.handle(TEAM_TOOL_APPROVAL_SETTINGS, handleToolApprovalSettings);
|
||||
logger.info('Team handlers registered');
|
||||
}
|
||||
|
||||
|
|
@ -294,6 +313,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_KILL_PROCESS);
|
||||
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
|
||||
ipcMain.removeHandler(TEAM_LEAD_CONTEXT);
|
||||
ipcMain.removeHandler(TEAM_MEMBER_SPAWN_STATUSES);
|
||||
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
||||
ipcMain.removeHandler(TEAM_RESTORE_TASK);
|
||||
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
||||
|
|
@ -305,6 +325,8 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT);
|
||||
ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT);
|
||||
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_RESPOND);
|
||||
ipcMain.removeHandler(TEAM_VALIDATE_CLI_ARGS);
|
||||
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_SETTINGS);
|
||||
}
|
||||
|
||||
function getTeamDataService(): TeamDataService {
|
||||
|
|
@ -407,13 +429,21 @@ async function handleGetData(
|
|||
}
|
||||
|
||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||
const isLeadThoughtLike = (msg: { source?: unknown; to?: string }): boolean =>
|
||||
!msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
|
||||
const getLeadThoughtFingerprint = (msg: {
|
||||
from: string;
|
||||
text: string;
|
||||
leadSessionId?: string;
|
||||
}): string => `${msg.leadSessionId ?? ''}\0${msg.from}\0${normalizeText(msg.text)}`;
|
||||
|
||||
// Collect text fingerprints from ALL non-live messages (inbox, lead_session, sentMessages)
|
||||
// so we can dedup lead_process live messages against them.
|
||||
// Collect fingerprints only for thought-like lead messages. Include leadSessionId so a
|
||||
// repeated thought in a new session does not get collapsed into an old session's history.
|
||||
const existingTextFingerprints = new Set<string>();
|
||||
for (const msg of data.messages) {
|
||||
if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
|
||||
existingTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`);
|
||||
if (!isLeadThoughtLike(msg)) continue;
|
||||
existingTextFingerprints.add(getLeadThoughtFingerprint(msg));
|
||||
}
|
||||
|
||||
const keyFor = (m: {
|
||||
|
|
@ -428,20 +458,20 @@ async function handleGetData(
|
|||
return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`;
|
||||
};
|
||||
|
||||
// Text-based fingerprints for lead_process messages to catch duplicates
|
||||
// with different messageIds (e.g. lead-turn-* vs lead-sendmsg-* with same text)
|
||||
// Text-based fingerprints for live lead thoughts to catch duplicates with different
|
||||
// messageIds inside the same session (e.g. lead-turn-* re-emits).
|
||||
const leadProcessTextFingerprints = new Set<string>();
|
||||
|
||||
const merged: typeof data.messages = [];
|
||||
const seen = new Set<string>();
|
||||
for (const msg of [...data.messages, ...live]) {
|
||||
if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) {
|
||||
const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
||||
// Skip if same text already exists from any source (inbox, lead_session, etc.)
|
||||
const fp = getLeadThoughtFingerprint(msg);
|
||||
// Skip if the same thought already exists in persisted history for the same session.
|
||||
if (existingTextFingerprints.has(fp)) {
|
||||
continue;
|
||||
}
|
||||
// Dedup lead_process messages with same text but different messageIds
|
||||
// Dedup live lead_process thoughts with the same text in the same session.
|
||||
if (leadProcessTextFingerprints.has(fp)) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -466,7 +496,10 @@ async function handleDeleteTeam(
|
|||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
return wrapTeamHandler('deleteTeam', () => getTeamDataService().deleteTeam(validated.value!));
|
||||
return wrapTeamHandler('deleteTeam', async () => {
|
||||
getTeamProvisioningService().stopTeam(validated.value!);
|
||||
await getTeamDataService().deleteTeam(validated.value!);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRestoreTeam(
|
||||
|
|
@ -641,6 +674,30 @@ async function validateProvisioningRequest(
|
|||
return { valid: false, error: 'cwd must be a directory' };
|
||||
}
|
||||
|
||||
if (payload.worktree !== undefined) {
|
||||
if (typeof payload.worktree !== 'string') {
|
||||
return { valid: false, error: 'worktree must be a string' };
|
||||
}
|
||||
const wt = payload.worktree.trim();
|
||||
if (wt.length > 128) {
|
||||
return { valid: false, error: 'worktree name too long (max 128)' };
|
||||
}
|
||||
if (wt && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(wt)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'worktree name: start with alphanumeric, use [a-zA-Z0-9._-]',
|
||||
};
|
||||
}
|
||||
}
|
||||
if (payload.extraCliArgs !== undefined) {
|
||||
if (typeof payload.extraCliArgs !== 'string') {
|
||||
return { valid: false, error: 'extraCliArgs must be a string' };
|
||||
}
|
||||
if (payload.extraCliArgs.length > 1024) {
|
||||
return { valid: false, error: 'extraCliArgs too long (max 1024)' };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
value: {
|
||||
|
|
@ -655,6 +712,14 @@ async function validateProvisioningRequest(
|
|||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||
skipPermissions:
|
||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||
worktree:
|
||||
typeof payload.worktree === 'string' && payload.worktree.trim()
|
||||
? payload.worktree.trim()
|
||||
: undefined,
|
||||
extraCliArgs:
|
||||
typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim()
|
||||
? payload.extraCliArgs.trim()
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -763,6 +828,12 @@ async function handleLaunchTeam(
|
|||
clearContext: payload.clearContext === true ? true : undefined,
|
||||
skipPermissions:
|
||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||
worktree:
|
||||
typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined,
|
||||
extraCliArgs:
|
||||
typeof payload.extraCliArgs === 'string'
|
||||
? payload.extraCliArgs.trim() || undefined
|
||||
: undefined,
|
||||
},
|
||||
(progress) => {
|
||||
try {
|
||||
|
|
@ -776,6 +847,32 @@ async function handleLaunchTeam(
|
|||
);
|
||||
}
|
||||
|
||||
async function handleValidateCliArgs(
|
||||
_event: IpcMainInvokeEvent,
|
||||
rawArgs: unknown
|
||||
): Promise<IpcResult<CliArgsValidationResult>> {
|
||||
if (typeof rawArgs !== 'string') {
|
||||
return { success: false, error: 'rawArgs must be a string' };
|
||||
}
|
||||
if (rawArgs.length > 2048) {
|
||||
return { success: false, error: 'rawArgs too long (max 2048)' };
|
||||
}
|
||||
return wrapTeamHandler('validateCliArgs', async () => {
|
||||
const helpOutput = await getTeamProvisioningService().getCliHelpOutput();
|
||||
const knownFlags = extractFlagsFromHelp(helpOutput);
|
||||
const userFlags = extractUserFlags(rawArgs);
|
||||
|
||||
const invalidFlags = userFlags.filter((f) => !knownFlags.has(f));
|
||||
const protectedFlags = userFlags.filter((f) => PROTECTED_CLI_FLAGS.has(f));
|
||||
const allBad = [...new Set([...invalidFlags, ...protectedFlags])];
|
||||
|
||||
return {
|
||||
valid: allBad.length === 0,
|
||||
invalidFlags: allBad.length > 0 ? allBad : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePrepareProvisioning(
|
||||
_event: IpcMainInvokeEvent,
|
||||
cwd: unknown
|
||||
|
|
@ -906,6 +1003,37 @@ function validateAttachments(
|
|||
return { valid: true, value: result };
|
||||
}
|
||||
|
||||
function buildMessageDeliveryText(
|
||||
baseText: string,
|
||||
opts: {
|
||||
actionMode?: AgentActionMode;
|
||||
isLeadRecipient: boolean;
|
||||
}
|
||||
): string {
|
||||
const hiddenBlocks: string[] = [];
|
||||
const actionModeBlock = buildActionModeAgentBlock(opts.actionMode);
|
||||
if (actionModeBlock) {
|
||||
hiddenBlocks.push(actionModeBlock);
|
||||
}
|
||||
if (!opts.isLeadRecipient) {
|
||||
hiddenBlocks.push(
|
||||
[
|
||||
AGENT_BLOCK_OPEN,
|
||||
'You received a direct message from the human user via the UI.',
|
||||
'Please reply back to recipient "user" with a short, human-readable answer.',
|
||||
'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
|
||||
AGENT_BLOCK_CLOSE,
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
if (hiddenBlocks.length === 0) {
|
||||
return baseText;
|
||||
}
|
||||
|
||||
return [...hiddenBlocks, baseText].join('\n\n');
|
||||
}
|
||||
|
||||
async function handleSendMessage(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
|
|
@ -937,6 +1065,9 @@ async function handleSendMessage(
|
|||
return { success: false, error: validatedFrom.error ?? 'Invalid from' };
|
||||
}
|
||||
}
|
||||
if (payload.actionMode !== undefined && !isAgentActionMode(payload.actionMode)) {
|
||||
return { success: false, error: 'actionMode must be one of: do, ask, delegate' };
|
||||
}
|
||||
|
||||
let validatedAttachments: AttachmentPayload[] | undefined;
|
||||
if (
|
||||
|
|
@ -951,14 +1082,41 @@ async function handleSendMessage(
|
|||
validatedAttachments = attResult.value;
|
||||
}
|
||||
|
||||
const tn = validatedTeamName.value!;
|
||||
const memberName = validatedMember.value!;
|
||||
let prevalidatedLeadName: string | null | undefined;
|
||||
let prevalidatedIsLeadRecipient: boolean | undefined;
|
||||
if (payload.actionMode === 'delegate') {
|
||||
try {
|
||||
prevalidatedLeadName = await getTeamDataService().getLeadMemberName(tn);
|
||||
} catch (error) {
|
||||
return wrapTeamHandler('sendMessage', async () => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
prevalidatedIsLeadRecipient =
|
||||
prevalidatedLeadName !== null && memberName === prevalidatedLeadName;
|
||||
if (!prevalidatedIsLeadRecipient) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Delegate mode is only supported when messaging the team lead',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return wrapTeamHandler('sendMessage', async () => {
|
||||
const tn = validatedTeamName.value!;
|
||||
const provisioning = getTeamProvisioningService();
|
||||
const isAlive = provisioning.isTeamAlive(tn);
|
||||
|
||||
const leadName = await getTeamDataService().getLeadMemberName(tn);
|
||||
const memberName = validatedMember.value!;
|
||||
const isLeadRecipient = leadName !== null && memberName === leadName;
|
||||
const leadName =
|
||||
prevalidatedLeadName !== undefined
|
||||
? prevalidatedLeadName
|
||||
: await getTeamDataService().getLeadMemberName(tn);
|
||||
const isLeadRecipient =
|
||||
prevalidatedIsLeadRecipient !== undefined
|
||||
? prevalidatedIsLeadRecipient
|
||||
: leadName !== null && memberName === leadName;
|
||||
const actionMode = payload.actionMode;
|
||||
|
||||
// Attachments only supported for live lead (stdin content blocks)
|
||||
if (validatedAttachments?.length && (!isLeadRecipient || !isAlive)) {
|
||||
|
|
@ -969,6 +1127,7 @@ async function handleSendMessage(
|
|||
|
||||
// Smart routing: lead + alive → stdin direct, else → inbox
|
||||
if (isLeadRecipient && isAlive) {
|
||||
const resolvedLeadName = leadName ?? memberName;
|
||||
// Separate try blocks: stdin delivery vs persistence
|
||||
// If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate)
|
||||
// Wrap with instructions so lead responds with visible text (not just agent-only blocks)
|
||||
|
|
@ -977,7 +1136,10 @@ async function handleSendMessage(
|
|||
`IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`,
|
||||
``,
|
||||
`Message from user:`,
|
||||
payload.text!,
|
||||
buildMessageDeliveryText(payload.text!, {
|
||||
actionMode,
|
||||
isLeadRecipient: true,
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
let stdinSent = false;
|
||||
|
|
@ -1010,7 +1172,7 @@ async function handleSendMessage(
|
|||
try {
|
||||
result = await getTeamDataService().sendDirectToLead(
|
||||
tn,
|
||||
leadName,
|
||||
resolvedLeadName,
|
||||
payload.text!,
|
||||
payload.summary,
|
||||
attachmentMeta
|
||||
|
|
@ -1029,7 +1191,7 @@ async function handleSendMessage(
|
|||
|
||||
provisioning.pushLiveLeadProcessMessage(tn, {
|
||||
from: 'user',
|
||||
to: leadName,
|
||||
to: resolvedLeadName,
|
||||
text: payload.text!,
|
||||
timestamp: new Date().toISOString(),
|
||||
read: true,
|
||||
|
|
@ -1045,17 +1207,10 @@ async function handleSendMessage(
|
|||
|
||||
// Inbox path: offline lead or regular members (no attachment support)
|
||||
const baseText = payload.text!.trim();
|
||||
const memberDeliveryText = isLeadRecipient
|
||||
? baseText
|
||||
: [
|
||||
baseText,
|
||||
'',
|
||||
AGENT_BLOCK_OPEN,
|
||||
'You received a direct message from the human user via the UI.',
|
||||
'Please reply back to recipient "user" with a short, human-readable answer.',
|
||||
'If you cannot respond now, reply with a brief status (e.g. "Busy, will reply later").',
|
||||
AGENT_BLOCK_CLOSE,
|
||||
].join('\n');
|
||||
const memberDeliveryText = buildMessageDeliveryText(baseText, {
|
||||
actionMode,
|
||||
isLeadRecipient,
|
||||
});
|
||||
const result = await getTeamDataService().sendMessage(tn, {
|
||||
member: memberName,
|
||||
text: memberDeliveryText,
|
||||
|
|
@ -1063,13 +1218,12 @@ async function handleSendMessage(
|
|||
from: payload.from,
|
||||
});
|
||||
|
||||
// Best-effort: if team is alive and recipient is a teammate (not lead),
|
||||
// also forward via the live lead process so in-process teammates receive it.
|
||||
// Best-effort live relay so active processes see the inbox row promptly.
|
||||
if (!isLeadRecipient && isAlive) {
|
||||
try {
|
||||
await provisioning.forwardUserDmToTeammate(tn, memberName, baseText, payload.summary);
|
||||
await provisioning.relayMemberInboxMessages(tn, memberName);
|
||||
} catch (e: unknown) {
|
||||
logger.warn(`Failed to forward user DM to teammate "${memberName}" via lead: ${String(e)}`);
|
||||
logger.warn(`Relay after sendMessage failed for teammate "${memberName}": ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1635,6 +1789,19 @@ async function handleLeadContext(
|
|||
);
|
||||
}
|
||||
|
||||
async function handleMemberSpawnStatuses(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
): Promise<IpcResult<Record<string, MemberSpawnStatusEntry>>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
return wrapTeamHandler('memberSpawnStatuses', async () =>
|
||||
getTeamProvisioningService().getMemberSpawnStatuses(validated.value!)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleStopTeam(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
|
|
@ -1691,12 +1858,19 @@ async function handleAddMember(
|
|||
if (!payload || typeof payload !== 'object') {
|
||||
return { success: false, error: 'Invalid payload' };
|
||||
}
|
||||
const { name, role } = payload as { name?: unknown; role?: unknown };
|
||||
const { name, role, workflow } = payload as {
|
||||
name?: unknown;
|
||||
role?: unknown;
|
||||
workflow?: unknown;
|
||||
};
|
||||
const vName = validateTeammateName(name);
|
||||
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
|
||||
if (role !== undefined && typeof role !== 'string') {
|
||||
return { success: false, error: 'role must be a string' };
|
||||
}
|
||||
if (workflow !== undefined && typeof workflow !== 'string') {
|
||||
return { success: false, error: 'workflow must be a string' };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('addMember', async () => {
|
||||
const tn = vTeam.value!;
|
||||
|
|
@ -1704,15 +1878,20 @@ async function handleAddMember(
|
|||
await getTeamDataService().addMember(tn, {
|
||||
name: memberName,
|
||||
role: role,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined,
|
||||
});
|
||||
|
||||
// If team is alive, notify the lead to spawn the new teammate
|
||||
const provisioning = getTeamProvisioningService();
|
||||
if (provisioning.isTeamAlive(tn)) {
|
||||
const roleHint = typeof role === 'string' && role.trim() ? ` with role "${role.trim()}"` : '';
|
||||
const workflowHint =
|
||||
typeof workflow === 'string' && workflow.trim()
|
||||
? ` Their workflow: ${workflow.trim()}`
|
||||
: '';
|
||||
const spawnMessage =
|
||||
`A new teammate "${memberName}"${roleHint} has been added to the team. ` +
|
||||
`Please spawn them immediately using the Task tool with team_name="${tn}" and name="${memberName}".`;
|
||||
`Please spawn them immediately using the Task tool with team_name="${tn}" and name="${memberName}".${workflowHint}`;
|
||||
try {
|
||||
await provisioning.sendMessageToTeam(tn, spawnMessage);
|
||||
} catch {
|
||||
|
|
@ -2307,3 +2486,40 @@ async function handleToolApprovalRespond(
|
|||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleToolApprovalSettings(
|
||||
_event: IpcMainInvokeEvent,
|
||||
settings: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
return { success: false, error: 'Settings must be an object' };
|
||||
}
|
||||
const s = settings as Record<string, unknown>;
|
||||
if (typeof s.autoAllowFileEdits !== 'boolean') {
|
||||
return { success: false, error: 'autoAllowFileEdits must be a boolean' };
|
||||
}
|
||||
if (typeof s.autoAllowSafeBash !== 'boolean') {
|
||||
return { success: false, error: 'autoAllowSafeBash must be a boolean' };
|
||||
}
|
||||
if (typeof s.timeoutAction !== 'string' || !['allow', 'deny', 'wait'].includes(s.timeoutAction)) {
|
||||
return { success: false, error: 'timeoutAction must be "allow", "deny", or "wait"' };
|
||||
}
|
||||
if (
|
||||
typeof s.timeoutSeconds !== 'number' ||
|
||||
!Number.isFinite(s.timeoutSeconds) ||
|
||||
s.timeoutSeconds < 5 ||
|
||||
s.timeoutSeconds > 300
|
||||
) {
|
||||
return { success: false, error: 'timeoutSeconds must be a number between 5 and 300' };
|
||||
}
|
||||
|
||||
try {
|
||||
getTeamProvisioningService().updateToolApprovalSettings(s as unknown as ToolApprovalSettings);
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to update tool approval settings: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@
|
|||
* Responsibilities:
|
||||
* - Check if sessions have subagent files
|
||||
* - List subagent files for a session
|
||||
* - Handle both NEW and OLD subagent directory structures:
|
||||
* - NEW: {projectId}/{sessionId}/subagents/agent-{agentId}.jsonl
|
||||
* - OLD: {projectId}/agent-{agentId}.jsonl (legacy, still supported)
|
||||
* - Determine subagent ownership for OLD structure
|
||||
* - Handle the canonical subagent directory structure:
|
||||
* - {projectId}/{sessionId}/subagents/agent-{agentId}.jsonl
|
||||
*/
|
||||
|
||||
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
|
||||
|
|
@ -91,110 +89,25 @@ export class SubagentLocator {
|
|||
}
|
||||
|
||||
/**
|
||||
* Lists all subagent files for a session from both NEW and OLD structures.
|
||||
* Returns NEW structure files first, then OLD structure files.
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param sessionId - The session ID
|
||||
* @returns Promise resolving to array of file paths
|
||||
* Lists all subagent files for a session from the canonical session-local structure.
|
||||
*/
|
||||
async listSubagentFiles(projectId: string, sessionId: string): Promise<string[]> {
|
||||
const allFiles: string[] = [];
|
||||
|
||||
try {
|
||||
// Scan NEW structure: {projectId}/{sessionId}/subagents/agent-*.jsonl
|
||||
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
|
||||
if (await this.fsProvider.exists(newSubagentsPath)) {
|
||||
const entries = await this.fsProvider.readdir(newSubagentsPath);
|
||||
const newFiles = entries
|
||||
return entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() && entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl')
|
||||
)
|
||||
.map((entry) => path.join(newSubagentsPath, entry.name));
|
||||
allFiles.push(...newFiles);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error scanning NEW subagent structure for session ${sessionId}:`, error);
|
||||
logger.error(`Error scanning subagent structure for session ${sessionId}:`, error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Scan OLD structure: {projectId}/agent-*.jsonl
|
||||
// Must filter by sessionId since all sessions share the same project root
|
||||
const oldFiles = await this.getProjectRootSubagentFiles(projectId, sessionId);
|
||||
allFiles.push(...oldFiles);
|
||||
} catch (error) {
|
||||
logger.error(`Error scanning OLD subagent structure for project ${projectId}:`, error);
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets subagent files from project root (OLD structure).
|
||||
* Scans {projectId}/agent-*.jsonl files and filters by sessionId.
|
||||
*
|
||||
* In the OLD structure, all subagent files are in the project root,
|
||||
* so we must read each file's first line to check if it belongs to the session.
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param sessionId - The session ID
|
||||
* @returns Promise resolving to array of file paths
|
||||
*/
|
||||
async getProjectRootSubagentFiles(projectId: string, sessionId: string): Promise<string[]> {
|
||||
try {
|
||||
const projectPath = path.join(this.projectsDir, extractBaseDir(projectId));
|
||||
|
||||
if (!(await this.fsProvider.exists(projectPath))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = await this.fsProvider.readdir(projectPath);
|
||||
const agentFiles = entries
|
||||
.filter((entry) => entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl'))
|
||||
.map((entry) => path.join(projectPath, entry.name));
|
||||
|
||||
// Filter files by checking if their sessionId matches
|
||||
const matchingFiles: string[] = [];
|
||||
for (const filePath of agentFiles) {
|
||||
if (await this.subagentBelongsToSession(filePath, sessionId)) {
|
||||
matchingFiles.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return matchingFiles;
|
||||
} catch (error) {
|
||||
logger.error(`Error reading project root for subagent files:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a subagent file belongs to a specific session by reading its first line.
|
||||
* Subagent files have a sessionId field that points to the parent session.
|
||||
*
|
||||
* @param filePath - Path to the subagent file
|
||||
* @param sessionId - The session ID to check
|
||||
* @returns Promise resolving to true if the subagent belongs to the session
|
||||
*/
|
||||
async subagentBelongsToSession(filePath: string, sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
// Read just the first line to check sessionId
|
||||
const content = await this.fsProvider.readFile(filePath);
|
||||
const firstNewline = content.indexOf('\n');
|
||||
const firstLine = firstNewline > 0 ? content.slice(0, firstNewline) : content;
|
||||
|
||||
if (!firstLine.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entry = JSON.parse(firstLine) as { sessionId?: string };
|
||||
return entry.sessionId === sessionId;
|
||||
} catch (error) {
|
||||
// If we can't read or parse the file, don't include it - log for debugging
|
||||
logger.debug(`SubagentLocator: Could not parse file ${filePath}:`, error);
|
||||
return false;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -57,7 +57,9 @@ export interface DetectedError {
|
|||
| 'lead_inbox'
|
||||
| 'user_inbox'
|
||||
| 'task_clarification'
|
||||
| 'task_status_change';
|
||||
| 'task_status_change'
|
||||
| 'schedule_completed'
|
||||
| 'schedule_failed';
|
||||
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
||||
dedupeKey?: string;
|
||||
/** Additional context about the error */
|
||||
|
|
|
|||
134
src/main/services/extensions/ExtensionFacadeService.ts
Normal file
134
src/main/services/extensions/ExtensionFacadeService.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* Facade service that combines plugin catalog + MCP catalog + installation state
|
||||
* into enriched data ready for the renderer.
|
||||
*
|
||||
* Also provides install target resolution for the security model
|
||||
* (main-side re-resolution: renderer sends pluginId/registryId, main resolves from catalog).
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type {
|
||||
EnrichedPlugin,
|
||||
InstalledMcpEntry,
|
||||
McpCatalogItem,
|
||||
McpSearchResult,
|
||||
PluginCatalogItem,
|
||||
} from '@shared/types/extensions';
|
||||
|
||||
import { PluginCatalogService } from './catalog/PluginCatalogService';
|
||||
import { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
|
||||
import { PluginInstallationStateService } from './state/PluginInstallationStateService';
|
||||
import { McpInstallationStateService } from './state/McpInstallationStateService';
|
||||
|
||||
const logger = createLogger('Extensions:Facade');
|
||||
|
||||
export class ExtensionFacadeService {
|
||||
constructor(
|
||||
private readonly pluginCatalog: PluginCatalogService,
|
||||
private readonly pluginState: PluginInstallationStateService,
|
||||
private readonly mcpAggregator: McpCatalogAggregator | null = null,
|
||||
private readonly mcpState: McpInstallationStateService | null = null
|
||||
) {}
|
||||
|
||||
// ── Plugin methods ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all plugins enriched with install status and counts.
|
||||
*/
|
||||
async getEnrichedPlugins(projectPath?: string, forceRefresh = false): Promise<EnrichedPlugin[]> {
|
||||
const [catalog, installed, counts] = await Promise.all([
|
||||
this.pluginCatalog.getPlugins(forceRefresh),
|
||||
this.pluginState.getInstalledPlugins(projectPath),
|
||||
this.pluginState.getInstallCounts(),
|
||||
]);
|
||||
|
||||
// Build installed lookup: pluginId → entries[]
|
||||
const installedMap = new Map<string, typeof installed>();
|
||||
for (const entry of installed) {
|
||||
const list = installedMap.get(entry.pluginId) ?? [];
|
||||
list.push(entry);
|
||||
installedMap.set(entry.pluginId, list);
|
||||
}
|
||||
|
||||
return catalog.map((item): EnrichedPlugin => {
|
||||
const installations = installedMap.get(item.pluginId) ?? [];
|
||||
const installCount = counts.get(item.pluginId) ?? 0;
|
||||
|
||||
return {
|
||||
...item,
|
||||
installCount,
|
||||
isInstalled: installations.length > 0,
|
||||
installations,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get README content for a plugin.
|
||||
*/
|
||||
async getPluginReadme(pluginId: string): Promise<string | null> {
|
||||
return this.pluginCatalog.getPluginReadme(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a pluginId to its install target.
|
||||
*/
|
||||
async resolvePluginInstallTarget(
|
||||
pluginId: string
|
||||
): Promise<{ qualifiedName: string; plugin: PluginCatalogItem } | null> {
|
||||
const plugin = await this.pluginCatalog.resolvePlugin(pluginId);
|
||||
if (!plugin) {
|
||||
logger.warn(`Cannot resolve install target: pluginId "${pluginId}" not found in catalog`);
|
||||
return null;
|
||||
}
|
||||
return { qualifiedName: plugin.qualifiedName, plugin };
|
||||
}
|
||||
|
||||
// ── MCP methods ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Search MCP servers across both registries.
|
||||
*/
|
||||
async searchMcp(query: string, limit?: number): Promise<McpSearchResult> {
|
||||
if (!this.mcpAggregator) {
|
||||
return { servers: [], warnings: ['MCP catalog not configured'] };
|
||||
}
|
||||
return this.mcpAggregator.search(query, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse MCP catalog with pagination.
|
||||
*/
|
||||
async browseMcp(
|
||||
cursor?: string,
|
||||
limit?: number
|
||||
): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> {
|
||||
if (!this.mcpAggregator) {
|
||||
return { servers: [] };
|
||||
}
|
||||
return this.mcpAggregator.browse(cursor, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single MCP server by registry ID (for install flow).
|
||||
*/
|
||||
async getMcpById(registryId: string): Promise<McpCatalogItem | null> {
|
||||
if (!this.mcpAggregator) return null;
|
||||
return this.mcpAggregator.getById(registryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installed MCP servers.
|
||||
*/
|
||||
async getInstalledMcp(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
||||
if (!this.mcpState) return [];
|
||||
return this.mcpState.getInstalled(projectPath);
|
||||
}
|
||||
|
||||
// ── Cache invalidation ───────────────────────────────────────────────
|
||||
|
||||
invalidateInstalledCache(): void {
|
||||
this.pluginState.invalidateCache();
|
||||
this.mcpState?.invalidateCache();
|
||||
}
|
||||
}
|
||||
398
src/main/services/extensions/apikeys/ApiKeyService.ts
Normal file
398
src/main/services/extensions/apikeys/ApiKeyService.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
/**
|
||||
* ApiKeyService — encrypted API key storage with CRUD operations.
|
||||
*
|
||||
* Encryption strategy (in priority order):
|
||||
* 1. Electron safeStorage (OS keychain: macOS Keychain / Windows DPAPI / Linux gnome-libsecret/kwallet)
|
||||
* 2. AES-256-GCM with machine-derived key (Linux fallback when no keyring is available)
|
||||
*
|
||||
* File permissions: 0o600 (owner read/write only) on Unix systems.
|
||||
* Storage file: ~/.claude/api-keys.json
|
||||
*/
|
||||
|
||||
import { safeStorage } from 'electron';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type {
|
||||
ApiKeyEntry,
|
||||
ApiKeyLookupResult,
|
||||
ApiKeySaveRequest,
|
||||
ApiKeyStorageStatus,
|
||||
} from '@shared/types/extensions';
|
||||
|
||||
const logger = createLogger('Extensions:ApiKeys');
|
||||
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i;
|
||||
|
||||
/** How the value was encrypted on disk */
|
||||
type EncryptionMethod = 'safeStorage' | 'aes-local' | 'base64';
|
||||
|
||||
interface StoredApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
envVarName: string;
|
||||
encryptedValue: string;
|
||||
/** @deprecated Use encryptionMethod instead. Kept for migration. */
|
||||
encrypted?: boolean;
|
||||
encryptionMethod?: EncryptionMethod;
|
||||
scope: 'user' | 'project';
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** AES-256-GCM constants */
|
||||
const AES_IV_BYTES = 12;
|
||||
const AES_TAG_BYTES = 16;
|
||||
const PBKDF2_ITERATIONS = 100_000;
|
||||
const PBKDF2_KEY_BYTES = 32;
|
||||
const PBKDF2_SALT = 'claude-apikey-storage-v1';
|
||||
|
||||
export class ApiKeyService {
|
||||
private readonly filePath: string;
|
||||
private cache: StoredApiKey[] | null = null;
|
||||
private aesKey: Buffer | null = null;
|
||||
|
||||
constructor(claudeDir?: string) {
|
||||
const baseDir = claudeDir ?? path.join(os.homedir(), '.claude');
|
||||
this.filePath = path.join(baseDir, 'api-keys.json');
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
async list(): Promise<ApiKeyEntry[]> {
|
||||
const keys = await this.readStore();
|
||||
return keys.map((k) => ({
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
envVarName: k.envVarName,
|
||||
maskedValue: this.mask(this.decrypt(k)),
|
||||
scope: k.scope,
|
||||
createdAt: k.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async save(request: ApiKeySaveRequest): Promise<ApiKeyEntry> {
|
||||
if (!request.name?.trim()) throw new Error('Key name is required');
|
||||
if (!request.envVarName?.trim()) throw new Error('Environment variable name is required');
|
||||
if (!ENV_KEY_RE.test(request.envVarName)) {
|
||||
throw new Error(
|
||||
`Invalid env var name: "${request.envVarName}". Use uppercase letters, digits, underscores.`
|
||||
);
|
||||
}
|
||||
if (!request.value) throw new Error('Key value is required');
|
||||
|
||||
const keys = await this.readStore();
|
||||
const now = new Date().toISOString();
|
||||
const { method, value } = this.encrypt(request.value);
|
||||
|
||||
if (request.id) {
|
||||
const idx = keys.findIndex((k) => k.id === request.id);
|
||||
if (idx === -1) throw new Error(`API key not found: ${request.id}`);
|
||||
keys[idx] = {
|
||||
...keys[idx],
|
||||
name: request.name.trim(),
|
||||
envVarName: request.envVarName.trim(),
|
||||
encryptedValue: value,
|
||||
encryptionMethod: method,
|
||||
scope: request.scope,
|
||||
updatedAt: now,
|
||||
};
|
||||
// Remove legacy field
|
||||
delete keys[idx].encrypted;
|
||||
} else {
|
||||
keys.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: request.name.trim(),
|
||||
envVarName: request.envVarName.trim(),
|
||||
encryptedValue: value,
|
||||
encryptionMethod: method,
|
||||
scope: request.scope,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await this.writeStore(keys);
|
||||
const saved = keys[request.id ? keys.findIndex((k) => k.id === request.id) : keys.length - 1];
|
||||
return {
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
envVarName: saved.envVarName,
|
||||
maskedValue: this.mask(request.value),
|
||||
scope: saved.scope,
|
||||
createdAt: saved.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const keys = await this.readStore();
|
||||
const filtered = keys.filter((k) => k.id !== id);
|
||||
if (filtered.length === keys.length) throw new Error(`API key not found: ${id}`);
|
||||
await this.writeStore(filtered);
|
||||
}
|
||||
|
||||
async lookup(envVarNames: string[]): Promise<ApiKeyLookupResult[]> {
|
||||
if (!envVarNames.length) return [];
|
||||
const keys = await this.readStore();
|
||||
const nameSet = new Set(envVarNames);
|
||||
return keys
|
||||
.filter((k) => nameSet.has(k.envVarName))
|
||||
.map((k) => ({
|
||||
envVarName: k.envVarName,
|
||||
value: this.decrypt(k),
|
||||
}));
|
||||
}
|
||||
|
||||
async getStorageStatus(): Promise<ApiKeyStorageStatus> {
|
||||
const secure = this.isSecureBackend();
|
||||
const backend = this.getBackendName();
|
||||
let fileSecure = true;
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
const stat = await fs.stat(this.filePath);
|
||||
fileSecure = (stat.mode & 0o077) === 0;
|
||||
} catch {
|
||||
// File doesn't exist yet — considered secure
|
||||
}
|
||||
}
|
||||
return {
|
||||
encryptionMethod: secure ? 'os-keychain' : 'aes-local',
|
||||
backend,
|
||||
fileSecure,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Encryption ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if the OS provides a real secure backend (not basic_text).
|
||||
* On Linux, safeStorage.isEncryptionAvailable() returns true even with basic_text
|
||||
* backend (hardcoded password), so we must check the actual backend name.
|
||||
*/
|
||||
private isSecureBackend(): boolean {
|
||||
try {
|
||||
if (!safeStorage.isEncryptionAvailable()) return false;
|
||||
if (process.platform === 'linux') {
|
||||
const backend = safeStorage.getSelectedStorageBackend();
|
||||
return backend !== 'basic_text' && backend !== 'unknown';
|
||||
}
|
||||
return true; // macOS Keychain / Windows DPAPI are always reliable
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private getBackendName(): string {
|
||||
if (process.platform === 'darwin') return 'macOS Keychain';
|
||||
if (process.platform === 'win32') return 'Windows DPAPI';
|
||||
try {
|
||||
return safeStorage.getSelectedStorageBackend();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
private encrypt(value: string): { method: EncryptionMethod; value: string } {
|
||||
// Try OS keychain first
|
||||
if (this.isSecureBackend()) {
|
||||
try {
|
||||
const buf = safeStorage.encryptString(value);
|
||||
return { method: 'safeStorage', value: buf.toString('base64') };
|
||||
} catch (err) {
|
||||
logger.warn('safeStorage encryption failed, falling back to AES-local:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: AES-256-GCM with machine-derived key
|
||||
return { method: 'aes-local', value: this.encryptAesLocal(value) };
|
||||
}
|
||||
|
||||
private decrypt(stored: StoredApiKey): string {
|
||||
try {
|
||||
const method = this.resolveMethod(stored);
|
||||
|
||||
switch (method) {
|
||||
case 'safeStorage': {
|
||||
const buf = Buffer.from(stored.encryptedValue, 'base64');
|
||||
return safeStorage.decryptString(buf);
|
||||
}
|
||||
case 'aes-local':
|
||||
return this.decryptAesLocal(stored.encryptedValue);
|
||||
case 'base64':
|
||||
return Buffer.from(stored.encryptedValue, 'base64').toString('utf-8');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Failed to decrypt API key "${stored.name}":`, err);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve encryption method, handling legacy entries without encryptionMethod field */
|
||||
private resolveMethod(stored: StoredApiKey): EncryptionMethod {
|
||||
if (stored.encryptionMethod) return stored.encryptionMethod;
|
||||
// Legacy migration: encrypted boolean → method
|
||||
return stored.encrypted ? 'safeStorage' : 'base64';
|
||||
}
|
||||
|
||||
// ── AES-256-GCM local encryption ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derive an AES key from machine-specific info.
|
||||
* Not as secure as OS keychain (extractable with root), but far better than plaintext.
|
||||
* Protects against casual read by other users.
|
||||
*/
|
||||
private getAesKey(): Buffer {
|
||||
if (this.aesKey) return this.aesKey;
|
||||
|
||||
const material = [os.hostname(), os.userInfo().username, this.getMachineId()].join(':');
|
||||
|
||||
this.aesKey = crypto.pbkdf2Sync(
|
||||
material,
|
||||
PBKDF2_SALT,
|
||||
PBKDF2_ITERATIONS,
|
||||
PBKDF2_KEY_BYTES,
|
||||
'sha512'
|
||||
);
|
||||
return this.aesKey;
|
||||
}
|
||||
|
||||
private getMachineId(): string {
|
||||
if (process.platform !== 'linux') {
|
||||
return `${process.platform}-${os.arch()}-${os.cpus()[0]?.model ?? 'unknown'}`;
|
||||
}
|
||||
// Linux: try /etc/machine-id (systemd) or /var/lib/dbus/machine-id
|
||||
const candidates = ['/etc/machine-id', '/var/lib/dbus/machine-id'];
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
// Synchronous read is OK — called once, result cached in aesKey
|
||||
const { readFileSync } = require('node:fs') as typeof import('node:fs');
|
||||
const id = readFileSync(p, 'utf-8').trim();
|
||||
if (id) return id;
|
||||
} catch {
|
||||
// Try next candidate
|
||||
}
|
||||
}
|
||||
return `linux-${os.arch()}-${os.cpus()[0]?.model ?? 'unknown'}`;
|
||||
}
|
||||
|
||||
private encryptAesLocal(value: string): string {
|
||||
const key = this.getAesKey();
|
||||
const iv = crypto.randomBytes(AES_IV_BYTES);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(value, 'utf-8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
// Format: iv:tag:ciphertext (all base64)
|
||||
return [iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':');
|
||||
}
|
||||
|
||||
private decryptAesLocal(stored: string): string {
|
||||
const parts = stored.split(':');
|
||||
if (parts.length !== 3) throw new Error('Invalid AES-local format');
|
||||
const [ivB64, tagB64, dataB64] = parts;
|
||||
const key = this.getAesKey();
|
||||
const iv = Buffer.from(ivB64, 'base64');
|
||||
const tag = Buffer.from(tagB64, 'base64');
|
||||
const data = Buffer.from(dataB64, 'base64');
|
||||
|
||||
if (iv.length !== AES_IV_BYTES) throw new Error('Invalid IV length');
|
||||
if (tag.length !== AES_TAG_BYTES) throw new Error('Invalid auth tag length');
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return decipher.update(data).toString('utf-8') + decipher.final('utf-8');
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private mask(value: string): string {
|
||||
if (value.length <= 3) return '***';
|
||||
return '*'.repeat(Math.min(value.length - 3, 20)) + value.slice(-3);
|
||||
}
|
||||
|
||||
// ── Storage I/O ─────────────────────────────────────────────────────────
|
||||
|
||||
private async readStore(): Promise<StoredApiKey[]> {
|
||||
if (this.cache) return this.cache;
|
||||
try {
|
||||
const raw = await fs.readFile(this.filePath, 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
const keys: StoredApiKey[] = Array.isArray(data) ? data : [];
|
||||
|
||||
// Fix file permissions if insecure
|
||||
await this.ensureFilePermissions();
|
||||
|
||||
// Migrate legacy entries (base64 → current method)
|
||||
const migrated = this.migrateKeys(keys);
|
||||
if (migrated) {
|
||||
await this.writeStore(keys);
|
||||
}
|
||||
|
||||
this.cache = keys;
|
||||
} catch {
|
||||
this.cache = [];
|
||||
}
|
||||
return this.cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy entries: re-encrypt base64 entries with current best method.
|
||||
* Returns true if any entries were migrated.
|
||||
*/
|
||||
private migrateKeys(keys: StoredApiKey[]): boolean {
|
||||
let migrated = false;
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const method = this.resolveMethod(keys[i]);
|
||||
if (method === 'base64') {
|
||||
try {
|
||||
const plaintext = Buffer.from(keys[i].encryptedValue, 'base64').toString('utf-8');
|
||||
if (!plaintext) continue;
|
||||
const { method: newMethod, value } = this.encrypt(plaintext);
|
||||
keys[i] = {
|
||||
...keys[i],
|
||||
encryptedValue: value,
|
||||
encryptionMethod: newMethod,
|
||||
};
|
||||
delete keys[i].encrypted;
|
||||
migrated = true;
|
||||
logger.info(`Migrated API key "${keys[i].name}" from base64 to ${newMethod}`);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to migrate API key "${keys[i].name}":`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
return migrated;
|
||||
}
|
||||
|
||||
private async writeStore(keys: StoredApiKey[]): Promise<void> {
|
||||
const dir = path.dirname(this.filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const tmpPath = this.filePath + `.tmp.${crypto.randomUUID()}`;
|
||||
await fs.writeFile(tmpPath, JSON.stringify(keys, null, 2), 'utf-8');
|
||||
|
||||
// Set restrictive permissions before rename (Unix only)
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
await fs.chmod(tmpPath, 0o600);
|
||||
} catch {
|
||||
// Best-effort — Windows or restricted FS
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rename(tmpPath, this.filePath);
|
||||
this.cache = keys;
|
||||
}
|
||||
|
||||
/** Ensure the storage file has owner-only permissions */
|
||||
private async ensureFilePermissions(): Promise<void> {
|
||||
if (process.platform === 'win32') return;
|
||||
try {
|
||||
const stat = await fs.stat(this.filePath);
|
||||
if ((stat.mode & 0o077) !== 0) {
|
||||
logger.warn('API keys file has insecure permissions, fixing to 0600');
|
||||
await fs.chmod(this.filePath, 0o600);
|
||||
}
|
||||
} catch {
|
||||
// File may not exist yet
|
||||
}
|
||||
}
|
||||
}
|
||||
184
src/main/services/extensions/catalog/GitHubStarsService.ts
Normal file
184
src/main/services/extensions/catalog/GitHubStarsService.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* GitHubStarsService — fetches stargazer counts from the GitHub public API.
|
||||
*
|
||||
* - Batch interface: accepts repository URLs, returns map of URL → stars
|
||||
* - In-memory cache with 1-hour TTL
|
||||
* - Concurrency-limited to 5 parallel requests
|
||||
* - Silent failure: 404, timeout, rate-limit → entry skipped
|
||||
*/
|
||||
|
||||
import https from 'node:https';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { parseGitHubOwnerRepo } from '@shared/utils/extensionNormalizers';
|
||||
|
||||
const logger = createLogger('Extensions:GitHubStars');
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const CACHE_TTL_MS = 60 * 60_000; // 1 hour
|
||||
const HTTP_TIMEOUT_MS = 10_000; // 10 seconds
|
||||
const MAX_CONCURRENCY = 5;
|
||||
const MAX_BODY_SIZE = 256 * 1024; // 256KB (GitHub repo JSON is ~10KB)
|
||||
|
||||
// ── Cache entry ────────────────────────────────────────────────────────────
|
||||
|
||||
interface CacheEntry {
|
||||
stars: number;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
// ── Service ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class GitHubStarsService {
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
|
||||
/**
|
||||
* Fetch GitHub stars for a batch of repository URLs.
|
||||
* Returns `Record<repositoryUrl, starCount>` — only includes URLs with valid results.
|
||||
*/
|
||||
async fetchStars(repositoryUrls: string[]): Promise<Record<string, number>> {
|
||||
const result: Record<string, number> = {};
|
||||
const tasks: Array<{ url: string; owner: string; repo: string }> = [];
|
||||
|
||||
for (const url of repositoryUrls) {
|
||||
const parsed = parseGitHubOwnerRepo(url);
|
||||
if (!parsed) continue;
|
||||
|
||||
const cacheKey = `${parsed.owner}/${parsed.repo}`.toLowerCase();
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
result[url] = cached.stars;
|
||||
continue;
|
||||
}
|
||||
|
||||
tasks.push({ url, owner: parsed.owner, repo: parsed.repo });
|
||||
}
|
||||
|
||||
if (tasks.length === 0) return result;
|
||||
|
||||
// Fetch with concurrency limit
|
||||
const outcomes = await this.withConcurrencyLimit(
|
||||
tasks.map(({ url, owner, repo }) => async () => {
|
||||
const stars = await this.fetchSingle(owner, repo);
|
||||
if (stars != null) {
|
||||
const cacheKey = `${owner}/${repo}`.toLowerCase();
|
||||
this.cache.set(cacheKey, { stars, fetchedAt: Date.now() });
|
||||
result[url] = stars;
|
||||
}
|
||||
}),
|
||||
MAX_CONCURRENCY
|
||||
);
|
||||
|
||||
// Log any failures for debugging (already silent to caller)
|
||||
const failures = outcomes.filter((o) => o === 'error').length;
|
||||
if (failures > 0) {
|
||||
logger.debug(
|
||||
`GitHub stars: ${tasks.length - failures}/${tasks.length} fetched, ${failures} failed`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch stargazer count for a single repo. Returns null on any error.
|
||||
*/
|
||||
private async fetchSingle(owner: string, repo: string): Promise<number | null> {
|
||||
try {
|
||||
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
||||
const { statusCode, body } = await githubGet(url);
|
||||
|
||||
if (statusCode !== 200) {
|
||||
if (statusCode === 403 || statusCode === 429) {
|
||||
logger.debug(`GitHub API rate limit hit (${statusCode}), skipping remaining`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = JSON.parse(body) as { stargazers_count?: number };
|
||||
return typeof data.stargazers_count === 'number' ? data.stargazers_count : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run async tasks with a concurrency limit.
|
||||
*/
|
||||
private async withConcurrencyLimit(
|
||||
tasks: Array<() => Promise<void>>,
|
||||
limit: number
|
||||
): Promise<Array<'ok' | 'error'>> {
|
||||
const results: Array<'ok' | 'error'> = [];
|
||||
let index = 0;
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
while (index < tasks.length) {
|
||||
const i = index++;
|
||||
try {
|
||||
await tasks[i]();
|
||||
results[i] = 'ok';
|
||||
} catch {
|
||||
results[i] = 'error';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workers = Array.from({ length: Math.min(limit, tasks.length) }, () => run());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP helper (GitHub-specific) ──────────────────────────────────────────
|
||||
|
||||
function githubGet(url: string): Promise<{ statusCode: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
const settleResolve = (v: { statusCode: number; body: string }): void => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(v);
|
||||
}
|
||||
};
|
||||
const settleReject = (e: Error): void => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.get(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'claude-devtools',
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const status = res.statusCode ?? 0;
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
|
||||
res.on('data', (c: Buffer) => {
|
||||
totalSize += c.length;
|
||||
if (totalSize > MAX_BODY_SIZE) {
|
||||
res.destroy(new Error('Response too large'));
|
||||
return;
|
||||
}
|
||||
chunks.push(c);
|
||||
});
|
||||
res.on('end', () =>
|
||||
settleResolve({ statusCode: status, body: Buffer.concat(chunks).toString('utf-8') })
|
||||
);
|
||||
res.on('error', settleReject);
|
||||
}
|
||||
);
|
||||
|
||||
req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error(`Timeout: ${url}`)));
|
||||
req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e))));
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* Fetches MCP server data from the Glama.ai API.
|
||||
*
|
||||
* Optional enrichment layer — NOT a hard dependency.
|
||||
* Provides: license, tools, Glama URL.
|
||||
* Does NOT provide install info (no packages/remotes).
|
||||
*
|
||||
* Base URL: https://glama.ai/api/mcp/v1/servers
|
||||
* Cursor-based pagination (after), no auth required.
|
||||
*/
|
||||
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type { McpCatalogItem, McpHostingType, McpToolDef } from '@shared/types/extensions';
|
||||
|
||||
const logger = createLogger('Extensions:GlamaMcp');
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const GLAMA_BASE_URL = 'https://glama.ai/api/mcp/v1/servers';
|
||||
const HTTP_TIMEOUT_MS = 15_000;
|
||||
const MAX_REDIRECTS = 5;
|
||||
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB safety limit
|
||||
|
||||
// ── HTTP helper ────────────────────────────────────────────────────────────
|
||||
|
||||
function httpGet(
|
||||
url: string,
|
||||
redirectsLeft = MAX_REDIRECTS
|
||||
): Promise<{ statusCode: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const transport = parsedUrl.protocol === 'http:' ? http : https;
|
||||
let settled = false;
|
||||
|
||||
const settleResolve = (v: { statusCode: number; body: string }): void => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(v);
|
||||
}
|
||||
};
|
||||
const settleReject = (e: Error): void => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
|
||||
const req = transport.get(url, (res) => {
|
||||
const status = res.statusCode ?? 0;
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
if (redirectsLeft <= 0) {
|
||||
res.destroy();
|
||||
settleReject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
res.destroy();
|
||||
httpGet(new URL(res.headers.location, url).toString(), redirectsLeft - 1).then(
|
||||
settleResolve,
|
||||
settleReject
|
||||
);
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
res.on('data', (c: Buffer) => {
|
||||
totalSize += c.length;
|
||||
if (totalSize > MAX_BODY_SIZE) {
|
||||
res.destroy(new Error(`Response body exceeds ${MAX_BODY_SIZE} bytes`));
|
||||
return;
|
||||
}
|
||||
chunks.push(c);
|
||||
});
|
||||
res.on('end', () =>
|
||||
settleResolve({ statusCode: status, body: Buffer.concat(chunks).toString('utf-8') })
|
||||
);
|
||||
res.on('error', settleReject);
|
||||
});
|
||||
req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error(`Timeout fetching ${url}`)));
|
||||
req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e))));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Raw Glama API shapes ───────────────────────────────────────────────────
|
||||
|
||||
interface GlamaResponse {
|
||||
pageInfo: {
|
||||
endCursor?: string;
|
||||
hasNextPage?: boolean;
|
||||
};
|
||||
servers: GlamaServer[];
|
||||
}
|
||||
|
||||
interface GlamaServer {
|
||||
id: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
description?: string;
|
||||
slug?: string;
|
||||
url?: string;
|
||||
repository?: { url: string };
|
||||
spdxLicense?: { name: string; url?: string } | null;
|
||||
tools?: { name?: string; description?: string }[];
|
||||
attributes?: string[];
|
||||
}
|
||||
|
||||
// ── Service ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class GlamaMcpEnrichmentService {
|
||||
/**
|
||||
* Search Glama for MCP servers.
|
||||
*/
|
||||
async search(query: string, limit = 20): Promise<McpCatalogItem[]> {
|
||||
const params = new URLSearchParams({ search: query, first: String(limit) });
|
||||
const url = `${GLAMA_BASE_URL}?${params}`;
|
||||
|
||||
try {
|
||||
const resp = await httpGet(url);
|
||||
if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
|
||||
const json = JSON.parse(resp.body) as GlamaResponse;
|
||||
return json.servers.map((s) => this.normalize(s));
|
||||
} catch (err) {
|
||||
logger.warn('Glama MCP search failed:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse Glama catalog with cursor pagination.
|
||||
*/
|
||||
async browse(
|
||||
cursor?: string,
|
||||
limit = 20
|
||||
): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> {
|
||||
const params = new URLSearchParams({ first: String(limit) });
|
||||
if (cursor) params.set('after', cursor);
|
||||
const url = `${GLAMA_BASE_URL}?${params}`;
|
||||
|
||||
try {
|
||||
const resp = await httpGet(url);
|
||||
if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
|
||||
const json = JSON.parse(resp.body) as GlamaResponse;
|
||||
return {
|
||||
servers: json.servers.map((s) => this.normalize(s)),
|
||||
nextCursor: json.pageInfo.hasNextPage ? json.pageInfo.endCursor : undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn('Glama MCP browse failed:', err);
|
||||
return { servers: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────────────
|
||||
|
||||
private normalize(raw: GlamaServer): McpCatalogItem {
|
||||
const tools: McpToolDef[] = (raw.tools ?? [])
|
||||
.filter((t): t is { name: string; description: string } => Boolean(t.name))
|
||||
.map((t) => ({ name: t.name, description: t.description ?? '' }));
|
||||
|
||||
return {
|
||||
id: `glama:${raw.id}`,
|
||||
name: raw.name,
|
||||
description: raw.description ?? '',
|
||||
repositoryUrl: raw.repository?.url,
|
||||
version: undefined, // Glama doesn't expose version
|
||||
source: 'glama',
|
||||
installSpec: null, // Glama has NO install info
|
||||
envVars: [],
|
||||
license: raw.spdxLicense?.name,
|
||||
tools,
|
||||
glamaUrl: raw.url,
|
||||
requiresAuth: false,
|
||||
author: raw.namespace,
|
||||
hostingType: this.deriveHostingType(raw.attributes),
|
||||
};
|
||||
}
|
||||
|
||||
private deriveHostingType(attributes?: string[]): McpHostingType | undefined {
|
||||
if (!attributes?.length) return undefined;
|
||||
const hasLocal = attributes.includes('hosting:local-only');
|
||||
const hasRemote = attributes.includes('hosting:remote-capable');
|
||||
if (hasLocal && hasRemote) return 'both';
|
||||
if (hasLocal) return 'local';
|
||||
if (hasRemote) return 'remote';
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
149
src/main/services/extensions/catalog/McpCatalogAggregator.ts
Normal file
149
src/main/services/extensions/catalog/McpCatalogAggregator.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Aggregates MCP catalog data from Official Registry + Glama.
|
||||
*
|
||||
* - Uses Promise.allSettled so partial API failures don't break the whole catalog
|
||||
* - Dedup by repository URL; Official source takes priority
|
||||
* - Enriches Official entries with Glama data (license, tools) when matched
|
||||
* - Provides getById() for secure install flow
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type { McpCatalogItem, McpSearchResult } from '@shared/types/extensions';
|
||||
import { normalizeRepoUrl } from '@shared/utils/extensionNormalizers';
|
||||
|
||||
import { OfficialMcpRegistryService } from './OfficialMcpRegistryService';
|
||||
import { GlamaMcpEnrichmentService } from './GlamaMcpEnrichmentService';
|
||||
|
||||
const logger = createLogger('Extensions:McpAggregator');
|
||||
|
||||
export class McpCatalogAggregator {
|
||||
constructor(
|
||||
private readonly official: OfficialMcpRegistryService,
|
||||
private readonly glama: GlamaMcpEnrichmentService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Search both sources and return merged results.
|
||||
*/
|
||||
async search(query: string, limit = 20): Promise<McpSearchResult> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
const [officialResult, glamaResult] = await Promise.allSettled([
|
||||
this.official.search(query, limit),
|
||||
this.glama.search(query, limit),
|
||||
]);
|
||||
|
||||
const officialServers = officialResult.status === 'fulfilled' ? officialResult.value : [];
|
||||
const glamaServers = glamaResult.status === 'fulfilled' ? glamaResult.value : [];
|
||||
|
||||
if (officialResult.status === 'rejected') {
|
||||
warnings.push('Official MCP Registry unavailable');
|
||||
logger.warn('Official registry search failed:', officialResult.reason);
|
||||
}
|
||||
if (glamaResult.status === 'rejected') {
|
||||
warnings.push('Glama enrichment unavailable');
|
||||
logger.warn('Glama search failed:', glamaResult.reason);
|
||||
}
|
||||
|
||||
const merged = this.mergeAndDeduplicate(officialServers, glamaServers);
|
||||
|
||||
return { servers: merged, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse the official registry with optional Glama enrichment.
|
||||
*/
|
||||
async browse(
|
||||
cursor?: string,
|
||||
limit = 20
|
||||
): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> {
|
||||
// Browse primarily from official registry (has pagination)
|
||||
const result = await this.official.browse(cursor, limit);
|
||||
|
||||
// Optionally enrich with Glama data (best effort, no pagination sync)
|
||||
try {
|
||||
const glamaBrowse = await this.glama.browse(undefined, limit);
|
||||
const enriched = this.enrichOfficialWithGlama(result.servers, glamaBrowse.servers);
|
||||
return { servers: enriched, nextCursor: result.nextCursor };
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single server by ID for secure install flow.
|
||||
* Delegates to appropriate source based on ID prefix.
|
||||
*/
|
||||
async getById(registryId: string): Promise<McpCatalogItem | null> {
|
||||
// Glama IDs are prefixed with "glama:"
|
||||
if (registryId.startsWith('glama:')) {
|
||||
logger.warn(`Cannot install Glama-only server: ${registryId}`);
|
||||
return null; // Glama servers can't be auto-installed
|
||||
}
|
||||
|
||||
// Official registry lookup
|
||||
return this.official.getById(registryId);
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Merge Official + Glama, dedup by repository URL.
|
||||
* Official entries take priority.
|
||||
*/
|
||||
private mergeAndDeduplicate(
|
||||
official: McpCatalogItem[],
|
||||
glama: McpCatalogItem[]
|
||||
): McpCatalogItem[] {
|
||||
// Build repo URL index from official entries
|
||||
const officialRepoUrls = new Set<string>();
|
||||
for (const item of official) {
|
||||
if (item.repositoryUrl) {
|
||||
officialRepoUrls.add(normalizeRepoUrl(item.repositoryUrl));
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich official entries with Glama data
|
||||
const enriched = this.enrichOfficialWithGlama(official, glama);
|
||||
|
||||
// Add Glama-only entries (no matching official entry)
|
||||
const glamaOnly = glama.filter((g) => {
|
||||
if (!g.repositoryUrl) return true; // no repo URL = can't match, show separately
|
||||
return !officialRepoUrls.has(normalizeRepoUrl(g.repositoryUrl));
|
||||
});
|
||||
|
||||
return [...enriched, ...glamaOnly];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich official entries with Glama metadata (license, tools, glamaUrl).
|
||||
*/
|
||||
private enrichOfficialWithGlama(
|
||||
official: McpCatalogItem[],
|
||||
glama: McpCatalogItem[]
|
||||
): McpCatalogItem[] {
|
||||
// Index Glama by normalized repo URL
|
||||
const glamaByRepo = new Map<string, McpCatalogItem>();
|
||||
for (const g of glama) {
|
||||
if (g.repositoryUrl) {
|
||||
glamaByRepo.set(normalizeRepoUrl(g.repositoryUrl), g);
|
||||
}
|
||||
}
|
||||
|
||||
return official.map((item) => {
|
||||
if (!item.repositoryUrl) return item;
|
||||
|
||||
const glamaMatch = glamaByRepo.get(normalizeRepoUrl(item.repositoryUrl));
|
||||
if (!glamaMatch) return item;
|
||||
|
||||
return {
|
||||
...item,
|
||||
license: item.license ?? glamaMatch.license,
|
||||
tools: item.tools.length > 0 ? item.tools : glamaMatch.tools,
|
||||
glamaUrl: glamaMatch.glamaUrl,
|
||||
author: item.author ?? glamaMatch.author,
|
||||
hostingType: item.hostingType ?? glamaMatch.hostingType,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
/**
|
||||
* Fetches and normalizes MCP servers from the Official MCP Registry.
|
||||
*
|
||||
* Base URL: https://registry.modelcontextprotocol.io/v0.1/servers
|
||||
* Cursor-based pagination, no auth required.
|
||||
* Filters for _meta.isLatest to pick only latest versions.
|
||||
*/
|
||||
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type { McpCatalogItem, McpEnvVarDef, McpInstallSpec } from '@shared/types/extensions';
|
||||
|
||||
const logger = createLogger('Extensions:OfficialMcpRegistry');
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const REGISTRY_BASE_URL = 'https://registry.modelcontextprotocol.io/v0.1/servers';
|
||||
const HTTP_TIMEOUT_MS = 15_000;
|
||||
const MAX_REDIRECTS = 5;
|
||||
const CACHE_TTL_MS = 15 * 60_000; // 15 minutes
|
||||
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB safety limit
|
||||
|
||||
// ── HTTP helper ────────────────────────────────────────────────────────────
|
||||
|
||||
function httpGet(
|
||||
url: string,
|
||||
redirectsLeft = MAX_REDIRECTS
|
||||
): Promise<{ statusCode: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const transport = parsedUrl.protocol === 'http:' ? http : https;
|
||||
let settled = false;
|
||||
|
||||
const settleResolve = (v: { statusCode: number; body: string }): void => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(v);
|
||||
}
|
||||
};
|
||||
const settleReject = (e: Error): void => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
|
||||
const req = transport.get(url, (res) => {
|
||||
const status = res.statusCode ?? 0;
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
if (redirectsLeft <= 0) {
|
||||
res.destroy();
|
||||
settleReject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
const redirectUrl = new URL(res.headers.location, url).toString();
|
||||
res.destroy();
|
||||
httpGet(redirectUrl, redirectsLeft - 1).then(settleResolve, settleReject);
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
res.on('data', (c: Buffer) => {
|
||||
totalSize += c.length;
|
||||
if (totalSize > MAX_BODY_SIZE) {
|
||||
res.destroy(new Error(`Response body exceeds ${MAX_BODY_SIZE} bytes`));
|
||||
return;
|
||||
}
|
||||
chunks.push(c);
|
||||
});
|
||||
res.on('end', () =>
|
||||
settleResolve({ statusCode: status, body: Buffer.concat(chunks).toString('utf-8') })
|
||||
);
|
||||
res.on('error', settleReject);
|
||||
});
|
||||
req.setTimeout(HTTP_TIMEOUT_MS, () => req.destroy(new Error(`Timeout fetching ${url}`)));
|
||||
req.on('error', (e) => settleReject(e instanceof Error ? e : new Error(String(e))));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Raw API response shapes ────────────────────────────────────────────────
|
||||
|
||||
interface RegistryResponse {
|
||||
servers: RegistryServerEntry[];
|
||||
metadata: { nextCursor?: string; count?: number };
|
||||
}
|
||||
|
||||
interface RegistryIcon {
|
||||
src: string;
|
||||
mimeType?: string;
|
||||
sizes?: string[];
|
||||
theme?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
interface RegistryServerEntry {
|
||||
server: {
|
||||
name: string;
|
||||
description?: string;
|
||||
title?: string;
|
||||
version?: string;
|
||||
repository?: { url: string; source?: string };
|
||||
websiteUrl?: string;
|
||||
packages?: RegistryPackage[];
|
||||
remotes?: RegistryRemote[];
|
||||
icons?: RegistryIcon[];
|
||||
};
|
||||
_meta?: {
|
||||
'io.modelcontextprotocol.registry/official'?: {
|
||||
status?: string;
|
||||
isLatest?: boolean;
|
||||
publishedAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface RegistryPackage {
|
||||
registryType: string;
|
||||
identifier: string;
|
||||
version?: string;
|
||||
transport?: { type: string };
|
||||
environmentVariables?: RegistryEnvVar[];
|
||||
}
|
||||
|
||||
interface RegistryRemote {
|
||||
type: string;
|
||||
url: string;
|
||||
headers?: RegistryHeader[];
|
||||
}
|
||||
|
||||
interface RegistryHeader {
|
||||
name: string;
|
||||
description?: string;
|
||||
isRequired?: boolean;
|
||||
isSecret?: boolean;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
interface RegistryEnvVar {
|
||||
name: string;
|
||||
description?: string;
|
||||
isSecret?: boolean;
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
// ── Cache ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SearchCache {
|
||||
key: string;
|
||||
result: McpCatalogItem[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
// ── Service ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class OfficialMcpRegistryService {
|
||||
private searchCache: SearchCache | null = null;
|
||||
|
||||
/**
|
||||
* Search the official registry by query text.
|
||||
*/
|
||||
async search(query: string, limit = 20): Promise<McpCatalogItem[]> {
|
||||
const cacheKey = `search:${query}:${limit}`;
|
||||
if (
|
||||
this.searchCache?.key === cacheKey &&
|
||||
Date.now() - this.searchCache.fetchedAt < CACHE_TTL_MS
|
||||
) {
|
||||
return this.searchCache.result;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ search: query, limit: String(limit) });
|
||||
const url = `${REGISTRY_BASE_URL}?${params}`;
|
||||
|
||||
try {
|
||||
const resp = await httpGet(url);
|
||||
if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
|
||||
const json = JSON.parse(resp.body) as RegistryResponse;
|
||||
const items = this.normalizeServers(json.servers);
|
||||
this.searchCache = { key: cacheKey, result: items, fetchedAt: Date.now() };
|
||||
return items;
|
||||
} catch (err) {
|
||||
logger.error('Official MCP Registry search failed:', err);
|
||||
return this.searchCache?.result ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse the registry with cursor-based pagination.
|
||||
*/
|
||||
async browse(
|
||||
cursor?: string,
|
||||
limit = 20
|
||||
): Promise<{ servers: McpCatalogItem[]; nextCursor?: string }> {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
const url = `${REGISTRY_BASE_URL}?${params}`;
|
||||
|
||||
try {
|
||||
const resp = await httpGet(url);
|
||||
if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
|
||||
const json = JSON.parse(resp.body) as RegistryResponse;
|
||||
return {
|
||||
servers: this.normalizeServers(json.servers),
|
||||
nextCursor: json.metadata.nextCursor,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error('Official MCP Registry browse failed:', err);
|
||||
return { servers: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single server by its registry ID (reverse-DNS name).
|
||||
* Used for secure install flow (main-side re-resolution).
|
||||
*/
|
||||
async getById(registryId: string): Promise<McpCatalogItem | null> {
|
||||
// The official registry search API can find by exact name
|
||||
const params = new URLSearchParams({ search: registryId, limit: '5' });
|
||||
const url = `${REGISTRY_BASE_URL}?${params}`;
|
||||
|
||||
try {
|
||||
const resp = await httpGet(url);
|
||||
if (resp.statusCode !== 200) throw new Error(`HTTP ${resp.statusCode}`);
|
||||
const json = JSON.parse(resp.body) as RegistryResponse;
|
||||
const items = this.normalizeServers(json.servers);
|
||||
return items.find((s) => s.id === registryId) ?? null;
|
||||
} catch (err) {
|
||||
logger.error(`Official MCP Registry getById(${registryId}) failed:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────────────
|
||||
|
||||
private normalizeServers(entries: RegistryServerEntry[]): McpCatalogItem[] {
|
||||
// Filter to isLatest only (same server name may appear multiple times)
|
||||
const latest = entries.filter((e) => {
|
||||
const meta = e._meta?.['io.modelcontextprotocol.registry/official'];
|
||||
return meta?.isLatest !== false; // include if isLatest is true or undefined
|
||||
});
|
||||
|
||||
// Filter to active only (include servers with no status or status "active")
|
||||
const active = latest.filter((e) => {
|
||||
const meta = e._meta?.['io.modelcontextprotocol.registry/official'];
|
||||
const status = meta?.status;
|
||||
return !status || status === 'active';
|
||||
});
|
||||
|
||||
// Deduplicate by server name (take first = latest version)
|
||||
const seen = new Set<string>();
|
||||
const unique: RegistryServerEntry[] = [];
|
||||
for (const entry of active) {
|
||||
if (!seen.has(entry.server.name)) {
|
||||
seen.add(entry.server.name);
|
||||
unique.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return unique.map((entry) => this.normalizeEntry(entry));
|
||||
}
|
||||
|
||||
private normalizeEntry(entry: RegistryServerEntry): McpCatalogItem {
|
||||
const { server } = entry;
|
||||
const meta = entry._meta?.['io.modelcontextprotocol.registry/official'];
|
||||
const installSpec = this.deriveInstallSpec(server);
|
||||
const envVars = this.collectEnvVars(server);
|
||||
const requiresAuth = this.detectAuthRequired(server);
|
||||
|
||||
return {
|
||||
id: server.name,
|
||||
name: server.title ?? server.name.split('/').pop() ?? server.name,
|
||||
description: server.description ?? '',
|
||||
repositoryUrl: server.repository?.url,
|
||||
version: server.version,
|
||||
source: 'official',
|
||||
installSpec,
|
||||
envVars,
|
||||
license: undefined, // Official registry doesn't expose license
|
||||
tools: [], // Tools not included in registry list response
|
||||
glamaUrl: undefined,
|
||||
requiresAuth,
|
||||
iconUrl: this.pickIconUrl(server.icons),
|
||||
websiteUrl: server.websiteUrl,
|
||||
status: meta?.status,
|
||||
publishedAt: meta?.publishedAt,
|
||||
updatedAt: meta?.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private deriveInstallSpec(server: RegistryServerEntry['server']): McpInstallSpec | null {
|
||||
// Prefer npm stdio package
|
||||
const npmPkg = server.packages?.find((p) => p.registryType === 'npm');
|
||||
if (npmPkg) {
|
||||
return {
|
||||
type: 'stdio',
|
||||
npmPackage: npmPkg.identifier,
|
||||
npmVersion: npmPkg.version,
|
||||
};
|
||||
}
|
||||
|
||||
// HTTP/SSE remote
|
||||
const remote = server.remotes?.[0];
|
||||
if (remote) {
|
||||
return {
|
||||
type: 'http',
|
||||
url: remote.url,
|
||||
transportType: remote.type as 'streamable-http' | 'sse' | 'http',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private collectEnvVars(server: RegistryServerEntry['server']): McpEnvVarDef[] {
|
||||
const envVars: McpEnvVarDef[] = [];
|
||||
|
||||
// From packages
|
||||
for (const pkg of server.packages ?? []) {
|
||||
for (const ev of pkg.environmentVariables ?? []) {
|
||||
envVars.push({
|
||||
name: ev.name,
|
||||
isSecret: ev.isSecret ?? false,
|
||||
description: ev.description,
|
||||
isRequired: ev.isRequired,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return envVars;
|
||||
}
|
||||
|
||||
private detectAuthRequired(server: RegistryServerEntry['server']): boolean {
|
||||
for (const remote of server.remotes ?? []) {
|
||||
for (const header of remote.headers ?? []) {
|
||||
if (header.isRequired) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Pick best icon URL from the registry icons array (prefer dark theme PNG). */
|
||||
private pickIconUrl(icons?: RegistryIcon[]): string | undefined {
|
||||
if (!icons || icons.length === 0) return undefined;
|
||||
// Prefer dark-theme icon, then first available
|
||||
const darkIcon = icons.find((i) => i.theme === 'dark');
|
||||
return (darkIcon ?? icons[0]).src;
|
||||
}
|
||||
}
|
||||
318
src/main/services/extensions/catalog/PluginCatalogService.ts
Normal file
318
src/main/services/extensions/catalog/PluginCatalogService.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
/**
|
||||
* Fetches and caches the Claude Code plugin marketplace catalog.
|
||||
*
|
||||
* - Fetches marketplace.json from raw.githubusercontent.com
|
||||
* - ETag + If-None-Match for bandwidth efficiency
|
||||
* - In-memory cache with TTL (15 min)
|
||||
* - Stale cache fallback on network error
|
||||
* - Deduplicates concurrent requests
|
||||
*/
|
||||
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type { PluginCatalogItem } from '@shared/types/extensions';
|
||||
import { buildPluginId } from '@shared/utils/extensionNormalizers';
|
||||
|
||||
const logger = createLogger('Extensions:PluginCatalog');
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const MARKETPLACE_URL =
|
||||
'https://raw.githubusercontent.com/anthropics/claude-plugins-official/main/.claude-plugin/marketplace.json';
|
||||
|
||||
const CACHE_TTL_MS = 15 * 60 * 1_000; // 15 minutes
|
||||
const HTTP_TIMEOUT_MS = 15_000; // 15 seconds
|
||||
const MAX_REDIRECTS = 5;
|
||||
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB safety limit
|
||||
const MAX_README_CACHE_SIZE = 50; // Max README entries to cache
|
||||
|
||||
// ── HTTP helpers (adapted from CliInstallerService) ────────────────────────
|
||||
|
||||
interface FetchOptions {
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface FetchResponse {
|
||||
statusCode: number;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function httpsGetFollowRedirects(
|
||||
url: string,
|
||||
options: FetchOptions = {},
|
||||
redirectsLeft = MAX_REDIRECTS,
|
||||
timeoutMs = HTTP_TIMEOUT_MS
|
||||
): Promise<FetchResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const transport = parsedUrl.protocol === 'http:' ? http : https;
|
||||
let settled = false;
|
||||
|
||||
const settleResolve = (value: FetchResponse): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
const settleReject = (err: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const reqOptions = {
|
||||
headers: options.headers ?? {},
|
||||
};
|
||||
|
||||
const req = transport.get(url, reqOptions, (res) => {
|
||||
const status = res.statusCode ?? 0;
|
||||
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
if (redirectsLeft <= 0) {
|
||||
res.destroy();
|
||||
settleReject(new Error('Too many redirects'));
|
||||
return;
|
||||
}
|
||||
const redirectUrl = new URL(res.headers.location, url).toString();
|
||||
res.destroy();
|
||||
httpsGetFollowRedirects(redirectUrl, options, redirectsLeft - 1, timeoutMs).then(
|
||||
settleResolve,
|
||||
settleReject
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_BODY_SIZE) {
|
||||
res.destroy(new Error(`Response body exceeds ${MAX_BODY_SIZE} bytes`));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () =>
|
||||
settleResolve({
|
||||
statusCode: status,
|
||||
headers: res.headers as Record<string, string | string[] | undefined>,
|
||||
body: Buffer.concat(chunks).toString('utf-8'),
|
||||
})
|
||||
);
|
||||
res.on('error', settleReject);
|
||||
});
|
||||
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
req.destroy(new Error(`Connection timed out after ${timeoutMs}ms fetching ${url}`));
|
||||
});
|
||||
req.on('error', (err) => settleReject(err instanceof Error ? err : new Error(String(err))));
|
||||
});
|
||||
}
|
||||
|
||||
// ── Marketplace JSON shape ─────────────────────────────────────────────────
|
||||
|
||||
interface MarketplaceJson {
|
||||
name: string;
|
||||
plugins: RawMarketplacePlugin[];
|
||||
}
|
||||
|
||||
interface RawMarketplacePlugin {
|
||||
name: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
category?: string;
|
||||
author?: { name: string; email?: string };
|
||||
source: string | { source: string; url: string; sha?: string };
|
||||
strict?: boolean;
|
||||
lspServers?: Record<string, unknown>;
|
||||
mcpServers?: Record<string, unknown>;
|
||||
agents?: Record<string, unknown>;
|
||||
commands?: Record<string, unknown>;
|
||||
hooks?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Cache ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface CatalogCache {
|
||||
items: PluginCatalogItem[];
|
||||
etag: string | null;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
// ── Service ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class PluginCatalogService {
|
||||
private cache: CatalogCache | null = null;
|
||||
private fetchInFlight: Promise<PluginCatalogItem[]> | null = null;
|
||||
private readmeCache = new Map<string, { content: string | null; fetchedAt: number }>();
|
||||
|
||||
/**
|
||||
* Get all plugins from the marketplace catalog.
|
||||
* Uses in-memory cache with ETag validation.
|
||||
*/
|
||||
async getPlugins(forceRefresh = false): Promise<PluginCatalogItem[]> {
|
||||
// Return cached if fresh and not forcing
|
||||
if (!forceRefresh && this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) {
|
||||
return this.cache.items;
|
||||
}
|
||||
|
||||
// Deduplicate concurrent requests
|
||||
if (this.fetchInFlight) {
|
||||
return this.fetchInFlight;
|
||||
}
|
||||
|
||||
this.fetchInFlight = this.fetchCatalog().finally(() => {
|
||||
this.fetchInFlight = null;
|
||||
});
|
||||
|
||||
return this.fetchInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get README content for a plugin by its pluginId.
|
||||
* For external plugins (source is URL), fetches README from the GitHub repo.
|
||||
* Returns null for local/bundled plugins or on error.
|
||||
*/
|
||||
async getPluginReadme(pluginId: string): Promise<string | null> {
|
||||
const cached = this.readmeCache.get(pluginId);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
return cached.content;
|
||||
}
|
||||
|
||||
// Need catalog to find the plugin's repo URL
|
||||
const plugins = await this.getPlugins();
|
||||
const plugin = plugins.find((p) => p.pluginId === pluginId);
|
||||
if (!plugin?.homepage) {
|
||||
this.setReadmeCache(pluginId, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const readmeUrl = this.buildReadmeUrl(plugin.homepage);
|
||||
if (!readmeUrl) {
|
||||
this.setReadmeCache(pluginId, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await httpsGetFollowRedirects(readmeUrl);
|
||||
if (response.statusCode === 200) {
|
||||
this.setReadmeCache(pluginId, response.body);
|
||||
return response.body;
|
||||
}
|
||||
this.setReadmeCache(pluginId, null);
|
||||
return null;
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to fetch README for ${pluginId}:`, err);
|
||||
this.setReadmeCache(pluginId, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a single plugin by pluginId from the cached catalog.
|
||||
* Used for main-side re-resolution during install.
|
||||
*/
|
||||
async resolvePlugin(pluginId: string): Promise<PluginCatalogItem | null> {
|
||||
const plugins = await this.getPlugins();
|
||||
return plugins.find((p) => p.pluginId === pluginId) ?? null;
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Set readme cache with LRU eviction */
|
||||
private setReadmeCache(pluginId: string, content: string | null): void {
|
||||
// Evict oldest entries if at capacity
|
||||
if (this.readmeCache.size >= MAX_README_CACHE_SIZE && !this.readmeCache.has(pluginId)) {
|
||||
let oldestKey: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
for (const [key, entry] of this.readmeCache) {
|
||||
if (entry.fetchedAt < oldestTime) {
|
||||
oldestTime = entry.fetchedAt;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
if (oldestKey) this.readmeCache.delete(oldestKey);
|
||||
}
|
||||
this.readmeCache.set(pluginId, { content, fetchedAt: Date.now() });
|
||||
}
|
||||
|
||||
private async fetchCatalog(): Promise<PluginCatalogItem[]> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.cache?.etag) {
|
||||
headers['If-None-Match'] = this.cache.etag;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await httpsGetFollowRedirects(MARKETPLACE_URL, { headers });
|
||||
|
||||
// 304 Not Modified — cache is still valid
|
||||
if (response.statusCode === 304 && this.cache) {
|
||||
this.cache.fetchedAt = Date.now();
|
||||
logger.info('Marketplace catalog not modified (304)');
|
||||
return this.cache.items;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
throw new Error(`HTTP ${response.statusCode} fetching marketplace`);
|
||||
}
|
||||
|
||||
const json = JSON.parse(response.body) as MarketplaceJson;
|
||||
const items = this.parseMarketplace(json);
|
||||
const etag = (response.headers['etag'] as string) ?? null;
|
||||
|
||||
this.cache = { items, etag, fetchedAt: Date.now() };
|
||||
logger.info(`Fetched ${items.length} plugins from marketplace "${json.name}"`);
|
||||
return items;
|
||||
} catch (err) {
|
||||
// Stale cache fallback
|
||||
if (this.cache) {
|
||||
logger.warn('Marketplace fetch failed, using stale cache:', err);
|
||||
return this.cache.items;
|
||||
}
|
||||
logger.error('Marketplace fetch failed with no cache:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private parseMarketplace(json: MarketplaceJson): PluginCatalogItem[] {
|
||||
const marketplaceName = json.name;
|
||||
|
||||
return json.plugins.map((raw): PluginCatalogItem => {
|
||||
const qualifiedName = buildPluginId(raw.name, marketplaceName);
|
||||
const isExternal = typeof raw.source === 'object';
|
||||
const homepage = isExternal ? (raw.source as { url: string }).url : undefined;
|
||||
|
||||
return {
|
||||
pluginId: qualifiedName,
|
||||
marketplaceId: qualifiedName,
|
||||
qualifiedName,
|
||||
name: raw.name,
|
||||
description: raw.description ?? '',
|
||||
category: raw.category ?? 'other',
|
||||
author: raw.author,
|
||||
version: raw.version,
|
||||
homepage: homepage?.replace(/\.git$/, ''),
|
||||
tags: undefined,
|
||||
hasLspServers: raw.lspServers != null && Object.keys(raw.lspServers).length > 0,
|
||||
hasMcpServers: raw.mcpServers != null && Object.keys(raw.mcpServers).length > 0,
|
||||
hasAgents: raw.agents != null && Object.keys(raw.agents).length > 0,
|
||||
hasCommands: raw.commands != null && Object.keys(raw.commands).length > 0,
|
||||
hasHooks: raw.hooks != null && Object.keys(raw.hooks).length > 0,
|
||||
isExternal,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build raw GitHub README URL from a GitHub repo URL.
|
||||
* e.g. https://github.com/org/repo → https://raw.githubusercontent.com/org/repo/main/README.md
|
||||
*/
|
||||
private buildReadmeUrl(repoUrl: string): string | null {
|
||||
const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
|
||||
if (!match) return null;
|
||||
const [, owner, repo] = match;
|
||||
return `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`;
|
||||
}
|
||||
}
|
||||
15
src/main/services/extensions/index.ts
Normal file
15
src/main/services/extensions/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Extension services barrel export.
|
||||
*/
|
||||
|
||||
export { PluginCatalogService } from './catalog/PluginCatalogService';
|
||||
export { OfficialMcpRegistryService } from './catalog/OfficialMcpRegistryService';
|
||||
export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService';
|
||||
export { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
|
||||
export { PluginInstallationStateService } from './state/PluginInstallationStateService';
|
||||
export { McpInstallationStateService } from './state/McpInstallationStateService';
|
||||
export { ExtensionFacadeService } from './ExtensionFacadeService';
|
||||
export { PluginInstallService } from './install/PluginInstallService';
|
||||
export { McpInstallService } from './install/McpInstallService';
|
||||
export { ApiKeyService } from './apikeys/ApiKeyService';
|
||||
export { GitHubStarsService } from './catalog/GitHubStarsService';
|
||||
347
src/main/services/extensions/install/McpInstallService.ts
Normal file
347
src/main/services/extensions/install/McpInstallService.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
/**
|
||||
* McpInstallService — installs/uninstalls MCP servers via Claude CLI.
|
||||
*
|
||||
* Security model: renderer sends ONLY registryId + user inputs (env values,
|
||||
* headers, server name). Main re-fetches server spec from registry via getById()
|
||||
* and builds CLI args from the fresh registry data (never trusts install spec
|
||||
* from renderer).
|
||||
*/
|
||||
|
||||
import { execCli } from '@main/utils/childProcess';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type {
|
||||
McpCustomInstallRequest,
|
||||
McpInstallRequest,
|
||||
OperationResult,
|
||||
} from '@shared/types/extensions';
|
||||
import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator';
|
||||
|
||||
const logger = createLogger('Extensions:McpInstall');
|
||||
|
||||
/** Validate server name: alphanumeric, dashes, underscores, dots */
|
||||
const SERVER_NAME_RE = /^[\w.-]{1,100}$/;
|
||||
|
||||
/** Allowed scope values (prevent command injection) */
|
||||
const VALID_SCOPES = new Set(['local', 'user', 'project']);
|
||||
|
||||
/** Env var key must be safe shell identifier */
|
||||
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i;
|
||||
|
||||
/** HTTP header key must be safe (RFC 7230 token) */
|
||||
const HEADER_KEY_RE = /^[A-Za-z][\w-]{0,100}$/;
|
||||
|
||||
const TIMEOUT_MS = 30_000;
|
||||
|
||||
export class McpInstallService {
|
||||
constructor(
|
||||
private readonly claudeBinary: string | null,
|
||||
private readonly aggregator: McpCatalogAggregator
|
||||
) {}
|
||||
|
||||
async install(request: McpInstallRequest): Promise<OperationResult> {
|
||||
const { registryId, serverName, scope, projectPath, envValues, headers } = request;
|
||||
|
||||
// 1. Validate server name
|
||||
if (!SERVER_NAME_RE.test(serverName)) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `Invalid server name: "${serverName}". Use alphanumeric, dashes, underscores, dots.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Validate scope
|
||||
if (scope && !VALID_SCOPES.has(scope)) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Validate env var keys (prevent command injection)
|
||||
for (const key of Object.keys(envValues)) {
|
||||
if (!ENV_KEY_RE.test(key)) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `Invalid environment variable name: "${key}". Use uppercase alphanumeric and underscores.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Validate header keys (prevent header injection)
|
||||
for (const header of headers) {
|
||||
if (header.key && !HEADER_KEY_RE.test(header.key)) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `Invalid header name: "${header.key}". Use alphanumeric, dashes, underscores.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Validate projectPath (if provided, must be absolute)
|
||||
if (projectPath && !projectPath.startsWith('/')) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: 'projectPath must be an absolute path',
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Re-fetch from registry (don't trust renderer-provided install spec)
|
||||
const server = await this.aggregator.getById(registryId);
|
||||
if (!server) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `MCP server "${registryId}" not found in registry`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!server.installSpec) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `MCP server "${server.name}" does not have an automatic install spec. Manual setup required.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 7. Build CLI args based on install spec type
|
||||
const args: string[] = ['mcp', 'add'];
|
||||
|
||||
// Scope flag (-s)
|
||||
if (scope && scope !== 'local') {
|
||||
args.push('-s', scope);
|
||||
}
|
||||
|
||||
if (server.installSpec.type === 'stdio') {
|
||||
// Stdio: claude mcp add [-s scope] [-e KEY=val...] <name> -- npx -y <package>[@version]
|
||||
// Add env flags
|
||||
for (const [key, value] of Object.entries(envValues)) {
|
||||
if (key && value) {
|
||||
args.push('-e', `${key}=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
args.push(serverName);
|
||||
args.push('--');
|
||||
args.push('npx', '-y');
|
||||
|
||||
const pkg = server.installSpec.npmVersion
|
||||
? `${server.installSpec.npmPackage}@${server.installSpec.npmVersion}`
|
||||
: server.installSpec.npmPackage;
|
||||
args.push(pkg);
|
||||
} else if (server.installSpec.type === 'http') {
|
||||
// HTTP/SSE: claude mcp add [-s scope] -t <transport> [-H "Key: val"...] <name> <url>
|
||||
const transport = server.installSpec.transportType === 'sse' ? 'sse' : 'http';
|
||||
args.push('-t', transport);
|
||||
|
||||
// Add header flags
|
||||
for (const header of headers) {
|
||||
if (header.key && header.value) {
|
||||
args.push('-H', `${header.key}: ${header.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add env flags (some HTTP servers also need env vars)
|
||||
for (const [key, value] of Object.entries(envValues)) {
|
||||
if (key && value) {
|
||||
args.push('-e', `${key}=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
args.push(serverName);
|
||||
args.push(server.installSpec.url);
|
||||
} else {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `Unsupported install spec type: ${(server.installSpec as { type: string }).type}`,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Installing MCP server: ${serverName} (type: ${server.installSpec.type}, scope: ${scope ?? 'local'})`
|
||||
);
|
||||
// Don't log env values or header values (may contain secrets)
|
||||
|
||||
try {
|
||||
const { stderr } = await execCli(this.claudeBinary, args, {
|
||||
timeout: TIMEOUT_MS,
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
if (stderr) {
|
||||
logger.warn(`MCP install stderr: ${stderr.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
return { state: 'success' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Mask potential secrets in error output
|
||||
const safeMessage = maskSecrets(
|
||||
message,
|
||||
envValues,
|
||||
headers.map((h) => h.value)
|
||||
);
|
||||
logger.error(`MCP install failed: ${safeMessage}`);
|
||||
return { state: 'error', error: safeMessage };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a custom MCP server — user provides installSpec directly (bypasses registry).
|
||||
*/
|
||||
async installCustom(request: McpCustomInstallRequest): Promise<OperationResult> {
|
||||
const { serverName, scope, projectPath, installSpec, envValues, headers } = request;
|
||||
|
||||
// Validate inputs (same rules as registry install)
|
||||
if (!SERVER_NAME_RE.test(serverName)) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `Invalid server name: "${serverName}". Use alphanumeric, dashes, underscores, dots.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (scope && !VALID_SCOPES.has(scope)) {
|
||||
return { state: 'error', error: `Invalid scope: "${scope}".` };
|
||||
}
|
||||
|
||||
for (const key of Object.keys(envValues)) {
|
||||
if (!ENV_KEY_RE.test(key)) {
|
||||
return { state: 'error', error: `Invalid env var name: "${key}".` };
|
||||
}
|
||||
}
|
||||
|
||||
for (const header of headers) {
|
||||
if (header.key && !HEADER_KEY_RE.test(header.key)) {
|
||||
return { state: 'error', error: `Invalid header name: "${header.key}".` };
|
||||
}
|
||||
}
|
||||
|
||||
if (projectPath && !projectPath.startsWith('/')) {
|
||||
return { state: 'error', error: 'projectPath must be an absolute path' };
|
||||
}
|
||||
|
||||
// Build CLI args from provided installSpec
|
||||
const args: string[] = ['mcp', 'add'];
|
||||
|
||||
if (scope && scope !== 'local') {
|
||||
args.push('-s', scope);
|
||||
}
|
||||
|
||||
if (installSpec.type === 'stdio') {
|
||||
for (const [key, value] of Object.entries(envValues)) {
|
||||
if (key && value) args.push('-e', `${key}=${value}`);
|
||||
}
|
||||
|
||||
args.push(serverName);
|
||||
args.push('--');
|
||||
args.push('npx', '-y');
|
||||
|
||||
const pkg = installSpec.npmVersion
|
||||
? `${installSpec.npmPackage}@${installSpec.npmVersion}`
|
||||
: installSpec.npmPackage;
|
||||
args.push(pkg);
|
||||
} else if (installSpec.type === 'http') {
|
||||
const transport = installSpec.transportType === 'sse' ? 'sse' : 'http';
|
||||
args.push('-t', transport);
|
||||
|
||||
for (const header of headers) {
|
||||
if (header.key && header.value) {
|
||||
args.push('-H', `${header.key}: ${header.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(envValues)) {
|
||||
if (key && value) args.push('-e', `${key}=${value}`);
|
||||
}
|
||||
|
||||
args.push(serverName);
|
||||
args.push(installSpec.url);
|
||||
} else {
|
||||
return { state: 'error', error: 'Unsupported install spec type' };
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Installing custom MCP server: ${serverName} (type: ${installSpec.type}, scope: ${scope ?? 'local'})`
|
||||
);
|
||||
|
||||
try {
|
||||
const { stderr } = await execCli(this.claudeBinary, args, {
|
||||
timeout: TIMEOUT_MS,
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
if (stderr) {
|
||||
logger.warn(`Custom MCP install stderr: ${stderr.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
return { state: 'success' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const safeMessage = maskSecrets(
|
||||
message,
|
||||
envValues,
|
||||
headers.map((h) => h.value)
|
||||
);
|
||||
logger.error(`Custom MCP install failed: ${safeMessage}`);
|
||||
return { state: 'error', error: safeMessage };
|
||||
}
|
||||
}
|
||||
|
||||
async uninstall(name: string, scope?: string, projectPath?: string): Promise<OperationResult> {
|
||||
if (!SERVER_NAME_RE.test(name)) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `Invalid server name: "${name}"`,
|
||||
};
|
||||
}
|
||||
|
||||
if (scope && !VALID_SCOPES.has(scope)) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (projectPath && !projectPath.startsWith('/')) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: 'projectPath must be an absolute path',
|
||||
};
|
||||
}
|
||||
|
||||
const args = ['mcp', 'remove'];
|
||||
if (scope && scope !== 'local') {
|
||||
args.push('-s', scope);
|
||||
}
|
||||
args.push(name);
|
||||
|
||||
logger.info(`Removing MCP server: ${name} (scope: ${scope ?? 'local'})`);
|
||||
|
||||
try {
|
||||
await execCli(this.claudeBinary, args, {
|
||||
timeout: TIMEOUT_MS,
|
||||
cwd: projectPath,
|
||||
});
|
||||
return { state: 'success' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error(`MCP uninstall failed: ${message}`);
|
||||
return { state: 'error', error: message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace secret values in error messages with [REDACTED] */
|
||||
function maskSecrets(
|
||||
message: string,
|
||||
envValues: Record<string, string>,
|
||||
headerValues: string[]
|
||||
): string {
|
||||
let result = message;
|
||||
const secrets = [
|
||||
...Object.values(envValues).filter((v) => v.length > 3),
|
||||
...headerValues.filter((v) => v.length > 3),
|
||||
];
|
||||
for (const secret of secrets) {
|
||||
result = result.replaceAll(secret, '[REDACTED]');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
154
src/main/services/extensions/install/PluginInstallService.ts
Normal file
154
src/main/services/extensions/install/PluginInstallService.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* PluginInstallService — installs/uninstalls plugins via Claude CLI.
|
||||
*
|
||||
* Security model: renderer sends ONLY pluginId, main resolves qualifiedName
|
||||
* from the current catalog snapshot (never trusts renderer-provided paths).
|
||||
*/
|
||||
|
||||
import { execCli } from '@main/utils/childProcess';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions';
|
||||
import type { PluginCatalogService } from '../catalog/PluginCatalogService';
|
||||
|
||||
const logger = createLogger('Extensions:PluginInstall');
|
||||
|
||||
/** Validate qualifiedName: must be <name>@<marketplace> with safe characters */
|
||||
const QUALIFIED_NAME_RE = /^[\w.-]+@[\w.-]+$/;
|
||||
|
||||
/** Allowed scope values (prevent command injection) */
|
||||
const VALID_SCOPES = new Set(['local', 'user', 'project']);
|
||||
|
||||
const INSTALL_TIMEOUT_MS = 120_000; // plugins may clone repos
|
||||
const UNINSTALL_TIMEOUT_MS = 30_000;
|
||||
|
||||
export class PluginInstallService {
|
||||
constructor(
|
||||
private readonly claudeBinary: string | null,
|
||||
private readonly catalogService: PluginCatalogService
|
||||
) {}
|
||||
|
||||
async install(request: PluginInstallRequest): Promise<OperationResult> {
|
||||
const { pluginId, scope, projectPath } = request;
|
||||
|
||||
// 1. Validate scope
|
||||
if (scope && !VALID_SCOPES.has(scope)) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Validate projectPath
|
||||
if (projectPath && !projectPath.startsWith('/')) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: 'projectPath must be an absolute path',
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Resolve qualifiedName from catalog (NOT from renderer)
|
||||
const resolved = await this.catalogService.resolvePlugin(pluginId);
|
||||
if (!resolved) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `Plugin "${pluginId}" not found in catalog`,
|
||||
};
|
||||
}
|
||||
|
||||
const { qualifiedName } = resolved;
|
||||
|
||||
// 2. Validate qualifiedName format (prevent injection)
|
||||
if (!QUALIFIED_NAME_RE.test(qualifiedName)) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `Invalid plugin identifier: ${qualifiedName}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Build CLI args: claude plugin install [-s scope] <qualifiedName>
|
||||
const args = ['plugin', 'install'];
|
||||
if (scope && scope !== 'user') {
|
||||
args.push('-s', scope);
|
||||
}
|
||||
args.push(qualifiedName);
|
||||
|
||||
logger.info(`Installing plugin: ${qualifiedName} (scope: ${scope ?? 'user'})`);
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execCli(this.claudeBinary, args, {
|
||||
timeout: INSTALL_TIMEOUT_MS,
|
||||
cwd: projectPath,
|
||||
});
|
||||
|
||||
if (stderr && !stdout) {
|
||||
logger.warn(`Plugin install stderr: ${stderr}`);
|
||||
}
|
||||
|
||||
return { state: 'success' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error(`Plugin install failed: ${message}`);
|
||||
return { state: 'error', error: message };
|
||||
}
|
||||
}
|
||||
|
||||
async uninstall(
|
||||
pluginId: string,
|
||||
scope?: string,
|
||||
projectPath?: string
|
||||
): Promise<OperationResult> {
|
||||
// Validate scope
|
||||
if (scope && !VALID_SCOPES.has(scope)) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (projectPath && !projectPath.startsWith('/')) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: 'projectPath must be an absolute path',
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve qualifiedName from catalog
|
||||
const resolved = await this.catalogService.resolvePlugin(pluginId);
|
||||
if (!resolved) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `Plugin "${pluginId}" not found in catalog`,
|
||||
};
|
||||
}
|
||||
|
||||
const { qualifiedName } = resolved;
|
||||
|
||||
if (!QUALIFIED_NAME_RE.test(qualifiedName)) {
|
||||
return {
|
||||
state: 'error',
|
||||
error: `Invalid plugin identifier: ${qualifiedName}`,
|
||||
};
|
||||
}
|
||||
|
||||
const args = ['plugin', 'uninstall'];
|
||||
if (scope && scope !== 'user') {
|
||||
args.push('-s', scope);
|
||||
}
|
||||
args.push(qualifiedName);
|
||||
|
||||
logger.info(`Uninstalling plugin: ${qualifiedName} (scope: ${scope ?? 'user'})`);
|
||||
|
||||
try {
|
||||
await execCli(this.claudeBinary, args, {
|
||||
timeout: UNINSTALL_TIMEOUT_MS,
|
||||
cwd: projectPath,
|
||||
});
|
||||
return { state: 'success' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error(`Plugin uninstall failed: ${message}`);
|
||||
return { state: 'error', error: message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Reads installed MCP server state from the filesystem.
|
||||
*
|
||||
* Sources:
|
||||
* - User scope: ~/.claude.json → mcpServers
|
||||
* - Project scope: .mcp.json in project root
|
||||
* - Local scope: determined by Claude CLI (may also be in ~/.claude.json)
|
||||
*
|
||||
* Both files are managed by the Claude CLI. This service is read-only.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type { InstalledMcpEntry } from '@shared/types/extensions';
|
||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||
|
||||
const logger = createLogger('Extensions:McpState');
|
||||
|
||||
const CACHE_TTL_MS = 10_000; // 10 seconds
|
||||
|
||||
interface TimedCache<T> {
|
||||
data: T;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export class McpInstallationStateService {
|
||||
private cache: TimedCache<InstalledMcpEntry[]> | null = null;
|
||||
|
||||
/**
|
||||
* Get all installed MCP servers across user and project scopes.
|
||||
*/
|
||||
async getInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
||||
// Cache is project-path-dependent, so invalidate on path change
|
||||
if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) {
|
||||
return this.cache.data;
|
||||
}
|
||||
|
||||
const entries: InstalledMcpEntry[] = [];
|
||||
|
||||
// User scope: ~/.claude.json
|
||||
const userEntries = await this.readUserMcpServers();
|
||||
entries.push(...userEntries);
|
||||
|
||||
// Project scope: .mcp.json
|
||||
if (projectPath) {
|
||||
const projectEntries = await this.readProjectMcpServers(projectPath);
|
||||
entries.push(...projectEntries);
|
||||
}
|
||||
|
||||
this.cache = { data: entries, fetchedAt: Date.now() };
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache. Call after install/uninstall operations.
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
this.cache = null;
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────────────
|
||||
|
||||
private async readUserMcpServers(): Promise<InstalledMcpEntry[]> {
|
||||
const configPath = path.join(getHomeDir(), '.claude.json');
|
||||
return this.readMcpServersFromFile(configPath, 'user');
|
||||
}
|
||||
|
||||
private async readProjectMcpServers(projectPath: string): Promise<InstalledMcpEntry[]> {
|
||||
const configPath = path.join(projectPath, '.mcp.json');
|
||||
return this.readMcpServersFromFile(configPath, 'project');
|
||||
}
|
||||
|
||||
private async readMcpServersFromFile(
|
||||
filePath: string,
|
||||
scope: 'user' | 'project'
|
||||
): Promise<InstalledMcpEntry[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf-8');
|
||||
const json = JSON.parse(raw) as Record<string, unknown>;
|
||||
const mcpServers = json.mcpServers as
|
||||
| Record<string, { command?: string; url?: string }>
|
||||
| undefined;
|
||||
|
||||
if (!mcpServers || typeof mcpServers !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => {
|
||||
let transport: string | undefined;
|
||||
if (config.command) transport = 'stdio';
|
||||
else if (config.url) transport = 'http';
|
||||
|
||||
return { name, scope, transport };
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
logger.error(`Failed to read MCP servers from ${filePath}:`, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* Reads plugin installed state and install counts from the filesystem.
|
||||
*
|
||||
* Sources:
|
||||
* - Installed state: ~/.claude/plugins/installed_plugins.json
|
||||
* - Install counts: ~/.claude/plugins/install-counts-cache.json
|
||||
*
|
||||
* Both files are managed by the Claude CLI. This service is read-only.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import type { InstalledPluginEntry } from '@shared/types/extensions';
|
||||
import type { InstallScope } from '@shared/types/extensions';
|
||||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
|
||||
const logger = createLogger('Extensions:PluginState');
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────
|
||||
|
||||
const INSTALLED_STATE_TTL_MS = 10_000; // 10 seconds
|
||||
const INSTALL_COUNTS_TTL_MS = 5 * 60_000; // 5 minutes
|
||||
|
||||
// ── Raw file shapes ────────────────────────────────────────────────────────
|
||||
|
||||
interface InstalledPluginsJson {
|
||||
version: number;
|
||||
plugins: Record<
|
||||
string, // qualifiedName
|
||||
Array<{
|
||||
scope: string;
|
||||
installPath?: string;
|
||||
version?: string;
|
||||
installedAt?: string;
|
||||
lastUpdated?: string;
|
||||
gitCommitSha?: string;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
interface InstallCountsJson {
|
||||
version: number;
|
||||
fetchedAt: string;
|
||||
counts: Array<{
|
||||
plugin: string; // qualifiedName format
|
||||
unique_installs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ── Cache ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TimedCache<T> {
|
||||
data: T;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
// ── Service ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class PluginInstallationStateService {
|
||||
private installedCache: TimedCache<InstalledPluginEntry[]> | null = null;
|
||||
private countsCache: TimedCache<Map<string, number>> | null = null;
|
||||
|
||||
/**
|
||||
* Get all installed plugins across all scopes.
|
||||
* Returns merged list from installed_plugins.json with scope tags.
|
||||
*/
|
||||
async getInstalledPlugins(_projectPath?: string): Promise<InstalledPluginEntry[]> {
|
||||
if (
|
||||
this.installedCache &&
|
||||
Date.now() - this.installedCache.fetchedAt < INSTALLED_STATE_TTL_MS
|
||||
) {
|
||||
return this.installedCache.data;
|
||||
}
|
||||
|
||||
const entries = await this.readInstalledPlugins();
|
||||
this.installedCache = { data: entries, fetchedAt: Date.now() };
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get install counts keyed by pluginId (qualifiedName).
|
||||
*/
|
||||
async getInstallCounts(): Promise<Map<string, number>> {
|
||||
if (this.countsCache && Date.now() - this.countsCache.fetchedAt < INSTALL_COUNTS_TTL_MS) {
|
||||
return this.countsCache.data;
|
||||
}
|
||||
|
||||
const counts = await this.readInstallCounts();
|
||||
this.countsCache = { data: counts, fetchedAt: Date.now() };
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all caches. Call after install/uninstall operations.
|
||||
*/
|
||||
invalidateCache(): void {
|
||||
this.installedCache = null;
|
||||
this.countsCache = null;
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────────────
|
||||
|
||||
private getPluginsDir(): string {
|
||||
return path.join(getClaudeBasePath(), 'plugins');
|
||||
}
|
||||
|
||||
private async readInstalledPlugins(): Promise<InstalledPluginEntry[]> {
|
||||
const filePath = path.join(this.getPluginsDir(), 'installed_plugins.json');
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf-8');
|
||||
const json = JSON.parse(raw) as InstalledPluginsJson;
|
||||
|
||||
if (json.version !== 2 || !json.plugins) {
|
||||
logger.warn(`Unexpected installed_plugins.json version: ${json.version}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: InstalledPluginEntry[] = [];
|
||||
|
||||
for (const [qualifiedName, installations] of Object.entries(json.plugins)) {
|
||||
for (const inst of installations) {
|
||||
entries.push({
|
||||
pluginId: qualifiedName,
|
||||
scope: this.normalizeScope(inst.scope),
|
||||
version: inst.version,
|
||||
installedAt: inst.installedAt,
|
||||
installPath: inst.installPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return []; // No plugins installed yet
|
||||
}
|
||||
logger.error('Failed to read installed_plugins.json:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async readInstallCounts(): Promise<Map<string, number>> {
|
||||
const filePath = path.join(this.getPluginsDir(), 'install-counts-cache.json');
|
||||
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, 'utf-8');
|
||||
const json = JSON.parse(raw) as InstallCountsJson;
|
||||
|
||||
const map = new Map<string, number>();
|
||||
|
||||
if (json.counts && Array.isArray(json.counts)) {
|
||||
for (const entry of json.counts) {
|
||||
// Install counts use qualifiedName format (name@marketplace)
|
||||
map.set(entry.plugin, entry.unique_installs);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return new Map();
|
||||
}
|
||||
logger.error('Failed to read install-counts-cache.json:', err);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeScope(raw: string): InstallScope {
|
||||
const lower = raw.toLowerCase();
|
||||
if (lower === 'user' || lower === 'project' || lower === 'local') {
|
||||
return lower;
|
||||
}
|
||||
return 'user'; // safe default
|
||||
}
|
||||
}
|
||||
|
|
@ -15,3 +15,5 @@ export * from './error';
|
|||
export * from './infrastructure';
|
||||
export * from './parsing';
|
||||
export * from './team';
|
||||
export * from './schedule';
|
||||
export * from './extensions';
|
||||
|
|
|
|||
206
src/main/services/schedule/JsonScheduleRepository.ts
Normal file
206
src/main/services/schedule/JsonScheduleRepository.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* JSON-based ScheduleRepository implementation.
|
||||
*
|
||||
* Storage layout:
|
||||
* {getSchedulesBasePath()}/
|
||||
* schedules.json — Schedule[]
|
||||
* runs/{scheduleId}.json — ScheduleRun[] (newest first, max 50)
|
||||
* logs/{scheduleId}/{runId}.log — stdout (max 64KB)
|
||||
* logs/{scheduleId}/{runId}.err — stderr (max 16KB)
|
||||
*/
|
||||
|
||||
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
||||
import { getSchedulesBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { Schedule, ScheduleRun } from '@shared/types';
|
||||
import type { ScheduleRepository } from './ScheduleRepository';
|
||||
|
||||
const logger = createLogger('Service:JsonScheduleRepo');
|
||||
|
||||
const READ_TIMEOUT_MS = 5_000;
|
||||
const MAX_RUNS_PER_SCHEDULE = 50;
|
||||
|
||||
export class JsonScheduleRepository implements ScheduleRepository {
|
||||
private get basePath(): string {
|
||||
return getSchedulesBasePath();
|
||||
}
|
||||
|
||||
private get schedulesFilePath(): string {
|
||||
return path.join(this.basePath, 'schedules.json');
|
||||
}
|
||||
|
||||
private runsFilePath(scheduleId: string): string {
|
||||
return path.join(this.basePath, 'runs', `${scheduleId}.json`);
|
||||
}
|
||||
|
||||
private logsDir(scheduleId: string): string {
|
||||
return path.join(this.basePath, 'logs', scheduleId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async listSchedules(): Promise<Schedule[]> {
|
||||
return this.readSchedulesFile();
|
||||
}
|
||||
|
||||
async getSchedule(id: string): Promise<Schedule | null> {
|
||||
const schedules = await this.readSchedulesFile();
|
||||
return schedules.find((s) => s.id === id) ?? null;
|
||||
}
|
||||
|
||||
async getSchedulesByTeam(teamName: string): Promise<Schedule[]> {
|
||||
const schedules = await this.readSchedulesFile();
|
||||
return schedules.filter((s) => s.teamName === teamName);
|
||||
}
|
||||
|
||||
async saveSchedule(schedule: Schedule): Promise<void> {
|
||||
const schedules = await this.readSchedulesFile();
|
||||
const idx = schedules.findIndex((s) => s.id === schedule.id);
|
||||
if (idx >= 0) {
|
||||
schedules[idx] = schedule;
|
||||
} else {
|
||||
schedules.push(schedule);
|
||||
}
|
||||
await this.writeSchedulesFile(schedules);
|
||||
}
|
||||
|
||||
async deleteSchedule(id: string): Promise<void> {
|
||||
const schedules = await this.readSchedulesFile();
|
||||
const filtered = schedules.filter((s) => s.id !== id);
|
||||
if (filtered.length !== schedules.length) {
|
||||
await this.writeSchedulesFile(filtered);
|
||||
}
|
||||
// Clean up runs and logs
|
||||
const runsFile = this.runsFilePath(id);
|
||||
await fs.promises.unlink(runsFile).catch(() => undefined);
|
||||
const logsPath = this.logsDir(id);
|
||||
await fs.promises.rm(logsPath, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async listRuns(
|
||||
scheduleId: string,
|
||||
opts?: { limit?: number; offset?: number }
|
||||
): Promise<ScheduleRun[]> {
|
||||
const runs = await this.readRunsFile(scheduleId);
|
||||
const offset = opts?.offset ?? 0;
|
||||
const limit = opts?.limit ?? runs.length;
|
||||
return runs.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
async getLatestRun(scheduleId: string): Promise<ScheduleRun | null> {
|
||||
const runs = await this.readRunsFile(scheduleId);
|
||||
return runs[0] ?? null;
|
||||
}
|
||||
|
||||
async saveRun(run: ScheduleRun): Promise<void> {
|
||||
const runs = await this.readRunsFile(run.scheduleId);
|
||||
const idx = runs.findIndex((r) => r.id === run.id);
|
||||
if (idx >= 0) {
|
||||
runs[idx] = run;
|
||||
} else {
|
||||
runs.unshift(run); // newest first
|
||||
}
|
||||
// Enforce max limit
|
||||
const trimmed = runs.slice(0, MAX_RUNS_PER_SCHEDULE);
|
||||
await this.writeRunsFile(run.scheduleId, trimmed);
|
||||
}
|
||||
|
||||
async pruneOldRuns(scheduleId: string, keepCount: number): Promise<number> {
|
||||
const runs = await this.readRunsFile(scheduleId);
|
||||
if (runs.length <= keepCount) {
|
||||
return 0;
|
||||
}
|
||||
const removed = runs.slice(keepCount);
|
||||
const kept = runs.slice(0, keepCount);
|
||||
await this.writeRunsFile(scheduleId, kept);
|
||||
|
||||
// Clean up log files for pruned runs
|
||||
for (const run of removed) {
|
||||
await this.deleteRunLogs(scheduleId, run.id);
|
||||
}
|
||||
|
||||
return removed.length;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal I/O
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async readSchedulesFile(): Promise<Schedule[]> {
|
||||
return this.readJsonFile<Schedule[]>(this.schedulesFilePath, []);
|
||||
}
|
||||
|
||||
private async writeSchedulesFile(schedules: Schedule[]): Promise<void> {
|
||||
await atomicWriteAsync(this.schedulesFilePath, JSON.stringify(schedules, null, 2));
|
||||
}
|
||||
|
||||
private async readRunsFile(scheduleId: string): Promise<ScheduleRun[]> {
|
||||
return this.readJsonFile<ScheduleRun[]>(this.runsFilePath(scheduleId), []);
|
||||
}
|
||||
|
||||
private async writeRunsFile(scheduleId: string, runs: ScheduleRun[]): Promise<void> {
|
||||
await atomicWriteAsync(this.runsFilePath(scheduleId), JSON.stringify(runs, null, 2));
|
||||
}
|
||||
|
||||
private async readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), READ_TIMEOUT_MS);
|
||||
try {
|
||||
const content = await fs.promises.readFile(filePath, {
|
||||
encoding: 'utf8',
|
||||
signal: controller.signal,
|
||||
});
|
||||
return JSON.parse(content) as T;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return defaultValue;
|
||||
}
|
||||
logger.warn(
|
||||
`Failed to read ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteRunLogs(scheduleId: string, runId: string): Promise<void> {
|
||||
const dir = this.logsDir(scheduleId);
|
||||
await fs.promises.unlink(path.join(dir, `${runId}.log`)).catch(() => undefined);
|
||||
await fs.promises.unlink(path.join(dir, `${runId}.err`)).catch(() => undefined);
|
||||
}
|
||||
|
||||
async saveRunLogs(
|
||||
scheduleId: string,
|
||||
runId: string,
|
||||
stdout: string,
|
||||
stderr: string
|
||||
): Promise<void> {
|
||||
const dir = this.logsDir(scheduleId);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await Promise.all([
|
||||
fs.promises.writeFile(path.join(dir, `${runId}.log`), stdout, 'utf8'),
|
||||
fs.promises.writeFile(path.join(dir, `${runId}.err`), stderr, 'utf8'),
|
||||
]);
|
||||
}
|
||||
|
||||
async getRunLogs(scheduleId: string, runId: string): Promise<{ stdout: string; stderr: string }> {
|
||||
const dir = this.logsDir(scheduleId);
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
fs.promises.readFile(path.join(dir, `${runId}.log`), 'utf8').catch(() => ''),
|
||||
fs.promises.readFile(path.join(dir, `${runId}.err`), 'utf8').catch(() => ''),
|
||||
]);
|
||||
return { stdout, stderr };
|
||||
}
|
||||
}
|
||||
24
src/main/services/schedule/ScheduleRepository.ts
Normal file
24
src/main/services/schedule/ScheduleRepository.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Schedule repository interface — abstracts storage backend.
|
||||
*
|
||||
* Current implementation: JsonScheduleRepository (JSON files on disk).
|
||||
* Future upgrade path: Drizzle + sql.js (WASM, no native modules).
|
||||
*/
|
||||
|
||||
import type { Schedule, ScheduleRun } from '@shared/types';
|
||||
|
||||
export interface ScheduleRepository {
|
||||
listSchedules(): Promise<Schedule[]>;
|
||||
getSchedule(id: string): Promise<Schedule | null>;
|
||||
getSchedulesByTeam(teamName: string): Promise<Schedule[]>;
|
||||
saveSchedule(schedule: Schedule): Promise<void>;
|
||||
deleteSchedule(id: string): Promise<void>;
|
||||
|
||||
listRuns(scheduleId: string, opts?: { limit?: number; offset?: number }): Promise<ScheduleRun[]>;
|
||||
getLatestRun(scheduleId: string): Promise<ScheduleRun | null>;
|
||||
saveRun(run: ScheduleRun): Promise<void>;
|
||||
pruneOldRuns(scheduleId: string, keepCount: number): Promise<number>;
|
||||
|
||||
saveRunLogs(scheduleId: string, runId: string, stdout: string, stderr: string): Promise<void>;
|
||||
getRunLogs(scheduleId: string, runId: string): Promise<{ stdout: string; stderr: string }>;
|
||||
}
|
||||
224
src/main/services/schedule/ScheduledTaskExecutor.ts
Normal file
224
src/main/services/schedule/ScheduledTaskExecutor.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* One-shot executor for scheduled tasks.
|
||||
*
|
||||
* Spawns `claude -p <prompt>` as a child process with stream-json output,
|
||||
* captures stdout/stderr, and returns the result when the process exits.
|
||||
*
|
||||
* Uses `--output-format stream-json` so the renderer can display rich logs
|
||||
* (thinking blocks, tool cards, markdown) via CliLogsRichView.
|
||||
*/
|
||||
|
||||
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
||||
|
||||
import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
|
||||
const logger = createLogger('Service:ScheduledTaskExecutor');
|
||||
|
||||
const STDOUT_MAX_BYTES = 512 * 1024; // 512KB — stream-json is verbose (JSON wrappers, thinking, tool_use)
|
||||
const STDERR_MAX_BYTES = 16 * 1024; // 16KB
|
||||
const SUMMARY_MAX_CHARS = 500;
|
||||
|
||||
/**
|
||||
* Extracts a human-readable summary from stream-json stdout.
|
||||
* Finds the last assistant message's text content blocks.
|
||||
* Falls back to raw stdout slice if parsing yields nothing.
|
||||
*/
|
||||
function extractSummaryFromStreamJson(stdout: string): string {
|
||||
const lines = stdout.split('\n');
|
||||
let lastText = '';
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
||||
if (parsed.type !== 'assistant') continue;
|
||||
|
||||
const content = (parsed.content ??
|
||||
(parsed.message as Record<string, unknown> | undefined)?.content) as
|
||||
| Array<{ type?: string; text?: string }>
|
||||
| undefined;
|
||||
if (!Array.isArray(content)) continue;
|
||||
|
||||
for (const block of content) {
|
||||
if (block?.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
|
||||
lastText = block.text.trim();
|
||||
}
|
||||
}
|
||||
if (lastText) break;
|
||||
} catch {
|
||||
// skip non-JSON lines
|
||||
}
|
||||
}
|
||||
|
||||
return (lastText || stdout).slice(0, SUMMARY_MAX_CHARS);
|
||||
}
|
||||
|
||||
export interface ScheduledTaskResult {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
summary: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface ExecutionRequest {
|
||||
runId: string;
|
||||
config: ScheduleLaunchConfig;
|
||||
maxTurns: number;
|
||||
maxBudgetUsd?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal extension of ScheduleRun with pinned storage path.
|
||||
* Used by SchedulerService to ensure writes go to the correct path
|
||||
* even if claudeRootPath changes mid-run.
|
||||
*/
|
||||
export interface InternalScheduleRun extends ScheduleRun {
|
||||
storageBasePath: string;
|
||||
}
|
||||
|
||||
export class ScheduledTaskExecutor {
|
||||
private activeProcesses = new Map<string, ChildProcess>();
|
||||
|
||||
async execute(request: ExecutionRequest): Promise<ScheduledTaskResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
if (!binaryPath) {
|
||||
throw new Error('Claude CLI binary not found');
|
||||
}
|
||||
|
||||
const shellEnv = await resolveInteractiveShellEnv();
|
||||
|
||||
const args = this.buildArgs(request);
|
||||
|
||||
logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`);
|
||||
|
||||
const child = spawnCli(binaryPath, args, {
|
||||
cwd: request.config.cwd,
|
||||
env: { ...process.env, ...shellEnv, CLAUDECODE: undefined },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
this.activeProcesses.set(request.runId, child);
|
||||
|
||||
try {
|
||||
const result = await this.waitForExit(child, request.runId);
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
summary: extractSummaryFromStreamJson(result.stdout),
|
||||
durationMs,
|
||||
};
|
||||
} finally {
|
||||
this.activeProcesses.delete(request.runId);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(runId: string): boolean {
|
||||
const child = this.activeProcesses.get(runId);
|
||||
if (!child) {
|
||||
return false;
|
||||
}
|
||||
logger.info(`[${runId}] Cancelling active run`);
|
||||
killProcessTree(child, 'SIGTERM');
|
||||
this.activeProcesses.delete(runId);
|
||||
return true;
|
||||
}
|
||||
|
||||
cancelAll(): void {
|
||||
for (const [runId, child] of this.activeProcesses) {
|
||||
logger.info(`[${runId}] Cancelling (shutdown)`);
|
||||
killProcessTree(child, 'SIGTERM');
|
||||
}
|
||||
this.activeProcesses.clear();
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return this.activeProcesses.size;
|
||||
}
|
||||
|
||||
private buildArgs(request: ExecutionRequest): string[] {
|
||||
const { config, maxTurns, maxBudgetUsd } = request;
|
||||
const args: string[] = [
|
||||
'-p',
|
||||
config.prompt,
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--max-turns',
|
||||
String(maxTurns),
|
||||
'--no-session-persistence',
|
||||
];
|
||||
|
||||
if (maxBudgetUsd != null) {
|
||||
args.push('--max-budget-usd', String(maxBudgetUsd));
|
||||
}
|
||||
|
||||
if (config.model) {
|
||||
args.push('--model', config.model);
|
||||
}
|
||||
|
||||
if (config.skipPermissions !== false) {
|
||||
args.push('--dangerously-skip-permissions');
|
||||
}
|
||||
|
||||
if (config.allowedTools?.length) {
|
||||
args.push('--allowed-tools', config.allowedTools.join(','));
|
||||
}
|
||||
|
||||
if (config.disallowedTools?.length) {
|
||||
args.push('--disallowed-tools', config.disallowedTools.join(','));
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private waitForExit(
|
||||
child: ChildProcess,
|
||||
runId: string
|
||||
): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
if (stdoutBytes < STDOUT_MAX_BYTES) {
|
||||
stdoutChunks.push(chunk);
|
||||
stdoutBytes += chunk.length;
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
if (stderrBytes < STDERR_MAX_BYTES) {
|
||||
stderrChunks.push(chunk);
|
||||
stderrBytes += chunk.length;
|
||||
}
|
||||
});
|
||||
|
||||
child.once('error', (error) => {
|
||||
logger.error(`[${runId}] Process error: ${error.message}`);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.once('close', (code) => {
|
||||
const stdout = Buffer.concat(stdoutChunks).toString('utf8').slice(0, STDOUT_MAX_BYTES);
|
||||
const stderr = Buffer.concat(stderrChunks).toString('utf8').slice(0, STDERR_MAX_BYTES);
|
||||
|
||||
logger.info(`[${runId}] Process exited with code ${code}`);
|
||||
resolve({ exitCode: code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
850
src/main/services/schedule/SchedulerService.ts
Normal file
850
src/main/services/schedule/SchedulerService.ts
Normal file
|
|
@ -0,0 +1,850 @@
|
|||
/**
|
||||
* SchedulerService — orchestrates scheduled task execution via croner.
|
||||
*
|
||||
* Manages cron jobs, warm-up timers, execution lifecycle, and concurrency locks.
|
||||
* Uses one-shot `claude -p` executor (NOT launchTeam stream-json).
|
||||
*/
|
||||
|
||||
import { getSchedulesBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { Cron } from 'croner';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type {
|
||||
CreateScheduleInput,
|
||||
Schedule,
|
||||
ScheduleChangeEvent,
|
||||
ScheduleRun,
|
||||
ScheduleRunStatus,
|
||||
UpdateSchedulePatch,
|
||||
} from '@shared/types';
|
||||
import type { ScheduleRepository } from './ScheduleRepository';
|
||||
import type { ScheduledTaskExecutor } from './ScheduledTaskExecutor';
|
||||
|
||||
const logger = createLogger('Service:Scheduler');
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const WARMUP_RETRY_DELAY_MS = 60_000;
|
||||
const WARMUP_MAX_RETRIES = 3;
|
||||
const EXECUTION_MAX_RETRIES = 2;
|
||||
const EXECUTION_RETRY_DELAY_MS = 90_000; // 90s between retries
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
type ChangeEmitter = (event: ScheduleChangeEvent) => void;
|
||||
|
||||
/** Warm-up function injected from main process (wraps prepareForProvisioning) */
|
||||
export type WarmUpFn = (cwd: string) => Promise<{ ready: boolean; message: string }>;
|
||||
|
||||
// =============================================================================
|
||||
// SchedulerService
|
||||
// =============================================================================
|
||||
|
||||
export class SchedulerService {
|
||||
private repository: ScheduleRepository;
|
||||
private executor: ScheduledTaskExecutor;
|
||||
private warmUpFn: WarmUpFn;
|
||||
private changeEmitter: ChangeEmitter | null = null;
|
||||
|
||||
// Croner jobs keyed by schedule ID
|
||||
private cronJobs = new Map<string, Cron>();
|
||||
|
||||
// Warm-up timers keyed by schedule ID (includes warm-up retry timers)
|
||||
private warmUpTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Execution retry delay timers keyed by schedule ID
|
||||
private retryDelayTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
// Active runs keyed by schedule ID (only one run per schedule at a time)
|
||||
private activeRuns = new Map<string, ScheduleRun>();
|
||||
|
||||
// CWD exclusion lock: cwd → schedule ID (prevents two schedule runs on same dir)
|
||||
private cwdLock = new Map<string, string>();
|
||||
|
||||
// Flag to prevent retry timers from firing after stop()
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
repository: ScheduleRepository,
|
||||
executor: ScheduledTaskExecutor,
|
||||
warmUpFn?: WarmUpFn
|
||||
) {
|
||||
this.repository = repository;
|
||||
this.executor = executor;
|
||||
this.warmUpFn = warmUpFn ?? (async () => ({ ready: true, message: 'warm-up skipped' }));
|
||||
}
|
||||
|
||||
setChangeEmitter(emitter: ChangeEmitter): void {
|
||||
this.changeEmitter = emitter;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Lifecycle
|
||||
// ===========================================================================
|
||||
|
||||
async start(): Promise<void> {
|
||||
logger.info(`Scheduler starting, basePath=${getSchedulesBasePath()}`);
|
||||
|
||||
this.stopped = false;
|
||||
|
||||
// Recovery: mark interrupted runs from previous session
|
||||
await this.recoverInterruptedRuns();
|
||||
|
||||
// Load all schedules and create cron jobs for active ones
|
||||
const schedules = await this.repository.listSchedules();
|
||||
let activeCount = 0;
|
||||
|
||||
for (const schedule of schedules) {
|
||||
if (schedule.status === 'active') {
|
||||
this.createCronJob(schedule);
|
||||
activeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Scheduler started: ${activeCount} active jobs out of ${schedules.length} schedules`
|
||||
);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
logger.info('Scheduler stopping');
|
||||
|
||||
// Prevent retry timers from dispatching new work after stop
|
||||
this.stopped = true;
|
||||
|
||||
// Cancel all active executions
|
||||
this.executor.cancelAll();
|
||||
|
||||
// Stop all cron jobs
|
||||
for (const [id, job] of this.cronJobs) {
|
||||
job.stop();
|
||||
logger.debug(`Stopped cron job: ${id}`);
|
||||
}
|
||||
this.cronJobs.clear();
|
||||
|
||||
// Clear all warm-up timers
|
||||
for (const [id, timer] of this.warmUpTimers) {
|
||||
clearTimeout(timer);
|
||||
logger.debug(`Cleared warm-up timer: ${id}`);
|
||||
}
|
||||
this.warmUpTimers.clear();
|
||||
|
||||
// Clear execution retry delay timers
|
||||
for (const [id, timer] of this.retryDelayTimers) {
|
||||
clearTimeout(timer);
|
||||
logger.debug(`Cleared retry delay timer: ${id}`);
|
||||
}
|
||||
this.retryDelayTimers.clear();
|
||||
|
||||
// Clear locks
|
||||
this.activeRuns.clear();
|
||||
this.cwdLock.clear();
|
||||
|
||||
logger.info('Scheduler stopped');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// CRUD
|
||||
// ===========================================================================
|
||||
|
||||
async listSchedules(): Promise<Schedule[]> {
|
||||
return this.repository.listSchedules();
|
||||
}
|
||||
|
||||
async getSchedule(id: string): Promise<Schedule | null> {
|
||||
return this.repository.getSchedule(id);
|
||||
}
|
||||
|
||||
async getSchedulesByTeam(teamName: string): Promise<Schedule[]> {
|
||||
return this.repository.getSchedulesByTeam(teamName);
|
||||
}
|
||||
|
||||
async createSchedule(input: CreateScheduleInput): Promise<Schedule> {
|
||||
const now = new Date().toISOString();
|
||||
const schedule: Schedule = {
|
||||
id: randomUUID(),
|
||||
teamName: input.teamName,
|
||||
label: input.label,
|
||||
cronExpression: input.cronExpression,
|
||||
timezone: input.timezone,
|
||||
status: 'active',
|
||||
warmUpMinutes: input.warmUpMinutes ?? 15,
|
||||
maxConsecutiveFailures: 3,
|
||||
consecutiveFailures: 0,
|
||||
maxTurns: input.maxTurns ?? 50,
|
||||
maxBudgetUsd: input.maxBudgetUsd,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
launchConfig: input.launchConfig,
|
||||
};
|
||||
|
||||
// Compute nextRunAt before saving
|
||||
schedule.nextRunAt = this.computeNextRunAt(schedule) ?? undefined;
|
||||
|
||||
await this.repository.saveSchedule(schedule);
|
||||
this.createCronJob(schedule);
|
||||
|
||||
this.emitChange({
|
||||
type: 'schedule-updated',
|
||||
scheduleId: schedule.id,
|
||||
teamName: schedule.teamName,
|
||||
detail: 'created',
|
||||
});
|
||||
|
||||
logger.info(`Schedule created: ${schedule.id} for team ${schedule.teamName}`);
|
||||
return schedule;
|
||||
}
|
||||
|
||||
async updateSchedule(id: string, patch: UpdateSchedulePatch): Promise<Schedule> {
|
||||
const existing = await this.repository.getSchedule(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Schedule not found: ${id}`);
|
||||
}
|
||||
|
||||
const updated: Schedule = {
|
||||
...existing,
|
||||
...(patch.label !== undefined && { label: patch.label }),
|
||||
...(patch.cronExpression !== undefined && { cronExpression: patch.cronExpression }),
|
||||
...(patch.timezone !== undefined && { timezone: patch.timezone }),
|
||||
...(patch.warmUpMinutes !== undefined && { warmUpMinutes: patch.warmUpMinutes }),
|
||||
...(patch.maxTurns !== undefined && { maxTurns: patch.maxTurns }),
|
||||
...(patch.maxBudgetUsd !== undefined && { maxBudgetUsd: patch.maxBudgetUsd }),
|
||||
...(patch.launchConfig && {
|
||||
launchConfig: { ...existing.launchConfig, ...patch.launchConfig },
|
||||
}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Reschedule cron job if expression or timezone changed
|
||||
const cronChanged = patch.cronExpression !== undefined || patch.timezone !== undefined;
|
||||
|
||||
if (cronChanged || patch.warmUpMinutes !== undefined) {
|
||||
this.removeCronJob(id);
|
||||
if (updated.status === 'active') {
|
||||
updated.nextRunAt = this.computeNextRunAt(updated) ?? undefined;
|
||||
this.createCronJob(updated);
|
||||
}
|
||||
}
|
||||
|
||||
await this.repository.saveSchedule(updated);
|
||||
this.emitChange({
|
||||
type: 'schedule-updated',
|
||||
scheduleId: updated.id,
|
||||
teamName: updated.teamName,
|
||||
detail: 'updated',
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteSchedule(id: string): Promise<void> {
|
||||
const existing = await this.repository.getSchedule(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Schedule not found: ${id}`);
|
||||
}
|
||||
|
||||
// Cancel active run if any
|
||||
const activeRun = this.activeRuns.get(id);
|
||||
if (activeRun) {
|
||||
this.executor.cancel(activeRun.id);
|
||||
}
|
||||
this.removeCronJob(id);
|
||||
this.releaseRunLocks(id);
|
||||
|
||||
await this.repository.deleteSchedule(id);
|
||||
this.emitChange({
|
||||
type: 'schedule-updated',
|
||||
scheduleId: id,
|
||||
teamName: existing.teamName,
|
||||
detail: 'deleted',
|
||||
});
|
||||
|
||||
logger.info(`Schedule deleted: ${id}`);
|
||||
}
|
||||
|
||||
async pauseSchedule(id: string): Promise<void> {
|
||||
const existing = await this.repository.getSchedule(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Schedule not found: ${id}`);
|
||||
}
|
||||
|
||||
// Pause cron job
|
||||
const job = this.cronJobs.get(id);
|
||||
if (job) {
|
||||
job.pause();
|
||||
}
|
||||
|
||||
// Clear warm-up timer
|
||||
this.clearWarmUpTimer(id);
|
||||
|
||||
const updated: Schedule = {
|
||||
...existing,
|
||||
status: 'paused',
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.repository.saveSchedule(updated);
|
||||
this.emitChange({
|
||||
type: 'schedule-paused',
|
||||
scheduleId: id,
|
||||
teamName: existing.teamName,
|
||||
});
|
||||
|
||||
logger.info(`Schedule paused: ${id}`);
|
||||
}
|
||||
|
||||
async resumeSchedule(id: string): Promise<void> {
|
||||
const existing = await this.repository.getSchedule(id);
|
||||
if (!existing) {
|
||||
throw new Error(`Schedule not found: ${id}`);
|
||||
}
|
||||
|
||||
// Remove old job and recreate to get fresh next-run timing
|
||||
this.removeCronJob(id);
|
||||
|
||||
const updated: Schedule = {
|
||||
...existing,
|
||||
status: 'active',
|
||||
consecutiveFailures: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
updated.nextRunAt = this.computeNextRunAt(updated) ?? undefined;
|
||||
this.createCronJob(updated);
|
||||
|
||||
await this.repository.saveSchedule(updated);
|
||||
this.emitChange({
|
||||
type: 'schedule-updated',
|
||||
scheduleId: id,
|
||||
teamName: existing.teamName,
|
||||
detail: 'resumed',
|
||||
});
|
||||
|
||||
logger.info(`Schedule resumed: ${id}`);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Run History
|
||||
// ===========================================================================
|
||||
|
||||
async getRuns(
|
||||
scheduleId: string,
|
||||
opts?: { limit?: number; offset?: number }
|
||||
): Promise<ScheduleRun[]> {
|
||||
return this.repository.listRuns(scheduleId, opts);
|
||||
}
|
||||
|
||||
async getRunLogs(scheduleId: string, runId: string): Promise<{ stdout: string; stderr: string }> {
|
||||
return this.repository.getRunLogs(scheduleId, runId);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Trigger Now
|
||||
// ===========================================================================
|
||||
|
||||
async triggerNow(id: string): Promise<ScheduleRun> {
|
||||
const schedule = await this.repository.getSchedule(id);
|
||||
if (!schedule) {
|
||||
throw new Error(`Schedule not found: ${id}`);
|
||||
}
|
||||
|
||||
// Check locks
|
||||
if (this.activeRuns.has(id)) {
|
||||
throw new Error(`Schedule ${id} already has an active run`);
|
||||
}
|
||||
|
||||
const cwd = schedule.launchConfig.cwd;
|
||||
const cwdHolder = this.cwdLock.get(cwd);
|
||||
if (cwdHolder && cwdHolder !== id) {
|
||||
throw new Error(`Working directory "${cwd}" is locked by another schedule: ${cwdHolder}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const run: ScheduleRun = {
|
||||
id: randomUUID(),
|
||||
scheduleId: id,
|
||||
teamName: schedule.teamName,
|
||||
status: 'running',
|
||||
scheduledFor: now,
|
||||
startedAt: now,
|
||||
executionStartedAt: now,
|
||||
retryCount: 0,
|
||||
};
|
||||
|
||||
await this.repository.saveRun(run);
|
||||
this.emitChange({ type: 'run-started', scheduleId: id, teamName: schedule.teamName });
|
||||
|
||||
// Execute in background (don't await — triggerNow returns immediately)
|
||||
void this.executeRunInBackground(schedule, run);
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// claudeRootPath Change
|
||||
// ===========================================================================
|
||||
|
||||
async reloadForClaudeRootChange(): Promise<void> {
|
||||
logger.info('Reloading schedules for claudeRootPath change');
|
||||
await this.stop();
|
||||
await this.start();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Cron Job Management
|
||||
// ===========================================================================
|
||||
|
||||
private createCronJob(schedule: Schedule): void {
|
||||
if (this.cronJobs.has(schedule.id)) {
|
||||
this.removeCronJob(schedule.id);
|
||||
}
|
||||
|
||||
try {
|
||||
const job = new Cron(schedule.cronExpression, { timezone: schedule.timezone }, () => {
|
||||
void this.onCronTick(schedule.id);
|
||||
});
|
||||
|
||||
this.cronJobs.set(schedule.id, job);
|
||||
|
||||
// Set warm-up timer for the next run
|
||||
this.scheduleWarmUp(schedule);
|
||||
|
||||
logger.info(
|
||||
`Cron job created for schedule ${schedule.id}: "${schedule.cronExpression}" ` +
|
||||
`(timezone: ${schedule.timezone}, next: ${job.nextRun()?.toISOString() ?? 'never'})`
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to create cron job for ${schedule.id}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
private removeCronJob(scheduleId: string): void {
|
||||
const job = this.cronJobs.get(scheduleId);
|
||||
if (job) {
|
||||
job.stop();
|
||||
this.cronJobs.delete(scheduleId);
|
||||
}
|
||||
this.clearWarmUpTimer(scheduleId);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Warm-Up Timer
|
||||
// ===========================================================================
|
||||
|
||||
private scheduleWarmUp(schedule: Schedule): void {
|
||||
this.clearWarmUpTimer(schedule.id);
|
||||
|
||||
if (schedule.warmUpMinutes <= 0) return;
|
||||
|
||||
const job = this.cronJobs.get(schedule.id);
|
||||
if (!job) return;
|
||||
|
||||
const msToNext = job.msToNext();
|
||||
if (msToNext == null) return;
|
||||
|
||||
const warmUpMs = schedule.warmUpMinutes * 60_000;
|
||||
const warmUpDelayMs = Math.max(0, msToNext - warmUpMs);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.warmUpTimers.delete(schedule.id);
|
||||
void this.performWarmUp(schedule);
|
||||
}, warmUpDelayMs);
|
||||
|
||||
// Don't block Electron quit
|
||||
timer.unref();
|
||||
this.warmUpTimers.set(schedule.id, timer);
|
||||
|
||||
logger.debug(
|
||||
`Warm-up scheduled for ${schedule.id}: in ${Math.round(warmUpDelayMs / 1000)}s ` +
|
||||
`(${schedule.warmUpMinutes}min before next run)`
|
||||
);
|
||||
}
|
||||
|
||||
private clearWarmUpTimer(scheduleId: string): void {
|
||||
const timer = this.warmUpTimers.get(scheduleId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.warmUpTimers.delete(scheduleId);
|
||||
}
|
||||
}
|
||||
|
||||
private async performWarmUp(schedule: Schedule, retryCount = 0): Promise<void> {
|
||||
logger.info(
|
||||
`[${schedule.id}] Starting warm-up (attempt ${retryCount + 1}/${WARMUP_MAX_RETRIES})`
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await this.warmUpFn(schedule.launchConfig.cwd);
|
||||
|
||||
if (result.ready) {
|
||||
logger.info(`[${schedule.id}] Warm-up successful: ${result.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`[${schedule.id}] Warm-up not ready: ${result.message}`);
|
||||
} catch (err) {
|
||||
logger.warn(`[${schedule.id}] Warm-up error: ${err}`);
|
||||
}
|
||||
|
||||
// Retry
|
||||
if (retryCount < WARMUP_MAX_RETRIES - 1) {
|
||||
const retryTimer = setTimeout(() => {
|
||||
this.warmUpTimers.delete(schedule.id);
|
||||
void this.performWarmUp(schedule, retryCount + 1);
|
||||
}, WARMUP_RETRY_DELAY_MS);
|
||||
retryTimer.unref();
|
||||
// Store in warmUpTimers so clearWarmUpTimer/stop() can cancel it
|
||||
this.warmUpTimers.set(schedule.id, retryTimer);
|
||||
} else {
|
||||
logger.warn(`[${schedule.id}] Warm-up failed after ${WARMUP_MAX_RETRIES} attempts`);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Cron Tick → Execution
|
||||
// ===========================================================================
|
||||
|
||||
private async onCronTick(scheduleId: string): Promise<void> {
|
||||
const schedule = await this.repository.getSchedule(scheduleId);
|
||||
if (!schedule || schedule.status !== 'active') {
|
||||
logger.debug(`Cron tick for ${scheduleId} skipped (not active)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check schedule-level lock
|
||||
if (this.activeRuns.has(scheduleId)) {
|
||||
logger.warn(`[${scheduleId}] Cron tick skipped: previous run still active`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cwd lock
|
||||
const cwd = schedule.launchConfig.cwd;
|
||||
const cwdHolder = this.cwdLock.get(cwd);
|
||||
if (cwdHolder && cwdHolder !== scheduleId) {
|
||||
logger.warn(
|
||||
`[${scheduleId}] Cron tick skipped: cwd "${cwd}" locked by schedule ${cwdHolder}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const run: ScheduleRun = {
|
||||
id: randomUUID(),
|
||||
scheduleId,
|
||||
teamName: schedule.teamName,
|
||||
status: 'running',
|
||||
scheduledFor: now,
|
||||
startedAt: now,
|
||||
executionStartedAt: now,
|
||||
retryCount: 0,
|
||||
};
|
||||
|
||||
await this.repository.saveRun(run);
|
||||
this.emitChange({ type: 'run-started', scheduleId, teamName: schedule.teamName });
|
||||
|
||||
void this.executeRunInBackground(schedule, run);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Execution
|
||||
// ===========================================================================
|
||||
|
||||
private async executeRunInBackground(schedule: Schedule, run: ScheduleRun): Promise<void> {
|
||||
const { id: scheduleId, launchConfig } = schedule;
|
||||
|
||||
// Acquire locks
|
||||
this.activeRuns.set(scheduleId, run);
|
||||
this.cwdLock.set(launchConfig.cwd, scheduleId);
|
||||
|
||||
let retriedInternally = false;
|
||||
|
||||
try {
|
||||
const result = await this.executor.execute({
|
||||
runId: run.id,
|
||||
config: launchConfig,
|
||||
maxTurns: schedule.maxTurns,
|
||||
maxBudgetUsd: schedule.maxBudgetUsd,
|
||||
});
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
// Success — save logs, complete run
|
||||
await this.repository.saveRunLogs(scheduleId, run.id, result.stdout, result.stderr);
|
||||
await this.completeRun(run, 'completed', result.exitCode, result.summary);
|
||||
await this.resetConsecutiveFailures(schedule);
|
||||
logger.info(
|
||||
`[${scheduleId}] Run ${run.id} completed successfully (${result.durationMs}ms)`
|
||||
);
|
||||
} else {
|
||||
// Failure — save logs before handling
|
||||
await this.repository.saveRunLogs(scheduleId, run.id, result.stdout, result.stderr);
|
||||
const errorMsg = result.stderr.slice(0, 500) || `Exit code: ${result.exitCode}`;
|
||||
retriedInternally = await this.handleRunFailure(schedule, run, result.exitCode, errorMsg);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
retriedInternally = await this.handleRunFailure(schedule, run, null, errorMsg);
|
||||
} finally {
|
||||
// Skip cleanup if retry took over — retry's own finally will handle it
|
||||
if (retriedInternally) return;
|
||||
|
||||
// Release locks only if this run still owns them (prevents double-release race)
|
||||
const currentActive = this.activeRuns.get(scheduleId);
|
||||
if (currentActive?.id === run.id) {
|
||||
this.releaseRunLocks(scheduleId);
|
||||
}
|
||||
|
||||
// Update schedule's lastRunAt and nextRunAt
|
||||
await this.updateScheduleTimestamps(schedule);
|
||||
|
||||
// Schedule next warm-up
|
||||
const freshSchedule = await this.repository.getSchedule(scheduleId);
|
||||
if (freshSchedule?.status === 'active') {
|
||||
this.scheduleWarmUp(freshSchedule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a failed run. Returns `true` if a retry was dispatched
|
||||
* (meaning the caller's finally block should skip cleanup).
|
||||
*/
|
||||
private async handleRunFailure(
|
||||
schedule: Schedule,
|
||||
run: ScheduleRun,
|
||||
exitCode: number | null,
|
||||
error: string
|
||||
): Promise<boolean> {
|
||||
logger.warn(`[${schedule.id}] Run ${run.id} failed: ${error}`);
|
||||
|
||||
// Retry logic
|
||||
if (run.retryCount < EXECUTION_MAX_RETRIES) {
|
||||
logger.info(
|
||||
`[${schedule.id}] Scheduling retry ${run.retryCount + 1}/${EXECUTION_MAX_RETRIES}`
|
||||
);
|
||||
|
||||
const retryRun: ScheduleRun = {
|
||||
...run,
|
||||
status: 'pending',
|
||||
retryCount: run.retryCount + 1,
|
||||
error,
|
||||
};
|
||||
await this.repository.saveRun(retryRun);
|
||||
|
||||
// Release locks before retry delay
|
||||
this.releaseRunLocks(schedule.id);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, EXECUTION_RETRY_DELAY_MS);
|
||||
timer.unref();
|
||||
this.retryDelayTimers.set(schedule.id, timer);
|
||||
});
|
||||
this.retryDelayTimers.delete(schedule.id);
|
||||
|
||||
// Bail if service was stopped during delay
|
||||
if (this.stopped) {
|
||||
await this.completeRun(retryRun, 'failed', exitCode, undefined, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-acquire locks and retry
|
||||
if (this.activeRuns.has(schedule.id)) {
|
||||
// Something else started — skip retry
|
||||
await this.completeRun(retryRun, 'failed', exitCode, undefined, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const freshSchedule = await this.repository.getSchedule(schedule.id);
|
||||
if (!freshSchedule || freshSchedule.status !== 'active') {
|
||||
await this.completeRun(retryRun, 'failed', exitCode, undefined, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Dispatch retry — caller's finally must not run cleanup
|
||||
void this.executeRunInBackground(freshSchedule, retryRun);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Max retries exhausted
|
||||
await this.completeRun(run, 'failed', exitCode, undefined, error);
|
||||
await this.incrementConsecutiveFailures(schedule);
|
||||
return false;
|
||||
}
|
||||
|
||||
private async completeRun(
|
||||
run: ScheduleRun,
|
||||
status: ScheduleRunStatus,
|
||||
exitCode: number | null,
|
||||
summary?: string,
|
||||
error?: string
|
||||
): Promise<void> {
|
||||
const completedAt = new Date().toISOString();
|
||||
const startedAt = new Date(run.startedAt).getTime();
|
||||
const durationMs = Date.now() - startedAt;
|
||||
|
||||
const updatedRun: ScheduleRun = {
|
||||
...run,
|
||||
status,
|
||||
completedAt,
|
||||
durationMs,
|
||||
exitCode,
|
||||
summary: summary ?? run.summary,
|
||||
error: error ?? run.error,
|
||||
};
|
||||
|
||||
await this.repository.saveRun(updatedRun);
|
||||
|
||||
const eventType = status === 'completed' ? 'run-completed' : 'run-failed';
|
||||
this.emitChange({
|
||||
type: eventType,
|
||||
scheduleId: run.scheduleId,
|
||||
teamName: run.teamName,
|
||||
detail: error,
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Consecutive Failure Tracking
|
||||
// ===========================================================================
|
||||
|
||||
private async resetConsecutiveFailures(schedule: Schedule): Promise<void> {
|
||||
if (schedule.consecutiveFailures === 0) return;
|
||||
|
||||
const updated: Schedule = {
|
||||
...schedule,
|
||||
consecutiveFailures: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.repository.saveSchedule(updated);
|
||||
}
|
||||
|
||||
private async incrementConsecutiveFailures(schedule: Schedule): Promise<void> {
|
||||
const newCount = schedule.consecutiveFailures + 1;
|
||||
const shouldAutoPause = newCount >= schedule.maxConsecutiveFailures;
|
||||
|
||||
const updated: Schedule = {
|
||||
...schedule,
|
||||
consecutiveFailures: newCount,
|
||||
status: shouldAutoPause ? 'paused' : schedule.status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (shouldAutoPause) {
|
||||
logger.warn(`[${schedule.id}] Auto-pausing after ${newCount} consecutive failures`);
|
||||
const job = this.cronJobs.get(schedule.id);
|
||||
if (job) job.pause();
|
||||
this.clearWarmUpTimer(schedule.id);
|
||||
}
|
||||
|
||||
await this.repository.saveSchedule(updated);
|
||||
|
||||
if (shouldAutoPause) {
|
||||
this.emitChange({
|
||||
type: 'schedule-paused',
|
||||
scheduleId: schedule.id,
|
||||
teamName: schedule.teamName,
|
||||
detail: `auto-paused after ${newCount} consecutive failures`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Schedule Timestamp Updates
|
||||
// ===========================================================================
|
||||
|
||||
private async updateScheduleTimestamps(schedule: Schedule): Promise<void> {
|
||||
// Reload fresh from repo to avoid overwriting changes from incrementConsecutiveFailures
|
||||
const fresh = await this.repository.getSchedule(schedule.id);
|
||||
if (!fresh) return;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const nextRunAt = this.computeNextRunAt(fresh);
|
||||
|
||||
const updated: Schedule = {
|
||||
...fresh,
|
||||
lastRunAt: now,
|
||||
nextRunAt: nextRunAt ?? undefined,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await this.repository.saveSchedule(updated);
|
||||
this.emitChange({
|
||||
type: 'schedule-updated',
|
||||
scheduleId: fresh.id,
|
||||
teamName: fresh.teamName,
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Recovery
|
||||
// ===========================================================================
|
||||
|
||||
private async recoverInterruptedRuns(): Promise<void> {
|
||||
const schedules = await this.repository.listSchedules();
|
||||
let recoveredCount = 0;
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const runs = await this.repository.listRuns(schedule.id, { limit: 5 });
|
||||
|
||||
for (const run of runs) {
|
||||
if (
|
||||
run.status === 'warming_up' ||
|
||||
run.status === 'warm' ||
|
||||
run.status === 'running' ||
|
||||
run.status === 'pending'
|
||||
) {
|
||||
const updated: ScheduleRun = {
|
||||
...run,
|
||||
status: 'failed_interrupted',
|
||||
completedAt: new Date().toISOString(),
|
||||
error: 'Interrupted by app restart',
|
||||
};
|
||||
await this.repository.saveRun(updated);
|
||||
recoveredCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (recoveredCount > 0) {
|
||||
logger.info(`Recovered ${recoveredCount} interrupted runs as failed_interrupted`);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Helpers
|
||||
// ===========================================================================
|
||||
|
||||
private computeNextRunAt(schedule: Schedule): string | null {
|
||||
try {
|
||||
const job = new Cron(schedule.cronExpression, {
|
||||
timezone: schedule.timezone,
|
||||
paused: true,
|
||||
});
|
||||
const next = job.nextRun();
|
||||
job.stop();
|
||||
return next?.toISOString() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private releaseRunLocks(scheduleId: string): void {
|
||||
this.activeRuns.delete(scheduleId);
|
||||
|
||||
// Release cwd lock for this schedule
|
||||
for (const [cwd, holder] of this.cwdLock) {
|
||||
if (holder === scheduleId) {
|
||||
this.cwdLock.delete(cwd);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emitChange(event: ScheduleChangeEvent): void {
|
||||
this.changeEmitter?.(event);
|
||||
}
|
||||
}
|
||||
14
src/main/services/schedule/index.ts
Normal file
14
src/main/services/schedule/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Schedule services barrel export.
|
||||
*/
|
||||
|
||||
export { JsonScheduleRepository } from './JsonScheduleRepository';
|
||||
export type { ScheduleRepository } from './ScheduleRepository';
|
||||
export { ScheduledTaskExecutor } from './ScheduledTaskExecutor';
|
||||
export type {
|
||||
ExecutionRequest,
|
||||
InternalScheduleRun,
|
||||
ScheduledTaskResult,
|
||||
} from './ScheduledTaskExecutor';
|
||||
export { SchedulerService } from './SchedulerService';
|
||||
export type { WarmUpFn } from './SchedulerService';
|
||||
59
src/main/services/team/CascadeGuard.ts
Normal file
59
src/main/services/team/CascadeGuard.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
const MAX_PER_MINUTE = 10;
|
||||
const PAIR_COOLDOWN_MS = 3_000;
|
||||
const MAX_CHAIN_DEPTH = 5;
|
||||
const WINDOW_MS = 60_000;
|
||||
|
||||
export class CascadeGuard {
|
||||
private teamCounters = new Map<string, number[]>();
|
||||
private pairTimestamps = new Map<string, number>();
|
||||
|
||||
check(fromTeam: string, toTeam: string, chainDepth: number): void {
|
||||
if (chainDepth >= MAX_CHAIN_DEPTH) {
|
||||
throw new Error(`Cross-team chain depth limit exceeded (max ${MAX_CHAIN_DEPTH})`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
this.cleanup(now);
|
||||
|
||||
const counts = this.teamCounters.get(fromTeam) ?? [];
|
||||
const recentCount = counts.filter((ts) => ts > now - WINDOW_MS).length;
|
||||
if (recentCount >= MAX_PER_MINUTE) {
|
||||
throw new Error(`Cross-team rate limit exceeded for ${fromTeam} (max ${MAX_PER_MINUTE}/min)`);
|
||||
}
|
||||
|
||||
const pairKey = `${fromTeam}→${toTeam}`;
|
||||
const lastPairTs = this.pairTimestamps.get(pairKey);
|
||||
if (lastPairTs !== undefined && now - lastPairTs < PAIR_COOLDOWN_MS) {
|
||||
throw new Error(`Cross-team pair cooldown active: ${fromTeam} → ${toTeam}`);
|
||||
}
|
||||
}
|
||||
|
||||
record(fromTeam: string, toTeam: string): void {
|
||||
const now = Date.now();
|
||||
const counts = this.teamCounters.get(fromTeam) ?? [];
|
||||
counts.push(now);
|
||||
this.teamCounters.set(fromTeam, counts);
|
||||
this.pairTimestamps.set(`${fromTeam}→${toTeam}`, now);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.teamCounters.clear();
|
||||
this.pairTimestamps.clear();
|
||||
}
|
||||
|
||||
private cleanup(now: number): void {
|
||||
for (const [team, timestamps] of this.teamCounters) {
|
||||
const fresh = timestamps.filter((ts) => ts > now - WINDOW_MS);
|
||||
if (fresh.length === 0) {
|
||||
this.teamCounters.delete(team);
|
||||
} else {
|
||||
this.teamCounters.set(team, fresh);
|
||||
}
|
||||
}
|
||||
for (const [key, ts] of this.pairTimestamps) {
|
||||
if (now - ts > WINDOW_MS) {
|
||||
this.pairTimestamps.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,17 @@ interface CacheEntry {
|
|||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface TaskChangeCacheEntry {
|
||||
data: TaskChangeSetV2;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface ParsedSnippetsCacheEntry {
|
||||
data: SnippetDiff[];
|
||||
mtime: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/** Ссылка на JSONL файл с привязкой к memberName */
|
||||
interface LogFileRef {
|
||||
filePath: string;
|
||||
|
|
@ -39,7 +50,11 @@ interface LogFileRef {
|
|||
|
||||
export class ChangeExtractorService {
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
private taskChangeCache = new Map<string, TaskChangeCacheEntry>();
|
||||
private parsedSnippetsCache = new Map<string, ParsedSnippetsCacheEntry>();
|
||||
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
|
||||
private readonly taskChangeCacheTtl = 20 * 1000; // 20 сек для task changes
|
||||
private readonly parsedSnippetsCacheTtl = 20 * 1000; // 20 сек для parsed JSONL snippets
|
||||
|
||||
constructor(
|
||||
private readonly logsFinder: TeamMemberLogsFinder,
|
||||
|
|
@ -113,8 +128,16 @@ export class ChangeExtractorService {
|
|||
status?: string;
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
since?: string;
|
||||
summaryOnly?: boolean;
|
||||
}
|
||||
): Promise<TaskChangeSetV2> {
|
||||
const includeDetails = options?.summaryOnly !== true;
|
||||
const cacheKey = `task:${teamName}:${taskId}`;
|
||||
const cached = includeDetails ? this.taskChangeCache.get(cacheKey) : undefined;
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const taskMeta = await this.readTaskMeta(teamName, taskId);
|
||||
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, {
|
||||
owner: options?.owner ?? taskMeta?.owner,
|
||||
|
|
@ -124,7 +147,14 @@ export class ChangeExtractorService {
|
|||
});
|
||||
const logRefs = await this.resolveLogFileRefs(teamName, logs);
|
||||
if (logRefs.length === 0) {
|
||||
return this.emptyTaskChangeSet(teamName, taskId);
|
||||
const empty = this.emptyTaskChangeSet(teamName, taskId);
|
||||
if (includeDetails) {
|
||||
this.taskChangeCache.set(cacheKey, {
|
||||
data: empty,
|
||||
expiresAt: Date.now() + this.taskChangeCacheTtl,
|
||||
});
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
const projectPath = await this.resolveProjectPath(teamName);
|
||||
|
|
@ -144,7 +174,7 @@ export class ChangeExtractorService {
|
|||
const intervals = options?.intervals ?? taskMeta?.intervals;
|
||||
if (Array.isArray(intervals) && intervals.length > 0) {
|
||||
const { files, toolUseIds, startTimestamp, endTimestamp } =
|
||||
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath);
|
||||
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails);
|
||||
|
||||
const intervalScope: TaskChangeScope = {
|
||||
taskId,
|
||||
|
|
@ -162,7 +192,7 @@ export class ChangeExtractorService {
|
|||
},
|
||||
};
|
||||
|
||||
return {
|
||||
const intervalResult: TaskChangeSetV2 = {
|
||||
teamName,
|
||||
taskId,
|
||||
files,
|
||||
|
|
@ -177,14 +207,39 @@ export class ChangeExtractorService {
|
|||
? ['No file edits found within persisted workIntervals.']
|
||||
: ['Task boundaries missing — scoped by workIntervals timestamps.'],
|
||||
};
|
||||
if (includeDetails) {
|
||||
this.taskChangeCache.set(cacheKey, {
|
||||
data: intervalResult,
|
||||
expiresAt: Date.now() + this.taskChangeCacheTtl,
|
||||
});
|
||||
}
|
||||
return intervalResult;
|
||||
}
|
||||
|
||||
return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath);
|
||||
const fallbackResult = await this.fallbackSingleTaskScope(
|
||||
teamName,
|
||||
taskId,
|
||||
logRefs,
|
||||
projectPath,
|
||||
includeDetails
|
||||
);
|
||||
if (includeDetails) {
|
||||
this.taskChangeCache.set(cacheKey, {
|
||||
data: fallbackResult,
|
||||
expiresAt: Date.now() + this.taskChangeCacheTtl,
|
||||
});
|
||||
}
|
||||
return fallbackResult;
|
||||
}
|
||||
|
||||
// Фильтруем snippets по tool_use IDs из scope
|
||||
const allowedToolUseIds = new Set(allScopes.flatMap((s) => s.toolUseIds));
|
||||
const files = await this.extractFilteredChanges(logRefs, allowedToolUseIds, projectPath);
|
||||
const files = await this.extractFilteredChanges(
|
||||
logRefs,
|
||||
allowedToolUseIds,
|
||||
projectPath,
|
||||
includeDetails
|
||||
);
|
||||
|
||||
const worstTier = Math.max(...allScopes.map((s) => s.confidence.tier));
|
||||
const warnings: string[] = [];
|
||||
|
|
@ -192,7 +247,7 @@ export class ChangeExtractorService {
|
|||
warnings.push('Some task boundaries could not be precisely determined.');
|
||||
}
|
||||
|
||||
return {
|
||||
const result: TaskChangeSetV2 = {
|
||||
teamName,
|
||||
taskId,
|
||||
files,
|
||||
|
|
@ -204,6 +259,13 @@ export class ChangeExtractorService {
|
|||
scope: allScopes[0],
|
||||
warnings,
|
||||
};
|
||||
if (includeDetails) {
|
||||
this.taskChangeCache.set(cacheKey, {
|
||||
data: result,
|
||||
expiresAt: Date.now() + this.taskChangeCacheTtl,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Получить краткую статистику */
|
||||
|
|
@ -244,12 +306,13 @@ export class ChangeExtractorService {
|
|||
|
||||
const derivedIntervals = (() => {
|
||||
if (Array.isArray(intervals) && intervals.length > 0) return intervals;
|
||||
const rawHistory = parsed.statusHistory;
|
||||
const rawHistory = parsed.historyEvents;
|
||||
if (!Array.isArray(rawHistory)) return undefined;
|
||||
|
||||
const transitions = rawHistory
|
||||
.map((h) => (h && typeof h === 'object' ? (h as Record<string, unknown>) : null))
|
||||
.filter((h): h is Record<string, unknown> => h !== null)
|
||||
.filter((h) => h.type === 'status_changed')
|
||||
.map((h) => ({
|
||||
to: typeof h.to === 'string' ? h.to : null,
|
||||
timestamp: typeof h.timestamp === 'string' ? h.timestamp : null,
|
||||
|
|
@ -300,7 +363,8 @@ export class ChangeExtractorService {
|
|||
private async extractIntervalScopedChanges(
|
||||
logRefs: LogFileRef[],
|
||||
intervals: { startedAt: string; completedAt?: string }[],
|
||||
projectPath?: string
|
||||
projectPath?: string,
|
||||
includeDetails = true
|
||||
): Promise<{
|
||||
files: FileChangeSummary[];
|
||||
toolUseIds: string[];
|
||||
|
|
@ -356,7 +420,7 @@ export class ChangeExtractorService {
|
|||
}
|
||||
}
|
||||
|
||||
const files = this.aggregateByFile(allowedSnippets, projectPath);
|
||||
const files = this.aggregateByFile(allowedSnippets, projectPath, includeDetails);
|
||||
return {
|
||||
files,
|
||||
toolUseIds: [...toolUseIdsSet],
|
||||
|
|
@ -387,6 +451,19 @@ export class ChangeExtractorService {
|
|||
|
||||
/** Парсить один JSONL файл и извлечь все snippets (двухпроходный подход) */
|
||||
private async parseJSONLFile(filePath: string): Promise<SnippetDiff[]> {
|
||||
let fileMtime = 0;
|
||||
try {
|
||||
const fileStat = await stat(filePath);
|
||||
fileMtime = fileStat.mtimeMs;
|
||||
const cached = this.parsedSnippetsCache.get(filePath);
|
||||
if (cached?.mtime === fileMtime && cached.expiresAt > Date.now()) {
|
||||
return cached.data;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(`Не удалось stat файла ${filePath}: ${String(err)}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Сначала считываем все записи в память для двух проходов
|
||||
const entries: Record<string, unknown>[] = [];
|
||||
|
||||
|
|
@ -519,6 +596,12 @@ export class ChangeExtractorService {
|
|||
}
|
||||
}
|
||||
|
||||
this.parsedSnippetsCache.set(filePath, {
|
||||
data: snippets,
|
||||
mtime: fileMtime,
|
||||
expiresAt: Date.now() + this.parsedSnippetsCacheTtl,
|
||||
});
|
||||
|
||||
return snippets;
|
||||
}
|
||||
|
||||
|
|
@ -580,7 +663,11 @@ export class ChangeExtractorService {
|
|||
}
|
||||
|
||||
/** Агрегировать snippets в FileChangeSummary[] */
|
||||
private aggregateByFile(snippets: SnippetDiff[], projectPath?: string): FileChangeSummary[] {
|
||||
private aggregateByFile(
|
||||
snippets: SnippetDiff[],
|
||||
projectPath?: string,
|
||||
includeDetails = true
|
||||
): FileChangeSummary[] {
|
||||
const fileMap = new Map<string, { snippets: SnippetDiff[]; isNewFile: boolean }>();
|
||||
|
||||
for (const snippet of snippets) {
|
||||
|
|
@ -620,11 +707,11 @@ export class ChangeExtractorService {
|
|||
return {
|
||||
filePath: fp,
|
||||
relativePath: relative,
|
||||
snippets: data.snippets,
|
||||
snippets: includeDetails ? data.snippets : [],
|
||||
linesAdded: totalAdded,
|
||||
linesRemoved: totalRemoved,
|
||||
isNewFile: data.isNewFile,
|
||||
timeline: this.buildTimeline(fp, data.snippets),
|
||||
timeline: includeDetails ? this.buildTimeline(fp, data.snippets) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -683,14 +770,27 @@ export class ChangeExtractorService {
|
|||
return false;
|
||||
}
|
||||
|
||||
/** Конвертировать MemberLogSummary[] в LogFileRef[] через findMemberLogPaths */
|
||||
/** Конвертировать MemberLogSummary[] в LogFileRef[] */
|
||||
private async resolveLogFileRefs(
|
||||
teamName: string,
|
||||
logs: MemberLogSummary[]
|
||||
): Promise<LogFileRef[]> {
|
||||
const refs: LogFileRef[] = [];
|
||||
const byMember = new Map<string, MemberLogSummary[]>();
|
||||
const logsNeedingResolve: MemberLogSummary[] = [];
|
||||
|
||||
for (const log of logs) {
|
||||
const memberName = log.memberName ?? 'unknown';
|
||||
if (log.filePath) {
|
||||
refs.push({ filePath: log.filePath, memberName });
|
||||
} else {
|
||||
logsNeedingResolve.push(log);
|
||||
}
|
||||
}
|
||||
|
||||
if (logsNeedingResolve.length === 0) return refs;
|
||||
|
||||
const byMember = new Map<string, MemberLogSummary[]>();
|
||||
for (const log of logsNeedingResolve) {
|
||||
const name = log.memberName ?? 'unknown';
|
||||
if (!byMember.has(name)) byMember.set(name, []);
|
||||
byMember.get(name)!.push(log);
|
||||
|
|
@ -715,7 +815,8 @@ export class ChangeExtractorService {
|
|||
private async extractFilteredChanges(
|
||||
logRefs: LogFileRef[],
|
||||
allowedToolUseIds: Set<string>,
|
||||
projectPath?: string
|
||||
projectPath?: string,
|
||||
includeDetails = true
|
||||
): Promise<FileChangeSummary[]> {
|
||||
const allSnippets: SnippetDiff[] = [];
|
||||
for (const ref of logRefs) {
|
||||
|
|
@ -731,17 +832,18 @@ export class ChangeExtractorService {
|
|||
allSnippets.push(...snippets);
|
||||
}
|
||||
}
|
||||
return this.aggregateByFile(allSnippets, projectPath);
|
||||
return this.aggregateByFile(allSnippets, projectPath, includeDetails);
|
||||
}
|
||||
|
||||
/** Извлечь все изменения из одного файла */
|
||||
private async extractAllChanges(
|
||||
filePath: string,
|
||||
_memberName: string,
|
||||
projectPath?: string
|
||||
projectPath?: string,
|
||||
includeDetails = true
|
||||
): Promise<FileChangeSummary[]> {
|
||||
const snippets = await this.parseJSONLFile(filePath);
|
||||
return this.aggregateByFile(snippets, projectPath);
|
||||
return this.aggregateByFile(snippets, projectPath, includeDetails);
|
||||
}
|
||||
|
||||
/** Fallback: вернуть все изменения из лог-файлов как Tier 4 */
|
||||
|
|
@ -749,11 +851,17 @@ export class ChangeExtractorService {
|
|||
teamName: string,
|
||||
taskId: string,
|
||||
logRefs: LogFileRef[],
|
||||
projectPath?: string
|
||||
projectPath?: string,
|
||||
includeDetails = true
|
||||
): Promise<TaskChangeSetV2> {
|
||||
const allFiles: FileChangeSummary[] = [];
|
||||
for (const ref of logRefs) {
|
||||
const files = await this.extractAllChanges(ref.filePath, ref.memberName, projectPath);
|
||||
const files = await this.extractAllChanges(
|
||||
ref.filePath,
|
||||
ref.memberName,
|
||||
projectPath,
|
||||
includeDetails
|
||||
);
|
||||
allFiles.push(...files);
|
||||
}
|
||||
|
||||
|
|
|
|||
106
src/main/services/team/CrossTeamOutbox.ts
Normal file
106
src/main/services/team/CrossTeamOutbox.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { withFileLock } from './fileLock';
|
||||
|
||||
import type { CrossTeamMessage } from '@shared/types';
|
||||
|
||||
const CROSS_TEAM_DEDUPE_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
function normalizeForDedupe(value: string | undefined): string {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function buildCrossTeamDedupeKey(message: CrossTeamMessage): string {
|
||||
return [
|
||||
normalizeForDedupe(message.fromTeam),
|
||||
normalizeForDedupe(message.fromMember),
|
||||
normalizeForDedupe(message.toTeam),
|
||||
normalizeForDedupe(message.summary),
|
||||
normalizeForDedupe(message.text),
|
||||
].join('||');
|
||||
}
|
||||
|
||||
function findRecentDuplicate(
|
||||
list: CrossTeamMessage[],
|
||||
message: CrossTeamMessage,
|
||||
windowMs: number
|
||||
): CrossTeamMessage | null {
|
||||
const dedupeKey = buildCrossTeamDedupeKey(message);
|
||||
const cutoff = Date.now() - windowMs;
|
||||
|
||||
for (let i = list.length - 1; i >= 0; i -= 1) {
|
||||
const entry = list[i];
|
||||
const ts = Date.parse(entry.timestamp);
|
||||
if (!Number.isFinite(ts) || ts < cutoff) {
|
||||
break;
|
||||
}
|
||||
if (buildCrossTeamDedupeKey(entry) === dedupeKey) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export class CrossTeamOutbox {
|
||||
private getOutboxPath(teamName: string): string {
|
||||
return path.join(getTeamsBasePath(), teamName, 'sent-cross-team.json');
|
||||
}
|
||||
|
||||
private async readUnlocked(outboxPath: string): Promise<CrossTeamMessage[]> {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(outboxPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return Array.isArray(parsed) ? (parsed as CrossTeamMessage[]) : [];
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async append(teamName: string, message: CrossTeamMessage): Promise<void> {
|
||||
const outboxPath = this.getOutboxPath(teamName);
|
||||
await withFileLock(outboxPath, async () => {
|
||||
const list = await this.readUnlocked(outboxPath);
|
||||
list.push(message);
|
||||
const dir = path.dirname(outboxPath);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(outboxPath, JSON.stringify(list, null, 2), 'utf8');
|
||||
});
|
||||
}
|
||||
|
||||
async appendIfNotRecent(
|
||||
teamName: string,
|
||||
message: CrossTeamMessage,
|
||||
onBeforeAppend: () => Promise<void>,
|
||||
windowMs = CROSS_TEAM_DEDUPE_WINDOW_MS
|
||||
): Promise<{ duplicate: CrossTeamMessage | null }> {
|
||||
const outboxPath = this.getOutboxPath(teamName);
|
||||
let duplicate: CrossTeamMessage | null = null;
|
||||
|
||||
await withFileLock(outboxPath, async () => {
|
||||
const list = await this.readUnlocked(outboxPath);
|
||||
duplicate = findRecentDuplicate(list, message, windowMs);
|
||||
if (duplicate) return;
|
||||
|
||||
await onBeforeAppend();
|
||||
|
||||
list.push(message);
|
||||
const dir = path.dirname(outboxPath);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(outboxPath, JSON.stringify(list, null, 2), 'utf8');
|
||||
});
|
||||
|
||||
return { duplicate };
|
||||
}
|
||||
|
||||
async read(teamName: string): Promise<CrossTeamMessage[]> {
|
||||
const outboxPath = this.getOutboxPath(teamName);
|
||||
return this.readUnlocked(outboxPath);
|
||||
}
|
||||
}
|
||||
211
src/main/services/team/CrossTeamService.ts
Normal file
211
src/main/services/team/CrossTeamService.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants';
|
||||
import { getClaudeBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
|
||||
import { buildActionModeAgentBlock } from './actionModeInstructions';
|
||||
import { CascadeGuard } from './CascadeGuard';
|
||||
import { CrossTeamOutbox } from './CrossTeamOutbox';
|
||||
|
||||
import type { TeamConfigReader } from './TeamConfigReader';
|
||||
import type { TeamDataService } from './TeamDataService';
|
||||
import type { TeamInboxWriter } from './TeamInboxWriter';
|
||||
import type { TeamProvisioningService } from './TeamProvisioningService';
|
||||
import type {
|
||||
CrossTeamMessage,
|
||||
CrossTeamSendRequest,
|
||||
CrossTeamSendResult,
|
||||
TeamConfig,
|
||||
} from '@shared/types';
|
||||
|
||||
const logger = createLogger('CrossTeamService');
|
||||
const { createController } = agentTeamsControllerModule;
|
||||
|
||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
|
||||
export interface CrossTeamTarget {
|
||||
teamName: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
leadName?: string;
|
||||
leadColor?: string;
|
||||
}
|
||||
|
||||
export class CrossTeamService {
|
||||
private cascadeGuard = new CascadeGuard();
|
||||
private outbox = new CrossTeamOutbox();
|
||||
|
||||
constructor(
|
||||
private configReader: TeamConfigReader,
|
||||
private dataService: TeamDataService,
|
||||
private inboxWriter: TeamInboxWriter,
|
||||
private provisioning: TeamProvisioningService | null
|
||||
) {}
|
||||
|
||||
async send(request: CrossTeamSendRequest): Promise<CrossTeamSendResult> {
|
||||
const { fromTeam, fromMember, toTeam, text, summary, actionMode } = request;
|
||||
const chainDepth = request.chainDepth ?? 0;
|
||||
const messageId = request.messageId?.trim() || randomUUID();
|
||||
const timestamp = request.timestamp ?? new Date().toISOString();
|
||||
const inferredReplyMeta =
|
||||
!request.conversationId && !request.replyToConversationId
|
||||
? (this.provisioning?.resolveCrossTeamReplyMetadata(fromTeam, toTeam) ?? null)
|
||||
: null;
|
||||
const replyToConversationId =
|
||||
request.replyToConversationId?.trim() ||
|
||||
inferredReplyMeta?.replyToConversationId ||
|
||||
undefined;
|
||||
const conversationId =
|
||||
request.conversationId?.trim() ||
|
||||
inferredReplyMeta?.conversationId ||
|
||||
replyToConversationId ||
|
||||
randomUUID();
|
||||
|
||||
// 1. Validate
|
||||
if (!TEAM_NAME_PATTERN.test(fromTeam)) {
|
||||
throw new Error(`Invalid fromTeam: ${fromTeam}`);
|
||||
}
|
||||
if (!TEAM_NAME_PATTERN.test(toTeam)) {
|
||||
throw new Error(`Invalid toTeam: ${toTeam}`);
|
||||
}
|
||||
if (fromTeam === toTeam) {
|
||||
throw new Error('Cannot send cross-team message to the same team');
|
||||
}
|
||||
if (!fromMember || typeof fromMember !== 'string' || fromMember.trim().length === 0) {
|
||||
throw new Error('fromMember is required');
|
||||
}
|
||||
if (!text || typeof text !== 'string' || text.trim().length === 0) {
|
||||
throw new Error('Message text is required');
|
||||
}
|
||||
|
||||
const targetConfig = await this.configReader.getConfig(toTeam);
|
||||
if (!targetConfig || targetConfig.deletedAt) {
|
||||
throw new Error(`Target team not found: ${toTeam}`);
|
||||
}
|
||||
|
||||
// 2. Resolve lead
|
||||
const leadName = (await this.dataService.getLeadMemberName(toTeam)) ?? 'team-lead';
|
||||
|
||||
// 3. Format
|
||||
const from = `${fromTeam}.${fromMember}`;
|
||||
const actionModeBlock = buildActionModeAgentBlock(actionMode);
|
||||
const deliveryText = actionModeBlock ? `${actionModeBlock}\n\n${text}` : text;
|
||||
const formattedText = formatCrossTeamText(from, chainDepth, deliveryText, {
|
||||
conversationId,
|
||||
replyToConversationId,
|
||||
});
|
||||
const outboxMessage: CrossTeamMessage = {
|
||||
messageId,
|
||||
fromTeam,
|
||||
fromMember,
|
||||
toTeam,
|
||||
conversationId,
|
||||
replyToConversationId,
|
||||
text,
|
||||
summary,
|
||||
chainDepth,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
const { duplicate } = await this.outbox.appendIfNotRecent(fromTeam, outboxMessage, async () => {
|
||||
// 4. Cascade check only for real new deliveries
|
||||
this.cascadeGuard.check(fromTeam, toTeam, chainDepth);
|
||||
this.cascadeGuard.record(fromTeam, toTeam);
|
||||
this.provisioning?.registerPendingCrossTeamReplyExpectation(fromTeam, toTeam, conversationId);
|
||||
|
||||
// 5. Inbox write to TARGET team (TeamInboxWriter handles file lock + in-process lock internally)
|
||||
await this.inboxWriter.sendMessage(toTeam, {
|
||||
member: leadName,
|
||||
text: formattedText,
|
||||
from,
|
||||
timestamp,
|
||||
messageId,
|
||||
summary: summary ?? `Cross-team message from ${fromTeam}`,
|
||||
source: CROSS_TEAM_SOURCE,
|
||||
conversationId,
|
||||
replyToConversationId,
|
||||
});
|
||||
});
|
||||
|
||||
if (duplicate) {
|
||||
return { messageId: duplicate.messageId, deliveredToInbox: true, deduplicated: true };
|
||||
}
|
||||
|
||||
// 6. Write a non-actionable sender copy so the message appears in activity without
|
||||
// waking the local lead through their inbox controller.
|
||||
try {
|
||||
createController({
|
||||
teamName: fromTeam,
|
||||
claudeDir: getClaudeBasePath(),
|
||||
}).messages.appendSentMessage({
|
||||
from: fromMember,
|
||||
to: `${toTeam}.${leadName}`,
|
||||
text,
|
||||
timestamp,
|
||||
messageId,
|
||||
summary: summary ?? `Cross-team message to ${toTeam}`,
|
||||
source: CROSS_TEAM_SENT_SOURCE,
|
||||
conversationId,
|
||||
replyToConversationId,
|
||||
});
|
||||
this.provisioning?.clearPendingCrossTeamReplyExpectation(fromTeam, toTeam, conversationId);
|
||||
} catch (e: unknown) {
|
||||
logger.warn(
|
||||
`Failed to write sender copy for ${fromTeam}: ${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Best-effort relay (if online)
|
||||
if (this.provisioning?.isTeamAlive(toTeam)) {
|
||||
void this.provisioning.relayLeadInboxMessages(toTeam).catch((e: unknown) => {
|
||||
logger.warn(`Cross-team relay to ${toTeam}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
});
|
||||
}
|
||||
|
||||
return { messageId, deliveredToInbox: true };
|
||||
}
|
||||
|
||||
async listAvailableTargets(excludeTeam?: string): Promise<CrossTeamTarget[]> {
|
||||
const teamsDir = getTeamsBasePath();
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.promises.readdir(teamsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targets: CrossTeamTarget[] = [];
|
||||
for (const entry of entries) {
|
||||
if (excludeTeam && entry === excludeTeam) continue;
|
||||
if (!TEAM_NAME_PATTERN.test(entry)) continue;
|
||||
|
||||
let config: TeamConfig | null;
|
||||
try {
|
||||
config = await this.configReader.getConfig(entry);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!config || config.deletedAt) continue;
|
||||
|
||||
const lead = config.members?.find((m) => m.role === 'lead' || m.name === 'team-lead');
|
||||
|
||||
targets.push({
|
||||
teamName: entry,
|
||||
displayName: config.name || entry,
|
||||
description: config.description,
|
||||
color: config.color,
|
||||
leadName: lead?.name,
|
||||
leadColor: lead?.color,
|
||||
});
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
async getOutbox(teamName: string): Promise<CrossTeamMessage[]> {
|
||||
return this.outbox.read(teamName);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ import type {
|
|||
|
||||
const logger = createLogger('Service:TaskBoundaryParser');
|
||||
|
||||
type TaskBoundaryEvent = 'start' | 'complete' | null;
|
||||
|
||||
/** Файл-модифицирующие инструменты, которые включаем в scope.toolUseIds */
|
||||
const FILE_MODIFYING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
||||
|
||||
|
|
@ -29,8 +31,21 @@ interface ToolUseInfo {
|
|||
filePath?: string;
|
||||
}
|
||||
|
||||
/** Regex для teamctl task команд */
|
||||
const TEAMCTL_TASK_REGEX = /task\s+(start|complete|set-status)\s+(\d+)/;
|
||||
const MCP_TASK_BOUNDARY_TOOLS = new Set(['task_start', 'task_complete', 'task_set_status']);
|
||||
|
||||
type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none';
|
||||
|
||||
function pickDetectedMechanism(
|
||||
current: DetectedMechanism,
|
||||
next: Exclude<DetectedMechanism, 'none'>
|
||||
): DetectedMechanism {
|
||||
const priority = {
|
||||
none: 0,
|
||||
TaskUpdate: 1,
|
||||
mcp: 2,
|
||||
} as const;
|
||||
return priority[next] > priority[current] ? next : current;
|
||||
}
|
||||
|
||||
export class TaskBoundaryParser {
|
||||
private cache = new Map<string, BoundaryCacheEntry>();
|
||||
|
|
@ -56,7 +71,7 @@ export class TaskBoundaryParser {
|
|||
const boundaries: TaskBoundary[] = [];
|
||||
const allToolUsesByLine = new Map<number, ToolUseInfo[]>();
|
||||
let lineNumber = 0;
|
||||
let detectedMechanism: 'TaskUpdate' | 'teamctl' | 'none' = 'none';
|
||||
let detectedMechanism: DetectedMechanism = 'none';
|
||||
|
||||
try {
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
|
|
@ -88,19 +103,19 @@ export class TaskBoundaryParser {
|
|||
allToolUsesByLine.get(lineNumber)!.push({ toolUseId, toolName, filePath: fp });
|
||||
}
|
||||
|
||||
// Пробуем TaskUpdate
|
||||
// Prefer structured task markers for modern runtime sessions.
|
||||
const taskUpdateBounds = this.extractTaskUpdateBoundaries(content, lineNumber, timestamp);
|
||||
if (taskUpdateBounds.length > 0) {
|
||||
detectedMechanism = 'TaskUpdate';
|
||||
detectedMechanism = pickDetectedMechanism(detectedMechanism, 'TaskUpdate');
|
||||
boundaries.push(...taskUpdateBounds);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Пробуем teamctl
|
||||
const teamctlBounds = this.extractTeamctlBoundaries(content, lineNumber, timestamp);
|
||||
if (teamctlBounds.length > 0) {
|
||||
detectedMechanism = 'teamctl';
|
||||
boundaries.push(...teamctlBounds);
|
||||
const mcpBounds = this.extractMcpTaskBoundaries(content, lineNumber, timestamp);
|
||||
if (mcpBounds.length > 0) {
|
||||
detectedMechanism = pickDetectedMechanism(detectedMechanism, 'mcp');
|
||||
boundaries.push(...mcpBounds);
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Пропускаем невалидные строки
|
||||
|
|
@ -186,7 +201,7 @@ export class TaskBoundaryParser {
|
|||
if (!taskId) continue;
|
||||
|
||||
const status = typeof input.status === 'string' ? input.status : '';
|
||||
let event: 'start' | 'complete' | null = null;
|
||||
let event: TaskBoundaryEvent = null;
|
||||
if (status === 'in_progress') event = 'start';
|
||||
else if (status === 'completed') event = 'complete';
|
||||
|
||||
|
|
@ -207,10 +222,9 @@ export class TaskBoundaryParser {
|
|||
}
|
||||
|
||||
/**
|
||||
* Найти teamctl task start/complete/set-status команды в Bash tool_use блоках.
|
||||
* Regex: /task\s+(start|complete|set-status)\s+(\d+)/
|
||||
* Find MCP task tools that mark task boundaries.
|
||||
*/
|
||||
private extractTeamctlBoundaries(
|
||||
private extractMcpTaskBoundaries(
|
||||
content: unknown[],
|
||||
lineNumber: number,
|
||||
timestamp: string
|
||||
|
|
@ -224,27 +238,27 @@ export class TaskBoundaryParser {
|
|||
|
||||
const rawName = typeof b.name === 'string' ? b.name : '';
|
||||
const toolName = rawName.replace(/^proxy_/, '');
|
||||
if (toolName !== 'Bash') continue;
|
||||
if (!MCP_TASK_BOUNDARY_TOOLS.has(toolName)) continue;
|
||||
|
||||
const input = b.input as Record<string, unknown> | undefined;
|
||||
if (!input) continue;
|
||||
|
||||
const command = typeof input.command === 'string' ? input.command : '';
|
||||
if (!command.includes('teamctl')) continue;
|
||||
const rawTaskId = input.taskId;
|
||||
const taskId =
|
||||
typeof rawTaskId === 'string'
|
||||
? rawTaskId
|
||||
: typeof rawTaskId === 'number'
|
||||
? String(rawTaskId)
|
||||
: '';
|
||||
if (!taskId) continue;
|
||||
|
||||
const match = TEAMCTL_TASK_REGEX.exec(command);
|
||||
if (!match) continue;
|
||||
|
||||
const action = match[1]; // start | complete | set-status
|
||||
const taskId = match[2];
|
||||
|
||||
let event: 'start' | 'complete' | null = null;
|
||||
if (action === 'start') event = 'start';
|
||||
else if (action === 'complete') event = 'complete';
|
||||
else if (action === 'set-status') {
|
||||
// set-status может быть start или complete — определяем по аргументам
|
||||
if (command.includes('in_progress') || command.includes('in-progress')) event = 'start';
|
||||
else if (command.includes('completed') || command.includes('done')) event = 'complete';
|
||||
let event: TaskBoundaryEvent = null;
|
||||
if (toolName === 'task_start') event = 'start';
|
||||
else if (toolName === 'task_complete') event = 'complete';
|
||||
else {
|
||||
const status = typeof input.status === 'string' ? input.status : '';
|
||||
if (status === 'in_progress') event = 'start';
|
||||
else if (status === 'completed') event = 'complete';
|
||||
}
|
||||
|
||||
if (event) {
|
||||
|
|
@ -254,7 +268,7 @@ export class TaskBoundaryParser {
|
|||
event,
|
||||
lineNumber,
|
||||
timestamp,
|
||||
mechanism: 'teamctl',
|
||||
mechanism: 'mcp',
|
||||
toolUseId,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -86,7 +86,9 @@ export class TeamInboxReader {
|
|||
if (
|
||||
typeof row.from !== 'string' ||
|
||||
typeof row.text !== 'string' ||
|
||||
typeof row.timestamp !== 'string'
|
||||
typeof row.timestamp !== 'string' ||
|
||||
typeof row.messageId !== 'string' ||
|
||||
row.messageId.trim().length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -98,9 +100,27 @@ export class TeamInboxReader {
|
|||
read: typeof row.read === 'boolean' ? row.read : false,
|
||||
summary: typeof row.summary === 'string' ? row.summary : undefined,
|
||||
color: typeof row.color === 'string' ? row.color : undefined,
|
||||
messageId: typeof row.messageId === 'string' ? row.messageId : undefined,
|
||||
messageId: row.messageId,
|
||||
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
||||
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
|
||||
conversationId: typeof row.conversationId === 'string' ? row.conversationId : undefined,
|
||||
replyToConversationId:
|
||||
typeof row.replyToConversationId === 'string' ? row.replyToConversationId : undefined,
|
||||
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
|
||||
toolSummary: typeof row.toolSummary === 'string' ? row.toolSummary : undefined,
|
||||
toolCalls: Array.isArray(row.toolCalls)
|
||||
? (row.toolCalls as unknown[])
|
||||
.filter(
|
||||
(tc): tc is { name: string; preview?: string } =>
|
||||
tc != null &&
|
||||
typeof tc === 'object' &&
|
||||
typeof (tc as Record<string, unknown>).name === 'string'
|
||||
)
|
||||
.map((tc) => ({
|
||||
name: tc.name,
|
||||
preview: typeof tc.preview === 'string' ? tc.preview : undefined,
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import * as fs from 'fs';
|
|||
import * as path from 'path';
|
||||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
import { withFileLock } from './fileLock';
|
||||
import { withInboxLock } from './inboxLock';
|
||||
|
||||
import type { InboxMessage, SendMessageRequest, SendMessageResult } from '@shared/types';
|
||||
|
|
@ -11,7 +12,7 @@ import type { InboxMessage, SendMessageRequest, SendMessageResult } from '@share
|
|||
export class TeamInboxWriter {
|
||||
async sendMessage(teamName: string, request: SendMessageRequest): Promise<SendMessageResult> {
|
||||
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${request.member}.json`);
|
||||
const messageId = randomUUID();
|
||||
const messageId = request.messageId?.trim() || randomUUID();
|
||||
|
||||
const attachmentMeta = request.attachments?.map((a) => ({
|
||||
id: a.id,
|
||||
|
|
@ -22,29 +23,38 @@ export class TeamInboxWriter {
|
|||
|
||||
const payload: InboxMessage = {
|
||||
from: request.from ?? 'user',
|
||||
to: request.member,
|
||||
to: request.to ?? request.member,
|
||||
text: request.text,
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp: request.timestamp ?? new Date().toISOString(),
|
||||
read: false,
|
||||
summary: request.summary,
|
||||
messageId,
|
||||
attachments: attachmentMeta?.length ? attachmentMeta : undefined,
|
||||
...(request.source && { source: request.source }),
|
||||
...(request.leadSessionId && { leadSessionId: request.leadSessionId }),
|
||||
...(request.color && { color: request.color }),
|
||||
...(request.conversationId && { conversationId: request.conversationId }),
|
||||
...(request.replyToConversationId && {
|
||||
replyToConversationId: request.replyToConversationId,
|
||||
}),
|
||||
...(request.toolSummary && { toolSummary: request.toolSummary }),
|
||||
...(request.toolCalls && { toolCalls: request.toolCalls }),
|
||||
};
|
||||
|
||||
await withInboxLock(inboxPath, async () => {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const list = await this.readInbox(inboxPath);
|
||||
list.push(payload);
|
||||
await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2));
|
||||
const written = await this.readInbox(inboxPath);
|
||||
if (written.some((msg) => msg.messageId === messageId)) {
|
||||
return;
|
||||
await withFileLock(inboxPath, async () => {
|
||||
await withInboxLock(inboxPath, async () => {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const list = await this.readInbox(inboxPath);
|
||||
list.push(payload);
|
||||
await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2));
|
||||
const written = await this.readInbox(inboxPath);
|
||||
if (written.some((msg) => msg.messageId === messageId)) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
|
||||
}
|
||||
throw new Error('Failed to verify inbox write');
|
||||
throw new Error('Failed to verify inbox write');
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
121
src/main/services/team/TeamMcpConfigBuilder.ts
Normal file
121
src/main/services/team/TeamMcpConfigBuilder.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { execFile } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
interface McpLaunchSpec {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
const MCP_SERVER_NAME = 'agent-teams';
|
||||
|
||||
function getWorkspaceRoot(): string {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
function getMcpServerDir(): string {
|
||||
return path.join(getWorkspaceRoot(), 'mcp-server');
|
||||
}
|
||||
|
||||
function getBuiltServerEntry(): string {
|
||||
return path.join(getMcpServerDir(), 'dist', 'index.js');
|
||||
}
|
||||
|
||||
function getSourceServerEntry(): string {
|
||||
return path.join(getMcpServerDir(), 'src', 'index.ts');
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(targetPath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let _resolvedNodePath: string | undefined;
|
||||
|
||||
/**
|
||||
* Find the real `node` binary path. In Electron, process.execPath is the
|
||||
* Electron binary — NOT node — so we must resolve node separately.
|
||||
* Uses async execFile('node', ...) which is cross-platform (no /usr/bin/env dependency).
|
||||
*/
|
||||
async function resolveNodePath(): Promise<string> {
|
||||
if (_resolvedNodePath) return _resolvedNodePath;
|
||||
|
||||
try {
|
||||
const resolved = await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
'node',
|
||||
['-e', 'process.stdout.write(process.execPath)'],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
},
|
||||
(err, stdout) => (err ? reject(err) : resolve(stdout.trim()))
|
||||
);
|
||||
});
|
||||
if (resolved) {
|
||||
_resolvedNodePath = resolved;
|
||||
return _resolvedNodePath;
|
||||
}
|
||||
} catch {
|
||||
// node not found or timed out — use bare 'node' and let the OS resolve it
|
||||
}
|
||||
_resolvedNodePath = 'node';
|
||||
return _resolvedNodePath;
|
||||
}
|
||||
|
||||
async function resolveMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
||||
const sourceEntry = getSourceServerEntry();
|
||||
if (await pathExists(sourceEntry)) {
|
||||
// Prefer source in workspace/dev runs so newly added MCP tools are available
|
||||
// immediately and we do not accidentally serve a stale built dist bundle.
|
||||
return {
|
||||
command: 'pnpm',
|
||||
args: ['--dir', getMcpServerDir(), 'exec', 'tsx', sourceEntry],
|
||||
};
|
||||
}
|
||||
|
||||
const builtEntry = getBuiltServerEntry();
|
||||
if (await pathExists(builtEntry)) {
|
||||
return {
|
||||
command: await resolveNodePath(),
|
||||
args: [builtEntry],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('agent-teams-mcp entrypoint not found in mcp-server package');
|
||||
}
|
||||
|
||||
export class TeamMcpConfigBuilder {
|
||||
async writeConfigFile(): Promise<string> {
|
||||
const launchSpec = await resolveMcpLaunchSpec();
|
||||
const configDir = path.join(os.tmpdir(), 'claude-team-mcp');
|
||||
const configPath = path.join(configDir, `agent-teams-mcp-${randomUUID()}.json`);
|
||||
|
||||
await fs.promises.mkdir(configDir, { recursive: true });
|
||||
await atomicWriteAsync(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
[MCP_SERVER_NAME]: {
|
||||
command: launchSpec.command,
|
||||
args: launchSpec.args,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
return configPath;
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,10 @@ const logger = createLogger('Service:TeamMemberLogsFinder');
|
|||
*/
|
||||
const ATTRIBUTION_SCAN_LINES = 50;
|
||||
|
||||
/** Grace before task creation — logs cannot reference a task before it exists. */
|
||||
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
|
||||
const FILE_MENTIONS_CACHE_MAX = 200;
|
||||
|
||||
interface StreamedMetadata {
|
||||
firstTimestamp: string | null;
|
||||
lastTimestamp: string | null;
|
||||
|
|
@ -42,6 +46,8 @@ function trimTrailingSlashes(value: string): string {
|
|||
}
|
||||
|
||||
export class TeamMemberLogsFinder {
|
||||
private readonly fileMentionsCache = new Map<string, boolean>();
|
||||
|
||||
constructor(
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
||||
|
|
@ -120,6 +126,7 @@ export class TeamMemberLogsFinder {
|
|||
const discovery = await this.discoverProjectSessions(teamName);
|
||||
if (!discovery) return [];
|
||||
|
||||
const sinceMs = this.deriveSinceMs(options);
|
||||
const { projectDir, projectId, config, sessionIds, knownMembers } = discovery;
|
||||
const results: MemberLogSummary[] = [];
|
||||
const leadMemberName =
|
||||
|
|
@ -129,7 +136,7 @@ export class TeamMemberLogsFinder {
|
|||
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
|
||||
try {
|
||||
await fs.access(leadJsonl);
|
||||
if (await this.fileMentionsTaskId(leadJsonl, teamName, taskId, true)) {
|
||||
if (await this.fileMentionsTaskIdCached(leadJsonl, teamName, taskId, true, sinceMs)) {
|
||||
const leadSummary = await this.parseLeadSessionSummary(
|
||||
leadJsonl,
|
||||
projectId,
|
||||
|
|
@ -155,7 +162,8 @@ export class TeamMemberLogsFinder {
|
|||
if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue;
|
||||
if (file.startsWith('agent-acompact')) continue;
|
||||
const filePath = path.join(subagentsDir, file);
|
||||
if (!(await this.fileMentionsTaskId(filePath, teamName, taskId))) continue;
|
||||
if (!(await this.fileMentionsTaskIdCached(filePath, teamName, taskId, false, sinceMs)))
|
||||
continue;
|
||||
const attribution = await this.attributeSubagent(filePath, knownMembers);
|
||||
if (!attribution) continue;
|
||||
const summary = await this.parseSubagentSummary(
|
||||
|
|
@ -307,7 +315,10 @@ export class TeamMemberLogsFinder {
|
|||
return paths;
|
||||
}
|
||||
|
||||
/** Быстрая проверка: содержит ли файл TaskUpdate/teamctl маркер для данного taskId */
|
||||
/**
|
||||
* Fast marker probe for task-related logs.
|
||||
* Prefer structured MCP/TaskUpdate markers for modern sessions.
|
||||
*/
|
||||
async hasTaskUpdateMarker(filePath: string, taskId: string): Promise<boolean> {
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
|
@ -322,7 +333,12 @@ export class TeamMemberLogsFinder {
|
|||
stream.destroy();
|
||||
return true;
|
||||
}
|
||||
if (line.includes('teamctl') && line.includes('task') && line.includes(taskId)) {
|
||||
if (
|
||||
(line.includes('"task_start"') ||
|
||||
line.includes('"task_complete"') ||
|
||||
line.includes('"task_set_status"')) &&
|
||||
pattern.test(line)
|
||||
) {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
return true;
|
||||
|
|
@ -403,6 +419,7 @@ export class TeamMemberLogsFinder {
|
|||
}
|
||||
}
|
||||
|
||||
const discoveredSessionIds = await this.listSessionDirs(projectDir);
|
||||
let sessionIds: string[];
|
||||
if (knownSessionIds.size > 0) {
|
||||
const verified: string[] = [];
|
||||
|
|
@ -415,9 +432,11 @@ export class TeamMemberLogsFinder {
|
|||
// dir doesn't exist
|
||||
}
|
||||
}
|
||||
sessionIds = verified.length > 0 ? verified : await this.listSessionDirs(projectDir);
|
||||
// Prefer config-backed sessions first, but also include any live session dirs that have
|
||||
// appeared on disk and are not yet reflected in config/sessionHistory.
|
||||
sessionIds = Array.from(new Set([...verified, ...discoveredSessionIds]));
|
||||
} else {
|
||||
sessionIds = await this.listSessionDirs(projectDir);
|
||||
sessionIds = discoveredSessionIds;
|
||||
}
|
||||
|
||||
const knownMembers = new Set<string>(
|
||||
|
|
@ -467,6 +486,59 @@ export class TeamMemberLogsFinder {
|
|||
return { ...discovery, isLeadMember };
|
||||
}
|
||||
|
||||
private deriveSinceMs(options?: {
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
since?: string;
|
||||
}): number | null {
|
||||
const sinceRaw = typeof options?.since === 'string' ? options.since : null;
|
||||
if (sinceRaw) {
|
||||
const ms = Date.parse(sinceRaw);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
const intervals = options?.intervals;
|
||||
if (!Array.isArray(intervals) || intervals.length === 0) return null;
|
||||
let earliest = Number.POSITIVE_INFINITY;
|
||||
for (const i of intervals) {
|
||||
if (typeof i.startedAt === 'string') {
|
||||
const ms = Date.parse(i.startedAt);
|
||||
if (Number.isFinite(ms) && ms < earliest) earliest = ms;
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(earliest) || earliest === Number.POSITIVE_INFINITY) return null;
|
||||
return earliest - TASK_SINCE_GRACE_MS;
|
||||
}
|
||||
|
||||
private async fileMentionsTaskIdCached(
|
||||
filePath: string,
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
assumeTeam: boolean,
|
||||
sinceMs: number | null
|
||||
): Promise<boolean> {
|
||||
let mtimeMs: number;
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
mtimeMs = stat.mtimeMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (sinceMs != null && mtimeMs < sinceMs - TASK_SINCE_GRACE_MS) {
|
||||
return false;
|
||||
}
|
||||
const cacheKey = `${filePath}:${mtimeMs}:${taskId}:${teamName}:${assumeTeam}`;
|
||||
const cached = this.fileMentionsCache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
const result = await this.fileMentionsTaskId(filePath, teamName, taskId, assumeTeam);
|
||||
this.fileMentionsCache.set(cacheKey, result);
|
||||
if (this.fileMentionsCache.size > FILE_MENTIONS_CACHE_MAX) {
|
||||
const keys = [...this.fileMentionsCache.keys()];
|
||||
for (let i = 0; i < Math.min(keys.length / 2, 50); i++) {
|
||||
this.fileMentionsCache.delete(keys[i]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async fileMentionsTaskId(
|
||||
filePath: string,
|
||||
teamName: string,
|
||||
|
|
@ -494,18 +566,6 @@ export class TeamMemberLogsFinder {
|
|||
return typeof raw === 'string' ? raw.trim() : null;
|
||||
};
|
||||
|
||||
const matchesTeamctlCommand = (command: string): boolean => {
|
||||
if (!/\bteamctl(?:\.js)?\b/i.test(command)) return false;
|
||||
|
||||
const teamMatch = /\s--team(?:\s+|=)(?:"([^"]+)"|'([^']+)'|([^\s]+))/i.exec(command);
|
||||
const cmdTeam = (teamMatch?.[1] ?? teamMatch?.[2] ?? teamMatch?.[3])?.trim();
|
||||
if (cmdTeam?.toLowerCase() !== teamLower) return false;
|
||||
|
||||
const taskMatch = /\btask\s+(?:start|complete|set-status)\s+(\d+)\b/i.exec(command);
|
||||
const cmdTaskId = taskMatch?.[1];
|
||||
return Boolean(cmdTaskId && cmdTaskId === taskIdStr);
|
||||
};
|
||||
|
||||
const matchesTeamMentionText = (text: string): boolean => {
|
||||
const t = text.toLowerCase();
|
||||
if (!t.includes(teamLower)) return false;
|
||||
|
|
@ -617,16 +677,6 @@ export class TeamMemberLogsFinder {
|
|||
taskSeenWithoutTeam = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic CLI match: teamctl command line (Bash tool).
|
||||
if (toolName === 'Bash') {
|
||||
const command = typeof input.command === 'string' ? input.command : '';
|
||||
if (command && matchesTeamctlCommand(command)) {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (teamSeen && taskSeenWithoutTeam) {
|
||||
|
|
@ -717,6 +767,7 @@ export class TeamMemberLogsFinder {
|
|||
durationMs: Math.max(0, durationMs),
|
||||
messageCount: metadata.messageCount,
|
||||
isOngoing,
|
||||
filePath,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -940,6 +991,7 @@ export class TeamMemberLogsFinder {
|
|||
durationMs: Math.max(0, durationMs),
|
||||
messageCount: metadata.messageCount,
|
||||
isOngoing,
|
||||
filePath: jsonlPath,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,46 @@ import type {
|
|||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
||||
'cross_team_send',
|
||||
'cross_team_list_targets',
|
||||
'cross_team_get_outbox',
|
||||
]);
|
||||
|
||||
function looksLikeQualifiedExternalRecipient(name: string): boolean {
|
||||
const trimmed = name.trim();
|
||||
const dot = trimmed.indexOf('.');
|
||||
if (dot <= 0 || dot === trimmed.length - 1) return false;
|
||||
const teamName = trimmed.slice(0, dot).trim();
|
||||
const memberName = trimmed.slice(dot + 1).trim();
|
||||
return TEAM_NAME_PATTERN.test(teamName) && memberName.length > 0;
|
||||
}
|
||||
|
||||
function looksLikeCrossTeamPseudoRecipient(name: string): boolean {
|
||||
const trimmed = name.trim();
|
||||
const prefixes = [
|
||||
'cross_team::',
|
||||
'cross_team--',
|
||||
'cross-team:',
|
||||
'cross-team-',
|
||||
'cross_team:',
|
||||
'cross_team-',
|
||||
];
|
||||
for (const prefix of prefixes) {
|
||||
if (!trimmed.startsWith(prefix)) continue;
|
||||
const teamName = trimmed.slice(prefix.length).trim();
|
||||
if (TEAM_NAME_PATTERN.test(teamName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function looksLikeCrossTeamToolRecipient(name: string): boolean {
|
||||
return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(name.trim());
|
||||
}
|
||||
|
||||
export class TeamMemberResolver {
|
||||
resolveMembers(
|
||||
config: TeamConfig,
|
||||
|
|
@ -17,11 +57,23 @@ export class TeamMemberResolver {
|
|||
messages: InboxMessage[]
|
||||
): ResolvedTeamMember[] {
|
||||
const names = new Set<string>();
|
||||
const explicitNames = new Set<string>();
|
||||
const seenNames = new Set<string>();
|
||||
const addName = (name: string): void => {
|
||||
const normalized = name.toLowerCase();
|
||||
if (seenNames.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seenNames.add(normalized);
|
||||
names.add(name);
|
||||
};
|
||||
|
||||
if (Array.isArray(config.members)) {
|
||||
for (const member of config.members) {
|
||||
if (typeof member?.name === 'string' && member.name.trim() !== '') {
|
||||
names.add(member.name.trim());
|
||||
const trimmed = member.name.trim();
|
||||
addName(trimmed);
|
||||
explicitNames.add(trimmed.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -29,14 +81,29 @@ export class TeamMemberResolver {
|
|||
if (Array.isArray(metaMembers)) {
|
||||
for (const member of metaMembers) {
|
||||
if (typeof member?.name === 'string' && member.name.trim() !== '') {
|
||||
names.add(member.name.trim());
|
||||
const trimmed = member.name.trim();
|
||||
addName(trimmed);
|
||||
explicitNames.add(trimmed.toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const inboxName of inboxNames) {
|
||||
if (typeof inboxName === 'string' && inboxName.trim() !== '') {
|
||||
names.add(inboxName.trim());
|
||||
const trimmed = inboxName.trim();
|
||||
if (
|
||||
looksLikeCrossTeamPseudoRecipient(trimmed) ||
|
||||
looksLikeCrossTeamToolRecipient(trimmed)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!explicitNames.has(trimmed.toLowerCase()) &&
|
||||
looksLikeQualifiedExternalRecipient(trimmed)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
addName(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +160,10 @@ export class TeamMemberResolver {
|
|||
const ownedTasks = tasks.filter((task) => task.owner === name);
|
||||
const currentTask =
|
||||
ownedTasks.find(
|
||||
(task) => task.status === 'in_progress' && task.kanbanColumn !== 'approved'
|
||||
(task) =>
|
||||
task.status === 'in_progress' &&
|
||||
task.reviewState !== 'approved' &&
|
||||
task.kanbanColumn !== 'approved'
|
||||
) ?? null;
|
||||
const memberMessages = messages.filter((message) => message.from === name);
|
||||
const latestMessage = memberMessages[0] ?? null;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -59,7 +59,9 @@ export class TeamSentMessagesStore {
|
|||
if (
|
||||
typeof row.from !== 'string' ||
|
||||
typeof row.text !== 'string' ||
|
||||
typeof row.timestamp !== 'string'
|
||||
typeof row.timestamp !== 'string' ||
|
||||
typeof row.messageId !== 'string' ||
|
||||
row.messageId.trim().length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -71,11 +73,14 @@ export class TeamSentMessagesStore {
|
|||
timestamp: row.timestamp,
|
||||
read: typeof row.read === 'boolean' ? row.read : true,
|
||||
summary: typeof row.summary === 'string' ? row.summary : undefined,
|
||||
messageId: typeof row.messageId === 'string' ? row.messageId : undefined,
|
||||
messageId: row.messageId,
|
||||
color: typeof row.color === 'string' ? row.color : undefined,
|
||||
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
|
||||
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
||||
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
|
||||
conversationId: typeof row.conversationId === 'string' ? row.conversationId : undefined,
|
||||
replyToConversationId:
|
||||
typeof row.replyToConversationId === 'string' ? row.replyToConversationId : undefined,
|
||||
toolSummary: typeof row.toolSummary === 'string' ? row.toolSummary : undefined,
|
||||
toolCalls: Array.isArray(row.toolCalls)
|
||||
? (row.toolCalls as unknown[])
|
||||
|
|
|
|||
|
|
@ -10,13 +10,6 @@ const logger = createLogger('Service:TeamTaskAttachmentStore');
|
|||
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
||||
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB
|
||||
|
||||
const KNOWN_IMAGE_MIME_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
export class TeamTaskAttachmentStore {
|
||||
private assertSafePathSegment(label: string, value: string): void {
|
||||
if (
|
||||
|
|
@ -42,7 +35,7 @@ export class TeamTaskAttachmentStore {
|
|||
|
||||
private sanitizeStoredFilename(original: string): string {
|
||||
const raw = String(original ?? '').trim();
|
||||
const base = raw ? raw.split(/[\\/]/).pop() ?? raw : '';
|
||||
const base = raw ? (raw.split(/[\\/]/).pop() ?? raw) : '';
|
||||
const cleaned = base
|
||||
.replace(/\0/g, '')
|
||||
.replace(/[\r\n\t]/g, ' ')
|
||||
|
|
@ -65,42 +58,15 @@ export class TeamTaskAttachmentStore {
|
|||
return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}--${safeName}`);
|
||||
}
|
||||
|
||||
/** Map known MIME types to file extension (legacy storage format). */
|
||||
private mimeToExt(mimeType: string): string {
|
||||
switch (mimeType) {
|
||||
case 'image/png':
|
||||
return '.png';
|
||||
case 'image/jpeg':
|
||||
return '.jpg';
|
||||
case 'image/gif':
|
||||
return '.gif';
|
||||
case 'image/webp':
|
||||
return '.webp';
|
||||
default:
|
||||
return '.bin';
|
||||
}
|
||||
}
|
||||
|
||||
private async findAttachmentFilePath(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
attachmentId: string,
|
||||
mimeType?: string
|
||||
_mimeType?: string
|
||||
): Promise<string | null> {
|
||||
const dir = this.getTaskDir(teamName, taskId);
|
||||
|
||||
// 1) Prefer legacy path for known image types (older storage format).
|
||||
if (mimeType && KNOWN_IMAGE_MIME_TYPES.has(mimeType)) {
|
||||
const legacy = path.join(dir, `${attachmentId}${this.mimeToExt(mimeType)}`);
|
||||
try {
|
||||
const stat = await fs.promises.stat(legacy);
|
||||
if (stat.isFile()) return legacy;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// 2) New format: "<id>--<filename>"
|
||||
// Canonical format: "<id>--<filename>"
|
||||
try {
|
||||
const entries = await fs.promises.readdir(dir);
|
||||
const prefix = `${attachmentId}--`;
|
||||
|
|
@ -108,13 +74,6 @@ export class TeamTaskAttachmentStore {
|
|||
if (matches.length > 0) {
|
||||
return path.join(dir, matches[0]);
|
||||
}
|
||||
|
||||
// 3) Fallback: any file starting with "<id>." (covers legacy when mimeType missing/wrong).
|
||||
const dotPrefix = `${attachmentId}.`;
|
||||
const dotMatches = entries.filter((e) => e.startsWith(dotPrefix));
|
||||
if (dotMatches.length > 0) {
|
||||
return path.join(dir, dotMatches[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
// Non-directory or other IO errors should surface.
|
||||
|
|
|
|||
|
|
@ -2,15 +2,17 @@ import { yieldToEventLoop } from '@main/utils/asyncYield';
|
|||
import { readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import { getTasksBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { getReviewStateFromTask } from '@shared/utils/reviewState';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||
|
||||
import type {
|
||||
StatusTransition,
|
||||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
TaskHistoryEvent,
|
||||
TaskWorkInterval,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
|
|
@ -101,24 +103,10 @@ export class TeamTaskReader {
|
|||
if (metadata?._internal === true) {
|
||||
continue;
|
||||
}
|
||||
// CLI sometimes writes "title" instead of "subject" — normalize
|
||||
const subject =
|
||||
typeof parsed.subject === 'string'
|
||||
? parsed.subject
|
||||
: typeof parsed.title === 'string'
|
||||
? parsed.title
|
||||
: '';
|
||||
// Resolve createdAt: prefer JSON field, fallback to fs.stat (reuse fileStat from above)
|
||||
let createdAt: string | undefined;
|
||||
const subject = typeof parsed.subject === 'string' ? parsed.subject : '';
|
||||
const createdAt = typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined;
|
||||
let updatedAt: string | undefined;
|
||||
if (typeof parsed.createdAt === 'string') {
|
||||
createdAt = parsed.createdAt;
|
||||
}
|
||||
try {
|
||||
if (!createdAt) {
|
||||
const bt = fileStat.birthtime.getTime();
|
||||
createdAt = (bt > 0 ? fileStat.birthtime : fileStat.mtime).toISOString();
|
||||
}
|
||||
updatedAt = fileStat.mtime.toISOString();
|
||||
} catch {
|
||||
/* leave undefined */
|
||||
|
|
@ -127,25 +115,17 @@ export class TeamTaskReader {
|
|||
// `satisfies Record<keyof TeamTask, unknown>` ensures compile-time
|
||||
// safety: if a field is added to TeamTask but not mapped here,
|
||||
// TypeScript will error. This prevents silently dropping new fields.
|
||||
const statusHistory: StatusTransition[] | undefined = Array.isArray(parsed.statusHistory)
|
||||
? (parsed.statusHistory as unknown[])
|
||||
const historyEvents: TaskHistoryEvent[] | undefined = Array.isArray(parsed.historyEvents)
|
||||
? (parsed.historyEvents as unknown[])
|
||||
.filter(
|
||||
(e): e is { from: string | null; to: string; timestamp: string; actor?: string } =>
|
||||
(e): e is Record<string, unknown> =>
|
||||
Boolean(e) &&
|
||||
typeof e === 'object' &&
|
||||
((e as Record<string, unknown>).from === null ||
|
||||
typeof (e as Record<string, unknown>).from === 'string') &&
|
||||
typeof (e as Record<string, unknown>).to === 'string' &&
|
||||
typeof (e as Record<string, unknown>).id === 'string' &&
|
||||
typeof (e as Record<string, unknown>).timestamp === 'string' &&
|
||||
((e as Record<string, unknown>).actor === undefined ||
|
||||
typeof (e as Record<string, unknown>).actor === 'string')
|
||||
typeof (e as Record<string, unknown>).type === 'string'
|
||||
)
|
||||
.map((e) => ({
|
||||
from: e.from as TeamTaskStatus | null,
|
||||
to: e.to as TeamTaskStatus,
|
||||
timestamp: e.timestamp,
|
||||
...(e.actor ? { actor: e.actor } : {}),
|
||||
}))
|
||||
.map((e) => e as unknown as TaskHistoryEvent)
|
||||
: undefined;
|
||||
const workIntervals: TaskWorkInterval[] | undefined = Array.isArray(parsed.workIntervals)
|
||||
? (parsed.workIntervals as unknown[])
|
||||
|
|
@ -165,6 +145,14 @@ export class TeamTaskReader {
|
|||
const task: TeamTask = {
|
||||
id:
|
||||
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||
displayId:
|
||||
typeof parsed.displayId === 'string' && parsed.displayId.trim().length > 0
|
||||
? parsed.displayId.trim()
|
||||
: deriveTaskDisplayId(
|
||||
typeof parsed.id === 'string' || typeof parsed.id === 'number'
|
||||
? String(parsed.id)
|
||||
: ''
|
||||
),
|
||||
subject,
|
||||
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
||||
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined,
|
||||
|
|
@ -176,7 +164,7 @@ export class TeamTaskReader {
|
|||
? (parsed.status as TeamTask['status'])
|
||||
: 'pending',
|
||||
workIntervals,
|
||||
statusHistory,
|
||||
historyEvents,
|
||||
blocks: Array.isArray(parsed.blocks)
|
||||
? (parsed.blocks as unknown[]).filter((id): id is string => typeof id === 'string')
|
||||
: undefined,
|
||||
|
|
@ -266,6 +254,10 @@ export class TeamTaskReader {
|
|||
addedAt: a.addedAt,
|
||||
}))
|
||||
: undefined,
|
||||
reviewState: getReviewStateFromTask({
|
||||
historyEvents,
|
||||
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
||||
}),
|
||||
} satisfies Record<keyof TeamTask, unknown>;
|
||||
if (task.status === 'deleted') {
|
||||
continue;
|
||||
|
|
@ -280,14 +272,20 @@ export class TeamTaskReader {
|
|||
}
|
||||
}
|
||||
|
||||
// Sort by numeric ID so kanban default order is deterministic (#1, #2, ..., #10, #11).
|
||||
// Fall back to stable lexicographic ordering for unexpected non-numeric IDs.
|
||||
// Sort by display ID first for stable human-facing ordering, then canonical id.
|
||||
tasks.sort((a, b) => {
|
||||
const aIsNumeric = /^\d+$/.test(a.id);
|
||||
const bIsNumeric = /^\d+$/.test(b.id);
|
||||
if (aIsNumeric && bIsNumeric) return Number(a.id) - Number(b.id);
|
||||
const aLabel = a.displayId ?? a.id;
|
||||
const bLabel = b.displayId ?? b.id;
|
||||
const aIsNumeric = /^\d+$/.test(aLabel);
|
||||
const bIsNumeric = /^\d+$/.test(bLabel);
|
||||
if (aIsNumeric && bIsNumeric) return Number(aLabel) - Number(bLabel);
|
||||
if (aIsNumeric) return -1;
|
||||
if (bIsNumeric) return 1;
|
||||
const byDisplay = aLabel.localeCompare(bLabel, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
if (byDisplay !== 0) return byDisplay;
|
||||
return a.id.localeCompare(b.id, undefined, { numeric: true, sensitivity: 'base' });
|
||||
});
|
||||
|
||||
|
|
@ -337,22 +335,28 @@ export class TeamTaskReader {
|
|||
continue;
|
||||
}
|
||||
|
||||
const subject =
|
||||
typeof parsed.subject === 'string'
|
||||
? parsed.subject
|
||||
: typeof parsed.title === 'string'
|
||||
? parsed.title
|
||||
: '';
|
||||
const subject = typeof parsed.subject === 'string' ? parsed.subject : '';
|
||||
|
||||
const task: TeamTask = {
|
||||
id:
|
||||
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||
displayId:
|
||||
typeof parsed.displayId === 'string' && parsed.displayId.trim().length > 0
|
||||
? parsed.displayId.trim()
|
||||
: deriveTaskDisplayId(
|
||||
typeof parsed.id === 'string' || typeof parsed.id === 'number'
|
||||
? String(parsed.id)
|
||||
: ''
|
||||
),
|
||||
subject,
|
||||
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||
status: 'deleted',
|
||||
deletedAt: typeof parsed.deletedAt === 'string' ? parsed.deletedAt : undefined,
|
||||
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
|
||||
reviewState: getReviewStateFromTask({
|
||||
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
||||
}),
|
||||
};
|
||||
|
||||
tasks.push(task);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue