@vibe-x/dsh-html-workbench 0.2.0 → 0.3.1

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/src/host.js CHANGED
@@ -28,38 +28,340 @@ return {
28
28
  const PORT = opts.port || 4317
29
29
  const SCRIPT = opts.script || null
30
30
  const EDITOR_ROOT = opts.editorRoot || null
31
+ const PLATFORM = opts.platform || (typeof process !== 'undefined' ? process.platform : null)
32
+ const IS_WINDOWS = PLATFORM === 'win32'
33
+ // Reported verbatim in the diagnostics. A dynamic (cordis_define) load has no
34
+ // config, so `version` stays null — which is itself the answer to "is this the
35
+ // packaged build or a hot-loaded one?", the first question every report raises.
36
+ const VERSION = opts.version || null
37
+ // Candidates must be a SINGLE executable token: every call site appends its
38
+ // own arguments, so a multi-token entry like `py -3` would place the script
39
+ // path after the launcher's own flags and break argument forwarding. The `py`
40
+ // launcher is still supported — see `expandWindowsLauncher`, which asks it for
41
+ // the interpreter's absolute path and uses that instead.
42
+ const PYTHON_CANDIDATES = opts.pythonCommand
43
+ ? [opts.pythonCommand]
44
+ : (IS_WINDOWS ? ['python3', 'python'] : ['python3'])
45
+ // Static packages inject the runtime directory resolved (and probed) by
46
+ // src/index.js. Keep a dynamic-loader fallback too: TEMP/TMPDIR cover
47
+ // Windows/macOS/Linux; only the final fallback is POSIX because dynamic
48
+ // runners may not expose Node's `os` module. The folder name matches
49
+ // RUNTIME_DIR_NAME in workbench.py so both halves agree on one location.
50
+ const tempRoot = typeof process !== 'undefined' && process.env
51
+ ? (process.env.TEMP || process.env.TMP || process.env.TMPDIR)
52
+ : null
53
+ // Strip a trailing separator from EITHER source: `%TEMP%` and an injected
54
+ // runtimeDir both legitimately end in one, and appending '/logs' to it would
55
+ // yield `C:\Temp\hwb\/logs` — a path Python tolerates but that reads as
56
+ // corruption in every log line and error message a user pastes back.
57
+ const trimSeparator = (value) => String(value).replace(/[\\/]+$/, '')
58
+ const RUNTIME_DIR = opts.runtimeDir
59
+ ? trimSeparator(opts.runtimeDir)
60
+ : (tempRoot ? trimSeparator(tempRoot) + '/.html-workbench' : '/tmp/.html-workbench')
61
+
62
+ // These are REQUESTS, not guarantees. The service runs as a DSH sandboxed
63
+ // child that may write only the session workspace and a private temp folder
64
+ // the sandbox hands it — the ambient %TEMP% root seen by this parent is
65
+ // denied, so a folder the parent can create is not necessarily one the child
66
+ // can use. workbench.py therefore re-probes in-process, falls back on its
67
+ // own, and advertises the directories it settled on; `adoptResolvedPaths`
68
+ // below surfaces those, so the panel describes where the service really
69
+ // writes instead of echoing the path we merely asked for. Both are disposable
70
+ // cache/log data either way.
71
+ const LOG_DIR = RUNTIME_DIR + '/logs'
72
+ const VENDOR_CACHE = RUNTIME_DIR + '/vendor'
73
+
74
+ // Directories the running service reported; null until it says so.
75
+ let resolvedPaths = null
31
76
 
32
77
  let assets = []
33
78
  let seq = 0
34
79
  let ownedProcess = null
35
80
  let serviceRunning = false
36
81
  let startError = null
82
+ let pythonCommand = opts.pythonCommand || (IS_WINDOWS ? null : 'python3')
83
+ // A failed probe must be remembered: `resolve` runs on every keystroke and
84
+ // `health` on a timer, so re-probing would spawn three 5s subprocesses per
85
+ // call and flood the journal with one repeated line. The TTL still lets a
86
+ // user who just installed Python recover without reloading the plugin.
87
+ const PROBE_RETRY_MS = 30000
88
+ let pythonProbeFailedAt = 0
89
+ let pythonProbe = null
37
90
  let disposed = false
38
91
 
39
92
  const shell = ctx.get('shell')
40
93
 
94
+ // ── Diagnostics journal ──────────────────────────────────────────────────
95
+ //
96
+ // A failed start used to surface as nothing but a red dot: the only signal
97
+ // was `running: false`, while the actual cause (a Python traceback, a busy
98
+ // port, a missing interpreter) stayed inside this Node process. Every
99
+ // subprocess outcome now lands in this ring buffer, which `status` returns
100
+ // and the panel renders, so the failure is readable where it is observed.
101
+ const JOURNAL_LIMIT = 40
102
+ // Errors are the whole point of the journal, yet they are also the rarest
103
+ // entries: a service that fails at start then gets polled by `status` every
104
+ // few seconds would push the original traceback out of a plain ring buffer
105
+ // long before anyone opens the panel. So evict by level — drop the oldest
106
+ // non-error first, and only sacrifice an error when nothing else is left.
107
+ const JOURNAL_ERROR_FLOOR = 12
108
+ let journal = []
109
+
110
+ const trimJournal = () => {
111
+ if (journal.length <= JOURNAL_LIMIT) return
112
+ const overflow = journal.length - JOURNAL_LIMIT
113
+ let dropped = 0
114
+ const errors = journal.filter((entry) => entry.level === 'error').length
115
+ const keep = []
116
+ for (let i = 0; i < journal.length; i += 1) {
117
+ const entry = journal[i]
118
+ const isError = entry.level === 'error'
119
+ // Never let noise starve the error history, but do not grow without
120
+ // bound either: past the floor, errors age out like anything else.
121
+ const expendable = !isError || errors - dropped > JOURNAL_ERROR_FLOOR
122
+ if (dropped < overflow && expendable) {
123
+ dropped += 1
124
+ continue
125
+ }
126
+ keep.push(entry)
127
+ }
128
+ // Errors alone can exceed the limit; fall back to a plain tail so the
129
+ // buffer still has a hard ceiling.
130
+ journal = keep.length > JOURNAL_LIMIT ? keep.slice(-JOURNAL_LIMIT) : keep
131
+ }
132
+
133
+ const note = (level, message, detail) => {
134
+ const text = String(message || '')
135
+ const body = detail ? String(detail).slice(0, 4000) : null
136
+ // Retrying a broken start replays the SAME failure verbatim, interleaved
137
+ // with the "restarting" line — so consecutive-only folding would miss it.
138
+ // Match anywhere, then move the entry to the end so its position still
139
+ // agrees with its timestamp when the panel renders newest-first.
140
+ let index = -1
141
+ for (let i = journal.length - 1; i >= 0; i -= 1) {
142
+ const entry = journal[i]
143
+ if (entry.level === level && entry.message === text && entry.detail === body) { index = i; break }
144
+ }
145
+ if (index >= 0) {
146
+ const entry = journal.splice(index, 1)[0]
147
+ entry.at = Date.now()
148
+ entry.repeated = (entry.repeated || 1) + 1
149
+ journal.push(entry)
150
+ } else {
151
+ journal.push({ at: Date.now(), level: level, message: text, detail: body })
152
+ trimJournal()
153
+ }
154
+ if (level === 'error') console.error('[html-workbench] ' + text, body || '')
155
+ }
156
+
157
+ // `CollectedOutput` carries more than text (truncation flags, spill paths);
158
+ // reduce it to the part a human reads, and say so when bytes were dropped.
159
+ const outputText = (collected) => {
160
+ if (!collected) return ''
161
+ const text = typeof collected === 'string' ? collected : (collected.text || '')
162
+ const trimmed = String(text).trim()
163
+ if (collected && collected.truncated) return trimmed + '\n…(输出被截断)'
164
+ return trimmed
165
+ }
166
+
167
+ // `shell.resolve({ command })` takes ONE command line, so every embedded path
168
+ // must be quoted for the platform's parser. Naive `'"' + p + '"'` breaks on
169
+ // Windows, where `C:\dir\` ends in a backslash that escapes the closing quote
170
+ // and swallows the rest of the line. Never use JSON.stringify here: it emits
171
+ // JSON escapes (`\\`), which cmd.exe passes through verbatim, so Python
172
+ // receives a path with doubled separators and reports every file as missing.
173
+ const quoteArg = (value) => {
174
+ const raw = String(value == null ? '' : value)
175
+ if (IS_WINDOWS) {
176
+ // Double only the run of backslashes that precedes the closing quote;
177
+ // interior ones are literal to cmd.exe. Inner quotes cannot appear in a
178
+ // Windows path, but strip them rather than emit an unbalanced line.
179
+ const cleaned = raw.replace(/"/g, '')
180
+ const tail = cleaned.match(/\\*$/)[0]
181
+ return '"' + cleaned + tail + '"'
182
+ }
183
+ return "'" + raw.replace(/'/g, "'\\''") + "'"
184
+ }
185
+
186
+ const describeRun = (result) => {
187
+ if (!result) return 'shell.run threw'
188
+ const parts = []
189
+ if (result.timedOut) parts.push('超时(' + result.timeoutMs + 'ms)')
190
+ if (result.aborted) parts.push('被取消')
191
+ if (result.signal) parts.push('信号 ' + result.signal)
192
+ parts.push('exit=' + String(result.exitCode))
193
+ const streams = [outputText(result.stderr), outputText(result.stdout)].filter(Boolean)
194
+ return parts.join(' ') + (streams.length ? '\n' + streams.join('\n') : '')
195
+ }
196
+
197
+ // Windows ships a `py` launcher that resolves versions the PATH may not
198
+ // expose. It is NOT usable as a command prefix (`py -3 script.py` mangles
199
+ // forwarded arguments), so ask it for the interpreter's absolute path and use
200
+ // that as a normal single-token executable.
201
+ const expandWindowsLauncher = async () => {
202
+ if (!IS_WINDOWS || !shell) return null
203
+ const probe = 'py -3 -c ' + quoteArg('import sys; print(sys.executable)')
204
+ try {
205
+ const result = await shell.run(shell.resolve({ command: probe, timeoutMs: 5000, stdoutMaxBytes: 4096 }))
206
+ if (!result || result.exitCode !== 0) return { failure: 'py -3: ' + describeRun(result) }
207
+ const executable = outputText(result.stdout).split('\n').pop().trim()
208
+ if (!executable) return { failure: 'py -3: 没有返回解释器路径' }
209
+ return { command: quoteArg(executable) }
210
+ } catch (e) {
211
+ return { failure: 'py -3: ' + ((e && e.message) || String(e)) }
212
+ }
213
+ }
214
+
215
+ const resolvePythonCommand = async () => {
216
+ if (pythonCommand) return pythonCommand
217
+ if (!shell) return null
218
+ // Collapse concurrent callers onto one probe: the panel's poll, an `open`
219
+ // and a keystroke-driven `resolve` can all arrive inside the same tick.
220
+ if (pythonProbe) return pythonProbe
221
+ if (pythonProbeFailedAt && Date.now() - pythonProbeFailedAt < PROBE_RETRY_MS) return null
222
+ pythonProbe = (async () => {
223
+ const failures = []
224
+ for (const candidate of PYTHON_CANDIDATES) {
225
+ try {
226
+ const spec = shell.resolve({ command: candidate + ' --version', timeoutMs: 5000, stdoutMaxBytes: 4096 })
227
+ const result = await shell.run(spec)
228
+ if (result && result.exitCode === 0) {
229
+ pythonCommand = candidate
230
+ pythonProbeFailedAt = 0
231
+ note('info', '使用 Python 解释器:' + candidate)
232
+ return pythonCommand
233
+ }
234
+ failures.push(candidate + ': ' + describeRun(result))
235
+ } catch (e) {
236
+ failures.push(candidate + ': ' + ((e && e.message) || String(e)))
237
+ }
238
+ }
239
+ const launcher = await expandWindowsLauncher()
240
+ if (launcher && launcher.command) {
241
+ pythonCommand = launcher.command
242
+ pythonProbeFailedAt = 0
243
+ note('info', '使用 Python 解释器(经 py 启动器解析):' + launcher.command)
244
+ return pythonCommand
245
+ }
246
+ if (launcher && launcher.failure) failures.push(launcher.failure)
247
+ pythonProbeFailedAt = Date.now()
248
+ startError = '找不到可用的 Python 3 解释器(已尝试:'
249
+ + PYTHON_CANDIDATES.join('、') + (IS_WINDOWS ? '、py -3' : '')
250
+ + ')。请安装 Python 3.9+ 并确保它在 PATH 中,然后点「重启服务」。'
251
+ note('error', startError, failures.join('\n'))
252
+ return null
253
+ })()
254
+ try {
255
+ return await pythonProbe
256
+ } finally {
257
+ pythonProbe = null
258
+ }
259
+ }
260
+
41
261
  const runCli = async (args, timeoutMs) => {
262
+ if (!shell) { note('error', 'shell 服务不可用,无法执行 workbench.py'); return null }
263
+ if (!SCRIPT) { note('error', 'workbench.py 路径未配置'); return null }
264
+ const python = await resolvePythonCommand()
265
+ if (!python) return null
266
+ const command = python + ' ' + quoteArg(SCRIPT) + ' ' + args
267
+ try {
268
+ const spec = shell.resolve({ command: command, timeoutMs: timeoutMs || 15000, stdoutMaxBytes: 64 * 1024 })
269
+ const result = await shell.run(spec)
270
+ // A non-zero exit is the interesting case and used to be discarded: the
271
+ // CLI prints a structured `{ok:false,error:{code,message}}` on stdout.
272
+ if (!result || result.exitCode !== 0) note('error', 'workbench.py ' + args.split(' ')[0] + ' 执行失败', describeRun(result))
273
+ return result
274
+ } catch (e) {
275
+ note('error', 'workbench.py ' + args.split(' ')[0] + ' 无法执行', (e && e.message) || String(e))
276
+ return null
277
+ }
278
+ }
279
+
280
+ // `health` runs on a timer, so its failures are expected and must not flood
281
+ // the journal — poll quietly and let the caller decide what is worth noting.
282
+ const probeHealth = async () => {
42
283
  if (!shell || !SCRIPT) return null
284
+ const python = await resolvePythonCommand()
285
+ if (!python) return null
43
286
  try {
44
287
  const spec = shell.resolve({
45
- command: 'python3 "' + SCRIPT + '" ' + args,
46
- timeoutMs: timeoutMs || 15000,
288
+ command: python + ' ' + quoteArg(SCRIPT) + ' health --port ' + PORT,
289
+ timeoutMs: 8000,
47
290
  stdoutMaxBytes: 64 * 1024,
48
291
  })
49
- return await shell.run(spec)
292
+ const result = await shell.run(spec)
293
+ if (!result || result.exitCode !== 0) return { ok: false, result: result }
294
+ try {
295
+ const payload = JSON.parse(outputText(result.stdout) || '')
296
+ return payload && payload.ok === true ? { ok: true, payload: payload } : { ok: false, result: result }
297
+ } catch (e) {
298
+ return { ok: false, result: result }
299
+ }
50
300
  } catch (e) {
51
- return null
301
+ return { ok: false, error: (e && e.message) || String(e) }
52
302
  }
53
303
  }
54
304
 
305
+ // `/api/health` advertises the directories the service really writes to. The
306
+ // paths it was ASKED for may be exactly what the sandbox refused, so adopting
307
+ // the reported ones is what lets the panel stay truthful — for a service we
308
+ // started and for one we merely reused (a reused service leaves us no startup
309
+ // output to read them from).
310
+ const adoptResolvedPaths = (payload) => {
311
+ if (!payload) return
312
+ const logDir = typeof payload.logDir === 'string' ? payload.logDir : null
313
+ const vendorCache = typeof payload.vendorCache === 'string' ? payload.vendorCache : null
314
+ if (logDir || vendorCache) resolvedPaths = { logDir: logDir, vendorCache: vendorCache }
315
+ }
316
+
55
317
  const health = async () => {
56
- const r = await runCli('health --port ' + PORT, 8000)
57
- if (!r || r.exitCode !== 0) return null
318
+ const probe = await probeHealth()
319
+ if (!probe || !probe.ok) return null
320
+ adoptResolvedPaths(probe.payload)
321
+ return probe.payload
322
+ }
323
+
324
+ // `workbench.py` reports failures as one structured JSON line on stderr. Turn
325
+ // the machine code into the sentence a person can act on; keep the raw line
326
+ // in the detail so nothing is lost when the code is one we do not know.
327
+ const CLI_HINTS = {
328
+ PORT_IN_USE: '端口已被占用。可能有别的进程(或旧版服务)占着它 —— 点「重启服务」会先退役占用者。',
329
+ ASSET_NOT_FOUND: '前端资源缺失,插件安装目录不完整(缺 assets/workbench.html)。',
330
+ PYTHON_TOO_OLD: '需要 Python 3.9 或更高版本。',
331
+ SERVER_OUTDATED: '端口上有旧版服务且无法自动停止,请手动结束该进程。',
332
+ FILE_NOT_FOUND: '找不到文件。',
333
+ VENDOR_DOWNLOAD_FAILED: '首次启动需要联网下载 GrapesJS,下载失败了。',
334
+ }
335
+
336
+ const explainOutput = (text) => {
337
+ if (!text) return null
338
+ for (const line of String(text).split('\n')) {
339
+ const trimmed = line.trim()
340
+ if (!trimmed.startsWith('{')) continue
341
+ try {
342
+ const payload = JSON.parse(trimmed)
343
+ if (payload && payload.ok === false && payload.error) {
344
+ const hint = CLI_HINTS[payload.error]
345
+ return (hint || payload.message || payload.error) + '(' + payload.error + ')'
346
+ }
347
+ } catch (e) { /* not the JSON line; keep scanning */ }
348
+ }
349
+ return null
350
+ }
351
+
352
+ // Everything the started process wrote since the last read. DSH settles a
353
+ // spawn failure as `killed` with the error on stderr, so this is the only
354
+ // place a "python3: command not found" or a traceback can be observed.
355
+ const drain = (proc) => {
58
356
  try {
59
- const p = JSON.parse((r.stdout && r.stdout.text) || '')
60
- return p && p.ok === true ? p : null
357
+ const read = proc.readOutput()
358
+ if (!read) return ''
359
+ let text = String(read.delta || '').trim()
360
+ if (read.lossy) text += '\n…(部分输出丢失)'
361
+ if (read.stderrSpillPath) text += '\n完整 stderr: ' + read.stderrSpillPath
362
+ return text
61
363
  } catch (e) {
62
- return null
364
+ return ''
63
365
  }
64
366
  }
65
367
 
@@ -67,34 +369,143 @@ return {
67
369
  if (disposed) return null
68
370
  if (serviceRunning) return health()
69
371
  const existing = await health()
70
- if (existing) { serviceRunning = true; startError = null; return existing }
71
- if (!shell) { startError = 'shell service unavailable'; return null }
72
- if (!SCRIPT) { startError = 'workbench.py 路径未配置(请用静态装载并随包分发 scripts/workbench.py)'; return null }
372
+ if (existing) {
373
+ serviceRunning = true
374
+ startError = null
375
+ healthConfirmedAt = Date.now()
376
+ // Record the reuse too, so the journal always explains the CURRENT state
377
+ // rather than only the failures.
378
+ note('info', '复用已在运行的服务:版本 ' + (existing.version || '未知') + ',端口 ' + PORT)
379
+ return existing
380
+ }
381
+ if (!shell) { startError = 'shell 服务不可用(DSH 未提供 shell,插件无法拉起本地服务)'; note('error', startError); return null }
382
+ if (!SCRIPT) { startError = 'workbench.py 路径未配置(请用静态装载并随包分发 scripts/workbench.py)'; note('error', startError); return null }
383
+ const python = await resolvePythonCommand()
384
+ if (!python) return null
73
385
  let proc = null
386
+ const command = python + ' ' + quoteArg(SCRIPT) + ' serve --port ' + PORT
387
+ + ' --log-dir ' + quoteArg(LOG_DIR)
388
+ + ' --vendor-cache ' + quoteArg(VENDOR_CACHE)
389
+ + (EDITOR_ROOT ? ' --editor-root ' + quoteArg(EDITOR_ROOT) : '')
74
390
  try {
75
- const editorRootArg = EDITOR_ROOT ? ' --editor-root "' + EDITOR_ROOT + '"' : ''
76
- const spec = shell.resolve({
77
- command: 'python3 "' + SCRIPT + '" serve --port ' + PORT + editorRootArg,
78
- stdoutMaxBytes: 64 * 1024,
79
- })
80
- proc = shell.start(spec)
391
+ note('info', '启动本地服务:端口 ' + PORT, command)
392
+ proc = shell.start(shell.resolve({ command: command, stdoutMaxBytes: 64 * 1024 }))
81
393
  if (disposed) { try { proc.kill() } catch (e) {} return null }
82
394
  ownedProcess = proc
395
+ let transcript = ''
83
396
  for (let i = 0; i < 240; i += 1) {
84
- const h = await health()
85
- if (h) { serviceRunning = true; startError = null; return h }
397
+ const chunk = drain(proc)
398
+ if (chunk) transcript += (transcript ? '\n' : '') + chunk
399
+ const probe = await probeHealth()
400
+ if (probe && probe.ok) {
401
+ serviceRunning = true
402
+ startError = null
403
+ healthConfirmedAt = Date.now()
404
+ adoptResolvedPaths(probe.payload)
405
+ note('info', '本地服务就绪:版本 ' + (probe.payload.version || '未知') + ',端口 ' + PORT)
406
+ return probe.payload
407
+ }
408
+ // Stop waiting the moment the process is gone. Polling health for the
409
+ // full 60s after `serve` already exited hides the reason and makes a
410
+ // fast, loud failure look like a hang.
411
+ if (proc.status !== 'running') {
412
+ const tail = drain(proc)
413
+ if (tail) transcript += (transcript ? '\n' : '') + tail
414
+ // Lead with the CLI's own diagnosis when it gave one — "端口已被占用"
415
+ // is actionable, "exit=1" is not.
416
+ const explained = explainOutput(transcript)
417
+ startError = explained || ('serve 进程已退出(exit=' + String(proc.exitCode) + (proc.signal ? ', 信号 ' + proc.signal : '') + ')')
418
+ note('error', startError, transcript || '进程没有任何输出。命令:' + command)
419
+ ownedProcess = null
420
+ return null
421
+ }
86
422
  await ctx.timeout(250)
87
423
  }
88
- startError = 'service did not become healthy'
424
+ startError = '服务在 60 秒内没有就绪(端口 ' + PORT + ' 未通过健康检查)'
425
+ note('error', startError, transcript || '进程仍在运行但健康检查一直失败。命令:' + command)
89
426
  return null
90
427
  } catch (e) {
91
- startError = e && e.message ? String(e.message) : String(e)
428
+ startError = 'serve 进程启动失败:' + ((e && e.message) || String(e))
429
+ note('error', startError, command)
92
430
  if (proc) { try { proc.kill() } catch (e2) {} }
93
431
  ownedProcess = null
94
432
  return null
95
433
  }
96
434
  }
97
435
 
436
+ // A service that dies AFTER a successful start used to leave the plugin
437
+ // frozen: nothing ever re-checked the process, so the panel kept showing a
438
+ // stale dot while every open failed with no explanation. Reading
439
+ // `ownedProcess.status` costs nothing (no subprocess), so the panel's
440
+ // existing poll can supervise the process for free.
441
+ const superviseOwnedProcess = () => {
442
+ const proc = ownedProcess
443
+ if (!proc || proc.status === 'running') return
444
+ const tail = drain(proc)
445
+ const explained = explainOutput(tail)
446
+ ownedProcess = null
447
+ serviceRunning = false
448
+ startError = explained || ('服务进程已退出(exit=' + String(proc.exitCode) + (proc.signal ? ', 信号 ' + proc.signal : '') + ')')
449
+ note('error', '服务进程意外退出', tail || '进程没有留下输出。可用「重启服务」重新拉起。')
450
+ }
451
+
452
+ // A REUSED service has no process handle, so its death is invisible to the
453
+ // check above. Revalidate with a real health call — throttled, because this
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.
457
+ const REVALIDATE_MS = 10000
458
+ let healthConfirmedAt = 0
459
+ let revalidating = false
460
+
461
+ const revalidateService = () => {
462
+ if (revalidating || ownedProcess || !serviceRunning) return
463
+ if (Date.now() - healthConfirmedAt < REVALIDATE_MS) return
464
+ revalidating = true
465
+ void (async () => {
466
+ try {
467
+ if (await health()) { healthConfirmedAt = Date.now(); return }
468
+ serviceRunning = false
469
+ startError = '之前复用的服务已经不再响应(端口 ' + PORT + ')。'
470
+ note('error', '复用的服务失去响应', '健康检查失败。可用「重启服务」重新拉起。')
471
+ } finally {
472
+ revalidating = false
473
+ }
474
+ })()
475
+ }
476
+
477
+ // Everything the panel needs to explain a red dot without opening a terminal.
478
+ // Reaping first means a process that died since the last call is reported as
479
+ // dead here, instead of leaving a stale "running" dot behind.
480
+ const diagnostics = () => {
481
+ superviseOwnedProcess()
482
+ revalidateService()
483
+ return {
484
+ ok: true,
485
+ running: serviceRunning,
486
+ 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
+ processStatus: ownedProcess ? ownedProcess.status : null,
503
+ exitCode: ownedProcess ? ownedProcess.exitCode : null,
504
+ startError: startError,
505
+ journal: journal.slice().reverse(),
506
+ }
507
+ }
508
+
98
509
  // 生命周期副作用:卸载时停掉本插件自己启动的服务进程。
99
510
  ctx.effect(() => () => {
100
511
  disposed = true
@@ -133,18 +544,59 @@ return {
133
544
  const snapshot = () => assets.slice().sort((a, b) => b.seq - a.seq)
134
545
 
135
546
  const openFile = async (file) => {
136
- if (typeof file !== 'string' || !file) return { ok: false, error: 'file is required' }
137
- if (!/\.html?$/i.test(file)) return { ok: false, error: 'only .html files are supported' }
138
- if (!SCRIPT) return { ok: false, error: startError || 'workbench.py 路径未配置' }
547
+ if (typeof file !== 'string' || !file) return { ok: false, error: '请填写文件路径' }
548
+ if (!/\.html?$/i.test(file)) return { ok: false, error: '只支持 .html / .htm 文件' }
549
+ if (!SCRIPT) return { ok: false, error: startError || 'workbench.py 路径未配置', diagnostics: diagnostics() }
139
550
  const wasUp = await health()
140
551
  if (!wasUp) await startService()
141
552
  const h = await health()
142
- if (!h) return { ok: false, error: startError || 'workbench service unavailable' }
553
+ // Carry the diagnostics WITH the failure: the panel can then show the real
554
+ // cause instead of a generic "unavailable" that sends the user hunting.
555
+ if (!h) return { ok: false, error: startError || '本地服务未就绪', diagnostics: diagnostics() }
143
556
  return {
144
557
  ok: true,
145
558
  url: 'http://127.0.0.1:' + PORT + '/?file=' + encodeURIComponent(file),
146
559
  reused: !!wasUp,
147
560
  port: PORT,
561
+ version: h.version || null,
562
+ }
563
+ }
564
+
565
+ const restartService = async () => {
566
+ const proc = ownedProcess
567
+ ownedProcess = null
568
+ serviceRunning = false
569
+ startError = null
570
+ if (proc) { try { proc.kill() } catch (e) {} }
571
+ note('info', '手动重启本地服务')
572
+ // Killing our own handle is not enough when the port is held by a process
573
+ // we do not own (a leftover from a previous DSH run, or an outdated
574
+ // version) — the spawn would then die with PORT_IN_USE. Clear the port
575
+ // first; `stop` is a no-op when nothing is listening.
576
+ const stopped = await runCli('stop --port ' + PORT, 20000)
577
+ if (stopped && stopped.exitCode !== 0) {
578
+ return { ok: false, error: '端口 ' + PORT + ' 上的旧进程无法停止,请手动结束后重试。', diagnostics: diagnostics() }
579
+ }
580
+ const h = await startService()
581
+ return h ? { ok: true, info: h, diagnostics: diagnostics() } : { ok: false, error: startError || '重启失败', diagnostics: diagnostics() }
582
+ }
583
+
584
+ // Browser address bars yield file:///… URLs while the CLI expects a native
585
+ // path. Normalize only for the local existence probe; the Python service
586
+ // repeats this normalization authoritatively for every request.
587
+ const normalizeFileReference = (value) => {
588
+ const raw = String(value || '').trim()
589
+ if (!/^file:/i.test(raw)) return raw
590
+ try {
591
+ const url = new URL(raw)
592
+ if (url.protocol !== 'file:' || url.search || url.hash) return raw
593
+ let path = decodeURIComponent(url.pathname)
594
+ const isWindows = typeof process !== 'undefined' && process.platform === 'win32'
595
+ if (isWindows && /^[\\/][A-Za-z]:\//.test(path)) path = path.slice(1)
596
+ if (isWindows && url.hostname && url.hostname !== 'localhost') path = '//' + url.hostname + path
597
+ return path
598
+ } catch (e) {
599
+ return raw
148
600
  }
149
601
  }
150
602
 
@@ -152,24 +604,75 @@ return {
152
604
  if (typeof file !== 'string') return { ok: false, error: 'file is required' }
153
605
  const trimmed = file.trim()
154
606
  if (!trimmed) return { ok: true, empty: true, isHtml: false, exists: false }
155
- if (!/\.html?$/i.test(trimmed)) return { ok: true, isHtml: false, exists: false }
156
- if (!shell) return { ok: true, isHtml: true, exists: null }
157
- const arg = JSON.stringify(trimmed)
158
- const spec = shell.resolve({ command: 'python3 -c "import os,sys; sys.exit(0 if os.path.isfile(sys.argv[1]) else 1)" ' + arg, timeoutMs: 5000, stdoutMaxBytes: 1024 })
607
+ const normalized = normalizeFileReference(trimmed)
608
+ if (!/\.html?$/i.test(normalized)) return { ok: true, isHtml: false, exists: false }
609
+ if (!shell) return { ok: true, isHtml: true, exists: null, normalized: normalized }
610
+ const python = await resolvePythonCommand()
611
+ // Say WHY the check could not run. Returning a bare `exists: null` renders
612
+ // as no indicator at all, which reads as "the field is ignoring me".
613
+ if (!python) {
614
+ return { ok: true, isHtml: true, exists: null, normalized: normalized, error: startError }
615
+ }
616
+ 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
+ })
159
622
  try {
160
623
  const r = await shell.run(spec)
161
- return { ok: true, isHtml: true, exists: !!(r && r.exitCode === 0) }
624
+ return { ok: true, isHtml: true, exists: !!(r && r.exitCode === 0), normalized: normalized }
162
625
  } catch (e) {
163
- return { ok: true, isHtml: true, exists: null }
626
+ return { ok: true, isHtml: true, exists: null, normalized: normalized, error: (e && e.message) || String(e) }
627
+ }
628
+ }
629
+
630
+ const listPayload = () => {
631
+ const base = diagnostics()
632
+ base.assets = snapshot()
633
+ return base
634
+ }
635
+
636
+ const statusPayload = async () => {
637
+ const h = await health()
638
+ const out = diagnostics()
639
+ out.ok = !!h
640
+ out.running = !!h
641
+ out.info = h || null
642
+ return out
643
+ }
644
+
645
+ // `diagnostics()` supervises the child process and revalidates the service,
646
+ // so it can itself throw (a dead handle, a shell that rejects). Since it is
647
+ // the payload every error path attaches, it must never be the thing that
648
+ // turns a readable failure back into an opaque one.
649
+ const safeDiagnostics = () => {
650
+ try {
651
+ return diagnostics()
652
+ } catch (error) {
653
+ return {
654
+ ok: false,
655
+ degraded: true,
656
+ 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
+ }
164
665
  }
165
666
  }
166
667
 
167
668
  // 动态传输:Package-private RPC(仅动态运行时存在 harness)。
168
669
  if (typeof harness !== 'undefined') {
169
- harness.handle('list', () => ({ ok: true, running: serviceRunning, owned: !!ownedProcess, port: PORT, assets: snapshot() }))
170
- harness.handle('status', async () => { const h = await health(); return { ok: !!h, running: !!h, owned: !!ownedProcess, port: PORT, info: h || null } })
670
+ harness.handle('list', () => listPayload())
671
+ harness.handle('status', () => statusPayload())
171
672
  harness.handle('open', async (args) => openFile(args && args.file))
172
673
  harness.handle('resolve', async (args) => resolvePath(args && args.file))
674
+ harness.handle('diagnostics', () => safeDiagnostics())
675
+ harness.handle('restart', async () => restartService())
173
676
  }
174
677
 
175
678
  // 静态传输:HTTP 路由,供静态 client bundle(fetch)与直接浏览器访问使用。
@@ -191,36 +694,78 @@ return {
191
694
  })
192
695
  return out
193
696
  }
697
+ // A handler that throws leaves the request hanging until the browser gives
698
+ // up, and `fetch` then rejects with a bare "Failed to fetch" — the exact
699
+ // dead end this plugin's users hit. Always answer, and answer with the
700
+ // reason plus the journal, so the panel can render something actionable.
701
+ const guard = (name, handler) => async (req, res) => {
702
+ try {
703
+ await handler(req, res)
704
+ } catch (error) {
705
+ const reason = (error && error.stack) || (error && error.message) || String(error)
706
+ note('error', '路由 ' + name + ' 处理失败', reason)
707
+ try {
708
+ sendJson(res, 500, {
709
+ ok: false,
710
+ error: '插件内部错误(' + name + '):' + ((error && error.message) || String(error)),
711
+ detail: reason,
712
+ diagnostics: safeDiagnostics(),
713
+ })
714
+ } catch (_) { /* the socket is already gone; the journal has the reason */ }
715
+ }
716
+ }
717
+ // Registered FIRST and deliberately dependency-free: if anything below
718
+ // fails to register, this route still answers, which is the only way to
719
+ // read the failure without access to the terminal that started DSH.
720
+ ctx.effect(() => webServer.register({
721
+ kind: 'exact',
722
+ path: '/html-workbench/diagnostics',
723
+ handler: guard('diagnostics', (req, res) => sendJson(res, 200, safeDiagnostics())),
724
+ }), 'html-workbench: diagnostics route')
194
725
  ctx.effect(() => webServer.register({
195
726
  kind: 'exact',
196
727
  path: '/html-workbench/list',
197
- handler: (req, res) => sendJson(res, 200, { ok: true, running: serviceRunning, owned: !!ownedProcess, port: PORT, assets: snapshot() }),
728
+ handler: guard('list', (req, res) => sendJson(res, 200, listPayload())),
198
729
  }), 'html-workbench: list route')
199
730
  ctx.effect(() => webServer.register({
200
731
  kind: 'exact',
201
732
  path: '/html-workbench/status',
202
- handler: async (req, res) => { const h = await health(); sendJson(res, 200, { ok: !!h, running: !!h, owned: !!ownedProcess, port: PORT, info: h || null }) },
733
+ handler: guard('status', async (req, res) => sendJson(res, 200, await statusPayload())),
203
734
  }), 'html-workbench: status route')
204
735
  ctx.effect(() => webServer.register({
205
736
  kind: 'exact',
206
737
  path: '/html-workbench/open',
207
- handler: async (req, res) => {
738
+ handler: guard('open', async (req, res) => {
208
739
  const file = parseQuery(req).file
209
740
  const out = await openFile(file)
210
741
  sendJson(res, out.ok ? 200 : 400, out)
211
- },
742
+ }),
212
743
  }), 'html-workbench: open route')
213
744
  ctx.effect(() => webServer.register({
214
745
  kind: 'exact',
215
746
  path: '/html-workbench/resolve',
216
- handler: async (req, res) => {
747
+ handler: guard('resolve', async (req, res) => {
217
748
  const out = await resolvePath(parseQuery(req).file)
218
749
  sendJson(res, out.ok ? 200 : 400, out)
219
- },
750
+ }),
220
751
  }), 'html-workbench: resolve route')
752
+ ctx.effect(() => webServer.register({
753
+ kind: 'exact',
754
+ path: '/html-workbench/restart',
755
+ handler: guard('restart', async (req, res) => {
756
+ const out = await restartService()
757
+ sendJson(res, out.ok ? 200 : 500, out)
758
+ }),
759
+ }), 'html-workbench: restart route')
221
760
  }
222
761
 
223
762
  // 注册时主动拉起服务(异步,不阻塞 apply)。
224
- void startService()
763
+ // A rejection here must not escape into Cordis: `apply` has already wired up
764
+ // the transports, and losing them would take the diagnostics route down with
765
+ // it — leaving the panel with nothing but "Failed to fetch" again.
766
+ Promise.resolve().then(startService).catch((error) => {
767
+ startError = '启动本地服务时发生未预期的错误:' + ((error && error.message) || String(error))
768
+ note('error', startError, (error && error.stack) || null)
769
+ })
225
770
  },
226
771
  }