@danypops/lector 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/LICENSE +21 -0
- package/README.md +57 -0
- package/package.json +48 -0
- package/src/adapters/directory-size.ts +19 -0
- package/src/adapters/find-source-files.ts +35 -0
- package/src/adapters/git-repo-fetcher.ts +146 -0
- package/src/adapters/in-memory-content-cache.ts +19 -0
- package/src/adapters/in-memory-search-cache.ts +32 -0
- package/src/adapters/in-memory-symbol-graph.ts +80 -0
- package/src/adapters/in-memory-workspace.ts +28 -0
- package/src/adapters/local-filesystem-workspace.ts +96 -0
- package/src/adapters/local-git.ts +73 -0
- package/src/adapters/lsp/discover-seed-file.ts +130 -0
- package/src/adapters/lsp/json-rpc-stream.ts +67 -0
- package/src/adapters/lsp/language-server-process.ts +184 -0
- package/src/adapters/lsp/lsp-symbol-index.ts +470 -0
- package/src/adapters/lsp/process-resource-usage.ts +61 -0
- package/src/adapters/lsp/typescript-project-files.ts +51 -0
- package/src/adapters/read-only-workspace.ts +23 -0
- package/src/adapters/ripgrep-text-search.ts +97 -0
- package/src/adapters/source-manifest.ts +44 -0
- package/src/adapters/sqlite-content-cache.ts +67 -0
- package/src/adapters/sqlite-search-cache.ts +60 -0
- package/src/adapters/sqlite-symbol-graph.ts +154 -0
- package/src/adapters/tiered-search-cache.ts +29 -0
- package/src/adapters/tree-sitter/typescript-tree-sitter-symbol-index.ts +155 -0
- package/src/cli.ts +777 -0
- package/src/client.ts +63 -0
- package/src/constants.ts +16 -0
- package/src/daemon.ts +166 -0
- package/src/domain/assert-safe-git-argument.ts +19 -0
- package/src/domain/assert-safe-path-segment.ts +17 -0
- package/src/domain/assert-safe-repo-reference.ts +20 -0
- package/src/domain/assert-safe-search-query.ts +17 -0
- package/src/domain/bounded-job-executor.ts +219 -0
- package/src/domain/call-hierarchy.ts +23 -0
- package/src/domain/code-range.ts +11 -0
- package/src/domain/content-hash.ts +14 -0
- package/src/domain/diagnostic.ts +12 -0
- package/src/domain/diagnostics.ts +7 -0
- package/src/domain/document-symbol.ts +19 -0
- package/src/domain/document-symbols.ts +7 -0
- package/src/domain/exact-edit.ts +43 -0
- package/src/domain/find-references.ts +7 -0
- package/src/domain/find-workspace-symbols.ts +7 -0
- package/src/domain/git-diff-result.ts +5 -0
- package/src/domain/git-log-entry.ts +9 -0
- package/src/domain/git-status.ts +17 -0
- package/src/domain/go-to-definition.ts +7 -0
- package/src/domain/go-to-implementation.ts +7 -0
- package/src/domain/hover-at.ts +8 -0
- package/src/domain/hover.ts +7 -0
- package/src/domain/incoming-calls.ts +8 -0
- package/src/domain/language-server-descriptor.ts +122 -0
- package/src/domain/outgoing-calls.ts +8 -0
- package/src/domain/populate-symbol-graph.ts +91 -0
- package/src/domain/prepare-call-hierarchy.ts +8 -0
- package/src/domain/race-workspace-query.ts +39 -0
- package/src/domain/raw-read.ts +24 -0
- package/src/domain/reachable-symbols-from.ts +13 -0
- package/src/domain/repo-fetch-result.ts +18 -0
- package/src/domain/repo-reference.ts +7 -0
- package/src/domain/search-cache-key.ts +16 -0
- package/src/domain/search-text.ts +29 -0
- package/src/domain/symbol-edges-from.ts +9 -0
- package/src/domain/symbol-edges-to.ts +9 -0
- package/src/domain/symbol-graph-generation.ts +14 -0
- package/src/domain/symbol-node-id.ts +13 -0
- package/src/domain/text-search-result.ts +15 -0
- package/src/domain/workspace-query-outcome.ts +24 -0
- package/src/domain/workspace-symbol.ts +14 -0
- package/src/index.ts +121 -0
- package/src/ports/code-intelligence-port.ts +38 -0
- package/src/ports/content-cache-port.ts +52 -0
- package/src/ports/git-port.ts +23 -0
- package/src/ports/repo-fetcher-port.ts +11 -0
- package/src/ports/search-cache-port.ts +15 -0
- package/src/ports/symbol-graph-port.ts +35 -0
- package/src/ports/symbol-index-port.ts +11 -0
- package/src/ports/text-search-port.ts +12 -0
- package/src/ports/workspace-port.ts +35 -0
- package/src/service.ts +961 -0
- package/src/version.ts +6 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { InMemorySearchCache } from "./adapters/in-memory-search-cache.ts";
|
|
5
|
+
import { InMemorySymbolGraph } from "./adapters/in-memory-symbol-graph.ts";
|
|
6
|
+
import { LocalFilesystemWorkspace } from "./adapters/local-filesystem-workspace.ts";
|
|
7
|
+
import { LocalGit } from "./adapters/local-git.ts";
|
|
8
|
+
import { discoverWorkspaceDescriptor } from "./adapters/lsp/discover-seed-file.ts";
|
|
9
|
+
import { LspSymbolIndex } from "./adapters/lsp/lsp-symbol-index.ts";
|
|
10
|
+
import { ReadOnlyWorkspace } from "./adapters/read-only-workspace.ts";
|
|
11
|
+
import { RipgrepTextSearch } from "./adapters/ripgrep-text-search.ts";
|
|
12
|
+
import { deriveSourceManifest } from "./adapters/source-manifest.ts";
|
|
13
|
+
import { BoundedJobExecutor, type JobSnapshot } from "./domain/bounded-job-executor.ts";
|
|
14
|
+
import type { CallHierarchyEntry, IncomingCall, OutgoingCall } from "./domain/call-hierarchy.ts";
|
|
15
|
+
import type { Diagnostic } from "./domain/diagnostic.ts";
|
|
16
|
+
import { diagnostics as diagnosticsQuery } from "./domain/diagnostics.ts";
|
|
17
|
+
import type { DocumentSymbolEntry } from "./domain/document-symbol.ts";
|
|
18
|
+
import { documentSymbols as documentSymbolsQuery } from "./domain/document-symbols.ts";
|
|
19
|
+
import { type EditOutcome, type ExpectedHashEdit, exactEdit, StaleExpectedHash } from "./domain/exact-edit.ts";
|
|
20
|
+
import { findReferences as findReferencesQuery } from "./domain/find-references.ts";
|
|
21
|
+
import { findWorkspaceSymbols } from "./domain/find-workspace-symbols.ts";
|
|
22
|
+
import type { GitDiffResult } from "./domain/git-diff-result.ts";
|
|
23
|
+
import type { GitLogEntry } from "./domain/git-log-entry.ts";
|
|
24
|
+
import type { GitStatusSummary } from "./domain/git-status.ts";
|
|
25
|
+
import { goToDefinition as goToDefinitionQuery } from "./domain/go-to-definition.ts";
|
|
26
|
+
import { goToImplementation as goToImplementationQuery } from "./domain/go-to-implementation.ts";
|
|
27
|
+
import type { Hover } from "./domain/hover.ts";
|
|
28
|
+
import { hoverAt } from "./domain/hover-at.ts";
|
|
29
|
+
import { incomingCalls as incomingCallsQuery } from "./domain/incoming-calls.ts";
|
|
30
|
+
import { descriptorForPath, LANGUAGE_SERVER_DESCRIPTORS, type LanguageServerDescriptor } from "./domain/language-server-descriptor.ts";
|
|
31
|
+
import { outgoingCalls as outgoingCallsQuery } from "./domain/outgoing-calls.ts";
|
|
32
|
+
import { type PopulateSymbolGraphResult, populateSymbolGraph as populateSymbolGraphQuery } from "./domain/populate-symbol-graph.ts";
|
|
33
|
+
import { prepareCallHierarchy as prepareCallHierarchyQuery } from "./domain/prepare-call-hierarchy.ts";
|
|
34
|
+
import { raceWorkspaceQuery } from "./domain/race-workspace-query.ts";
|
|
35
|
+
import { type RawRead, rawRead, WorkspaceEntryNotFound } from "./domain/raw-read.ts";
|
|
36
|
+
import { reachableSymbolsFrom } from "./domain/reachable-symbols-from.ts";
|
|
37
|
+
import type { RepoFetchResult } from "./domain/repo-fetch-result.ts";
|
|
38
|
+
import type { RepoReference } from "./domain/repo-reference.ts";
|
|
39
|
+
import { searchText as searchTextQuery } from "./domain/search-text.ts";
|
|
40
|
+
import { symbolEdgesFrom } from "./domain/symbol-edges-from.ts";
|
|
41
|
+
import { symbolEdgesTo } from "./domain/symbol-edges-to.ts";
|
|
42
|
+
import type { WorkspaceCacheStatus } from "./domain/symbol-graph-generation.ts";
|
|
43
|
+
import { deriveSymbolNodeId } from "./domain/symbol-node-id.ts";
|
|
44
|
+
import type { TextSearchResult } from "./domain/text-search-result.ts";
|
|
45
|
+
import type { WorkspaceQueryOutcome } from "./domain/workspace-query-outcome.ts";
|
|
46
|
+
import type { WorkspaceLocation, WorkspaceSymbol } from "./domain/workspace-symbol.ts";
|
|
47
|
+
import type { CodeIntelligencePort } from "./ports/code-intelligence-port.ts";
|
|
48
|
+
import type { GitPort } from "./ports/git-port.ts";
|
|
49
|
+
import type { RepoFetcherPort } from "./ports/repo-fetcher-port.ts";
|
|
50
|
+
import type { SearchCachePort } from "./ports/search-cache-port.ts";
|
|
51
|
+
import type { SymbolEdgeKind, SymbolGraphPort, SymbolNode } from "./ports/symbol-graph-port.ts";
|
|
52
|
+
import type { SymbolIndexPort } from "./ports/symbol-index-port.ts";
|
|
53
|
+
import type { TextSearchPort } from "./ports/text-search-port.ts";
|
|
54
|
+
import type { WorkspacePort } from "./ports/workspace-port.ts";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Identifies which registered workspace an operation targets. There is no
|
|
58
|
+
* default/implicit workspace: an operation must always name one explicitly.
|
|
59
|
+
* (Locus LCS-BUG-97/LCS-BUG-88 class -- an operation given no explicit
|
|
60
|
+
* target must never fall back to "whatever was registered/used last".)
|
|
61
|
+
*/
|
|
62
|
+
export type WorkspaceId = string;
|
|
63
|
+
|
|
64
|
+
/** Raised when an operation names a workspaceId nothing was registered under. */
|
|
65
|
+
export class UnknownWorkspace extends Error {
|
|
66
|
+
constructor(readonly workspaceId: WorkspaceId) {
|
|
67
|
+
super(`no workspace registered under id "${workspaceId}"`);
|
|
68
|
+
this.name = "UnknownWorkspace";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Raised when workspace.registerPath is given a path that isn't a real, accessible directory. */
|
|
73
|
+
export class InvalidWorkspaceRoot extends Error {
|
|
74
|
+
constructor(
|
|
75
|
+
readonly path: string,
|
|
76
|
+
reason: string,
|
|
77
|
+
) {
|
|
78
|
+
super(`cannot register "${path}" as a workspace root: ${reason}`);
|
|
79
|
+
this.name = "InvalidWorkspaceRoot";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Raised when a symbol query targets a workspace with no known root path (not registered via workspace.registerPath). */
|
|
84
|
+
export class SymbolQueryUnavailable extends Error {
|
|
85
|
+
constructor(readonly workspaceId: WorkspaceId) {
|
|
86
|
+
super(`workspace "${workspaceId}" has no known root path; symbol queries require a workspace registered via workspace.registerPath`);
|
|
87
|
+
this.name = "SymbolQueryUnavailable";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Raised when a git operation targets a workspace whose root is not inside a git repository -- a real, expected case, not every registered workspace is one. */
|
|
92
|
+
export class NotAGitRepository extends Error {
|
|
93
|
+
constructor(readonly workspaceId: WorkspaceId) {
|
|
94
|
+
super(`workspace "${workspaceId}" is not inside a git repository`);
|
|
95
|
+
this.name = "NotAGitRepository";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Raised when a file's extension (or, with no path/seedFile at all, the whole workspace) matches none of Lector's known LanguageServerDescriptors. */
|
|
100
|
+
export class UnsupportedLanguage extends Error {
|
|
101
|
+
constructor(readonly hint: string) {
|
|
102
|
+
super(`no supported language server for "${hint}" -- known extensions: ${LANGUAGE_SERVER_DESCRIPTORS.flatMap((d) => d.extensions).join(", ")}`);
|
|
103
|
+
this.name = "UnsupportedLanguage";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Raised when a Tier A code-intelligence operation (goToDefinition, findReferences,
|
|
109
|
+
* hover, documentSymbols) targets a workspace whose warm index is not backed by a
|
|
110
|
+
* real language server -- e.g. a test override using the tree-sitter backend, which
|
|
111
|
+
* has no type system and cannot honestly resolve cross-file references or types.
|
|
112
|
+
* An honest failure, not a silent empty result or a crash.
|
|
113
|
+
*/
|
|
114
|
+
export class CodeIntelligenceUnavailable extends Error {
|
|
115
|
+
constructor(readonly workspaceId: WorkspaceId) {
|
|
116
|
+
super(
|
|
117
|
+
`workspace "${workspaceId}"'s symbol index does not support code-intelligence queries (definition/references/hover/documentSymbols/diagnostics/callHierarchy) -- only findSymbols`,
|
|
118
|
+
);
|
|
119
|
+
this.name = "CodeIntelligenceUnavailable";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Raised when repo.fetch is called on a service constructed without a createRepoFetcher option -- fetching an external repo needs a real disk location a host must explicitly provide, unlike e.g. createSymbolGraph's safe in-memory default. */
|
|
124
|
+
export class RepoFetcherNotConfigured extends Error {
|
|
125
|
+
constructor() {
|
|
126
|
+
super("repo.fetch requires a service constructed with options.createRepoFetcher");
|
|
127
|
+
this.name = "RepoFetcherNotConfigured";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class UnsupportedJobOperation extends Error {
|
|
132
|
+
constructor(readonly operation: string) {
|
|
133
|
+
super(`operation "${operation}" cannot run as a background job; supported operations: workspace.populateSymbolGraph`);
|
|
134
|
+
this.name = "UnsupportedJobOperation";
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export class InvalidJobInput extends Error {
|
|
139
|
+
constructor(reason: string) {
|
|
140
|
+
super(`invalid background job input: ${reason}`);
|
|
141
|
+
this.name = "InvalidJobInput";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class WorkspaceChangedDuringPopulation extends Error {
|
|
146
|
+
constructor(readonly workspaceId: WorkspaceId) {
|
|
147
|
+
super(
|
|
148
|
+
`workspace "${workspaceId}" changed while its symbol graph was being populated; no cached generation was recorded -- retry against a stable source tree`,
|
|
149
|
+
);
|
|
150
|
+
this.name = "WorkspaceChangedDuringPopulation";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export class JobWaitTooLong extends Error {
|
|
155
|
+
constructor(
|
|
156
|
+
readonly waitMs: number,
|
|
157
|
+
readonly maxWaitMs: number,
|
|
158
|
+
) {
|
|
159
|
+
super(`background job initial wait ${waitMs}ms exceeds the ${maxWaitMs}ms service bound; submit with a shorter wait and poll job.status`);
|
|
160
|
+
this.name = "JobWaitTooLong";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export type OperationName =
|
|
165
|
+
| "workspace.rawRead"
|
|
166
|
+
| "workspace.exactEdit"
|
|
167
|
+
| "workspace.registerPath"
|
|
168
|
+
| "workspace.findSymbols"
|
|
169
|
+
| "workspace.goToDefinition"
|
|
170
|
+
| "workspace.goToImplementation"
|
|
171
|
+
| "workspace.findReferences"
|
|
172
|
+
| "workspace.hover"
|
|
173
|
+
| "workspace.documentSymbols"
|
|
174
|
+
| "workspace.diagnostics"
|
|
175
|
+
| "workspace.prepareCallHierarchy"
|
|
176
|
+
| "workspace.incomingCalls"
|
|
177
|
+
| "workspace.outgoingCalls"
|
|
178
|
+
| "workspace.populateSymbolGraph"
|
|
179
|
+
| "workspace.reachableFrom"
|
|
180
|
+
| "workspace.symbolEdgesFrom"
|
|
181
|
+
| "workspace.symbolEdgesTo"
|
|
182
|
+
| "workspace.hasWarmIndex"
|
|
183
|
+
| "workspace.cacheStatus"
|
|
184
|
+
| "workspace.gitStatus"
|
|
185
|
+
| "workspace.gitLog"
|
|
186
|
+
| "workspace.gitDiff"
|
|
187
|
+
| "repo.fetch"
|
|
188
|
+
| "workspace.searchText"
|
|
189
|
+
| "search.symbols"
|
|
190
|
+
| "search.text"
|
|
191
|
+
| "job.submit"
|
|
192
|
+
| "job.status";
|
|
193
|
+
|
|
194
|
+
export const OPERATION_NAMES: readonly OperationName[] = [
|
|
195
|
+
"workspace.rawRead",
|
|
196
|
+
"workspace.exactEdit",
|
|
197
|
+
"workspace.registerPath",
|
|
198
|
+
"workspace.findSymbols",
|
|
199
|
+
"workspace.goToDefinition",
|
|
200
|
+
"workspace.goToImplementation",
|
|
201
|
+
"workspace.findReferences",
|
|
202
|
+
"workspace.hover",
|
|
203
|
+
"workspace.documentSymbols",
|
|
204
|
+
"workspace.diagnostics",
|
|
205
|
+
"workspace.prepareCallHierarchy",
|
|
206
|
+
"workspace.incomingCalls",
|
|
207
|
+
"workspace.outgoingCalls",
|
|
208
|
+
"workspace.populateSymbolGraph",
|
|
209
|
+
"workspace.reachableFrom",
|
|
210
|
+
"workspace.symbolEdgesFrom",
|
|
211
|
+
"workspace.symbolEdgesTo",
|
|
212
|
+
"workspace.hasWarmIndex",
|
|
213
|
+
"workspace.cacheStatus",
|
|
214
|
+
"workspace.gitStatus",
|
|
215
|
+
"workspace.gitLog",
|
|
216
|
+
"workspace.gitDiff",
|
|
217
|
+
"repo.fetch",
|
|
218
|
+
"workspace.searchText",
|
|
219
|
+
"search.symbols",
|
|
220
|
+
"search.text",
|
|
221
|
+
"job.submit",
|
|
222
|
+
"job.status",
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
/** A single position within a file already registered under `workspaceId`, 1-indexed. */
|
|
226
|
+
interface WorkspacePosition {
|
|
227
|
+
workspaceId: WorkspaceId;
|
|
228
|
+
path: string;
|
|
229
|
+
line: number;
|
|
230
|
+
character: number;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface OperationInputs {
|
|
234
|
+
"workspace.rawRead": { workspaceId: WorkspaceId; path: string };
|
|
235
|
+
"workspace.exactEdit": { workspaceId: WorkspaceId } & ExpectedHashEdit;
|
|
236
|
+
"workspace.registerPath": { path: string };
|
|
237
|
+
"workspace.findSymbols": { workspaceId: WorkspaceId; query: string; seedFile?: string };
|
|
238
|
+
"workspace.goToDefinition": WorkspacePosition;
|
|
239
|
+
"workspace.goToImplementation": WorkspacePosition;
|
|
240
|
+
"workspace.findReferences": WorkspacePosition & { includeDeclaration: boolean };
|
|
241
|
+
"workspace.hover": WorkspacePosition;
|
|
242
|
+
"workspace.documentSymbols": { workspaceId: WorkspaceId; path: string };
|
|
243
|
+
"workspace.diagnostics": { workspaceId: WorkspaceId; path: string };
|
|
244
|
+
"workspace.prepareCallHierarchy": WorkspacePosition;
|
|
245
|
+
"workspace.incomingCalls": WorkspacePosition;
|
|
246
|
+
"workspace.outgoingCalls": WorkspacePosition;
|
|
247
|
+
"workspace.populateSymbolGraph": { workspaceId: WorkspaceId; maxFiles: number; maxSymbolsPerFile: number };
|
|
248
|
+
"workspace.reachableFrom": WorkspacePosition & { maxDepth: number; kind?: SymbolEdgeKind };
|
|
249
|
+
"workspace.symbolEdgesFrom": WorkspacePosition & { kind?: SymbolEdgeKind };
|
|
250
|
+
"workspace.symbolEdgesTo": WorkspacePosition & { kind?: SymbolEdgeKind };
|
|
251
|
+
"workspace.hasWarmIndex": { workspaceId: WorkspaceId; path?: string };
|
|
252
|
+
"workspace.cacheStatus": { workspaceId: WorkspaceId; maxFiles: number; maxSymbolsPerFile: number };
|
|
253
|
+
"workspace.gitStatus": { workspaceId: WorkspaceId };
|
|
254
|
+
"workspace.gitLog": { workspaceId: WorkspaceId; maxCount: number };
|
|
255
|
+
"workspace.gitDiff": { workspaceId: WorkspaceId; ref?: string; maxBytes: number };
|
|
256
|
+
"repo.fetch": RepoReference;
|
|
257
|
+
"workspace.searchText": { workspaceId: WorkspaceId; query: string; maxMatches: number; maxBytes: number };
|
|
258
|
+
/**
|
|
259
|
+
* No single workspaceId -- fans out across several at once, unlike every other findSymbols-
|
|
260
|
+
* shaped operation. `workspaceIds`, when given, restricts the fan-out to exactly those
|
|
261
|
+
* (each validated -- an unknown id is reported per-entry, not silently dropped). Omitted
|
|
262
|
+
* defaults to every currently-registered workspace -- found live, not assumed: this daemon is
|
|
263
|
+
* a shared, system-wide service, so "every registered workspace" can include projects an
|
|
264
|
+
* entirely different, concurrent Pi session registered, not just this caller's own. A caller
|
|
265
|
+
* that means "my own current projects" must say so explicitly.
|
|
266
|
+
*/
|
|
267
|
+
"search.symbols": { query: string; workspaceIds?: readonly WorkspaceId[]; timeoutMs?: number };
|
|
268
|
+
"search.text": { query: string; maxMatches: number; maxBytes: number; workspaceIds?: readonly WorkspaceId[]; timeoutMs?: number };
|
|
269
|
+
"job.submit": {
|
|
270
|
+
operation: "workspace.populateSymbolGraph";
|
|
271
|
+
input: { workspaceId: WorkspaceId; maxFiles: number; maxSymbolsPerFile: number };
|
|
272
|
+
/** Bounded wait before returning the current snapshot. Zero/omitted always returns immediately. */
|
|
273
|
+
waitMs?: number;
|
|
274
|
+
};
|
|
275
|
+
"job.status": { jobId: string };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface OperationOutputs {
|
|
279
|
+
"workspace.rawRead": RawRead;
|
|
280
|
+
"workspace.exactEdit": EditOutcome;
|
|
281
|
+
"workspace.registerPath": { workspaceId: WorkspaceId; created: boolean };
|
|
282
|
+
"workspace.findSymbols": { symbols: readonly WorkspaceSymbol[] };
|
|
283
|
+
"workspace.goToDefinition": { locations: readonly WorkspaceLocation[] };
|
|
284
|
+
"workspace.goToImplementation": { locations: readonly WorkspaceLocation[] };
|
|
285
|
+
"workspace.findReferences": { locations: readonly WorkspaceLocation[] };
|
|
286
|
+
"workspace.hover": { hover: Hover | undefined };
|
|
287
|
+
"workspace.documentSymbols": { symbols: readonly DocumentSymbolEntry[] };
|
|
288
|
+
"workspace.diagnostics": { diagnostics: readonly Diagnostic[] };
|
|
289
|
+
"workspace.prepareCallHierarchy": { items: readonly CallHierarchyEntry[] };
|
|
290
|
+
"workspace.incomingCalls": { calls: readonly IncomingCall[] };
|
|
291
|
+
"workspace.outgoingCalls": { calls: readonly OutgoingCall[] };
|
|
292
|
+
"workspace.populateSymbolGraph": { filesProcessed: number; symbolsProcessed: number; nodesAdded: number; edgesAdded: number };
|
|
293
|
+
"workspace.reachableFrom": { symbols: readonly SymbolNode[] };
|
|
294
|
+
"workspace.symbolEdgesFrom": { symbols: readonly SymbolNode[] };
|
|
295
|
+
"workspace.symbolEdgesTo": { symbols: readonly SymbolNode[] };
|
|
296
|
+
"workspace.hasWarmIndex": { warm: boolean };
|
|
297
|
+
"workspace.cacheStatus": WorkspaceCacheStatus;
|
|
298
|
+
"workspace.gitStatus": GitStatusSummary;
|
|
299
|
+
"workspace.gitLog": { entries: readonly GitLogEntry[] };
|
|
300
|
+
"workspace.gitDiff": GitDiffResult;
|
|
301
|
+
"repo.fetch": RepoFetchResult & { workspaceId: WorkspaceId };
|
|
302
|
+
"workspace.searchText": TextSearchResult;
|
|
303
|
+
"search.symbols": { results: readonly WorkspaceQueryOutcome<{ symbols: readonly WorkspaceSymbol[] }>[] };
|
|
304
|
+
"search.text": { results: readonly WorkspaceQueryOutcome<TextSearchResult>[] };
|
|
305
|
+
"job.submit": { job: JobSnapshot<PopulateSymbolGraphResult> };
|
|
306
|
+
"job.status": { job: JobSnapshot<PopulateSymbolGraphResult> };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Deterministically derive a workspaceId from a resolved absolute path, so the same
|
|
311
|
+
* directory always yields the same id -- across repeat calls AND across a daemon
|
|
312
|
+
* restart, since nothing about this derivation depends on runtime/in-memory state.
|
|
313
|
+
* A shorter digest than ContentHash's is deliberate: this identifies a workspace root
|
|
314
|
+
* for addressing/logging, not a content value needing full collision resistance.
|
|
315
|
+
*/
|
|
316
|
+
function deriveWorkspaceId(absolutePath: string): WorkspaceId {
|
|
317
|
+
return createHash("sha256").update(absolutePath).digest("hex").slice(0, 16);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export interface LectorService {
|
|
321
|
+
readonly operations: readonly OperationName[];
|
|
322
|
+
dispatch<Name extends OperationName>(operation: Name, input: OperationInputs[Name]): Promise<OperationOutputs[Name]>;
|
|
323
|
+
/** Stops every warm symbol-index subprocess this service spawned. Idempotent. */
|
|
324
|
+
close(): Promise<void>;
|
|
325
|
+
/**
|
|
326
|
+
* Closes and removes any warm symbol index (e.g. an LSP subprocess) not used within
|
|
327
|
+
* maxIdleMs. Returns the number reaped. Wired into the daemon's periodic maintenance --
|
|
328
|
+
* a long-lived, dynamic-workspace daemon that has touched many different projects over
|
|
329
|
+
* its uptime must not keep every one of their warm indexes alive forever (Oculus's own
|
|
330
|
+
* TTL-eviction lesson: idle LSP servers are a real, unbounded resource-growth risk, not
|
|
331
|
+
* a theoretical one).
|
|
332
|
+
*/
|
|
333
|
+
reapIdleSymbolIndexes(maxIdleMs: number): Promise<number>;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
interface RegisteredWorkspace {
|
|
337
|
+
readonly port: WorkspacePort;
|
|
338
|
+
/** Present only for workspaces registered via workspace.registerPath -- required for symbol queries. */
|
|
339
|
+
readonly rootPath?: string;
|
|
340
|
+
/** Local work always outranks disposable fetched-repo work in the bounded job queue. */
|
|
341
|
+
readonly origin: "local" | "remote";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
type MutableRegistry = Map<WorkspaceId, RegisteredWorkspace>;
|
|
345
|
+
|
|
346
|
+
/** A SymbolIndexPort the service can also shut down when it stops. */
|
|
347
|
+
export type ClosableSymbolIndex = SymbolIndexPort & { close(): Promise<void> };
|
|
348
|
+
|
|
349
|
+
export interface LectorServiceOptions {
|
|
350
|
+
/** Factory for the symbol index backing workspace.findSymbols and code intelligence, given the descriptor resolved for the call. Defaults to an LspSymbolIndex configured for whichever descriptor is passed. */
|
|
351
|
+
createSymbolIndex?: (rootPath: string, descriptor: LanguageServerDescriptor, seedFile?: string) => ClosableSymbolIndex;
|
|
352
|
+
/**
|
|
353
|
+
* Explicit opt-in to start with zero registered workspaces, relying entirely on
|
|
354
|
+
* workspace.registerPath at runtime -- the shape a long-lived background daemon that
|
|
355
|
+
* attaches to whatever project a host adapter (pi-lector) is used from actually needs,
|
|
356
|
+
* since workspace.registerPath already validates its own explicit absolute path (no
|
|
357
|
+
* implicit fallback reappears just because the registry started empty). Without this,
|
|
358
|
+
* zero workspaces at construction is still refused (Locus LCS-BUG-88 class): the default
|
|
359
|
+
* stays "fail loud on likely misconfiguration," and a caller must say what it actually
|
|
360
|
+
* intends rather than the guard being silently loosened for everyone.
|
|
361
|
+
*/
|
|
362
|
+
allowDynamicOnly?: boolean;
|
|
363
|
+
/** Factory for the graph backing workspace.populateSymbolGraph/reachableFrom/symbolEdgesFrom/symbolEdgesTo. Defaults to an in-memory graph (not durable across a restart). */
|
|
364
|
+
createSymbolGraph?: (workspaceId: WorkspaceId) => SymbolGraphPort;
|
|
365
|
+
/** Factory for the git port backing workspace.gitStatus/gitLog/gitDiff. Defaults to LocalGit, the real `git` CLI. Cheap to construct -- never cached, unlike symbol indexes. */
|
|
366
|
+
createGitPort?: (rootPath: string) => GitPort;
|
|
367
|
+
/** Factory for the port backing repo.fetch. No default -- unlike createSymbolGraph's safe in-memory fallback, fetching a real external repo always needs a real disk location only a host (daemon.ts) can supply. Called once at construction and reused, not per-call. */
|
|
368
|
+
createRepoFetcher?: () => RepoFetcherPort;
|
|
369
|
+
/** Factory for the port backing workspace.searchText. Defaults to RipgrepTextSearch -- cheap to construct, no disk dependency, safe like createGitPort's default. Called once at construction and reused. */
|
|
370
|
+
createTextSearch?: () => TextSearchPort;
|
|
371
|
+
/** Factory for workspace.searchText's result cache. Defaults to an in-memory-only InMemorySearchCache -- safe, no disk dependency. A host wanting the disk-backed tier too (surviving a restart) supplies a TieredSearchCache here. Called once at construction and reused. */
|
|
372
|
+
createSearchCache?: () => SearchCachePort;
|
|
373
|
+
/** Process-lifetime background executor. Tests inject deterministic ids and tighter bounds. */
|
|
374
|
+
createJobExecutor?: () => BoundedJobExecutor<PopulateSymbolGraphResult>;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function resolveWorkspace(registry: MutableRegistry, workspaceId: WorkspaceId): WorkspacePort {
|
|
378
|
+
const entry = registry.get(workspaceId);
|
|
379
|
+
if (!entry) throw new UnknownWorkspace(workspaceId);
|
|
380
|
+
return entry.port;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** True when a warm SymbolIndexPort is also a real CodeIntelligencePort (currently: any LspSymbolIndex, never TreeSitterSymbolIndex). */
|
|
384
|
+
function supportsCodeIntelligence(index: SymbolIndexPort): index is SymbolIndexPort & CodeIntelligencePort {
|
|
385
|
+
return typeof (index as Partial<CodeIntelligencePort>).goToDefinition === "function";
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
type OperationHandlers = {
|
|
389
|
+
[Name in OperationName]: (registry: MutableRegistry, input: OperationInputs[Name]) => Promise<OperationOutputs[Name]>;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
async function registerPath(registry: MutableRegistry, input: OperationInputs["workspace.registerPath"]): Promise<OperationOutputs["workspace.registerPath"]> {
|
|
393
|
+
const absolutePath = resolve(input.path);
|
|
394
|
+
const workspaceId = deriveWorkspaceId(absolutePath);
|
|
395
|
+
if (registry.has(workspaceId)) {
|
|
396
|
+
return { workspaceId, created: false };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
let stats: Awaited<ReturnType<typeof stat>>;
|
|
400
|
+
try {
|
|
401
|
+
stats = await stat(absolutePath);
|
|
402
|
+
} catch {
|
|
403
|
+
throw new InvalidWorkspaceRoot(absolutePath, "path does not exist or is not accessible");
|
|
404
|
+
}
|
|
405
|
+
if (!stats.isDirectory()) {
|
|
406
|
+
throw new InvalidWorkspaceRoot(absolutePath, "path is not a directory");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
registry.set(workspaceId, { port: new LocalFilesystemWorkspace(absolutePath), rootPath: absolutePath, origin: "local" });
|
|
410
|
+
return { workspaceId, created: true };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* How long search.symbols/search.text wait for one workspace's own query before reporting it as
|
|
415
|
+
* "loading" and moving on -- generous enough for typical cold-start (TypeScript/Python/Go/C++/
|
|
416
|
+
* Bash/YAML all settle well under 3s; only rust-analyzer's own measured ~2.5s worst case comes
|
|
417
|
+
* close), bounded so one slow workspace can't stall every other workspace's real results.
|
|
418
|
+
*/
|
|
419
|
+
const DEFAULT_CROSS_WORKSPACE_TIMEOUT_MS = 3000;
|
|
420
|
+
const MAX_INITIAL_JOB_WAIT_MS = 30_000;
|
|
421
|
+
const MAX_SOURCE_MANIFEST_BYTES = 50 * 1024 * 1024;
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Create the Lector service over an explicit initial registry of workspaces.
|
|
425
|
+
* Refuses to start with zero registered workspaces -- fails loudly at
|
|
426
|
+
* construction (before the daemon ever binds a listener) rather than
|
|
427
|
+
* starting and returning empty/error results per call later.
|
|
428
|
+
* (Locus LCS-BUG-88 class.) The registry grows at runtime only through
|
|
429
|
+
* workspace.registerPath; there is still no operation that guesses a target
|
|
430
|
+
* from anything other than an explicit id or an explicit path.
|
|
431
|
+
*/
|
|
432
|
+
export function createLectorService(workspaces: ReadonlyMap<WorkspaceId, WorkspacePort>, options: LectorServiceOptions = {}): LectorService {
|
|
433
|
+
if (workspaces.size === 0 && !options.allowDynamicOnly) {
|
|
434
|
+
throw new Error(
|
|
435
|
+
"Lector service requires at least one registered workspace; refusing to start with none " +
|
|
436
|
+
"(pass options.allowDynamicOnly if this daemon intentionally registers workspaces only via workspace.registerPath at runtime)",
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
const registry: MutableRegistry = new Map(Array.from(workspaces, ([id, port]) => [id, { port, origin: "local" as const }]));
|
|
440
|
+
let nextJobId = 0;
|
|
441
|
+
const jobs =
|
|
442
|
+
options.createJobExecutor?.() ??
|
|
443
|
+
new BoundedJobExecutor<PopulateSymbolGraphResult>({
|
|
444
|
+
maxConcurrent: 2,
|
|
445
|
+
maxQueued: 32,
|
|
446
|
+
maxRetained: 128,
|
|
447
|
+
retentionMs: 10 * 60 * 1000,
|
|
448
|
+
createId: () => `job-${Date.now().toString(36)}-${(++nextJobId).toString(36)}`,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// One warm symbol index per (workspace, language) actually queried, reused across calls --
|
|
452
|
+
// a fresh process per query would pay a fork+initialize cost every time. A polyglot
|
|
453
|
+
// workspace holds one warm index per language touched, never one guessed for the whole tree.
|
|
454
|
+
// lastUsedAt backs reapIdleSymbolIndexes -- an idle-eviction TTL, not just a warm cache.
|
|
455
|
+
const symbolIndexes = new Map<string, { index: ClosableSymbolIndex; workspaceId: WorkspaceId; lastUsedAt: number }>();
|
|
456
|
+
const createSymbolIndex =
|
|
457
|
+
options.createSymbolIndex ??
|
|
458
|
+
((rootPath: string, descriptor: LanguageServerDescriptor, seedFile?: string) => new LspSymbolIndex(rootPath, descriptor, seedFile));
|
|
459
|
+
function symbolIndexKey(workspaceId: WorkspaceId, languageId: string): string {
|
|
460
|
+
return `${workspaceId}:${languageId}`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// One symbol graph per workspace, populated only when workspace.populateSymbolGraph is
|
|
464
|
+
// actually invoked -- unlike symbolIndexes, there is no idle-eviction TTL here: a graph
|
|
465
|
+
// is inert data, not a warm subprocess with a real resource cost while sitting unused.
|
|
466
|
+
const symbolGraphs = new Map<WorkspaceId, SymbolGraphPort>();
|
|
467
|
+
const activePopulationJobByWorkspace = new Map<WorkspaceId, string>();
|
|
468
|
+
const createSymbolGraph = options.createSymbolGraph ?? (() => new InMemorySymbolGraph());
|
|
469
|
+
|
|
470
|
+
function ensureSymbolGraph(workspaceId: WorkspaceId): SymbolGraphPort {
|
|
471
|
+
let graph = symbolGraphs.get(workspaceId);
|
|
472
|
+
if (!graph) {
|
|
473
|
+
graph = createSymbolGraph(workspaceId);
|
|
474
|
+
symbolGraphs.set(workspaceId, graph);
|
|
475
|
+
}
|
|
476
|
+
return graph;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const createGitPort = options.createGitPort ?? ((rootPath: string) => new LocalGit(rootPath));
|
|
480
|
+
// Constructed once, not per-call -- reconstructing would rehydrate the same on-disk index
|
|
481
|
+
// every time, wastefully, and would risk losing the in-memory LRU's recency ordering
|
|
482
|
+
// between calls for no benefit (the index itself is what makes rehydration correct at all).
|
|
483
|
+
const repoFetcher = options.createRepoFetcher?.();
|
|
484
|
+
const textSearch = options.createTextSearch?.() ?? new RipgrepTextSearch();
|
|
485
|
+
const searchCache = options.createSearchCache?.() ?? new InMemorySearchCache();
|
|
486
|
+
|
|
487
|
+
/** Never cached: cheap to construct, and a stale-git-repo check would be wrong to memoize across a repo that could be git-init'd or removed mid-session. */
|
|
488
|
+
async function requireGitRepository(workspaceId: WorkspaceId): Promise<GitPort> {
|
|
489
|
+
const entry = registry.get(workspaceId);
|
|
490
|
+
if (!entry) throw new UnknownWorkspace(workspaceId);
|
|
491
|
+
if (!entry.rootPath) throw new SymbolQueryUnavailable(workspaceId);
|
|
492
|
+
const git = createGitPort(entry.rootPath);
|
|
493
|
+
if (!(await git.isGitRepository())) throw new NotAGitRepository(workspaceId);
|
|
494
|
+
return git;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function gitStatusHandler(_registry: MutableRegistry, input: OperationInputs["workspace.gitStatus"]): Promise<OperationOutputs["workspace.gitStatus"]> {
|
|
498
|
+
const git = await requireGitRepository(input.workspaceId);
|
|
499
|
+
return git.status();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function gitLogHandler(_registry: MutableRegistry, input: OperationInputs["workspace.gitLog"]): Promise<OperationOutputs["workspace.gitLog"]> {
|
|
503
|
+
const git = await requireGitRepository(input.workspaceId);
|
|
504
|
+
return { entries: await git.log(input.maxCount) };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function gitDiffHandler(_registry: MutableRegistry, input: OperationInputs["workspace.gitDiff"]): Promise<OperationOutputs["workspace.gitDiff"]> {
|
|
508
|
+
const git = await requireGitRepository(input.workspaceId);
|
|
509
|
+
return git.diff(input.ref, input.maxBytes);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Fetches (or reuses a cached clone of) an external repo and registers it read-only -- the same registry every other operation already reads from, so find_symbols/go_to_definition/git status etc. work on it unchanged once fetched. */
|
|
513
|
+
async function repoFetchHandler(registry: MutableRegistry, input: OperationInputs["repo.fetch"]): Promise<OperationOutputs["repo.fetch"]> {
|
|
514
|
+
if (!repoFetcher) throw new RepoFetcherNotConfigured();
|
|
515
|
+
const result = await repoFetcher.fetch(input);
|
|
516
|
+
const absolutePath = resolve(result.path);
|
|
517
|
+
const workspaceId = deriveWorkspaceId(absolutePath);
|
|
518
|
+
if (!registry.has(workspaceId)) {
|
|
519
|
+
registry.set(workspaceId, { port: new ReadOnlyWorkspace(new LocalFilesystemWorkspace(absolutePath)), rootPath: absolutePath, origin: "remote" });
|
|
520
|
+
}
|
|
521
|
+
return { workspaceId, ...result };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function searchTextHandler(
|
|
525
|
+
registry: MutableRegistry,
|
|
526
|
+
input: OperationInputs["workspace.searchText"],
|
|
527
|
+
): Promise<OperationOutputs["workspace.searchText"]> {
|
|
528
|
+
const entry = registry.get(input.workspaceId);
|
|
529
|
+
if (!entry) throw new UnknownWorkspace(input.workspaceId);
|
|
530
|
+
if (!entry.rootPath) throw new SymbolQueryUnavailable(input.workspaceId);
|
|
531
|
+
return searchTextQuery(textSearch, searchCache, entry.rootPath, input.workspaceId, input.query, { maxMatches: input.maxMatches, maxBytes: input.maxBytes });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Every workspace with a known root -- the same precondition workspace.findSymbols/searchText already require individually, applied to all of them at once. */
|
|
535
|
+
function registeredWorkspaceIds(registry: MutableRegistry): readonly WorkspaceId[] {
|
|
536
|
+
return Array.from(registry.entries())
|
|
537
|
+
.filter(([, entry]) => entry.rootPath !== undefined)
|
|
538
|
+
.map(([workspaceId]) => workspaceId);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* `explicitIds` given: validates each one (an unknown id or one with no root path becomes an
|
|
543
|
+
* immediate "error" outcome, not a silent drop) and searches exactly that set -- nothing more.
|
|
544
|
+
* `explicitIds` omitted: every registered workspace, daemon-wide. This is the one place that
|
|
545
|
+
* default is genuinely risky: found live, this daemon is a shared, system-wide service, and
|
|
546
|
+
* "every registered workspace" can include a project an entirely different, concurrent Pi
|
|
547
|
+
* session registered. A caller that means "just my own current projects" must pass
|
|
548
|
+
* explicitIds -- pi-lector's own tools always do.
|
|
549
|
+
*/
|
|
550
|
+
function resolveCrossWorkspaceTargets(
|
|
551
|
+
registry: MutableRegistry,
|
|
552
|
+
explicitIds: readonly WorkspaceId[] | undefined,
|
|
553
|
+
): { targets: readonly WorkspaceId[]; immediateErrors: readonly { workspaceId: WorkspaceId; status: "error"; message: string }[] } {
|
|
554
|
+
if (!explicitIds) return { targets: registeredWorkspaceIds(registry), immediateErrors: [] };
|
|
555
|
+
const targets: WorkspaceId[] = [];
|
|
556
|
+
const immediateErrors: { workspaceId: WorkspaceId; status: "error"; message: string }[] = [];
|
|
557
|
+
for (const workspaceId of explicitIds) {
|
|
558
|
+
const entry = registry.get(workspaceId);
|
|
559
|
+
if (!entry) {
|
|
560
|
+
immediateErrors.push({ workspaceId, status: "error", message: `no workspace registered under id "${workspaceId}"` });
|
|
561
|
+
} else if (!entry.rootPath) {
|
|
562
|
+
immediateErrors.push({
|
|
563
|
+
workspaceId,
|
|
564
|
+
status: "error",
|
|
565
|
+
message: `workspace "${workspaceId}" has no known root path -- cross-workspace search requires workspace.registerPath or repo.fetch`,
|
|
566
|
+
});
|
|
567
|
+
} else {
|
|
568
|
+
targets.push(workspaceId);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return { targets, immediateErrors };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function crossFindSymbols(registry: MutableRegistry, input: OperationInputs["search.symbols"]): Promise<OperationOutputs["search.symbols"]> {
|
|
575
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_CROSS_WORKSPACE_TIMEOUT_MS;
|
|
576
|
+
const { targets, immediateErrors } = resolveCrossWorkspaceTargets(registry, input.workspaceIds);
|
|
577
|
+
const results = await Promise.all(
|
|
578
|
+
targets.map((workspaceId) =>
|
|
579
|
+
raceWorkspaceQuery(
|
|
580
|
+
workspaceId,
|
|
581
|
+
() => findSymbols(registry, { workspaceId, query: input.query }),
|
|
582
|
+
timeoutMs,
|
|
583
|
+
"this workspace's symbol index is still warming up (a cold-starting language server) -- its data may exist once it finishes; retry shortly",
|
|
584
|
+
),
|
|
585
|
+
),
|
|
586
|
+
);
|
|
587
|
+
return { results: [...immediateErrors, ...results] };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function crossSearchText(registry: MutableRegistry, input: OperationInputs["search.text"]): Promise<OperationOutputs["search.text"]> {
|
|
591
|
+
const timeoutMs = input.timeoutMs ?? DEFAULT_CROSS_WORKSPACE_TIMEOUT_MS;
|
|
592
|
+
const { targets, immediateErrors } = resolveCrossWorkspaceTargets(registry, input.workspaceIds);
|
|
593
|
+
const results = await Promise.all(
|
|
594
|
+
targets.map((workspaceId) =>
|
|
595
|
+
raceWorkspaceQuery(
|
|
596
|
+
workspaceId,
|
|
597
|
+
() => searchTextHandler(registry, { workspaceId, query: input.query, maxMatches: input.maxMatches, maxBytes: input.maxBytes }),
|
|
598
|
+
timeoutMs,
|
|
599
|
+
"this workspace's search is still running -- its data may exist once it finishes; retry shortly",
|
|
600
|
+
),
|
|
601
|
+
),
|
|
602
|
+
);
|
|
603
|
+
return { results: [...immediateErrors, ...results] };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Resolves which descriptor a call targets: path/seedFile's own extension, per-file like every mainstream editor -- never a guess about "the workspace's language" -- except findSymbols with neither, which has no anchor file at all. */
|
|
607
|
+
function resolveDescriptor(
|
|
608
|
+
rootPath: string,
|
|
609
|
+
hint: { path?: string; seedFile?: string },
|
|
610
|
+
): { descriptor: LanguageServerDescriptor; seedFile: string | undefined } {
|
|
611
|
+
const pathHint = hint.path ?? hint.seedFile;
|
|
612
|
+
if (pathHint) {
|
|
613
|
+
const descriptor = descriptorForPath(pathHint);
|
|
614
|
+
if (!descriptor) throw new UnsupportedLanguage(pathHint);
|
|
615
|
+
return { descriptor, seedFile: hint.seedFile };
|
|
616
|
+
}
|
|
617
|
+
const discovered = discoverWorkspaceDescriptor(rootPath, LANGUAGE_SERVER_DESCRIPTORS);
|
|
618
|
+
if (!discovered) throw new UnsupportedLanguage(rootPath);
|
|
619
|
+
return { descriptor: discovered.descriptor, seedFile: discovered.seedFile };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function ensureWarmIndex(input: {
|
|
623
|
+
workspaceId: WorkspaceId;
|
|
624
|
+
path?: string;
|
|
625
|
+
seedFile?: string;
|
|
626
|
+
}): Promise<{ index: ClosableSymbolIndex; descriptor: LanguageServerDescriptor }> {
|
|
627
|
+
const entry = registry.get(input.workspaceId);
|
|
628
|
+
if (!entry) throw new UnknownWorkspace(input.workspaceId);
|
|
629
|
+
if (!entry.rootPath) throw new SymbolQueryUnavailable(input.workspaceId);
|
|
630
|
+
const rootPath = entry.rootPath;
|
|
631
|
+
|
|
632
|
+
const { descriptor, seedFile } = resolveDescriptor(rootPath, input);
|
|
633
|
+
const key = symbolIndexKey(input.workspaceId, descriptor.languageId);
|
|
634
|
+
let entryIndex = symbolIndexes.get(key);
|
|
635
|
+
if (!entryIndex) {
|
|
636
|
+
entryIndex = { index: createSymbolIndex(rootPath, descriptor, seedFile), workspaceId: input.workspaceId, lastUsedAt: Date.now() };
|
|
637
|
+
symbolIndexes.set(key, entryIndex);
|
|
638
|
+
} else {
|
|
639
|
+
entryIndex.lastUsedAt = Date.now();
|
|
640
|
+
}
|
|
641
|
+
return { index: entryIndex.index, descriptor };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Never spawns -- a caller deciding whether to enrich a result with LSP-backed info must not pay a cold-start cost just to check. With a path, checks that file's own language; without one, whether anything is warm for the workspace at all. */
|
|
645
|
+
async function hasWarmIndex(
|
|
646
|
+
registry: MutableRegistry,
|
|
647
|
+
input: OperationInputs["workspace.hasWarmIndex"],
|
|
648
|
+
): Promise<OperationOutputs["workspace.hasWarmIndex"]> {
|
|
649
|
+
const entry = registry.get(input.workspaceId);
|
|
650
|
+
if (!entry) throw new UnknownWorkspace(input.workspaceId);
|
|
651
|
+
if (input.path) {
|
|
652
|
+
const descriptor = descriptorForPath(input.path);
|
|
653
|
+
if (!descriptor) return { warm: false };
|
|
654
|
+
return { warm: symbolIndexes.has(symbolIndexKey(input.workspaceId, descriptor.languageId)) };
|
|
655
|
+
}
|
|
656
|
+
for (const value of symbolIndexes.values()) {
|
|
657
|
+
if (value.workspaceId === input.workspaceId) return { warm: true };
|
|
658
|
+
}
|
|
659
|
+
return { warm: false };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function findSymbols(_registry: MutableRegistry, input: OperationInputs["workspace.findSymbols"]): Promise<OperationOutputs["workspace.findSymbols"]> {
|
|
663
|
+
const { index } = await ensureWarmIndex(input);
|
|
664
|
+
const symbols = await findWorkspaceSymbols(index, input.query);
|
|
665
|
+
return { symbols };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function requireCodeIntelligence(input: {
|
|
669
|
+
workspaceId: WorkspaceId;
|
|
670
|
+
path?: string;
|
|
671
|
+
seedFile?: string;
|
|
672
|
+
}): Promise<{ index: SymbolIndexPort & CodeIntelligencePort; descriptor: LanguageServerDescriptor }> {
|
|
673
|
+
const { index, descriptor } = await ensureWarmIndex(input);
|
|
674
|
+
if (!supportsCodeIntelligence(index)) throw new CodeIntelligenceUnavailable(input.workspaceId);
|
|
675
|
+
return { index, descriptor };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function goToDefinition(
|
|
679
|
+
_registry: MutableRegistry,
|
|
680
|
+
input: OperationInputs["workspace.goToDefinition"],
|
|
681
|
+
): Promise<OperationOutputs["workspace.goToDefinition"]> {
|
|
682
|
+
const { index } = await requireCodeIntelligence(input);
|
|
683
|
+
const locations = await goToDefinitionQuery(index, { path: input.path, line: input.line, character: input.character });
|
|
684
|
+
return { locations };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function goToImplementation(
|
|
688
|
+
_registry: MutableRegistry,
|
|
689
|
+
input: OperationInputs["workspace.goToImplementation"],
|
|
690
|
+
): Promise<OperationOutputs["workspace.goToImplementation"]> {
|
|
691
|
+
const { index } = await requireCodeIntelligence(input);
|
|
692
|
+
const locations = await goToImplementationQuery(index, { path: input.path, line: input.line, character: input.character });
|
|
693
|
+
return { locations };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function findReferences(
|
|
697
|
+
_registry: MutableRegistry,
|
|
698
|
+
input: OperationInputs["workspace.findReferences"],
|
|
699
|
+
): Promise<OperationOutputs["workspace.findReferences"]> {
|
|
700
|
+
const { index } = await requireCodeIntelligence(input);
|
|
701
|
+
const locations = await findReferencesQuery(index, { path: input.path, line: input.line, character: input.character }, input.includeDeclaration);
|
|
702
|
+
return { locations };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function hover(_registry: MutableRegistry, input: OperationInputs["workspace.hover"]): Promise<OperationOutputs["workspace.hover"]> {
|
|
706
|
+
const { index } = await requireCodeIntelligence(input);
|
|
707
|
+
const hover = await hoverAt(index, { path: input.path, line: input.line, character: input.character });
|
|
708
|
+
return { hover };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async function documentSymbolsHandler(
|
|
712
|
+
_registry: MutableRegistry,
|
|
713
|
+
input: OperationInputs["workspace.documentSymbols"],
|
|
714
|
+
): Promise<OperationOutputs["workspace.documentSymbols"]> {
|
|
715
|
+
const { index } = await requireCodeIntelligence(input);
|
|
716
|
+
const symbols = await documentSymbolsQuery(index, input.path);
|
|
717
|
+
return { symbols };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async function diagnosticsHandler(
|
|
721
|
+
_registry: MutableRegistry,
|
|
722
|
+
input: OperationInputs["workspace.diagnostics"],
|
|
723
|
+
): Promise<OperationOutputs["workspace.diagnostics"]> {
|
|
724
|
+
const { index } = await requireCodeIntelligence(input);
|
|
725
|
+
const diagnostics = await diagnosticsQuery(index, input.path);
|
|
726
|
+
return { diagnostics };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async function prepareCallHierarchyHandler(
|
|
730
|
+
_registry: MutableRegistry,
|
|
731
|
+
input: OperationInputs["workspace.prepareCallHierarchy"],
|
|
732
|
+
): Promise<OperationOutputs["workspace.prepareCallHierarchy"]> {
|
|
733
|
+
const { index } = await requireCodeIntelligence(input);
|
|
734
|
+
const items = await prepareCallHierarchyQuery(index, { path: input.path, line: input.line, character: input.character });
|
|
735
|
+
return { items };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function incomingCallsHandler(
|
|
739
|
+
_registry: MutableRegistry,
|
|
740
|
+
input: OperationInputs["workspace.incomingCalls"],
|
|
741
|
+
): Promise<OperationOutputs["workspace.incomingCalls"]> {
|
|
742
|
+
const { index } = await requireCodeIntelligence(input);
|
|
743
|
+
const calls = await incomingCallsQuery(index, { path: input.path, line: input.line, character: input.character });
|
|
744
|
+
return { calls };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function outgoingCallsHandler(
|
|
748
|
+
_registry: MutableRegistry,
|
|
749
|
+
input: OperationInputs["workspace.outgoingCalls"],
|
|
750
|
+
): Promise<OperationOutputs["workspace.outgoingCalls"]> {
|
|
751
|
+
const { index } = await requireCodeIntelligence(input);
|
|
752
|
+
const calls = await outgoingCallsQuery(index, { path: input.path, line: input.line, character: input.character });
|
|
753
|
+
return { calls };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function populateSymbolGraphHandler(
|
|
757
|
+
_registry: MutableRegistry,
|
|
758
|
+
input: OperationInputs["workspace.populateSymbolGraph"],
|
|
759
|
+
): Promise<OperationOutputs["workspace.populateSymbolGraph"]> {
|
|
760
|
+
const { index, descriptor } = await requireCodeIntelligence(input);
|
|
761
|
+
const entry = registry.get(input.workspaceId);
|
|
762
|
+
if (!entry?.rootPath) throw new SymbolQueryUnavailable(input.workspaceId);
|
|
763
|
+
const rootPath = entry.rootPath;
|
|
764
|
+
const before = await deriveSourceManifest(rootPath, descriptor.extensions, input.maxFiles, MAX_SOURCE_MANIFEST_BYTES);
|
|
765
|
+
const graph = ensureSymbolGraph(input.workspaceId);
|
|
766
|
+
const result = await populateSymbolGraphQuery(index, graph, before.absoluteFiles, input.maxSymbolsPerFile);
|
|
767
|
+
const after = await deriveSourceManifest(rootPath, descriptor.extensions, input.maxFiles, MAX_SOURCE_MANIFEST_BYTES);
|
|
768
|
+
if (after.fingerprint !== before.fingerprint) throw new WorkspaceChangedDuringPopulation(input.workspaceId);
|
|
769
|
+
await graph.setGeneration({
|
|
770
|
+
sourceFingerprint: after.fingerprint,
|
|
771
|
+
maxFiles: input.maxFiles,
|
|
772
|
+
maxSymbolsPerFile: input.maxSymbolsPerFile,
|
|
773
|
+
completedAt: Date.now(),
|
|
774
|
+
result,
|
|
775
|
+
});
|
|
776
|
+
return result;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function cacheStatusHandler(
|
|
780
|
+
_registry: MutableRegistry,
|
|
781
|
+
input: OperationInputs["workspace.cacheStatus"],
|
|
782
|
+
): Promise<OperationOutputs["workspace.cacheStatus"]> {
|
|
783
|
+
const entry = registry.get(input.workspaceId);
|
|
784
|
+
if (!entry) throw new UnknownWorkspace(input.workspaceId);
|
|
785
|
+
if (!entry.rootPath) throw new SymbolQueryUnavailable(input.workspaceId);
|
|
786
|
+
const activeJobId = activePopulationJobByWorkspace.get(input.workspaceId);
|
|
787
|
+
if (activeJobId) {
|
|
788
|
+
const snapshot = jobs.status(activeJobId);
|
|
789
|
+
if (snapshot.status === "queued" || snapshot.status === "running") return { status: "caching", jobId: activeJobId };
|
|
790
|
+
activePopulationJobByWorkspace.delete(input.workspaceId);
|
|
791
|
+
}
|
|
792
|
+
const graph = ensureSymbolGraph(input.workspaceId);
|
|
793
|
+
const generation = await graph.getGeneration();
|
|
794
|
+
if (!generation) return { status: "not-cached", reason: "no-completed-generation" };
|
|
795
|
+
if (generation.maxFiles !== input.maxFiles || generation.maxSymbolsPerFile !== input.maxSymbolsPerFile) {
|
|
796
|
+
return { status: "not-cached", reason: "bounds-changed" };
|
|
797
|
+
}
|
|
798
|
+
const { descriptor } = resolveDescriptor(entry.rootPath, {});
|
|
799
|
+
let currentFingerprint: string;
|
|
800
|
+
try {
|
|
801
|
+
currentFingerprint = (await deriveSourceManifest(entry.rootPath, descriptor.extensions, input.maxFiles, MAX_SOURCE_MANIFEST_BYTES)).fingerprint;
|
|
802
|
+
} catch {
|
|
803
|
+
return { status: "not-cached", reason: "source-changed" };
|
|
804
|
+
}
|
|
805
|
+
if (currentFingerprint !== generation.sourceFingerprint) return { status: "not-cached", reason: "source-changed" };
|
|
806
|
+
return { status: "cached", generation };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
810
|
+
return typeof value === "object" && value !== null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function submitJobHandler(_registry: MutableRegistry, request: OperationInputs["job.submit"]): Promise<OperationOutputs["job.submit"]> {
|
|
814
|
+
const rawRequest: unknown = request;
|
|
815
|
+
if (!isRecord(rawRequest)) throw new InvalidJobInput("request must be an object");
|
|
816
|
+
const operation = rawRequest.operation;
|
|
817
|
+
if (operation !== "workspace.populateSymbolGraph") throw new UnsupportedJobOperation(String(operation));
|
|
818
|
+
const rawInput = rawRequest.input;
|
|
819
|
+
if (!isRecord(rawInput)) throw new InvalidJobInput("input must be an object");
|
|
820
|
+
const { workspaceId, maxFiles, maxSymbolsPerFile } = rawInput;
|
|
821
|
+
if (typeof workspaceId !== "string" || workspaceId.length === 0) throw new InvalidJobInput("workspaceId must be a non-empty string");
|
|
822
|
+
if (typeof maxFiles !== "number" || !Number.isSafeInteger(maxFiles) || maxFiles < 1) throw new InvalidJobInput("maxFiles must be a positive safe integer");
|
|
823
|
+
if (typeof maxSymbolsPerFile !== "number" || !Number.isSafeInteger(maxSymbolsPerFile) || maxSymbolsPerFile < 1) {
|
|
824
|
+
throw new InvalidJobInput("maxSymbolsPerFile must be a positive safe integer");
|
|
825
|
+
}
|
|
826
|
+
const rawWaitMs = rawRequest.waitMs;
|
|
827
|
+
const waitMs = rawWaitMs ?? 0;
|
|
828
|
+
if (typeof waitMs !== "number" || !Number.isSafeInteger(waitMs) || waitMs < 0) throw new InvalidJobInput("waitMs must be a non-negative safe integer");
|
|
829
|
+
if (waitMs > MAX_INITIAL_JOB_WAIT_MS) throw new JobWaitTooLong(waitMs, MAX_INITIAL_JOB_WAIT_MS);
|
|
830
|
+
const workspace = registry.get(workspaceId);
|
|
831
|
+
if (!workspace) throw new UnknownWorkspace(workspaceId);
|
|
832
|
+
const existingJobId = activePopulationJobByWorkspace.get(workspaceId);
|
|
833
|
+
if (existingJobId) {
|
|
834
|
+
const existing = jobs.status(existingJobId);
|
|
835
|
+
if (existing.status === "queued" || existing.status === "running") {
|
|
836
|
+
return { job: waitMs === 0 ? existing : await jobs.wait(existing.id, waitMs) };
|
|
837
|
+
}
|
|
838
|
+
activePopulationJobByWorkspace.delete(workspaceId);
|
|
839
|
+
}
|
|
840
|
+
const input = { workspaceId, maxFiles, maxSymbolsPerFile };
|
|
841
|
+
let submittedJobId = "";
|
|
842
|
+
const submitted = jobs.submit({
|
|
843
|
+
operation,
|
|
844
|
+
priority: workspace.origin,
|
|
845
|
+
run: async () => {
|
|
846
|
+
try {
|
|
847
|
+
return await populateSymbolGraphHandler(registry, input);
|
|
848
|
+
} finally {
|
|
849
|
+
if (activePopulationJobByWorkspace.get(workspaceId) === submittedJobId) activePopulationJobByWorkspace.delete(workspaceId);
|
|
850
|
+
}
|
|
851
|
+
},
|
|
852
|
+
});
|
|
853
|
+
submittedJobId = submitted.id;
|
|
854
|
+
activePopulationJobByWorkspace.set(workspaceId, submitted.id);
|
|
855
|
+
return { job: waitMs === 0 ? submitted : await jobs.wait(submitted.id, waitMs) };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function jobStatusHandler(_registry: MutableRegistry, input: OperationInputs["job.status"]): Promise<OperationOutputs["job.status"]> {
|
|
859
|
+
const rawInput: unknown = input;
|
|
860
|
+
if (!isRecord(rawInput) || typeof rawInput.jobId !== "string" || rawInput.jobId.length === 0) {
|
|
861
|
+
return Promise.reject(new InvalidJobInput("jobId must be a non-empty string"));
|
|
862
|
+
}
|
|
863
|
+
return Promise.resolve({ job: jobs.status(rawInput.jobId) });
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
async function reachableFromHandler(
|
|
867
|
+
_registry: MutableRegistry,
|
|
868
|
+
input: OperationInputs["workspace.reachableFrom"],
|
|
869
|
+
): Promise<OperationOutputs["workspace.reachableFrom"]> {
|
|
870
|
+
const graph = ensureSymbolGraph(input.workspaceId);
|
|
871
|
+
const id = deriveSymbolNodeId({ path: input.path, line: input.line, character: input.character });
|
|
872
|
+
const symbols = await reachableSymbolsFrom(graph, id, { maxDepth: input.maxDepth, kind: input.kind });
|
|
873
|
+
return { symbols };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async function symbolEdgesFromHandler(
|
|
877
|
+
_registry: MutableRegistry,
|
|
878
|
+
input: OperationInputs["workspace.symbolEdgesFrom"],
|
|
879
|
+
): Promise<OperationOutputs["workspace.symbolEdgesFrom"]> {
|
|
880
|
+
const graph = ensureSymbolGraph(input.workspaceId);
|
|
881
|
+
const id = deriveSymbolNodeId({ path: input.path, line: input.line, character: input.character });
|
|
882
|
+
const symbols = await symbolEdgesFrom(graph, id, input.kind);
|
|
883
|
+
return { symbols };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
async function symbolEdgesToHandler(
|
|
887
|
+
_registry: MutableRegistry,
|
|
888
|
+
input: OperationInputs["workspace.symbolEdgesTo"],
|
|
889
|
+
): Promise<OperationOutputs["workspace.symbolEdgesTo"]> {
|
|
890
|
+
const graph = ensureSymbolGraph(input.workspaceId);
|
|
891
|
+
const id = deriveSymbolNodeId({ path: input.path, line: input.line, character: input.character });
|
|
892
|
+
const symbols = await symbolEdgesTo(graph, id, input.kind);
|
|
893
|
+
return { symbols };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const handlers: OperationHandlers = {
|
|
897
|
+
"workspace.rawRead": (registry, input) => rawRead(resolveWorkspace(registry, input.workspaceId), input.path),
|
|
898
|
+
"workspace.exactEdit": (registry, input) => {
|
|
899
|
+
const { workspaceId, ...edit } = input;
|
|
900
|
+
return exactEdit(resolveWorkspace(registry, workspaceId), edit);
|
|
901
|
+
},
|
|
902
|
+
"workspace.registerPath": registerPath,
|
|
903
|
+
"workspace.findSymbols": findSymbols,
|
|
904
|
+
"workspace.goToDefinition": goToDefinition,
|
|
905
|
+
"workspace.goToImplementation": goToImplementation,
|
|
906
|
+
"workspace.findReferences": findReferences,
|
|
907
|
+
"workspace.hover": hover,
|
|
908
|
+
"workspace.documentSymbols": documentSymbolsHandler,
|
|
909
|
+
"workspace.diagnostics": diagnosticsHandler,
|
|
910
|
+
"workspace.prepareCallHierarchy": prepareCallHierarchyHandler,
|
|
911
|
+
"workspace.incomingCalls": incomingCallsHandler,
|
|
912
|
+
"workspace.outgoingCalls": outgoingCallsHandler,
|
|
913
|
+
"workspace.populateSymbolGraph": populateSymbolGraphHandler,
|
|
914
|
+
"workspace.reachableFrom": reachableFromHandler,
|
|
915
|
+
"workspace.symbolEdgesFrom": symbolEdgesFromHandler,
|
|
916
|
+
"workspace.symbolEdgesTo": symbolEdgesToHandler,
|
|
917
|
+
"workspace.hasWarmIndex": hasWarmIndex,
|
|
918
|
+
"workspace.cacheStatus": cacheStatusHandler,
|
|
919
|
+
"workspace.gitStatus": gitStatusHandler,
|
|
920
|
+
"workspace.gitLog": gitLogHandler,
|
|
921
|
+
"workspace.gitDiff": gitDiffHandler,
|
|
922
|
+
"repo.fetch": repoFetchHandler,
|
|
923
|
+
"workspace.searchText": searchTextHandler,
|
|
924
|
+
"search.symbols": crossFindSymbols,
|
|
925
|
+
"search.text": crossSearchText,
|
|
926
|
+
"job.submit": submitJobHandler,
|
|
927
|
+
"job.status": jobStatusHandler,
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
return {
|
|
931
|
+
operations: OPERATION_NAMES,
|
|
932
|
+
// Declared `async` deliberately, not just typed `Promise<...>`: a handler (e.g.
|
|
933
|
+
// resolveWorkspace's UnknownWorkspace) can throw synchronously, and only an `async`
|
|
934
|
+
// function body converts a synchronous throw into a rejected promise automatically.
|
|
935
|
+
// Without it, `dispatch` would sometimes throw and sometimes reject depending on
|
|
936
|
+
// which operation ran -- a broken contract for any in-process caller (standalone
|
|
937
|
+
// mode, a future Alef adapter) that isn't protected by the HTTP layer's try/catch.
|
|
938
|
+
async dispatch<Name extends OperationName>(operation: Name, input: OperationInputs[Name]): Promise<OperationOutputs[Name]> {
|
|
939
|
+
const handler = handlers[operation] as (registry: MutableRegistry, input: OperationInputs[Name]) => Promise<OperationOutputs[Name]>;
|
|
940
|
+
return handler(registry, input);
|
|
941
|
+
},
|
|
942
|
+
async close(): Promise<void> {
|
|
943
|
+
jobs.close();
|
|
944
|
+
const entries = Array.from(symbolIndexes.values());
|
|
945
|
+
symbolIndexes.clear();
|
|
946
|
+
const graphs = Array.from(symbolGraphs.values());
|
|
947
|
+
symbolGraphs.clear();
|
|
948
|
+
await Promise.all([...entries.map((entry) => entry.index.close()), ...graphs.map((graph) => graph.close())]);
|
|
949
|
+
},
|
|
950
|
+
async reapIdleSymbolIndexes(maxIdleMs: number): Promise<number> {
|
|
951
|
+
const now = Date.now();
|
|
952
|
+
const idle = Array.from(symbolIndexes.entries()).filter(([, entry]) => now - entry.lastUsedAt > maxIdleMs);
|
|
953
|
+
for (const [workspaceId] of idle) symbolIndexes.delete(workspaceId);
|
|
954
|
+
await Promise.all(idle.map(([, entry]) => entry.index.close()));
|
|
955
|
+
return idle.length;
|
|
956
|
+
},
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
export { JobCapacityExceeded, JobNotFound } from "./domain/bounded-job-executor.ts";
|
|
961
|
+
export { StaleExpectedHash, WorkspaceEntryNotFound };
|