@danypops/pi-lector 0.12.5 → 0.12.7
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/extension/src/client-diagnostics.ts +46 -0
- package/extension/src/cross-workspace-search/operations.ts +9 -3
- package/extension/src/index.ts +40 -8
- package/extension/src/lector-client.ts +152 -64
- package/extension/src/reference-based-rename/operations.ts +5 -6
- package/extension/src/symbol-annotation/operations.ts +16 -14
- package/extension/src/workspace-cache/operations.ts +66 -13
- package/extension/src/workspace-cache/rendering.ts +2 -2
- package/package.json +3 -3
- package/extension/src/nearest-workspace-root.ts +0 -130
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { RetryingClientDiagnosticEvent } from "@danypops/vehicle-client/daemon-client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Formats one createRetryingClient diagnostic event as a single, compact line -- the same real
|
|
5
|
+
* RCA gap @danypops/vehicle-client's own onEvent hook was added to close: a scrubbed "connector
|
|
6
|
+
* unavailable" error at the tool-call boundary otherwise gives no way to tell, after the fact,
|
|
7
|
+
* whether it was a genuine fresh connect() failure, a circuit-breaker short-circuit (no connect
|
|
8
|
+
* attempted at all), or an in-flight operation's stale-connection retry. Never includes a stack
|
|
9
|
+
* trace -- name and message only, matching this house's other client-diagnostic channels
|
|
10
|
+
* (@danypops/vehicle-client-pi's own client-diagnostics.ts).
|
|
11
|
+
*/
|
|
12
|
+
/** Never risks Object's default `[object Object]` stringification for a non-Error, non-string throw. */
|
|
13
|
+
function describeError(error: unknown): { name: string; message: string } {
|
|
14
|
+
if (error instanceof Error) return { name: error.name, message: error.message };
|
|
15
|
+
if (typeof error === "string") return { name: "string", message: error };
|
|
16
|
+
if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") return { name: typeof error, message: String(error) };
|
|
17
|
+
return { name: typeof error, message: "(unprintable value)" };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatClientDiagnosticEvent(event: RetryingClientDiagnosticEvent): string {
|
|
21
|
+
const parts = [`[lector-client] ${event.type}`];
|
|
22
|
+
if (event.attempt !== undefined) parts.push(`attempt=${event.attempt}`);
|
|
23
|
+
if (event.consecutiveFailures !== undefined) parts.push(`consecutiveFailures=${event.consecutiveFailures}`);
|
|
24
|
+
if (event.operationId !== undefined) parts.push(`operationId=${event.operationId}`);
|
|
25
|
+
if (event.error !== undefined) {
|
|
26
|
+
const { name, message } = describeError(event.error);
|
|
27
|
+
parts.push(`error=${name}: ${message}`);
|
|
28
|
+
}
|
|
29
|
+
return parts.join(" ");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Logs a createRetryingClient diagnostic event to stderr, only when LECTOR_CLIENT_DIAG is set --
|
|
34
|
+
* zero cost and zero output for every session that never opts in, matching the env-gated
|
|
35
|
+
* convention @danypops/vehicle-client-pi's own VEHICLE_CLIENT_DIAG already established in this
|
|
36
|
+
* house. Never throws: onEvent's own contract requires it stay side-effect-light, and a broken
|
|
37
|
+
* local console (or a formatting bug) must not take down a real client call.
|
|
38
|
+
*/
|
|
39
|
+
export function logClientDiagnosticEvent(event: RetryingClientDiagnosticEvent): void {
|
|
40
|
+
if (!process.env.LECTOR_CLIENT_DIAG) return;
|
|
41
|
+
try {
|
|
42
|
+
console.error(formatClientDiagnosticEvent(event));
|
|
43
|
+
} catch {
|
|
44
|
+
// best-effort only -- see doc comment above.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -10,7 +10,13 @@ import { forgetWorkspaceId, lectorClient, type ResolvedWorkspace, workspaceForPr
|
|
|
10
10
|
* required, same "no implicit fallback" convention as find_symbols/search_code.
|
|
11
11
|
*/
|
|
12
12
|
export interface CrossWorkspaceSearchOperations {
|
|
13
|
-
|
|
13
|
+
/** maxResults, when given, applies per project (see workspace.cacheStatus's own OperationInputs note on search.symbols) -- omitted falls back to the daemon's own conservative default, never an unbounded per-project search.symbols call fanned out across every requested directory at once. */
|
|
14
|
+
findSymbols(
|
|
15
|
+
query: string,
|
|
16
|
+
directories: readonly string[],
|
|
17
|
+
timeoutMs?: number,
|
|
18
|
+
maxResults?: number,
|
|
19
|
+
): Promise<readonly CrossWorkspaceOutcome<SymbolSearchResult>[]>;
|
|
14
20
|
searchText(
|
|
15
21
|
query: string,
|
|
16
22
|
directories: readonly string[],
|
|
@@ -126,11 +132,11 @@ async function withCrossWorkspaceRestartRecovery<T>(
|
|
|
126
132
|
|
|
127
133
|
export function createLectorCrossWorkspaceSearchOperations(): CrossWorkspaceSearchOperations {
|
|
128
134
|
return {
|
|
129
|
-
findSymbols(query, directories, timeoutMs) {
|
|
135
|
+
findSymbols(query, directories, timeoutMs, maxResults) {
|
|
130
136
|
return withCrossWorkspaceRestartRecovery(directories, async (resolved) => {
|
|
131
137
|
const workspaceIds = resolved.map((r) => r.workspaceId);
|
|
132
138
|
const client = await lectorClient();
|
|
133
|
-
const { results } = await client.call("search.symbols", { query, workspaceIds, timeoutMs });
|
|
139
|
+
const { results } = await client.call("search.symbols", { query, workspaceIds, timeoutMs, ...(maxResults !== undefined ? { maxResults } : {}) });
|
|
134
140
|
return zipOutcomes(directories, workspaceIds, results);
|
|
135
141
|
});
|
|
136
142
|
},
|
package/extension/src/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
1
3
|
import { resolve } from "node:path";
|
|
2
4
|
import type {
|
|
3
5
|
CachedRepositoryPage,
|
|
@@ -44,6 +46,7 @@ import { Type } from "typebox";
|
|
|
44
46
|
/** Real ANSI-aware measurement for Table -- Malevich's own default is ASCII-only, unsafe against theme-styled cell/header text. */
|
|
45
47
|
const tableMeasure: TextMeasure = { visibleWidth, truncateToWidth };
|
|
46
48
|
|
|
49
|
+
import { classifyAutoPopulationRoot, isFilesystemRoot } from "@danypops/lector";
|
|
47
50
|
import { createLectorApplyPatchOperations } from "./apply-patch/operations.ts";
|
|
48
51
|
import { formatApplyPatchCall, formatApplyPatchResult } from "./apply-patch/rendering.ts";
|
|
49
52
|
import { createLectorCodeIntelligenceOperations } from "./code-intelligence/operations.ts";
|
|
@@ -89,11 +92,10 @@ import { createLectorFindSymbolsOperations } from "./find-symbols/operations.ts"
|
|
|
89
92
|
import { describeFindSymbolSources, formatFindSymbolsCall, formatFindSymbolsResult } from "./find-symbols/rendering.ts";
|
|
90
93
|
import { createLectorGitOperations } from "./git/operations.ts";
|
|
91
94
|
import { formatGitCall, formatGitResult, type GitToolDetails } from "./git/rendering.ts";
|
|
92
|
-
import { setNewWorkspaceObserver } from "./lector-client.ts";
|
|
95
|
+
import { nearestGitWorkspaceRoot, setNewWorkspaceObserver } from "./lector-client.ts";
|
|
93
96
|
import { createLectorLineEditOperations } from "./line-edit/operations.ts";
|
|
94
97
|
import { formatLineEditCall, formatLineEditResult } from "./line-edit/rendering.ts";
|
|
95
98
|
import { createMutationHistoryOperations } from "./mutation-history/operations.ts";
|
|
96
|
-
import { isFilesystemRoot, nearestGitRoot } from "./nearest-workspace-root.ts";
|
|
97
99
|
import { createLectorPackageSourceOperations, type PackageSourceListPage } from "./package-source/operations.ts";
|
|
98
100
|
import {
|
|
99
101
|
buildPackageSourceListTableRows,
|
|
@@ -214,10 +216,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
214
216
|
* intentional fallback for a raw read/write of a file outside any git repo can register
|
|
215
217
|
* exactly this as a "new workspace", and auto-populating it would attempt a full
|
|
216
218
|
* filesystem-wide symbol-graph scan -- confirmed live as a real, previously-shipped bug.
|
|
219
|
+
*
|
|
220
|
+
* Also short-circuits a broad host directory (home directory, an XDG config/cache/data
|
|
221
|
+
* root, a dotfile directory) the exact same way workspace.populateSymbolGraph's own
|
|
222
|
+
* server-side gate would refuse it -- avoids the round trip (and the queue-behind-real-
|
|
223
|
+
* projects ergonomics this caused live for ~/.pi/agent) entirely, using the identical
|
|
224
|
+
* classification Lector itself uses so the two never drift. A readdir failure (permission,
|
|
225
|
+
* race) is treated as "can't tell, don't block" -- the server-side gate is still authoritative.
|
|
217
226
|
*/
|
|
218
227
|
function startMonitoringRoot(root: string, ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1]): void {
|
|
219
228
|
if (isFilesystemRoot(root)) return;
|
|
220
229
|
if (monitoringRoots.has(root)) return;
|
|
230
|
+
try {
|
|
231
|
+
const topLevelEntries = readdirSync(root);
|
|
232
|
+
if (classifyAutoPopulationRoot({ rootPath: root, homeDir: homedir(), topLevelEntries }) === "broad-non-project") return;
|
|
233
|
+
} catch {
|
|
234
|
+
// Can't read it here -- let the server-side gate be the authoritative answer.
|
|
235
|
+
}
|
|
221
236
|
monitoringRoots.add(root);
|
|
222
237
|
const thisGeneration = sessionGeneration;
|
|
223
238
|
void monitorWorkspaceCache(cacheOperations, {
|
|
@@ -279,9 +294,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
279
294
|
lastInjectedSummary = undefined;
|
|
280
295
|
uiContext = ctx;
|
|
281
296
|
setNewWorkspaceObserver((root) => startMonitoringRoot(root, ctx));
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
297
|
+
const thisGeneration = sessionGeneration;
|
|
298
|
+
void nearestGitWorkspaceRoot(cwd)
|
|
299
|
+
.then((projectRoot) => {
|
|
300
|
+
if (sessionGeneration !== thisGeneration) return;
|
|
301
|
+
if (projectRoot) startMonitoringRoot(projectRoot, ctx);
|
|
302
|
+
else ctx.ui.setStatus("lector-cache", undefined);
|
|
303
|
+
})
|
|
304
|
+
.catch(() => {
|
|
305
|
+
// A daemon that isn't running yet is not a session_start failure -- the first real
|
|
306
|
+
// tool call surfaces that clearly instead.
|
|
307
|
+
});
|
|
285
308
|
|
|
286
309
|
pi.registerTool(createReadToolDefinition(cwd, { operations: createLectorReadOperations() }));
|
|
287
310
|
pi.registerTool(createWriteToolDefinition(cwd, { operations: createLectorWriteOperations() }));
|
|
@@ -1155,14 +1178,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
1155
1178
|
if (!Number.isSafeInteger(waitMs) || waitMs < 1 || waitMs > 300_000)
|
|
1156
1179
|
throw new Error("workspace_cache action=wait waitMs must be an integer from 1 to 300000");
|
|
1157
1180
|
const pollIntervalMs = 5_000;
|
|
1158
|
-
const
|
|
1181
|
+
const outcome = await waitForJobCompletion(cacheOperations, params.jobId, {
|
|
1159
1182
|
pollIntervalMs,
|
|
1160
1183
|
maxPolls: Math.ceil(waitMs / pollIntervalMs),
|
|
1161
1184
|
shouldContinue: () => !signal?.aborted,
|
|
1162
1185
|
signal,
|
|
1163
1186
|
});
|
|
1164
1187
|
if (signal?.aborted) throw new DOMException("workspace cache wait canceled", "AbortError");
|
|
1165
|
-
|
|
1188
|
+
if (outcome.kind === "transport-failed") throw outcome.error instanceof Error ? outcome.error : new Error(String(outcome.error));
|
|
1189
|
+
if (outcome.kind === "job-not-found") {
|
|
1190
|
+
throw new Error(
|
|
1191
|
+
`workspace cache job "${params.jobId}" is no longer known -- it expired, was evicted, or belonged to a previous daemon process; check workspace_cache action=status instead`,
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
const job = outcome.kind === "terminal" ? outcome.job : await cacheOperations.jobStatus(params.jobId);
|
|
1166
1195
|
return { content: [{ type: "text", text: JSON.stringify(job) }], details: { action: "wait", job } };
|
|
1167
1196
|
}
|
|
1168
1197
|
if (!params.directory) throw new Error(`workspace_cache action=${params.action} requires directory`);
|
|
@@ -1810,10 +1839,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1810
1839
|
directories: Type.Array(Type.String(), { description: "Project directories to search, each absolute or relative to the current working directory" }),
|
|
1811
1840
|
query: Type.String({ description: "Symbol name (or substring) to search for" }),
|
|
1812
1841
|
timeoutMs: Type.Optional(Type.Number({ description: "How long to wait per project before reporting it as still-loading; defaults to 3000" })),
|
|
1842
|
+
maxResults: Type.Optional(
|
|
1843
|
+
Type.Number({ description: "Maximum matches to return per project (not total across every project); defaults to a conservative per-project bound" }),
|
|
1844
|
+
),
|
|
1813
1845
|
}),
|
|
1814
1846
|
async execute(_toolCallId, params) {
|
|
1815
1847
|
const directories = params.directories.map((directory) => resolve(cwd, directory));
|
|
1816
|
-
const results = await crossWorkspaceSearchOperations.findSymbols(params.query, directories, params.timeoutMs);
|
|
1848
|
+
const results = await crossWorkspaceSearchOperations.findSymbols(params.query, directories, params.timeoutMs, params.maxResults);
|
|
1817
1849
|
return { content: [{ type: "text", text: JSON.stringify(results) }], details: { results } };
|
|
1818
1850
|
},
|
|
1819
1851
|
renderCall(args, theme, context) {
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { dirname, extname, parse } from "node:path";
|
|
1
|
+
import { dirname, extname } from "node:path";
|
|
3
2
|
import {
|
|
4
3
|
connectLectorClient,
|
|
5
|
-
descriptorForExtension,
|
|
6
|
-
LANGUAGE_SERVER_DESCRIPTORS,
|
|
7
4
|
type LectorClient,
|
|
8
5
|
LectorDaemonUnavailable,
|
|
9
6
|
type OperationInputs,
|
|
@@ -11,25 +8,32 @@ import {
|
|
|
11
8
|
type OperationOutputs,
|
|
12
9
|
remoteErrorIs,
|
|
13
10
|
type WorkspaceId,
|
|
11
|
+
type WorkspaceResolutionRequest,
|
|
14
12
|
} from "@danypops/lector";
|
|
15
13
|
import { createRetryingClient, isLikelyStaleConnectionError, type RetryingClient } from "@danypops/vehicle-client/daemon-client";
|
|
16
|
-
import {
|
|
14
|
+
import { logClientDiagnosticEvent } from "./client-diagnostics.ts";
|
|
17
15
|
|
|
18
16
|
/**
|
|
19
|
-
* Lazily connects to a running Lector daemon and caches, per
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* not cached, so the very next tool call retries once the daemon is
|
|
24
|
-
* actually running.
|
|
17
|
+
* Lazily connects to a running Lector daemon and caches, per resolution request, the
|
|
18
|
+
* workspace it resolves to. Never auto-spawns the daemon: a clear "start it with `lector serve`"
|
|
19
|
+
* error is preferable to guessing at a lifecycle the user didn't ask for. A failed connection
|
|
20
|
+
* attempt is not cached, so the very next tool call retries once the daemon is actually running.
|
|
25
21
|
*
|
|
26
|
-
* The daemon binds a new random port on every restart. A client resolved
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
22
|
+
* The daemon binds a new random port on every restart. A client resolved once and cached for
|
|
23
|
+
* the rest of the session would otherwise point at a dead port after any later restart --
|
|
24
|
+
* daemon-kit's createRetryingClient detects that on the failing call itself (not just the first
|
|
25
|
+
* connection attempt) and retries once against a freshly re-resolved client, the same policy
|
|
26
|
+
* this file used to hand-roll and now shares with web-spider's callWebSpider(), papyrus's
|
|
27
|
+
* callService(), and pi-packed's createNatives().
|
|
28
|
+
*
|
|
29
|
+
* Workspace-root resolution itself (which file belongs to which project) is Lector's own
|
|
30
|
+
* server-side concern -- see @danypops/lector's workspace.resolvePath and its own
|
|
31
|
+
* resolveWorkspacePath. This module used to reimplement that same filesystem walk-up locally
|
|
32
|
+
* (nearestGitRoot/nearestProjectRoot/nearestDeclaredWorkspaceRoot); two real, previously-shipped
|
|
33
|
+
* bugs (a project's own root directory silently resolving to its parent, and two sibling
|
|
34
|
+
* monorepo packages in this very repo collapsing onto the same workspaceId) traced directly to
|
|
35
|
+
* that logic living in the wrong process. Every workspaceForXxx below is now a thin RPC wrapper
|
|
36
|
+
* over workspace.resolvePath.
|
|
33
37
|
*/
|
|
34
38
|
|
|
35
39
|
type ClientConnector = () => Promise<LectorClient>;
|
|
@@ -41,15 +45,19 @@ let connector: ClientConnector = () => connectLectorClient();
|
|
|
41
45
|
const retryingClient: RetryingClient<LectorClient> = createRetryingClient(() => connector(), {
|
|
42
46
|
label: "Lector",
|
|
43
47
|
isStaleConnectionError: (error) => error instanceof LectorDaemonUnavailable || isLikelyStaleConnectionError(error),
|
|
48
|
+
// Purely observational, opt-in via LECTOR_CLIENT_DIAG -- see client-diagnostics.ts's own doc
|
|
49
|
+
// comment for the exact RCA gap this closes.
|
|
50
|
+
onEvent: logClientDiagnosticEvent,
|
|
44
51
|
});
|
|
45
|
-
const workspaceIdByRoot = new Map<string, WorkspaceId>();
|
|
46
52
|
|
|
47
53
|
/**
|
|
48
|
-
* Fires exactly once per distinct root, the moment
|
|
49
|
-
* never on a later call that
|
|
54
|
+
* Fires exactly once per distinct root, the moment the daemon itself first registers it --
|
|
55
|
+
* never on a later call that resolves an already-registered root. The single choke point every
|
|
50
56
|
* resolver (workspaceForPath, workspaceForDirectory, workspaceForCodeIntelligencePath,
|
|
51
57
|
* workspaceForPathOrDirectory) funnels through, so this is genuinely "the first time any tool
|
|
52
|
-
* call resolves this workspace," not just the one cwd workspace at session start.
|
|
58
|
+
* call resolves this workspace," not just the one cwd workspace at session start. Driven by
|
|
59
|
+
* workspace.resolvePath's own authoritative `created` flag, not a local cache -- correct even
|
|
60
|
+
* when a different process registered the same root moments earlier.
|
|
53
61
|
*/
|
|
54
62
|
let onNewWorkspace: ((root: string) => void) | undefined;
|
|
55
63
|
|
|
@@ -80,18 +88,41 @@ export async function lectorClient(): Promise<RetryingLectorClient> {
|
|
|
80
88
|
|
|
81
89
|
export interface ResolvedWorkspace {
|
|
82
90
|
workspaceId: WorkspaceId;
|
|
83
|
-
/** The
|
|
91
|
+
/** The root workspace.resolvePath actually registered -- a git root, a language project root, or the filesystem root, never a fixed session cwd. */
|
|
84
92
|
root: string;
|
|
85
93
|
}
|
|
86
94
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Caches by the exact resolution request, not by the discovered root (this process no longer
|
|
97
|
+
* discovers roots itself) -- a repeated call with the identical path+strategy avoids a network
|
|
98
|
+
* round trip; two different files under the same repo each pay one round trip the first time,
|
|
99
|
+
* same as workspace.registerPath's own idempotent registration would cost anyway. A daemon
|
|
100
|
+
* restart wipes its in-memory registry; withWorkspace's own UnknownWorkspace retry (unchanged
|
|
101
|
+
* below) evicts exactly the stale entry this cache produced, the same recovery it always gave.
|
|
102
|
+
*/
|
|
103
|
+
const resolutionCache = new Map<string, ResolvedWorkspace>();
|
|
104
|
+
|
|
105
|
+
function requestCacheKey(request: WorkspaceResolutionRequest): string {
|
|
106
|
+
return JSON.stringify(request, Object.keys(request).sort());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function resolveWorkspace(request: WorkspaceResolutionRequest): Promise<ResolvedWorkspace> {
|
|
110
|
+
const key = requestCacheKey(request);
|
|
111
|
+
const cached = resolutionCache.get(key);
|
|
112
|
+
if (cached) return cached;
|
|
90
113
|
const client = await lectorClient();
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
114
|
+
const outcome = await client.callOnce("workspace.resolvePath", request);
|
|
115
|
+
if (!outcome.found) {
|
|
116
|
+
// Every strategy this function is used for (git-root/language-project-root with an
|
|
117
|
+
// explicit fallback, path-or-directory) always resolves to something server-side --
|
|
118
|
+
// declared-monorepo-root (the one strategy that can legitimately report not found) is
|
|
119
|
+
// never routed through this function, see resolveDeclaredMonorepoRoot below.
|
|
120
|
+
throw new Error(`workspace.resolvePath unexpectedly reported not-found for a fallback-guaranteed strategy: ${key}`);
|
|
121
|
+
}
|
|
122
|
+
const resolved: ResolvedWorkspace = { workspaceId: outcome.workspaceId, root: outcome.root };
|
|
123
|
+
resolutionCache.set(key, resolved);
|
|
124
|
+
if (outcome.created) onNewWorkspace?.(outcome.root);
|
|
125
|
+
return resolved;
|
|
95
126
|
}
|
|
96
127
|
|
|
97
128
|
/**
|
|
@@ -99,9 +130,8 @@ async function workspaceForRoot(root: string): Promise<ResolvedWorkspace> {
|
|
|
99
130
|
* contains this absolute FILE path -- never a session's original cwd.
|
|
100
131
|
* Files under the same repo share one cached workspace+id; a path under a
|
|
101
132
|
* different repo (or outside any repo entirely) gets its own, registered
|
|
102
|
-
* on demand
|
|
103
|
-
*
|
|
104
|
-
* tools always have -- not just paths under wherever the session started.
|
|
133
|
+
* on demand. This is what makes read/write/edit work for *any* absolute
|
|
134
|
+
* path in one session, exactly like Pi's built-in tools always have.
|
|
105
135
|
*
|
|
106
136
|
* Falls back to the filesystem root when no enclosing git repo exists:
|
|
107
137
|
* unlike workspaceForDirectory, any absolute path is fair game here (a
|
|
@@ -110,9 +140,7 @@ async function workspaceForRoot(root: string): Promise<ResolvedWorkspace> {
|
|
|
110
140
|
* built-in read/write/edit already allow.
|
|
111
141
|
*/
|
|
112
142
|
export function workspaceForPath(absolutePath: string): Promise<ResolvedWorkspace> {
|
|
113
|
-
|
|
114
|
-
const root = nearestGitRoot(directory) ?? parse(directory).root;
|
|
115
|
-
return workspaceForRoot(root);
|
|
143
|
+
return resolveWorkspace({ strategy: "git-root", path: dirname(absolutePath), fallback: "filesystem-root" });
|
|
116
144
|
}
|
|
117
145
|
|
|
118
146
|
/**
|
|
@@ -124,8 +152,19 @@ export function workspaceForPath(absolutePath: string): Promise<ResolvedWorkspac
|
|
|
124
152
|
* outside the project) and unbounded (scanning the whole disk).
|
|
125
153
|
*/
|
|
126
154
|
export function workspaceForDirectory(directory: string): Promise<ResolvedWorkspace> {
|
|
127
|
-
|
|
128
|
-
|
|
155
|
+
return resolveWorkspace({ strategy: "git-root", path: directory, fallback: "given-directory" });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Honest "does a real git repo exist here at all" -- unlike workspaceForDirectory, no fallback
|
|
160
|
+
* masks a non-project directory as its own root. Used by session_start to decide whether cwd
|
|
161
|
+
* looks like a real project worth auto-populating a cache for, never a bare scratch/home
|
|
162
|
+
* directory.
|
|
163
|
+
*/
|
|
164
|
+
export async function nearestGitWorkspaceRoot(directory: string): Promise<string | undefined> {
|
|
165
|
+
const client = await lectorClient();
|
|
166
|
+
const outcome = await client.call("workspace.resolvePath", { strategy: "git-root", path: directory });
|
|
167
|
+
return outcome.found ? outcome.root : undefined;
|
|
129
168
|
}
|
|
130
169
|
|
|
131
170
|
/**
|
|
@@ -141,15 +180,14 @@ export function workspaceForDirectory(directory: string): Promise<ResolvedWorksp
|
|
|
141
180
|
* gets that subproject's rootUri instead of the whole repo's.
|
|
142
181
|
*/
|
|
143
182
|
export function workspaceForCodeIntelligencePath(absolutePath: string): Promise<ResolvedWorkspace> {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
183
|
+
return resolveWorkspace({
|
|
184
|
+
strategy: "language-project-root",
|
|
185
|
+
path: dirname(absolutePath),
|
|
186
|
+
fallback: "given-directory",
|
|
187
|
+
extension: extname(absolutePath),
|
|
188
|
+
});
|
|
148
189
|
}
|
|
149
190
|
|
|
150
|
-
/** Every known language's own rootMarkers, deduplicated -- see workspaceForProjectDirectory. */
|
|
151
|
-
const ALL_PROJECT_ROOT_MARKERS: readonly string[] = [...new Set(LANGUAGE_SERVER_DESCRIPTORS.flatMap((descriptor) => descriptor.rootMarkers))];
|
|
152
|
-
|
|
153
191
|
/**
|
|
154
192
|
* Resolves a caller-supplied directory to its OWN nearest project root -- never the outer repo's
|
|
155
193
|
* git root -- so distinct sibling packages under one monorepo stay distinct workspaces. Unlike
|
|
@@ -162,13 +200,11 @@ const ALL_PROJECT_ROOT_MARKERS: readonly string[] = [...new Set(LANGUAGE_SERVER_
|
|
|
162
200
|
*
|
|
163
201
|
* Unlike workspaceForCodeIntelligencePath, there is no single file (and therefore no known
|
|
164
202
|
* extension) to pick one specific language's markers from -- a caller-supplied directory could
|
|
165
|
-
* be any language, so
|
|
166
|
-
*
|
|
167
|
-
* internally (it appends ".git" to whatever marker list it's given).
|
|
203
|
+
* be any language, so the daemon checks the union of every known language's rootMarkers when no
|
|
204
|
+
* extension is given.
|
|
168
205
|
*/
|
|
169
206
|
export function workspaceForProjectDirectory(directory: string): Promise<ResolvedWorkspace> {
|
|
170
|
-
|
|
171
|
-
return workspaceForRoot(root);
|
|
207
|
+
return resolveWorkspace({ strategy: "language-project-root", path: directory, fallback: "given-directory" });
|
|
172
208
|
}
|
|
173
209
|
|
|
174
210
|
/**
|
|
@@ -180,19 +216,67 @@ export function workspaceForProjectDirectory(directory: string): Promise<Resolve
|
|
|
180
216
|
* dirname() strips its final segment, silently resolving to the *parent*
|
|
181
217
|
* directory's own nearest git root instead -- for a project nested one level
|
|
182
218
|
* under a broader already-registered workspace, this mixes in every sibling
|
|
183
|
-
* project's own graph, with no error at all.
|
|
184
|
-
* itself a real, existing directory first; only takes dirname() when
|
|
185
|
-
* not (a file, or a not-yet-existing path).
|
|
219
|
+
* project's own graph, with no error at all. The daemon checks whether the
|
|
220
|
+
* path is itself a real, existing directory first; only takes dirname() when
|
|
221
|
+
* it is not (a file, or a not-yet-existing path).
|
|
186
222
|
*/
|
|
187
223
|
export function workspaceForPathOrDirectory(path: string): Promise<ResolvedWorkspace> {
|
|
188
|
-
|
|
189
|
-
|
|
224
|
+
return resolveWorkspace({ strategy: "path-or-directory", path });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Raised by workspaceForAnnotationPath for a path that does not exist on disk at all -- a distinct, explicit failure the caller must handle, never silently guessed as "must be a file, take its dirname()." */
|
|
228
|
+
export class AnnotationPathDoesNotExist extends Error {
|
|
229
|
+
constructor(readonly path: string) {
|
|
230
|
+
super(`"${path}" does not exist -- a symbol-annotation scope must be a real project directory or an existing source file`);
|
|
231
|
+
this.name = "AnnotationPathDoesNotExist";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* For symbol-annotation's own `path` parameter, which genuinely means "which workspace does this
|
|
237
|
+
* belong to" and can honestly be either an existing project directory or one specific source
|
|
238
|
+
* file -- unlike workspaceForCodeIntelligencePath (files only, dirname() unconditional), a real
|
|
239
|
+
* project directory resolves via its own language markers, not its parent's. A genuinely
|
|
240
|
+
* nonexistent path throws AnnotationPathDoesNotExist rather than being silently treated as a file.
|
|
241
|
+
*/
|
|
242
|
+
export async function workspaceForAnnotationPath(path: string): Promise<ResolvedWorkspace> {
|
|
243
|
+
const request: WorkspaceResolutionRequest = { strategy: "code-intelligence-path-or-directory", path };
|
|
244
|
+
const key = requestCacheKey(request);
|
|
245
|
+
const cached = resolutionCache.get(key);
|
|
246
|
+
if (cached) return cached;
|
|
247
|
+
const client = await lectorClient();
|
|
248
|
+
const outcome = await client.callOnce("workspace.resolvePath", request);
|
|
249
|
+
if (!outcome.found) {
|
|
250
|
+
if (outcome.reason === "nonexistent-path") throw new AnnotationPathDoesNotExist(path);
|
|
251
|
+
throw new Error(`workspace.resolvePath unexpectedly reported not-found for code-intelligence-path-or-directory: ${path}`);
|
|
252
|
+
}
|
|
253
|
+
const resolved: ResolvedWorkspace = { workspaceId: outcome.workspaceId, root: outcome.root };
|
|
254
|
+
resolutionCache.set(key, resolved);
|
|
255
|
+
if (outcome.created) onNewWorkspace?.(outcome.root);
|
|
256
|
+
return resolved;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The nearest ancestor of an already-resolved project root whose own package.json declares that
|
|
261
|
+
* project as a workspace member via npm/yarn/bun's "workspaces" field -- undefined (no
|
|
262
|
+
* directory-itself/filesystem-root fallback) when no such ancestor exists, a real and expected
|
|
263
|
+
* outcome for a plain single-package repo. Used only by reference-based-rename's own
|
|
264
|
+
* widen-and-retry: on ReferenceBasedRenameRequiresFreshGraph, retry once against the declared
|
|
265
|
+
* monorepo root instead of the narrower project the rename was first attempted against.
|
|
266
|
+
* Deliberately uncached (a rare retry path, not a hot loop).
|
|
267
|
+
*/
|
|
268
|
+
export async function workspaceForDeclaredMonorepoRoot(projectRoot: string): Promise<ResolvedWorkspace | undefined> {
|
|
269
|
+
const client = await lectorClient();
|
|
270
|
+
const outcome = await client.callOnce("workspace.resolvePath", { strategy: "declared-monorepo-root", path: projectRoot });
|
|
271
|
+
if (!outcome.found) return undefined;
|
|
272
|
+
if (outcome.created) onNewWorkspace?.(outcome.root);
|
|
273
|
+
return { workspaceId: outcome.workspaceId, root: outcome.root };
|
|
190
274
|
}
|
|
191
275
|
|
|
192
276
|
/**
|
|
193
277
|
* Resolves a workspace via `resolve`, then calls `perform` with it. A daemon
|
|
194
278
|
* restart wipes its in-memory workspace registry (workspace ids are not
|
|
195
|
-
* persisted across restarts by design), but this module's own
|
|
279
|
+
* persisted across restarts by design), but this module's own resolution
|
|
196
280
|
* cache does not know that on its own -- a call through a stale cached id
|
|
197
281
|
* fails with UnknownWorkspace even though the underlying files on disk
|
|
198
282
|
* never changed. On exactly that failure, the stale cache entry is dropped
|
|
@@ -208,30 +292,34 @@ export async function withWorkspace<T>(resolve: () => Promise<ResolvedWorkspace>
|
|
|
208
292
|
return await perform(resolved);
|
|
209
293
|
} catch (error) {
|
|
210
294
|
if (attempt === 1 || !remoteErrorIs(error, "UnknownWorkspace")) throw error;
|
|
211
|
-
|
|
295
|
+
forgetWorkspaceId(resolved.root);
|
|
212
296
|
}
|
|
213
297
|
}
|
|
214
298
|
throw new Error("Lector workspace resolution retry exhausted");
|
|
215
299
|
}
|
|
216
300
|
|
|
217
301
|
/**
|
|
218
|
-
* Drops
|
|
219
|
-
* withWorkspace's own single-workspace
|
|
220
|
-
*
|
|
221
|
-
*
|
|
302
|
+
* Drops every cache entry resolved to this root without retrying anything itself -- the batch
|
|
303
|
+
* sibling of withWorkspace's own single-workspace recovery, for a caller (cross-workspace
|
|
304
|
+
* search's fan-out) that resolves many roots at once and needs to evict only the specific ones a
|
|
305
|
+
* daemon restart actually invalidated, not the whole cache. A root can appear under more than one
|
|
306
|
+
* cache key (workspaceForPath and workspaceForDirectory can each independently resolve to the
|
|
307
|
+
* same root for related paths), so this scans by value, not a single key lookup.
|
|
222
308
|
*/
|
|
223
309
|
export function forgetWorkspaceId(root: string): void {
|
|
224
|
-
|
|
310
|
+
for (const [key, resolved] of resolutionCache) {
|
|
311
|
+
if (resolved.root === root) resolutionCache.delete(key);
|
|
312
|
+
}
|
|
225
313
|
}
|
|
226
314
|
|
|
227
315
|
export function setLectorClientConnectorForTests(value: ClientConnector): void {
|
|
228
316
|
retryingClient.reset();
|
|
229
|
-
|
|
317
|
+
resolutionCache.clear();
|
|
230
318
|
connector = value;
|
|
231
319
|
}
|
|
232
320
|
|
|
233
321
|
export function resetLectorClientForTests(): void {
|
|
234
322
|
retryingClient.reset();
|
|
235
|
-
|
|
323
|
+
resolutionCache.clear();
|
|
236
324
|
connector = () => connectLectorClient();
|
|
237
325
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { type OperationOutputs, remoteErrorIs } from "@danypops/lector";
|
|
2
|
-
import { lectorClient, type ResolvedWorkspace, withWorkspace, workspaceForCodeIntelligencePath,
|
|
3
|
-
import { nearestDeclaredWorkspaceRoot } from "../nearest-workspace-root.ts";
|
|
2
|
+
import { lectorClient, type ResolvedWorkspace, withWorkspace, workspaceForCodeIntelligencePath, workspaceForDeclaredMonorepoRoot } from "../lector-client.ts";
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* Thin wrapper over Lector's non-LSP reference-based rename: moves a file and rewrites every
|
|
@@ -31,10 +30,10 @@ export function createReferenceBasedRenameOperations(): ReferenceBasedRenameOper
|
|
|
31
30
|
return await withWorkspace(() => workspaceForCodeIntelligencePath(fromPath), performRename);
|
|
32
31
|
} catch (error) {
|
|
33
32
|
if (!remoteErrorIs(error, "ReferenceBasedRenameRequiresFreshGraph")) throw error;
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
if (!
|
|
37
|
-
return withWorkspace(() =>
|
|
33
|
+
const narrow = await workspaceForCodeIntelligencePath(fromPath);
|
|
34
|
+
const declared = await workspaceForDeclaredMonorepoRoot(narrow.root);
|
|
35
|
+
if (!declared) throw error;
|
|
36
|
+
return withWorkspace(() => Promise.resolve(declared), performRename);
|
|
38
37
|
}
|
|
39
38
|
},
|
|
40
39
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OperationInputs, OperationOutputs } from "@danypops/lector";
|
|
2
|
-
import { lectorClient, withWorkspace,
|
|
2
|
+
import { lectorClient, withWorkspace, workspaceForAnnotationPath } from "../lector-client.ts";
|
|
3
3
|
|
|
4
4
|
/** A bare symbol position an anchor is given as -- symbolNodeId and the anchor's baseline file hash are derived server-side, never supplied by the caller. */
|
|
5
5
|
export interface AnnotationAnchorInput {
|
|
@@ -9,10 +9,12 @@ export interface AnnotationAnchorInput {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Thin wrappers over Lector's annotation operations.
|
|
13
|
-
* via
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* Thin wrappers over Lector's annotation operations. Every operation resolves its workspace from
|
|
13
|
+
* its own `path` parameter via workspaceForAnnotationPath -- a real project directory or an
|
|
14
|
+
* existing source file, never dirname()'d unconditionally the way a plain code-intelligence
|
|
15
|
+
* path is. `path` here means "which workspace this call belongs to," not necessarily one of the
|
|
16
|
+
* operation's own anchors (create/refresh's anchors carry their own, separately-validated paths
|
|
17
|
+
* server-side).
|
|
16
18
|
*/
|
|
17
19
|
export interface SymbolAnnotationOperations {
|
|
18
20
|
create(
|
|
@@ -46,7 +48,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
|
|
|
46
48
|
return {
|
|
47
49
|
async create(path, subtype, title, body, anchors) {
|
|
48
50
|
return withWorkspace(
|
|
49
|
-
() =>
|
|
51
|
+
() => workspaceForAnnotationPath(path),
|
|
50
52
|
async ({ workspaceId }) => {
|
|
51
53
|
const client = await lectorClient();
|
|
52
54
|
return client.callOnce("workspace.createAnnotation", { workspaceId, subtype, title, body, anchors });
|
|
@@ -55,7 +57,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
|
|
|
55
57
|
},
|
|
56
58
|
async get(path, id) {
|
|
57
59
|
return withWorkspace(
|
|
58
|
-
() =>
|
|
60
|
+
() => workspaceForAnnotationPath(path),
|
|
59
61
|
async ({ workspaceId }) => {
|
|
60
62
|
const client = await lectorClient();
|
|
61
63
|
return client.call("workspace.getAnnotation", { workspaceId, id });
|
|
@@ -64,7 +66,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
|
|
|
64
66
|
},
|
|
65
67
|
async list(path, options = {}) {
|
|
66
68
|
return withWorkspace(
|
|
67
|
-
() =>
|
|
69
|
+
() => workspaceForAnnotationPath(path),
|
|
68
70
|
async ({ workspaceId }) => {
|
|
69
71
|
const client = await lectorClient();
|
|
70
72
|
return client.call("workspace.listAnnotations", {
|
|
@@ -79,7 +81,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
|
|
|
79
81
|
},
|
|
80
82
|
async refresh(path, id, subtype, title, body, anchors) {
|
|
81
83
|
return withWorkspace(
|
|
82
|
-
() =>
|
|
84
|
+
() => workspaceForAnnotationPath(path),
|
|
83
85
|
async ({ workspaceId }) => {
|
|
84
86
|
const client = await lectorClient();
|
|
85
87
|
return client.callOnce("workspace.refreshAnnotation", { workspaceId, id, subtype, title, body, anchors });
|
|
@@ -88,7 +90,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
|
|
|
88
90
|
},
|
|
89
91
|
async scrub(path, id) {
|
|
90
92
|
return withWorkspace(
|
|
91
|
-
() =>
|
|
93
|
+
() => workspaceForAnnotationPath(path),
|
|
92
94
|
async ({ workspaceId }) => {
|
|
93
95
|
const client = await lectorClient();
|
|
94
96
|
return client.callOnce("workspace.scrubAnnotation", { workspaceId, id });
|
|
@@ -97,7 +99,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
|
|
|
97
99
|
},
|
|
98
100
|
async restore(path, id) {
|
|
99
101
|
return withWorkspace(
|
|
100
|
-
() =>
|
|
102
|
+
() => workspaceForAnnotationPath(path),
|
|
101
103
|
async ({ workspaceId }) => {
|
|
102
104
|
const client = await lectorClient();
|
|
103
105
|
return client.callOnce("workspace.restoreAnnotation", { workspaceId, id });
|
|
@@ -106,7 +108,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
|
|
|
106
108
|
},
|
|
107
109
|
async contain(path, parentId, childId) {
|
|
108
110
|
return withWorkspace(
|
|
109
|
-
() =>
|
|
111
|
+
() => workspaceForAnnotationPath(path),
|
|
110
112
|
async ({ workspaceId }) => {
|
|
111
113
|
const client = await lectorClient();
|
|
112
114
|
return client.callOnce("workspace.containAnnotation", { workspaceId, parentId, childId });
|
|
@@ -115,7 +117,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
|
|
|
115
117
|
},
|
|
116
118
|
async uncontain(path, parentId, childId) {
|
|
117
119
|
return withWorkspace(
|
|
118
|
-
() =>
|
|
120
|
+
() => workspaceForAnnotationPath(path),
|
|
119
121
|
async ({ workspaceId }) => {
|
|
120
122
|
const client = await lectorClient();
|
|
121
123
|
return client.callOnce("workspace.uncontainAnnotation", { workspaceId, parentId, childId });
|
|
@@ -124,7 +126,7 @@ export function createLectorSymbolAnnotationOperations(): SymbolAnnotationOperat
|
|
|
124
126
|
},
|
|
125
127
|
async tree(path, rootId, maxDepth) {
|
|
126
128
|
return withWorkspace(
|
|
127
|
-
() =>
|
|
129
|
+
() => workspaceForAnnotationPath(path),
|
|
128
130
|
async ({ workspaceId }) => {
|
|
129
131
|
const client = await lectorClient();
|
|
130
132
|
return client.call("workspace.annotationTree", { workspaceId, rootId, maxDepth });
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type CacheResultCounts,
|
|
3
|
+
type JobSnapshot,
|
|
4
|
+
type PopulateSymbolGraphResult,
|
|
5
|
+
remoteErrorIs,
|
|
6
|
+
resolveLectorDaemonConnection,
|
|
7
|
+
type WorkspaceCacheStatus,
|
|
8
|
+
} from "@danypops/lector";
|
|
2
9
|
import { connectPushChannel } from "@danypops/vehicle-client/daemon-client";
|
|
3
10
|
import { lectorClient, withWorkspace, workspaceForProjectDirectory } from "../lector-client.ts";
|
|
4
11
|
|
|
@@ -79,7 +86,7 @@ export type CachePresentationState =
|
|
|
79
86
|
| { readonly status: "not-cached"; readonly reason: string }
|
|
80
87
|
| { readonly status: "caching"; readonly jobId: string }
|
|
81
88
|
| { readonly status: "finished-caching"; readonly job: JobSnapshot<PopulateSymbolGraphResult> & { readonly status: "succeeded" } }
|
|
82
|
-
| { readonly status: "partial"; readonly result:
|
|
89
|
+
| { readonly status: "partial"; readonly result: CacheResultCounts }
|
|
83
90
|
| { readonly status: "cached" };
|
|
84
91
|
|
|
85
92
|
export function describeCacheState(state: CachePresentationState): string {
|
|
@@ -117,12 +124,27 @@ export interface WaitForJobCompletionOptions {
|
|
|
117
124
|
readonly sleep?: (ms: number) => Promise<void>;
|
|
118
125
|
}
|
|
119
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Exhaustive outcome of waiting on one job -- every path monitorWorkspaceCache must reconcile
|
|
129
|
+
* against authoritative workspace.cacheStatus, distinct from a genuine terminal result:
|
|
130
|
+
* "job-not-found" (the id expired, was evicted, or survives from a since-restarted daemon),
|
|
131
|
+
* "timed-out" (the bounded poll budget was spent and the job is still non-terminal), and
|
|
132
|
+
* "transport-failed" (the daemon couldn't be reached at all) all mean "we no longer know this
|
|
133
|
+
* job's real state" -- never treated as if the job were still "caching" forever.
|
|
134
|
+
*/
|
|
135
|
+
export type JobCompletionOutcome =
|
|
136
|
+
| { readonly kind: "terminal"; readonly job: JobSnapshot<PopulateSymbolGraphResult> & { readonly status: "succeeded" | "failed" } }
|
|
137
|
+
| { readonly kind: "timed-out" }
|
|
138
|
+
| { readonly kind: "canceled" }
|
|
139
|
+
| { readonly kind: "job-not-found" }
|
|
140
|
+
| { readonly kind: "transport-failed"; readonly error: unknown };
|
|
141
|
+
|
|
120
142
|
/** Waits on Vehicle push delivery and checks status on a bounded cadence when push is unavailable or disconnected. */
|
|
121
143
|
export async function waitForJobCompletion(
|
|
122
144
|
operations: WorkspaceCacheOperations,
|
|
123
145
|
jobId: string,
|
|
124
146
|
options: WaitForJobCompletionOptions,
|
|
125
|
-
): Promise<
|
|
147
|
+
): Promise<JobCompletionOutcome> {
|
|
126
148
|
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
127
149
|
let pushedResolve: ((job: JobSnapshot<PopulateSymbolGraphResult>) => void) | undefined;
|
|
128
150
|
const pushed = new Promise<JobSnapshot<PopulateSymbolGraphResult>>((resolve) => {
|
|
@@ -135,17 +157,29 @@ export async function waitForJobCompletion(
|
|
|
135
157
|
});
|
|
136
158
|
const onAbort = () => resolveAbort();
|
|
137
159
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
160
|
+
|
|
161
|
+
async function checkStatus(): Promise<JobCompletionOutcome | undefined> {
|
|
162
|
+
try {
|
|
163
|
+
const current = await operations.jobStatus(jobId);
|
|
164
|
+
if (current.status === "succeeded" || current.status === "failed") return { kind: "terminal", job: current };
|
|
165
|
+
return undefined;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (remoteErrorIs(error, "JobNotFound")) return { kind: "job-not-found" };
|
|
168
|
+
return { kind: "transport-failed", error };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
138
172
|
try {
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
173
|
+
const subscription = await operations.watchJob?.(jobId, (job) => pushedResolve?.(job));
|
|
174
|
+
if (subscription?.status === "subscribed") watch = subscription.handle;
|
|
141
175
|
for (let poll = 0; poll < options.maxPolls && options.shouldContinue() && !options.signal?.aborted; poll++) {
|
|
142
|
-
const
|
|
143
|
-
if (
|
|
176
|
+
const checked = await checkStatus();
|
|
177
|
+
if (checked) return checked;
|
|
144
178
|
const next = await Promise.race([pushed, sleep(options.pollIntervalMs).then(() => undefined), aborted.then(() => undefined)]);
|
|
145
|
-
if (next?.status === "succeeded" || next?.status === "failed") return next;
|
|
179
|
+
if (next?.status === "succeeded" || next?.status === "failed") return { kind: "terminal", job: next };
|
|
146
180
|
}
|
|
147
|
-
if (options.shouldContinue()
|
|
148
|
-
return
|
|
181
|
+
if (!options.shouldContinue() || options.signal?.aborted) return { kind: "canceled" };
|
|
182
|
+
return (await checkStatus()) ?? { kind: "timed-out" };
|
|
149
183
|
} finally {
|
|
150
184
|
options.signal?.removeEventListener("abort", onAbort);
|
|
151
185
|
watch?.close();
|
|
@@ -186,12 +220,31 @@ export async function monitorWorkspaceCache(operations: WorkspaceCacheOperations
|
|
|
186
220
|
}
|
|
187
221
|
options.onState({ status: "caching", jobId });
|
|
188
222
|
|
|
189
|
-
const
|
|
223
|
+
const outcome = await waitForJobCompletion(operations, jobId, {
|
|
190
224
|
pollIntervalMs: options.pollIntervalMs,
|
|
191
225
|
maxPolls: options.maxPolls,
|
|
192
226
|
shouldContinue: options.shouldContinue,
|
|
193
227
|
sleep,
|
|
194
228
|
});
|
|
195
|
-
if (
|
|
196
|
-
if (
|
|
229
|
+
if (outcome.kind === "canceled") return;
|
|
230
|
+
if (outcome.kind === "terminal") {
|
|
231
|
+
if (outcome.job.status === "failed") throw new Error(`${outcome.job.error.code}: ${outcome.job.error.message}`);
|
|
232
|
+
reportCompleted(outcome.job);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// job-not-found, timed-out, and transport-failed all mean the same thing here: this specific
|
|
236
|
+
// watch no longer knows the job's real state. Never leave the last-reported "caching" message
|
|
237
|
+
// standing -- ask the daemon's own authoritative record what is actually true right now.
|
|
238
|
+
const reconciled = await operations.status(options.directory, options.maxFiles, options.maxSymbolsPerFile);
|
|
239
|
+
if (reconciled.status === "cached") {
|
|
240
|
+
options.onState({ status: "cached" });
|
|
241
|
+
} else if (reconciled.status === "partial") {
|
|
242
|
+
options.onState({ status: "partial", result: reconciled.generation.result });
|
|
243
|
+
} else if (reconciled.status === "not-cached") {
|
|
244
|
+
options.onState({ status: "not-cached", reason: reconciled.reason });
|
|
245
|
+
} else {
|
|
246
|
+
// Still genuinely caching or queued behind resource admission under a fresh check --
|
|
247
|
+
// an accurate report, not a stale one, even though the presented status string repeats.
|
|
248
|
+
options.onState({ status: "caching", jobId: reconciled.jobId });
|
|
249
|
+
}
|
|
197
250
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { JobSnapshot, PopulateSymbolGraphResult, WorkspaceCacheStatus } from "@danypops/lector";
|
|
1
|
+
import type { CacheResultCounts, JobSnapshot, PopulateSymbolGraphResult, WorkspaceCacheStatus } from "@danypops/lector";
|
|
2
2
|
import type { LectorTheme } from "../lector-tui-theme.ts";
|
|
3
3
|
|
|
4
4
|
type WorkspaceCacheAction = "status" | "populate" | "wait" | "job_status";
|
|
@@ -23,7 +23,7 @@ export function formatWorkspaceCacheCall(
|
|
|
23
23
|
return `${label} ${theme.fg("accent", action)} ${theme.fg("dim", directory)}${bounds}`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function formatResultCounts(result:
|
|
26
|
+
function formatResultCounts(result: CacheResultCounts): string {
|
|
27
27
|
const failed = result.filesFailed > 0 ? `, ${result.filesFailed} failed` : "";
|
|
28
28
|
return `${result.filesProcessed}/${result.filesAttempted} files${failed}, ${result.symbolsProcessed} symbols, ${result.nodesAdded} nodes, ${result.edgesAdded} edges`;
|
|
29
29
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-lector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.7",
|
|
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",
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
"typebox": "*"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@danypops/vehicle-client": "^0.2
|
|
21
|
+
"@danypops/vehicle-client": "^0.8.2",
|
|
22
22
|
"@danypops/lector": "^0.18.0",
|
|
23
|
-
"malevich-tui-components": "^0.
|
|
23
|
+
"malevich-tui-components": "^0.25.0",
|
|
24
24
|
"picomatch": "^4.0.5"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join, parse, relative } from "node:path";
|
|
3
|
-
import picomatch from "picomatch";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The bare filesystem root is never a legitimate discovered project root, even if it happens
|
|
7
|
-
* to contain a marker file (a stray `git init /`, a leftover `package.json`) -- matches the
|
|
8
|
-
* same convention already established elsewhere in this house (oculus/survey/rust_scanner.go's
|
|
9
|
-
* findCrateRoot, oculus/locator/match.go's effectiveParent: reaching "/" during a walk-up means
|
|
10
|
-
* "not found", never "found here"). Confirmed live: without this, a Lector daemon registered
|
|
11
|
-
* "/" as a workspace and a background job attempted to symbol-graph the entire filesystem.
|
|
12
|
-
* `exists` is injectable so a test can simulate "a marker exists at the filesystem root"
|
|
13
|
-
* without ever touching the real one.
|
|
14
|
-
*/
|
|
15
|
-
function walkUpForMarkers(startDirectory: string, markers: readonly string[], exists: (path: string) => boolean = existsSync): string | undefined {
|
|
16
|
-
let dir = startDirectory;
|
|
17
|
-
const fsRoot = parse(dir).root;
|
|
18
|
-
while (dir !== fsRoot) {
|
|
19
|
-
if (markers.some((marker) => exists(join(dir, marker)))) return dir;
|
|
20
|
-
const parent = dirname(dir);
|
|
21
|
-
if (parent === dir) break; // defensive: dirname must be strictly ascending
|
|
22
|
-
dir = parent;
|
|
23
|
-
}
|
|
24
|
-
return undefined;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* The nearest enclosing git repository root starting from (and including) a given directory,
|
|
29
|
-
* or undefined if none is found (e.g. /tmp scratch files, dotfiles outside any repo, or the
|
|
30
|
-
* walk reaching the filesystem root without a match). Callers choose their own fallback -- see
|
|
31
|
-
* lector-client.ts's workspaceForPath (falls back to the filesystem root: any absolute path is
|
|
32
|
-
* fair game for read/write/edit, exactly as Pi's built-in tools already allow) vs.
|
|
33
|
-
* workspaceForDirectory (falls back to the directory itself: widening a symbol-search scope all
|
|
34
|
-
* the way to the entire filesystem when a project isn't a git repo would be absurd).
|
|
35
|
-
*
|
|
36
|
-
* This -- not a Pi session's original cwd -- is Lector's real workspace granularity. A session
|
|
37
|
-
* routinely touches many unrelated repos, sibling projects, and scratch paths in one run; Pi's
|
|
38
|
-
* built-in read/write/edit tools have never restricted which absolute path can be touched, and
|
|
39
|
-
* Lector must not either. (Real, shipped bug this fixes: read/write/edit hard-locked to
|
|
40
|
-
* whatever directory the session happened to start in, refusing every legitimate path outside
|
|
41
|
-
* it with a "Lector-registered workspace root" error -- discovered live, in a separate session,
|
|
42
|
-
* working against a completely different, unrelated repository.)
|
|
43
|
-
*
|
|
44
|
-
* `exists` is injectable for tests -- see walkUpForMarkers.
|
|
45
|
-
*/
|
|
46
|
-
/**
|
|
47
|
-
* True for the bare filesystem root itself ("/" on Linux/macOS, "C:\\" on Windows) -- the one
|
|
48
|
-
* path a caller must never treat as a real project to auto-index. workspaceForPath's own
|
|
49
|
-
* intentional fallback for a raw read/write of a file outside any git repo can still produce
|
|
50
|
-
* this value; callers that trigger background work (auto-population, cache monitoring) off a
|
|
51
|
-
* newly-registered workspace must check this explicitly rather than assuming
|
|
52
|
-
* nearestGitRoot/nearestProjectRoot are the only paths that can hand them a workspace root.
|
|
53
|
-
*/
|
|
54
|
-
export function isFilesystemRoot(path: string): boolean {
|
|
55
|
-
return parse(path).root === path;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function nearestGitRoot(startDirectory: string, exists: (path: string) => boolean = existsSync): string | undefined {
|
|
59
|
-
return walkUpForMarkers(startDirectory, [".git"], exists);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Same nearest-enclosing-root walk as nearestGitRoot, but also checks a language's own root
|
|
64
|
-
* markers (tsconfig.json, go.mod, Cargo.toml, ...) at each directory, nearest first -- so a
|
|
65
|
-
* monorepo subproject with its own root marker resolves to itself, not the outer repo's .git.
|
|
66
|
-
* Found via @arvoretech/pi-lsp comparison: without this, a file inside a monorepo subproject
|
|
67
|
-
* misattributes its whole project to the repo root, handing the language server the wrong
|
|
68
|
-
* rootUri (and, for TypeScript, the wrong tsconfig.json) even though a closer one exists.
|
|
69
|
-
*/
|
|
70
|
-
export function nearestProjectRoot(startDirectory: string, rootMarkers: readonly string[], exists: (path: string) => boolean = existsSync): string | undefined {
|
|
71
|
-
return walkUpForMarkers(startDirectory, [...rootMarkers, ".git"], exists);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** An npm/yarn/bun package.json's own "workspaces" field: either a bare glob array, or `{ packages: [...] }` (pnpm's own equivalent shape for the same field, embedded inside package.json rather than a separate pnpm-workspace.yaml). */
|
|
75
|
-
interface WorkspacesManifest {
|
|
76
|
-
workspaces?: string[] | { packages?: string[] };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function readWorkspaceGlobs(packageJsonPath: string, readFile: (path: string) => string): string[] | undefined {
|
|
80
|
-
let parsed: unknown;
|
|
81
|
-
try {
|
|
82
|
-
parsed = JSON.parse(readFile(packageJsonPath));
|
|
83
|
-
} catch {
|
|
84
|
-
return undefined;
|
|
85
|
-
}
|
|
86
|
-
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
87
|
-
const { workspaces } = parsed as WorkspacesManifest;
|
|
88
|
-
const globs = Array.isArray(workspaces) ? workspaces : (workspaces?.packages ?? undefined);
|
|
89
|
-
return Array.isArray(globs) ? globs.filter((entry): entry is string => typeof entry === "string") : undefined;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* The nearest ancestor of a real project root (as found by nearestProjectRoot) whose own
|
|
94
|
-
* package.json declares that project as a workspace member via npm/yarn/bun's "workspaces"
|
|
95
|
-
* field -- never an arbitrary ancestor that merely happens to have its own marker file. Walks
|
|
96
|
-
* upward past ancestors with no "workspaces" field (or one that doesn't actually match this
|
|
97
|
-
* project's relative path) rather than stopping at the first package.json found, since an
|
|
98
|
-
* intermediate directory can be a plain package with no workspaces declaration of its own.
|
|
99
|
-
*
|
|
100
|
-
* Mirrors how mature language tooling handles this same monorepo shape: TypeScript's tsserver
|
|
101
|
-
* only widens a file's project scope to an ancestor "solution" tsconfig that explicitly lists
|
|
102
|
-
* the nearer project in its own `references`, and rust-analyzer treats Cargo's `[workspace]`
|
|
103
|
-
* `members` list as the authoritative multi-crate boundary rather than inferring one from
|
|
104
|
-
* directory structure. This is the same idea applied to npm/yarn/bun's own declared
|
|
105
|
-
* "workspaces" glob instead of a language-specific manifest.
|
|
106
|
-
*
|
|
107
|
-
* Returns undefined (no declared ancestor) for a plain single-package repo, or when no ancestor's
|
|
108
|
-
* "workspaces" globs actually match this project -- callers must not treat an arbitrary git root
|
|
109
|
-
* as an implicit stand-in.
|
|
110
|
-
*/
|
|
111
|
-
export function nearestDeclaredWorkspaceRoot(
|
|
112
|
-
projectRoot: string,
|
|
113
|
-
exists: (path: string) => boolean = existsSync,
|
|
114
|
-
readFile: (path: string) => string = (path) => readFileSync(path, "utf8"),
|
|
115
|
-
): string | undefined {
|
|
116
|
-
let dir = dirname(projectRoot);
|
|
117
|
-
const fsRoot = parse(dir).root;
|
|
118
|
-
while (dir !== fsRoot) {
|
|
119
|
-
const packageJsonPath = join(dir, "package.json");
|
|
120
|
-
if (exists(packageJsonPath)) {
|
|
121
|
-
const globs = readWorkspaceGlobs(packageJsonPath, readFile);
|
|
122
|
-
const relativePath = relative(dir, projectRoot);
|
|
123
|
-
if (globs?.some((glob) => picomatch(glob)(relativePath))) return dir;
|
|
124
|
-
}
|
|
125
|
-
const parent = dirname(dir);
|
|
126
|
-
if (parent === dir) break; // defensive: dirname must be strictly ascending
|
|
127
|
-
dir = parent;
|
|
128
|
-
}
|
|
129
|
-
return undefined;
|
|
130
|
-
}
|