@vibe-x/dsh-html-workbench 0.3.1 → 0.3.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 +55 -6
- package/assets/workbench.html +51 -14
- package/dsh.plugin.json +1 -1
- package/lib/client.js +480 -199
- package/package.json +2 -1
- package/scripts/workbench.py +275 -53
- package/src/client.js +479 -198
- package/src/host.js +260 -111
- package/src/index.js +32 -7
package/src/host.js
CHANGED
|
@@ -41,11 +41,10 @@ return {
|
|
|
41
41
|
// the interpreter's absolute path and uses that instead.
|
|
42
42
|
const PYTHON_CANDIDATES = opts.pythonCommand
|
|
43
43
|
? [opts.pythonCommand]
|
|
44
|
-
:
|
|
44
|
+
: ['python3', 'python']
|
|
45
45
|
// Static packages inject the runtime directory resolved (and probed) by
|
|
46
|
-
// src/index.js.
|
|
47
|
-
//
|
|
48
|
-
// runners may not expose Node's `os` module. The folder name matches
|
|
46
|
+
// src/index.js. Dynamic loads use TEMP/TMPDIR or the current workspace.
|
|
47
|
+
// The folder name matches
|
|
49
48
|
// RUNTIME_DIR_NAME in workbench.py so both halves agree on one location.
|
|
50
49
|
const tempRoot = typeof process !== 'undefined' && process.env
|
|
51
50
|
? (process.env.TEMP || process.env.TMP || process.env.TMPDIR)
|
|
@@ -55,9 +54,10 @@ return {
|
|
|
55
54
|
// yield `C:\Temp\hwb\/logs` — a path Python tolerates but that reads as
|
|
56
55
|
// corruption in every log line and error message a user pastes back.
|
|
57
56
|
const trimSeparator = (value) => String(value).replace(/[\\/]+$/, '')
|
|
57
|
+
const currentDir = typeof process !== 'undefined' && typeof process.cwd === 'function' ? process.cwd() : '.'
|
|
58
58
|
const RUNTIME_DIR = opts.runtimeDir
|
|
59
59
|
? trimSeparator(opts.runtimeDir)
|
|
60
|
-
: (tempRoot
|
|
60
|
+
: trimSeparator(tempRoot || currentDir) + '/.html-workbench'
|
|
61
61
|
|
|
62
62
|
// These are REQUESTS, not guarantees. The service runs as a DSH sandboxed
|
|
63
63
|
// child that may write only the session workspace and a private temp folder
|
|
@@ -70,16 +70,24 @@ return {
|
|
|
70
70
|
// cache/log data either way.
|
|
71
71
|
const LOG_DIR = RUNTIME_DIR + '/logs'
|
|
72
72
|
const VENDOR_CACHE = RUNTIME_DIR + '/vendor'
|
|
73
|
+
const hostLog = opts.hostLog || null
|
|
74
|
+
const nodeVersion = typeof process !== 'undefined' ? process.version : null
|
|
75
|
+
const arch = typeof process !== 'undefined' ? process.arch : null
|
|
76
|
+
const mode = typeof harness !== 'undefined' ? 'dynamic' : 'static'
|
|
77
|
+
let hostLogError = null
|
|
73
78
|
|
|
74
79
|
// Directories the running service reported; null until it says so.
|
|
75
80
|
let resolvedPaths = null
|
|
81
|
+
let serviceInfo = null
|
|
82
|
+
let startupLogFile
|
|
76
83
|
|
|
77
84
|
let assets = []
|
|
78
85
|
let seq = 0
|
|
79
86
|
let ownedProcess = null
|
|
80
87
|
let serviceRunning = false
|
|
81
88
|
let startError = null
|
|
82
|
-
let pythonCommand =
|
|
89
|
+
let pythonCommand = null
|
|
90
|
+
let dependencyIssue = null
|
|
83
91
|
// A failed probe must be remembered: `resolve` runs on every keystroke and
|
|
84
92
|
// `health` on a timer, so re-probing would spawn three 5s subprocesses per
|
|
85
93
|
// call and flood the journal with one repeated line. The TTL still lets a
|
|
@@ -88,9 +96,34 @@ return {
|
|
|
88
96
|
let pythonProbeFailedAt = 0
|
|
89
97
|
let pythonProbe = null
|
|
90
98
|
let disposed = false
|
|
99
|
+
let starting = null
|
|
100
|
+
let restarting = null
|
|
101
|
+
let serviceEpoch = 0
|
|
91
102
|
|
|
92
103
|
const shell = ctx.get('shell')
|
|
93
104
|
|
|
105
|
+
// ── Shell API: two shapes, one contract ──────────────────────────────────
|
|
106
|
+
//
|
|
107
|
+
// The ShellExecutor that public kernels ship (`@deepseek-ai/dsh-shell`
|
|
108
|
+
// 0.1.7-rc.2 / 0.2.0-rc.2) exposes `resolve()` + `execute()` only — `run()`
|
|
109
|
+
// and `start()` were never published, so on a stock installation every call
|
|
110
|
+
// below threw (`shell.start is not a function`) and the service could never
|
|
111
|
+
// start. Both shapes are honoured: an executor offering the legacy pair is
|
|
112
|
+
// driven exactly as before, anything else goes through `execute()` —
|
|
113
|
+
// `await execute(spec).result()` IS the old `run()` (the foreground
|
|
114
|
+
// projection whose ShellRunResult/CollectedOutput fields the diagnostics
|
|
115
|
+
// below already read), and the value `execute()` resolves to IS the old
|
|
116
|
+
// `start()` handle (ShellExecution extends ShellProcess: status / exitCode /
|
|
117
|
+
// signal / readOutput() / kill()).
|
|
118
|
+
const runShell = async (spec) =>
|
|
119
|
+
typeof shell.run === 'function' ? shell.run(spec) : (await shell.execute(spec)).result()
|
|
120
|
+
const startShell = async (spec) =>
|
|
121
|
+
// `onExpiry` only in this branch: `resolve()` defaults to `'kill'` with a
|
|
122
|
+
// 60s deadline, which would kill the long-running serve process a minute
|
|
123
|
+
// after start. The legacy branch inherits whatever its executor already
|
|
124
|
+
// did with a background handle.
|
|
125
|
+
typeof shell.start === 'function' ? shell.start(spec) : shell.execute({ ...spec, onExpiry: 'none' })
|
|
126
|
+
|
|
94
127
|
// ── Diagnostics journal ──────────────────────────────────────────────────
|
|
95
128
|
//
|
|
96
129
|
// A failed start used to surface as nothing but a red dot: the only signal
|
|
@@ -131,8 +164,10 @@ return {
|
|
|
131
164
|
}
|
|
132
165
|
|
|
133
166
|
const note = (level, message, detail) => {
|
|
134
|
-
const text = String(message || '')
|
|
135
|
-
const
|
|
167
|
+
const text = String(message || '').slice(0, 2000)
|
|
168
|
+
const raw = detail ? String(detail) : null
|
|
169
|
+
const body = raw && raw.length > 4000 ? raw.slice(0, 1000) + '\n…(中间输出已截断)\n' + raw.slice(-3000) : raw
|
|
170
|
+
const at = Date.now()
|
|
136
171
|
// Retrying a broken start replays the SAME failure verbatim, interleaved
|
|
137
172
|
// with the "restarting" line — so consecutive-only folding would miss it.
|
|
138
173
|
// Match anywhere, then move the entry to the end so its position still
|
|
@@ -144,13 +179,27 @@ return {
|
|
|
144
179
|
}
|
|
145
180
|
if (index >= 0) {
|
|
146
181
|
const entry = journal.splice(index, 1)[0]
|
|
147
|
-
entry.at =
|
|
182
|
+
entry.at = at
|
|
148
183
|
entry.repeated = (entry.repeated || 1) + 1
|
|
149
184
|
journal.push(entry)
|
|
150
185
|
} else {
|
|
151
|
-
journal.push({ at:
|
|
186
|
+
journal.push({ at, firstAt: at, level, message: text, detail: body })
|
|
152
187
|
trimJournal()
|
|
153
188
|
}
|
|
189
|
+
const entry = journal[journal.length - 1]
|
|
190
|
+
if (entry.loggedAt != null && at - entry.loggedAt < 60000) return
|
|
191
|
+
entry.loggedAt = at
|
|
192
|
+
if (hostLog) {
|
|
193
|
+
try {
|
|
194
|
+
hostLog.append({ at, level, message: text, detail: body, port: PORT, version: VERSION,
|
|
195
|
+
firstAt: entry.firstAt, repeated: entry.repeated || 1 })
|
|
196
|
+
hostLogError = null
|
|
197
|
+
} catch (e) {
|
|
198
|
+
const failure = (e && e.message) || String(e)
|
|
199
|
+
if (hostLogError !== failure) console.error('[html-workbench] 无法写入宿主日志', failure)
|
|
200
|
+
hostLogError = failure
|
|
201
|
+
}
|
|
202
|
+
}
|
|
154
203
|
if (level === 'error') console.error('[html-workbench] ' + text, body || '')
|
|
155
204
|
}
|
|
156
205
|
|
|
@@ -202,7 +251,7 @@ return {
|
|
|
202
251
|
if (!IS_WINDOWS || !shell) return null
|
|
203
252
|
const probe = 'py -3 -c ' + quoteArg('import sys; print(sys.executable)')
|
|
204
253
|
try {
|
|
205
|
-
const result = await
|
|
254
|
+
const result = await runShell(shell.resolve({ command: probe, timeoutMs: 5000, stdoutMaxBytes: 4096 }))
|
|
206
255
|
if (!result || result.exitCode !== 0) return { failure: 'py -3: ' + describeRun(result) }
|
|
207
256
|
const executable = outputText(result.stdout).split('\n').pop().trim()
|
|
208
257
|
if (!executable) return { failure: 'py -3: 没有返回解释器路径' }
|
|
@@ -221,33 +270,41 @@ return {
|
|
|
221
270
|
if (pythonProbeFailedAt && Date.now() - pythonProbeFailedAt < PROBE_RETRY_MS) return null
|
|
222
271
|
pythonProbe = (async () => {
|
|
223
272
|
const failures = []
|
|
224
|
-
|
|
273
|
+
const unsupported = []
|
|
274
|
+
const checkVersion = async (command) => {
|
|
225
275
|
try {
|
|
226
|
-
const spec = shell.resolve({ command:
|
|
227
|
-
const result = await
|
|
276
|
+
const spec = shell.resolve({ command: command + ' --version', timeoutMs: 5000, stdoutMaxBytes: 4096 })
|
|
277
|
+
const result = await runShell(spec)
|
|
228
278
|
if (result && result.exitCode === 0) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
279
|
+
const output = outputText(result.stdout) + '\n' + outputText(result.stderr)
|
|
280
|
+
const version = /\bPython (\d+)\.(\d+)(?:\.\d+)?/.exec(output)
|
|
281
|
+
if (version && (Number(version[1]) > 3 || (version[1] === '3' && Number(version[2]) >= 9))) {
|
|
282
|
+
pythonCommand = command
|
|
283
|
+
pythonProbeFailedAt = 0
|
|
284
|
+
if (dependencyIssue?.code.startsWith('PYTHON_')) dependencyIssue = null
|
|
285
|
+
note('info', '使用 Python 解释器:' + command + '(' + version[0] + ')')
|
|
286
|
+
return true
|
|
287
|
+
}
|
|
288
|
+
if (version) unsupported.push(version[0])
|
|
233
289
|
}
|
|
234
|
-
failures.push(
|
|
290
|
+
failures.push(command + ': ' + describeRun(result))
|
|
235
291
|
} catch (e) {
|
|
236
|
-
failures.push(
|
|
292
|
+
failures.push(command + ': ' + ((e && e.message) || String(e)))
|
|
237
293
|
}
|
|
294
|
+
return false
|
|
238
295
|
}
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
pythonProbeFailedAt = 0
|
|
243
|
-
note('info', '使用 Python 解释器(经 py 启动器解析):' + launcher.command)
|
|
244
|
-
return pythonCommand
|
|
296
|
+
for (const candidate of PYTHON_CANDIDATES) {
|
|
297
|
+
const command = opts.pythonCommand ? quoteArg(candidate) : candidate
|
|
298
|
+
if (await checkVersion(command)) return pythonCommand
|
|
245
299
|
}
|
|
300
|
+
const launcher = await expandWindowsLauncher()
|
|
301
|
+
if (launcher?.command && await checkVersion(launcher.command)) return pythonCommand
|
|
246
302
|
if (launcher && launcher.failure) failures.push(launcher.failure)
|
|
247
303
|
pythonProbeFailedAt = Date.now()
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
304
|
+
const versions = [...new Set(unsupported)].join('、')
|
|
305
|
+
startError = versions ? '检测到 ' + versions + ',版本过低。' : '找不到可用的 Python 3 解释器。'
|
|
306
|
+
startError += ' 请在运行 DSH 的电脑上安装 Python 3.9 或更高版本,确认 Python 已加入 PATH,然后重启 DSH。'
|
|
307
|
+
dependencyIssue = { code: versions ? 'PYTHON_TOO_OLD' : 'PYTHON_UNAVAILABLE', message: startError }
|
|
251
308
|
note('error', startError, failures.join('\n'))
|
|
252
309
|
return null
|
|
253
310
|
})()
|
|
@@ -266,7 +323,7 @@ return {
|
|
|
266
323
|
const command = python + ' ' + quoteArg(SCRIPT) + ' ' + args
|
|
267
324
|
try {
|
|
268
325
|
const spec = shell.resolve({ command: command, timeoutMs: timeoutMs || 15000, stdoutMaxBytes: 64 * 1024 })
|
|
269
|
-
const result = await
|
|
326
|
+
const result = await runShell(spec)
|
|
270
327
|
// A non-zero exit is the interesting case and used to be discarded: the
|
|
271
328
|
// CLI prints a structured `{ok:false,error:{code,message}}` on stdout.
|
|
272
329
|
if (!result || result.exitCode !== 0) note('error', 'workbench.py ' + args.split(' ')[0] + ' 执行失败', describeRun(result))
|
|
@@ -279,17 +336,17 @@ return {
|
|
|
279
336
|
|
|
280
337
|
// `health` runs on a timer, so its failures are expected and must not flood
|
|
281
338
|
// the journal — poll quietly and let the caller decide what is worth noting.
|
|
282
|
-
const probeHealth = async () => {
|
|
339
|
+
const probeHealth = async (timeoutMs = 8000) => {
|
|
283
340
|
if (!shell || !SCRIPT) return null
|
|
284
341
|
const python = await resolvePythonCommand()
|
|
285
342
|
if (!python) return null
|
|
286
343
|
try {
|
|
287
344
|
const spec = shell.resolve({
|
|
288
345
|
command: python + ' ' + quoteArg(SCRIPT) + ' health --port ' + PORT,
|
|
289
|
-
timeoutMs:
|
|
346
|
+
timeoutMs: timeoutMs,
|
|
290
347
|
stdoutMaxBytes: 64 * 1024,
|
|
291
348
|
})
|
|
292
|
-
const result = await
|
|
349
|
+
const result = await runShell(spec)
|
|
293
350
|
if (!result || result.exitCode !== 0) return { ok: false, result: result }
|
|
294
351
|
try {
|
|
295
352
|
const payload = JSON.parse(outputText(result.stdout) || '')
|
|
@@ -309,6 +366,11 @@ return {
|
|
|
309
366
|
// output to read them from).
|
|
310
367
|
const adoptResolvedPaths = (payload) => {
|
|
311
368
|
if (!payload) return
|
|
369
|
+
serviceInfo = { version: payload.version || null, pythonVersion: payload.pythonVersion || null }
|
|
370
|
+
for (const key of ['platform', 'pid', 'pythonExecutable', 'logging']) {
|
|
371
|
+
if (key in payload) serviceInfo[key] = payload[key]
|
|
372
|
+
}
|
|
373
|
+
if ('logFile' in payload) serviceInfo.logFile = payload.logFile
|
|
312
374
|
const logDir = typeof payload.logDir === 'string' ? payload.logDir : null
|
|
313
375
|
const vendorCache = typeof payload.vendorCache === 'string' ? payload.vendorCache : null
|
|
314
376
|
if (logDir || vendorCache) resolvedPaths = { logDir: logDir, vendorCache: vendorCache }
|
|
@@ -326,14 +388,14 @@ return {
|
|
|
326
388
|
// in the detail so nothing is lost when the code is one we do not know.
|
|
327
389
|
const CLI_HINTS = {
|
|
328
390
|
PORT_IN_USE: '端口已被占用。可能有别的进程(或旧版服务)占着它 —— 点「重启服务」会先退役占用者。',
|
|
329
|
-
ASSET_NOT_FOUND: '
|
|
391
|
+
ASSET_NOT_FOUND: '缺少编辑器文件 assets/workbench.html。请重新安装 HTML Workbench 插件,然后重启 DSH。',
|
|
330
392
|
PYTHON_TOO_OLD: '需要 Python 3.9 或更高版本。',
|
|
331
393
|
SERVER_OUTDATED: '端口上有旧版服务且无法自动停止,请手动结束该进程。',
|
|
332
394
|
FILE_NOT_FOUND: '找不到文件。',
|
|
333
|
-
VENDOR_DOWNLOAD_FAILED: '
|
|
395
|
+
VENDOR_DOWNLOAD_FAILED: '无法准备编辑器所需的 GrapesJS。请检查运行 DSH 的电脑能否联网,以及缓存目录是否可写,然后在工作台中重启服务。',
|
|
334
396
|
}
|
|
335
397
|
|
|
336
|
-
const explainOutput = (text) => {
|
|
398
|
+
const explainOutput = (text, startup = false) => {
|
|
337
399
|
if (!text) return null
|
|
338
400
|
for (const line of String(text).split('\n')) {
|
|
339
401
|
const trimmed = line.trim()
|
|
@@ -341,8 +403,13 @@ return {
|
|
|
341
403
|
try {
|
|
342
404
|
const payload = JSON.parse(trimmed)
|
|
343
405
|
if (payload && payload.ok === false && payload.error) {
|
|
406
|
+
if ('logFile' in payload) startupLogFile = payload.logFile
|
|
344
407
|
const hint = CLI_HINTS[payload.error]
|
|
345
|
-
|
|
408
|
+
const message = hint || payload.message || payload.error
|
|
409
|
+
if (startup && ['VENDOR_DOWNLOAD_FAILED', 'ASSET_NOT_FOUND'].includes(payload.error)) {
|
|
410
|
+
dependencyIssue = { code: payload.error, message }
|
|
411
|
+
}
|
|
412
|
+
return message + '(' + payload.error + ')'
|
|
346
413
|
}
|
|
347
414
|
} catch (e) { /* not the JSON line; keep scanning */ }
|
|
348
415
|
}
|
|
@@ -365,13 +432,16 @@ return {
|
|
|
365
432
|
}
|
|
366
433
|
}
|
|
367
434
|
|
|
368
|
-
const
|
|
435
|
+
const startOrReuseService = async () => {
|
|
369
436
|
if (disposed) return null
|
|
437
|
+
serviceEpoch += 1
|
|
370
438
|
if (serviceRunning) return health()
|
|
371
439
|
const existing = await health()
|
|
440
|
+
if (disposed) return null
|
|
372
441
|
if (existing) {
|
|
373
442
|
serviceRunning = true
|
|
374
443
|
startError = null
|
|
444
|
+
dependencyIssue = null
|
|
375
445
|
healthConfirmedAt = Date.now()
|
|
376
446
|
// Record the reuse too, so the journal always explains the CURRENT state
|
|
377
447
|
// rather than only the failures.
|
|
@@ -381,7 +451,8 @@ return {
|
|
|
381
451
|
if (!shell) { startError = 'shell 服务不可用(DSH 未提供 shell,插件无法拉起本地服务)'; note('error', startError); return null }
|
|
382
452
|
if (!SCRIPT) { startError = 'workbench.py 路径未配置(请用静态装载并随包分发 scripts/workbench.py)'; note('error', startError); return null }
|
|
383
453
|
const python = await resolvePythonCommand()
|
|
384
|
-
if (!python) return null
|
|
454
|
+
if (disposed || !python) return null
|
|
455
|
+
startupLogFile = undefined
|
|
385
456
|
let proc = null
|
|
386
457
|
const command = python + ' ' + quoteArg(SCRIPT) + ' serve --port ' + PORT
|
|
387
458
|
+ ' --log-dir ' + quoteArg(LOG_DIR)
|
|
@@ -389,20 +460,29 @@ return {
|
|
|
389
460
|
+ (EDITOR_ROOT ? ' --editor-root ' + quoteArg(EDITOR_ROOT) : '')
|
|
390
461
|
try {
|
|
391
462
|
note('info', '启动本地服务:端口 ' + PORT, command)
|
|
392
|
-
proc =
|
|
463
|
+
proc = await startShell(shell.resolve({ command: command, stdoutMaxBytes: 64 * 1024 }))
|
|
393
464
|
if (disposed) { try { proc.kill() } catch (e) {} return null }
|
|
394
465
|
ownedProcess = proc
|
|
466
|
+
const deadline = Date.now() + 60000
|
|
395
467
|
let transcript = ''
|
|
396
|
-
|
|
468
|
+
let lastProbe = null
|
|
469
|
+
while (!disposed && Date.now() < deadline) {
|
|
397
470
|
const chunk = drain(proc)
|
|
398
|
-
if (chunk) transcript
|
|
399
|
-
const probe = await probeHealth()
|
|
400
|
-
|
|
471
|
+
if (chunk) transcript = (transcript + '\n' + chunk).trim().slice(-16000)
|
|
472
|
+
const probe = await probeHealth(Math.max(1, Math.min(8000, deadline - Date.now())))
|
|
473
|
+
lastProbe = probe
|
|
474
|
+
if (disposed) return null
|
|
475
|
+
if (probe && probe.ok && proc.status === 'running') {
|
|
401
476
|
serviceRunning = true
|
|
402
477
|
startError = null
|
|
478
|
+
dependencyIssue = null
|
|
403
479
|
healthConfirmedAt = Date.now()
|
|
404
480
|
adoptResolvedPaths(probe.payload)
|
|
405
481
|
note('info', '本地服务就绪:版本 ' + (probe.payload.version || '未知') + ',端口 ' + PORT)
|
|
482
|
+
if (probe.payload.logFile === null) {
|
|
483
|
+
const output = drain(proc)
|
|
484
|
+
note('warn', 'Python 服务未能启用文件日志', [transcript, output].filter(Boolean).join('\n'))
|
|
485
|
+
}
|
|
406
486
|
return probe.payload
|
|
407
487
|
}
|
|
408
488
|
// Stop waiting the moment the process is gone. Polling health for the
|
|
@@ -410,29 +490,41 @@ return {
|
|
|
410
490
|
// fast, loud failure look like a hang.
|
|
411
491
|
if (proc.status !== 'running') {
|
|
412
492
|
const tail = drain(proc)
|
|
413
|
-
if (tail) transcript
|
|
493
|
+
if (tail) transcript = (transcript + '\n' + tail).trim().slice(-16000)
|
|
414
494
|
// Lead with the CLI's own diagnosis when it gave one — "端口已被占用"
|
|
415
495
|
// is actionable, "exit=1" is not.
|
|
416
|
-
const explained = explainOutput(transcript)
|
|
417
|
-
|
|
418
|
-
|
|
496
|
+
const explained = explainOutput(transcript, true)
|
|
497
|
+
const exit = 'serve 进程已退出(exit=' + String(proc.exitCode) + (proc.signal ? ', 信号 ' + proc.signal : '') + ')'
|
|
498
|
+
startError = explained || exit
|
|
499
|
+
note('error', startError, exit + '\n' + (transcript || '进程没有任何输出。命令:' + command))
|
|
419
500
|
ownedProcess = null
|
|
420
501
|
return null
|
|
421
502
|
}
|
|
422
|
-
|
|
503
|
+
const remaining = deadline - Date.now()
|
|
504
|
+
if (remaining > 0) await ctx.timeout(Math.min(250, remaining))
|
|
423
505
|
}
|
|
506
|
+
if (disposed) return null
|
|
424
507
|
startError = '服务在 60 秒内没有就绪(端口 ' + PORT + ' 未通过健康检查)'
|
|
425
|
-
|
|
508
|
+
const probeDetail = lastProbe && (lastProbe.error || describeRun(lastProbe.result))
|
|
509
|
+
note('error', startError, [transcript, probeDetail && '最后一次健康检查:' + probeDetail, command].filter(Boolean).join('\n'))
|
|
510
|
+
// The public executor leaves serve running until we stop it.
|
|
511
|
+
proc.kill()
|
|
512
|
+
ownedProcess = null
|
|
426
513
|
return null
|
|
427
514
|
} catch (e) {
|
|
428
515
|
startError = 'serve 进程启动失败:' + ((e && e.message) || String(e))
|
|
429
|
-
note('error', startError, command)
|
|
516
|
+
note('error', startError, [e && e.stack, command].filter(Boolean).join('\n'))
|
|
430
517
|
if (proc) { try { proc.kill() } catch (e2) {} }
|
|
431
518
|
ownedProcess = null
|
|
432
519
|
return null
|
|
433
520
|
}
|
|
434
521
|
}
|
|
435
522
|
|
|
523
|
+
const startService = () => {
|
|
524
|
+
if (!starting) starting = startOrReuseService().finally(() => { starting = null })
|
|
525
|
+
return starting
|
|
526
|
+
}
|
|
527
|
+
|
|
436
528
|
// A service that dies AFTER a successful start used to leave the plugin
|
|
437
529
|
// frozen: nothing ever re-checked the process, so the panel kept showing a
|
|
438
530
|
// stale dot while every open failed with no explanation. Reading
|
|
@@ -440,34 +532,40 @@ return {
|
|
|
440
532
|
// existing poll can supervise the process for free.
|
|
441
533
|
const superviseOwnedProcess = () => {
|
|
442
534
|
const proc = ownedProcess
|
|
443
|
-
if (!proc || proc.status === 'running') return
|
|
535
|
+
if (starting || !proc || proc.status === 'running') return
|
|
444
536
|
const tail = drain(proc)
|
|
445
537
|
const explained = explainOutput(tail)
|
|
446
538
|
ownedProcess = null
|
|
447
539
|
serviceRunning = false
|
|
448
540
|
startError = explained || ('服务进程已退出(exit=' + String(proc.exitCode) + (proc.signal ? ', 信号 ' + proc.signal : '') + ')')
|
|
449
|
-
note('error', '服务进程意外退出', tail || '
|
|
541
|
+
note('error', '服务进程意外退出', [startError, tail || '进程没有留下输出。'].join('\n'))
|
|
450
542
|
}
|
|
451
543
|
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
// spawns a subprocess and the panel polls every few seconds. The window is
|
|
455
|
-
// seeded when the service is confirmed up, so a freshly verified service is
|
|
456
|
-
// not immediately re-checked.
|
|
544
|
+
// Periodically refresh health and log-write status for both owned and reused
|
|
545
|
+
// services. A live process handle alone cannot tell whether logging failed.
|
|
457
546
|
const REVALIDATE_MS = 10000
|
|
458
547
|
let healthConfirmedAt = 0
|
|
459
548
|
let revalidating = false
|
|
460
549
|
|
|
461
550
|
const revalidateService = () => {
|
|
462
|
-
if (
|
|
551
|
+
if (disposed || starting || restarting || revalidating || !serviceRunning) return
|
|
463
552
|
if (Date.now() - healthConfirmedAt < REVALIDATE_MS) return
|
|
553
|
+
const epoch = serviceEpoch
|
|
464
554
|
revalidating = true
|
|
465
555
|
void (async () => {
|
|
466
556
|
try {
|
|
467
|
-
|
|
557
|
+
const probe = await probeHealth()
|
|
558
|
+
// A delayed probe belongs to the service it checked, even if a restart
|
|
559
|
+
// has already finished. Never replace the new process's diagnostics.
|
|
560
|
+
if (disposed || epoch !== serviceEpoch) return
|
|
561
|
+
if (probe && probe.ok) {
|
|
562
|
+
adoptResolvedPaths(probe.payload)
|
|
563
|
+
healthConfirmedAt = Date.now()
|
|
564
|
+
return
|
|
565
|
+
}
|
|
468
566
|
serviceRunning = false
|
|
469
|
-
startError = '
|
|
470
|
-
note('error', '
|
|
567
|
+
startError = '本地服务已经不再响应(端口 ' + PORT + ')。'
|
|
568
|
+
note('error', '本地服务失去响应', probe && (probe.error || describeRun(probe.result)))
|
|
471
569
|
} finally {
|
|
472
570
|
revalidating = false
|
|
473
571
|
}
|
|
@@ -477,42 +575,54 @@ return {
|
|
|
477
575
|
// Everything the panel needs to explain a red dot without opening a terminal.
|
|
478
576
|
// Reaping first means a process that died since the last call is reported as
|
|
479
577
|
// dead here, instead of leaving a stale "running" dot behind.
|
|
578
|
+
const diagnosticFacts = () => ({
|
|
579
|
+
version: VERSION,
|
|
580
|
+
mode,
|
|
581
|
+
platform: PLATFORM,
|
|
582
|
+
arch,
|
|
583
|
+
nodeVersion,
|
|
584
|
+
shellAPI: shell ? ['resolve', 'execute', 'run', 'start'].filter(name => typeof shell[name] === 'function') : [],
|
|
585
|
+
port: PORT,
|
|
586
|
+
script: SCRIPT,
|
|
587
|
+
editorRoot: EDITOR_ROOT,
|
|
588
|
+
runtimeDir: RUNTIME_DIR,
|
|
589
|
+
pythonCommand,
|
|
590
|
+
pythonCandidates: PYTHON_CANDIDATES.slice(),
|
|
591
|
+
logDir: (resolvedPaths && resolvedPaths.logDir) || LOG_DIR,
|
|
592
|
+
vendorCache: (resolvedPaths && resolvedPaths.vendorCache) || VENDOR_CACHE,
|
|
593
|
+
hostLogFile: hostLog ? hostLog.file : null,
|
|
594
|
+
hostLogError,
|
|
595
|
+
service: serviceInfo,
|
|
596
|
+
startupLogFile,
|
|
597
|
+
hasShell: !!shell,
|
|
598
|
+
startError,
|
|
599
|
+
dependencyIssue,
|
|
600
|
+
journal: journal.slice().reverse(),
|
|
601
|
+
})
|
|
602
|
+
|
|
480
603
|
const diagnostics = () => {
|
|
481
604
|
superviseOwnedProcess()
|
|
482
605
|
revalidateService()
|
|
483
606
|
return {
|
|
607
|
+
...diagnosticFacts(),
|
|
484
608
|
ok: true,
|
|
485
609
|
running: serviceRunning,
|
|
486
610
|
owned: !!ownedProcess,
|
|
487
|
-
version: VERSION,
|
|
488
|
-
platform: PLATFORM,
|
|
489
|
-
port: PORT,
|
|
490
|
-
script: SCRIPT,
|
|
491
|
-
editorRoot: EDITOR_ROOT,
|
|
492
|
-
runtimeDir: RUNTIME_DIR,
|
|
493
|
-
pythonCommand: pythonCommand,
|
|
494
|
-
pythonCandidates: PYTHON_CANDIDATES.slice(),
|
|
495
|
-
// Where the service really writes, once it has told us. The requested
|
|
496
|
-
// paths stand in only until then: they may be exactly what the sandbox
|
|
497
|
-
// refused, and showing them after the fact is what made a working service
|
|
498
|
-
// look broken.
|
|
499
|
-
logDir: (resolvedPaths && resolvedPaths.logDir) || LOG_DIR,
|
|
500
|
-
vendorCache: (resolvedPaths && resolvedPaths.vendorCache) || VENDOR_CACHE,
|
|
501
|
-
hasShell: !!shell,
|
|
502
611
|
processStatus: ownedProcess ? ownedProcess.status : null,
|
|
503
612
|
exitCode: ownedProcess ? ownedProcess.exitCode : null,
|
|
504
|
-
startError: startError,
|
|
505
|
-
journal: journal.slice().reverse(),
|
|
506
613
|
}
|
|
507
614
|
}
|
|
508
615
|
|
|
509
616
|
// 生命周期副作用:卸载时停掉本插件自己启动的服务进程。
|
|
510
617
|
ctx.effect(() => () => {
|
|
511
618
|
disposed = true
|
|
619
|
+
serviceEpoch += 1
|
|
512
620
|
const proc = ownedProcess
|
|
513
621
|
ownedProcess = null
|
|
514
622
|
serviceRunning = false
|
|
515
|
-
if (proc) {
|
|
623
|
+
if (proc) {
|
|
624
|
+
try { proc.kill() } catch (e) { note('error', '卸载插件时无法停止服务', e.stack || e) }
|
|
625
|
+
}
|
|
516
626
|
})
|
|
517
627
|
|
|
518
628
|
const recordAsset = (path, kind) => {
|
|
@@ -537,7 +647,7 @@ return {
|
|
|
537
647
|
if (!/\.html?$/i.test(path)) return
|
|
538
648
|
recordAsset(path, exec.name === 'write' ? 'create' : 'edit')
|
|
539
649
|
} catch (e) {
|
|
540
|
-
|
|
650
|
+
note('error', '记录 HTML 产物时出错', e.stack || e)
|
|
541
651
|
}
|
|
542
652
|
})
|
|
543
653
|
|
|
@@ -547,12 +657,18 @@ return {
|
|
|
547
657
|
if (typeof file !== 'string' || !file) return { ok: false, error: '请填写文件路径' }
|
|
548
658
|
if (!/\.html?$/i.test(file)) return { ok: false, error: '只支持 .html / .htm 文件' }
|
|
549
659
|
if (!SCRIPT) return { ok: false, error: startError || 'workbench.py 路径未配置', diagnostics: diagnostics() }
|
|
550
|
-
const wasUp = await health()
|
|
551
|
-
|
|
552
|
-
const h = await health()
|
|
660
|
+
const wasUp = !starting && !restarting ? await health() : null
|
|
661
|
+
const h = restarting ? (await restarting).info : wasUp || await startService()
|
|
553
662
|
// Carry the diagnostics WITH the failure: the panel can then show the real
|
|
554
663
|
// cause instead of a generic "unavailable" that sends the user hunting.
|
|
555
|
-
if (!h)
|
|
664
|
+
if (!h) {
|
|
665
|
+
note('error', '打开编辑器时健康检查失败', startError)
|
|
666
|
+
return { ok: false, error: startError || '本地服务未就绪', diagnostics: diagnostics() }
|
|
667
|
+
}
|
|
668
|
+
adoptResolvedPaths(h)
|
|
669
|
+
serviceRunning = true
|
|
670
|
+
startError = null
|
|
671
|
+
healthConfirmedAt = Date.now()
|
|
556
672
|
return {
|
|
557
673
|
ok: true,
|
|
558
674
|
url: 'http://127.0.0.1:' + PORT + '/?file=' + encodeURIComponent(file),
|
|
@@ -562,12 +678,19 @@ return {
|
|
|
562
678
|
}
|
|
563
679
|
}
|
|
564
680
|
|
|
565
|
-
const
|
|
681
|
+
const stopAndRestartService = async () => {
|
|
682
|
+
serviceEpoch += 1
|
|
683
|
+
await starting
|
|
684
|
+
if (disposed) return { ok: false, error: 'HTML Workbench 已卸载' }
|
|
566
685
|
const proc = ownedProcess
|
|
567
686
|
ownedProcess = null
|
|
568
687
|
serviceRunning = false
|
|
569
688
|
startError = null
|
|
570
|
-
if (
|
|
689
|
+
if (dependencyIssue?.code.startsWith('PYTHON_')) pythonProbeFailedAt = 0
|
|
690
|
+
dependencyIssue = null
|
|
691
|
+
if (proc) {
|
|
692
|
+
try { proc.kill() } catch (e) { note('error', '重启前无法停止服务进程', e.stack || e) }
|
|
693
|
+
}
|
|
571
694
|
note('info', '手动重启本地服务')
|
|
572
695
|
// Killing our own handle is not enough when the port is held by a process
|
|
573
696
|
// we do not own (a leftover from a previous DSH run, or an outdated
|
|
@@ -581,6 +704,18 @@ return {
|
|
|
581
704
|
return h ? { ok: true, info: h, diagnostics: diagnostics() } : { ok: false, error: startError || '重启失败', diagnostics: diagnostics() }
|
|
582
705
|
}
|
|
583
706
|
|
|
707
|
+
const restartService = () => {
|
|
708
|
+
if (!restarting) restarting = stopAndRestartService().finally(() => { restarting = null })
|
|
709
|
+
return restarting
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const dependencyPayload = async () => {
|
|
713
|
+
const pending = restarting || starting
|
|
714
|
+
if (pending) await pending
|
|
715
|
+
else if (!serviceRunning && !startError) await startService()
|
|
716
|
+
return { ok: true, dependencyIssue }
|
|
717
|
+
}
|
|
718
|
+
|
|
584
719
|
// Browser address bars yield file:///… URLs while the CLI expects a native
|
|
585
720
|
// path. Normalize only for the local existence probe; the Python service
|
|
586
721
|
// repeats this normalization authoritatively for every request.
|
|
@@ -591,9 +726,8 @@ return {
|
|
|
591
726
|
const url = new URL(raw)
|
|
592
727
|
if (url.protocol !== 'file:' || url.search || url.hash) return raw
|
|
593
728
|
let path = decodeURIComponent(url.pathname)
|
|
594
|
-
|
|
595
|
-
if (
|
|
596
|
-
if (isWindows && url.hostname && url.hostname !== 'localhost') path = '//' + url.hostname + path
|
|
729
|
+
if (IS_WINDOWS && /^[\\/][A-Za-z]:\//.test(path)) path = path.slice(1)
|
|
730
|
+
if (IS_WINDOWS && url.hostname && url.hostname !== 'localhost') path = '//' + url.hostname + path
|
|
597
731
|
return path
|
|
598
732
|
} catch (e) {
|
|
599
733
|
return raw
|
|
@@ -614,15 +748,20 @@ return {
|
|
|
614
748
|
return { ok: true, isHtml: true, exists: null, normalized: normalized, error: startError }
|
|
615
749
|
}
|
|
616
750
|
const probe = 'import os,sys; sys.exit(0 if os.path.isfile(sys.argv[1]) else 1)'
|
|
617
|
-
const spec = shell.resolve({
|
|
618
|
-
command: python + ' -c ' + quoteArg(probe) + ' ' + quoteArg(normalized),
|
|
619
|
-
timeoutMs: 5000,
|
|
620
|
-
stdoutMaxBytes: 1024,
|
|
621
|
-
})
|
|
622
751
|
try {
|
|
623
|
-
const
|
|
624
|
-
|
|
752
|
+
const spec = shell.resolve({
|
|
753
|
+
command: python + ' -c ' + quoteArg(probe) + ' ' + quoteArg(normalized),
|
|
754
|
+
timeoutMs: 5000,
|
|
755
|
+
stdoutMaxBytes: 1024,
|
|
756
|
+
})
|
|
757
|
+
const r = await runShell(spec)
|
|
758
|
+
if (r && !r.timedOut && !r.aborted && !r.signal && [0, 1].includes(r.exitCode)) {
|
|
759
|
+
return { ok: true, isHtml: true, exists: r.exitCode === 0, normalized: normalized }
|
|
760
|
+
}
|
|
761
|
+
note('error', '文件检查未完成', describeRun(r))
|
|
762
|
+
return { ok: true, isHtml: true, exists: null, normalized, error: '文件检查未完成,请查看诊断日志。' }
|
|
625
763
|
} catch (e) {
|
|
764
|
+
note('error', '文件检查未完成', e.stack || e)
|
|
626
765
|
return { ok: true, isHtml: true, exists: null, normalized: normalized, error: (e && e.message) || String(e) }
|
|
627
766
|
}
|
|
628
767
|
}
|
|
@@ -651,28 +790,32 @@ return {
|
|
|
651
790
|
return diagnostics()
|
|
652
791
|
} catch (error) {
|
|
653
792
|
return {
|
|
793
|
+
...diagnosticFacts(),
|
|
654
794
|
ok: false,
|
|
655
795
|
degraded: true,
|
|
656
796
|
error: '收集诊断信息时出错:' + ((error && error.message) || String(error)),
|
|
657
|
-
version: VERSION,
|
|
658
|
-
platform: PLATFORM,
|
|
659
|
-
port: PORT,
|
|
660
|
-
script: SCRIPT,
|
|
661
|
-
runtimeDir: RUNTIME_DIR,
|
|
662
|
-
startError: startError,
|
|
663
|
-
journal: journal.slice().reverse(),
|
|
664
797
|
}
|
|
665
798
|
}
|
|
666
799
|
}
|
|
667
800
|
|
|
668
801
|
// 动态传输:Package-private RPC(仅动态运行时存在 harness)。
|
|
669
802
|
if (typeof harness !== 'undefined') {
|
|
670
|
-
harness.handle(
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
803
|
+
const handle = (name, handler) => harness.handle(name, async (args) => {
|
|
804
|
+
try {
|
|
805
|
+
return await handler(args)
|
|
806
|
+
} catch (error) {
|
|
807
|
+
const reason = (error && error.stack) || String(error)
|
|
808
|
+
note('error', 'RPC ' + name + ' 处理失败', reason)
|
|
809
|
+
return { ok: false, error: (error && error.message) || String(error), diagnostics: safeDiagnostics() }
|
|
810
|
+
}
|
|
811
|
+
})
|
|
812
|
+
handle('list', listPayload)
|
|
813
|
+
handle('status', statusPayload)
|
|
814
|
+
handle('open', (args) => openFile(args && args.file))
|
|
815
|
+
handle('resolve', (args) => resolvePath(args && args.file))
|
|
816
|
+
handle('diagnostics', safeDiagnostics)
|
|
817
|
+
handle('dependencies', dependencyPayload)
|
|
818
|
+
handle('restart', restartService)
|
|
676
819
|
}
|
|
677
820
|
|
|
678
821
|
// 静态传输:HTTP 路由,供静态 client bundle(fetch)与直接浏览器访问使用。
|
|
@@ -727,6 +870,11 @@ return {
|
|
|
727
870
|
path: '/html-workbench/list',
|
|
728
871
|
handler: guard('list', (req, res) => sendJson(res, 200, listPayload())),
|
|
729
872
|
}), 'html-workbench: list route')
|
|
873
|
+
ctx.effect(() => webServer.register({
|
|
874
|
+
kind: 'exact',
|
|
875
|
+
path: '/html-workbench/dependencies',
|
|
876
|
+
handler: guard('dependencies', async (req, res) => sendJson(res, 200, await dependencyPayload())),
|
|
877
|
+
}), 'html-workbench: dependencies route')
|
|
730
878
|
ctx.effect(() => webServer.register({
|
|
731
879
|
kind: 'exact',
|
|
732
880
|
path: '/html-workbench/status',
|
|
@@ -763,7 +911,8 @@ return {
|
|
|
763
911
|
// A rejection here must not escape into Cordis: `apply` has already wired up
|
|
764
912
|
// the transports, and losing them would take the diagnostics route down with
|
|
765
913
|
// it — leaving the panel with nothing but "Failed to fetch" again.
|
|
766
|
-
|
|
914
|
+
note('info', '插件宿主已加载', JSON.stringify({ mode, platform: PLATFORM, arch, nodeVersion, script: SCRIPT, runtimeDir: RUNTIME_DIR }))
|
|
915
|
+
Promise.resolve().then(() => restarting ? null : startService()).catch((error) => {
|
|
767
916
|
startError = '启动本地服务时发生未预期的错误:' + ((error && error.message) || String(error))
|
|
768
917
|
note('error', startError, (error && error.stack) || null)
|
|
769
918
|
})
|