@danypops/pi-lector 0.12.3 → 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 { SymbolSearchResult, TextSearchResult, WorkspaceQueryOutcome } from "@danypops/lector";
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
- async function resolveWorkspaceIds(directories: readonly string[]): Promise<readonly string[]> {
40
- const resolved = await Promise.all(directories.map((directory) => workspaceForProjectDirectory(directory)));
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. The daemon's search.symbols/search.text handlers map workspaceIds to
47
- * results 1:1, in order, with no deduplication of their own (confirmed by reading service.ts's
48
- * crossFindSymbols/crossSearchText: `targets.map(...)` over the exact `workspaceIds` array
49
- * given) -- so a length mismatch here means that contract broke, not a normal runtime condition
50
- * to paper over with an unsafe cast.
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, in order`,
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
- return directories.map((directory, index) => {
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
- const outcome = outcomes[index];
65
- if (workspaceId === undefined || outcome === undefined) {
66
- throw new Error(`Lector's search fan-out is missing a workspaceId/outcome for directory "${directory}"`);
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
- async findSymbols(query, directories, timeoutMs) {
76
- const workspaceIds = await resolveWorkspaceIds(directories);
77
- const client = await lectorClient();
78
- const { results } = await client.call("search.symbols", { query, workspaceIds, timeoutMs });
79
- return zipOutcomes(directories, workspaceIds, results);
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
- async searchText(query, directories, maxMatches, maxBytes, timeoutMs) {
82
- const workspaceIds = await resolveWorkspaceIds(directories);
83
- const client = await lectorClient();
84
- const { results } = await client.call("search.text", { query, maxMatches, maxBytes, workspaceIds, timeoutMs });
85
- return zipOutcomes(directories, workspaceIds, results);
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
  }
@@ -5,13 +5,14 @@ import {
5
5
  descriptorForExtension,
6
6
  LANGUAGE_SERVER_DESCRIPTORS,
7
7
  type LectorClient,
8
+ LectorDaemonUnavailable,
8
9
  type OperationInputs,
9
10
  type OperationName,
10
11
  type OperationOutputs,
11
12
  remoteErrorIs,
12
13
  type WorkspaceId,
13
14
  } from "@danypops/lector";
14
- import { createRetryingClient, type RetryingClient } from "@danypops/vehicle-client/daemon-client";
15
+ import { createRetryingClient, isLikelyStaleConnectionError, type RetryingClient } from "@danypops/vehicle-client/daemon-client";
15
16
  import { nearestGitRoot, nearestProjectRoot } from "./nearest-workspace-root.ts";
16
17
 
17
18
  /**
@@ -37,7 +38,10 @@ let connector: ClientConnector = () => connectLectorClient();
37
38
  // Wraps `() => connector()` rather than `connector` itself, so a test's
38
39
  // setLectorClientConnectorForTests still takes effect after this retrying
39
40
  // client is constructed once at module load.
40
- const retryingClient: RetryingClient<LectorClient> = createRetryingClient(() => connector(), { label: "Lector" });
41
+ const retryingClient: RetryingClient<LectorClient> = createRetryingClient(() => connector(), {
42
+ label: "Lector",
43
+ isStaleConnectionError: (error) => error instanceof LectorDaemonUnavailable || isLikelyStaleConnectionError(error),
44
+ });
41
45
  const workspaceIdByRoot = new Map<string, WorkspaceId>();
42
46
 
43
47
  /**
@@ -210,6 +214,16 @@ export async function withWorkspace<T>(resolve: () => Promise<ResolvedWorkspace>
210
214
  throw new Error("Lector workspace resolution retry exhausted");
211
215
  }
212
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
+
213
227
  export function setLectorClientConnectorForTests(value: ClientConnector): void {
214
228
  retryingClient.reset();
215
229
  workspaceIdByRoot.clear();
@@ -172,7 +172,7 @@ export async function monitorWorkspaceCache(operations: WorkspaceCacheOperations
172
172
  }
173
173
 
174
174
  let jobId: string;
175
- if (initial.status === "caching") {
175
+ if (initial.status === "caching" || initial.status === "waiting-for-resources") {
176
176
  jobId = initial.jobId;
177
177
  } else {
178
178
  options.onState({ status: "not-cached", reason: initial.reason });
@@ -32,6 +32,7 @@ export function formatWorkspaceCacheStatusResult(status: WorkspaceCacheStatus |
32
32
  if (!status) return theme.fg("dim", "No result.");
33
33
  if (status.status === "not-cached") return theme.fg("warning", `not cached (${status.reason})`);
34
34
  if (status.status === "caching") return theme.fg("accent", `caching (job ${status.jobId})`);
35
+ if (status.status === "waiting-for-resources") return theme.fg("accent", `waiting for resources (job ${status.jobId})`);
35
36
  if (status.status === "partial") return theme.fg("warning", `partial -- ${formatResultCounts(status.generation.result)}`);
36
37
  return theme.fg("success", `cached -- ${formatResultCounts(status.generation.result)}`);
37
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-lector",
3
- "version": "0.12.3",
3
+ "version": "0.12.5",
4
4
  "description": "Pi host adapter for Lector: overrides read/write/edit with a daemon-backed, hash-guarded filesystem",
5
5
  "license": "MIT",
6
6
  "type": "module",