@vibe-x/dsh-html-workbench 0.3.0 → 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
@@ -28,33 +28,102 @@ return {
28
28
  const PORT = opts.port || 4317
29
29
  const SCRIPT = opts.script || null
30
30
  const EDITOR_ROOT = opts.editorRoot || null
31
- // Static packages inject Node's platform-aware `tmpdir()` from src/index.js.
32
- // Keep a dynamic-loader fallback too: TEMP/TMPDIR cover Windows/macOS/Linux;
33
- // only the final fallback is POSIX because dynamic runners may not expose
34
- // Node's `os` module. This folder contains only disposable cache/log data.
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
+ : ['python3', 'python']
45
+ // Static packages inject the runtime directory resolved (and probed) by
46
+ // src/index.js. Dynamic loads use TEMP/TMPDIR or the current workspace.
47
+ // The folder name matches
48
+ // RUNTIME_DIR_NAME in workbench.py so both halves agree on one location.
35
49
  const tempRoot = typeof process !== 'undefined' && process.env
36
50
  ? (process.env.TEMP || process.env.TMP || process.env.TMPDIR)
37
51
  : null
38
- const RUNTIME_DIR = opts.runtimeDir || (tempRoot ? tempRoot.replace(/[\\/]$/, '') + '/html-workbench-dsh' : '/tmp/html-workbench-dsh')
39
-
40
- // DSH runs plugin subprocesses under a workspace-write sandbox. `~/.cache`
41
- // is deliberately outside that allow-list, so the service used to die before
42
- // binding the port while trying to open its rotating log. Keep ALL runtime
43
- // writes (service logs + downloaded GrapesJS vendor files) in /tmp instead.
44
- // This is a cache by design: it is safe to delete and the service recreates
45
- // it on the next start.
52
+ // Strip a trailing separator from EITHER source: `%TEMP%` and an injected
53
+ // runtimeDir both legitimately end in one, and appending '/logs' to it would
54
+ // yield `C:\Temp\hwb\/logs` — a path Python tolerates but that reads as
55
+ // corruption in every log line and error message a user pastes back.
56
+ const trimSeparator = (value) => String(value).replace(/[\\/]+$/, '')
57
+ const currentDir = typeof process !== 'undefined' && typeof process.cwd === 'function' ? process.cwd() : '.'
58
+ const RUNTIME_DIR = opts.runtimeDir
59
+ ? trimSeparator(opts.runtimeDir)
60
+ : trimSeparator(tempRoot || currentDir) + '/.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.
46
71
  const LOG_DIR = RUNTIME_DIR + '/logs'
47
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
78
+
79
+ // Directories the running service reported; null until it says so.
80
+ let resolvedPaths = null
81
+ let serviceInfo = null
82
+ let startupLogFile
48
83
 
49
84
  let assets = []
50
85
  let seq = 0
51
86
  let ownedProcess = null
52
87
  let serviceRunning = false
53
88
  let startError = null
89
+ let pythonCommand = null
90
+ let dependencyIssue = null
91
+ // A failed probe must be remembered: `resolve` runs on every keystroke and
92
+ // `health` on a timer, so re-probing would spawn three 5s subprocesses per
93
+ // call and flood the journal with one repeated line. The TTL still lets a
94
+ // user who just installed Python recover without reloading the plugin.
95
+ const PROBE_RETRY_MS = 30000
96
+ let pythonProbeFailedAt = 0
97
+ let pythonProbe = null
54
98
  let disposed = false
99
+ let starting = null
100
+ let restarting = null
101
+ let serviceEpoch = 0
55
102
 
56
103
  const shell = ctx.get('shell')
57
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
+
58
127
  // ── Diagnostics journal ──────────────────────────────────────────────────
59
128
  //
60
129
  // A failed start used to surface as nothing but a red dot: the only signal
@@ -63,17 +132,75 @@ return {
63
132
  // subprocess outcome now lands in this ring buffer, which `status` returns
64
133
  // and the panel renders, so the failure is readable where it is observed.
65
134
  const JOURNAL_LIMIT = 40
135
+ // Errors are the whole point of the journal, yet they are also the rarest
136
+ // entries: a service that fails at start then gets polled by `status` every
137
+ // few seconds would push the original traceback out of a plain ring buffer
138
+ // long before anyone opens the panel. So evict by level — drop the oldest
139
+ // non-error first, and only sacrifice an error when nothing else is left.
140
+ const JOURNAL_ERROR_FLOOR = 12
66
141
  let journal = []
67
142
 
143
+ const trimJournal = () => {
144
+ if (journal.length <= JOURNAL_LIMIT) return
145
+ const overflow = journal.length - JOURNAL_LIMIT
146
+ let dropped = 0
147
+ const errors = journal.filter((entry) => entry.level === 'error').length
148
+ const keep = []
149
+ for (let i = 0; i < journal.length; i += 1) {
150
+ const entry = journal[i]
151
+ const isError = entry.level === 'error'
152
+ // Never let noise starve the error history, but do not grow without
153
+ // bound either: past the floor, errors age out like anything else.
154
+ const expendable = !isError || errors - dropped > JOURNAL_ERROR_FLOOR
155
+ if (dropped < overflow && expendable) {
156
+ dropped += 1
157
+ continue
158
+ }
159
+ keep.push(entry)
160
+ }
161
+ // Errors alone can exceed the limit; fall back to a plain tail so the
162
+ // buffer still has a hard ceiling.
163
+ journal = keep.length > JOURNAL_LIMIT ? keep.slice(-JOURNAL_LIMIT) : keep
164
+ }
165
+
68
166
  const note = (level, message, detail) => {
69
- journal.push({
70
- at: Date.now(),
71
- level: level,
72
- message: String(message || ''),
73
- detail: detail ? String(detail).slice(0, 4000) : null,
74
- })
75
- if (journal.length > JOURNAL_LIMIT) journal = journal.slice(-JOURNAL_LIMIT)
76
- if (level === 'error') console.error('[html-workbench] ' + message, detail || '')
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()
171
+ // Retrying a broken start replays the SAME failure verbatim, interleaved
172
+ // with the "restarting" line — so consecutive-only folding would miss it.
173
+ // Match anywhere, then move the entry to the end so its position still
174
+ // agrees with its timestamp when the panel renders newest-first.
175
+ let index = -1
176
+ for (let i = journal.length - 1; i >= 0; i -= 1) {
177
+ const entry = journal[i]
178
+ if (entry.level === level && entry.message === text && entry.detail === body) { index = i; break }
179
+ }
180
+ if (index >= 0) {
181
+ const entry = journal.splice(index, 1)[0]
182
+ entry.at = at
183
+ entry.repeated = (entry.repeated || 1) + 1
184
+ journal.push(entry)
185
+ } else {
186
+ journal.push({ at, firstAt: at, level, message: text, detail: body })
187
+ trimJournal()
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
+ }
203
+ if (level === 'error') console.error('[html-workbench] ' + text, body || '')
77
204
  }
78
205
 
79
206
  // `CollectedOutput` carries more than text (truncation flags, spill paths);
@@ -86,6 +213,25 @@ return {
86
213
  return trimmed
87
214
  }
88
215
 
216
+ // `shell.resolve({ command })` takes ONE command line, so every embedded path
217
+ // must be quoted for the platform's parser. Naive `'"' + p + '"'` breaks on
218
+ // Windows, where `C:\dir\` ends in a backslash that escapes the closing quote
219
+ // and swallows the rest of the line. Never use JSON.stringify here: it emits
220
+ // JSON escapes (`\\`), which cmd.exe passes through verbatim, so Python
221
+ // receives a path with doubled separators and reports every file as missing.
222
+ const quoteArg = (value) => {
223
+ const raw = String(value == null ? '' : value)
224
+ if (IS_WINDOWS) {
225
+ // Double only the run of backslashes that precedes the closing quote;
226
+ // interior ones are literal to cmd.exe. Inner quotes cannot appear in a
227
+ // Windows path, but strip them rather than emit an unbalanced line.
228
+ const cleaned = raw.replace(/"/g, '')
229
+ const tail = cleaned.match(/\\*$/)[0]
230
+ return '"' + cleaned + tail + '"'
231
+ }
232
+ return "'" + raw.replace(/'/g, "'\\''") + "'"
233
+ }
234
+
89
235
  const describeRun = (result) => {
90
236
  if (!result) return 'shell.run threw'
91
237
  const parts = []
@@ -97,13 +243,87 @@ return {
97
243
  return parts.join(' ') + (streams.length ? '\n' + streams.join('\n') : '')
98
244
  }
99
245
 
246
+ // Windows ships a `py` launcher that resolves versions the PATH may not
247
+ // expose. It is NOT usable as a command prefix (`py -3 script.py` mangles
248
+ // forwarded arguments), so ask it for the interpreter's absolute path and use
249
+ // that as a normal single-token executable.
250
+ const expandWindowsLauncher = async () => {
251
+ if (!IS_WINDOWS || !shell) return null
252
+ const probe = 'py -3 -c ' + quoteArg('import sys; print(sys.executable)')
253
+ try {
254
+ const result = await runShell(shell.resolve({ command: probe, timeoutMs: 5000, stdoutMaxBytes: 4096 }))
255
+ if (!result || result.exitCode !== 0) return { failure: 'py -3: ' + describeRun(result) }
256
+ const executable = outputText(result.stdout).split('\n').pop().trim()
257
+ if (!executable) return { failure: 'py -3: 没有返回解释器路径' }
258
+ return { command: quoteArg(executable) }
259
+ } catch (e) {
260
+ return { failure: 'py -3: ' + ((e && e.message) || String(e)) }
261
+ }
262
+ }
263
+
264
+ const resolvePythonCommand = async () => {
265
+ if (pythonCommand) return pythonCommand
266
+ if (!shell) return null
267
+ // Collapse concurrent callers onto one probe: the panel's poll, an `open`
268
+ // and a keystroke-driven `resolve` can all arrive inside the same tick.
269
+ if (pythonProbe) return pythonProbe
270
+ if (pythonProbeFailedAt && Date.now() - pythonProbeFailedAt < PROBE_RETRY_MS) return null
271
+ pythonProbe = (async () => {
272
+ const failures = []
273
+ const unsupported = []
274
+ const checkVersion = async (command) => {
275
+ try {
276
+ const spec = shell.resolve({ command: command + ' --version', timeoutMs: 5000, stdoutMaxBytes: 4096 })
277
+ const result = await runShell(spec)
278
+ if (result && result.exitCode === 0) {
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])
289
+ }
290
+ failures.push(command + ': ' + describeRun(result))
291
+ } catch (e) {
292
+ failures.push(command + ': ' + ((e && e.message) || String(e)))
293
+ }
294
+ return false
295
+ }
296
+ for (const candidate of PYTHON_CANDIDATES) {
297
+ const command = opts.pythonCommand ? quoteArg(candidate) : candidate
298
+ if (await checkVersion(command)) return pythonCommand
299
+ }
300
+ const launcher = await expandWindowsLauncher()
301
+ if (launcher?.command && await checkVersion(launcher.command)) return pythonCommand
302
+ if (launcher && launcher.failure) failures.push(launcher.failure)
303
+ pythonProbeFailedAt = Date.now()
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 }
308
+ note('error', startError, failures.join('\n'))
309
+ return null
310
+ })()
311
+ try {
312
+ return await pythonProbe
313
+ } finally {
314
+ pythonProbe = null
315
+ }
316
+ }
317
+
100
318
  const runCli = async (args, timeoutMs) => {
101
319
  if (!shell) { note('error', 'shell 服务不可用,无法执行 workbench.py'); return null }
102
320
  if (!SCRIPT) { note('error', 'workbench.py 路径未配置'); return null }
103
- const command = 'python3 "' + SCRIPT + '" ' + args
321
+ const python = await resolvePythonCommand()
322
+ if (!python) return null
323
+ const command = python + ' ' + quoteArg(SCRIPT) + ' ' + args
104
324
  try {
105
325
  const spec = shell.resolve({ command: command, timeoutMs: timeoutMs || 15000, stdoutMaxBytes: 64 * 1024 })
106
- const result = await shell.run(spec)
326
+ const result = await runShell(spec)
107
327
  // A non-zero exit is the interesting case and used to be discarded: the
108
328
  // CLI prints a structured `{ok:false,error:{code,message}}` on stdout.
109
329
  if (!result || result.exitCode !== 0) note('error', 'workbench.py ' + args.split(' ')[0] + ' 执行失败', describeRun(result))
@@ -116,15 +336,17 @@ return {
116
336
 
117
337
  // `health` runs on a timer, so its failures are expected and must not flood
118
338
  // the journal — poll quietly and let the caller decide what is worth noting.
119
- const probeHealth = async () => {
339
+ const probeHealth = async (timeoutMs = 8000) => {
120
340
  if (!shell || !SCRIPT) return null
341
+ const python = await resolvePythonCommand()
342
+ if (!python) return null
121
343
  try {
122
344
  const spec = shell.resolve({
123
- command: 'python3 "' + SCRIPT + '" health --port ' + PORT,
124
- timeoutMs: 8000,
345
+ command: python + ' ' + quoteArg(SCRIPT) + ' health --port ' + PORT,
346
+ timeoutMs: timeoutMs,
125
347
  stdoutMaxBytes: 64 * 1024,
126
348
  })
127
- const result = await shell.run(spec)
349
+ const result = await runShell(spec)
128
350
  if (!result || result.exitCode !== 0) return { ok: false, result: result }
129
351
  try {
130
352
  const payload = JSON.parse(outputText(result.stdout) || '')
@@ -137,9 +359,28 @@ return {
137
359
  }
138
360
  }
139
361
 
362
+ // `/api/health` advertises the directories the service really writes to. The
363
+ // paths it was ASKED for may be exactly what the sandbox refused, so adopting
364
+ // the reported ones is what lets the panel stay truthful — for a service we
365
+ // started and for one we merely reused (a reused service leaves us no startup
366
+ // output to read them from).
367
+ const adoptResolvedPaths = (payload) => {
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
374
+ const logDir = typeof payload.logDir === 'string' ? payload.logDir : null
375
+ const vendorCache = typeof payload.vendorCache === 'string' ? payload.vendorCache : null
376
+ if (logDir || vendorCache) resolvedPaths = { logDir: logDir, vendorCache: vendorCache }
377
+ }
378
+
140
379
  const health = async () => {
141
380
  const probe = await probeHealth()
142
- return probe && probe.ok ? probe.payload : null
381
+ if (!probe || !probe.ok) return null
382
+ adoptResolvedPaths(probe.payload)
383
+ return probe.payload
143
384
  }
144
385
 
145
386
  // `workbench.py` reports failures as one structured JSON line on stderr. Turn
@@ -147,14 +388,14 @@ return {
147
388
  // in the detail so nothing is lost when the code is one we do not know.
148
389
  const CLI_HINTS = {
149
390
  PORT_IN_USE: '端口已被占用。可能有别的进程(或旧版服务)占着它 —— 点「重启服务」会先退役占用者。',
150
- ASSET_NOT_FOUND: '前端资源缺失,插件安装目录不完整(缺 assets/workbench.html)。',
391
+ ASSET_NOT_FOUND: '缺少编辑器文件 assets/workbench.html。请重新安装 HTML Workbench 插件,然后重启 DSH。',
151
392
  PYTHON_TOO_OLD: '需要 Python 3.9 或更高版本。',
152
393
  SERVER_OUTDATED: '端口上有旧版服务且无法自动停止,请手动结束该进程。',
153
394
  FILE_NOT_FOUND: '找不到文件。',
154
- VENDOR_DOWNLOAD_FAILED: '首次启动需要联网下载 GrapesJS,下载失败了。',
395
+ VENDOR_DOWNLOAD_FAILED: '无法准备编辑器所需的 GrapesJS。请检查运行 DSH 的电脑能否联网,以及缓存目录是否可写,然后在工作台中重启服务。',
155
396
  }
156
397
 
157
- const explainOutput = (text) => {
398
+ const explainOutput = (text, startup = false) => {
158
399
  if (!text) return null
159
400
  for (const line of String(text).split('\n')) {
160
401
  const trimmed = line.trim()
@@ -162,8 +403,13 @@ return {
162
403
  try {
163
404
  const payload = JSON.parse(trimmed)
164
405
  if (payload && payload.ok === false && payload.error) {
406
+ if ('logFile' in payload) startupLogFile = payload.logFile
165
407
  const hint = CLI_HINTS[payload.error]
166
- 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 + ')'
167
413
  }
168
414
  } catch (e) { /* not the JSON line; keep scanning */ }
169
415
  }
@@ -186,13 +432,16 @@ return {
186
432
  }
187
433
  }
188
434
 
189
- const startService = async () => {
435
+ const startOrReuseService = async () => {
190
436
  if (disposed) return null
437
+ serviceEpoch += 1
191
438
  if (serviceRunning) return health()
192
439
  const existing = await health()
440
+ if (disposed) return null
193
441
  if (existing) {
194
442
  serviceRunning = true
195
443
  startError = null
444
+ dependencyIssue = null
196
445
  healthConfirmedAt = Date.now()
197
446
  // Record the reuse too, so the journal always explains the CURRENT state
198
447
  // rather than only the failures.
@@ -201,26 +450,39 @@ return {
201
450
  }
202
451
  if (!shell) { startError = 'shell 服务不可用(DSH 未提供 shell,插件无法拉起本地服务)'; note('error', startError); return null }
203
452
  if (!SCRIPT) { startError = 'workbench.py 路径未配置(请用静态装载并随包分发 scripts/workbench.py)'; note('error', startError); return null }
453
+ const python = await resolvePythonCommand()
454
+ if (disposed || !python) return null
455
+ startupLogFile = undefined
204
456
  let proc = null
205
- const command = 'python3 "' + SCRIPT + '" serve --port ' + PORT
206
- + ' --log-dir "' + LOG_DIR + '"'
207
- + ' --vendor-cache "' + VENDOR_CACHE + '"'
208
- + (EDITOR_ROOT ? ' --editor-root "' + EDITOR_ROOT + '"' : '')
457
+ const command = python + ' ' + quoteArg(SCRIPT) + ' serve --port ' + PORT
458
+ + ' --log-dir ' + quoteArg(LOG_DIR)
459
+ + ' --vendor-cache ' + quoteArg(VENDOR_CACHE)
460
+ + (EDITOR_ROOT ? ' --editor-root ' + quoteArg(EDITOR_ROOT) : '')
209
461
  try {
210
462
  note('info', '启动本地服务:端口 ' + PORT, command)
211
- proc = shell.start(shell.resolve({ command: command, stdoutMaxBytes: 64 * 1024 }))
463
+ proc = await startShell(shell.resolve({ command: command, stdoutMaxBytes: 64 * 1024 }))
212
464
  if (disposed) { try { proc.kill() } catch (e) {} return null }
213
465
  ownedProcess = proc
466
+ const deadline = Date.now() + 60000
214
467
  let transcript = ''
215
- for (let i = 0; i < 240; i += 1) {
468
+ let lastProbe = null
469
+ while (!disposed && Date.now() < deadline) {
216
470
  const chunk = drain(proc)
217
- if (chunk) transcript += (transcript ? '\n' : '') + chunk
218
- const probe = await probeHealth()
219
- 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') {
220
476
  serviceRunning = true
221
477
  startError = null
478
+ dependencyIssue = null
222
479
  healthConfirmedAt = Date.now()
480
+ adoptResolvedPaths(probe.payload)
223
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
+ }
224
486
  return probe.payload
225
487
  }
226
488
  // Stop waiting the moment the process is gone. Polling health for the
@@ -228,29 +490,41 @@ return {
228
490
  // fast, loud failure look like a hang.
229
491
  if (proc.status !== 'running') {
230
492
  const tail = drain(proc)
231
- if (tail) transcript += (transcript ? '\n' : '') + tail
493
+ if (tail) transcript = (transcript + '\n' + tail).trim().slice(-16000)
232
494
  // Lead with the CLI's own diagnosis when it gave one — "端口已被占用"
233
495
  // is actionable, "exit=1" is not.
234
- const explained = explainOutput(transcript)
235
- startError = explained || ('serve 进程已退出(exit=' + String(proc.exitCode) + (proc.signal ? ', 信号 ' + proc.signal : '') + ')')
236
- 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))
237
500
  ownedProcess = null
238
501
  return null
239
502
  }
240
- await ctx.timeout(250)
503
+ const remaining = deadline - Date.now()
504
+ if (remaining > 0) await ctx.timeout(Math.min(250, remaining))
241
505
  }
506
+ if (disposed) return null
242
507
  startError = '服务在 60 秒内没有就绪(端口 ' + PORT + ' 未通过健康检查)'
243
- 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
244
513
  return null
245
514
  } catch (e) {
246
515
  startError = 'serve 进程启动失败:' + ((e && e.message) || String(e))
247
- note('error', startError, command)
516
+ note('error', startError, [e && e.stack, command].filter(Boolean).join('\n'))
248
517
  if (proc) { try { proc.kill() } catch (e2) {} }
249
518
  ownedProcess = null
250
519
  return null
251
520
  }
252
521
  }
253
522
 
523
+ const startService = () => {
524
+ if (!starting) starting = startOrReuseService().finally(() => { starting = null })
525
+ return starting
526
+ }
527
+
254
528
  // A service that dies AFTER a successful start used to leave the plugin
255
529
  // frozen: nothing ever re-checked the process, so the panel kept showing a
256
530
  // stale dot while every open failed with no explanation. Reading
@@ -258,34 +532,40 @@ return {
258
532
  // existing poll can supervise the process for free.
259
533
  const superviseOwnedProcess = () => {
260
534
  const proc = ownedProcess
261
- if (!proc || proc.status === 'running') return
535
+ if (starting || !proc || proc.status === 'running') return
262
536
  const tail = drain(proc)
263
537
  const explained = explainOutput(tail)
264
538
  ownedProcess = null
265
539
  serviceRunning = false
266
540
  startError = explained || ('服务进程已退出(exit=' + String(proc.exitCode) + (proc.signal ? ', 信号 ' + proc.signal : '') + ')')
267
- note('error', '服务进程意外退出', tail || '进程没有留下输出。可用「重启服务」重新拉起。')
541
+ note('error', '服务进程意外退出', [startError, tail || '进程没有留下输出。'].join('\n'))
268
542
  }
269
543
 
270
- // A REUSED service has no process handle, so its death is invisible to the
271
- // check above. Revalidate with a real health call — throttled, because this
272
- // spawns a subprocess and the panel polls every few seconds. The window is
273
- // seeded when the service is confirmed up, so a freshly verified service is
274
- // 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.
275
546
  const REVALIDATE_MS = 10000
276
547
  let healthConfirmedAt = 0
277
548
  let revalidating = false
278
549
 
279
550
  const revalidateService = () => {
280
- if (revalidating || ownedProcess || !serviceRunning) return
551
+ if (disposed || starting || restarting || revalidating || !serviceRunning) return
281
552
  if (Date.now() - healthConfirmedAt < REVALIDATE_MS) return
553
+ const epoch = serviceEpoch
282
554
  revalidating = true
283
555
  void (async () => {
284
556
  try {
285
- 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
+ }
286
566
  serviceRunning = false
287
- startError = '之前复用的服务已经不再响应(端口 ' + PORT + ')。'
288
- note('error', '复用的服务失去响应', '健康检查失败。可用「重启服务」重新拉起。')
567
+ startError = '本地服务已经不再响应(端口 ' + PORT + ')。'
568
+ note('error', '本地服务失去响应', probe && (probe.error || describeRun(probe.result)))
289
569
  } finally {
290
570
  revalidating = false
291
571
  }
@@ -295,34 +575,54 @@ return {
295
575
  // Everything the panel needs to explain a red dot without opening a terminal.
296
576
  // Reaping first means a process that died since the last call is reported as
297
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
+
298
603
  const diagnostics = () => {
299
604
  superviseOwnedProcess()
300
605
  revalidateService()
301
606
  return {
607
+ ...diagnosticFacts(),
302
608
  ok: true,
303
609
  running: serviceRunning,
304
610
  owned: !!ownedProcess,
305
- port: PORT,
306
- script: SCRIPT,
307
- editorRoot: EDITOR_ROOT,
308
- runtimeDir: RUNTIME_DIR,
309
- logDir: LOG_DIR,
310
- vendorCache: VENDOR_CACHE,
311
- hasShell: !!shell,
312
611
  processStatus: ownedProcess ? ownedProcess.status : null,
313
612
  exitCode: ownedProcess ? ownedProcess.exitCode : null,
314
- startError: startError,
315
- journal: journal.slice().reverse(),
316
613
  }
317
614
  }
318
615
 
319
616
  // 生命周期副作用:卸载时停掉本插件自己启动的服务进程。
320
617
  ctx.effect(() => () => {
321
618
  disposed = true
619
+ serviceEpoch += 1
322
620
  const proc = ownedProcess
323
621
  ownedProcess = null
324
622
  serviceRunning = false
325
- 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
+ }
326
626
  })
327
627
 
328
628
  const recordAsset = (path, kind) => {
@@ -347,7 +647,7 @@ return {
347
647
  if (!/\.html?$/i.test(path)) return
348
648
  recordAsset(path, exec.name === 'write' ? 'create' : 'edit')
349
649
  } catch (e) {
350
- console.error('[html-workbench] track failed', e)
650
+ note('error', '记录 HTML 产物时出错', e.stack || e)
351
651
  }
352
652
  })
353
653
 
@@ -357,12 +657,18 @@ return {
357
657
  if (typeof file !== 'string' || !file) return { ok: false, error: '请填写文件路径' }
358
658
  if (!/\.html?$/i.test(file)) return { ok: false, error: '只支持 .html / .htm 文件' }
359
659
  if (!SCRIPT) return { ok: false, error: startError || 'workbench.py 路径未配置', diagnostics: diagnostics() }
360
- const wasUp = await health()
361
- if (!wasUp) await startService()
362
- const h = await health()
660
+ const wasUp = !starting && !restarting ? await health() : null
661
+ const h = restarting ? (await restarting).info : wasUp || await startService()
363
662
  // Carry the diagnostics WITH the failure: the panel can then show the real
364
663
  // cause instead of a generic "unavailable" that sends the user hunting.
365
- 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()
366
672
  return {
367
673
  ok: true,
368
674
  url: 'http://127.0.0.1:' + PORT + '/?file=' + encodeURIComponent(file),
@@ -372,12 +678,19 @@ return {
372
678
  }
373
679
  }
374
680
 
375
- const restartService = async () => {
681
+ const stopAndRestartService = async () => {
682
+ serviceEpoch += 1
683
+ await starting
684
+ if (disposed) return { ok: false, error: 'HTML Workbench 已卸载' }
376
685
  const proc = ownedProcess
377
686
  ownedProcess = null
378
687
  serviceRunning = false
379
688
  startError = null
380
- 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
+ }
381
694
  note('info', '手动重启本地服务')
382
695
  // Killing our own handle is not enough when the port is held by a process
383
696
  // we do not own (a leftover from a previous DSH run, or an outdated
@@ -391,6 +704,18 @@ return {
391
704
  return h ? { ok: true, info: h, diagnostics: diagnostics() } : { ok: false, error: startError || '重启失败', diagnostics: diagnostics() }
392
705
  }
393
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
+
394
719
  // Browser address bars yield file:///… URLs while the CLI expects a native
395
720
  // path. Normalize only for the local existence probe; the Python service
396
721
  // repeats this normalization authoritatively for every request.
@@ -401,9 +726,8 @@ return {
401
726
  const url = new URL(raw)
402
727
  if (url.protocol !== 'file:' || url.search || url.hash) return raw
403
728
  let path = decodeURIComponent(url.pathname)
404
- const isWindows = typeof process !== 'undefined' && process.platform === 'win32'
405
- if (isWindows && /^[\\/][A-Za-z]:\//.test(path)) path = path.slice(1)
406
- 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
407
731
  return path
408
732
  } catch (e) {
409
733
  return raw
@@ -416,14 +740,29 @@ return {
416
740
  if (!trimmed) return { ok: true, empty: true, isHtml: false, exists: false }
417
741
  const normalized = normalizeFileReference(trimmed)
418
742
  if (!/\.html?$/i.test(normalized)) return { ok: true, isHtml: false, exists: false }
419
- if (!shell) return { ok: true, isHtml: true, exists: null }
420
- const arg = JSON.stringify(normalized)
421
- 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 })
743
+ if (!shell) return { ok: true, isHtml: true, exists: null, normalized: normalized }
744
+ const python = await resolvePythonCommand()
745
+ // Say WHY the check could not run. Returning a bare `exists: null` renders
746
+ // as no indicator at all, which reads as "the field is ignoring me".
747
+ if (!python) {
748
+ return { ok: true, isHtml: true, exists: null, normalized: normalized, error: startError }
749
+ }
750
+ const probe = 'import os,sys; sys.exit(0 if os.path.isfile(sys.argv[1]) else 1)'
422
751
  try {
423
- const r = await shell.run(spec)
424
- 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: '文件检查未完成,请查看诊断日志。' }
425
763
  } catch (e) {
426
- return { ok: true, isHtml: true, exists: null, normalized: normalized }
764
+ note('error', '文件检查未完成', e.stack || e)
765
+ return { ok: true, isHtml: true, exists: null, normalized: normalized, error: (e && e.message) || String(e) }
427
766
  }
428
767
  }
429
768
 
@@ -433,14 +772,50 @@ return {
433
772
  return base
434
773
  }
435
774
 
775
+ const statusPayload = async () => {
776
+ const h = await health()
777
+ const out = diagnostics()
778
+ out.ok = !!h
779
+ out.running = !!h
780
+ out.info = h || null
781
+ return out
782
+ }
783
+
784
+ // `diagnostics()` supervises the child process and revalidates the service,
785
+ // so it can itself throw (a dead handle, a shell that rejects). Since it is
786
+ // the payload every error path attaches, it must never be the thing that
787
+ // turns a readable failure back into an opaque one.
788
+ const safeDiagnostics = () => {
789
+ try {
790
+ return diagnostics()
791
+ } catch (error) {
792
+ return {
793
+ ...diagnosticFacts(),
794
+ ok: false,
795
+ degraded: true,
796
+ error: '收集诊断信息时出错:' + ((error && error.message) || String(error)),
797
+ }
798
+ }
799
+ }
800
+
436
801
  // 动态传输:Package-private RPC(仅动态运行时存在 harness)。
437
802
  if (typeof harness !== 'undefined') {
438
- harness.handle('list', () => listPayload())
439
- harness.handle('status', async () => { const h = await health(); const out = diagnostics(); out.ok = !!h; out.running = !!h; out.info = h || null; return out })
440
- harness.handle('open', async (args) => openFile(args && args.file))
441
- harness.handle('resolve', async (args) => resolvePath(args && args.file))
442
- harness.handle('diagnostics', () => diagnostics())
443
- 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)
444
819
  }
445
820
 
446
821
  // 静态传输:HTTP 路由,供静态 client bundle(fetch)与直接浏览器访问使用。
@@ -462,58 +837,84 @@ return {
462
837
  })
463
838
  return out
464
839
  }
840
+ // A handler that throws leaves the request hanging until the browser gives
841
+ // up, and `fetch` then rejects with a bare "Failed to fetch" — the exact
842
+ // dead end this plugin's users hit. Always answer, and answer with the
843
+ // reason plus the journal, so the panel can render something actionable.
844
+ const guard = (name, handler) => async (req, res) => {
845
+ try {
846
+ await handler(req, res)
847
+ } catch (error) {
848
+ const reason = (error && error.stack) || (error && error.message) || String(error)
849
+ note('error', '路由 ' + name + ' 处理失败', reason)
850
+ try {
851
+ sendJson(res, 500, {
852
+ ok: false,
853
+ error: '插件内部错误(' + name + '):' + ((error && error.message) || String(error)),
854
+ detail: reason,
855
+ diagnostics: safeDiagnostics(),
856
+ })
857
+ } catch (_) { /* the socket is already gone; the journal has the reason */ }
858
+ }
859
+ }
860
+ // Registered FIRST and deliberately dependency-free: if anything below
861
+ // fails to register, this route still answers, which is the only way to
862
+ // read the failure without access to the terminal that started DSH.
863
+ ctx.effect(() => webServer.register({
864
+ kind: 'exact',
865
+ path: '/html-workbench/diagnostics',
866
+ handler: guard('diagnostics', (req, res) => sendJson(res, 200, safeDiagnostics())),
867
+ }), 'html-workbench: diagnostics route')
465
868
  ctx.effect(() => webServer.register({
466
869
  kind: 'exact',
467
870
  path: '/html-workbench/list',
468
- handler: (req, res) => sendJson(res, 200, listPayload()),
871
+ handler: guard('list', (req, res) => sendJson(res, 200, listPayload())),
469
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')
470
878
  ctx.effect(() => webServer.register({
471
879
  kind: 'exact',
472
880
  path: '/html-workbench/status',
473
- handler: async (req, res) => {
474
- const h = await health()
475
- const out = diagnostics()
476
- out.ok = !!h
477
- out.running = !!h
478
- out.info = h || null
479
- sendJson(res, 200, out)
480
- },
881
+ handler: guard('status', async (req, res) => sendJson(res, 200, await statusPayload())),
481
882
  }), 'html-workbench: status route')
482
883
  ctx.effect(() => webServer.register({
483
884
  kind: 'exact',
484
885
  path: '/html-workbench/open',
485
- handler: async (req, res) => {
886
+ handler: guard('open', async (req, res) => {
486
887
  const file = parseQuery(req).file
487
888
  const out = await openFile(file)
488
889
  sendJson(res, out.ok ? 200 : 400, out)
489
- },
890
+ }),
490
891
  }), 'html-workbench: open route')
491
892
  ctx.effect(() => webServer.register({
492
893
  kind: 'exact',
493
894
  path: '/html-workbench/resolve',
494
- handler: async (req, res) => {
895
+ handler: guard('resolve', async (req, res) => {
495
896
  const out = await resolvePath(parseQuery(req).file)
496
897
  sendJson(res, out.ok ? 200 : 400, out)
497
- },
898
+ }),
498
899
  }), 'html-workbench: resolve route')
499
- // Readable straight from a browser tab — the fastest path from "red dot"
500
- // to "here is the traceback" without any UI in the way.
501
- ctx.effect(() => webServer.register({
502
- kind: 'exact',
503
- path: '/html-workbench/diagnostics',
504
- handler: (req, res) => sendJson(res, 200, diagnostics()),
505
- }), 'html-workbench: diagnostics route')
506
900
  ctx.effect(() => webServer.register({
507
901
  kind: 'exact',
508
902
  path: '/html-workbench/restart',
509
- handler: async (req, res) => {
903
+ handler: guard('restart', async (req, res) => {
510
904
  const out = await restartService()
511
905
  sendJson(res, out.ok ? 200 : 500, out)
512
- },
906
+ }),
513
907
  }), 'html-workbench: restart route')
514
908
  }
515
909
 
516
910
  // 注册时主动拉起服务(异步,不阻塞 apply)。
517
- void startService()
911
+ // A rejection here must not escape into Cordis: `apply` has already wired up
912
+ // the transports, and losing them would take the diagnostics route down with
913
+ // it — leaving the panel with nothing but "Failed to fetch" again.
914
+ note('info', '插件宿主已加载', JSON.stringify({ mode, platform: PLATFORM, arch, nodeVersion, script: SCRIPT, runtimeDir: RUNTIME_DIR }))
915
+ Promise.resolve().then(() => restarting ? null : startService()).catch((error) => {
916
+ startError = '启动本地服务时发生未预期的错误:' + ((error && error.message) || String(error))
917
+ note('error', startError, (error && error.stack) || null)
918
+ })
518
919
  },
519
920
  }