@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/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
- : (IS_WINDOWS ? ['python3', 'python'] : ['python3'])
44
+ : ['python3', 'python']
45
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
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 ? trimSeparator(tempRoot) + '/.html-workbench' : '/tmp/.html-workbench')
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 = opts.pythonCommand || (IS_WINDOWS ? null : 'python3')
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 body = detail ? String(detail).slice(0, 4000) : null
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 = Date.now()
182
+ entry.at = at
148
183
  entry.repeated = (entry.repeated || 1) + 1
149
184
  journal.push(entry)
150
185
  } else {
151
- journal.push({ at: Date.now(), level: level, message: text, detail: body })
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 shell.run(shell.resolve({ command: probe, timeoutMs: 5000, stdoutMaxBytes: 4096 }))
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
- for (const candidate of PYTHON_CANDIDATES) {
273
+ const unsupported = []
274
+ const checkVersion = async (command) => {
225
275
  try {
226
- const spec = shell.resolve({ command: candidate + ' --version', timeoutMs: 5000, stdoutMaxBytes: 4096 })
227
- const result = await shell.run(spec)
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
- pythonCommand = candidate
230
- pythonProbeFailedAt = 0
231
- note('info', '使用 Python 解释器:' + candidate)
232
- return pythonCommand
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(candidate + ': ' + describeRun(result))
290
+ failures.push(command + ': ' + describeRun(result))
235
291
  } catch (e) {
236
- failures.push(candidate + ': ' + ((e && e.message) || String(e)))
292
+ failures.push(command + ': ' + ((e && e.message) || String(e)))
237
293
  }
294
+ return false
238
295
  }
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
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
- startError = '找不到可用的 Python 3 解释器(已尝试:'
249
- + PYTHON_CANDIDATES.join('、') + (IS_WINDOWS ? '、py -3' : '')
250
- + ')。请安装 Python 3.9+ 并确保它在 PATH 中,然后点「重启服务」。'
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 shell.run(spec)
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: 8000,
346
+ timeoutMs: timeoutMs,
290
347
  stdoutMaxBytes: 64 * 1024,
291
348
  })
292
- const result = await shell.run(spec)
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: '前端资源缺失,插件安装目录不完整(缺 assets/workbench.html)。',
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: '首次启动需要联网下载 GrapesJS,下载失败了。',
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
- return (hint || payload.message || payload.error) + '(' + payload.error + ')'
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 startService = async () => {
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 = shell.start(shell.resolve({ command: command, stdoutMaxBytes: 64 * 1024 }))
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
- for (let i = 0; i < 240; i += 1) {
468
+ let lastProbe = null
469
+ while (!disposed && Date.now() < deadline) {
397
470
  const chunk = drain(proc)
398
- if (chunk) transcript += (transcript ? '\n' : '') + chunk
399
- const probe = await probeHealth()
400
- if (probe && probe.ok) {
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 += (transcript ? '\n' : '') + tail
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
- startError = explained || ('serve 进程已退出(exit=' + String(proc.exitCode) + (proc.signal ? ', 信号 ' + proc.signal : '') + ')')
418
- note('error', startError, transcript || '进程没有任何输出。命令:' + command)
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
- await ctx.timeout(250)
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
- note('error', startError, transcript || '进程仍在运行但健康检查一直失败。命令:' + command)
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
- // 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.
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 (revalidating || ownedProcess || !serviceRunning) return
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
- if (await health()) { healthConfirmedAt = Date.now(); return }
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 = '之前复用的服务已经不再响应(端口 ' + PORT + ')。'
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) { try { proc.kill() } catch (e) { console.error('[html-workbench] stop failed', e) } }
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
- console.error('[html-workbench] track failed', e)
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
- if (!wasUp) await startService()
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) return { ok: false, error: startError || '本地服务未就绪', diagnostics: diagnostics() }
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 restartService = async () => {
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 (proc) { try { proc.kill() } catch (e) {} }
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
- 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
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 r = await shell.run(spec)
624
- return { ok: true, isHtml: true, exists: !!(r && r.exitCode === 0), normalized: normalized }
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('list', () => listPayload())
671
- harness.handle('status', () => statusPayload())
672
- harness.handle('open', async (args) => openFile(args && args.file))
673
- harness.handle('resolve', async (args) => resolvePath(args && args.file))
674
- harness.handle('diagnostics', () => safeDiagnostics())
675
- harness.handle('restart', async () => restartService())
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
- Promise.resolve().then(startService).catch((error) => {
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
  })