preflight-mcp 0.1.3 → 0.1.4
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 +21 -7
- package/dist/bundle/github.js +34 -3
- package/dist/bundle/githubArchive.js +58 -6
- package/dist/bundle/service.js +224 -5
- package/dist/config.js +1 -0
- package/dist/evidence/dependencyGraph.js +312 -2
- package/dist/jobs/progressTracker.js +191 -0
- package/dist/server.js +261 -36
- package/package.json +1 -1
|
@@ -12,12 +12,15 @@ export const DependencyGraphInputSchema = {
|
|
|
12
12
|
target: z.object({
|
|
13
13
|
file: z
|
|
14
14
|
.string()
|
|
15
|
-
.describe('Bundle-relative file path (
|
|
15
|
+
.describe('Bundle-relative file path (NOT absolute path). Format: repos/{owner}/{repo}/norm/{path}. ' +
|
|
16
|
+
'Example: repos/owner/repo/norm/src/index.ts or repos/jonnyhoo/langextract/norm/langextract/__init__.py. ' +
|
|
17
|
+
'Use preflight_search_bundle to discover the correct path if unsure.'),
|
|
16
18
|
symbol: z
|
|
17
19
|
.string()
|
|
18
20
|
.optional()
|
|
19
21
|
.describe('Optional symbol name (function/class). If omitted, graph is file-level.'),
|
|
20
|
-
}),
|
|
22
|
+
}).optional().describe('Target file/symbol to analyze. If omitted, generates a GLOBAL dependency graph of all code files in the bundle. ' +
|
|
23
|
+
'Global mode shows import relationships between all files but may be truncated for large projects.'),
|
|
21
24
|
options: z
|
|
22
25
|
.object({
|
|
23
26
|
maxFiles: z.number().int().min(1).max(500).default(200),
|
|
@@ -243,6 +246,26 @@ export async function generateDependencyGraph(cfg, rawArgs) {
|
|
|
243
246
|
edges.push(e);
|
|
244
247
|
};
|
|
245
248
|
const bundleFileUri = (p) => toBundleFileUri({ bundleId: args.bundleId, relativePath: p });
|
|
249
|
+
// Global mode: no target specified
|
|
250
|
+
if (!args.target) {
|
|
251
|
+
return generateGlobalDependencyGraph({
|
|
252
|
+
cfg,
|
|
253
|
+
args,
|
|
254
|
+
paths,
|
|
255
|
+
manifest,
|
|
256
|
+
limits,
|
|
257
|
+
nodes,
|
|
258
|
+
edges,
|
|
259
|
+
warnings,
|
|
260
|
+
startedAt,
|
|
261
|
+
requestId,
|
|
262
|
+
timeBudgetMs,
|
|
263
|
+
checkBudget,
|
|
264
|
+
addNode,
|
|
265
|
+
addEdge,
|
|
266
|
+
bundleFileUri,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
246
269
|
const targetFile = args.target.file.replaceAll('\\', '/');
|
|
247
270
|
const targetRepo = parseRepoNormPath(targetFile);
|
|
248
271
|
const targetFileId = `file:${targetFile}`;
|
|
@@ -678,6 +701,38 @@ export async function generateDependencyGraph(cfg, rawArgs) {
|
|
|
678
701
|
}
|
|
679
702
|
}
|
|
680
703
|
catch (err) {
|
|
704
|
+
// If target file not found, throw a helpful error instead of just warning
|
|
705
|
+
if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
|
|
706
|
+
// Detect if path looks like an absolute filesystem path (wrong format)
|
|
707
|
+
const looksLikeAbsolutePath = /^[A-Za-z]:[\\/]|^\/(?:home|Users|var|tmp|etc)\//i.test(targetFile);
|
|
708
|
+
// Detect if path looks like correct bundle-relative format
|
|
709
|
+
const looksLikeBundleRelative = /^repos\/[^/]+\/[^/]+\/norm\//i.test(targetFile);
|
|
710
|
+
if (looksLikeAbsolutePath) {
|
|
711
|
+
throw new Error(`Target file not found: ${targetFile}\n\n` +
|
|
712
|
+
`ERROR: You provided an absolute filesystem path, but file paths must be bundle-relative.\n` +
|
|
713
|
+
`Correct format: repos/{owner}/{repo}/norm/{path/to/file}\n` +
|
|
714
|
+
`Example: repos/owner/myrepo/norm/src/main.py\n\n` +
|
|
715
|
+
`Use preflight_search_bundle to find the correct file path.`);
|
|
716
|
+
}
|
|
717
|
+
else if (looksLikeBundleRelative) {
|
|
718
|
+
throw new Error(`Target file not found: ${targetFile}\n\n` +
|
|
719
|
+
`The path format looks correct, but the file does not exist in the bundle.\n` +
|
|
720
|
+
`Possible causes:\n` +
|
|
721
|
+
`1. The bundle may be incomplete (download timed out or failed)\n` +
|
|
722
|
+
`2. The file path may have a typo\n\n` +
|
|
723
|
+
`Suggested actions:\n` +
|
|
724
|
+
`- Use preflight_search_bundle to verify available files\n` +
|
|
725
|
+
`- Use preflight_update_bundle with updateExisting:true to re-download\n` +
|
|
726
|
+
`- Check if repair shows "indexed 0 file(s)" which indicates incomplete bundle`);
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
throw new Error(`Target file not found: ${targetFile}\n\n` +
|
|
730
|
+
`File paths must be bundle-relative, NOT absolute filesystem paths.\n` +
|
|
731
|
+
`Correct format: repos/{owner}/{repo}/norm/{path/to/file}\n` +
|
|
732
|
+
`Example: repos/owner/myrepo/norm/src/main.py\n\n` +
|
|
733
|
+
`Use preflight_search_bundle to find the correct file path.`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
681
736
|
warnings.push({
|
|
682
737
|
code: 'target_file_unreadable',
|
|
683
738
|
message: `Failed to read target file for import extraction: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -824,3 +879,258 @@ export async function generateDependencyGraph(cfg, rawArgs) {
|
|
|
824
879
|
};
|
|
825
880
|
return out;
|
|
826
881
|
}
|
|
882
|
+
/**
|
|
883
|
+
* Global dependency graph mode: analyze all code files in the bundle.
|
|
884
|
+
* Generates import relationships between all files.
|
|
885
|
+
*/
|
|
886
|
+
async function generateGlobalDependencyGraph(ctx) {
|
|
887
|
+
const { cfg, args, paths, manifest, limits, nodes, edges, warnings, startedAt, requestId, timeBudgetMs, checkBudget, addNode, addEdge, bundleFileUri, } = ctx;
|
|
888
|
+
let truncated = false;
|
|
889
|
+
let truncatedReason;
|
|
890
|
+
let filesProcessed = 0;
|
|
891
|
+
let usedAstCount = 0;
|
|
892
|
+
// Collect all code files
|
|
893
|
+
const codeExtensions = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb', '.php']);
|
|
894
|
+
const codeFiles = [];
|
|
895
|
+
async function* walkDir(dir, prefix) {
|
|
896
|
+
try {
|
|
897
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
898
|
+
for (const ent of entries) {
|
|
899
|
+
if (checkBudget('timeBudget exceeded during file discovery'))
|
|
900
|
+
return;
|
|
901
|
+
const relPath = prefix ? `${prefix}/${ent.name}` : ent.name;
|
|
902
|
+
if (ent.isDirectory()) {
|
|
903
|
+
yield* walkDir(path.join(dir, ent.name), relPath);
|
|
904
|
+
}
|
|
905
|
+
else if (ent.isFile()) {
|
|
906
|
+
const ext = path.extname(ent.name).toLowerCase();
|
|
907
|
+
if (codeExtensions.has(ext)) {
|
|
908
|
+
yield relPath;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
catch {
|
|
914
|
+
// ignore unreadable directories
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// Walk repos directory
|
|
918
|
+
for await (const relPath of walkDir(paths.reposDir, 'repos')) {
|
|
919
|
+
if (codeFiles.length >= limits.maxFiles) {
|
|
920
|
+
truncated = true;
|
|
921
|
+
truncatedReason = 'maxFiles reached during discovery';
|
|
922
|
+
break;
|
|
923
|
+
}
|
|
924
|
+
// Only include files under norm/ directories
|
|
925
|
+
if (relPath.includes('/norm/')) {
|
|
926
|
+
codeFiles.push(relPath);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
warnings.push({
|
|
930
|
+
code: 'global_mode',
|
|
931
|
+
message: `Global dependency graph mode: analyzing ${codeFiles.length} code file(s). Results show import relationships between files.`,
|
|
932
|
+
});
|
|
933
|
+
// Process each file
|
|
934
|
+
const resolvedImportsCache = new Map();
|
|
935
|
+
for (const filePath of codeFiles) {
|
|
936
|
+
if (checkBudget('timeBudget exceeded during file processing')) {
|
|
937
|
+
truncated = true;
|
|
938
|
+
truncatedReason = 'timeBudget exceeded';
|
|
939
|
+
break;
|
|
940
|
+
}
|
|
941
|
+
const fileId = `file:${filePath}`;
|
|
942
|
+
addNode({ id: fileId, kind: 'file', name: filePath, file: filePath });
|
|
943
|
+
// Read and extract imports
|
|
944
|
+
try {
|
|
945
|
+
const absPath = safeJoin(paths.rootDir, filePath);
|
|
946
|
+
const raw = await fs.readFile(absPath, 'utf8');
|
|
947
|
+
const normalized = raw.replace(/\r\n/g, '\n');
|
|
948
|
+
const lines = normalized.split('\n');
|
|
949
|
+
const extracted = await extractImportsForFile(cfg, filePath, normalized, lines, warnings);
|
|
950
|
+
if (extracted.usedAst)
|
|
951
|
+
usedAstCount++;
|
|
952
|
+
filesProcessed++;
|
|
953
|
+
const fileRepo = parseRepoNormPath(filePath);
|
|
954
|
+
if (!fileRepo)
|
|
955
|
+
continue;
|
|
956
|
+
// Resolve imports to files in the bundle
|
|
957
|
+
for (const imp of extracted.imports) {
|
|
958
|
+
if (checkBudget('timeBudget exceeded during import resolution'))
|
|
959
|
+
break;
|
|
960
|
+
// Try to resolve the import to a file in the same repo
|
|
961
|
+
const resolvedFile = await resolveImportInRepo({
|
|
962
|
+
rootDir: paths.rootDir,
|
|
963
|
+
repoRoot: fileRepo.repoRoot,
|
|
964
|
+
importerRepoRel: fileRepo.repoRelativePath,
|
|
965
|
+
module: imp.module,
|
|
966
|
+
cache: resolvedImportsCache,
|
|
967
|
+
});
|
|
968
|
+
if (resolvedFile) {
|
|
969
|
+
const targetId = `file:${resolvedFile}`;
|
|
970
|
+
addNode({ id: targetId, kind: 'file', name: resolvedFile, file: resolvedFile });
|
|
971
|
+
const source = {
|
|
972
|
+
file: filePath,
|
|
973
|
+
range: imp.range,
|
|
974
|
+
uri: bundleFileUri(filePath),
|
|
975
|
+
snippet: clampSnippet(lines[imp.range.startLine - 1] ?? '', 200),
|
|
976
|
+
};
|
|
977
|
+
source.snippetSha256 = sha256Hex(source.snippet ?? '');
|
|
978
|
+
addEdge({
|
|
979
|
+
evidenceId: makeEvidenceId(['imports_resolved', fileId, targetId, String(imp.range.startLine)]),
|
|
980
|
+
kind: 'edge',
|
|
981
|
+
type: 'imports_resolved',
|
|
982
|
+
from: fileId,
|
|
983
|
+
to: targetId,
|
|
984
|
+
method: imp.method,
|
|
985
|
+
confidence: Math.min(0.85, imp.confidence),
|
|
986
|
+
sources: [source],
|
|
987
|
+
notes: [...imp.notes, `resolved import "${imp.module}" to ${resolvedFile}`],
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
catch {
|
|
993
|
+
// Skip unreadable files
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
// Post-process warnings
|
|
997
|
+
warnings.push({
|
|
998
|
+
code: 'limitations',
|
|
999
|
+
message: usedAstCount > 0
|
|
1000
|
+
? `Global graph used AST parsing for ${usedAstCount}/${filesProcessed} files. Import resolution is best-effort. Only internal imports (resolved to files in the bundle) are shown.`
|
|
1001
|
+
: 'Global graph used regex-based import extraction. Import resolution is best-effort. Only internal imports (resolved to files in the bundle) are shown.',
|
|
1002
|
+
});
|
|
1003
|
+
const importEdges = edges.filter((e) => e.type === 'imports_resolved').length;
|
|
1004
|
+
return {
|
|
1005
|
+
meta: {
|
|
1006
|
+
requestId,
|
|
1007
|
+
generatedAt: nowIso(),
|
|
1008
|
+
timeMs: Date.now() - startedAt,
|
|
1009
|
+
repo: {
|
|
1010
|
+
bundleId: args.bundleId,
|
|
1011
|
+
headSha: manifest.repos?.[0]?.headSha,
|
|
1012
|
+
},
|
|
1013
|
+
budget: {
|
|
1014
|
+
timeBudgetMs,
|
|
1015
|
+
truncated,
|
|
1016
|
+
truncatedReason,
|
|
1017
|
+
limits,
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
facts: {
|
|
1021
|
+
nodes: Array.from(nodes.values()),
|
|
1022
|
+
edges,
|
|
1023
|
+
},
|
|
1024
|
+
signals: {
|
|
1025
|
+
stats: {
|
|
1026
|
+
filesRead: filesProcessed,
|
|
1027
|
+
searchHits: 0,
|
|
1028
|
+
callEdges: 0,
|
|
1029
|
+
importEdges,
|
|
1030
|
+
},
|
|
1031
|
+
warnings,
|
|
1032
|
+
},
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Resolve an import to a file path within the same repo.
|
|
1037
|
+
*/
|
|
1038
|
+
async function resolveImportInRepo(ctx) {
|
|
1039
|
+
const { rootDir, repoRoot, importerRepoRel, module, cache } = ctx;
|
|
1040
|
+
// Get or create cache for this repo
|
|
1041
|
+
let repoCache = cache.get(repoRoot);
|
|
1042
|
+
if (!repoCache) {
|
|
1043
|
+
repoCache = new Map();
|
|
1044
|
+
cache.set(repoRoot, repoCache);
|
|
1045
|
+
}
|
|
1046
|
+
const cacheKey = `${importerRepoRel}:${module}`;
|
|
1047
|
+
const cached = repoCache.get(cacheKey);
|
|
1048
|
+
if (cached !== undefined)
|
|
1049
|
+
return cached;
|
|
1050
|
+
const ext = path.extname(importerRepoRel).toLowerCase();
|
|
1051
|
+
const cleaned = (module.split(/[?#]/, 1)[0] ?? '').trim();
|
|
1052
|
+
if (!cleaned) {
|
|
1053
|
+
repoCache.set(cacheKey, null);
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
const bundlePathForRepoRel = (repoRel) => `${repoRoot}/${repoRel.replaceAll('\\', '/')}`;
|
|
1057
|
+
const normalizeDir = (d) => (d === '.' ? '' : d);
|
|
1058
|
+
// JS/TS resolution
|
|
1059
|
+
const isJs = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext);
|
|
1060
|
+
if (isJs) {
|
|
1061
|
+
if (!cleaned.startsWith('.') && !cleaned.startsWith('/')) {
|
|
1062
|
+
repoCache.set(cacheKey, null);
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
const importerDir = normalizeDir(path.posix.dirname(importerRepoRel));
|
|
1066
|
+
const base = cleaned.startsWith('/')
|
|
1067
|
+
? path.posix.normalize(cleaned.slice(1))
|
|
1068
|
+
: path.posix.normalize(path.posix.join(importerDir, cleaned));
|
|
1069
|
+
const candidates = [];
|
|
1070
|
+
const baseExt = path.posix.extname(base).toLowerCase();
|
|
1071
|
+
if (baseExt) {
|
|
1072
|
+
candidates.push(base);
|
|
1073
|
+
if (['.js', '.mjs', '.cjs'].includes(baseExt)) {
|
|
1074
|
+
const stem = base.slice(0, -baseExt.length);
|
|
1075
|
+
candidates.push(`${stem}.ts`, `${stem}.tsx`, `${stem}.jsx`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
else {
|
|
1079
|
+
const exts = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
|
|
1080
|
+
for (const e of exts)
|
|
1081
|
+
candidates.push(`${base}${e}`);
|
|
1082
|
+
for (const e of exts)
|
|
1083
|
+
candidates.push(path.posix.join(base, `index${e}`));
|
|
1084
|
+
}
|
|
1085
|
+
for (const repoRel of candidates) {
|
|
1086
|
+
const bundleRel = bundlePathForRepoRel(repoRel);
|
|
1087
|
+
const abs = safeJoin(rootDir, bundleRel);
|
|
1088
|
+
if (await fileExists(abs)) {
|
|
1089
|
+
repoCache.set(cacheKey, bundleRel);
|
|
1090
|
+
return bundleRel;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
// Python resolution
|
|
1095
|
+
if (ext === '.py') {
|
|
1096
|
+
if (cleaned.startsWith('.')) {
|
|
1097
|
+
// Relative import
|
|
1098
|
+
const m = cleaned.match(/^(\.+)(.*)$/);
|
|
1099
|
+
if (m) {
|
|
1100
|
+
const dotCount = m[1]?.length ?? 0;
|
|
1101
|
+
const rest = (m[2] ?? '').replace(/^\.+/, '');
|
|
1102
|
+
let baseDir = normalizeDir(path.posix.dirname(importerRepoRel));
|
|
1103
|
+
for (let i = 1; i < dotCount; i++) {
|
|
1104
|
+
baseDir = normalizeDir(path.posix.dirname(baseDir));
|
|
1105
|
+
}
|
|
1106
|
+
const restPath = rest ? rest.replace(/\./g, '/') : '';
|
|
1107
|
+
const candidates = restPath
|
|
1108
|
+
? [path.posix.join(baseDir, `${restPath}.py`), path.posix.join(baseDir, restPath, '__init__.py')]
|
|
1109
|
+
: [path.posix.join(baseDir, '__init__.py')];
|
|
1110
|
+
for (const repoRel of candidates) {
|
|
1111
|
+
const bundleRel = bundlePathForRepoRel(repoRel);
|
|
1112
|
+
const abs = safeJoin(rootDir, bundleRel);
|
|
1113
|
+
if (await fileExists(abs)) {
|
|
1114
|
+
repoCache.set(cacheKey, bundleRel);
|
|
1115
|
+
return bundleRel;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
else {
|
|
1121
|
+
// Absolute import - try common patterns
|
|
1122
|
+
const modPath = cleaned.replace(/\./g, '/');
|
|
1123
|
+
const candidates = [`${modPath}.py`, path.posix.join(modPath, '__init__.py')];
|
|
1124
|
+
for (const repoRel of candidates) {
|
|
1125
|
+
const bundleRel = bundlePathForRepoRel(repoRel);
|
|
1126
|
+
const abs = safeJoin(rootDir, bundleRel);
|
|
1127
|
+
if (await fileExists(abs)) {
|
|
1128
|
+
repoCache.set(cacheKey, bundleRel);
|
|
1129
|
+
return bundleRel;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
repoCache.set(cacheKey, null);
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress tracking for long-running bundle creation tasks.
|
|
3
|
+
* Enables progress notifications via MCP and prevents duplicate task creation.
|
|
4
|
+
*/
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
function nowIso() {
|
|
7
|
+
return new Date().toISOString();
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Tracks progress of bundle creation tasks.
|
|
11
|
+
* Provides in-memory state for active tasks and emits progress updates.
|
|
12
|
+
*/
|
|
13
|
+
export class ProgressTracker {
|
|
14
|
+
tasks = new Map();
|
|
15
|
+
fingerprintToTaskId = new Map();
|
|
16
|
+
progressCallback;
|
|
17
|
+
/**
|
|
18
|
+
* Set a callback to receive progress updates.
|
|
19
|
+
* Used to forward updates to MCP progress notifications.
|
|
20
|
+
*/
|
|
21
|
+
setProgressCallback(callback) {
|
|
22
|
+
this.progressCallback = callback;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Start tracking a new task.
|
|
26
|
+
* @returns taskId for the new task
|
|
27
|
+
*/
|
|
28
|
+
startTask(fingerprint, repos) {
|
|
29
|
+
// Check if task already exists for this fingerprint
|
|
30
|
+
const existingTaskId = this.fingerprintToTaskId.get(fingerprint);
|
|
31
|
+
if (existingTaskId) {
|
|
32
|
+
const existingTask = this.tasks.get(existingTaskId);
|
|
33
|
+
if (existingTask && existingTask.phase !== 'complete' && existingTask.phase !== 'failed') {
|
|
34
|
+
// Return existing active task
|
|
35
|
+
return existingTaskId;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const taskId = crypto.randomUUID();
|
|
39
|
+
const now = nowIso();
|
|
40
|
+
const task = {
|
|
41
|
+
taskId,
|
|
42
|
+
fingerprint,
|
|
43
|
+
phase: 'starting',
|
|
44
|
+
progress: 0,
|
|
45
|
+
message: `Starting bundle creation for ${repos.join(', ')}`,
|
|
46
|
+
startedAt: now,
|
|
47
|
+
updatedAt: now,
|
|
48
|
+
repos,
|
|
49
|
+
};
|
|
50
|
+
this.tasks.set(taskId, task);
|
|
51
|
+
this.fingerprintToTaskId.set(fingerprint, taskId);
|
|
52
|
+
this.emitProgress(task);
|
|
53
|
+
return taskId;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Update progress for an existing task.
|
|
57
|
+
*/
|
|
58
|
+
updateProgress(taskId, phase, progress, message, total) {
|
|
59
|
+
const task = this.tasks.get(taskId);
|
|
60
|
+
if (!task)
|
|
61
|
+
return;
|
|
62
|
+
task.phase = phase;
|
|
63
|
+
task.progress = progress;
|
|
64
|
+
task.message = message;
|
|
65
|
+
task.updatedAt = nowIso();
|
|
66
|
+
if (total !== undefined) {
|
|
67
|
+
task.total = total;
|
|
68
|
+
}
|
|
69
|
+
this.emitProgress(task);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Mark a task as complete.
|
|
73
|
+
*/
|
|
74
|
+
completeTask(taskId, bundleId) {
|
|
75
|
+
const task = this.tasks.get(taskId);
|
|
76
|
+
if (!task)
|
|
77
|
+
return;
|
|
78
|
+
task.phase = 'complete';
|
|
79
|
+
task.progress = 100;
|
|
80
|
+
task.message = `Bundle created: ${bundleId}`;
|
|
81
|
+
task.updatedAt = nowIso();
|
|
82
|
+
task.bundleId = bundleId;
|
|
83
|
+
this.emitProgress(task);
|
|
84
|
+
// Clean up after a delay to allow final status queries
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
this.tasks.delete(taskId);
|
|
87
|
+
if (this.fingerprintToTaskId.get(task.fingerprint) === taskId) {
|
|
88
|
+
this.fingerprintToTaskId.delete(task.fingerprint);
|
|
89
|
+
}
|
|
90
|
+
}, 60_000); // Keep completed task for 1 minute
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Mark a task as failed.
|
|
94
|
+
*/
|
|
95
|
+
failTask(taskId, error) {
|
|
96
|
+
const task = this.tasks.get(taskId);
|
|
97
|
+
if (!task)
|
|
98
|
+
return;
|
|
99
|
+
task.phase = 'failed';
|
|
100
|
+
task.message = `Failed: ${error}`;
|
|
101
|
+
task.updatedAt = nowIso();
|
|
102
|
+
task.error = error;
|
|
103
|
+
this.emitProgress(task);
|
|
104
|
+
// Clean up after a delay
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
this.tasks.delete(taskId);
|
|
107
|
+
if (this.fingerprintToTaskId.get(task.fingerprint) === taskId) {
|
|
108
|
+
this.fingerprintToTaskId.delete(task.fingerprint);
|
|
109
|
+
}
|
|
110
|
+
}, 60_000);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get task by ID.
|
|
114
|
+
*/
|
|
115
|
+
getTask(taskId) {
|
|
116
|
+
return this.tasks.get(taskId);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Get task by fingerprint.
|
|
120
|
+
*/
|
|
121
|
+
getTaskByFingerprint(fingerprint) {
|
|
122
|
+
const taskId = this.fingerprintToTaskId.get(fingerprint);
|
|
123
|
+
if (!taskId)
|
|
124
|
+
return undefined;
|
|
125
|
+
return this.tasks.get(taskId);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* List all active (non-complete, non-failed) tasks.
|
|
129
|
+
*/
|
|
130
|
+
listActiveTasks() {
|
|
131
|
+
return Array.from(this.tasks.values()).filter((t) => t.phase !== 'complete' && t.phase !== 'failed');
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* List all tasks (including recently completed/failed).
|
|
135
|
+
*/
|
|
136
|
+
listAllTasks() {
|
|
137
|
+
return Array.from(this.tasks.values());
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Check if a task is active (in progress).
|
|
141
|
+
*/
|
|
142
|
+
isTaskActive(fingerprint) {
|
|
143
|
+
const task = this.getTaskByFingerprint(fingerprint);
|
|
144
|
+
return task !== undefined && task.phase !== 'complete' && task.phase !== 'failed';
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Remove a task (e.g., when lock times out).
|
|
148
|
+
*/
|
|
149
|
+
removeTask(taskId) {
|
|
150
|
+
const task = this.tasks.get(taskId);
|
|
151
|
+
if (task) {
|
|
152
|
+
this.tasks.delete(taskId);
|
|
153
|
+
if (this.fingerprintToTaskId.get(task.fingerprint) === taskId) {
|
|
154
|
+
this.fingerprintToTaskId.delete(task.fingerprint);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
emitProgress(task) {
|
|
159
|
+
if (this.progressCallback) {
|
|
160
|
+
this.progressCallback(task);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Singleton instance for global access
|
|
165
|
+
let globalTracker;
|
|
166
|
+
export function getProgressTracker() {
|
|
167
|
+
if (!globalTracker) {
|
|
168
|
+
globalTracker = new ProgressTracker();
|
|
169
|
+
}
|
|
170
|
+
return globalTracker;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Helper to format bytes for display.
|
|
174
|
+
*/
|
|
175
|
+
export function formatBytes(bytes) {
|
|
176
|
+
if (bytes < 1024)
|
|
177
|
+
return `${bytes}B`;
|
|
178
|
+
if (bytes < 1024 * 1024)
|
|
179
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
180
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
181
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
182
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)}GB`;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Helper to calculate percentage.
|
|
186
|
+
*/
|
|
187
|
+
export function calcPercent(current, total) {
|
|
188
|
+
if (total <= 0)
|
|
189
|
+
return 0;
|
|
190
|
+
return Math.min(100, Math.round((current / total) * 100));
|
|
191
|
+
}
|