@volter/live 0.5.211 → 0.5.213
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/BUNDLED_NOTICES +13 -13
- package/README.md +10 -11
- package/dist/asset-library.d.ts +98 -0
- package/dist/asset-library.js +7 -0
- package/dist/asset-library.js.map +7 -0
- package/dist/chunk-7WKVJGHZ.js +15 -0
- package/dist/chunk-7WKVJGHZ.js.map +7 -0
- package/dist/chunk-B2HUWFTT.js +25 -0
- package/dist/chunk-B2HUWFTT.js.map +7 -0
- package/dist/chunk-IZJOOON2.js +805 -0
- package/dist/chunk-IZJOOON2.js.map +7 -0
- package/dist/chunk-JDOIPALX.js +158 -0
- package/dist/chunk-JDOIPALX.js.map +7 -0
- package/dist/chunk-JFAHP5CC.js +218 -0
- package/dist/chunk-JFAHP5CC.js.map +7 -0
- package/dist/chunk-PC56TDHV.js +189 -0
- package/dist/chunk-PC56TDHV.js.map +7 -0
- package/dist/chunk-WIAH4XZE.js +82 -0
- package/dist/chunk-WIAH4XZE.js.map +7 -0
- package/dist/client.d.ts +376 -0
- package/dist/client.js +10 -0
- package/dist/client.js.map +7 -0
- package/dist/discovery.d.ts +32 -0
- package/dist/discovery.js +19 -0
- package/dist/discovery.js.map +7 -0
- package/dist/editor-document.d.ts +12 -47
- package/dist/editor.d.ts +45 -183
- package/dist/http-transport.d.ts +6 -0
- package/dist/index.d.ts +3 -6
- package/dist/index.js +83 -1762
- package/dist/index.js.map +4 -4
- package/dist/installed-package.d.ts +6 -0
- package/dist/installed-package.js +7 -0
- package/dist/installed-package.js.map +7 -0
- package/dist/lazy-proxy.d.ts +11 -11
- package/dist/member-hints.d.ts +6 -9
- package/dist/product-create.d.ts +29 -0
- package/dist/product-create.js +13 -0
- package/dist/product-create.js.map +7 -0
- package/dist/product-locator.d.ts +100 -0
- package/dist/product-locator.js +25 -0
- package/dist/product-locator.js.map +7 -0
- package/dist/registry-format.d.ts +72 -0
- package/dist/registry-format.js +31 -0
- package/dist/registry-format.js.map +7 -0
- package/dist/session.d.ts +11 -15
- package/dist/singleton.d.ts +1 -13
- package/dist/volter-manifest.d.ts +55 -0
- package/dist/volter-manifest.js +9 -0
- package/dist/volter-manifest.js.map +7 -0
- package/dist/workbench-locator.d.ts +84 -0
- package/dist/workbench-locator.js +416 -0
- package/dist/workbench-locator.js.map +7 -0
- package/package.json +39 -3
- package/src/asset-library.ts +104 -0
- package/src/client.ts +1093 -0
- package/src/discovery.ts +105 -0
- package/src/editor-document.ts +12 -49
- package/src/editor.ts +56 -215
- package/src/http-transport.ts +19 -0
- package/src/index.ts +5 -8
- package/src/installed-package.ts +15 -0
- package/src/lazy-proxy.ts +11 -11
- package/src/member-hints.ts +8 -12
- package/src/product-create.ts +38 -0
- package/src/product-locator.ts +327 -0
- package/src/registry-format.ts +248 -0
- package/src/session.ts +36 -144
- package/src/singleton.ts +1 -16
- package/src/volter-manifest.ts +97 -0
- package/src/workbench-locator.ts +625 -0
- package/dist/tools.d.ts +0 -23
- package/src/tools.ts +0 -45
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared editor-session registry format: entry shape, guards, path,
|
|
3
|
+
* liveness-filtered reads, served-project parsing and pending launches.
|
|
4
|
+
* The server’s session-registry module owns writes to this format.
|
|
5
|
+
*
|
|
6
|
+
* The registry is also the CREDENTIAL FILE for the owner's own processes: each entry carries its session's
|
|
7
|
+
* connection token, and `sessionFetch` is how a process on this machine reaches a session with it, as a Code-OSS
|
|
8
|
+
* client reads its server's `--connection-token-file` (`src/vs/server/node/serverConnectionToken.ts:62-79` in the
|
|
9
|
+
* `volter-ai/code-oss` fork). The file is the user's alone (0600, written by the session).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
realpathSync,
|
|
17
|
+
unlinkSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from 'node:fs';
|
|
20
|
+
import { homedir } from 'node:os';
|
|
21
|
+
import { join, resolve } from 'node:path';
|
|
22
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
23
|
+
|
|
24
|
+
/** One registry entry, exactly as the server's write half records it. */
|
|
25
|
+
export interface EditorSessionEntry {
|
|
26
|
+
/** Canonical project root currently open, or null when none. */
|
|
27
|
+
project: string | null;
|
|
28
|
+
port: number;
|
|
29
|
+
/** PID of the dev-server process. */
|
|
30
|
+
pid: number;
|
|
31
|
+
/** ISO timestamp of server start. */
|
|
32
|
+
startedAt: string;
|
|
33
|
+
sessionId: string | null;
|
|
34
|
+
/** The session's connection token, which every request to it carries (`sessionFetch`). Never returned by a
|
|
35
|
+
* session route; it is in this user-private file and in the page's cookie. */
|
|
36
|
+
connectionToken?: string | null;
|
|
37
|
+
repositoryId: string | null;
|
|
38
|
+
worktreeId: string | null;
|
|
39
|
+
worktreeRoot: string | null;
|
|
40
|
+
projectRelativePath: string | null;
|
|
41
|
+
branch: string | null;
|
|
42
|
+
headCommit: string | null;
|
|
43
|
+
baseCommit: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const EDITOR_SESSIONS_REGISTRY_FILE = join(homedir(), '.volter', 'editor-sessions.json');
|
|
47
|
+
|
|
48
|
+
export function isEditorSessionEntry(v: unknown): v is EditorSessionEntry {
|
|
49
|
+
if (typeof v !== 'object' || v === null) return false;
|
|
50
|
+
const s = v as Record<string, unknown>;
|
|
51
|
+
const optionalIdentity = (value: unknown) =>
|
|
52
|
+
value === undefined || value === null || typeof value === 'string';
|
|
53
|
+
return (
|
|
54
|
+
(typeof s['project'] === 'string' || s['project'] === null) &&
|
|
55
|
+
typeof s['port'] === 'number' &&
|
|
56
|
+
typeof s['pid'] === 'number' &&
|
|
57
|
+
typeof s['startedAt'] === 'string' &&
|
|
58
|
+
optionalIdentity(s['sessionId']) &&
|
|
59
|
+
optionalIdentity(s['connectionToken']) &&
|
|
60
|
+
optionalIdentity(s['repositoryId']) &&
|
|
61
|
+
optionalIdentity(s['worktreeId']) &&
|
|
62
|
+
optionalIdentity(s['worktreeRoot']) &&
|
|
63
|
+
optionalIdentity(s['projectRelativePath']) &&
|
|
64
|
+
optionalIdentity(s['branch']) &&
|
|
65
|
+
optionalIdentity(s['headCommit']) &&
|
|
66
|
+
optionalIdentity(s['baseCommit'])
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function normalizeEditorSessionEntry(session: EditorSessionEntry): EditorSessionEntry {
|
|
71
|
+
return {
|
|
72
|
+
...session,
|
|
73
|
+
sessionId: session.sessionId ?? null,
|
|
74
|
+
connectionToken: session.connectionToken ?? null,
|
|
75
|
+
repositoryId: session.repositoryId ?? null,
|
|
76
|
+
worktreeId: session.worktreeId ?? null,
|
|
77
|
+
worktreeRoot: session.worktreeRoot ?? null,
|
|
78
|
+
projectRelativePath: session.projectRelativePath ?? null,
|
|
79
|
+
branch: session.branch ?? null,
|
|
80
|
+
headCommit: session.headCommit ?? null,
|
|
81
|
+
baseCommit: session.baseCommit ?? null,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether pid exists. Signal 0's EPERM means it exists but this caller cannot signal it. */
|
|
86
|
+
export function pidAlive(pid: number): boolean {
|
|
87
|
+
try {
|
|
88
|
+
process.kill(pid, 0);
|
|
89
|
+
return true;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The registry's live entries — shape-validated and PID-liveness-filtered. */
|
|
96
|
+
export function readLiveRegisteredSessions(): EditorSessionEntry[] {
|
|
97
|
+
try {
|
|
98
|
+
const raw: unknown = JSON.parse(readFileSync(EDITOR_SESSIONS_REGISTRY_FILE, 'utf8'));
|
|
99
|
+
return Array.isArray(raw)
|
|
100
|
+
? raw
|
|
101
|
+
.filter(isEditorSessionEntry)
|
|
102
|
+
.map(normalizeEditorSessionEntry)
|
|
103
|
+
.filter((s) => pidAlive(s.pid))
|
|
104
|
+
: [];
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The cookie the session reads its connection token from: Code-OSS's own (`connectionTokenCookieName`,
|
|
112
|
+
* `src/vs/base/common/network.ts:187`). The page's Code-OSS server sets it on the page's host from the address's
|
|
113
|
+
* `?tkn=` (`src/vs/server/node/webClientServer.ts:313-336`), and the session's routes share that host through its
|
|
114
|
+
* proxy and the token through the project's token file, so one cookie carries both.
|
|
115
|
+
*/
|
|
116
|
+
export const CONNECTION_TOKEN_COOKIE = 'vscode-tkn';
|
|
117
|
+
|
|
118
|
+
/** The connection token of the live session listening on `port` of this machine, from its entry; null when no live
|
|
119
|
+
* session there registered one. */
|
|
120
|
+
export function registeredConnectionToken(port: number): string | null {
|
|
121
|
+
return readLiveRegisteredSessions().find((session) => session.port === port)?.connectionToken ?? null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
125
|
+
|
|
126
|
+
/** The port of a session `url` names on this machine, or null for any other address. */
|
|
127
|
+
function loopbackSessionPort(url: string | URL): number | null {
|
|
128
|
+
const target = new URL(url);
|
|
129
|
+
return target.protocol === 'http:' && LOOPBACK_HOSTS.has(target.hostname) && target.port !== '' ? Number(target.port) : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** `init` with the connection token of the session `url` names, as the cookie the session reads. Only a loopback
|
|
133
|
+
* address gets one, and only a registered session's: the token never leaves this machine. */
|
|
134
|
+
export function withSessionToken(url: string | URL, init: RequestInit = {}): RequestInit {
|
|
135
|
+
const port = loopbackSessionPort(url);
|
|
136
|
+
const token = port === null ? null : registeredConnectionToken(port);
|
|
137
|
+
if (token === null) return init;
|
|
138
|
+
const headers = new Headers(init.headers);
|
|
139
|
+
headers.set('cookie', `${CONNECTION_TOKEN_COOKIE}=${token}`);
|
|
140
|
+
return { ...init, headers };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Say why a session on this machine refused (403) a request this process could send it no token with: its registry
|
|
145
|
+
* entry, the one place the owner's processes read the token from, is missing or carries none. Throws that; returns
|
|
146
|
+
* when the refusal is something else (a token was sent, or the address is not a local session's).
|
|
147
|
+
*/
|
|
148
|
+
export function refuseWithoutRegisteredToken(url: string | URL, status: number): void {
|
|
149
|
+
if (status !== 403) return;
|
|
150
|
+
const port = loopbackSessionPort(url);
|
|
151
|
+
if (port === null || registeredConnectionToken(port) !== null) return;
|
|
152
|
+
throw new Error(
|
|
153
|
+
`The editor session on port ${port} refused this request: its connection token could not be read, because the ` +
|
|
154
|
+
`session registry ${EDITOR_SESSIONS_REGISTRY_FILE} has no live entry for port ${port} carrying one. The session ` +
|
|
155
|
+
"says why its entry could not be written in its terminal and on its console, and writes it again every minute.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** `fetch` to a session on this machine, carrying its connection token (`withSessionToken`). Every request a process
|
|
160
|
+
* of the owner's makes to a session goes through here or through `EditorClient`, which does the same, and a refusal
|
|
161
|
+
* for want of the token says so (`refuseWithoutRegisteredToken`). */
|
|
162
|
+
export async function sessionFetch(url: string | URL, init?: RequestInit): Promise<Response> {
|
|
163
|
+
const response = await fetch(url, withSessionToken(url, init));
|
|
164
|
+
refuseWithoutRegisteredToken(url, response.status);
|
|
165
|
+
return response;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Announce a launch before the server can register. Each launcher owns its file. */
|
|
169
|
+
export function announceEditorLaunch(project: string): () => void {
|
|
170
|
+
const directory = join(project, '.volter', 'editor-launches');
|
|
171
|
+
mkdirSync(directory, { recursive: true });
|
|
172
|
+
const file = join(directory, `${process.pid}.json`);
|
|
173
|
+
writeFileSync(file, JSON.stringify({ pid: process.pid }));
|
|
174
|
+
const clear = () => {
|
|
175
|
+
process.removeListener('exit', clear);
|
|
176
|
+
try {
|
|
177
|
+
unlinkSync(file);
|
|
178
|
+
} catch {
|
|
179
|
+
// Already cleared when the server became ready.
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
process.once('exit', clear);
|
|
183
|
+
return clear;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function hasPendingEditorLaunch(project: string): boolean {
|
|
187
|
+
const directory = join(project, '.volter', 'editor-launches');
|
|
188
|
+
try {
|
|
189
|
+
return readdirSync(directory).some((name) => {
|
|
190
|
+
if (!/^\d+\.json$/.test(name)) return false;
|
|
191
|
+
const pid = Number(name.slice(0, -5));
|
|
192
|
+
return pid !== process.pid && pidAlive(pid);
|
|
193
|
+
});
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Wait only for an observed live launcher, never for a missing editor. */
|
|
200
|
+
export async function waitForPendingEditorLaunch(project: string): Promise<void> {
|
|
201
|
+
const canon = canonicalizeProjectPath(project);
|
|
202
|
+
const ready = () =>
|
|
203
|
+
readLiveRegisteredSessions().some(
|
|
204
|
+
(session) => session.project !== null && canonicalizeProjectPath(session.project) === canon,
|
|
205
|
+
);
|
|
206
|
+
if (ready() || !hasPendingEditorLaunch(canon)) return;
|
|
207
|
+
const deadline = Date.now() + 180_000;
|
|
208
|
+
while (!ready()) {
|
|
209
|
+
if (!hasPendingEditorLaunch(canon)) {
|
|
210
|
+
// The launcher clears its announcement only after registration.
|
|
211
|
+
if (ready()) return;
|
|
212
|
+
throw new Error(`The editor launch for ${canon} ended before registering a session.`);
|
|
213
|
+
}
|
|
214
|
+
if (Date.now() >= deadline) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`The editor launch for ${canon} is still running but has not registered within 180s.`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
await delay(100);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Read the project identity reported by /__editor/project. A serving path
|
|
224
|
+
* identifies an open project even when its manifest cannot be parsed or
|
|
225
|
+
* validated; project: null alone denotes no described project. */
|
|
226
|
+
export function servedProjectAnswer(body: unknown): {
|
|
227
|
+
path: string | null;
|
|
228
|
+
manifestError: string | null;
|
|
229
|
+
} {
|
|
230
|
+
const b = body as {
|
|
231
|
+
project?: { path?: string } | null;
|
|
232
|
+
serving?: { path?: string; error?: string } | null;
|
|
233
|
+
};
|
|
234
|
+
return {
|
|
235
|
+
path: b.project?.path ?? b.serving?.path ?? null,
|
|
236
|
+
manifestError: b.project ? null : (b.serving?.error ?? null),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Canonical (realpathed when possible) absolute form of a project path. */
|
|
241
|
+
function canonicalizeProjectPath(p: string): string {
|
|
242
|
+
const absolute = resolve(p);
|
|
243
|
+
try {
|
|
244
|
+
return realpathSync(absolute);
|
|
245
|
+
} catch {
|
|
246
|
+
return absolute;
|
|
247
|
+
}
|
|
248
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
1
|
-
/** Resolve an existing session for exactly one project.
|
|
2
|
-
*
|
|
3
|
-
* then consult the shared registry. Never fall back to another project.
|
|
4
|
-
* Storage identifiers move with the server in the coordinated migration. */
|
|
1
|
+
/** Resolve an existing session for exactly one project, from the shared registry.
|
|
2
|
+
* Never fall back to another project. */
|
|
5
3
|
|
|
6
|
-
import { existsSync,
|
|
7
|
-
import { dirname,
|
|
4
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
5
|
+
import { dirname, resolve } from 'node:path';
|
|
8
6
|
import { resolveManifestPath } from '@volter/project/manifest/locate';
|
|
9
|
-
import { resolveProductForProject } from '
|
|
7
|
+
import { resolveProductForProject } from './product-locator.js';
|
|
10
8
|
import {
|
|
11
9
|
EDITOR_SESSION_DISCOVERY_TIMEOUT_MS,
|
|
12
10
|
type EditorSessionInfo,
|
|
13
11
|
type SessionListingTransport as SessionDiscoveryTransport,
|
|
14
12
|
HttpSessionDiscovery,
|
|
15
|
-
servedProjectAnswer,
|
|
16
13
|
withTimeout,
|
|
17
|
-
} from '
|
|
14
|
+
} from './discovery.js';
|
|
18
15
|
|
|
19
16
|
/** Nearest ancestor containing the project's canonical manifest. */
|
|
20
17
|
export function findProjectRootFrom(dir: string): string | null {
|
|
@@ -42,17 +39,6 @@ export interface SessionResolutionDeps {
|
|
|
42
39
|
transport?: SessionListingTransport;
|
|
43
40
|
/** Project-root discovery — defaults to the real fs walk (`findProjectRootFrom` above). Overridable for tests. */
|
|
44
41
|
findProjectRootFrom?: (dir: string) => string | null;
|
|
45
|
-
/** Project-local session hint reader. Defaults to `.volter/session.json`. */
|
|
46
|
-
readProjectSession?: (projectRoot: string) => ProjectSessionHint | null;
|
|
47
|
-
/** Exact-session verifier. Defaults to GET /__editor/project + canonical project matching. */
|
|
48
|
-
verifyProjectSession?: (hint: ProjectSessionHint, projectRoot: string) => Promise<boolean>;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface ProjectSessionHint {
|
|
52
|
-
port: number;
|
|
53
|
-
pid: number;
|
|
54
|
-
url: string;
|
|
55
|
-
startedAt: string;
|
|
56
42
|
}
|
|
57
43
|
|
|
58
44
|
function canonicalPath(path: string): string {
|
|
@@ -78,109 +64,34 @@ function sameDirectory(a: string, b: string): boolean {
|
|
|
78
64
|
/** The code a refusal carries when the registry was read and no session opens the project: the
|
|
79
65
|
* one state in which "no editor is open here" is a fact, which `close` answers as done. */
|
|
80
66
|
export const NO_SESSION_FOR_PROJECT = 'VOLTER_NO_SESSION_FOR_PROJECT';
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
try {
|
|
84
|
-
const value: unknown = JSON.parse(
|
|
85
|
-
readFileSync(join(projectRoot, '.volter', 'session.json'), 'utf8'),
|
|
86
|
-
);
|
|
87
|
-
if (typeof value !== 'object' || value === null) return null;
|
|
88
|
-
const hint = value as Record<string, unknown>;
|
|
89
|
-
if (
|
|
90
|
-
typeof hint['port'] !== 'number' ||
|
|
91
|
-
!Number.isInteger(hint['port']) ||
|
|
92
|
-
hint['port'] <= 0 ||
|
|
93
|
-
typeof hint['pid'] !== 'number' ||
|
|
94
|
-
typeof hint['url'] !== 'string' ||
|
|
95
|
-
typeof hint['startedAt'] !== 'string'
|
|
96
|
-
) {
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
return hint as unknown as ProjectSessionHint;
|
|
100
|
-
} catch {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* WHICH PROJECT a `/__editor/project` body says its server is serving, and the
|
|
107
|
-
* reason it cannot describe it.
|
|
108
|
-
*
|
|
109
|
-
* `serving` is the server's own statement of "I AM serving this project, I
|
|
110
|
-
* just cannot describe it" — its manifest is unparseable or fails strict
|
|
111
|
-
* validation. The route added it because a bare `{ project: null }` there is
|
|
112
|
-
* indistinguishable from "no project open"; reading only `project.path`
|
|
113
|
-
* reproduces that collapse on this side, and the cost is the worst kind of
|
|
114
|
-
* wrong answer: this door refused a session that WAS open on the caller's
|
|
115
|
-
* project with "no live editor session found … (N other live session(s) found,
|
|
116
|
-
* but none open this project)" — sending the operator to start an editor that
|
|
117
|
-
* was already running, with the real defect (their own manifest) never named.
|
|
118
|
-
*/
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* What the server on `port` says it serves — `undefined` when it did not
|
|
122
|
-
* answer at all. One probe: the manifest failure arrives with the path, so
|
|
123
|
-
* neither the hint path nor the refusal path pays a second round trip.
|
|
124
|
-
*/
|
|
125
|
-
async function probeServedProject(
|
|
126
|
-
port: number,
|
|
127
|
-
): Promise<{ path: string | null; manifestError: string | null } | undefined> {
|
|
128
|
-
try {
|
|
129
|
-
const response = await fetch(`http://127.0.0.1:${port}/__editor/project`, {
|
|
130
|
-
signal: AbortSignal.timeout(EDITOR_SESSION_DISCOVERY_TIMEOUT_MS),
|
|
131
|
-
});
|
|
132
|
-
if (!response.ok) return undefined;
|
|
133
|
-
return servedProjectAnswer(await response.json());
|
|
134
|
-
} catch {
|
|
135
|
-
return undefined;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
67
|
+
/** The `code` of the refusal for this project's session whose manifest does not load; it carries `session`. */
|
|
68
|
+
export const SESSION_MANIFEST_UNREADABLE = 'VOLTER_SESSION_MANIFEST_UNREADABLE';
|
|
138
69
|
|
|
139
70
|
/**
|
|
140
71
|
* The refusal for a session that IS this project's and cannot be driven,
|
|
141
72
|
* because the project's manifest does not load.
|
|
142
73
|
*
|
|
143
74
|
* A refusal, not an attach: with no readable manifest the editor page itself
|
|
144
|
-
* renders its startup-error screen, so there is no editor
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* of "no live editor session found", which sent operators to start an editor
|
|
148
|
-
* that was already running while their actual defect went unnamed.
|
|
75
|
+
* renders its startup-error screen, so there is no editor behind this door to
|
|
76
|
+
* bind `{ editor, session }` to. It names the failing file and key, so an
|
|
77
|
+
* operator fixes the manifest rather than starting a second editor.
|
|
149
78
|
*/
|
|
150
|
-
function manifestRefusal(projectRoot: string, manifestError: string): Error {
|
|
151
|
-
return
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
79
|
+
function manifestRefusal(projectRoot: string, manifestError: string, port: number): Error {
|
|
80
|
+
return Object.assign(
|
|
81
|
+
new Error(
|
|
82
|
+
`@volter/live: the editor session for ${projectRoot} is live, but its volter.project.json does ` +
|
|
83
|
+
'not load, so there is no editor or project to drive — the editor page is showing this same ' +
|
|
84
|
+
`error. Fix the manifest and retry; the session recovers on save, no restart needed.\n${manifestError}`,
|
|
85
|
+
),
|
|
86
|
+
// The session is still this project's, so a caller that only needs to END it (`close`) can.
|
|
87
|
+
{ code: SESSION_MANIFEST_UNREADABLE, session: { port, projectRoot } },
|
|
155
88
|
);
|
|
156
89
|
}
|
|
157
90
|
|
|
158
91
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* silently attaches to an unrelated project's session — see the module doc
|
|
163
|
-
* above) when no volter.project.json is found, or no live session covers it.
|
|
164
|
-
*/
|
|
165
|
-
/**
|
|
166
|
-
* THE REFUSAL WHEN NOTHING MATCHED — and it says WHICH nothing.
|
|
167
|
-
*
|
|
168
|
-
* "No live editor session found … run the editor's `edit` command" used to be the answer to
|
|
169
|
-
* four different states, only one of which it described. The other three sent
|
|
170
|
-
* the operator to start an editor that was already running:
|
|
171
|
-
*
|
|
172
|
-
* - discovery FAILED (a probe timeout under load) — nothing was learned, so
|
|
173
|
-
* "no session is running" is not a fact anyone established;
|
|
174
|
-
* - the registry was read and is genuinely empty — the one case the old text
|
|
175
|
-
* was right about;
|
|
176
|
-
* - sessions exist, but every one resolves to a different canonical path. In
|
|
177
|
-
* a repo worked through git worktrees this is the ORDINARY miss: two
|
|
178
|
-
* checkouts of the same project differ only in a path prefix, and a
|
|
179
|
-
* symlinked worktree's `realpath` diverges from the path the caller typed.
|
|
180
|
-
* Naming both sides is what makes it a two-second diagnosis instead of a
|
|
181
|
-
* hunt.
|
|
182
|
-
*
|
|
183
|
-
* Each branch prescribes only what its own state supports.
|
|
92
|
+
* Distinguish failed discovery, a readable empty registry and sessions serving
|
|
93
|
+
* other canonical paths. Include the requested and listed paths for a path
|
|
94
|
+
* mismatch; only a confirmed absence carries `NO_SESSION_FOR_PROJECT`.
|
|
184
95
|
*/
|
|
185
96
|
function noMatchingSessionRefusal(
|
|
186
97
|
projectRoot: string,
|
|
@@ -192,8 +103,10 @@ function noMatchingSessionRefusal(
|
|
|
192
103
|
return new Error(
|
|
193
104
|
`@volter/live: could not complete editor session discovery while looking for ${projectRoot} — ` +
|
|
194
105
|
`${discoveryFailure}. This is not the answer "no editor is running": the question went ` +
|
|
195
|
-
'unanswered, so nothing is known about what is live.
|
|
196
|
-
|
|
106
|
+
'unanswered, so nothing is known about what is live. The step named above is the one that ' +
|
|
107
|
+
'did not answer: that session\'s server holds its port but did not reply to the request. ' +
|
|
108
|
+
'Its own log (logs/editor-<port>.log in the project it serves) says how far its start got; ' +
|
|
109
|
+
`${sessionsCommandFor(projectRoot)} asks the same question directly.`,
|
|
197
110
|
);
|
|
198
111
|
}
|
|
199
112
|
if (sessions.length === 0) {
|
|
@@ -221,6 +134,12 @@ function noMatchingSessionRefusal(
|
|
|
221
134
|
), thisFolderElsewhere ? {} : { code: NO_SESSION_FOR_PROJECT });
|
|
222
135
|
}
|
|
223
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Resolve `projectDir` (default `process.cwd()`) to its already-running editor
|
|
139
|
+
* session from the shared registry, with each discovery probe bounded by
|
|
140
|
+
* `EDITOR_SESSION_DISCOVERY_TIMEOUT_MS`.
|
|
141
|
+
* Throw when no project manifest or matching live session is found.
|
|
142
|
+
*/
|
|
224
143
|
export async function resolveSession(
|
|
225
144
|
projectDir: string = process.cwd(),
|
|
226
145
|
deps: SessionResolutionDeps = {},
|
|
@@ -235,36 +154,9 @@ export async function resolveSession(
|
|
|
235
154
|
);
|
|
236
155
|
}
|
|
237
156
|
|
|
238
|
-
// The editor writes this exact-project hint at boot and removes it on
|
|
239
|
-
// shutdown. Prefer it over a global all-session scan: it is both faster and
|
|
240
|
-
// immune to an unrelated slow/dead registry entry consuming the discovery
|
|
241
|
-
// deadline. The live probe remains authoritative, so a stale file cannot
|
|
242
|
-
// attach us to the wrong project or port.
|
|
243
|
-
const localHint = (deps.readProjectSession ?? readProjectSession)(projectRoot);
|
|
244
|
-
if (localHint) {
|
|
245
|
-
// `deps.verifyProjectSession` is the test-only override and keeps its
|
|
246
|
-
// boolean meaning: "this hint is this project's session, and healthy".
|
|
247
|
-
// With no override we read the probe ourselves, because the same response
|
|
248
|
-
// carries WHY a served project cannot be described.
|
|
249
|
-
if (deps.verifyProjectSession) {
|
|
250
|
-
if (await deps.verifyProjectSession(localHint, projectRoot)) {
|
|
251
|
-
return { port: localHint.port, projectRoot };
|
|
252
|
-
}
|
|
253
|
-
} else {
|
|
254
|
-
const served = await probeServedProject(localHint.port);
|
|
255
|
-
if (served?.path != null && canonicalPath(served.path) === canonicalPath(projectRoot)) {
|
|
256
|
-
if (served.manifestError !== null) throw manifestRefusal(projectRoot, served.manifestError);
|
|
257
|
-
return { port: localHint.port, projectRoot };
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
157
|
let sessions: EditorSessionInfo[];
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
// the editor's `edit` command — for a probe that merely timed out under load, sending the
|
|
266
|
-
// operator to start an editor that was already running while the real defect
|
|
267
|
-
// went unnamed. The same collapse the manifest refusal above was added for.
|
|
158
|
+
// Retain discovery failures separately from an empty session list so the
|
|
159
|
+
// refusal names an unanswered probe rather than claiming no session exists.
|
|
268
160
|
let discoveryFailure: string | null = null;
|
|
269
161
|
try {
|
|
270
162
|
sessions = await withTimeout(
|
|
@@ -281,7 +173,7 @@ export async function resolveSession(
|
|
|
281
173
|
const match = sessions.find((s) => s.project !== null && canonicalPath(s.project) === canon);
|
|
282
174
|
if (!match) throw noMatchingSessionRefusal(projectRoot, canon, sessions, discoveryFailure);
|
|
283
175
|
|
|
284
|
-
if (match.manifestError != null) throw manifestRefusal(projectRoot, match.manifestError);
|
|
176
|
+
if (match.manifestError != null) throw manifestRefusal(projectRoot, match.manifestError, match.port);
|
|
285
177
|
|
|
286
178
|
return { port: match.port, projectRoot };
|
|
287
179
|
}
|
package/src/singleton.ts
CHANGED
|
@@ -1,19 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A tiny memoized-async-factory primitive — the machinery behind
|
|
3
|
-
* `index.ts`'s lazy top-level `editor`/`tools` singletons. Pulled into
|
|
4
|
-
* its own module (rather than inlined) so it's independently unit-testable
|
|
5
|
-
* with a fake factory, with no need to exercise a real `connect()` (session
|
|
6
|
-
* discovery, network) just to prove "two accesses, one connect".
|
|
7
|
-
*
|
|
8
|
-
* Caches the in-flight PROMISE, not just the resolved value — two callers
|
|
9
|
-
* racing `ensure()` before the first resolves still share the SAME
|
|
10
|
-
* connection attempt, not two independent ones.
|
|
11
|
-
*/
|
|
1
|
+
/** Memoized async connection. Concurrent ensure calls share the same in-flight promise. */
|
|
12
2
|
export interface LazySession<T> {
|
|
13
3
|
/** Returns the memoized promise, creating it via `factory()` on first call. */
|
|
14
4
|
ensure(): Promise<T>;
|
|
15
|
-
/** Clears the memo — the NEXT `ensure()` calls `factory()` again. Exposed for tests (and for a caller that deliberately wants to reconnect); not needed in ordinary use. */
|
|
16
|
-
reset(): void;
|
|
17
5
|
}
|
|
18
6
|
|
|
19
7
|
export function createLazySession<T>(factory: () => Promise<T>): LazySession<T> {
|
|
@@ -23,8 +11,5 @@ export function createLazySession<T>(factory: () => Promise<T>): LazySession<T>
|
|
|
23
11
|
promise ??= factory();
|
|
24
12
|
return promise;
|
|
25
13
|
},
|
|
26
|
-
reset(): void {
|
|
27
|
-
promise = null;
|
|
28
|
-
},
|
|
29
14
|
};
|
|
30
15
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { readPageEntry, readVolterContributions } from '@volter/sdk/volter-contributions';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* THE NODE READER of a package's `package.json` as Volter reads it: its name, its dependency
|
|
7
|
+
* names and its `contributes.volter` declarations, which `@volter/sdk/volter-contributions` reads as the workbench and
|
|
8
|
+
* the page read them. The build's product plugin, the session's catalog and
|
|
9
|
+
* serving doors, the shared-React scope and the pre-bundle crawl all read a package here, so a
|
|
10
|
+
* declaration means one thing everywhere it is read.
|
|
11
|
+
*
|
|
12
|
+
* It parses and does no policy. What an unusable declaration costs is the caller's to say: a
|
|
13
|
+
* product build refuses it, the catalog reports it as a load error, a scope decision treats it
|
|
14
|
+
* as no declaration. {@link declaredContributions} is the one test of "this package contributes".
|
|
15
|
+
*/
|
|
16
|
+
export interface VolterManifest {
|
|
17
|
+
/** The `package.json`, absolute. */
|
|
18
|
+
readonly path: string;
|
|
19
|
+
/** Its package's directory. */
|
|
20
|
+
readonly dir: string;
|
|
21
|
+
readonly name: string | null;
|
|
22
|
+
readonly version: string | null;
|
|
23
|
+
readonly private: boolean;
|
|
24
|
+
readonly bin: unknown;
|
|
25
|
+
/** Every name in `dependencies`, `devDependencies` and `optionalDependencies`, in that order,
|
|
26
|
+
* each once. */
|
|
27
|
+
readonly dependencyNames: readonly string[];
|
|
28
|
+
/** `package.json#contributes.volter`, unchecked: the package's contribution modules, an object
|
|
29
|
+
* keyed by kind, and its declarations (`serving`, `viewRoutes`, `product`). It is where Code-OSS's extension
|
|
30
|
+
* service reads them too, through the kit's `volter` extension point. */
|
|
31
|
+
readonly contributions: unknown;
|
|
32
|
+
/** `package.json#contributes.languageModelTools`, unchecked: the agent tools Code-OSS registers from it. */
|
|
33
|
+
readonly languageModelTools: unknown;
|
|
34
|
+
/** `package.json#contributes.volter.product`, a product's declaration, read and checked by `product-locator.ts`;
|
|
35
|
+
* `undefined` when the package declares none or it is not an object. */
|
|
36
|
+
readonly product: ProductDeclaration | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `package.json#contributes.volter.product`, as declared and unchecked. */
|
|
40
|
+
export interface ProductDeclaration {
|
|
41
|
+
readonly entry?: unknown;
|
|
42
|
+
readonly composes?: unknown;
|
|
43
|
+
readonly colorTheme?: unknown;
|
|
44
|
+
readonly workbench?: unknown;
|
|
45
|
+
readonly displayName?: unknown;
|
|
46
|
+
readonly install?: unknown;
|
|
47
|
+
readonly upgrade?: unknown;
|
|
48
|
+
readonly adapterRequired?: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `path`'s manifest, or `null` when there is no file there or it is not a JSON object. */
|
|
52
|
+
export function readVolterManifest(path: string): VolterManifest | null {
|
|
53
|
+
let json: unknown;
|
|
54
|
+
try {
|
|
55
|
+
json = JSON.parse(readFileSync(path, 'utf8'));
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (json === null || typeof json !== 'object' || Array.isArray(json)) return null;
|
|
60
|
+
const record = json as Record<string, unknown>;
|
|
61
|
+
const names = (key: string): string[] => {
|
|
62
|
+
const section = record[key];
|
|
63
|
+
return section !== null && typeof section === 'object' ? Object.keys(section) : [];
|
|
64
|
+
};
|
|
65
|
+
const contributes = record['contributes'];
|
|
66
|
+
const contributions = contributes !== null && typeof contributes === 'object' ? (contributes as Record<string, unknown>)['volter'] : undefined;
|
|
67
|
+
const declared = contributions !== null && typeof contributions === 'object' ? (contributions as Record<string, unknown>) : {};
|
|
68
|
+
const product = declared['product'];
|
|
69
|
+
return {
|
|
70
|
+
path,
|
|
71
|
+
dir: dirname(path),
|
|
72
|
+
name: typeof record['name'] === 'string' ? record['name'] : null,
|
|
73
|
+
version: typeof record['version'] === 'string' ? record['version'] : null,
|
|
74
|
+
private: record['private'] === true,
|
|
75
|
+
bin: record['bin'],
|
|
76
|
+
dependencyNames: [...new Set([...names('dependencies'), ...names('devDependencies'), ...names('optionalDependencies')])],
|
|
77
|
+
contributions,
|
|
78
|
+
languageModelTools: contributes !== null && typeof contributes === 'object' ? (contributes as Record<string, unknown>)['languageModelTools'] : undefined,
|
|
79
|
+
product: product !== null && typeof product === 'object' && !Array.isArray(product) ? (product as ProductDeclaration) : undefined,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The package's declared modules, each contribution's and its page half's, with why any entry is refused; `null` when
|
|
85
|
+
* it declares none (an absent `contributes.volter`, or one that is not an object). A package with the object, even an
|
|
86
|
+
* empty one, is a contributing package: its modules are editor-tree code.
|
|
87
|
+
*/
|
|
88
|
+
export function declaredContributions(manifest: VolterManifest | null): { readonly modules: readonly string[]; readonly errors: readonly string[] } | null {
|
|
89
|
+
const declared = manifest?.contributions;
|
|
90
|
+
if (declared === null || typeof declared !== 'object' || Array.isArray(declared)) return null;
|
|
91
|
+
const { entries, errors } = readVolterContributions(declared);
|
|
92
|
+
const page = readPageEntry(manifest?.name ?? manifest?.path ?? '', (declared as Record<string, unknown>)['page']);
|
|
93
|
+
return {
|
|
94
|
+
modules: [...entries.map(({ entry }) => entry), ...('entry' in page && page.entry !== null ? [page.entry] : [])],
|
|
95
|
+
errors: 'malformed' in page ? [...errors, page.malformed] : errors,
|
|
96
|
+
};
|
|
97
|
+
}
|