knodin 0.12.2 → 0.13.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 +16 -1
- package/dist/bin/cli.js +173 -14
- package/dist/src/agent-events.js +25 -7
- package/dist/src/authenticated-cursor.js +81 -0
- package/dist/src/class-consumer-contract.js +18 -0
- package/dist/src/class-consumer-cursor.js +91 -0
- package/dist/src/class-consumer-delivery.js +22 -0
- package/dist/src/class-consumer-page.js +148 -0
- package/dist/src/cli-model.js +9 -3
- package/dist/src/docs-sections.js +1 -0
- package/dist/src/engine/apex-class-uses.js +430 -0
- package/dist/src/engine/apex-entry-points.js +98 -0
- package/dist/src/engine/apex-receiver.js +301 -0
- package/dist/src/engine/embedding-reuse.js +57 -0
- package/dist/src/engine/embeddings.js +22 -0
- package/dist/src/engine/index-coverage.js +215 -0
- package/dist/src/engine/index.js +2495 -252
- package/dist/src/engine/salesforce-components.js +460 -0
- package/dist/src/engine/seal.js +3 -0
- package/dist/src/engine/sqlite.js +44 -0
- package/dist/src/evidence-bundle.js +283 -0
- package/dist/src/evidence-graph.js +163 -0
- package/dist/src/failure-diagnosis.js +80 -5
- package/dist/src/file-dependency.js +35 -0
- package/dist/src/graph-query-health.js +47 -1
- package/dist/src/implementation-search.js +69 -0
- package/dist/src/index-coverage-read.js +33 -0
- package/dist/src/investigation.js +195 -0
- package/dist/src/mcp-reliability.js +4 -0
- package/dist/src/mcp-worker-supervisor.js +122 -6
- package/dist/src/progressive-evidence.js +4 -4
- package/dist/src/response-budget.js +129 -3
- package/dist/src/server.js +18 -5
- package/dist/src/shared-index/publisher.js +41 -1
- package/dist/src/tools/knodin-tools.js +224 -41
- package/docs/CLI.md +92 -0
- package/docs/MCP.md +67 -0
- package/docs/PROGRESSIVE-EVIDENCE.md +62 -0
- package/docs/SALESFORCE-BINDINGS.md +121 -0
- package/docs/SALESFORCE-DEAD-CODE.md +45 -0
- package/docs/SCOPED-INDEXING.md +76 -0
- package/docs/apex-receiver-resolution.md +41 -0
- package/docs/releases/0.13.0.md +62 -0
- package/docs/structural-only-indexing.md +20 -0
- package/package.json +11 -5
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { indexCoverageCore, indexCoverageNotice, indexCoverageSnapshot, } from "./index-coverage-read.js";
|
|
1
2
|
function hasNonReconciliableDamage(graph) {
|
|
2
3
|
if (Object.values(graph.orphaned).some((count) => count > 0))
|
|
3
4
|
return true;
|
|
@@ -17,6 +18,14 @@ function unavailableState(graph) {
|
|
|
17
18
|
return "indexing";
|
|
18
19
|
if (graph.status === "indexing")
|
|
19
20
|
return "indexing";
|
|
21
|
+
if (graph.indexCoverage?.origin === "invalid")
|
|
22
|
+
return "repair-needed";
|
|
23
|
+
if (graph.coverage.sourceFiles === 0 &&
|
|
24
|
+
graph.indexCoverage &&
|
|
25
|
+
(graph.indexCoverage.intentState !== "complete" || graph.indexCoverage.mode === "unknown"))
|
|
26
|
+
return "unknown";
|
|
27
|
+
if (graph.coverage.sourceFiles === 0 && graph.indexCoverage?.mode === "scoped")
|
|
28
|
+
return "empty-scope";
|
|
20
29
|
if (graph.schemaVersion === 0)
|
|
21
30
|
return graph.coverage.sourceFiles === 0 ? "empty-repository" : "not-initialized";
|
|
22
31
|
if (graph.coverage.sourceFiles === 0)
|
|
@@ -44,6 +53,7 @@ export async function inspectGraphQueryHealth(repo, status) {
|
|
|
44
53
|
status: "unavailable",
|
|
45
54
|
state,
|
|
46
55
|
graph,
|
|
56
|
+
...(graph.indexCoverage ? { indexCoverage: graph.indexCoverage } : {}),
|
|
47
57
|
remediation: graph.repairSteps,
|
|
48
58
|
};
|
|
49
59
|
}
|
|
@@ -72,9 +82,40 @@ function hasNoMatches(value) {
|
|
|
72
82
|
return false;
|
|
73
83
|
}
|
|
74
84
|
/** Add an explicit healthy/no-match outcome without replacing operation data. */
|
|
75
|
-
export function decorateGraphQueryResult(value, graphState = "healthy", freshness) {
|
|
85
|
+
export function decorateGraphQueryResult(value, graphState = "healthy", freshness, ...coverageObservations) {
|
|
86
|
+
const [indexCoverage, initialCoverage] = coverageObservations;
|
|
87
|
+
const ownCoverage = value && typeof value === "object" && !Array.isArray(value)
|
|
88
|
+
? value.indexCoverage
|
|
89
|
+
: undefined;
|
|
90
|
+
const readCoverage = ownCoverage ?? initialCoverage;
|
|
91
|
+
// Omitted observations on legacy callers are not the same as an explicit
|
|
92
|
+
// post-read observation that lost coverage metadata. A result's own scope
|
|
93
|
+
// belongs to its read lease and supersedes an older preflight observation.
|
|
94
|
+
if (coverageObservations.length >= 1 &&
|
|
95
|
+
(ownCoverage !== undefined || coverageObservations.length >= 2) &&
|
|
96
|
+
indexCoverageSnapshot(readCoverage) !== indexCoverageSnapshot(indexCoverage))
|
|
97
|
+
return {
|
|
98
|
+
available: false,
|
|
99
|
+
status: "unavailable",
|
|
100
|
+
state: "changed-during-read",
|
|
101
|
+
indexCoverage,
|
|
102
|
+
indexCoverageAtRead: indexCoverageCore(readCoverage),
|
|
103
|
+
remediation: [
|
|
104
|
+
"Index coverage changed during the read; results were withheld. Retry against the current coverage.",
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
const selectedCoverage = ownCoverage ?? indexCoverage;
|
|
108
|
+
const coverageFields = selectedCoverage
|
|
109
|
+
? {
|
|
110
|
+
indexCoverage: selectedCoverage,
|
|
111
|
+
...(indexCoverageNotice(selectedCoverage)
|
|
112
|
+
? { coverageNotice: indexCoverageNotice(selectedCoverage) }
|
|
113
|
+
: {}),
|
|
114
|
+
}
|
|
115
|
+
: {};
|
|
76
116
|
if (value === null || value === undefined) {
|
|
77
117
|
return {
|
|
118
|
+
...coverageFields,
|
|
78
119
|
status: "no-match",
|
|
79
120
|
result: value ?? null,
|
|
80
121
|
...(freshness ? { freshness } : {}),
|
|
@@ -83,6 +124,7 @@ export function decorateGraphQueryResult(value, graphState = "healthy", freshnes
|
|
|
83
124
|
}
|
|
84
125
|
if (Array.isArray(value)) {
|
|
85
126
|
return {
|
|
127
|
+
...coverageFields,
|
|
86
128
|
status: value.length === 0 ? "no-match" : "ok",
|
|
87
129
|
results: value,
|
|
88
130
|
...(freshness ? { freshness } : {}),
|
|
@@ -95,12 +137,15 @@ export function decorateGraphQueryResult(value, graphState = "healthy", freshnes
|
|
|
95
137
|
}
|
|
96
138
|
if (typeof value !== "object") {
|
|
97
139
|
return {
|
|
140
|
+
...coverageFields,
|
|
98
141
|
result: value,
|
|
99
142
|
...(freshness ? { freshness } : {}),
|
|
100
143
|
availability: { state: graphState, outcome: "complete" },
|
|
101
144
|
};
|
|
102
145
|
}
|
|
103
146
|
const root = value;
|
|
147
|
+
if (root.available === false || root.status === "unavailable")
|
|
148
|
+
return { ...root, ...coverageFields };
|
|
104
149
|
// An unresolved target is not a no-match: nothing was searched for. Calling
|
|
105
150
|
// it `no-match` is what let a misspelled symbol read as proof that nothing
|
|
106
151
|
// calls it (KNODIN-28). Ambiguity is exempted for the same reason.
|
|
@@ -109,6 +154,7 @@ export function decorateGraphQueryResult(value, graphState = "healthy", freshnes
|
|
|
109
154
|
const state = notFound ? "target-not-found" : noMatch ? "no-match" : graphState;
|
|
110
155
|
return {
|
|
111
156
|
...root,
|
|
157
|
+
...coverageFields,
|
|
112
158
|
...(freshness ? { freshness } : {}),
|
|
113
159
|
...(notFound && root.status === undefined ? { status: "target-not-found" } : {}),
|
|
114
160
|
...(noMatch && root.status === undefined ? { status: "no-match" } : {}),
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** An explicit discovery lens, never the default for general symbol search. */
|
|
2
|
+
export const IMPLEMENTATION_KINDS = ["function", "method", "class", "constructor"];
|
|
3
|
+
/** Rank possible implementations without choosing one or turning relevance into proof. */
|
|
4
|
+
export async function findImplementationCandidates(engine, repo, request) {
|
|
5
|
+
const task = request.task.trim();
|
|
6
|
+
if (!task)
|
|
7
|
+
throw new Error("Implementation discovery requires a task");
|
|
8
|
+
const limit = request.limit ?? 10;
|
|
9
|
+
const offset = request.offset ?? 0;
|
|
10
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 50)
|
|
11
|
+
throw new Error("Implementation discovery limit must be an integer from 1 to 50");
|
|
12
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
13
|
+
throw new Error("Implementation discovery offset must be a nonnegative integer");
|
|
14
|
+
const page = await engine.search(task, repo, limit, {
|
|
15
|
+
kinds: [...IMPLEMENTATION_KINDS],
|
|
16
|
+
federate: false,
|
|
17
|
+
includeSource: false,
|
|
18
|
+
offset,
|
|
19
|
+
});
|
|
20
|
+
const unresolved = [];
|
|
21
|
+
if (page.semanticReadiness !== "ready")
|
|
22
|
+
unresolved.push(`Semantic evidence is ${page.semanticReadiness}; implementations may be missing.`);
|
|
23
|
+
if (page.results.some((row) => row.staleness !== "fresh" && row.staleness !== "reconciled"))
|
|
24
|
+
unresolved.push("Some candidate evidence is stale or has unknown freshness.");
|
|
25
|
+
return {
|
|
26
|
+
mode: "implementation-discovery",
|
|
27
|
+
repoPath: repo,
|
|
28
|
+
task,
|
|
29
|
+
status: unresolved.length ? "partial" : page.results.length ? "candidates" : "no-candidates",
|
|
30
|
+
candidates: page.results.map((row) => ({
|
|
31
|
+
identity: row.identity,
|
|
32
|
+
symbol: row.symbol,
|
|
33
|
+
file: row.filePath,
|
|
34
|
+
kind: row.kind,
|
|
35
|
+
rankScore: row.rrfScore,
|
|
36
|
+
staleness: row.staleness,
|
|
37
|
+
followup: {
|
|
38
|
+
operation: "context",
|
|
39
|
+
repoPath: repo,
|
|
40
|
+
task,
|
|
41
|
+
symbol: row.symbol,
|
|
42
|
+
identity: row.identity,
|
|
43
|
+
file: row.filePath,
|
|
44
|
+
},
|
|
45
|
+
})),
|
|
46
|
+
offset: page.offset,
|
|
47
|
+
limit: page.limit,
|
|
48
|
+
hasMore: page.hasMore,
|
|
49
|
+
continuation: page.hasMore
|
|
50
|
+
? {
|
|
51
|
+
operation: "context",
|
|
52
|
+
contextMode: "implementation",
|
|
53
|
+
repoPath: repo,
|
|
54
|
+
task,
|
|
55
|
+
offset: page.offset + page.results.length,
|
|
56
|
+
limit,
|
|
57
|
+
}
|
|
58
|
+
: null,
|
|
59
|
+
retrieval: page.retrieval,
|
|
60
|
+
semanticReadiness: page.semanticReadiness,
|
|
61
|
+
unresolved,
|
|
62
|
+
bounds: {
|
|
63
|
+
includedKinds: [...IMPLEMENTATION_KINDS],
|
|
64
|
+
localRepositoryOnly: true,
|
|
65
|
+
automaticSelection: false,
|
|
66
|
+
note: "Ranked candidates require inspection. Types, interfaces, constants and other kinds are intentionally excluded; use ordinary search to find them. No completeness or implementation-correctness claim.",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Bounded, semantic coverage state. Previews are presentation, not snapshot identity. */
|
|
2
|
+
export function indexCoverageCore(coverage) {
|
|
3
|
+
if (!coverage)
|
|
4
|
+
return undefined;
|
|
5
|
+
const { version, mode, revision, digest, origin, prefixCount, fileCount, intentState, repositoryComplete, negativeScope, } = coverage;
|
|
6
|
+
return {
|
|
7
|
+
version,
|
|
8
|
+
mode,
|
|
9
|
+
revision,
|
|
10
|
+
digest,
|
|
11
|
+
origin,
|
|
12
|
+
prefixCount,
|
|
13
|
+
fileCount,
|
|
14
|
+
intentState,
|
|
15
|
+
repositoryComplete,
|
|
16
|
+
negativeScope,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function indexCoverageSnapshot(coverage) {
|
|
20
|
+
return JSON.stringify(indexCoverageCore(coverage) ?? null);
|
|
21
|
+
}
|
|
22
|
+
/** Inventory coverage cannot certify complete static relationships or runtime nonuse. */
|
|
23
|
+
export function indexCoverageNotice(coverage) {
|
|
24
|
+
if (!coverage)
|
|
25
|
+
return undefined;
|
|
26
|
+
if (coverage.intentState !== "complete" || coverage.negativeScope === "unknown")
|
|
27
|
+
return "Index coverage completeness is unverified, including within any declared scope; a negative graph answer does not establish absence.";
|
|
28
|
+
if (coverage.mode === "scoped")
|
|
29
|
+
return "Indexed graph evidence is limited to the declared index scope; repository-wide completeness is not established. This does not describe separate source, language-server or federated coverage.";
|
|
30
|
+
if (coverage.mode === "unknown" || !coverage.repositoryComplete)
|
|
31
|
+
return "Index coverage completeness is unverified; a negative answer does not establish repository-wide absence.";
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { compareBytes } from "./compare.js";
|
|
2
|
+
import { indexCoverageCore, indexCoverageNotice, indexCoverageSnapshot, } from "./index-coverage-read.js";
|
|
3
|
+
function snapshot(state) {
|
|
4
|
+
return JSON.stringify([
|
|
5
|
+
state.indexGeneration,
|
|
6
|
+
state.freshness?.currentHead,
|
|
7
|
+
state.freshness?.indexedHead,
|
|
8
|
+
state.freshness?.workingTree?.indexedFingerprint,
|
|
9
|
+
indexCoverageSnapshot(state.indexCoverage),
|
|
10
|
+
]);
|
|
11
|
+
}
|
|
12
|
+
/** A bounded investigation over existing facts; never an assertion of runtime completeness. */
|
|
13
|
+
export async function buildTaskInvestigation(engine, repo, request) {
|
|
14
|
+
const { symbol, task } = request;
|
|
15
|
+
if (!symbol.trim() || !task.trim())
|
|
16
|
+
throw new Error("An investigation requires task and symbol");
|
|
17
|
+
const limit = request.limit ?? 20;
|
|
18
|
+
const depth = request.depth ?? 3;
|
|
19
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
20
|
+
throw new Error("Investigation limit must be an integer from 1 to 100");
|
|
21
|
+
if (!Number.isInteger(depth) || depth < 1 || depth > 6)
|
|
22
|
+
throw new Error("Investigation depth must be an integer from 1 to 6");
|
|
23
|
+
const selector = {
|
|
24
|
+
identity: request.identity,
|
|
25
|
+
file: request.file,
|
|
26
|
+
kind: request.kind,
|
|
27
|
+
};
|
|
28
|
+
// Establish the read lease before snapshotting: a cold disk graph can
|
|
29
|
+
// reconcile on its first query without any concurrent source mutation.
|
|
30
|
+
await engine.query("file_summary", request.file ?? "", repo, undefined, 1);
|
|
31
|
+
const before = await engine.status(repo);
|
|
32
|
+
const beforeSnapshot = snapshot(before);
|
|
33
|
+
const coverageAtRead = indexCoverageSnapshot(before.indexCoverage);
|
|
34
|
+
const explanation = await engine.explain(symbol, repo, "minimal", selector);
|
|
35
|
+
// Older adapters may omit per-operation coverage. An explicit observation
|
|
36
|
+
// must agree with the captured lease, even if status later changes back.
|
|
37
|
+
const explanationCoverageChanged = explanation.indexCoverage !== undefined &&
|
|
38
|
+
indexCoverageSnapshot(explanation.indexCoverage) !== coverageAtRead;
|
|
39
|
+
const base = {
|
|
40
|
+
mode: "investigation",
|
|
41
|
+
repoPath: repo,
|
|
42
|
+
task,
|
|
43
|
+
symbol: explanation.symbol,
|
|
44
|
+
...(before.indexCoverage ? { indexCoverage: indexCoverageCore(before.indexCoverage) } : {}),
|
|
45
|
+
...(indexCoverageNotice(before.indexCoverage)
|
|
46
|
+
? { coverageNotice: indexCoverageNotice(before.indexCoverage) }
|
|
47
|
+
: {}),
|
|
48
|
+
};
|
|
49
|
+
const changed = (after) => ({
|
|
50
|
+
...base,
|
|
51
|
+
available: false,
|
|
52
|
+
indexCoverage: indexCoverageCore(after.indexCoverage),
|
|
53
|
+
coverageNotice: indexCoverageNotice(after.indexCoverage),
|
|
54
|
+
status: "changed-during-investigation",
|
|
55
|
+
targetResolution: "unverified",
|
|
56
|
+
unresolved: [
|
|
57
|
+
"Graph generation or index coverage changed during investigation; mixed evidence was withheld. Retry against the current source and scope.",
|
|
58
|
+
],
|
|
59
|
+
continuation: { operation: "context", repoPath: repo, task, symbol, ...selector, limit, depth },
|
|
60
|
+
});
|
|
61
|
+
if (explanation.ambiguity || !explanation.identity || explanationCoverageChanged) {
|
|
62
|
+
const after = await engine.status(repo);
|
|
63
|
+
if (explanationCoverageChanged || beforeSnapshot !== snapshot(after))
|
|
64
|
+
return changed(after);
|
|
65
|
+
}
|
|
66
|
+
if (explanation.ambiguity)
|
|
67
|
+
return {
|
|
68
|
+
...base,
|
|
69
|
+
status: "ambiguous",
|
|
70
|
+
ambiguity: explanation.ambiguity,
|
|
71
|
+
next: "Select one identity or definition file before investigating dependencies.",
|
|
72
|
+
};
|
|
73
|
+
if (!explanation.identity)
|
|
74
|
+
return {
|
|
75
|
+
...base,
|
|
76
|
+
status: "not-found",
|
|
77
|
+
targetResolution: "not-found",
|
|
78
|
+
next: "Locate the definition with search, then supply its symbol and file or identity.",
|
|
79
|
+
};
|
|
80
|
+
const resolved = { ...selector, identity: explanation.identity };
|
|
81
|
+
const query = (pattern, target = explanation.symbol, selected = resolved) => engine.query(pattern, target, repo, undefined, limit, depth, "minimal", selected, pattern === "impact" ? { direction: "upstream", includeTests: true } : undefined);
|
|
82
|
+
const [sites, callers, impact, tests] = await Promise.all([
|
|
83
|
+
query("rename_preview"),
|
|
84
|
+
query("callers_of"),
|
|
85
|
+
query("impact"),
|
|
86
|
+
query("tests_for"),
|
|
87
|
+
]);
|
|
88
|
+
const definition = sites.results.find((row) => row.kind === "definition" && row.identity === explanation.identity);
|
|
89
|
+
const importers = definition?.file ? await query("importers_of", definition.file, {}) : undefined;
|
|
90
|
+
const after = await engine.status(repo);
|
|
91
|
+
const observationCoverageChanged = [sites, callers, impact, tests, importers].some((result) => result?.indexCoverage !== undefined &&
|
|
92
|
+
indexCoverageSnapshot(result.indexCoverage) !== coverageAtRead);
|
|
93
|
+
if (observationCoverageChanged || beforeSnapshot !== snapshot(after))
|
|
94
|
+
return changed(after);
|
|
95
|
+
const unresolved = [];
|
|
96
|
+
const coverageNotice = indexCoverageNotice(after.indexCoverage);
|
|
97
|
+
if (coverageNotice)
|
|
98
|
+
unresolved.push(coverageNotice);
|
|
99
|
+
if (after.freshness?.state !== "fresh")
|
|
100
|
+
unresolved.push(`Graph freshness is ${after.freshness?.state ?? "unknown"}.`);
|
|
101
|
+
if (!definition)
|
|
102
|
+
unresolved.push("Definition location was not returned within the site limit; file importers were not checked.");
|
|
103
|
+
if (explanation.sourceAvailability && explanation.sourceAvailability.state !== "available")
|
|
104
|
+
unresolved.push(`Source availability: ${explanation.sourceAvailability.state}`);
|
|
105
|
+
if (explanation.omissionCount)
|
|
106
|
+
unresolved.push(`${explanation.omissionCount} static-analysis omissions reported for the target; inspect explain for details.`);
|
|
107
|
+
const observations = [
|
|
108
|
+
["sites", sites],
|
|
109
|
+
["callers", callers],
|
|
110
|
+
["impact", impact],
|
|
111
|
+
["tests", tests],
|
|
112
|
+
["importers", importers],
|
|
113
|
+
];
|
|
114
|
+
for (const [name, result] of observations) {
|
|
115
|
+
if (!result)
|
|
116
|
+
continue;
|
|
117
|
+
if (result.staleness !== "fresh")
|
|
118
|
+
unresolved.push(`${name} evidence freshness is ${result.staleness ?? "unknown"}.`);
|
|
119
|
+
if (result.hasMore || result.count > result.results.length)
|
|
120
|
+
unresolved.push(`${name} results exceed the item limit; replay with a larger limit or inspect that relationship separately.`);
|
|
121
|
+
if (result.available === false || result.ambiguity || result.targetResolution === "not-found")
|
|
122
|
+
unresolved.push(`${name} could not establish the requested evidence.`);
|
|
123
|
+
}
|
|
124
|
+
if (explanation.staleness !== "fresh")
|
|
125
|
+
unresolved.push(`Target evidence freshness is ${explanation.staleness ?? "unknown"}.`);
|
|
126
|
+
const section = (result) => result && {
|
|
127
|
+
count: result.count,
|
|
128
|
+
returned: result.results.length,
|
|
129
|
+
truncated: result.hasMore === true || result.count > result.results.length,
|
|
130
|
+
results: result.results,
|
|
131
|
+
staleness: result.staleness,
|
|
132
|
+
continuation: limit < 100
|
|
133
|
+
? {
|
|
134
|
+
operation: "context",
|
|
135
|
+
repoPath: repo,
|
|
136
|
+
task,
|
|
137
|
+
symbol: explanation.symbol,
|
|
138
|
+
...resolved,
|
|
139
|
+
limit: Math.min(limit * 2, 100),
|
|
140
|
+
depth,
|
|
141
|
+
}
|
|
142
|
+
: null,
|
|
143
|
+
next: limit < 100
|
|
144
|
+
? "Replay the investigation with a larger per-query limit; this is not an offset page."
|
|
145
|
+
: `Investigation limit reached. Inspect ${result.pattern} separately with an explicit larger budget; no completeness claim is made.`,
|
|
146
|
+
};
|
|
147
|
+
const source = explanation.source ?? "";
|
|
148
|
+
const sourceLines = source.split("\n");
|
|
149
|
+
let selectedSource = "";
|
|
150
|
+
for (const line of sourceLines) {
|
|
151
|
+
const next = selectedSource ? `${selectedSource}\n${line}` : line;
|
|
152
|
+
if (Buffer.byteLength(next, "utf8") > 4096)
|
|
153
|
+
break;
|
|
154
|
+
selectedSource = next;
|
|
155
|
+
}
|
|
156
|
+
const sourceTruncated = selectedSource !== source;
|
|
157
|
+
if (sourceTruncated)
|
|
158
|
+
unresolved.push("Target source exceeds the investigation excerpt; use explain with source detail.");
|
|
159
|
+
return {
|
|
160
|
+
...base,
|
|
161
|
+
status: unresolved.length ? "partial" : "evidence-ready",
|
|
162
|
+
targetResolution: "resolved",
|
|
163
|
+
target: {
|
|
164
|
+
identity: explanation.identity,
|
|
165
|
+
file: definition?.file,
|
|
166
|
+
line: definition?.line,
|
|
167
|
+
summary: explanation.summary,
|
|
168
|
+
source: selectedSource,
|
|
169
|
+
sourceTruncated,
|
|
170
|
+
continuation: {
|
|
171
|
+
operation: "explain",
|
|
172
|
+
repoPath: repo,
|
|
173
|
+
symbol: explanation.symbol,
|
|
174
|
+
...resolved,
|
|
175
|
+
includeSource: true,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
sites: section(sites),
|
|
179
|
+
callers: section(callers),
|
|
180
|
+
impact: section(impact),
|
|
181
|
+
tests: section(tests),
|
|
182
|
+
importers: section(importers),
|
|
183
|
+
filesToInspect: [
|
|
184
|
+
...new Set(observations.flatMap(([, result]) => result?.results.flatMap((row) => (row.file ? [row.file] : [])) ?? [])),
|
|
185
|
+
].sort(compareBytes),
|
|
186
|
+
unresolved,
|
|
187
|
+
bounds: {
|
|
188
|
+
depth,
|
|
189
|
+
itemsPerQuery: limit,
|
|
190
|
+
staticAnalysis: true,
|
|
191
|
+
runtimeCompleteness: false,
|
|
192
|
+
note: "Returned sites and tests are evidence to inspect, not a complete edit plan or proof of test coverage.",
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -103,6 +103,10 @@ export function classifyMcpFailure(error) {
|
|
|
103
103
|
return { kind: "client-disconnected", code: "KNODIN_CLIENT_DISCONNECTED" };
|
|
104
104
|
if (code === "KNODIN_DEADLINE_EXCEEDED")
|
|
105
105
|
return { kind: "deadline-exceeded", code };
|
|
106
|
+
// Admission failed before dispatch, so preserve this code for safe retry even
|
|
107
|
+
// for operations that would otherwise mutate repository state.
|
|
108
|
+
if (code === "KNODIN_WORKER_CAPACITY")
|
|
109
|
+
return { kind: "worker-capacity", code };
|
|
106
110
|
if (code === "SQLITE_BUSY" ||
|
|
107
111
|
code === "SQLITE_LOCKED" ||
|
|
108
112
|
/database is locked|graph lock/i.test(message))
|
|
@@ -21,16 +21,21 @@ function defaultWorkerCommand() {
|
|
|
21
21
|
export class RepositoryWorker extends EventEmitter {
|
|
22
22
|
repoPath;
|
|
23
23
|
workerCommand;
|
|
24
|
+
admission;
|
|
24
25
|
child;
|
|
25
26
|
ready;
|
|
26
27
|
pending = new Map();
|
|
27
28
|
tail = Promise.resolve();
|
|
28
29
|
restartTimes = [];
|
|
29
30
|
lastFailureTraceId;
|
|
30
|
-
|
|
31
|
+
requests = 0;
|
|
32
|
+
closed = false;
|
|
33
|
+
closing;
|
|
34
|
+
constructor(repoPath, workerCommand = defaultWorkerCommand(), admission = Promise.resolve()) {
|
|
31
35
|
super();
|
|
32
36
|
this.repoPath = repoPath;
|
|
33
37
|
this.workerCommand = workerCommand;
|
|
38
|
+
this.admission = admission;
|
|
34
39
|
}
|
|
35
40
|
start() {
|
|
36
41
|
if (this.child && this.ready)
|
|
@@ -54,6 +59,8 @@ export class RepositoryWorker extends EventEmitter {
|
|
|
54
59
|
// exactly the amount raised, which surfaces as the next request
|
|
55
60
|
// burning its whole deadline in startup rather than as slowness here.
|
|
56
61
|
const startup = setTimeout(() => child.kill("SIGKILL"), 10_000);
|
|
62
|
+
child.once("exit", () => clearTimeout(startup));
|
|
63
|
+
child.once("error", () => clearTimeout(startup));
|
|
57
64
|
if (!child.stdout)
|
|
58
65
|
throw codedError("KNODIN_WORKER_CRASHED", "graph worker stdout unavailable");
|
|
59
66
|
const lines = readline.createInterface({ input: child.stdout });
|
|
@@ -167,8 +174,47 @@ export class RepositoryWorker extends EventEmitter {
|
|
|
167
174
|
}, CANCEL_GRACE_MS);
|
|
168
175
|
}
|
|
169
176
|
async execute(context, args, signal, onProgress) {
|
|
177
|
+
if (this.closed)
|
|
178
|
+
throw codedError("KNODIN_WORKER_CLOSED", "graph worker was retired; acquire it again");
|
|
179
|
+
this.requests++;
|
|
180
|
+
this.emit("busy");
|
|
181
|
+
const deadlineAt = Date.now() + operationDeadlineMs(context.operation);
|
|
182
|
+
try {
|
|
183
|
+
let admissionTimer;
|
|
184
|
+
let admissionAbort;
|
|
185
|
+
try {
|
|
186
|
+
await Promise.race([
|
|
187
|
+
this.admission,
|
|
188
|
+
new Promise((_resolve, reject) => {
|
|
189
|
+
admissionTimer = setTimeout(() => reject(codedError("KNODIN_DEADLINE_EXCEEDED", "request expired while waiting for worker admission")), Math.max(1, deadlineAt - Date.now()));
|
|
190
|
+
admissionAbort = () => reject(codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected before worker admission"));
|
|
191
|
+
signal.addEventListener("abort", admissionAbort, { once: true });
|
|
192
|
+
if (signal.aborted)
|
|
193
|
+
admissionAbort();
|
|
194
|
+
}),
|
|
195
|
+
]);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
if (admissionTimer)
|
|
199
|
+
clearTimeout(admissionTimer);
|
|
200
|
+
if (admissionAbort)
|
|
201
|
+
signal.removeEventListener("abort", admissionAbort);
|
|
202
|
+
}
|
|
203
|
+
if (signal.aborted)
|
|
204
|
+
throw codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected before worker admission");
|
|
205
|
+
return await this.executeRequest(context, args, signal, onProgress, deadlineAt);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
this.requests--;
|
|
209
|
+
if (!this.requests)
|
|
210
|
+
this.emit("idle");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
get busy() {
|
|
214
|
+
return this.requests > 0;
|
|
215
|
+
}
|
|
216
|
+
async executeRequest(context, args, signal, onProgress, deadlineAt = Date.now() + operationDeadlineMs(context.operation)) {
|
|
170
217
|
const deadlineMs = operationDeadlineMs(context.operation);
|
|
171
|
-
const deadlineAt = Date.now() + deadlineMs;
|
|
172
218
|
const predecessor = this.tail;
|
|
173
219
|
let release;
|
|
174
220
|
this.tail = new Promise((resolve) => {
|
|
@@ -181,7 +227,7 @@ export class RepositoryWorker extends EventEmitter {
|
|
|
181
227
|
await Promise.race([
|
|
182
228
|
predecessor,
|
|
183
229
|
new Promise((_resolve, reject) => {
|
|
184
|
-
queueTimer = setTimeout(() => reject(codedError("KNODIN_DEADLINE_EXCEEDED", `${context.operation} exceeded its ${deadlineMs}ms deadline while queued`)),
|
|
230
|
+
queueTimer = setTimeout(() => reject(codedError("KNODIN_DEADLINE_EXCEEDED", `${context.operation} exceeded its ${deadlineMs}ms deadline while queued`)), Math.max(1, deadlineAt - Date.now()));
|
|
185
231
|
queueAbort = () => reject(codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected while queued"));
|
|
186
232
|
signal.addEventListener("abort", queueAbort, { once: true });
|
|
187
233
|
}),
|
|
@@ -200,6 +246,10 @@ export class RepositoryWorker extends EventEmitter {
|
|
|
200
246
|
}
|
|
201
247
|
const { requestId, operation } = context;
|
|
202
248
|
try {
|
|
249
|
+
if (this.closed)
|
|
250
|
+
throw codedError("KNODIN_WORKER_CLOSED", "graph worker is closed");
|
|
251
|
+
if (Date.now() >= deadlineAt)
|
|
252
|
+
throw codedError("KNODIN_DEADLINE_EXCEEDED", "request expired before worker dispatch");
|
|
203
253
|
// Same race as the startup one below, one phase earlier: the queueAbort
|
|
204
254
|
// arm above reports CLIENT_DISCONNECTED, and this re-check reported
|
|
205
255
|
// CANCELLED for the identical event whenever the queue wait resolved in
|
|
@@ -267,6 +317,8 @@ export class RepositoryWorker extends EventEmitter {
|
|
|
267
317
|
if (signal.aborted)
|
|
268
318
|
throw codedError("KNODIN_CLIENT_DISCONNECTED", "MCP client disconnected during worker startup");
|
|
269
319
|
const predecessorTraceId = this.lastFailureTraceId;
|
|
320
|
+
if (this.closed)
|
|
321
|
+
throw codedError("KNODIN_WORKER_CLOSED", "graph worker is closed");
|
|
270
322
|
if (predecessorTraceId)
|
|
271
323
|
recordMcpLifecycle(context, "restart", { predecessorTraceId });
|
|
272
324
|
const result = await new Promise((resolve, reject) => {
|
|
@@ -317,6 +369,12 @@ export class RepositoryWorker extends EventEmitter {
|
|
|
317
369
|
this.child?.kill("SIGKILL");
|
|
318
370
|
}
|
|
319
371
|
async close() {
|
|
372
|
+
this.closed = true;
|
|
373
|
+
this.closing ??= this.closeChild();
|
|
374
|
+
return this.closing;
|
|
375
|
+
}
|
|
376
|
+
async closeChild() {
|
|
377
|
+
await this.admission;
|
|
320
378
|
const child = this.child;
|
|
321
379
|
if (child?.exitCode !== null)
|
|
322
380
|
return;
|
|
@@ -348,12 +406,23 @@ export class RepositoryWorker extends EventEmitter {
|
|
|
348
406
|
}
|
|
349
407
|
}
|
|
350
408
|
export class WorkerSupervisor {
|
|
409
|
+
options;
|
|
351
410
|
workers = new Map();
|
|
411
|
+
idleTimers = new Map();
|
|
412
|
+
retiring = new Set();
|
|
413
|
+
closed = false;
|
|
414
|
+
maxWorkers;
|
|
415
|
+
idleTimeoutMs;
|
|
352
416
|
exitHandler = () => void this.close();
|
|
353
|
-
constructor() {
|
|
417
|
+
constructor(options = {}) {
|
|
418
|
+
this.options = options;
|
|
419
|
+
this.maxWorkers = workerLimit(options.maxWorkers ?? Number(process.env.KNODIN_MCP_MAX_WORKERS ?? 4), "maxWorkers");
|
|
420
|
+
this.idleTimeoutMs = workerLimit(options.idleTimeoutMs ?? Number(process.env.KNODIN_MCP_IDLE_TIMEOUT_MS ?? 300_000), "idleTimeoutMs");
|
|
354
421
|
process.once("exit", this.exitHandler);
|
|
355
422
|
}
|
|
356
423
|
worker(repoPath) {
|
|
424
|
+
if (this.closed)
|
|
425
|
+
throw codedError("KNODIN_WORKER_CLOSED", "worker supervisor is closed");
|
|
357
426
|
const resolved = fs.realpathSync(path.resolve(repoPath));
|
|
358
427
|
let key = resolved;
|
|
359
428
|
try {
|
|
@@ -368,14 +437,61 @@ export class WorkerSupervisor {
|
|
|
368
437
|
}
|
|
369
438
|
let worker = this.workers.get(key);
|
|
370
439
|
if (!worker) {
|
|
371
|
-
|
|
440
|
+
if (this.workers.size >= this.maxWorkers) {
|
|
441
|
+
const victim = [...this.workers].find(([, candidate]) => !candidate.busy);
|
|
442
|
+
if (!victim)
|
|
443
|
+
throw codedError("KNODIN_WORKER_CAPACITY", `all ${this.maxWorkers} repository workers are busy; retry after an active request completes`);
|
|
444
|
+
this.retire(victim[0], victim[1]);
|
|
445
|
+
}
|
|
446
|
+
worker = new RepositoryWorker(key, this.options.workerCommand, Promise.all([...this.retiring]).then(() => undefined));
|
|
372
447
|
this.workers.set(key, worker);
|
|
448
|
+
const instance = worker;
|
|
449
|
+
worker.on("busy", () => this.clearIdleTimer(key));
|
|
450
|
+
worker.on("idle", () => this.touch(key, instance));
|
|
373
451
|
}
|
|
452
|
+
this.touch(key, worker);
|
|
374
453
|
return worker;
|
|
375
454
|
}
|
|
455
|
+
clearIdleTimer(key) {
|
|
456
|
+
clearTimeout(this.idleTimers.get(key));
|
|
457
|
+
this.idleTimers.delete(key);
|
|
458
|
+
}
|
|
459
|
+
touch(key, worker) {
|
|
460
|
+
if (this.closed || this.workers.get(key) !== worker)
|
|
461
|
+
return;
|
|
462
|
+
this.clearIdleTimer(key);
|
|
463
|
+
this.workers.delete(key);
|
|
464
|
+
this.workers.set(key, worker);
|
|
465
|
+
if (!worker.busy) {
|
|
466
|
+
const timer = setTimeout(() => {
|
|
467
|
+
if (!worker.busy && this.workers.get(key) === worker)
|
|
468
|
+
this.retire(key, worker);
|
|
469
|
+
}, this.idleTimeoutMs);
|
|
470
|
+
timer.unref();
|
|
471
|
+
this.idleTimers.set(key, timer);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
retire(key, worker) {
|
|
475
|
+
this.clearIdleTimer(key);
|
|
476
|
+
this.workers.delete(key);
|
|
477
|
+
const closing = worker.close();
|
|
478
|
+
this.retiring.add(closing);
|
|
479
|
+
void closing.finally(() => this.retiring.delete(closing));
|
|
480
|
+
}
|
|
376
481
|
async close() {
|
|
377
|
-
|
|
482
|
+
this.closed = true;
|
|
483
|
+
for (const key of this.idleTimers.keys())
|
|
484
|
+
this.clearIdleTimer(key);
|
|
485
|
+
await Promise.all([
|
|
486
|
+
...this.retiring,
|
|
487
|
+
...[...this.workers.values()].map((worker) => worker.close()),
|
|
488
|
+
]);
|
|
378
489
|
this.workers.clear();
|
|
379
490
|
process.off("exit", this.exitHandler);
|
|
380
491
|
}
|
|
381
492
|
}
|
|
493
|
+
function workerLimit(value, name) {
|
|
494
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 2_147_483_647)
|
|
495
|
+
throw new RangeError(`${name} must be a positive integer at most 2147483647`);
|
|
496
|
+
return value;
|
|
497
|
+
}
|
|
@@ -11,7 +11,7 @@ const MIN_PROGRESSIVE_BYTES = 1_600;
|
|
|
11
11
|
function sha256(value) {
|
|
12
12
|
return createHash("sha256").update(value).digest("hex");
|
|
13
13
|
}
|
|
14
|
-
function
|
|
14
|
+
export function normalizeEvidenceFile(repo, file) {
|
|
15
15
|
if (!file || path.isAbsolute(file))
|
|
16
16
|
throw new Error("file must be a repository-relative path");
|
|
17
17
|
const root = fs.realpathSync(repo);
|
|
@@ -38,7 +38,7 @@ function normalizeFile(repo, file) {
|
|
|
38
38
|
throw new Error("progressive evidence requires a regular repository file");
|
|
39
39
|
return { absolute, relative, root };
|
|
40
40
|
}
|
|
41
|
-
function
|
|
41
|
+
export function evidenceHandleSecret(root) {
|
|
42
42
|
const stateDirectory = resolveStateDir(root);
|
|
43
43
|
if (fs.existsSync(stateDirectory) && fs.lstatSync(stateDirectory).isSymbolicLink())
|
|
44
44
|
throw new Error("progressive evidence refuses a symlinked .knodin directory");
|
|
@@ -201,8 +201,8 @@ function omissionPath(request, handle, currentHash, contentBytes, relative) {
|
|
|
201
201
|
export function deliverProgressiveEvidence(request) {
|
|
202
202
|
if (!LEVELS.has(request.level))
|
|
203
203
|
throw new Error("invalid progressive evidence level");
|
|
204
|
-
const { absolute, relative, root } =
|
|
205
|
-
const handleKey =
|
|
204
|
+
const { absolute, relative, root } = normalizeEvidenceFile(request.repo, request.file);
|
|
205
|
+
const handleKey = evidenceHandleSecret(root);
|
|
206
206
|
const fileStat = fs.statSync(absolute);
|
|
207
207
|
const keyStat = fs.statSync(handleKey.path);
|
|
208
208
|
if (fileStat.dev === keyStat.dev && fileStat.ino === keyStat.ino)
|