@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-x/dsh-html-workbench",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "DeepSeek Harness 插件:在右侧栏预览并可视化编辑 agent 生成的 HTML(复用/拉起本地 html-workbench 服务)。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -46,6 +46,7 @@
46
46
  "node": ">=20"
47
47
  },
48
48
  "publishConfig": {
49
+ "registry": "https://registry.npmjs.org/",
49
50
  "access": "public"
50
51
  }
51
52
  }
@@ -14,6 +14,7 @@ import logging
14
14
  import mimetypes
15
15
  import os
16
16
  import re
17
+ import secrets
17
18
  import signal
18
19
  import subprocess
19
20
  import sys
@@ -21,6 +22,7 @@ import tarfile
21
22
  import tempfile
22
23
  import threading
23
24
  import time
25
+ import traceback
24
26
  import urllib.error
25
27
  import urllib.parse
26
28
  import urllib.request
@@ -113,8 +115,10 @@ def default_vendor_cache() -> Path:
113
115
  return base / "html-workbench" / "vendor" / "grapesjs" / GRAPESJS_VERSION
114
116
 
115
117
 
116
- LOG_MAX_BYTES = 1_000_000
118
+ LOG_MAX_BYTES = 1024 * 1024
117
119
  LOG_BACKUP_COUNT = 5
120
+ LOG_RECORD_MAX_BYTES = 16 * 1024
121
+ BOOTSTRAP_MAX_BYTES = 256 * 1024
118
122
 
119
123
 
120
124
  def default_log_dir() -> Path:
@@ -198,7 +202,7 @@ def self_ignore(directory: Path) -> None:
198
202
  pass
199
203
 
200
204
 
201
- def pick_writable_dir(*candidates: Path) -> Path:
205
+ def pick_writable_dir(*candidates: Path, failures: list[str] | None = None) -> Path:
202
206
  """Return the first candidate directory this process can really write into.
203
207
 
204
208
  Existence proves nothing: the sandbox DACL looks writable on paper while the
@@ -209,18 +213,15 @@ def pick_writable_dir(*candidates: Path) -> Path:
209
213
  still gets a deterministic path (and a clear error at the real write).
210
214
  """
211
215
  fallback: Path | None = None
212
- for candidate in candidates:
213
- if candidate is None:
214
- continue
216
+ for candidate in dict.fromkeys(candidates):
215
217
  if fallback is None:
216
218
  fallback = candidate
217
219
  try:
218
220
  candidate.mkdir(parents=True, exist_ok=True)
219
- except OSError:
220
- continue
221
- try:
222
221
  descriptor, probe = make_temp_file(candidate, prefix=".wb-write-probe.")
223
- except OSError:
222
+ except OSError as caught:
223
+ if failures is not None:
224
+ failures.append(f"{candidate}: {caught}")
224
225
  continue
225
226
  os.close(descriptor)
226
227
  try:
@@ -237,7 +238,7 @@ def log_candidates(log_dir: Path | str) -> tuple[Path, ...]:
237
238
  return (
238
239
  Path(log_dir).expanduser(),
239
240
  runtime_directory() / "logs",
240
- Path(tempfile.gettempdir()) / RUNTIME_DIR_NAME / "logs",
241
+ temporary_runtime_directory() / "logs",
241
242
  )
242
243
 
243
244
 
@@ -251,9 +252,95 @@ def vendor_candidates(vendor_cache: Path | str, log_dir: Path) -> tuple[Path, ..
251
252
  Path(vendor_cache).expanduser(),
252
253
  Path(log_dir).parent / "vendor",
253
254
  runtime_directory() / "vendor",
254
- Path(tempfile.gettempdir()) / RUNTIME_DIR_NAME / "vendor",
255
+ temporary_runtime_directory() / "vendor",
255
256
  )
256
257
 
