neoctl-web 0.1.0 → 0.1.2
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/README.md +244 -238
- package/bin/neow.mjs +139 -123
- package/core-runtime.mjs +55 -55
- package/cpa-quota.mjs +209 -209
- package/dist/assets/{index-H7num-0s.js → index-B6NPbDJ9.js} +1 -1
- package/dist/assets/{index-BSFq6wfd.css → index-DG8NYKAP.css} +1 -1
- package/dist/favicon.svg +3 -3
- package/dist/index.html +2 -2
- package/memory-monitor.mjs +150 -150
- package/package.json +63 -62
- package/platform-paths.mjs +40 -0
- package/plugin-settings.mjs +67 -67
- package/plugins/downloads/downloads.mjs +147 -147
- package/plugins/downloads/index.mjs +19 -19
- package/plugins/downloads/neo-plugin.json +9 -9
- package/plugins/xhs-artifact/artifacts.mjs +388 -388
- package/plugins/xhs-artifact/editor-page.mjs +73 -73
- package/plugins/xhs-artifact/index.mjs +48 -48
- package/plugins/xhs-artifact/neo-plugin.json +9 -9
- package/plugins.mjs +111 -111
- package/runtime-router-cleanup.mjs +152 -152
- package/runtime-workspaces.mjs +515 -515
- package/server.mjs +447 -445
- package/tool-settings.mjs +80 -80
package/runtime-workspaces.mjs
CHANGED
|
@@ -1,515 +1,515 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import { mkdir, readFile, readdir, rmdir, stat, writeFile } from 'node:fs/promises';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import { QueryEngine, WebRepl } from './core-runtime.mjs';
|
|
5
|
-
|
|
6
|
-
export function createWorkspaceRuntimeManager(options) {
|
|
7
|
-
const projectRoot = path.resolve(options.projectRoot || process.cwd());
|
|
8
|
-
const workspaceRoot = path.resolve(options.workspaceRoot || path.join(projectRoot, 'workspace'));
|
|
9
|
-
const registryFile = path.resolve(options.registryFile || path.join(projectRoot, '.neoctl-web', 'session-workspaces.json'));
|
|
10
|
-
const registry = new SessionWorkspaceRegistry(registryFile, workspaceRoot);
|
|
11
|
-
const maxSubscribers = positiveNumber(process.env.NEO_SESSION_MAX_SUBSCRIBERS, 32);
|
|
12
|
-
const claimedWorkspacePaths = new Set();
|
|
13
|
-
const pendingWorkspacePaths = new Set();
|
|
14
|
-
let claimedWorkspacePathsLoaded = false;
|
|
15
|
-
let workspaceAllocationQueue = Promise.resolve();
|
|
16
|
-
|
|
17
|
-
const withWorkspaceAllocationLock = (operation) => {
|
|
18
|
-
const result = workspaceAllocationQueue.then(operation, operation);
|
|
19
|
-
workspaceAllocationQueue = result.then(() => undefined, () => undefined);
|
|
20
|
-
return result;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const loadClaimedWorkspacePaths = async () => {
|
|
24
|
-
if (claimedWorkspacePathsLoaded) return;
|
|
25
|
-
for (const cwd of await registry.paths()) claimedWorkspacePaths.add(cwd);
|
|
26
|
-
claimedWorkspacePathsLoaded = true;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const reserveWorkspacePath = () => withWorkspaceAllocationLock(async () => {
|
|
30
|
-
await loadClaimedWorkspacePaths();
|
|
31
|
-
const candidate = await reserveWorkspace(workspaceRoot, claimedWorkspacePaths);
|
|
32
|
-
pendingWorkspacePaths.add(candidate);
|
|
33
|
-
return candidate;
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
const materializeWorkspacePath = (candidate) => withWorkspaceAllocationLock(async () => {
|
|
37
|
-
await loadClaimedWorkspacePaths();
|
|
38
|
-
const cwd = await materializeWorkspace(workspaceRoot, candidate, claimedWorkspacePaths);
|
|
39
|
-
pendingWorkspacePaths.delete(candidate);
|
|
40
|
-
return cwd;
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
const markCwdNoticeConsumed = async (sessionId) => {
|
|
44
|
-
const entry = await registry.entry(sessionId);
|
|
45
|
-
if (entry?.cwdNoticePending) await registry.set(sessionId, entry.cwd, { ...entry, cwdNoticePending: false });
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
class WorkspaceWebRepl extends WebRepl {
|
|
49
|
-
syncScheduled = false;
|
|
50
|
-
|
|
51
|
-
subscribe(res) {
|
|
52
|
-
if (this.subscribers.size >= maxSubscribers) {
|
|
53
|
-
res.writeHead(503, {
|
|
54
|
-
'Content-Type': 'text/plain; charset=utf-8',
|
|
55
|
-
'Cache-Control': 'no-store',
|
|
56
|
-
'Retry-After': '30',
|
|
57
|
-
});
|
|
58
|
-
res.end('too many live viewers for this session');
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
super.subscribe(res);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
snapshot(includeCatalog = false) {
|
|
65
|
-
return {
|
|
66
|
-
...super.snapshot(includeCatalog),
|
|
67
|
-
cwd: currentEngineCwd(this.runtime.engine, projectRoot),
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async submit(text, attachments = []) {
|
|
72
|
-
if (!String(text || '').trim() && attachments.length === 0) return super.submit(text, attachments);
|
|
73
|
-
await this.materializeCurrentWorkspace();
|
|
74
|
-
return super.submit(text, attachments);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async browseWorkspace(value) {
|
|
78
|
-
try {
|
|
79
|
-
return { ok: true, ...(await browseWorkspace(value, currentEngineCwd(this.runtime.engine, projectRoot))) };
|
|
80
|
-
} catch (error) {
|
|
81
|
-
return workspaceFailure('CWD_INVALID', error);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async createWorkspaceDirectory(value) {
|
|
86
|
-
try {
|
|
87
|
-
const current = currentEngineCwd(this.runtime.engine, projectRoot);
|
|
88
|
-
const target = resolveWorkspaceInput(value, current);
|
|
89
|
-
await mkdir(target, { recursive: true });
|
|
90
|
-
return { ok: true, ...(await browseWorkspace(target, current)) };
|
|
91
|
-
} catch (error) {
|
|
92
|
-
return workspaceFailure('CWD_CREATE_FAILED', error);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async deleteWorkspaceDirectory(value) {
|
|
97
|
-
try {
|
|
98
|
-
const current = currentEngineCwd(this.runtime.engine, projectRoot);
|
|
99
|
-
const target = resolveWorkspaceInput(value, current);
|
|
100
|
-
const root = path.parse(target).root;
|
|
101
|
-
if (target === root || isSameOrAncestor(target, current)) throw new Error('当前工作目录及其上级目录不能删除');
|
|
102
|
-
await rmdir(target);
|
|
103
|
-
return { ok: true, ...(await browseWorkspace(path.dirname(target), current)) };
|
|
104
|
-
} catch (error) {
|
|
105
|
-
return workspaceFailure('CWD_DELETE_FAILED', error);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async changeWorkspace(value) {
|
|
110
|
-
if (this.busy) return { ok: false, errorCode: 'CWD_UPDATE_BLOCKED', error: '模型回答期间不能切换工作目录' };
|
|
111
|
-
try {
|
|
112
|
-
const previous = currentEngineCwd(this.runtime.engine, projectRoot);
|
|
113
|
-
const cwd = await validateWorkspaceDirectory(resolveWorkspaceInput(value, previous));
|
|
114
|
-
if (cwd === previous) return { ok: true, cwd, unchanged: true };
|
|
115
|
-
const snapshot = this.runtime.engine.snapshot().session;
|
|
116
|
-
if (!snapshot) throw new Error('session transcripts are disabled');
|
|
117
|
-
const stored = await registry.entry(snapshot.sessionId);
|
|
118
|
-
const history = normalizeCwdHistory(stored?.cwdHistory, previous);
|
|
119
|
-
if (history.at(-1) !== previous) history.push(previous);
|
|
120
|
-
if (history.at(-1) !== cwd) history.push(cwd);
|
|
121
|
-
await registry.set(snapshot.sessionId, cwd, {
|
|
122
|
-
materialized: true,
|
|
123
|
-
cwdHistory: history,
|
|
124
|
-
cwdNoticePending: true,
|
|
125
|
-
});
|
|
126
|
-
this.runtime.engine = createWorkspaceEngine(this.runtime.engine, cwd, snapshot.sessionId, true, {
|
|
127
|
-
cwdTransitionPaths: history,
|
|
128
|
-
onCwdTransitionConsumed: () => markCwdNoticeConsumed(snapshot.sessionId),
|
|
129
|
-
});
|
|
130
|
-
await this.runtime.engine.initialize();
|
|
131
|
-
registerEngineSync(this);
|
|
132
|
-
await Promise.all([this.loadSessionPlugins(snapshot.sessionId), this.loadSessionTools(snapshot.sessionId)]);
|
|
133
|
-
await this.refreshSessionView();
|
|
134
|
-
return { ok: true, cwd, history };
|
|
135
|
-
} catch (error) {
|
|
136
|
-
return workspaceFailure('CWD_UPDATE_FAILED', error);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async materializeCurrentWorkspace() {
|
|
141
|
-
const candidate = currentEngineCwd(this.runtime.engine, projectRoot);
|
|
142
|
-
if (!isInsideRoot(candidate, workspaceRoot)) return candidate;
|
|
143
|
-
if (!pendingWorkspacePaths.has(candidate) && await pathExists(candidate)) {
|
|
144
|
-
claimedWorkspacePaths.add(candidate);
|
|
145
|
-
return candidate;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const cwd = await materializeWorkspacePath(candidate);
|
|
149
|
-
const snapshot = this.runtime.engine.snapshot().session;
|
|
150
|
-
if (!snapshot) throw new Error('session transcripts are disabled');
|
|
151
|
-
if (cwd !== candidate) {
|
|
152
|
-
this.runtime.engine = createWorkspaceEngine(this.runtime.engine, cwd, snapshot.sessionId, true);
|
|
153
|
-
await this.runtime.engine.initialize();
|
|
154
|
-
registerEngineSync(this);
|
|
155
|
-
await Promise.all([this.loadSessionPlugins(snapshot.sessionId), this.loadSessionTools(snapshot.sessionId)]);
|
|
156
|
-
await this.refreshSessionView();
|
|
157
|
-
}
|
|
158
|
-
await registry.set(snapshot.sessionId, cwd, { materialized: true, cwdHistory: [cwd] });
|
|
159
|
-
return cwd;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async newSession() {
|
|
163
|
-
try {
|
|
164
|
-
await this.detachRunningForeground('new session');
|
|
165
|
-
const cwd = await reserveWorkspacePath();
|
|
166
|
-
this.runtime.engine = createWorkspaceEngine(this.runtime.engine, cwd, undefined, false);
|
|
167
|
-
await this.runtime.engine.initialize();
|
|
168
|
-
registerEngineSync(this);
|
|
169
|
-
const snapshot = this.runtime.engine.snapshot().session;
|
|
170
|
-
if (!snapshot) throw new Error('session transcripts are disabled');
|
|
171
|
-
await Promise.all([this.loadSessionPlugins(snapshot.sessionId), this.loadSessionTools(snapshot.sessionId)]);
|
|
172
|
-
await registry.set(snapshot.sessionId, cwd, { materialized: false, cwdHistory: [cwd] });
|
|
173
|
-
await this.refreshSessionView();
|
|
174
|
-
return { ok: true, cwd };
|
|
175
|
-
} catch (error) {
|
|
176
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
177
|
-
return { ok: false, errorCode: 'SESSION_CREATE_FAILED', error: message };
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
async resumeSession(sessionId) {
|
|
182
|
-
if (!sessionId) return { ok: false, errorCode: 'INVALID_REQUEST', error: 'sessionId is required' };
|
|
183
|
-
if (this.backgroundSessionRuns.has(sessionId)) return super.resumeSession(sessionId);
|
|
184
|
-
try {
|
|
185
|
-
await this.detachRunningForeground('session switch');
|
|
186
|
-
const workspace = await registry.entry(sessionId);
|
|
187
|
-
const cwd = workspace?.cwd || projectRoot;
|
|
188
|
-
if (workspace && !workspace.materialized) pendingWorkspacePaths.add(cwd);
|
|
189
|
-
this.runtime.engine = createWorkspaceEngine(this.runtime.engine, cwd, sessionId, true, {
|
|
190
|
-
cwdTransitionPaths: workspace?.cwdNoticePending ? workspace.cwdHistory : undefined,
|
|
191
|
-
onCwdTransitionConsumed: () => markCwdNoticeConsumed(sessionId),
|
|
192
|
-
});
|
|
193
|
-
await this.runtime.engine.initialize();
|
|
194
|
-
registerEngineSync(this);
|
|
195
|
-
const snapshot = this.runtime.engine.snapshot().session;
|
|
196
|
-
if (!snapshot) throw new Error('session transcripts are disabled');
|
|
197
|
-
await Promise.all([this.loadSessionPlugins(snapshot.sessionId), this.loadSessionTools(snapshot.sessionId)]);
|
|
198
|
-
await this.refreshSessionView();
|
|
199
|
-
return { ok: true, cwd };
|
|
200
|
-
} catch (error) {
|
|
201
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
202
|
-
return { ok: false, errorCode: 'SESSION_RESUME_FAILED', error: message };
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
broadcastSync() {
|
|
207
|
-
if (this.syncScheduled) return;
|
|
208
|
-
this.syncScheduled = true;
|
|
209
|
-
setImmediate(() => {
|
|
210
|
-
this.syncScheduled = false;
|
|
211
|
-
const payload = this.snapshot(false);
|
|
212
|
-
for (const subscriber of this.subscribers) {
|
|
213
|
-
const res = subscriber.response;
|
|
214
|
-
if (res.destroyed || res.writableEnded) continue;
|
|
215
|
-
this.send(subscriber, 'sync', payload);
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
return {
|
|
222
|
-
workspaceRoot,
|
|
223
|
-
async createRuntime(runtimeOptions = {}) {
|
|
224
|
-
const mappedWorkspace = runtimeOptions.sessionId
|
|
225
|
-
? await registry.entry(runtimeOptions.sessionId)
|
|
226
|
-
: undefined;
|
|
227
|
-
const mappedCwd = mappedWorkspace?.cwd;
|
|
228
|
-
const shouldAllocate = !mappedCwd && runtimeOptions.resume === false;
|
|
229
|
-
const cwd = mappedCwd || (shouldAllocate ? await reserveWorkspacePath() : projectRoot);
|
|
230
|
-
if (mappedCwd && (!mappedWorkspace.materialized || !await pathExists(mappedCwd))) {
|
|
231
|
-
claimedWorkspacePaths.add(mappedCwd);
|
|
232
|
-
pendingWorkspacePaths.add(mappedCwd);
|
|
233
|
-
}
|
|
234
|
-
const runtime = await options.createRuntime({
|
|
235
|
-
...runtimeOptions,
|
|
236
|
-
cwd,
|
|
237
|
-
cwdTransitionPaths: mappedWorkspace?.cwdNoticePending ? mappedWorkspace.cwdHistory : undefined,
|
|
238
|
-
onCwdTransitionConsumed: runtimeOptions.sessionId
|
|
239
|
-
? () => markCwdNoticeConsumed(runtimeOptions.sessionId)
|
|
240
|
-
: undefined,
|
|
241
|
-
});
|
|
242
|
-
const sessionId = runtime.engine.snapshot().session?.sessionId;
|
|
243
|
-
if (sessionId && cwd !== projectRoot) {
|
|
244
|
-
await registry.set(sessionId, cwd, { materialized: !pendingWorkspacePaths.has(cwd) });
|
|
245
|
-
}
|
|
246
|
-
return runtime;
|
|
247
|
-
},
|
|
248
|
-
createRepl(runtime) {
|
|
249
|
-
return new WorkspaceWebRepl(runtime);
|
|
250
|
-
},
|
|
251
|
-
};
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function createWorkspaceEngine(source, cwd, sessionId, resume, overrides = {}) {
|
|
255
|
-
const settings = source.getModelSettings();
|
|
256
|
-
return new QueryEngine({
|
|
257
|
-
...source.options,
|
|
258
|
-
cwd,
|
|
259
|
-
model: settings.model,
|
|
260
|
-
reasoning: settings.reasoning,
|
|
261
|
-
...overrides,
|
|
262
|
-
session: source.options.session
|
|
263
|
-
? { ...source.options.session, sessionId, resume }
|
|
264
|
-
: undefined,
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function registerEngineSync(repl) {
|
|
269
|
-
repl.runtime.engine.onSessionTitleChange(() => repl.broadcastSync());
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function currentEngineCwd(engine, fallback) {
|
|
273
|
-
return path.resolve(engine?.cwd || engine?.options?.cwd || fallback);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
export async function reserveWorkspace(root, claimed = new Set()) {
|
|
277
|
-
await mkdir(root, { recursive: true });
|
|
278
|
-
const now = new Date();
|
|
279
|
-
for (let offset = 0; offset < 120; offset += 1) {
|
|
280
|
-
const candidate = path.join(root, formatWorkspaceStamp(new Date(now.getTime() + offset * 1000)));
|
|
281
|
-
if (claimed.has(candidate) || await pathExists(candidate)) continue;
|
|
282
|
-
claimed.add(candidate);
|
|
283
|
-
return candidate;
|
|
284
|
-
}
|
|
285
|
-
throw new Error('unable to reserve a unique workspace directory');
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
export async function materializeWorkspace(root, candidate, claimed = new Set()) {
|
|
289
|
-
await mkdir(root, { recursive: true });
|
|
290
|
-
const resolvedCandidate = path.resolve(candidate);
|
|
291
|
-
if (!isInsideRoot(resolvedCandidate, root)) throw new Error('workspace path is outside workspace root');
|
|
292
|
-
try {
|
|
293
|
-
await mkdir(resolvedCandidate);
|
|
294
|
-
claimed.add(resolvedCandidate);
|
|
295
|
-
return resolvedCandidate;
|
|
296
|
-
} catch (error) {
|
|
297
|
-
if (error?.code !== 'EEXIST') throw error;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
claimed.delete(resolvedCandidate);
|
|
301
|
-
const replacement = await reserveWorkspace(root, claimed);
|
|
302
|
-
await mkdir(replacement);
|
|
303
|
-
claimed.add(replacement);
|
|
304
|
-
return replacement;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
async function pathExists(candidate) {
|
|
308
|
-
try {
|
|
309
|
-
await stat(candidate);
|
|
310
|
-
return true;
|
|
311
|
-
} catch (error) {
|
|
312
|
-
if (error?.code === 'ENOENT') return false;
|
|
313
|
-
throw error;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function formatWorkspaceStamp(date) {
|
|
318
|
-
const two = (value) => String(value).padStart(2, '0');
|
|
319
|
-
return [
|
|
320
|
-
two(date.getFullYear() % 100),
|
|
321
|
-
two(date.getMonth() + 1),
|
|
322
|
-
two(date.getDate()),
|
|
323
|
-
two(date.getHours()),
|
|
324
|
-
two(date.getMinutes()),
|
|
325
|
-
two(date.getSeconds()),
|
|
326
|
-
].join('');
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
export class SessionWorkspaceRegistry {
|
|
330
|
-
constructor(file, workspaceRoot) {
|
|
331
|
-
this.file = file;
|
|
332
|
-
this.workspaceRoot = workspaceRoot;
|
|
333
|
-
this.items = undefined;
|
|
334
|
-
this.writeQueue = Promise.resolve();
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
async get(sessionId) {
|
|
338
|
-
return (await this.entry(sessionId))?.cwd;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
async entry(sessionId) {
|
|
342
|
-
const items = await this.load();
|
|
343
|
-
const value = items[String(sessionId || '')];
|
|
344
|
-
if (!value) return undefined;
|
|
345
|
-
const rawCwd = typeof value === 'string' ? value : value?.cwd;
|
|
346
|
-
if (!rawCwd) return undefined;
|
|
347
|
-
const cwd = path.resolve(rawCwd);
|
|
348
|
-
return {
|
|
349
|
-
cwd,
|
|
350
|
-
materialized: typeof value === 'string' ? true : value.materialized !== false,
|
|
351
|
-
cwdHistory: normalizeCwdHistory(typeof value === 'string' ? undefined : value.cwdHistory, cwd),
|
|
352
|
-
cwdNoticePending: typeof value === 'string' ? false : value.cwdNoticePending === true,
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
async set(sessionId, cwd, options = {}) {
|
|
357
|
-
const id = String(sessionId || '').trim();
|
|
358
|
-
if (!id) return;
|
|
359
|
-
const resolved = path.resolve(cwd);
|
|
360
|
-
const items = await this.load();
|
|
361
|
-
const previous = items[id];
|
|
362
|
-
const previousObject = typeof previous === 'object' && previous ? previous : {};
|
|
363
|
-
items[id] = {
|
|
364
|
-
...previousObject,
|
|
365
|
-
cwd: resolved,
|
|
366
|
-
materialized: options.materialized !== false,
|
|
367
|
-
cwdHistory: normalizeCwdHistory(options.cwdHistory ?? previousObject.cwdHistory, resolved),
|
|
368
|
-
cwdNoticePending: options.cwdNoticePending === undefined
|
|
369
|
-
? previousObject.cwdNoticePending === true
|
|
370
|
-
: options.cwdNoticePending === true,
|
|
371
|
-
};
|
|
372
|
-
this.writeQueue = this.writeQueue.then(async () => {
|
|
373
|
-
await mkdir(path.dirname(this.file), { recursive: true });
|
|
374
|
-
await writeFile(this.file, `${JSON.stringify(items, null, 2)}\n`, 'utf8');
|
|
375
|
-
});
|
|
376
|
-
await this.writeQueue;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
async paths() {
|
|
380
|
-
const items = await this.load();
|
|
381
|
-
return Object.values(items)
|
|
382
|
-
.map((value) => path.resolve(String(typeof value === 'string' ? value : value?.cwd || '')))
|
|
383
|
-
.filter((value) => isInsideRoot(value, this.workspaceRoot));
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
async load() {
|
|
387
|
-
if (this.items) return this.items;
|
|
388
|
-
try {
|
|
389
|
-
const parsed = JSON.parse(await readFile(this.file, 'utf8'));
|
|
390
|
-
this.items = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
391
|
-
} catch (error) {
|
|
392
|
-
if (error?.code !== 'ENOENT') throw error;
|
|
393
|
-
this.items = {};
|
|
394
|
-
}
|
|
395
|
-
return this.items;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
export async function browseWorkspace(value, currentCwd) {
|
|
400
|
-
const current = await validateWorkspaceDirectory(resolveWorkspaceInput(value, currentCwd));
|
|
401
|
-
const [entries, locations] = await Promise.all([
|
|
402
|
-
readdir(current, { withFileTypes: true }),
|
|
403
|
-
discoverWorkspaceLocations(),
|
|
404
|
-
]);
|
|
405
|
-
return {
|
|
406
|
-
cwd: current,
|
|
407
|
-
parent: current === path.parse(current).root ? undefined : path.dirname(current),
|
|
408
|
-
home: os.homedir(),
|
|
409
|
-
locations,
|
|
410
|
-
entries: entries
|
|
411
|
-
.filter((entry) => entry.isDirectory())
|
|
412
|
-
.map((entry) => ({ name: entry.name, path: path.join(current, entry.name) }))
|
|
413
|
-
.sort((left, right) => left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: 'base' })),
|
|
414
|
-
};
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
let workspaceLocationsCache;
|
|
418
|
-
let workspaceLocationsCachedAt = 0;
|
|
419
|
-
|
|
420
|
-
export async function discoverWorkspaceLocations() {
|
|
421
|
-
if (workspaceLocationsCache && Date.now() - workspaceLocationsCachedAt < 5000) return workspaceLocationsCache;
|
|
422
|
-
const home = os.homedir();
|
|
423
|
-
const candidates = [
|
|
424
|
-
{ id: 'home', label: '主目录', path: home, kind: 'home' },
|
|
425
|
-
...[
|
|
426
|
-
['desktop', '桌面', ['Desktop', '桌面']],
|
|
427
|
-
['documents', '文档', ['Documents', '文档']],
|
|
428
|
-
['downloads', '下载', ['Downloads', '下载']],
|
|
429
|
-
].flatMap(([id, label, names]) => names.map((name) => ({ id, label, path: path.join(home, name), kind: 'favorite' }))),
|
|
430
|
-
];
|
|
431
|
-
|
|
432
|
-
if (process.platform === 'win32') {
|
|
433
|
-
const drives = await Promise.all('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(async (letter) => {
|
|
434
|
-
const drivePath = `${letter}:\\`;
|
|
435
|
-
return await directoryExists(drivePath)
|
|
436
|
-
? { id: `drive-${letter}`, label: `本地磁盘 (${letter}:)`, path: drivePath, kind: 'drive' }
|
|
437
|
-
: undefined;
|
|
438
|
-
}));
|
|
439
|
-
candidates.push(...drives.filter(Boolean));
|
|
440
|
-
} else {
|
|
441
|
-
candidates.push({ id: 'root', label: '文件系统', path: '/', kind: 'root' });
|
|
442
|
-
const mountRoots = process.platform === 'darwin'
|
|
443
|
-
? ['/Volumes']
|
|
444
|
-
: ['/mnt', '/media', path.join('/media', os.userInfo().username), path.join('/run/media', os.userInfo().username)];
|
|
445
|
-
for (const mountRoot of mountRoots) {
|
|
446
|
-
const mounts = await readdir(mountRoot, { withFileTypes: true }).catch(() => []);
|
|
447
|
-
for (const mount of mounts.filter((entry) => entry.isDirectory())) {
|
|
448
|
-
candidates.push({ id: `volume-${mount.name}`, label: mount.name, path: path.join(mountRoot, mount.name), kind: 'volume' });
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
const seen = new Set();
|
|
454
|
-
const locations = [];
|
|
455
|
-
for (const candidate of candidates) {
|
|
456
|
-
const resolved = path.resolve(candidate.path);
|
|
457
|
-
const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
458
|
-
if (seen.has(key) || !await directoryExists(resolved)) continue;
|
|
459
|
-
seen.add(key);
|
|
460
|
-
locations.push({ ...candidate, path: resolved });
|
|
461
|
-
}
|
|
462
|
-
workspaceLocationsCache = locations;
|
|
463
|
-
workspaceLocationsCachedAt = Date.now();
|
|
464
|
-
return locations;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
export function resolveWorkspaceInput(value, currentCwd) {
|
|
468
|
-
let input = String(value || '').trim().replace(/^["']|["']$/g, '');
|
|
469
|
-
if (!input) return path.resolve(currentCwd || process.cwd());
|
|
470
|
-
if (input === '~') input = os.homedir();
|
|
471
|
-
else if (input.startsWith('~/') || input.startsWith('~\\')) input = path.join(os.homedir(), input.slice(2));
|
|
472
|
-
input = input.replace(/[\\/]+/g, path.sep);
|
|
473
|
-
if (process.platform === 'win32' && /^[a-zA-Z]:$/.test(input)) input += path.sep;
|
|
474
|
-
return path.resolve(currentCwd || process.cwd(), input);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
async function validateWorkspaceDirectory(candidate) {
|
|
478
|
-
const resolved = path.resolve(candidate);
|
|
479
|
-
const info = await stat(resolved);
|
|
480
|
-
if (!info.isDirectory()) throw new Error('路径不是文件夹');
|
|
481
|
-
return resolved;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
async function directoryExists(candidate) {
|
|
485
|
-
try {
|
|
486
|
-
return (await stat(candidate)).isDirectory();
|
|
487
|
-
} catch {
|
|
488
|
-
return false;
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
function normalizeCwdHistory(value, fallback) {
|
|
493
|
-
const history = Array.isArray(value) ? value.map((entry) => String(entry || '').trim()).filter(Boolean) : [];
|
|
494
|
-
if (!history.length && fallback) history.push(path.resolve(fallback));
|
|
495
|
-
return history;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function isSameOrAncestor(candidate, descendant) {
|
|
499
|
-
const relative = path.relative(path.resolve(candidate), path.resolve(descendant));
|
|
500
|
-
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
function workspaceFailure(errorCode, error) {
|
|
504
|
-
return { ok: false, errorCode, error: error instanceof Error ? error.message : String(error) };
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
function isInsideRoot(candidate, root) {
|
|
508
|
-
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
509
|
-
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
function positiveNumber(value, fallback) {
|
|
513
|
-
const parsed = Number(value);
|
|
514
|
-
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
515
|
-
}
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { mkdir, readFile, readdir, rmdir, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { QueryEngine, WebRepl } from './core-runtime.mjs';
|
|
5
|
+
|
|
6
|
+
export function createWorkspaceRuntimeManager(options) {
|
|
7
|
+
const projectRoot = path.resolve(options.projectRoot || process.cwd());
|
|
8
|
+
const workspaceRoot = path.resolve(options.workspaceRoot || path.join(projectRoot, 'workspace'));
|
|
9
|
+
const registryFile = path.resolve(options.registryFile || path.join(projectRoot, '.neoctl-web', 'session-workspaces.json'));
|
|
10
|
+
const registry = new SessionWorkspaceRegistry(registryFile, workspaceRoot);
|
|
11
|
+
const maxSubscribers = positiveNumber(process.env.NEO_SESSION_MAX_SUBSCRIBERS, 32);
|
|
12
|
+
const claimedWorkspacePaths = new Set();
|
|
13
|
+
const pendingWorkspacePaths = new Set();
|
|
14
|
+
let claimedWorkspacePathsLoaded = false;
|
|
15
|
+
let workspaceAllocationQueue = Promise.resolve();
|
|
16
|
+
|
|
17
|
+
const withWorkspaceAllocationLock = (operation) => {
|
|
18
|
+
const result = workspaceAllocationQueue.then(operation, operation);
|
|
19
|
+
workspaceAllocationQueue = result.then(() => undefined, () => undefined);
|
|
20
|
+
return result;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const loadClaimedWorkspacePaths = async () => {
|
|
24
|
+
if (claimedWorkspacePathsLoaded) return;
|
|
25
|
+
for (const cwd of await registry.paths()) claimedWorkspacePaths.add(cwd);
|
|
26
|
+
claimedWorkspacePathsLoaded = true;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const reserveWorkspacePath = () => withWorkspaceAllocationLock(async () => {
|
|
30
|
+
await loadClaimedWorkspacePaths();
|
|
31
|
+
const candidate = await reserveWorkspace(workspaceRoot, claimedWorkspacePaths);
|
|
32
|
+
pendingWorkspacePaths.add(candidate);
|
|
33
|
+
return candidate;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const materializeWorkspacePath = (candidate) => withWorkspaceAllocationLock(async () => {
|
|
37
|
+
await loadClaimedWorkspacePaths();
|
|
38
|
+
const cwd = await materializeWorkspace(workspaceRoot, candidate, claimedWorkspacePaths);
|
|
39
|
+
pendingWorkspacePaths.delete(candidate);
|
|
40
|
+
return cwd;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const markCwdNoticeConsumed = async (sessionId) => {
|
|
44
|
+
const entry = await registry.entry(sessionId);
|
|
45
|
+
if (entry?.cwdNoticePending) await registry.set(sessionId, entry.cwd, { ...entry, cwdNoticePending: false });
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
class WorkspaceWebRepl extends WebRepl {
|
|
49
|
+
syncScheduled = false;
|
|
50
|
+
|
|
51
|
+
subscribe(res) {
|
|
52
|
+
if (this.subscribers.size >= maxSubscribers) {
|
|
53
|
+
res.writeHead(503, {
|
|
54
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
55
|
+
'Cache-Control': 'no-store',
|
|
56
|
+
'Retry-After': '30',
|
|
57
|
+
});
|
|
58
|
+
res.end('too many live viewers for this session');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
super.subscribe(res);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
snapshot(includeCatalog = false) {
|
|
65
|
+
return {
|
|
66
|
+
...super.snapshot(includeCatalog),
|
|
67
|
+
cwd: currentEngineCwd(this.runtime.engine, projectRoot),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async submit(text, attachments = []) {
|
|
72
|
+
if (!String(text || '').trim() && attachments.length === 0) return super.submit(text, attachments);
|
|
73
|
+
await this.materializeCurrentWorkspace();
|
|
74
|
+
return super.submit(text, attachments);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async browseWorkspace(value) {
|
|
78
|
+
try {
|
|
79
|
+
return { ok: true, ...(await browseWorkspace(value, currentEngineCwd(this.runtime.engine, projectRoot))) };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return workspaceFailure('CWD_INVALID', error);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async createWorkspaceDirectory(value) {
|
|
86
|
+
try {
|
|
87
|
+
const current = currentEngineCwd(this.runtime.engine, projectRoot);
|
|
88
|
+
const target = resolveWorkspaceInput(value, current);
|
|
89
|
+
await mkdir(target, { recursive: true });
|
|
90
|
+
return { ok: true, ...(await browseWorkspace(target, current)) };
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return workspaceFailure('CWD_CREATE_FAILED', error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async deleteWorkspaceDirectory(value) {
|
|
97
|
+
try {
|
|
98
|
+
const current = currentEngineCwd(this.runtime.engine, projectRoot);
|
|
99
|
+
const target = resolveWorkspaceInput(value, current);
|
|
100
|
+
const root = path.parse(target).root;
|
|
101
|
+
if (target === root || isSameOrAncestor(target, current)) throw new Error('当前工作目录及其上级目录不能删除');
|
|
102
|
+
await rmdir(target);
|
|
103
|
+
return { ok: true, ...(await browseWorkspace(path.dirname(target), current)) };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return workspaceFailure('CWD_DELETE_FAILED', error);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async changeWorkspace(value) {
|
|
110
|
+
if (this.busy) return { ok: false, errorCode: 'CWD_UPDATE_BLOCKED', error: '模型回答期间不能切换工作目录' };
|
|
111
|
+
try {
|
|
112
|
+
const previous = currentEngineCwd(this.runtime.engine, projectRoot);
|
|
113
|
+
const cwd = await validateWorkspaceDirectory(resolveWorkspaceInput(value, previous));
|
|
114
|
+
if (cwd === previous) return { ok: true, cwd, unchanged: true };
|
|
115
|
+
const snapshot = this.runtime.engine.snapshot().session;
|
|
116
|
+
if (!snapshot) throw new Error('session transcripts are disabled');
|
|
117
|
+
const stored = await registry.entry(snapshot.sessionId);
|
|
118
|
+
const history = normalizeCwdHistory(stored?.cwdHistory, previous);
|
|
119
|
+
if (history.at(-1) !== previous) history.push(previous);
|
|
120
|
+
if (history.at(-1) !== cwd) history.push(cwd);
|
|
121
|
+
await registry.set(snapshot.sessionId, cwd, {
|
|
122
|
+
materialized: true,
|
|
123
|
+
cwdHistory: history,
|
|
124
|
+
cwdNoticePending: true,
|
|
125
|
+
});
|
|
126
|
+
this.runtime.engine = createWorkspaceEngine(this.runtime.engine, cwd, snapshot.sessionId, true, {
|
|
127
|
+
cwdTransitionPaths: history,
|
|
128
|
+
onCwdTransitionConsumed: () => markCwdNoticeConsumed(snapshot.sessionId),
|
|
129
|
+
});
|
|
130
|
+
await this.runtime.engine.initialize();
|
|
131
|
+
registerEngineSync(this);
|
|
132
|
+
await Promise.all([this.loadSessionPlugins(snapshot.sessionId), this.loadSessionTools(snapshot.sessionId)]);
|
|
133
|
+
await this.refreshSessionView();
|
|
134
|
+
return { ok: true, cwd, history };
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return workspaceFailure('CWD_UPDATE_FAILED', error);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async materializeCurrentWorkspace() {
|
|
141
|
+
const candidate = currentEngineCwd(this.runtime.engine, projectRoot);
|
|
142
|
+
if (!isInsideRoot(candidate, workspaceRoot)) return candidate;
|
|
143
|
+
if (!pendingWorkspacePaths.has(candidate) && await pathExists(candidate)) {
|
|
144
|
+
claimedWorkspacePaths.add(candidate);
|
|
145
|
+
return candidate;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const cwd = await materializeWorkspacePath(candidate);
|
|
149
|
+
const snapshot = this.runtime.engine.snapshot().session;
|
|
150
|
+
if (!snapshot) throw new Error('session transcripts are disabled');
|
|
151
|
+
if (cwd !== candidate) {
|
|
152
|
+
this.runtime.engine = createWorkspaceEngine(this.runtime.engine, cwd, snapshot.sessionId, true);
|
|
153
|
+
await this.runtime.engine.initialize();
|
|
154
|
+
registerEngineSync(this);
|
|
155
|
+
await Promise.all([this.loadSessionPlugins(snapshot.sessionId), this.loadSessionTools(snapshot.sessionId)]);
|
|
156
|
+
await this.refreshSessionView();
|
|
157
|
+
}
|
|
158
|
+
await registry.set(snapshot.sessionId, cwd, { materialized: true, cwdHistory: [cwd] });
|
|
159
|
+
return cwd;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async newSession() {
|
|
163
|
+
try {
|
|
164
|
+
await this.detachRunningForeground('new session');
|
|
165
|
+
const cwd = await reserveWorkspacePath();
|
|
166
|
+
this.runtime.engine = createWorkspaceEngine(this.runtime.engine, cwd, undefined, false);
|
|
167
|
+
await this.runtime.engine.initialize();
|
|
168
|
+
registerEngineSync(this);
|
|
169
|
+
const snapshot = this.runtime.engine.snapshot().session;
|
|
170
|
+
if (!snapshot) throw new Error('session transcripts are disabled');
|
|
171
|
+
await Promise.all([this.loadSessionPlugins(snapshot.sessionId), this.loadSessionTools(snapshot.sessionId)]);
|
|
172
|
+
await registry.set(snapshot.sessionId, cwd, { materialized: false, cwdHistory: [cwd] });
|
|
173
|
+
await this.refreshSessionView();
|
|
174
|
+
return { ok: true, cwd };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
177
|
+
return { ok: false, errorCode: 'SESSION_CREATE_FAILED', error: message };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async resumeSession(sessionId) {
|
|
182
|
+
if (!sessionId) return { ok: false, errorCode: 'INVALID_REQUEST', error: 'sessionId is required' };
|
|
183
|
+
if (this.backgroundSessionRuns.has(sessionId)) return super.resumeSession(sessionId);
|
|
184
|
+
try {
|
|
185
|
+
await this.detachRunningForeground('session switch');
|
|
186
|
+
const workspace = await registry.entry(sessionId);
|
|
187
|
+
const cwd = workspace?.cwd || projectRoot;
|
|
188
|
+
if (workspace && !workspace.materialized) pendingWorkspacePaths.add(cwd);
|
|
189
|
+
this.runtime.engine = createWorkspaceEngine(this.runtime.engine, cwd, sessionId, true, {
|
|
190
|
+
cwdTransitionPaths: workspace?.cwdNoticePending ? workspace.cwdHistory : undefined,
|
|
191
|
+
onCwdTransitionConsumed: () => markCwdNoticeConsumed(sessionId),
|
|
192
|
+
});
|
|
193
|
+
await this.runtime.engine.initialize();
|
|
194
|
+
registerEngineSync(this);
|
|
195
|
+
const snapshot = this.runtime.engine.snapshot().session;
|
|
196
|
+
if (!snapshot) throw new Error('session transcripts are disabled');
|
|
197
|
+
await Promise.all([this.loadSessionPlugins(snapshot.sessionId), this.loadSessionTools(snapshot.sessionId)]);
|
|
198
|
+
await this.refreshSessionView();
|
|
199
|
+
return { ok: true, cwd };
|
|
200
|
+
} catch (error) {
|
|
201
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
202
|
+
return { ok: false, errorCode: 'SESSION_RESUME_FAILED', error: message };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
broadcastSync() {
|
|
207
|
+
if (this.syncScheduled) return;
|
|
208
|
+
this.syncScheduled = true;
|
|
209
|
+
setImmediate(() => {
|
|
210
|
+
this.syncScheduled = false;
|
|
211
|
+
const payload = this.snapshot(false);
|
|
212
|
+
for (const subscriber of this.subscribers) {
|
|
213
|
+
const res = subscriber.response;
|
|
214
|
+
if (res.destroyed || res.writableEnded) continue;
|
|
215
|
+
this.send(subscriber, 'sync', payload);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
workspaceRoot,
|
|
223
|
+
async createRuntime(runtimeOptions = {}) {
|
|
224
|
+
const mappedWorkspace = runtimeOptions.sessionId
|
|
225
|
+
? await registry.entry(runtimeOptions.sessionId)
|
|
226
|
+
: undefined;
|
|
227
|
+
const mappedCwd = mappedWorkspace?.cwd;
|
|
228
|
+
const shouldAllocate = !mappedCwd && runtimeOptions.resume === false;
|
|
229
|
+
const cwd = mappedCwd || (shouldAllocate ? await reserveWorkspacePath() : projectRoot);
|
|
230
|
+
if (mappedCwd && (!mappedWorkspace.materialized || !await pathExists(mappedCwd))) {
|
|
231
|
+
claimedWorkspacePaths.add(mappedCwd);
|
|
232
|
+
pendingWorkspacePaths.add(mappedCwd);
|
|
233
|
+
}
|
|
234
|
+
const runtime = await options.createRuntime({
|
|
235
|
+
...runtimeOptions,
|
|
236
|
+
cwd,
|
|
237
|
+
cwdTransitionPaths: mappedWorkspace?.cwdNoticePending ? mappedWorkspace.cwdHistory : undefined,
|
|
238
|
+
onCwdTransitionConsumed: runtimeOptions.sessionId
|
|
239
|
+
? () => markCwdNoticeConsumed(runtimeOptions.sessionId)
|
|
240
|
+
: undefined,
|
|
241
|
+
});
|
|
242
|
+
const sessionId = runtime.engine.snapshot().session?.sessionId;
|
|
243
|
+
if (sessionId && cwd !== projectRoot) {
|
|
244
|
+
await registry.set(sessionId, cwd, { materialized: !pendingWorkspacePaths.has(cwd) });
|
|
245
|
+
}
|
|
246
|
+
return runtime;
|
|
247
|
+
},
|
|
248
|
+
createRepl(runtime) {
|
|
249
|
+
return new WorkspaceWebRepl(runtime);
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function createWorkspaceEngine(source, cwd, sessionId, resume, overrides = {}) {
|
|
255
|
+
const settings = source.getModelSettings();
|
|
256
|
+
return new QueryEngine({
|
|
257
|
+
...source.options,
|
|
258
|
+
cwd,
|
|
259
|
+
model: settings.model,
|
|
260
|
+
reasoning: settings.reasoning,
|
|
261
|
+
...overrides,
|
|
262
|
+
session: source.options.session
|
|
263
|
+
? { ...source.options.session, sessionId, resume }
|
|
264
|
+
: undefined,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function registerEngineSync(repl) {
|
|
269
|
+
repl.runtime.engine.onSessionTitleChange(() => repl.broadcastSync());
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function currentEngineCwd(engine, fallback) {
|
|
273
|
+
return path.resolve(engine?.cwd || engine?.options?.cwd || fallback);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function reserveWorkspace(root, claimed = new Set()) {
|
|
277
|
+
await mkdir(root, { recursive: true });
|
|
278
|
+
const now = new Date();
|
|
279
|
+
for (let offset = 0; offset < 120; offset += 1) {
|
|
280
|
+
const candidate = path.join(root, formatWorkspaceStamp(new Date(now.getTime() + offset * 1000)));
|
|
281
|
+
if (claimed.has(candidate) || await pathExists(candidate)) continue;
|
|
282
|
+
claimed.add(candidate);
|
|
283
|
+
return candidate;
|
|
284
|
+
}
|
|
285
|
+
throw new Error('unable to reserve a unique workspace directory');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export async function materializeWorkspace(root, candidate, claimed = new Set()) {
|
|
289
|
+
await mkdir(root, { recursive: true });
|
|
290
|
+
const resolvedCandidate = path.resolve(candidate);
|
|
291
|
+
if (!isInsideRoot(resolvedCandidate, root)) throw new Error('workspace path is outside workspace root');
|
|
292
|
+
try {
|
|
293
|
+
await mkdir(resolvedCandidate);
|
|
294
|
+
claimed.add(resolvedCandidate);
|
|
295
|
+
return resolvedCandidate;
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
claimed.delete(resolvedCandidate);
|
|
301
|
+
const replacement = await reserveWorkspace(root, claimed);
|
|
302
|
+
await mkdir(replacement);
|
|
303
|
+
claimed.add(replacement);
|
|
304
|
+
return replacement;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function pathExists(candidate) {
|
|
308
|
+
try {
|
|
309
|
+
await stat(candidate);
|
|
310
|
+
return true;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (error?.code === 'ENOENT') return false;
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function formatWorkspaceStamp(date) {
|
|
318
|
+
const two = (value) => String(value).padStart(2, '0');
|
|
319
|
+
return [
|
|
320
|
+
two(date.getFullYear() % 100),
|
|
321
|
+
two(date.getMonth() + 1),
|
|
322
|
+
two(date.getDate()),
|
|
323
|
+
two(date.getHours()),
|
|
324
|
+
two(date.getMinutes()),
|
|
325
|
+
two(date.getSeconds()),
|
|
326
|
+
].join('');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export class SessionWorkspaceRegistry {
|
|
330
|
+
constructor(file, workspaceRoot) {
|
|
331
|
+
this.file = file;
|
|
332
|
+
this.workspaceRoot = workspaceRoot;
|
|
333
|
+
this.items = undefined;
|
|
334
|
+
this.writeQueue = Promise.resolve();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async get(sessionId) {
|
|
338
|
+
return (await this.entry(sessionId))?.cwd;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async entry(sessionId) {
|
|
342
|
+
const items = await this.load();
|
|
343
|
+
const value = items[String(sessionId || '')];
|
|
344
|
+
if (!value) return undefined;
|
|
345
|
+
const rawCwd = typeof value === 'string' ? value : value?.cwd;
|
|
346
|
+
if (!rawCwd) return undefined;
|
|
347
|
+
const cwd = path.resolve(rawCwd);
|
|
348
|
+
return {
|
|
349
|
+
cwd,
|
|
350
|
+
materialized: typeof value === 'string' ? true : value.materialized !== false,
|
|
351
|
+
cwdHistory: normalizeCwdHistory(typeof value === 'string' ? undefined : value.cwdHistory, cwd),
|
|
352
|
+
cwdNoticePending: typeof value === 'string' ? false : value.cwdNoticePending === true,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async set(sessionId, cwd, options = {}) {
|
|
357
|
+
const id = String(sessionId || '').trim();
|
|
358
|
+
if (!id) return;
|
|
359
|
+
const resolved = path.resolve(cwd);
|
|
360
|
+
const items = await this.load();
|
|
361
|
+
const previous = items[id];
|
|
362
|
+
const previousObject = typeof previous === 'object' && previous ? previous : {};
|
|
363
|
+
items[id] = {
|
|
364
|
+
...previousObject,
|
|
365
|
+
cwd: resolved,
|
|
366
|
+
materialized: options.materialized !== false,
|
|
367
|
+
cwdHistory: normalizeCwdHistory(options.cwdHistory ?? previousObject.cwdHistory, resolved),
|
|
368
|
+
cwdNoticePending: options.cwdNoticePending === undefined
|
|
369
|
+
? previousObject.cwdNoticePending === true
|
|
370
|
+
: options.cwdNoticePending === true,
|
|
371
|
+
};
|
|
372
|
+
this.writeQueue = this.writeQueue.then(async () => {
|
|
373
|
+
await mkdir(path.dirname(this.file), { recursive: true });
|
|
374
|
+
await writeFile(this.file, `${JSON.stringify(items, null, 2)}\n`, 'utf8');
|
|
375
|
+
});
|
|
376
|
+
await this.writeQueue;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async paths() {
|
|
380
|
+
const items = await this.load();
|
|
381
|
+
return Object.values(items)
|
|
382
|
+
.map((value) => path.resolve(String(typeof value === 'string' ? value : value?.cwd || '')))
|
|
383
|
+
.filter((value) => isInsideRoot(value, this.workspaceRoot));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async load() {
|
|
387
|
+
if (this.items) return this.items;
|
|
388
|
+
try {
|
|
389
|
+
const parsed = JSON.parse(await readFile(this.file, 'utf8'));
|
|
390
|
+
this.items = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
393
|
+
this.items = {};
|
|
394
|
+
}
|
|
395
|
+
return this.items;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function browseWorkspace(value, currentCwd) {
|
|
400
|
+
const current = await validateWorkspaceDirectory(resolveWorkspaceInput(value, currentCwd));
|
|
401
|
+
const [entries, locations] = await Promise.all([
|
|
402
|
+
readdir(current, { withFileTypes: true }),
|
|
403
|
+
discoverWorkspaceLocations(),
|
|
404
|
+
]);
|
|
405
|
+
return {
|
|
406
|
+
cwd: current,
|
|
407
|
+
parent: current === path.parse(current).root ? undefined : path.dirname(current),
|
|
408
|
+
home: os.homedir(),
|
|
409
|
+
locations,
|
|
410
|
+
entries: entries
|
|
411
|
+
.filter((entry) => entry.isDirectory())
|
|
412
|
+
.map((entry) => ({ name: entry.name, path: path.join(current, entry.name) }))
|
|
413
|
+
.sort((left, right) => left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: 'base' })),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
let workspaceLocationsCache;
|
|
418
|
+
let workspaceLocationsCachedAt = 0;
|
|
419
|
+
|
|
420
|
+
export async function discoverWorkspaceLocations() {
|
|
421
|
+
if (workspaceLocationsCache && Date.now() - workspaceLocationsCachedAt < 5000) return workspaceLocationsCache;
|
|
422
|
+
const home = os.homedir();
|
|
423
|
+
const candidates = [
|
|
424
|
+
{ id: 'home', label: '主目录', path: home, kind: 'home' },
|
|
425
|
+
...[
|
|
426
|
+
['desktop', '桌面', ['Desktop', '桌面']],
|
|
427
|
+
['documents', '文档', ['Documents', '文档']],
|
|
428
|
+
['downloads', '下载', ['Downloads', '下载']],
|
|
429
|
+
].flatMap(([id, label, names]) => names.map((name) => ({ id, label, path: path.join(home, name), kind: 'favorite' }))),
|
|
430
|
+
];
|
|
431
|
+
|
|
432
|
+
if (process.platform === 'win32') {
|
|
433
|
+
const drives = await Promise.all('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(async (letter) => {
|
|
434
|
+
const drivePath = `${letter}:\\`;
|
|
435
|
+
return await directoryExists(drivePath)
|
|
436
|
+
? { id: `drive-${letter}`, label: `本地磁盘 (${letter}:)`, path: drivePath, kind: 'drive' }
|
|
437
|
+
: undefined;
|
|
438
|
+
}));
|
|
439
|
+
candidates.push(...drives.filter(Boolean));
|
|
440
|
+
} else {
|
|
441
|
+
candidates.push({ id: 'root', label: '文件系统', path: '/', kind: 'root' });
|
|
442
|
+
const mountRoots = process.platform === 'darwin'
|
|
443
|
+
? ['/Volumes']
|
|
444
|
+
: ['/mnt', '/media', path.join('/media', os.userInfo().username), path.join('/run/media', os.userInfo().username)];
|
|
445
|
+
for (const mountRoot of mountRoots) {
|
|
446
|
+
const mounts = await readdir(mountRoot, { withFileTypes: true }).catch(() => []);
|
|
447
|
+
for (const mount of mounts.filter((entry) => entry.isDirectory())) {
|
|
448
|
+
candidates.push({ id: `volume-${mount.name}`, label: mount.name, path: path.join(mountRoot, mount.name), kind: 'volume' });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const seen = new Set();
|
|
454
|
+
const locations = [];
|
|
455
|
+
for (const candidate of candidates) {
|
|
456
|
+
const resolved = path.resolve(candidate.path);
|
|
457
|
+
const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
458
|
+
if (seen.has(key) || !await directoryExists(resolved)) continue;
|
|
459
|
+
seen.add(key);
|
|
460
|
+
locations.push({ ...candidate, path: resolved });
|
|
461
|
+
}
|
|
462
|
+
workspaceLocationsCache = locations;
|
|
463
|
+
workspaceLocationsCachedAt = Date.now();
|
|
464
|
+
return locations;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function resolveWorkspaceInput(value, currentCwd) {
|
|
468
|
+
let input = String(value || '').trim().replace(/^["']|["']$/g, '');
|
|
469
|
+
if (!input) return path.resolve(currentCwd || process.cwd());
|
|
470
|
+
if (input === '~') input = os.homedir();
|
|
471
|
+
else if (input.startsWith('~/') || input.startsWith('~\\')) input = path.join(os.homedir(), input.slice(2));
|
|
472
|
+
input = input.replace(/[\\/]+/g, path.sep);
|
|
473
|
+
if (process.platform === 'win32' && /^[a-zA-Z]:$/.test(input)) input += path.sep;
|
|
474
|
+
return path.resolve(currentCwd || process.cwd(), input);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function validateWorkspaceDirectory(candidate) {
|
|
478
|
+
const resolved = path.resolve(candidate);
|
|
479
|
+
const info = await stat(resolved);
|
|
480
|
+
if (!info.isDirectory()) throw new Error('路径不是文件夹');
|
|
481
|
+
return resolved;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function directoryExists(candidate) {
|
|
485
|
+
try {
|
|
486
|
+
return (await stat(candidate)).isDirectory();
|
|
487
|
+
} catch {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function normalizeCwdHistory(value, fallback) {
|
|
493
|
+
const history = Array.isArray(value) ? value.map((entry) => String(entry || '').trim()).filter(Boolean) : [];
|
|
494
|
+
if (!history.length && fallback) history.push(path.resolve(fallback));
|
|
495
|
+
return history;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function isSameOrAncestor(candidate, descendant) {
|
|
499
|
+
const relative = path.relative(path.resolve(candidate), path.resolve(descendant));
|
|
500
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function workspaceFailure(errorCode, error) {
|
|
504
|
+
return { ok: false, errorCode, error: error instanceof Error ? error.message : String(error) };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function isInsideRoot(candidate, root) {
|
|
508
|
+
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
509
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function positiveNumber(value, fallback) {
|
|
513
|
+
const parsed = Number(value);
|
|
514
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
515
|
+
}
|