preflight-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +208 -0
- package/README.zh-CN.md +406 -0
- package/dist/bundle/analysis.js +91 -0
- package/dist/bundle/context7.js +301 -0
- package/dist/bundle/deepwiki.js +206 -0
- package/dist/bundle/facts.js +296 -0
- package/dist/bundle/github.js +55 -0
- package/dist/bundle/guides.js +65 -0
- package/dist/bundle/ingest.js +152 -0
- package/dist/bundle/manifest.js +14 -0
- package/dist/bundle/overview.js +222 -0
- package/dist/bundle/paths.js +29 -0
- package/dist/bundle/service.js +803 -0
- package/dist/bundle/tagging.js +206 -0
- package/dist/config.js +65 -0
- package/dist/context7/client.js +30 -0
- package/dist/context7/tools.js +58 -0
- package/dist/core/scheduler.js +166 -0
- package/dist/errors.js +150 -0
- package/dist/index.js +7 -0
- package/dist/jobs/bundle-auto-update-job.js +71 -0
- package/dist/jobs/health-check-job.js +172 -0
- package/dist/jobs/storage-cleanup-job.js +148 -0
- package/dist/logging/logger.js +311 -0
- package/dist/mcp/uris.js +45 -0
- package/dist/search/sqliteFts.js +481 -0
- package/dist/server/optimized-server.js +255 -0
- package/dist/server.js +778 -0
- package/dist/storage/compression.js +249 -0
- package/dist/storage/storage-adapter.js +316 -0
- package/dist/utils/index.js +100 -0
- package/package.json +44 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-detect tags for a bundle based on its content
|
|
3
|
+
*/
|
|
4
|
+
export function autoDetectTags(params) {
|
|
5
|
+
const tags = new Set();
|
|
6
|
+
// 1. Detect by repo name patterns
|
|
7
|
+
for (const repoId of params.repoIds) {
|
|
8
|
+
const lowerRepo = repoId.toLowerCase();
|
|
9
|
+
// MCP related
|
|
10
|
+
if (lowerRepo.includes('mcp') || lowerRepo.includes('model-context-protocol')) {
|
|
11
|
+
tags.add('mcp');
|
|
12
|
+
tags.add('ai-tools');
|
|
13
|
+
}
|
|
14
|
+
// Agent frameworks
|
|
15
|
+
if (lowerRepo.includes('agent') || lowerRepo.includes('langchain') || lowerRepo.includes('autogen')) {
|
|
16
|
+
tags.add('agents');
|
|
17
|
+
tags.add('ai');
|
|
18
|
+
}
|
|
19
|
+
// Development tools
|
|
20
|
+
if (lowerRepo.includes('tool') || lowerRepo.includes('cli') || lowerRepo.includes('util')) {
|
|
21
|
+
tags.add('dev-tools');
|
|
22
|
+
}
|
|
23
|
+
// Testing/debugging
|
|
24
|
+
if (lowerRepo.includes('test') || lowerRepo.includes('debug') || lowerRepo.includes('mock')) {
|
|
25
|
+
tags.add('testing');
|
|
26
|
+
tags.add('debugging');
|
|
27
|
+
}
|
|
28
|
+
// Web scraping / crawling
|
|
29
|
+
if (lowerRepo.includes('scraper') || lowerRepo.includes('crawler') || lowerRepo.includes('spider')) {
|
|
30
|
+
tags.add('web-scraping');
|
|
31
|
+
}
|
|
32
|
+
// Anti-detection / bypassing
|
|
33
|
+
if (lowerRepo.includes('bypass') || lowerRepo.includes('anti') || lowerRepo.includes('stealth')) {
|
|
34
|
+
tags.add('anti-detection');
|
|
35
|
+
tags.add('web-scraping');
|
|
36
|
+
}
|
|
37
|
+
// Code analysis
|
|
38
|
+
if (lowerRepo.includes('lint') || lowerRepo.includes('analyzer') || lowerRepo.includes('ast')) {
|
|
39
|
+
tags.add('code-analysis');
|
|
40
|
+
tags.add('dev-tools');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// 2. Detect by frameworks (if facts available)
|
|
44
|
+
if (params.facts) {
|
|
45
|
+
for (const framework of params.facts.frameworks) {
|
|
46
|
+
const lowerFw = framework.toLowerCase();
|
|
47
|
+
if (lowerFw.includes('react') || lowerFw.includes('vue') || lowerFw.includes('angular')) {
|
|
48
|
+
tags.add('frontend');
|
|
49
|
+
tags.add('web-framework');
|
|
50
|
+
}
|
|
51
|
+
if (lowerFw.includes('express') || lowerFw.includes('fastify') || lowerFw.includes('nestjs')) {
|
|
52
|
+
tags.add('backend');
|
|
53
|
+
tags.add('web-framework');
|
|
54
|
+
}
|
|
55
|
+
if (lowerFw.includes('next') || lowerFw.includes('nuxt')) {
|
|
56
|
+
tags.add('full-stack');
|
|
57
|
+
tags.add('web-framework');
|
|
58
|
+
}
|
|
59
|
+
if (lowerFw.includes('django') || lowerFw.includes('flask') || lowerFw.includes('fastapi')) {
|
|
60
|
+
tags.add('backend');
|
|
61
|
+
tags.add('python');
|
|
62
|
+
}
|
|
63
|
+
if (lowerFw.includes('jest') || lowerFw.includes('vitest') || lowerFw.includes('pytest')) {
|
|
64
|
+
tags.add('testing');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Language tags
|
|
68
|
+
for (const lang of params.facts.languages) {
|
|
69
|
+
const lowerLang = lang.language.toLowerCase();
|
|
70
|
+
if (lowerLang === 'typescript' || lowerLang === 'javascript') {
|
|
71
|
+
tags.add('javascript');
|
|
72
|
+
}
|
|
73
|
+
else if (lowerLang === 'python') {
|
|
74
|
+
tags.add('python');
|
|
75
|
+
}
|
|
76
|
+
else if (lowerLang === 'go') {
|
|
77
|
+
tags.add('golang');
|
|
78
|
+
}
|
|
79
|
+
else if (lowerLang === 'rust') {
|
|
80
|
+
tags.add('rust');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Dependency-based detection
|
|
84
|
+
const allDeps = [
|
|
85
|
+
...params.facts.dependencies.runtime.map((d) => d.name.toLowerCase()),
|
|
86
|
+
...params.facts.dependencies.dev.map((d) => d.name.toLowerCase()),
|
|
87
|
+
];
|
|
88
|
+
if (allDeps.some((d) => d.includes('puppeteer') || d.includes('playwright') || d.includes('selenium'))) {
|
|
89
|
+
tags.add('browser-automation');
|
|
90
|
+
tags.add('web-scraping');
|
|
91
|
+
}
|
|
92
|
+
if (allDeps.some((d) => d.includes('axios') || d.includes('fetch') || d.includes('request'))) {
|
|
93
|
+
tags.add('http-client');
|
|
94
|
+
}
|
|
95
|
+
if (allDeps.some((d) => d.includes('cheerio') || d.includes('beautifulsoup') || d.includes('jsdom'))) {
|
|
96
|
+
tags.add('html-parsing');
|
|
97
|
+
tags.add('web-scraping');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// 3. Detect by file patterns
|
|
101
|
+
const fileNames = params.files.map((f) => f.repoRelativePath.toLowerCase());
|
|
102
|
+
if (fileNames.some((f) => f.includes('dockerfile') || f.includes('docker-compose'))) {
|
|
103
|
+
tags.add('docker');
|
|
104
|
+
tags.add('devops');
|
|
105
|
+
}
|
|
106
|
+
if (fileNames.some((f) => f.includes('kubernetes') || f.includes('k8s'))) {
|
|
107
|
+
tags.add('kubernetes');
|
|
108
|
+
tags.add('devops');
|
|
109
|
+
}
|
|
110
|
+
if (fileNames.some((f) => f.includes('ci') || f.includes('github/workflows'))) {
|
|
111
|
+
tags.add('ci-cd');
|
|
112
|
+
tags.add('devops');
|
|
113
|
+
}
|
|
114
|
+
if (fileNames.some((f) => f.includes('readme') || f.includes('docs/'))) {
|
|
115
|
+
tags.add('documented');
|
|
116
|
+
}
|
|
117
|
+
return Array.from(tags).sort();
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Generate a human-readable display name from repo IDs
|
|
121
|
+
*/
|
|
122
|
+
export function generateDisplayName(repoIds) {
|
|
123
|
+
if (repoIds.length === 0)
|
|
124
|
+
return 'Empty Bundle';
|
|
125
|
+
if (repoIds.length === 1) {
|
|
126
|
+
// Single repo: use repo name
|
|
127
|
+
const parts = repoIds[0].split('/');
|
|
128
|
+
return parts[1] || parts[0] || 'Unknown';
|
|
129
|
+
}
|
|
130
|
+
// Multiple repos: combine smartly
|
|
131
|
+
const repoNames = repoIds.map((id) => {
|
|
132
|
+
const parts = id.split('/');
|
|
133
|
+
return parts[1] || parts[0] || 'unknown';
|
|
134
|
+
});
|
|
135
|
+
if (repoNames.length <= 3) {
|
|
136
|
+
return repoNames.join(' + ');
|
|
137
|
+
}
|
|
138
|
+
return `${repoNames[0]} + ${repoNames.length - 1} more`;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Generate a brief description from facts
|
|
142
|
+
*/
|
|
143
|
+
export function generateDescription(params) {
|
|
144
|
+
if (!params.facts) {
|
|
145
|
+
return `Bundle containing ${params.repoIds.length} repository(ies)`;
|
|
146
|
+
}
|
|
147
|
+
const parts = [];
|
|
148
|
+
// Primary language
|
|
149
|
+
if (params.facts.languages.length > 0) {
|
|
150
|
+
const topLang = params.facts.languages[0];
|
|
151
|
+
if (topLang) {
|
|
152
|
+
parts.push(`${topLang.language} project`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Project type
|
|
156
|
+
if (params.facts.frameworks.length > 0) {
|
|
157
|
+
const frameworks = params.facts.frameworks.slice(0, 2).join(', ');
|
|
158
|
+
parts.push(`using ${frameworks}`);
|
|
159
|
+
}
|
|
160
|
+
// Special categories
|
|
161
|
+
if (params.tags.includes('mcp')) {
|
|
162
|
+
parts.push('(MCP Server)');
|
|
163
|
+
}
|
|
164
|
+
else if (params.tags.includes('agents')) {
|
|
165
|
+
parts.push('(AI Agent)');
|
|
166
|
+
}
|
|
167
|
+
else if (params.tags.includes('web-scraping')) {
|
|
168
|
+
parts.push('(Web Scraping)');
|
|
169
|
+
}
|
|
170
|
+
if (parts.length === 0) {
|
|
171
|
+
return `Bundle with ${params.facts.fileStructure.totalFiles} files`;
|
|
172
|
+
}
|
|
173
|
+
return parts.join(' ');
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Get category for a bundle based on tags (for grouping)
|
|
177
|
+
*/
|
|
178
|
+
export function getCategoryFromTags(tags) {
|
|
179
|
+
// Priority order for categorization
|
|
180
|
+
if (tags.includes('mcp'))
|
|
181
|
+
return 'mcp-servers';
|
|
182
|
+
if (tags.includes('agents'))
|
|
183
|
+
return 'ai-agents';
|
|
184
|
+
if (tags.includes('web-scraping'))
|
|
185
|
+
return 'web-scraping';
|
|
186
|
+
if (tags.includes('code-analysis'))
|
|
187
|
+
return 'code-analysis';
|
|
188
|
+
if (tags.includes('testing') || tags.includes('debugging'))
|
|
189
|
+
return 'testing-debugging';
|
|
190
|
+
if (tags.includes('web-framework'))
|
|
191
|
+
return 'web-frameworks';
|
|
192
|
+
if (tags.includes('dev-tools'))
|
|
193
|
+
return 'dev-tools';
|
|
194
|
+
if (tags.includes('devops'))
|
|
195
|
+
return 'devops';
|
|
196
|
+
// Language-based fallback
|
|
197
|
+
if (tags.includes('javascript'))
|
|
198
|
+
return 'javascript';
|
|
199
|
+
if (tags.includes('python'))
|
|
200
|
+
return 'python';
|
|
201
|
+
if (tags.includes('golang'))
|
|
202
|
+
return 'golang';
|
|
203
|
+
if (tags.includes('rust'))
|
|
204
|
+
return 'rust';
|
|
205
|
+
return 'uncategorized';
|
|
206
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
function envNumber(name, fallback) {
|
|
4
|
+
const raw = process.env[name];
|
|
5
|
+
if (!raw)
|
|
6
|
+
return fallback;
|
|
7
|
+
const n = Number(raw);
|
|
8
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
9
|
+
}
|
|
10
|
+
function parseAnalysisMode(raw) {
|
|
11
|
+
const v = (raw ?? '').trim().toLowerCase();
|
|
12
|
+
if (v === 'none')
|
|
13
|
+
return 'none';
|
|
14
|
+
if (v === 'quick')
|
|
15
|
+
return 'quick';
|
|
16
|
+
// Back-compat: deep used to exist; treat it as quick (but never run LLM).
|
|
17
|
+
if (v === 'deep')
|
|
18
|
+
return 'quick';
|
|
19
|
+
return 'quick';
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Parse storage directories from environment.
|
|
23
|
+
* Supports:
|
|
24
|
+
* - PREFLIGHT_STORAGE_DIRS (semicolon-separated, e.g. "D:\path1;E:\path2")
|
|
25
|
+
* - PREFLIGHT_STORAGE_DIR (single path, for backward compatibility)
|
|
26
|
+
*/
|
|
27
|
+
function parseStorageDirs() {
|
|
28
|
+
// Multi-path takes precedence
|
|
29
|
+
const multiPath = process.env.PREFLIGHT_STORAGE_DIRS;
|
|
30
|
+
if (multiPath) {
|
|
31
|
+
return multiPath.split(';').map((p) => p.trim()).filter((p) => p.length > 0);
|
|
32
|
+
}
|
|
33
|
+
// Fallback to single path
|
|
34
|
+
const singlePath = process.env.PREFLIGHT_STORAGE_DIR;
|
|
35
|
+
if (singlePath) {
|
|
36
|
+
return [singlePath];
|
|
37
|
+
}
|
|
38
|
+
// Default
|
|
39
|
+
return [path.join(os.homedir(), '.preflight-mcp', 'bundles')];
|
|
40
|
+
}
|
|
41
|
+
export function getConfig() {
|
|
42
|
+
const storageDirs = parseStorageDirs();
|
|
43
|
+
const storageDir = storageDirs[0]; // Primary for new bundles (always at least one from default)
|
|
44
|
+
const tmpDir = process.env.PREFLIGHT_TMP_DIR ?? path.join(os.tmpdir(), 'preflight-mcp');
|
|
45
|
+
const analysisMode = parseAnalysisMode(process.env.PREFLIGHT_ANALYSIS_MODE);
|
|
46
|
+
return {
|
|
47
|
+
storageDir,
|
|
48
|
+
storageDirs,
|
|
49
|
+
tmpDir,
|
|
50
|
+
githubToken: process.env.GITHUB_TOKEN,
|
|
51
|
+
context7ApiKey: process.env.CONTEXT7_API_KEY,
|
|
52
|
+
context7McpUrl: process.env.CONTEXT7_MCP_URL ?? 'https://mcp.context7.com/mcp',
|
|
53
|
+
maxFileBytes: envNumber('PREFLIGHT_MAX_FILE_BYTES', 512 * 1024),
|
|
54
|
+
maxTotalBytes: envNumber('PREFLIGHT_MAX_TOTAL_BYTES', 50 * 1024 * 1024),
|
|
55
|
+
analysisMode,
|
|
56
|
+
// Tuning parameters with defaults (can be overridden via env vars)
|
|
57
|
+
maxContext7Libraries: envNumber('PREFLIGHT_MAX_CONTEXT7_LIBRARIES', 20),
|
|
58
|
+
maxContext7Topics: envNumber('PREFLIGHT_MAX_CONTEXT7_TOPICS', 10),
|
|
59
|
+
maxFtsQueryTokens: envNumber('PREFLIGHT_MAX_FTS_QUERY_TOKENS', 12),
|
|
60
|
+
maxSkippedNotes: envNumber('PREFLIGHT_MAX_SKIPPED_NOTES', 50),
|
|
61
|
+
defaultMaxAgeHours: envNumber('PREFLIGHT_DEFAULT_MAX_AGE_HOURS', 24),
|
|
62
|
+
maxSearchLimit: envNumber('PREFLIGHT_MAX_SEARCH_LIMIT', 200),
|
|
63
|
+
defaultSearchLimit: envNumber('PREFLIGHT_DEFAULT_SEARCH_LIMIT', 30),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
3
|
+
export async function connectContext7(cfg) {
|
|
4
|
+
const url = new URL(cfg.context7McpUrl);
|
|
5
|
+
const headers = {};
|
|
6
|
+
// Context7 supports running without a key (rate-limited). If present, pass it.
|
|
7
|
+
if (cfg.context7ApiKey) {
|
|
8
|
+
headers['CONTEXT7_API_KEY'] = cfg.context7ApiKey;
|
|
9
|
+
}
|
|
10
|
+
const transport = new StreamableHTTPClientTransport(url, {
|
|
11
|
+
requestInit: {
|
|
12
|
+
headers,
|
|
13
|
+
},
|
|
14
|
+
reconnectionOptions: {
|
|
15
|
+
// Keep retries low to avoid hanging bundle generation.
|
|
16
|
+
initialReconnectionDelay: 500,
|
|
17
|
+
maxReconnectionDelay: 2000,
|
|
18
|
+
reconnectionDelayGrowFactor: 1.5,
|
|
19
|
+
maxRetries: 1,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
const client = new Client({ name: 'preflight-context7', version: '0.1.0' });
|
|
23
|
+
await client.connect(transport);
|
|
24
|
+
return {
|
|
25
|
+
client,
|
|
26
|
+
close: async () => {
|
|
27
|
+
await client.close().catch(() => undefined);
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export function textFromToolResult(res) {
|
|
2
|
+
const content = 'content' in res ? res.content : undefined;
|
|
3
|
+
if (!Array.isArray(content))
|
|
4
|
+
return '';
|
|
5
|
+
const parts = [];
|
|
6
|
+
for (const c of content) {
|
|
7
|
+
if (c && c.type === 'text' && typeof c.text === 'string') {
|
|
8
|
+
parts.push(c.text);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return parts.join('\n');
|
|
12
|
+
}
|
|
13
|
+
function collectStrings(value, out) {
|
|
14
|
+
if (typeof value === 'string') {
|
|
15
|
+
out.push(value);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (!value || typeof value !== 'object')
|
|
19
|
+
return;
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
for (const v of value)
|
|
22
|
+
collectStrings(v, out);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
for (const v of Object.values(value)) {
|
|
26
|
+
collectStrings(v, out);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function extractContext7IdsFromResult(res) {
|
|
30
|
+
const candidates = [];
|
|
31
|
+
if (res.structuredContent) {
|
|
32
|
+
collectStrings(res.structuredContent, candidates);
|
|
33
|
+
}
|
|
34
|
+
const text = textFromToolResult(res);
|
|
35
|
+
if (text) {
|
|
36
|
+
// Match strings like /owner/repo, /scope/pkg, etc.
|
|
37
|
+
const re = /\/[A-Za-z0-9@._-]+(?:\/[A-Za-z0-9@._-]+)+/g;
|
|
38
|
+
for (const m of text.matchAll(re)) {
|
|
39
|
+
if (m[0])
|
|
40
|
+
candidates.push(m[0]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Normalize + de-dupe, keep order.
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
const ids = [];
|
|
46
|
+
for (const raw of candidates) {
|
|
47
|
+
const s = raw.trim();
|
|
48
|
+
if (!s.startsWith('/'))
|
|
49
|
+
continue;
|
|
50
|
+
if (!s.includes('/'))
|
|
51
|
+
continue;
|
|
52
|
+
if (seen.has(s))
|
|
53
|
+
continue;
|
|
54
|
+
seen.add(s);
|
|
55
|
+
ids.push(s);
|
|
56
|
+
}
|
|
57
|
+
return ids;
|
|
58
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import cron from "node-cron";
|
|
2
|
+
import { logger } from '../logging/logger.js';
|
|
3
|
+
export class Job {
|
|
4
|
+
// 可选的失败重试配置
|
|
5
|
+
getMaxRetries() {
|
|
6
|
+
return 3;
|
|
7
|
+
}
|
|
8
|
+
getRetryDelay() {
|
|
9
|
+
return 1000; // 1秒
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class Scheduler {
|
|
13
|
+
tasks = new Map();
|
|
14
|
+
isRunning = false;
|
|
15
|
+
async start() {
|
|
16
|
+
if (this.isRunning) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
this.isRunning = true;
|
|
20
|
+
// Start any tasks that were scheduled while the scheduler was stopped.
|
|
21
|
+
for (const [name, jobTask] of this.tasks) {
|
|
22
|
+
jobTask.task.start();
|
|
23
|
+
logger.debug(`Started job: ${name}`);
|
|
24
|
+
}
|
|
25
|
+
logger.info('Scheduler started');
|
|
26
|
+
}
|
|
27
|
+
build(JobClass) {
|
|
28
|
+
const job = new JobClass();
|
|
29
|
+
const jobName = job.getName();
|
|
30
|
+
return {
|
|
31
|
+
schedule: (cronExpression) => {
|
|
32
|
+
const task = cron.createTask(cronExpression, async () => {
|
|
33
|
+
await this.executeJob(jobName, job);
|
|
34
|
+
});
|
|
35
|
+
const jobTask = {
|
|
36
|
+
job,
|
|
37
|
+
task,
|
|
38
|
+
cronExpression,
|
|
39
|
+
retries: 0,
|
|
40
|
+
running: false
|
|
41
|
+
};
|
|
42
|
+
this.tasks.set(jobName, jobTask);
|
|
43
|
+
if (this.isRunning) {
|
|
44
|
+
task.start();
|
|
45
|
+
}
|
|
46
|
+
logger.info(`Scheduled job ${jobName}`, { cronExpression });
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async executeJob(jobName, job, isRetry = false) {
|
|
51
|
+
const jobTask = this.tasks.get(jobName);
|
|
52
|
+
if (!jobTask) {
|
|
53
|
+
logger.error(`Job ${jobName} not found in task registry`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!this.isRunning) {
|
|
57
|
+
// Scheduler stopped: don't execute or schedule retries.
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// Avoid overlapping executions.
|
|
61
|
+
if (jobTask.running) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// If a retry is already scheduled, let it run instead of piling up executions from cron.
|
|
65
|
+
if (!isRetry && jobTask.retryTimeout) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
jobTask.running = true;
|
|
69
|
+
try {
|
|
70
|
+
const startTime = Date.now();
|
|
71
|
+
logger.debug(`Executing job: ${jobName}`);
|
|
72
|
+
await job.run();
|
|
73
|
+
const duration = Date.now() - startTime;
|
|
74
|
+
jobTask.lastRun = new Date();
|
|
75
|
+
jobTask.retries = 0;
|
|
76
|
+
jobTask.lastError = undefined;
|
|
77
|
+
if (jobTask.retryTimeout) {
|
|
78
|
+
clearTimeout(jobTask.retryTimeout);
|
|
79
|
+
jobTask.retryTimeout = undefined;
|
|
80
|
+
}
|
|
81
|
+
logger.info(`Job ${jobName} completed`, { durationMs: duration });
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
jobTask.lastError = error instanceof Error ? error : new Error(String(error));
|
|
85
|
+
logger.error(`Job ${jobName} failed`, error instanceof Error ? error : undefined);
|
|
86
|
+
if (!this.isRunning) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const maxRetries = job.getMaxRetries();
|
|
90
|
+
if (jobTask.retries < maxRetries) {
|
|
91
|
+
jobTask.retries++;
|
|
92
|
+
const delay = job.getRetryDelay() * Math.pow(2, jobTask.retries - 1);
|
|
93
|
+
logger.info(`Retrying job ${jobName}`, { attempt: jobTask.retries, maxRetries, delayMs: delay });
|
|
94
|
+
if (jobTask.retryTimeout) {
|
|
95
|
+
clearTimeout(jobTask.retryTimeout);
|
|
96
|
+
}
|
|
97
|
+
jobTask.retryTimeout = setTimeout(() => {
|
|
98
|
+
jobTask.retryTimeout = undefined;
|
|
99
|
+
void this.executeJob(jobName, job, true);
|
|
100
|
+
}, delay);
|
|
101
|
+
jobTask.retryTimeout.unref?.();
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
logger.error(`Job ${jobName} failed after ${maxRetries} retries`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
jobTask.running = false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async stop() {
|
|
112
|
+
if (!this.isRunning) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Mark stopped first so in-flight jobs won't schedule retries.
|
|
116
|
+
this.isRunning = false;
|
|
117
|
+
for (const [name, jobTask] of this.tasks) {
|
|
118
|
+
jobTask.task.stop();
|
|
119
|
+
if (jobTask.retryTimeout) {
|
|
120
|
+
clearTimeout(jobTask.retryTimeout);
|
|
121
|
+
jobTask.retryTimeout = undefined;
|
|
122
|
+
}
|
|
123
|
+
jobTask.running = false;
|
|
124
|
+
logger.debug(`Stopped job: ${name}`);
|
|
125
|
+
}
|
|
126
|
+
logger.info('Scheduler stopped');
|
|
127
|
+
}
|
|
128
|
+
async clear() {
|
|
129
|
+
for (const [name, jobTask] of this.tasks) {
|
|
130
|
+
if (jobTask.retryTimeout) {
|
|
131
|
+
clearTimeout(jobTask.retryTimeout);
|
|
132
|
+
jobTask.retryTimeout = undefined;
|
|
133
|
+
}
|
|
134
|
+
jobTask.running = false;
|
|
135
|
+
jobTask.task.destroy();
|
|
136
|
+
logger.debug(`Destroyed job: ${name}`);
|
|
137
|
+
}
|
|
138
|
+
this.tasks.clear();
|
|
139
|
+
logger.info('Scheduler cleared all tasks');
|
|
140
|
+
}
|
|
141
|
+
// 获取任务状态
|
|
142
|
+
getJobStatus(name) {
|
|
143
|
+
const jobTask = this.tasks.get(name);
|
|
144
|
+
if (!jobTask) {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const status = jobTask.task.getStatus();
|
|
148
|
+
return {
|
|
149
|
+
scheduled: !['stopped', 'destroyed'].includes(status),
|
|
150
|
+
lastRun: jobTask.lastRun,
|
|
151
|
+
lastError: jobTask.lastError,
|
|
152
|
+
retries: jobTask.retries,
|
|
153
|
+
cronExpression: jobTask.cronExpression
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// 获取所有任务状态
|
|
157
|
+
getAllJobsStatus() {
|
|
158
|
+
const status = {};
|
|
159
|
+
for (const [name] of this.tasks) {
|
|
160
|
+
status[name] = this.getJobStatus(name);
|
|
161
|
+
}
|
|
162
|
+
return status;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// 单例实例
|
|
166
|
+
export const PreflightScheduler = new Scheduler();
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom error types for preflight-mcp.
|
|
3
|
+
* Provides structured error handling with error codes and context.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Base error class for all preflight-mcp errors.
|
|
7
|
+
*/
|
|
8
|
+
export class PreflightError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
context;
|
|
11
|
+
cause;
|
|
12
|
+
constructor(message, code, options) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'PreflightError';
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.context = options?.context;
|
|
17
|
+
this.cause = options?.cause;
|
|
18
|
+
// Maintains proper stack trace for where our error was thrown (V8 only)
|
|
19
|
+
if (Error.captureStackTrace) {
|
|
20
|
+
Error.captureStackTrace(this, this.constructor);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
toJSON() {
|
|
24
|
+
return {
|
|
25
|
+
name: this.name,
|
|
26
|
+
code: this.code,
|
|
27
|
+
message: this.message,
|
|
28
|
+
context: this.context,
|
|
29
|
+
cause: this.cause?.message,
|
|
30
|
+
stack: this.stack,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Error thrown when a bundle is not found.
|
|
36
|
+
*/
|
|
37
|
+
export class BundleNotFoundError extends PreflightError {
|
|
38
|
+
constructor(bundleId) {
|
|
39
|
+
super(`Bundle not found: ${bundleId}`, 'BUNDLE_NOT_FOUND', {
|
|
40
|
+
context: { bundleId },
|
|
41
|
+
});
|
|
42
|
+
this.name = 'BundleNotFoundError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Error thrown when storage operations fail.
|
|
47
|
+
*/
|
|
48
|
+
export class StorageError extends PreflightError {
|
|
49
|
+
constructor(message, options) {
|
|
50
|
+
super(message, 'STORAGE_ERROR', options);
|
|
51
|
+
this.name = 'StorageError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Error thrown when no storage directory is available.
|
|
56
|
+
*/
|
|
57
|
+
export class StorageUnavailableError extends StorageError {
|
|
58
|
+
constructor(attemptedPaths) {
|
|
59
|
+
super('No storage directory available. All mount points are inaccessible.', {
|
|
60
|
+
context: { attemptedPaths },
|
|
61
|
+
});
|
|
62
|
+
this.name = 'StorageUnavailableError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Error thrown when bundle creation fails.
|
|
67
|
+
*/
|
|
68
|
+
export class BundleCreationError extends PreflightError {
|
|
69
|
+
constructor(message, bundleId, options) {
|
|
70
|
+
super(`Failed to create bundle: ${message}`, 'BUNDLE_CREATION_ERROR', {
|
|
71
|
+
...options,
|
|
72
|
+
context: { ...options?.context, bundleId },
|
|
73
|
+
});
|
|
74
|
+
this.name = 'BundleCreationError';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Error thrown when bundle validation fails.
|
|
79
|
+
*/
|
|
80
|
+
export class BundleValidationError extends PreflightError {
|
|
81
|
+
constructor(bundleId, missingComponents) {
|
|
82
|
+
super(`Bundle creation incomplete. Missing: ${missingComponents.join(', ')}`, 'BUNDLE_VALIDATION_ERROR', {
|
|
83
|
+
context: { bundleId, missingComponents },
|
|
84
|
+
});
|
|
85
|
+
this.name = 'BundleValidationError';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Error thrown when GitHub operations fail.
|
|
90
|
+
*/
|
|
91
|
+
export class GitHubError extends PreflightError {
|
|
92
|
+
constructor(message, options) {
|
|
93
|
+
super(message, 'GITHUB_ERROR', options);
|
|
94
|
+
this.name = 'GitHubError';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Error thrown when Context7 operations fail.
|
|
99
|
+
*/
|
|
100
|
+
export class Context7Error extends PreflightError {
|
|
101
|
+
constructor(message, options) {
|
|
102
|
+
super(message, 'CONTEXT7_ERROR', options);
|
|
103
|
+
this.name = 'Context7Error';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Error thrown when search operations fail.
|
|
108
|
+
*/
|
|
109
|
+
export class SearchError extends PreflightError {
|
|
110
|
+
constructor(message, options) {
|
|
111
|
+
super(message, 'SEARCH_ERROR', options);
|
|
112
|
+
this.name = 'SearchError';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Error thrown when file ingestion fails.
|
|
117
|
+
*/
|
|
118
|
+
export class IngestError extends PreflightError {
|
|
119
|
+
constructor(message, options) {
|
|
120
|
+
super(message, 'INGEST_ERROR', options);
|
|
121
|
+
this.name = 'IngestError';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Error thrown for configuration-related issues.
|
|
126
|
+
*/
|
|
127
|
+
export class ConfigError extends PreflightError {
|
|
128
|
+
constructor(message, options) {
|
|
129
|
+
super(message, 'CONFIG_ERROR', options);
|
|
130
|
+
this.name = 'ConfigError';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Helper to wrap unknown errors as PreflightError.
|
|
135
|
+
*/
|
|
136
|
+
export function wrapError(err, code = 'UNKNOWN_ERROR') {
|
|
137
|
+
if (err instanceof PreflightError) {
|
|
138
|
+
return err;
|
|
139
|
+
}
|
|
140
|
+
if (err instanceof Error) {
|
|
141
|
+
return new PreflightError(err.message, code, { cause: err });
|
|
142
|
+
}
|
|
143
|
+
return new PreflightError(String(err), code);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Type guard to check if an error is a PreflightError.
|
|
147
|
+
*/
|
|
148
|
+
export function isPreflightError(err) {
|
|
149
|
+
return err instanceof PreflightError;
|
|
150
|
+
}
|