@danypops/pi-lector 0.12.4 → 0.12.5
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.
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { lectorClient, workspaceForProjectDirectory } from "../lector-client.ts";
|
|
1
|
+
import { type SymbolSearchResult, type TextSearchResult, UnknownWorkspace, type WorkspaceQueryOutcome } from "@danypops/lector";
|
|
2
|
+
import { forgetWorkspaceId, lectorClient, type ResolvedWorkspace, workspaceForProjectDirectory } from "../lector-client.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Fans out across explicitly-named directories only -- never the daemon's own "every registered
|
|
@@ -36,18 +36,23 @@ export interface CrossWorkspaceOutcome<T> {
|
|
|
36
36
|
readonly outcome: WorkspaceQueryOutcome<T>;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return resolved.map((r) => r.workspaceId);
|
|
39
|
+
function resolveWorkspaces(directories: readonly string[]): Promise<readonly ResolvedWorkspace[]> {
|
|
40
|
+
return Promise.all(directories.map((directory) => workspaceForProjectDirectory(directory)));
|
|
42
41
|
}
|
|
43
42
|
|
|
44
43
|
/**
|
|
45
44
|
* Zips the daemon's own outcomes back onto the literal directories that produced them, and
|
|
46
|
-
* computes collapsedWith
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* to
|
|
45
|
+
* computes collapsedWith -- by workspaceId identity, never by array position. A real, reproduced
|
|
46
|
+
* live bug: the daemon's own response order can legitimately differ from the request's
|
|
47
|
+
* workspaceIds order (an immediate per-item error for an unregistered id is reported ahead of
|
|
48
|
+
* real results, for instance) -- a caller correlating positionally then hands one directory's
|
|
49
|
+
* result to a completely different directory. Every outcome already carries its own workspaceId;
|
|
50
|
+
* this groups outcomes by that id (preserving arrival order *within* one id's own group, since
|
|
51
|
+
* two directories can legitimately share one workspaceId -- a monorepo's unmarked siblings) and
|
|
52
|
+
* consumes exactly one outcome per requested (directory, workspaceId) pair in that group, in
|
|
53
|
+
* order. A missing, duplicated-beyond-what-was-asked, or wholly unrequested workspaceId in the
|
|
54
|
+
* response means the daemon's contract broke -- fails loud rather than silently mislabeling or
|
|
55
|
+
* dropping data.
|
|
51
56
|
*/
|
|
52
57
|
function zipOutcomes<T>(
|
|
53
58
|
directories: readonly string[],
|
|
@@ -56,33 +61,86 @@ function zipOutcomes<T>(
|
|
|
56
61
|
): readonly CrossWorkspaceOutcome<T>[] {
|
|
57
62
|
if (outcomes.length !== directories.length) {
|
|
58
63
|
throw new Error(
|
|
59
|
-
`Lector's search fan-out returned ${outcomes.length} outcome(s) for ${directories.length} requested directories -- expected exactly one outcome per directory
|
|
64
|
+
`Lector's search fan-out returned ${outcomes.length} outcome(s) for ${directories.length} requested directories -- expected exactly one outcome per directory`,
|
|
60
65
|
);
|
|
61
66
|
}
|
|
62
|
-
|
|
67
|
+
const outcomesByWorkspaceId = new Map<string, WorkspaceQueryOutcome<T>[]>();
|
|
68
|
+
for (const outcome of outcomes) {
|
|
69
|
+
const bucket = outcomesByWorkspaceId.get(outcome.workspaceId);
|
|
70
|
+
if (bucket) bucket.push(outcome);
|
|
71
|
+
else outcomesByWorkspaceId.set(outcome.workspaceId, [outcome]);
|
|
72
|
+
}
|
|
73
|
+
const consumedByWorkspaceId = new Map<string, number>();
|
|
74
|
+
const results = directories.map((directory, index) => {
|
|
63
75
|
const workspaceId = workspaceIds[index];
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
76
|
+
if (workspaceId === undefined) throw new Error(`Lector's search fan-out is missing a resolved workspaceId for directory "${directory}"`);
|
|
77
|
+
const consumed = consumedByWorkspaceId.get(workspaceId) ?? 0;
|
|
78
|
+
const outcome = outcomesByWorkspaceId.get(workspaceId)?.[consumed];
|
|
79
|
+
if (!outcome) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Lector's search fan-out returned no outcome for workspace "${workspaceId}" (directory "${directory}") -- the daemon's response no longer corresponds to the request`,
|
|
82
|
+
);
|
|
67
83
|
}
|
|
84
|
+
consumedByWorkspaceId.set(workspaceId, consumed + 1);
|
|
68
85
|
const collapsedWith = directories.filter((_, otherIndex) => otherIndex !== index && workspaceIds[otherIndex] === workspaceId);
|
|
69
86
|
return { directory, workspaceId, collapsedWith, outcome };
|
|
70
87
|
});
|
|
88
|
+
const totalConsumed = [...consumedByWorkspaceId.values()].reduce((sum, count) => sum + count, 0);
|
|
89
|
+
if (totalConsumed !== outcomes.length) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
"Lector's search fan-out returned an outcome for a workspace nobody asked for -- the daemon's response no longer corresponds to the request",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True exactly for the daemon's own "this workspaceId is not registered" outcome for this request's own workspaceId -- never a string-matched guess against an unrelated error. */
|
|
98
|
+
function isUnknownWorkspaceOutcome<T>(outcome: WorkspaceQueryOutcome<T>, workspaceId: string): boolean {
|
|
99
|
+
return outcome.status === "error" && outcome.message === new UnknownWorkspace(workspaceId).message;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A daemon restart wipes its in-memory workspace registry, but this process's own workspaceId
|
|
104
|
+
* cache does not know that on its own -- a cross-workspace call through a stale cached id comes
|
|
105
|
+
* back with a real, correctly-correlated "no workspace registered" outcome for that one
|
|
106
|
+
* workspace, even though the underlying directory on disk never changed. On exactly that
|
|
107
|
+
* outcome, the stale cache entries are dropped and the whole fan-out (re-resolve every
|
|
108
|
+
* directory, then re-run) retries once -- the same bounded, idempotent recovery withWorkspace
|
|
109
|
+
* already gives single-workspace operations, extended to a batch. A genuine per-workspace error
|
|
110
|
+
* unrelated to registration (an unsupported language, a real internal failure) is never retried.
|
|
111
|
+
*/
|
|
112
|
+
async function withCrossWorkspaceRestartRecovery<T>(
|
|
113
|
+
directories: readonly string[],
|
|
114
|
+
perform: (resolved: readonly ResolvedWorkspace[]) => Promise<readonly CrossWorkspaceOutcome<T>[]>,
|
|
115
|
+
): Promise<readonly CrossWorkspaceOutcome<T>[]> {
|
|
116
|
+
const resolved = await resolveWorkspaces(directories);
|
|
117
|
+
const outcomes = await perform(resolved);
|
|
118
|
+
const stale = outcomes.filter((entry) => isUnknownWorkspaceOutcome(entry.outcome, entry.workspaceId));
|
|
119
|
+
if (stale.length === 0) return outcomes;
|
|
120
|
+
for (const entry of stale) {
|
|
121
|
+
const match = resolved.find((candidate) => candidate.workspaceId === entry.workspaceId);
|
|
122
|
+
if (match) forgetWorkspaceId(match.root);
|
|
123
|
+
}
|
|
124
|
+
return perform(await resolveWorkspaces(directories));
|
|
71
125
|
}
|
|
72
126
|
|
|
73
127
|
export function createLectorCrossWorkspaceSearchOperations(): CrossWorkspaceSearchOperations {
|
|
74
128
|
return {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
129
|
+
findSymbols(query, directories, timeoutMs) {
|
|
130
|
+
return withCrossWorkspaceRestartRecovery(directories, async (resolved) => {
|
|
131
|
+
const workspaceIds = resolved.map((r) => r.workspaceId);
|
|
132
|
+
const client = await lectorClient();
|
|
133
|
+
const { results } = await client.call("search.symbols", { query, workspaceIds, timeoutMs });
|
|
134
|
+
return zipOutcomes(directories, workspaceIds, results);
|
|
135
|
+
});
|
|
80
136
|
},
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
137
|
+
searchText(query, directories, maxMatches, maxBytes, timeoutMs) {
|
|
138
|
+
return withCrossWorkspaceRestartRecovery(directories, async (resolved) => {
|
|
139
|
+
const workspaceIds = resolved.map((r) => r.workspaceId);
|
|
140
|
+
const client = await lectorClient();
|
|
141
|
+
const { results } = await client.call("search.text", { query, maxMatches, maxBytes, workspaceIds, timeoutMs });
|
|
142
|
+
return zipOutcomes(directories, workspaceIds, results);
|
|
143
|
+
});
|
|
86
144
|
},
|
|
87
145
|
};
|
|
88
146
|
}
|
|
@@ -214,6 +214,16 @@ export async function withWorkspace<T>(resolve: () => Promise<ResolvedWorkspace>
|
|
|
214
214
|
throw new Error("Lector workspace resolution retry exhausted");
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Drops one root's cached workspaceId without retrying anything itself -- the batch sibling of
|
|
219
|
+
* withWorkspace's own single-workspace `workspaceIdByRoot.delete(resolved.root)` recovery, for a
|
|
220
|
+
* caller (cross-workspace search's fan-out) that resolves many roots at once and needs to evict
|
|
221
|
+
* only the specific ones a daemon restart actually invalidated, not the whole cache.
|
|
222
|
+
*/
|
|
223
|
+
export function forgetWorkspaceId(root: string): void {
|
|
224
|
+
workspaceIdByRoot.delete(root);
|
|
225
|
+
}
|
|
226
|
+
|
|
217
227
|
export function setLectorClientConnectorForTests(value: ClientConnector): void {
|
|
218
228
|
retryingClient.reset();
|
|
219
229
|
workspaceIdByRoot.clear();
|
package/package.json
CHANGED