258
+ def temporary_runtime_directory() -> Path:
259
+ try:
260
+ return Path(tempfile.gettempdir()) / RUNTIME_DIR_NAME
261
+ except OSError:
262
+ # A denied TEMP must not prevent trying the explicitly supplied directory.
263
+ return runtime_directory()
264
+
265
+
266
+ def bounded_utf8(text: str, limit: int) -> bytes:
267
+ data = text.encode("utf-8", errors="backslashreplace")
268
+ if len(data) <= limit:
269
+ return data
270
+ marker = b"\n...[truncated]...\n"
271
+ remaining = limit - len(marker)
272
+ head = data[:remaining // 3].decode("utf-8", errors="ignore").encode("utf-8")
273
+ tail = data[-(remaining - remaining // 3):].decode("utf-8", errors="ignore").encode("utf-8")
274
+ return head + marker + tail
275
+
276
+
277
+ class BoundedRotatingFileHandler(RotatingFileHandler):
278
+ """Bound UTF-8 bytes, including exception stacks; back off on disk failures."""
279
+
280
+ last_error: str | None = None
281
+ dropped_records = 0
282
+ retry_at = 0.0
283
+
284
+ def emit(self, record: logging.LogRecord) -> None:
285
+ if time.monotonic() < self.retry_at:
286
+ self.dropped_records += 1
287
+ return
288
+ try:
289
+ data = bounded_utf8(self.format(record), min(LOG_RECORD_MAX_BYTES, self.maxBytes) - 1) + b"\n"
290
+ if self.stream is None:
291
+ self.stream = self._open()
292
+ if os.fstat(self.stream.fileno()).st_size + len(data) > self.maxBytes:
293
+ self.doRollover()
294
+ # Binary writes avoid Windows newline translation and count CJK bytes.
295
+ self.stream.buffer.write(data)
296
+ self.stream.flush()
297
+ self.last_error = None
298
+ except Exception as caught:
299
+ self.last_error = str(caught)[:500]
300
+ self.dropped_records += 1
301
+ self.retry_at = time.monotonic() + 60
302
+ try:
303
+ print(json.dumps({"event": "log_write_failed", "message": self.last_error}), file=sys.stderr, flush=True)
304
+ except OSError:
305
+ pass
306
+
307
+
308
+ class BoundedOutput(io.TextIOBase):
309
+ """Cap the detached child's combined stdout/stderr, including library output."""
310
+
311
+ def __init__(self, output: Any) -> None:
312
+ self.output = output
313
+ self.lock = threading.Lock()
314
+ self.full = False
315
+
316
+ @property
317
+ def encoding(self) -> str:
318
+ return "utf-8"
319
+
320
+ def write(self, text: str) -> int:
321
+ with self.lock:
322
+ if self.full:
323
+ return len(text)
324
+ try:
325
+ remaining = BOOTSTRAP_MAX_BYTES - os.fstat(self.output.fileno()).st_size
326
+ data = text.encode("utf-8", errors="backslashreplace")
327
+ marker = b"\n[bootstrap output limit reached]\n"
328
+ if len(data) + len(marker) > remaining:
329
+ self.full = True
330
+ data = data[:max(0, remaining - len(marker))].decode("utf-8", errors="ignore").encode("utf-8")
331
+ data += marker[:max(0, remaining - len(data))]
332
+ self.output.write(data)
333
+ self.output.flush()
334
+ except OSError:
335
+ self.full = True
336
+ return len(text)
337
+
338
+ def flush(self) -> None:
339
+ try:
340
+ self.output.flush()
341
+ except (OSError, ValueError):
342
+ pass
343
+
257
344
 
258
345
  _logger: logging.Logger | None = None
259
346
 
@@ -286,8 +373,8 @@ def setup_logging(log_dir: Path | str, port: int | None = None) -> tuple[logging
286
373
  swallowed and the service runs without a file log rather than never binding
287
374
  its port.
288
375
  """
289
- global _logger
290
- directory = pick_writable_dir(*log_candidates(log_dir))
376
+ failures: list[str] = []
377
+ directory = pick_writable_dir(*log_candidates(log_dir), failures=failures)
291
378
  filename = f"workbench-{port}.log" if port is not None else "workbench.log"
292
379
  logger = get_logger()
293
380
  for existing in list(logger.handlers):
@@ -296,16 +383,24 @@ def setup_logging(log_dir: Path | str, port: int | None = None) -> tuple[logging
296
383
  logger.removeHandler(existing)
297
384
  logger.setLevel(logging.INFO)
298
385
  try:
299
- handler = RotatingFileHandler(
386
+ handler = BoundedRotatingFileHandler(
300
387
  directory / filename,
301
388
  maxBytes=LOG_MAX_BYTES,
302
389
  backupCount=LOG_BACKUP_COUNT,
303
390
  encoding="utf-8",
304
391
  )
305
- except OSError:
392
+ except OSError as caught:
393
+ print(json.dumps({
394
+ "level": "warning", "event": "log_file_unavailable",
395
+ "message": str(caught), "attempts": failures,
396
+ }, ensure_ascii=False), file=sys.stderr, flush=True)
306
397
  return logger, directory
307
- handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
398
+ formatter = logging.Formatter("%(asctime)s.%(msecs)03dZ %(levelname)s pid=%(process)d %(message)s", "%Y-%m-%dT%H:%M:%S")
399
+ formatter.converter = time.gmtime
400
+ handler.setFormatter(formatter)
308
401
  logger.addHandler(handler)
402
+ for failure in failures:
403
+ logger.warning("log_directory_rejected %s", failure)
309
404
  return logger, directory
310
405
 
311
406
 
@@ -373,13 +468,15 @@ def write_vendor_cache(cache_dir: Path, files: dict[str, bytes]) -> None:
373
468
 
374
469
 
375
470
  def ensure_vendor_assets(cache_dir: Path) -> dict[str, Path]:
471
+ logger = get_logger()
376
472
  paths = {name: cache_dir / name for name in VENDOR_ASSETS}
377
473
  try:
378
474
  for name, path in paths.items():
379
475
  verify_vendor_content(name, path.read_bytes())
476
+ logger.info("vendor_cache_ready grapesjs=%s", GRAPESJS_VERSION)
380
477
  return paths
381
- except (OSError, ValueError):
382
- pass
478
+ except (OSError, ValueError) as caught:
479
+ logger.info("vendor_cache_miss grapesjs=%s reason=%s", GRAPESJS_VERSION, caught)
383
480
 
384
481
  failures: list[str] = []
385
482
  sources = [
@@ -387,12 +484,23 @@ def ensure_vendor_assets(cache_dir: Path) -> dict[str, Path]:
387
484
  *((name, url, vendor_from_cdn) for name, url in VENDOR_CDNS),
388
485
  ]
389
486
  for source_name, url, loader in sources:
487
+ started = time.monotonic()
390
488
  try:
489
+ logger.info("vendor_download_start source=%s", source_name)
391
490
  files = loader(url)
392
- write_vendor_cache(cache_dir, files)
393
- return paths
394
491
  except (OSError, ValueError, tarfile.TarError, urllib.error.URLError) as caught:
395
492
  failures.append(f"{source_name}: {caught}")
493
+ logger.warning("vendor_download_failed source=%s elapsed_ms=%.0f reason=%s",
494
+ source_name, (time.monotonic() - started) * 1000, caught)
495
+ continue
496
+ try:
497
+ write_vendor_cache(cache_dir, files)
498
+ except (OSError, ValueError) as caught:
499
+ failures.append(f"{source_name} cache write: {caught}")
500
+ logger.warning("vendor_cache_write_failed source=%s reason=%s", source_name, caught)
501
+ continue
502
+ logger.info("vendor_download_ready source=%s elapsed_ms=%.0f", source_name, (time.monotonic() - started) * 1000)
503
+ return paths
396
504
  raise WorkbenchError(
397
505
  "VENDOR_DOWNLOAD_FAILED",
398
506
  "首次启动需要下载约 1.2 MB 的 GrapesJS,但所有下载源均不可用。请检查网络后重试。",
@@ -1399,6 +1507,7 @@ class WorkbenchServer(ThreadingHTTPServer):
1399
1507
  vendor_files: dict[str, Path],
1400
1508
  log_dir: Path | None = None,
1401
1509
  vendor_cache: Path | None = None,
1510
+ log_file: Path | None = None,
1402
1511
  ) -> None:
1403
1512
  self.asset_file = asset_file.resolve(strict=True)
1404
1513
  self.editor_root = editor_root.resolve(strict=True)
@@ -1409,6 +1518,7 @@ class WorkbenchServer(ThreadingHTTPServer):
1409
1518
  # startup output to read them from.
1410
1519
  self.log_dir = Path(log_dir) if log_dir is not None else None
1411
1520
  self.vendor_cache = Path(vendor_cache) if vendor_cache is not None else None
1521
+ self.log_file = log_file
1412
1522
  super().__init__(address, WorkbenchHandler)
1413
1523
 
1414
1524
 
@@ -1416,10 +1526,42 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
1416
1526
  server: WorkbenchServer
1417
1527
  protocol_version = "HTTP/1.1"
1418
1528
 
1419
- def log_message(self, format_string: str, *args: Any) -> None:
1420
- message = format_string % args
1421
- get_logger().info("%s %s", self.log_date_time_string(), message)
1422
- sys.stderr.write(f"{self.log_date_time_string()} {message}\n")
1529
+ def handle_one_request(self) -> None:
1530
+ self.request_id = secrets.token_hex(6)
1531
+ self.request_started = time.monotonic()
1532
+ self.error_code = None
1533
+ self.file_ref = "-"
1534
+ super().handle_one_request()
1535
+
1536
+ def parse_request(self) -> bool:
1537
+ # The connection may sit idle before its next request line arrives.
1538
+ self.request_started = time.monotonic()
1539
+ return super().parse_request()
1540
+
1541
+ def log_request(self, code: int | str = "-", size: int | str = "-") -> None:
1542
+ route = getattr(self, "path", "").partition("?")[0]
1543
+ if route == "/api/health" and code == 200:
1544
+ return
1545
+ if route.startswith("/assets/"):
1546
+ route = "/assets/*"
1547
+ elif route not in {"/", "/api/health", "/api/document", "/api/events", "/api/context", "/api/anchor", "/api/shutdown"}:
1548
+ route = route if route in self.server.vendor_routes else "other"
1549
+ status = int(code) if str(code).isdigit() else 0
1550
+ level = logging.ERROR if status >= 500 else logging.WARNING if status >= 400 else logging.INFO
1551
+ method = self.command if self.command in {"GET", "PUT", "POST", "HEAD", "OPTIONS"} else "other"
1552
+ get_logger().log(level, "http_request id=%s method=%s route=%s status=%s elapsed_ms=%.1f code=%s file_ref=%s",
1553
+ self.request_id, method, route, code,
1554
+ (time.monotonic() - self.request_started) * 1000, self.error_code or "-", self.file_ref)
1555
+
1556
+ def log_error(self, format_string: str, *args: Any) -> None:
1557
+ # Base HTTP errors also pass through log_request. Skip the raw message,
1558
+ # which can contain user-controlled request text.
1559
+ if format_string.startswith("Request timed out"):
1560
+ get_logger().warning("http_timeout id=%s", self.request_id)
1561
+
1562
+ def end_headers(self) -> None:
1563
+ self.send_header("X-Workbench-Request-Id", self.request_id)
1564
+ super().end_headers()
1423
1565
 
1424
1566
  def send_bytes(self, status: int, content: bytes, content_type: str, cache: str = "no-store") -> None:
1425
1567
  self.send_response(status)
@@ -1436,29 +1578,38 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
1436
1578
  self.wfile.write(content)
1437
1579
 
1438
1580
  def send_json(self, status: int, payload: dict[str, Any]) -> None:
1581
+ if status >= 400:
1582
+ self.error_code = payload.get("error")
1439
1583
  content = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
1440
1584
  self.send_bytes(status, content, "application/json; charset=utf-8")
1441
1585
 
1442
1586
  def send_error_payload(self, caught: Exception) -> None:
1443
1587
  if isinstance(caught, WorkbenchError):
1444
- get_logger().warning("request failed: %s (%s)", caught.code, caught)
1588
+ get_logger().warning("request_failed id=%s code=%s reason=%s", self.request_id, caught.code, caught)
1445
1589
  self.send_json(caught.status, {"error": caught.code, "message": str(caught), **caught.extra})
1446
1590
  else:
1447
- get_logger().exception("unhandled request error")
1591
+ get_logger().exception("request_failed id=%s code=SERVER_ERROR", self.request_id)
1448
1592
  self.send_json(500, {"error": "SERVER_ERROR", "message": str(caught)})
1449
1593
 
1450
1594
  def query_file(self, query: dict[str, list[str]]) -> Path:
1451
- return resolve_html_file(query.get("file", [""])[0])
1595
+ raw = query.get("file", [""])[0]
1596
+ self.file_ref = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
1597
+ return resolve_html_file(raw)
1452
1598
 
1453
1599
  def do_GET(self) -> None:
1454
- parsed = urllib.parse.urlsplit(self.path)
1455
- query = urllib.parse.parse_qs(parsed.query)
1456
1600
  try:
1601
+ parsed = urllib.parse.urlsplit(self.path)
1602
+ query = urllib.parse.parse_qs(parsed.query)
1457
1603
  if parsed.path == "/api/health":
1458
1604
  payload: dict[str, Any] = {
1459
1605
  "ok": True,
1460
1606
  "service": SERVICE_NAME,
1461
1607
  "version": SERVICE_VERSION,
1608
+ "pythonVersion": ".".join(map(str, sys.version_info[:3])),
1609
+ "pythonExecutable": sys.executable,
1610
+ "platform": sys.platform,
1611
+ "pid": os.getpid(),
1612
+ "logging": logging_status(),
1462
1613
  "port": self.server.server_port,
1463
1614
  "editorRoot": str(self.server.editor_root),
1464
1615
  "capabilities": list(SERVICE_CAPABILITIES),
@@ -1469,6 +1620,7 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
1469
1620
  # be exactly what the sandbox refused.
1470
1621
  if self.server.log_dir is not None:
1471
1622
  payload["logDir"] = str(self.server.log_dir)
1623
+ payload["logFile"] = str(self.server.log_file) if self.server.log_file is not None else None
1472
1624
  if self.server.vendor_cache is not None:
1473
1625
  payload["vendorCache"] = str(self.server.vendor_cache)
1474
1626
  self.send_json(200, payload)
@@ -1494,23 +1646,25 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
1494
1646
  self.send_error_payload(caught)
1495
1647
 
1496
1648
  def do_PUT(self) -> None:
1497
- parsed = urllib.parse.urlsplit(self.path)
1498
- if parsed.path != "/api/document":
1499
- self.send_json(404, {"error": "NOT_FOUND", "message": "接口不存在。"})
1500
- return
1501
1649
  try:
1650
+ parsed = urllib.parse.urlsplit(self.path)
1651
+ if parsed.path != "/api/document":
1652
+ self.send_json(404, {"error": "NOT_FOUND", "message": "接口不存在。"})
1653
+ return
1502
1654
  body = self.read_json_body()
1503
1655
  query = urllib.parse.parse_qs(parsed.query)
1504
1656
  self.send_json(200, save_document(self.query_file(query), body))
1657
+ except (BrokenPipeError, ConnectionResetError):
1658
+ return
1505
1659
  except json.JSONDecodeError as caught:
1506
1660
  self.send_error_payload(WorkbenchError("INVALID_SAVE", f"保存请求不是有效 JSON:{caught}", 400))
1507
1661
  except Exception as caught:
1508
1662
  self.send_error_payload(caught)
1509
1663
 
1510
1664
  def do_POST(self) -> None:
1511
- parsed = urllib.parse.urlsplit(self.path)
1512
- query = urllib.parse.parse_qs(parsed.query)
1513
1665
  try:
1666
+ parsed = urllib.parse.urlsplit(self.path)
1667
+ query = urllib.parse.parse_qs(parsed.query)
1514
1668
  if parsed.path == "/api/shutdown":
1515
1669
  # Lets a newer `open` retire this process cleanly. The service
1516
1670
  # holds no state of its own, so exiting is always safe.
@@ -1533,6 +1687,8 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
1533
1687
  self.send_json(200, promote_identities(target, selections, str(body.get("baseRevision") or "")))
1534
1688
  return
1535
1689
  self.send_json(200, build_selection_context(target, selections))
1690
+ except (BrokenPipeError, ConnectionResetError):
1691
+ return
1536
1692
  except json.JSONDecodeError as caught:
1537
1693
  self.send_error_payload(WorkbenchError("INVALID_ANCHOR", f"请求不是有效 JSON:{caught}", 400))
1538
1694
  except Exception as caught:
@@ -1565,15 +1721,20 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
1565
1721
  self.send_header("Connection", "keep-alive")
1566
1722
  self.end_headers()
1567
1723
  last_revision = ""
1724
+ missing = False
1568
1725
  while True:
1569
1726
  try:
1570
1727
  document = read_document(target)
1728
+ missing = False
1571
1729
  if document["revision"] != last_revision:
1572
1730
  last_revision = document["revision"]
1573
1731
  payload = json.dumps({"revision": last_revision, "mtimeMs": document["mtimeMs"]}, separators=(",", ":"))
1574
1732
  self.wfile.write(f"data: {payload}\n\n".encode("utf-8"))
1575
1733
  self.wfile.flush()
1576
1734
  except FileNotFoundError:
1735
+ if not missing:
1736
+ get_logger().warning("file_watch_missing id=%s code=FILE_NOT_FOUND", self.request_id)
1737
+ missing = True
1577
1738
  self.wfile.write(b'event: error\ndata: {"error":"FILE_NOT_FOUND"}\n\n')
1578
1739
  self.wfile.flush()
1579
1740
  time.sleep(0.5)
@@ -1586,12 +1747,19 @@ def default_asset_file(script_file: Path) -> Path:
1586
1747
  raise WorkbenchError("ASSET_NOT_FOUND", f"找不到前端资源:{packaged}", 500)
1587
1748
 
1588
1749
 
1589
- def health(port: int, timeout: float = 0.5) -> dict[str, Any] | None:
1750
+ def health(port: int, timeout: float = 0.5, failures: list[str] | None = None) -> dict[str, Any] | None:
1590
1751
  try:
1591
1752
  with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=timeout) as response:
1592
- payload = json.loads(response.read().decode("utf-8"))
1593
- return payload if payload.get("service") == SERVICE_NAME else None
1594
- except (OSError, ValueError, urllib.error.URLError):
1753
+ content = response.read(64 * 1024 + 1)
1754
+ if len(content) > 64 * 1024:
1755
+ raise ValueError("health response exceeds 64 KiB")
1756
+ payload = json.loads(content.decode("utf-8"))
1757
+ if not isinstance(payload, dict) or payload.get("service") != SERVICE_NAME:
1758
+ raise ValueError("port responded with a different service identity")
1759
+ return payload
1760
+ except (OSError, ValueError, urllib.error.URLError) as caught:
1761
+ if failures is not None:
1762
+ failures.append(type(caught).__name__ + ": " + str(caught)[:1000])
1595
1763
  return None
1596
1764
 
1597
1765
 
@@ -1709,7 +1877,7 @@ def start_detached(script_file: Path, port: int, editor_root: Path, asset_file:
1709
1877
  """
1710
1878
  directory = pick_writable_dir(*log_candidates(log_dir))
1711
1879
  log_file: Path | None = directory / f"html-workbench-{port}.log"
1712
- command = [sys.executable, str(script_file), "serve", "--port", str(port), "--editor-root", str(editor_root), "--vendor-cache", str(vendor_cache), "--log-dir", str(directory)]
1880
+ command = [sys.executable, str(script_file), "serve", "--bounded-output", "--port", str(port), "--editor-root", str(editor_root), "--vendor-cache", str(vendor_cache), "--log-dir", str(directory)]
1713
1881
  if asset_file is not None:
1714
1882
  command.extend(["--asset", str(asset_file)])
1715
1883
  flags = 0
@@ -1719,7 +1887,14 @@ def start_detached(script_file: Path, port: int, editor_root: Path, asset_file:
1719
1887
  kwargs["creationflags"] = flags
1720
1888
  kwargs.pop("start_new_session", None)
1721
1889
  try:
1722
- log = log_file.open("ab")
1890
+ if log_file.is_file():
1891
+ # Migrate historical unbounded output using a bounded tail read.
1892
+ with log_file.open("rb") as previous:
1893
+ previous.seek(max(0, os.fstat(previous.fileno()).st_size - BOOTSTRAP_MAX_BYTES))
1894
+ # A tail offset can bisect a multibyte character in old logs.
1895
+ tail = previous.read(BOOTSTRAP_MAX_BYTES).decode("utf-8", errors="replace")
1896
+ log_file.with_suffix(".log.1").write_bytes(bounded_utf8(tail, BOOTSTRAP_MAX_BYTES))
1897
+ log = log_file.open("wb")
1723
1898
  except OSError:
1724
1899
  log_file = None
1725
1900
  log = None
@@ -1739,14 +1914,20 @@ def start_detached(script_file: Path, port: int, editor_root: Path, asset_file:
1739
1914
 
1740
1915
  def command_serve(args: argparse.Namespace, script_file: Path) -> int:
1741
1916
  logger, log_directory = setup_logging(args.log_dir, args.port)
1917
+ log_file = service_log_file(logger)
1918
+ logger.info("service_starting version=%s python=%s platform=%s port=%s executable=%s cwd=%s",
1919
+ SERVICE_VERSION, sys.version.split()[0], sys.platform, args.port, sys.executable, Path.cwd())
1742
1920
  asset_file = Path(args.asset).expanduser().resolve(strict=True) if args.asset else default_asset_file(script_file)
1743
1921
  editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
1744
1922
  # Logs and cache are both disposable, so they follow the writable location the
1745
1923
  # sandbox really granted instead of the path the parent requested.
1746
- vendor_cache = pick_writable_dir(*vendor_candidates(args.vendor_cache, log_directory))
1924
+ failures: list[str] = []
1925
+ vendor_cache = pick_writable_dir(*vendor_candidates(args.vendor_cache, log_directory), failures=failures)
1926
+ for failure in failures:
1927
+ logger.warning("vendor_directory_rejected %s", failure)
1747
1928
  vendor_files = ensure_vendor_assets(vendor_cache)
1748
- server = WorkbenchServer(("127.0.0.1", args.port), asset_file, editor_root, vendor_files, log_directory, vendor_cache)
1749
- logger.info("service started: port=%s editorRoot=%s logDir=%s vendorCache=%s", args.port, editor_root, log_directory, vendor_cache)
1929
+ server = WorkbenchServer(("127.0.0.1", args.port), asset_file, editor_root, vendor_files, log_directory, vendor_cache, log_file)
1930
+ logger.info("service_ready port=%s editorRoot=%s logFile=%s vendorCache=%s", args.port, editor_root, log_file, vendor_cache)
1750
1931
  # The host surfaces these two paths in its diagnostics, so the panel shows the
1751
1932
  # directories actually in use rather than echoing the ones it asked for — the
1752
1933
  # requested ones may be exactly what the sandbox refused.
@@ -1755,6 +1936,7 @@ def command_serve(args: argparse.Namespace, script_file: Path) -> int:
1755
1936
  "url": f"http://127.0.0.1:{args.port}",
1756
1937
  "editorRoot": str(editor_root),
1757
1938
  "logDir": str(log_directory),
1939
+ "logFile": str(log_file) if log_file is not None else None,
1758
1940
  "vendorCache": str(vendor_cache),
1759
1941
  }, ensure_ascii=False), flush=True)
1760
1942
  try:
@@ -1763,7 +1945,7 @@ def command_serve(args: argparse.Namespace, script_file: Path) -> int:
1763
1945
  pass
1764
1946
  finally:
1765
1947
  server.server_close()
1766
- logger.info("service stopped: port=%s", args.port)
1948
+ logger.info("service_stopped port=%s", args.port)
1767
1949
  return 0
1768
1950
 
1769
1951
 
@@ -1817,6 +1999,7 @@ def build_parser() -> argparse.ArgumentParser:
1817
1999
  serve.add_argument("--asset", help="override the bundled workbench.html path")
1818
2000
  serve.add_argument("--vendor-cache", default=str(default_vendor_cache()), help="directory for verified GrapesJS files")
1819
2001
  serve.add_argument("--log-dir", default=str(default_log_dir()), help="directory for rotating service logs")
2002
+ serve.add_argument("--bounded-output", action="store_true", help=argparse.SUPPRESS)
1820
2003
 
1821
2004
  open_command = subparsers.add_parser("open", help="start or reuse the service and print an editor URL")
1822
2005
  open_command.add_argument("file")
@@ -1851,11 +2034,48 @@ def command_stop(args: argparse.Namespace) -> int:
1851
2034
  raise WorkbenchError("SERVER_OUTDATED", f"端口 {args.port} 上的进程无法停止,请手动结束后重试。", 500)
1852
2035
 
1853
2036
 
2037
+ def service_log_file(logger: logging.Logger) -> Path | None:
2038
+ for handler in logger.handlers:
2039
+ if isinstance(handler, RotatingFileHandler):
2040
+ return Path(handler.baseFilename)
2041
+ return None
2042
+
2043
+
2044
+ def logging_status() -> dict[str, Any]:
2045
+ for handler in get_logger().handlers:
2046
+ if isinstance(handler, BoundedRotatingFileHandler):
2047
+ return {
2048
+ "file": handler.baseFilename, "error": handler.last_error,
2049
+ "droppedRecords": handler.dropped_records,
2050
+ "maxBytes": LOG_MAX_BYTES, "backups": LOG_BACKUP_COUNT,
2051
+ "recordMaxBytes": LOG_RECORD_MAX_BYTES,
2052
+ }
2053
+ return {"file": None, "error": "file logging unavailable"}
2054
+
2055
+
2056
+ def report_cli_error(command: str, code: str, caught: Exception, **details: Any) -> int:
2057
+ payload = {"ok": False, "error": code, "message": str(caught), **details}
2058
+ if command == "serve":
2059
+ logger = get_logger()
2060
+ log_file = service_log_file(logger)
2061
+ payload["logFile"] = str(log_file) if log_file is not None else None
2062
+ logger.exception("service_failed code=%s", code)
2063
+ if log_file is None or logging_status().get("error"):
2064
+ print(bounded_utf8(traceback.format_exc(), LOG_RECORD_MAX_BYTES).decode("utf-8"), file=sys.stderr)
2065
+ print(json.dumps(payload, ensure_ascii=False), file=sys.stderr, flush=True)
2066
+ return 1
2067
+
2068
+
1854
2069
  def main() -> int:
2070
+ for stream in (sys.stdout, sys.stderr):
2071
+ if hasattr(stream, "reconfigure"):
2072
+ stream.reconfigure(encoding="utf-8", errors="backslashreplace")
1855
2073
  if sys.version_info < (3, 9):
1856
2074
  print(json.dumps({"ok": False, "error": "PYTHON_TOO_OLD", "message": "需要 Python 3.9 或更高版本。"}, ensure_ascii=False), file=sys.stderr)
1857
2075
  return 2
1858
2076
  args = build_parser().parse_args()
2077
+ if getattr(args, "bounded_output", False):
2078
+ sys.stdout = sys.stderr = BoundedOutput(sys.stdout.buffer)
1859
2079
  script_file = Path(__file__).resolve()
1860
2080
  try:
1861
2081
  if args.command == "serve":
@@ -1864,19 +2084,21 @@ def main() -> int:
1864
2084
  return command_open(args, script_file)
1865
2085
  if args.command == "stop":
1866
2086
  return command_stop(args)
1867
- payload = health(args.port)
1868
- print(json.dumps(payload or {"ok": False, "error": "SERVER_UNAVAILABLE"}, ensure_ascii=False))
2087
+ failures: list[str] = []
2088
+ payload = health(args.port, failures=failures)
2089
+ print(json.dumps(payload or {"ok": False, "error": "SERVER_UNAVAILABLE", "detail": failures}, ensure_ascii=False))
1869
2090
  return 0 if payload else 1
1870
2091
  except WorkbenchError as caught:
1871
- print(json.dumps({"ok": False, "error": caught.code, "message": str(caught)}, ensure_ascii=False), file=sys.stderr)
1872
- return 1
2092
+ return report_cli_error(args.command, caught.code, caught, **caught.extra)
1873
2093
  except FileNotFoundError as caught:
1874
- print(json.dumps({"ok": False, "error": "FILE_NOT_FOUND", "message": str(caught)}, ensure_ascii=False), file=sys.stderr)
1875
- return 1
2094
+ return report_cli_error(args.command, "FILE_NOT_FOUND", caught)
1876
2095
  except OSError as caught:
1877
2096
  code = "PORT_IN_USE" if getattr(caught, "errno", None) in {48, 98, 10048} else "SERVER_UNAVAILABLE"
1878
- print(json.dumps({"ok": False, "error": code, "message": str(caught)}, ensure_ascii=False), file=sys.stderr)
1879
- return 1
2097
+ return report_cli_error(args.command, code, caught)
2098
+ except Exception as caught:
2099
+ if args.command != "serve":
2100
+ raise
2101
+ return report_cli_error(args.command, "SERVER_ERROR", caught)
1880
2102
 
1881
2103
 
1882
2104
  if __name__ == "__main__":