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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import base64
8
+ import bisect
8
9
  import hashlib
9
10
  import html
10
11
  import io
@@ -13,10 +14,12 @@ import logging
13
14
  import mimetypes
14
15
  import os
15
16
  import re
17
+ import signal
16
18
  import subprocess
17
19
  import sys
18
20
  import tarfile
19
21
  import tempfile
22
+ import threading
20
23
  import time
21
24
  import urllib.error
22
25
  import urllib.parse
@@ -31,10 +34,44 @@ from typing import Any
31
34
 
32
35
 
33
36
  SERVICE_NAME = "html-workbench"
34
- SERVICE_VERSION = "2.0.0"
37
+ SERVICE_VERSION = "2.2.0"
38
+
35
39
  DEFAULT_PORT = 4317
40
+ # Capability names advertised by /api/health. `command_open` refuses to reuse a
41
+ # service that lacks any of these: a long-lived process keeps running the code it
42
+ # was started with, while the HTML/JS assets are re-read from disk on every
43
+ # request. Reusing a stale process therefore serves a NEW frontend against an OLD
44
+ # API, and the missing route answers 501 with an HTML error page — which the
45
+ # frontend then fails to parse as JSON. Version-gating the reuse turns that
46
+ # confusing symptom into a silent, automatic restart.
47
+ SERVICE_CAPABILITIES = (
48
+ "grapesjs-canvas",
49
+ "autosave",
50
+ "file-query",
51
+ "file-watch",
52
+ "stdlib-python",
53
+ "visual-context",
54
+ )
36
55
  MAX_REQUEST_BYTES = 8 * 1024 * 1024
37
56
  SCRIPT_PATTERN = re.compile(r"^<script\b[\s\S]*</script\s*>$", re.IGNORECASE)
57
+
58
+ # ── Visual selection context ────────────────────────────────────────────────
59
+ # Anchors are resolved against the on-disk source, never against the GrapesJS
60
+ # canvas DOM: the agent edits the file, so every coordinate it receives must be
61
+ # a real offset in that file. Identity attributes are ordered by how durable
62
+ # they are across an agent rewrite. `data-wb-id` comes FIRST because we mint it
63
+ # ourselves and it is human-readable ("card", "pricing-pro"); a page's `id` may
64
+ # instead be a random token GrapesJS generated for its own style rules, which
65
+ # tells the agent nothing about what the user picked.
66
+ IDENTITY_ATTRIBUTES = ("data-wb-id", "id", "data-mode")
67
+ # Wrappers that carry no meaning on their own; a rectangle hit that lands on
68
+ # one of these is usually the user pointing at its parent.
69
+ TRANSPARENT_TAGS = {"span", "em", "strong", "b", "i", "u", "small", "svg", "path", "g", "circle", "rect", "use", "defs", "line", "polyline", "polygon", "tspan", "br"}
70
+ MAX_CONTEXT_SELECTIONS = 12
71
+ MAX_SNIPPET_CHARS = 1600
72
+ MAX_CONTEXT_BYTES = 24_000
73
+ WB_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
74
+
38
75
  GRAPESJS_VERSION = "0.23.4"
39
76
  VENDOR_ASSETS = {
40
77
  "grapes.min.js": {
@@ -89,6 +126,135 @@ def default_log_dir() -> Path:
89
126
  return base / "html-workbench" / "logs"
90
127
 
91
128
 
129
+ # Name of the runtime-state folder this process keeps beside its working
130
+ # directory. The DSH plugin resolves the same name host-side, so both halves
131
+ # agree on one location instead of scattering `.html-workbench` and
132
+ # `html-workbench-dsh` through the user's workspace.
133
+ RUNTIME_DIR_NAME = ".html-workbench"
134
+
135
+
136
+ def runtime_directory() -> Path:
137
+ """Return this process's runtime-state root: `<cwd>/<RUNTIME_DIR_NAME>`.
138
+
139
+ Under DSH the working directory is the session workspace, which is one of the
140
+ only two trees the subprocess sandbox grants write access to — the other is a
141
+ private temp directory it hands the child through TEMP/TMP. Everywhere else
142
+ the working directory is simply a sensible, self-cleaning location. Logs and
143
+ the GrapesJS cache are disposable, so either is acceptable; what is NOT
144
+ acceptable is a path the sandbox refuses (see `pick_writable_dir`).
145
+ """
146
+ return Path.cwd() / RUNTIME_DIR_NAME
147
+
148
+
149
+ def make_temp_file(directory: Path, prefix: str, suffix: str = "") -> tuple[int, Path]:
150
+ """Create a uniquely named temporary file in `directory`; return (fd, path).
151
+
152
+ Deliberately NOT `tempfile.mkstemp`. On Windows CPython's `_mkstemp_inner`
153
+ treats a `PermissionError` as "that name is already taken": it re-checks the
154
+ directory with `os.access(dir, os.W_OK)` and then retries with a NEW random
155
+ name, up to TMP_MAX (10 000) times. A real DACL always looks writable to
156
+ `os.access`, which cannot see the restricted token DSH's sandbox runs the
157
+ child under — so a directory the sandbox refuses makes `mkstemp` spin for
158
+ minutes (one denied `os.open` per iteration, ~4 ms each) instead of failing
159
+ in milliseconds. A single O_EXCL open turns that into an immediate error the
160
+ caller can catch and recover from.
161
+ """
162
+ for attempt in range(64):
163
+ candidate = Path(directory) / f"{prefix}{os.getpid()}-{attempt}-{os.urandom(6).hex()}{suffix}"
164
+ try:
165
+ descriptor = os.open(
166
+ candidate,
167
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0),
168
+ 0o600,
169
+ )
170
+ except FileExistsError:
171
+ continue
172
+ return descriptor, candidate
173
+ raise FileExistsError(f"无法在 {directory} 创建临时文件:连续的文件名冲突")
174
+
175
+
176
+ def self_ignore(directory: Path) -> None:
177
+ """Make a runtime directory invisible to git, best effort.
178
+
179
+ The runtime root usually lands inside the session workspace, which for most
180
+ users is a repository: without this, every start dirties `git status` with a
181
+ folder holding nothing but logs and a re-downloadable cache, and the noise
182
+ lands in the user's own project rather than ours. A `.gitignore` containing
183
+ `*` excludes the tree including that file, so nothing has to be added to the
184
+ repository's own ignore rules.
185
+
186
+ Only the runtime root is marked — never an arbitrary directory the caller
187
+ happened to pass, which may be the user's source tree (`--log-dir .`).
188
+ """
189
+ root = next((parent for parent in (directory, *directory.parents) if parent.name == RUNTIME_DIR_NAME), None)
190
+ if root is None:
191
+ return
192
+ marker = root / ".gitignore"
193
+ if marker.exists():
194
+ return
195
+ try:
196
+ marker.write_text("# Created by html-workbench: disposable logs and cache.\n*\n", encoding="utf-8")
197
+ except OSError:
198
+ pass
199
+
200
+
201
+ def pick_writable_dir(*candidates: Path) -> Path:
202
+ """Return the first candidate directory this process can really write into.
203
+
204
+ Existence proves nothing: the sandbox DACL looks writable on paper while the
205
+ restricted token is denied, and a folder the unconfined parent created may
206
+ reject every file creation here with EACCES / WinError 5. The only reliable
207
+ test is an actual create, which `make_temp_file` performs and fails fast.
208
+ Falls back to the first candidate so a caller with nothing writable at all
209
+ still gets a deterministic path (and a clear error at the real write).
210
+ """
211
+ fallback: Path | None = None
212
+ for candidate in candidates:
213
+ if candidate is None:
214
+ continue
215
+ if fallback is None:
216
+ fallback = candidate
217
+ try:
218
+ candidate.mkdir(parents=True, exist_ok=True)
219
+ except OSError:
220
+ continue
221
+ try:
222
+ descriptor, probe = make_temp_file(candidate, prefix=".wb-write-probe.")
223
+ except OSError:
224
+ continue
225
+ os.close(descriptor)
226
+ try:
227
+ probe.unlink()
228
+ except OSError:
229
+ pass
230
+ self_ignore(candidate)
231
+ return candidate
232
+ return fallback if fallback is not None else Path.cwd()
233
+
234
+
235
+ def log_candidates(log_dir: Path | str) -> tuple[Path, ...]:
236
+ """Log directories to try, best first: the caller's, then writable fallbacks."""
237
+ return (
238
+ Path(log_dir).expanduser(),
239
+ runtime_directory() / "logs",
240
+ Path(tempfile.gettempdir()) / RUNTIME_DIR_NAME / "logs",
241
+ )
242
+
243
+
244
+ def vendor_candidates(vendor_cache: Path | str, log_dir: Path) -> tuple[Path, ...]:
245
+ """Vendor-cache directories to try, best first.
246
+
247
+ The cache sits beside whichever directory accepted the log: both are
248
+ re-creatable, and the log's parent already proved it is writable.
249
+ """
250
+ return (
251
+ Path(vendor_cache).expanduser(),
252
+ Path(log_dir).parent / "vendor",
253
+ runtime_directory() / "vendor",
254
+ Path(tempfile.gettempdir()) / RUNTIME_DIR_NAME / "vendor",
255
+ )
256
+
257
+
92
258
  _logger: logging.Logger | None = None
93
259
 
94
260
 
@@ -107,27 +273,40 @@ def get_logger() -> logging.Logger:
107
273
  return _logger
108
274
 
109
275
 
110
- def setup_logging(log_dir: Path | str, port: int | None = None) -> logging.Logger:
111
- """Attach the rotating file handler and return the module logger."""
276
+ def setup_logging(log_dir: Path | str, port: int | None = None) -> tuple[logging.Logger, Path]:
277
+ """Attach the rotating file handler; return the logger and the directory used.
278
+
279
+ The requested `--log-dir` can be unusable even when it already exists — the
280
+ sandbox denies writes to a folder whose DACL looks writable (see
281
+ `pick_writable_dir`). The directory is therefore probed and handed back to
282
+ the caller, so the host can report where the log really is instead of echoing
283
+ the path it merely asked for. A missing service log is never worth failing
284
+ the whole start: when EVERY candidate refuses writes, `pick_writable_dir`
285
+ returns a deterministic path that the handler cannot open, so the failure is
286
+ swallowed and the service runs without a file log rather than never binding
287
+ its port.
288
+ """
112
289
  global _logger
113
- directory = Path(log_dir).expanduser()
114
- directory.mkdir(parents=True, exist_ok=True)
290
+ directory = pick_writable_dir(*log_candidates(log_dir))
115
291
  filename = f"workbench-{port}.log" if port is not None else "workbench.log"
116
- handler = RotatingFileHandler(
117
- directory / filename,
118
- maxBytes=LOG_MAX_BYTES,
119
- backupCount=LOG_BACKUP_COUNT,
120
- encoding="utf-8",
121
- )
122
- handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
123
292
  logger = get_logger()
124
293
  for existing in list(logger.handlers):
125
294
  if isinstance(existing, RotatingFileHandler):
126
295
  existing.close()
127
296
  logger.removeHandler(existing)
128
- logger.addHandler(handler)
129
297
  logger.setLevel(logging.INFO)
130
- return logger
298
+ try:
299
+ handler = RotatingFileHandler(
300
+ directory / filename,
301
+ maxBytes=LOG_MAX_BYTES,
302
+ backupCount=LOG_BACKUP_COUNT,
303
+ encoding="utf-8",
304
+ )
305
+ except OSError:
306
+ return logger, directory
307
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
308
+ logger.addHandler(handler)
309
+ return logger, directory
131
310
 
132
311
 
133
312
  def sha256_bytes(content: bytes) -> str:
@@ -181,7 +360,7 @@ def vendor_from_cdn(base_url: str) -> dict[str, bytes]:
181
360
  def write_vendor_cache(cache_dir: Path, files: dict[str, bytes]) -> None:
182
361
  cache_dir.mkdir(parents=True, exist_ok=True)
183
362
  for name, content in files.items():
184
- descriptor, temporary_name = tempfile.mkstemp(prefix=f".{name}.", dir=cache_dir)
363
+ descriptor, temporary_name = make_temp_file(cache_dir, prefix=f".{name}.")
185
364
  try:
186
365
  with os.fdopen(descriptor, "wb") as output:
187
366
  output.write(content)
@@ -465,10 +644,657 @@ def revision_of(content: str) -> str:
465
644
  return hashlib.sha256(content.encode("utf-8")).hexdigest()
466
645
 
467
646
 
647
+ # ── Visual selection anchors ────────────────────────────────────────────────
648
+ #
649
+ # The agent edits the file on disk, so a selection is only useful if it can be
650
+ # translated into *source* coordinates. The canvas DOM is a GrapesJS runtime
651
+ # model and must never be treated as the authority here: the browser reports
652
+ # WHICH node the user picked, and this module re-parses the on-disk source to
653
+ # decide WHERE that node lives.
654
+ #
655
+ # Resolution is deliberately conservative. An anchor is only accepted when it
656
+ # maps to exactly one source node; zero and multiple matches are both refused
657
+ # so an ambiguous anchor can never reach the agent.
658
+
659
+
660
+ def source_body(parser: SourceHtmlParser) -> HtmlNode | None:
661
+ return next((node for node in parser.nodes if node.tag == "body"), None)
662
+
663
+
664
+ def parse_document_tree(source: str) -> tuple[SourceHtmlParser, HtmlNode]:
665
+ parser = SourceHtmlParser(source)
666
+ try:
667
+ parser.feed(source)
668
+ parser.close()
669
+ except Exception as caught:
670
+ raise WorkbenchError("INVALID_DOCUMENT", f"HTML 解析失败:{caught}", 400) from caught
671
+ body = source_body(parser)
672
+ if body is None:
673
+ raise WorkbenchError("INVALID_DOCUMENT", "目标必须是包含 body 的完整 HTML 文档。", 400)
674
+ return parser, body
675
+
676
+
677
+ def structural_children(node: HtmlNode) -> list[HtmlNode]:
678
+ """Element children as the *editor* sees them.
679
+
680
+ `parse_source` strips body `<script>` elements before handing the document
681
+ to GrapesJS, so the canvas child indices skip them. The structural fallback
682
+ path must use the same numbering or every index after an inline script
683
+ would be off by one.
684
+ """
685
+ return [child for child in node.children if child.tag != "script"]
686
+
687
+
688
+ def node_path(node: HtmlNode, body: HtmlNode) -> list[int] | None:
689
+ path: list[int] = []
690
+ current = node
691
+ while current is not body:
692
+ parent = current.parent
693
+ if parent is None:
694
+ return None
695
+ siblings = structural_children(parent)
696
+ if current not in siblings:
697
+ return None
698
+ path.append(siblings.index(current))
699
+ current = parent
700
+ path.reverse()
701
+ return path
702
+
703
+
704
+ def node_at_path(body: HtmlNode, path: list[int]) -> HtmlNode | None:
705
+ current = body
706
+ for index in path:
707
+ siblings = structural_children(current)
708
+ if not isinstance(index, int) or index < 0 or index >= len(siblings):
709
+ return None
710
+ current = siblings[index]
711
+ return current if current is not body else None
712
+
713
+
714
+ def line_of(parser: SourceHtmlParser, offset: int) -> int:
715
+ return bisect.bisect_right(parser.line_offsets, offset)
716
+
717
+
718
+ def node_text(source: str, node: HtmlNode) -> str:
719
+ if node.end_start is None:
720
+ return ""
721
+ raw = source[node.start_end : node.end_start]
722
+ raw = re.sub(r"<script\b[\s\S]*?</script\s*>", " ", raw, flags=re.IGNORECASE)
723
+ raw = re.sub(r"<[^>]+>", " ", raw)
724
+ return re.sub(r"\s+", " ", html.unescape(raw)).strip()
725
+
726
+
727
+ def identity_of(node: HtmlNode) -> dict[str, str] | None:
728
+ # Ordering does the work: `data-wb-id` is checked before `id`, so a random
729
+ # GrapesJS token only becomes the label when nothing better exists.
730
+ for name in IDENTITY_ATTRIBUTES:
731
+ value = node.attrs.get(name)
732
+ if value:
733
+ return {"name": name, "value": value}
734
+ return None
735
+
736
+
737
+ def selector_of(node: HtmlNode) -> str:
738
+ identity = identity_of(node)
739
+ if identity is None:
740
+ classes = [item for item in node.attrs.get("class", "").split() if item]
741
+ return node.tag + "".join(f".{item}" for item in classes[:2])
742
+ if identity["name"] == "id":
743
+ return f"#{identity['value']}"
744
+ return f'[{identity["name"]}="{identity["value"]}"]'
745
+
746
+
747
+ def normalized_text(value: str) -> str:
748
+ return re.sub(r"\s+", " ", value or "").strip()
749
+
750
+
751
+ def resolve_anchor(parser: SourceHtmlParser, body: HtmlNode, descriptor: dict[str, Any]) -> HtmlNode:
752
+ """Map a browser-side selection descriptor onto exactly one source node."""
753
+ if not isinstance(descriptor, dict):
754
+ raise WorkbenchError("INVALID_ANCHOR", "选区描述必须是对象。", 400)
755
+
756
+ expected_tag = str(descriptor.get("tag") or "").lower() or None
757
+ raw_path = descriptor.get("path")
758
+ path = [item for item in raw_path if isinstance(item, int)] if isinstance(raw_path, list) else None
759
+ if isinstance(raw_path, list) and path is not None and len(path) != len(raw_path):
760
+ path = None
761
+ text_hint = normalized_text(str(descriptor.get("textHint") or ""))
762
+
763
+ identity = descriptor.get("identity")
764
+ candidates: list[HtmlNode] = []
765
+ strategy = ""
766
+
767
+ if isinstance(identity, dict) and identity.get("name") in IDENTITY_ATTRIBUTES and identity.get("value"):
768
+ name = str(identity["name"])
769
+ value = str(identity["value"])
770
+ candidates = [
771
+ node for node in parser.nodes
772
+ if node.is_inside(body) and node.attrs.get(name) == value
773
+ ]
774
+ strategy = "identity"
775
+
776
+ if len(candidates) != 1 and path is not None:
777
+ # Either the identity was absent or it was ambiguous; the structural
778
+ # path can both stand alone and disambiguate an identity collision.
779
+ located = node_at_path(body, path)
780
+ if located is not None:
781
+ if len(candidates) > 1 and located in candidates:
782
+ candidates = [located]
783
+ strategy = "identity+path"
784
+ elif not candidates:
785
+ candidates = [located]
786
+ strategy = "path"
787
+
788
+ if not candidates:
789
+ raise WorkbenchError(
790
+ "ANCHOR_NOT_FOUND",
791
+ "选中的元素在当前磁盘文件中已不存在,请重新选择。",
792
+ 409,
793
+ )
794
+ if len(candidates) > 1:
795
+ raise WorkbenchError(
796
+ "ANCHOR_AMBIGUOUS",
797
+ f"选中的元素在源文件中匹配到 {len(candidates)} 个节点,无法唯一定位。",
798
+ 409,
799
+ )
800
+
801
+ node = candidates[0]
802
+ if expected_tag and node.tag != expected_tag:
803
+ raise WorkbenchError(
804
+ "ANCHOR_NOT_FOUND",
805
+ f"定位到的节点是 <{node.tag}>,与选中的 <{expected_tag}> 不一致,请重新选择。",
806
+ 409,
807
+ )
808
+ if text_hint and strategy in {"path", "identity+path"}:
809
+ # A path is only as good as the tree it was computed against. When the
810
+ # caller supplied visible text, require it to still overlap.
811
+ #
812
+ # Compare with whitespace REMOVED, not merely collapsed. `node_text`
813
+ # replaces each tag with a space, so `<h3>专业版</h3><p>¥99</p>` becomes
814
+ # "专业版 ¥99", while the browser's `textContent` yields "专业版¥99" — the
815
+ # same content, differing only where markup used to be. Comparing those
816
+ # literally rejects every correct anchor in tightly-written HTML.
817
+ actual = collapse_for_comparison(node_text(parser.source, node))
818
+ probe = collapse_for_comparison(text_hint)[:40]
819
+ if probe and probe not in actual and actual[:40] not in probe:
820
+ raise WorkbenchError(
821
+ "ANCHOR_NOT_FOUND",
822
+ "定位到的节点内容与选中的元素不一致,请重新选择。",
823
+ 409,
824
+ )
825
+ return node
826
+
827
+
828
+ def collapse_for_comparison(value: str) -> str:
829
+ return re.sub(r"\s+", "", value or "")
830
+
831
+
832
+ def describe_anchor(parser: SourceHtmlParser, body: HtmlNode, node: HtmlNode) -> dict[str, Any]:
833
+ source = parser.source
834
+ end = node.end_end if node.end_end is not None else node.start_end
835
+ snippet = source[node.start : end]
836
+ truncated = len(snippet) > MAX_SNIPPET_CHARS
837
+ if truncated:
838
+ head = snippet[: MAX_SNIPPET_CHARS // 2].rstrip()
839
+ tail = snippet[-(MAX_SNIPPET_CHARS // 4) :].lstrip()
840
+ snippet = f"{head}\n<!-- … 省略 {len(source[node.start:end]) - len(head) - len(tail)} 字符 … -->\n{tail}"
841
+
842
+ ancestors: list[str] = []
843
+ current = node.parent
844
+ while current is not None and current is not body:
845
+ ancestors.append(selector_of(current))
846
+ current = current.parent
847
+ ancestors.reverse()
848
+
849
+ behavior = {
850
+ name: value for name, value in node.attrs.items()
851
+ if name in {"data-action", "data-target"} or name.lower().startswith("on")
852
+ }
853
+
854
+ return {
855
+ "tag": node.tag,
856
+ "selector": selector_of(node),
857
+ "identity": identity_of(node),
858
+ "path": node_path(node, body),
859
+ "lineStart": line_of(parser, node.start),
860
+ "lineEnd": line_of(parser, max(node.start, end - 1)),
861
+ "startOffset": node.start,
862
+ "endOffset": end,
863
+ "text": node_text(source, node)[:240],
864
+ "classes": [item for item in node.attrs.get("class", "").split() if item],
865
+ "attributes": {name: value for name, value in node.attrs.items() if name != "class"},
866
+ "behavior": behavior,
867
+ "ancestors": ancestors,
868
+ "snippet": snippet,
869
+ "snippetTruncated": truncated,
870
+ }
871
+
872
+
873
+ # ── Related style and behaviour evidence ────────────────────────────────────
874
+ #
875
+ # "Make this purple" only needs the element and its CSS. "Improve this
876
+ # interaction" needs the JavaScript that drives it — and `parse_source` keeps
877
+ # body scripts out of the editable model, so that code has to be recovered from
878
+ # the raw source separately.
879
+
880
+ STYLE_BLOCK_PATTERN = re.compile(r"<style\b([^>]*)>([\s\S]*?)</style\s*>", re.IGNORECASE)
881
+ SCRIPT_BLOCK_PATTERN = re.compile(r"<script\b([^>]*)>([\s\S]*?)</script\s*>", re.IGNORECASE)
882
+ AT_RULE_PATTERN = re.compile(r"@[a-zA-Z-]+")
883
+
884
+
885
+ def split_css_rules(css: str, base_offset: int) -> list[dict[str, Any]]:
886
+ """Split a stylesheet into top-level rules, keeping source offsets.
887
+
888
+ A hand-rolled brace scanner rather than a regex: nested at-rules such as
889
+ `@media` contain their own blocks, and a flat regex would cut them in half.
890
+ """
891
+ rules: list[dict[str, Any]] = []
892
+ depth = 0
893
+ start = 0
894
+ index = 0
895
+ length = len(css)
896
+ while index < length:
897
+ char = css[index]
898
+ if char == "/" and css.startswith("/*", index):
899
+ closing = css.find("*/", index + 2)
900
+ index = length if closing == -1 else closing + 2
901
+ continue
902
+ if char in {'"', "'"}:
903
+ quote = char
904
+ index += 1
905
+ while index < length and css[index] != quote:
906
+ index += 2 if css[index] == "\\" else 1
907
+ index += 1
908
+ continue
909
+ if char == "{":
910
+ if depth == 0:
911
+ prelude = css[start:index]
912
+ depth += 1
913
+ elif char == "}":
914
+ depth -= 1
915
+ if depth <= 0:
916
+ depth = 0
917
+ body = css[start : index + 1]
918
+ selector = normalized_text(re.sub(r"/\*[\s\S]*?\*/", " ", prelude))
919
+ if selector:
920
+ rules.append({
921
+ "selector": selector,
922
+ "text": body.strip(),
923
+ "start": base_offset + start,
924
+ "end": base_offset + index + 1,
925
+ })
926
+ start = index + 1
927
+ index += 1
928
+ return rules
929
+
930
+
931
+ def document_css_rules(source: str) -> list[dict[str, Any]]:
932
+ rules: list[dict[str, Any]] = []
933
+ for match in STYLE_BLOCK_PATTERN.finditer(source):
934
+ if "data-grapesjs-overrides" in match.group(1):
935
+ continue
936
+ rules.extend(split_css_rules(match.group(2), match.start(2)))
937
+ return rules
938
+
939
+
940
+ def selector_tokens(node: HtmlNode) -> set[str]:
941
+ tokens = {node.tag}
942
+ for item in node.attrs.get("class", "").split():
943
+ if item:
944
+ tokens.add(f".{item}")
945
+ identity = node.attrs.get("id")
946
+ if identity:
947
+ tokens.add(f"#{identity}")
948
+ # Attribute selectors, i.e. every identity attribute except `id`, which is
949
+ # already covered by the `#` form above.
950
+ for name in IDENTITY_ATTRIBUTES:
951
+ if name != "id" and node.attrs.get(name):
952
+ tokens.add(f"[{name}")
953
+ return tokens
954
+
955
+
956
+ def related_css(parser: SourceHtmlParser, source: str, nodes: list[HtmlNode], rules: list[dict[str, Any]]) -> list[dict[str, Any]]:
957
+ """Rules whose selector mentions a class/id/tag carried by the selection.
958
+
959
+ Deliberately a *superset* heuristic. Missing a rule would make the agent
960
+ invent CSS that already exists; including one extra rule only costs a few
961
+ tokens. Bare tag names are ignored unless the element has no class or id,
962
+ because `p { }` matches almost everything.
963
+ """
964
+ wanted: set[str] = set()
965
+ tag_only: set[str] = set()
966
+ for node in nodes:
967
+ tokens = selector_tokens(node)
968
+ specific = {token for token in tokens if token.startswith((".", "#", "["))}
969
+ if specific:
970
+ wanted |= specific
971
+ else:
972
+ tag_only.add(node.tag)
973
+
974
+ matched: list[dict[str, Any]] = []
975
+ for rule in rules:
976
+ selector = rule["selector"]
977
+ if AT_RULE_PATTERN.match(selector):
978
+ # Keep at-rules only when their body mentions a wanted token, so a
979
+ # responsive override for the selection still travels with it.
980
+ if not any(token in rule["text"] for token in wanted):
981
+ continue
982
+ elif not any(token in selector for token in wanted):
983
+ bare = {part.strip() for part in re.split(r"[\s,>+~]+", selector) if part.strip()}
984
+ if not (tag_only & bare):
985
+ continue
986
+ matched.append({
987
+ "selector": selector,
988
+ "lineStart": line_of(parser, rule["start"]),
989
+ "lineEnd": line_of(parser, max(rule["start"], rule["end"] - 1)),
990
+ "text": rule["text"] if len(rule["text"]) <= 600 else rule["text"][:600] + "\n /* … */\n}",
991
+ })
992
+ return matched[:14]
993
+
994
+
995
+ def related_scripts(parser: SourceHtmlParser, source: str, nodes: list[HtmlNode]) -> list[dict[str, Any]]:
996
+ """Script blocks that reference an identifier carried by the selection."""
997
+ needles: set[str] = set()
998
+ for node in nodes:
999
+ for name in IDENTITY_ATTRIBUTES:
1000
+ value = node.attrs.get(name)
1001
+ if value:
1002
+ needles.add(value)
1003
+ for name in ("data-action", "data-target"):
1004
+ value = node.attrs.get(name)
1005
+ if value:
1006
+ needles.add(value)
1007
+ for item in node.attrs.get("class", "").split():
1008
+ if item:
1009
+ needles.add(item)
1010
+ if not needles:
1011
+ return []
1012
+
1013
+ blocks: list[dict[str, Any]] = []
1014
+ for match in SCRIPT_BLOCK_PATTERN.finditer(source):
1015
+ code = match.group(2)
1016
+ if not code.strip():
1017
+ continue
1018
+ hits = sorted({needle for needle in needles if needle in code})
1019
+ if not hits:
1020
+ continue
1021
+ blocks.append({
1022
+ "lineStart": line_of(parser, match.start()),
1023
+ "lineEnd": line_of(parser, max(match.start(), match.end() - 1)),
1024
+ "matches": hits,
1025
+ "text": code.strip() if len(code.strip()) <= 2400 else code.strip()[:2400] + "\n/* … */",
1026
+ })
1027
+ return blocks[:3]
1028
+
1029
+
1030
+ # ── Stable identity promotion ───────────────────────────────────────────────
1031
+ #
1032
+ # Most elements carry neither `id` nor `data-wb-id`, so their only anchor is a
1033
+ # structural path — which dies the moment the agent rewrites that subtree. On
1034
+ # selection the workbench therefore writes a readable `data-wb-id` into the
1035
+ # source. This is not pollution: `data-wb-id` is already the project's own
1036
+ # contract for addressing elements (see the editable-HTML guidelines), so
1037
+ # promoting a selection moves the page *towards* the spec rather than away.
1038
+
1039
+ STOPWORD_SLUGS = {"the", "and", "for", "with", "you", "your", "our", "this", "that", "from", "are", "was"}
1040
+
1041
+
1042
+ def slugify(value: str, fallback: str) -> str:
1043
+ ascii_value = re.sub(r"[^a-z0-9]+", "-", (value or "").lower()).strip("-")
1044
+ words = [word for word in ascii_value.split("-") if word and word not in STOPWORD_SLUGS]
1045
+ slug = "-".join(words[:4])[:40].strip("-")
1046
+ return slug or fallback
1047
+
1048
+
1049
+ def suggest_wb_id(node: HtmlNode, taken: set[str]) -> str:
1050
+ classes = [item for item in node.attrs.get("class", "").split() if item]
1051
+ seeds = [
1052
+ slugify(classes[0], "") if classes else "",
1053
+ slugify(node_text_seed(node), ""),
1054
+ node.tag,
1055
+ ]
1056
+ base = next((seed for seed in seeds if seed), node.tag)
1057
+ if not base.startswith(node.tag) and base != node.tag:
1058
+ base = f"{base}"
1059
+ candidate = base
1060
+ counter = 2
1061
+ while candidate in taken:
1062
+ candidate = f"{base}-{counter}"
1063
+ counter += 1
1064
+ return candidate
1065
+
1066
+
1067
+ def node_text_seed(node: HtmlNode) -> str:
1068
+ for name in ("aria-label", "alt", "title", "name", "data-action"):
1069
+ value = node.attrs.get(name)
1070
+ if value:
1071
+ return value
1072
+ return ""
1073
+
1074
+
1075
+ def existing_wb_ids(parser: SourceHtmlParser) -> set[str]:
1076
+ taken: set[str] = set()
1077
+ for node in parser.nodes:
1078
+ for name in ("id", "data-wb-id"):
1079
+ value = node.attrs.get(name)
1080
+ if value:
1081
+ taken.add(value)
1082
+ return taken
1083
+
1084
+
1085
+ def promote_identities(target: Path, descriptors: list[dict[str, Any]], base_revision: str) -> dict[str, Any]:
1086
+ """Give every anchorless selection a durable `data-wb-id`, atomically."""
1087
+ current = read_document(target)
1088
+ if base_revision and current["revision"] != base_revision:
1089
+ raise WorkbenchError("REVISION_CONFLICT", "磁盘文件已被外部修改,请重新加载后再试。", 409, document=current)
1090
+
1091
+ source = current["html"]
1092
+ parser, body = parse_document_tree(source)
1093
+ taken = existing_wb_ids(parser)
1094
+ insertions: list[tuple[int, str]] = []
1095
+ assigned: list[dict[str, Any]] = []
1096
+
1097
+ for descriptor in descriptors:
1098
+ node = resolve_anchor(parser, body, descriptor)
1099
+ identity = identity_of(node)
1100
+ if identity is not None:
1101
+ assigned.append({"identity": identity, "created": False})
1102
+ continue
1103
+ value = suggest_wb_id(node, taken)
1104
+ taken.add(value)
1105
+ # Insert just before the '>' that closes the start tag. Self-closing
1106
+ # tags keep their slash, so target the last character of the raw tag.
1107
+ raw = source[node.start : node.start_end]
1108
+ offset = node.start + len(raw.rstrip()[:-1].rstrip("/").rstrip()) if raw.endswith(">") else node.start_end - 1
1109
+ insertions.append((offset, f' data-wb-id="{html.escape(value, quote=True)}"'))
1110
+ assigned.append({"identity": {"name": "data-wb-id", "value": value}, "created": True})
1111
+
1112
+ if not insertions:
1113
+ return {"revision": current["revision"], "assigned": assigned, "changed": False}
1114
+
1115
+ updated = source
1116
+ for offset, text in sorted(insertions, key=lambda item: item[0], reverse=True):
1117
+ updated = updated[:offset] + text + updated[offset:]
1118
+
1119
+ latest = read_document(target)
1120
+ if latest["revision"] != current["revision"]:
1121
+ raise WorkbenchError("REVISION_CONFLICT", "写入期间文件发生外部修改,未覆盖最新版。", 409, document=latest)
1122
+ write_atomic(target, updated)
1123
+ return {"revision": revision_of(updated), "assigned": assigned, "changed": True}
1124
+
1125
+
1126
+ def write_atomic(target: Path, content: str) -> None:
1127
+ temporary_name: Path | None = None
1128
+ try:
1129
+ descriptor, temporary_name = make_temp_file(target.parent, prefix=f".{target.name}.workbench-", suffix=".tmp")
1130
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as output:
1131
+ output.write(content)
1132
+ output.flush()
1133
+ os.fsync(output.fileno())
1134
+ os.chmod(temporary_name, target.stat().st_mode)
1135
+ os.replace(temporary_name, target)
1136
+ finally:
1137
+ if temporary_name and os.path.exists(temporary_name):
1138
+ os.unlink(temporary_name)
1139
+
1140
+
1141
+ # ── Context packet ──────────────────────────────────────────────────────────
1142
+
1143
+
1144
+ def build_selection_context(target: Path, descriptors: list[dict[str, Any]]) -> dict[str, Any]:
1145
+ if not isinstance(descriptors, list) or not descriptors:
1146
+ raise WorkbenchError("INVALID_ANCHOR", "请至少选择一个元素。", 400)
1147
+ if len(descriptors) > MAX_CONTEXT_SELECTIONS:
1148
+ raise WorkbenchError("INVALID_ANCHOR", f"一次最多添加 {MAX_CONTEXT_SELECTIONS} 个元素。", 400)
1149
+
1150
+ document = read_document(target)
1151
+ source = document["html"]
1152
+ parser, body = parse_document_tree(source)
1153
+
1154
+ nodes = [resolve_anchor(parser, body, descriptor) for descriptor in descriptors]
1155
+ # Drop selections already contained in another selection: sending both a
1156
+ # section and its heading duplicates the source and blurs the instruction.
1157
+ kept: list[HtmlNode] = []
1158
+ for node in nodes:
1159
+ if any(node is not other and node.is_inside(other) for other in nodes):
1160
+ continue
1161
+ if any(node is other for other in kept):
1162
+ continue
1163
+ kept.append(node)
1164
+
1165
+ selections = [describe_anchor(parser, body, node) for node in kept]
1166
+ rules = document_css_rules(source)
1167
+ payload = {
1168
+ "filePath": document["filePath"],
1169
+ "fileName": document["fileName"],
1170
+ "revision": document["revision"],
1171
+ "selections": selections,
1172
+ "css": related_css(parser, source, kept, rules),
1173
+ "scripts": related_scripts(parser, source, kept),
1174
+ "collapsed": len(nodes) - len(kept),
1175
+ }
1176
+ payload["markdown"] = render_context_markdown(payload)
1177
+ return payload
1178
+
1179
+
1180
+ def render_context_markdown(payload: dict[str, Any]) -> str:
1181
+ """Render the packet the agent actually reads.
1182
+
1183
+ Optimised for an agent that cannot see the page: every selection leads with
1184
+ how to find it again (anchor + line range) and only then describes what it
1185
+ is. The literal source snippet is included so a string-replacing edit tool
1186
+ has an exact `old_string` to match.
1187
+ """
1188
+ lines: list[str] = []
1189
+ lines.append("## 视觉选区(HTML Workbench)")
1190
+ lines.append("")
1191
+ lines.append(f"- 文件:`{payload['filePath']}`")
1192
+ lines.append(f"- revision:`{payload['revision'][:12]}`")
1193
+ lines.append(f"- 选中元素:{len(payload['selections'])} 个(编辑态框选)")
1194
+ lines.append("")
1195
+ lines.append("用户在页面上直接选中了下列元素。后续指令中的「这个 / 这里 / 这块」均指这些元素。")
1196
+ lines.append("")
1197
+
1198
+ for index, item in enumerate(payload["selections"], start=1):
1199
+ marker = f"### {index}. `{item['selector']}`"
1200
+ lines.append(marker)
1201
+ lines.append("")
1202
+ identity = item.get("identity")
1203
+ if identity:
1204
+ lines.append(f"- 稳定锚点:`[{identity['name']}=\"{identity['value']}\"]`")
1205
+ else:
1206
+ lines.append("- 稳定锚点:无(该元素没有 id / data-wb-id)")
1207
+ lines.append(f"- 源码位置:第 {item['lineStart']}–{item['lineEnd']} 行")
1208
+ if item["ancestors"]:
1209
+ lines.append(f"- 层级:`{' > '.join(item['ancestors'][-4:])} > {item['selector']}`")
1210
+ if item["text"]:
1211
+ lines.append(f"- 可见文本:{item['text']}")
1212
+ if item["behavior"]:
1213
+ behavior = " ".join(f'{name}="{value}"' for name, value in item["behavior"].items())
1214
+ lines.append(f"- 行为属性:`{behavior}`")
1215
+ lines.append("")
1216
+ lines.append("```html")
1217
+ lines.append(item["snippet"])
1218
+ lines.append("```")
1219
+ lines.append("")
1220
+
1221
+ if payload["css"]:
1222
+ lines.append("### 关联样式规则")
1223
+ lines.append("")
1224
+ for rule in payload["css"]:
1225
+ lines.append(f"`{rule['selector']}` — 第 {rule['lineStart']}–{rule['lineEnd']} 行")
1226
+ lines.append("")
1227
+ lines.append("```css")
1228
+ lines.append(rule["text"])
1229
+ lines.append("```")
1230
+ lines.append("")
1231
+
1232
+ if payload["scripts"]:
1233
+ lines.append("### 关联脚本")
1234
+ lines.append("")
1235
+ for block in payload["scripts"]:
1236
+ hits = "、".join(f"`{item}`" for item in block["matches"][:6])
1237
+ lines.append(f"第 {block['lineStart']}–{block['lineEnd']} 行,引用了 {hits}")
1238
+ lines.append("")
1239
+ lines.append("```js")
1240
+ lines.append(block["text"])
1241
+ lines.append("```")
1242
+ lines.append("")
1243
+
1244
+ lines.append("### 修改约束")
1245
+ lines.append("")
1246
+ lines.append("- 只修改上述选区及其关联样式 / 脚本区间,页面其余部分保持不变。")
1247
+ lines.append("- 优先用稳定锚点定位;不要依赖 `nth-child`、兄弟顺序或行号硬编码。")
1248
+ lines.append("- 编辑前请重新读取该文件:用户可能在此期间继续做了可视化修改。")
1249
+ lines.append("")
1250
+
1251
+ markdown = "\n".join(lines)
1252
+ if len(markdown.encode("utf-8")) > MAX_CONTEXT_BYTES:
1253
+ encoded = markdown.encode("utf-8")[:MAX_CONTEXT_BYTES]
1254
+ markdown = encoded.decode("utf-8", "ignore") + "\n\n<!-- 上下文过长,已截断 -->\n"
1255
+ return markdown
1256
+
1257
+
1258
+
1259
+
1260
+
1261
+ def normalize_file_reference(raw_file: str) -> str:
1262
+ """Turn a browser `file://` URL into the platform's local path string.
1263
+
1264
+ The address bar accepts both a normal filesystem path and a URL copied from
1265
+ Finder/Explorer/browser. `Path('file:///…%E4…')` is not a URL parser: it
1266
+ keeps the scheme and percent escapes literally, which made a valid existing
1267
+ file look missing. Decode it here, at the service boundary shared by every
1268
+ API and CLI command.
1269
+ """
1270
+ value = raw_file.strip()
1271
+ parsed = urllib.parse.urlsplit(value)
1272
+ if parsed.scheme.lower() != "file":
1273
+ return value
1274
+ if parsed.query or parsed.fragment:
1275
+ raise WorkbenchError("INVALID_FILE_URL", "file:// 地址不能包含 query 或 hash。", 400)
1276
+
1277
+ host = parsed.netloc.lower()
1278
+ path = urllib.parse.unquote(parsed.path)
1279
+ if host not in {"", "localhost"}:
1280
+ if os.name != "nt":
1281
+ raise WorkbenchError("INVALID_FILE_URL", "当前系统只支持本机 file:// 地址。", 400)
1282
+ # file://server/share/page.html → \\server\share\page.html on Windows.
1283
+ path = f"//{parsed.netloc}{path}"
1284
+ elif os.name == "nt" and re.match(r"^/[A-Za-z]:/", path):
1285
+ # URL syntax requires a leading slash before a Windows drive: file:///C:/…
1286
+ path = path[1:]
1287
+
1288
+ if not path:
1289
+ raise WorkbenchError("INVALID_FILE_URL", "file:// 地址未包含文件路径。", 400)
1290
+ return path
1291
+
1292
+
468
1293
  def resolve_html_file(raw_file: str) -> Path:
469
1294
  if not raw_file.strip():
470
1295
  raise WorkbenchError("FILE_REQUIRED", "请通过 file 参数指定 HTML 文件。", 400)
471
- requested = Path(raw_file).expanduser()
1296
+ normalized = normalize_file_reference(raw_file)
1297
+ requested = Path(normalized).expanduser()
472
1298
  try:
473
1299
  target = requested.resolve(strict=True)
474
1300
  except FileNotFoundError as caught:
@@ -547,7 +1373,7 @@ def save_document(target: Path, body: dict[str, Any]) -> dict[str, Any]:
547
1373
  if latest["revision"] != base_revision:
548
1374
  raise WorkbenchError("REVISION_CONFLICT", "保存期间文件发生外部修改,未覆盖最新版。", 409, document=latest)
549
1375
 
550
- descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.workbench-", suffix=".tmp", dir=target.parent)
1376
+ descriptor, temporary_name = make_temp_file(target.parent, prefix=f".{target.name}.workbench-", suffix=".tmp")
551
1377
  try:
552
1378
  with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as output:
553
1379
  output.write(source)
@@ -565,11 +1391,24 @@ class WorkbenchServer(ThreadingHTTPServer):
565
1391
  daemon_threads = True
566
1392
  allow_reuse_address = True
567
1393
 
568
- def __init__(self, address: tuple[str, int], asset_file: Path, editor_root: Path, vendor_files: dict[str, Path]) -> None:
1394
+ def __init__(
1395
+ self,
1396
+ address: tuple[str, int],
1397
+ asset_file: Path,
1398
+ editor_root: Path,
1399
+ vendor_files: dict[str, Path],
1400
+ log_dir: Path | None = None,
1401
+ vendor_cache: Path | None = None,
1402
+ ) -> None:
569
1403
  self.asset_file = asset_file.resolve(strict=True)
570
1404
  self.editor_root = editor_root.resolve(strict=True)
571
1405
  self.vendor_files = {name: path.resolve(strict=True) for name, path in vendor_files.items()}
572
1406
  self.vendor_routes = {str(metadata["route"]): name for name, metadata in VENDOR_ASSETS.items()}
1407
+ # Advertised by /api/health so the host can report the directories actually
1408
+ # in use — including when it REUSES a service it did not start and has no
1409
+ # startup output to read them from.
1410
+ self.log_dir = Path(log_dir) if log_dir is not None else None
1411
+ self.vendor_cache = Path(vendor_cache) if vendor_cache is not None else None
573
1412
  super().__init__(address, WorkbenchHandler)
574
1413
 
575
1414
 
@@ -616,14 +1455,23 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
616
1455
  query = urllib.parse.parse_qs(parsed.query)
617
1456
  try:
618
1457
  if parsed.path == "/api/health":
619
- self.send_json(200, {
1458
+ payload: dict[str, Any] = {
620
1459
  "ok": True,
621
1460
  "service": SERVICE_NAME,
622
1461
  "version": SERVICE_VERSION,
623
1462
  "port": self.server.server_port,
624
1463
  "editorRoot": str(self.server.editor_root),
625
- "capabilities": ["grapesjs-canvas", "autosave", "file-query", "file-watch", "stdlib-python"],
626
- })
1464
+ "capabilities": list(SERVICE_CAPABILITIES),
1465
+ }
1466
+ # Report where this process really writes. The host shows these in
1467
+ # its diagnostics, so a REUSED service describes its own
1468
+ # directories instead of the ones the caller asked for — which may
1469
+ # be exactly what the sandbox refused.
1470
+ if self.server.log_dir is not None:
1471
+ payload["logDir"] = str(self.server.log_dir)
1472
+ if self.server.vendor_cache is not None:
1473
+ payload["vendorCache"] = str(self.server.vendor_cache)
1474
+ self.send_json(200, payload)
627
1475
  return
628
1476
  if parsed.path == "/api/document":
629
1477
  self.send_json(200, read_document(self.query_file(query)))
@@ -651,10 +1499,7 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
651
1499
  self.send_json(404, {"error": "NOT_FOUND", "message": "接口不存在。"})
652
1500
  return
653
1501
  try:
654
- length = int(self.headers.get("Content-Length", "0"))
655
- if length <= 0 or length > MAX_REQUEST_BYTES:
656
- raise WorkbenchError("INVALID_SAVE", "保存请求大小无效。", 400)
657
- body = json.loads(self.rfile.read(length).decode("utf-8"))
1502
+ body = self.read_json_body()
658
1503
  query = urllib.parse.parse_qs(parsed.query)
659
1504
  self.send_json(200, save_document(self.query_file(query), body))
660
1505
  except json.JSONDecodeError as caught:
@@ -662,6 +1507,46 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
662
1507
  except Exception as caught:
663
1508
  self.send_error_payload(caught)
664
1509
 
1510
+ def do_POST(self) -> None:
1511
+ parsed = urllib.parse.urlsplit(self.path)
1512
+ query = urllib.parse.parse_qs(parsed.query)
1513
+ try:
1514
+ if parsed.path == "/api/shutdown":
1515
+ # Lets a newer `open` retire this process cleanly. The service
1516
+ # holds no state of its own, so exiting is always safe.
1517
+ self.send_json(200, {"ok": True, "stopping": True})
1518
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
1519
+ return
1520
+ if parsed.path not in {"/api/context", "/api/anchor"}:
1521
+ self.send_json(404, {"error": "NOT_FOUND", "message": "接口不存在。"})
1522
+ return
1523
+ body = self.read_json_body()
1524
+ target = self.query_file(query)
1525
+ selections = body.get("selections")
1526
+ if parsed.path == "/api/anchor":
1527
+ # Identity promotion mutates the file, so it is a separate,
1528
+ # explicitly revision-checked step ahead of context building.
1529
+ if not isinstance(selections, list) or not selections:
1530
+ raise WorkbenchError("INVALID_ANCHOR", "请至少选择一个元素。", 400)
1531
+ if len(selections) > MAX_CONTEXT_SELECTIONS:
1532
+ raise WorkbenchError("INVALID_ANCHOR", f"一次最多添加 {MAX_CONTEXT_SELECTIONS} 个元素。", 400)
1533
+ self.send_json(200, promote_identities(target, selections, str(body.get("baseRevision") or "")))
1534
+ return
1535
+ self.send_json(200, build_selection_context(target, selections))
1536
+ except json.JSONDecodeError as caught:
1537
+ self.send_error_payload(WorkbenchError("INVALID_ANCHOR", f"请求不是有效 JSON:{caught}", 400))
1538
+ except Exception as caught:
1539
+ self.send_error_payload(caught)
1540
+
1541
+ def read_json_body(self) -> dict[str, Any]:
1542
+ length = int(self.headers.get("Content-Length", "0"))
1543
+ if length <= 0 or length > MAX_REQUEST_BYTES:
1544
+ raise WorkbenchError("INVALID_SAVE", "请求大小无效。", 400)
1545
+ payload = json.loads(self.rfile.read(length).decode("utf-8"))
1546
+ if not isinstance(payload, dict):
1547
+ raise WorkbenchError("INVALID_SAVE", "请求体必须是 JSON 对象。", 400)
1548
+ return payload
1549
+
665
1550
  def send_asset(self, route: str) -> None:
666
1551
  parts = route.split("/", 3)
667
1552
  if len(parts) < 4:
@@ -710,10 +1595,120 @@ def health(port: int, timeout: float = 0.5) -> dict[str, Any] | None:
710
1595
  return None
711
1596
 
712
1597
 
713
- def start_detached(script_file: Path, port: int, editor_root: Path, asset_file: Path | None, vendor_cache: Path, log_dir: Path) -> tuple[int, Path]:
714
- directory = Path(log_dir).expanduser()
715
- directory.mkdir(parents=True, exist_ok=True)
716
- log_file = directory / f"html-workbench-{port}.log"
1598
+ def is_reusable(payload: dict[str, Any] | None) -> bool:
1599
+ """Whether a live service speaks the same API as this script.
1600
+
1601
+ A process serves the code it was started with, but serves assets from disk.
1602
+ So an old process + new assets is the worst combination: the UI offers
1603
+ features whose routes answer 501. Require every capability this script
1604
+ advertises before reusing.
1605
+ """
1606
+ if not payload:
1607
+ return False
1608
+ advertised = payload.get("capabilities")
1609
+ if not isinstance(advertised, list):
1610
+ return False
1611
+ return set(SERVICE_CAPABILITIES).issubset({str(item) for item in advertised})
1612
+
1613
+
1614
+ def stop_service(port: int, timeout: float = 6.0) -> bool:
1615
+ """Ask an outdated service to exit, then free its TCP listener.
1616
+
1617
+ Before 2.1.0 there was no shutdown route. Also, a failed/foreign listener
1618
+ can hold the port without answering health: a health failure is *not* proof
1619
+ that binding will succeed. We therefore wait for the actual listener to go
1620
+ away, and use the native process terminator on each platform as a fallback.
1621
+ """
1622
+ try:
1623
+ request = urllib.request.Request(f"http://127.0.0.1:{port}/api/shutdown", method="POST")
1624
+ urllib.request.urlopen(request, timeout=timeout).close()
1625
+ except (OSError, ValueError, urllib.error.URLError):
1626
+ pass
1627
+ if wait_for_port_free(port, 2.0):
1628
+ return True
1629
+ for pid in listeners_on_port(port):
1630
+ terminate_process(pid)
1631
+ return wait_for_port_free(port, timeout)
1632
+
1633
+
1634
+ def wait_for_port_free(port: int, timeout: float) -> bool:
1635
+ """Wait until no process is listening, including unhealthy listeners."""
1636
+ deadline = time.monotonic() + timeout
1637
+ while time.monotonic() < deadline:
1638
+ if not listeners_on_port(port):
1639
+ return True
1640
+ time.sleep(0.1)
1641
+ return not listeners_on_port(port)
1642
+
1643
+
1644
+ def listeners_on_port(port: int) -> list[int]:
1645
+ """Return PIDs listening on a local TCP port, best-effort on every OS.
1646
+
1647
+ macOS/Linux use lsof. Windows ships `netstat` and exposes the PID in its
1648
+ final column, avoiding an optional Python dependency such as psutil.
1649
+ """
1650
+ try:
1651
+ if os.name == "nt":
1652
+ result = subprocess.run(
1653
+ ["netstat", "-ano", "-p", "tcp"],
1654
+ capture_output=True, text=True, timeout=5,
1655
+ )
1656
+ pids = []
1657
+ for line in result.stdout.splitlines():
1658
+ columns = line.split()
1659
+ # TCP 127.0.0.1:4317 0.0.0.0:0 LISTENING 1234
1660
+ if len(columns) < 5 or columns[0].upper() != "TCP" or columns[3].upper() != "LISTENING":
1661
+ continue
1662
+ local = columns[1].rsplit(":", 1)
1663
+ if len(local) != 2 or local[1] != str(port):
1664
+ continue
1665
+ try:
1666
+ pid = int(columns[-1])
1667
+ except ValueError:
1668
+ continue
1669
+ if pid != os.getpid():
1670
+ pids.append(pid)
1671
+ return pids
1672
+ result = subprocess.run(
1673
+ ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
1674
+ capture_output=True, text=True, timeout=5,
1675
+ )
1676
+ except (OSError, subprocess.SubprocessError):
1677
+ return []
1678
+ pids = []
1679
+ for line in result.stdout.split():
1680
+ try:
1681
+ pid = int(line)
1682
+ except ValueError:
1683
+ continue
1684
+ if pid != os.getpid():
1685
+ pids.append(pid)
1686
+ return pids
1687
+
1688
+
1689
+ def terminate_process(pid: int) -> None:
1690
+ """Best-effort process-tree termination on the current platform."""
1691
+ try:
1692
+ if os.name == "nt":
1693
+ subprocess.run(
1694
+ ["taskkill", "/PID", str(pid), "/T", "/F"],
1695
+ capture_output=True, text=True, timeout=5,
1696
+ )
1697
+ else:
1698
+ os.kill(pid, signal.SIGTERM)
1699
+ except (OSError, subprocess.SubprocessError):
1700
+ pass
1701
+
1702
+
1703
+ def start_detached(script_file: Path, port: int, editor_root: Path, asset_file: Path | None, vendor_cache: Path, log_dir: Path) -> tuple[int, Path | None]:
1704
+ """Spawn a detached `serve` and return (pid, path of its captured output).
1705
+
1706
+ The path is None when no candidate directory accepted the file: a service
1707
+ whose console output cannot be captured must still start, so the output is
1708
+ discarded rather than the spawn abandoned (see `setup_logging`).
1709
+ """
1710
+ directory = pick_writable_dir(*log_candidates(log_dir))
1711
+ log_file: Path | None = directory / f"html-workbench-{port}.log"
717
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)]
718
1713
  if asset_file is not None:
719
1714
  command.extend(["--asset", str(asset_file)])
@@ -723,19 +1718,45 @@ def start_detached(script_file: Path, port: int, editor_root: Path, asset_file:
723
1718
  flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS # type: ignore[attr-defined]
724
1719
  kwargs["creationflags"] = flags
725
1720
  kwargs.pop("start_new_session", None)
726
- with log_file.open("ab") as log:
727
- process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, close_fds=True, **kwargs)
1721
+ try:
1722
+ log = log_file.open("ab")
1723
+ except OSError:
1724
+ log_file = None
1725
+ log = None
1726
+ try:
1727
+ process = subprocess.Popen(
1728
+ command,
1729
+ stdout=log if log is not None else subprocess.DEVNULL,
1730
+ stderr=subprocess.STDOUT,
1731
+ close_fds=True,
1732
+ **kwargs,
1733
+ )
1734
+ finally:
1735
+ if log is not None:
1736
+ log.close()
728
1737
  return process.pid, log_file
729
1738
 
730
1739
 
731
1740
  def command_serve(args: argparse.Namespace, script_file: Path) -> int:
732
- logger = setup_logging(args.log_dir, args.port)
1741
+ logger, log_directory = setup_logging(args.log_dir, args.port)
733
1742
  asset_file = Path(args.asset).expanduser().resolve(strict=True) if args.asset else default_asset_file(script_file)
734
1743
  editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
735
- vendor_files = ensure_vendor_assets(Path(args.vendor_cache).expanduser())
736
- server = WorkbenchServer(("127.0.0.1", args.port), asset_file, editor_root, vendor_files)
737
- logger.info("service started: port=%s editorRoot=%s logDir=%s", args.port, editor_root, Path(args.log_dir).expanduser())
738
- print(json.dumps({"ok": True, "url": f"http://127.0.0.1:{args.port}", "editorRoot": str(editor_root)}, ensure_ascii=False), flush=True)
1744
+ # Logs and cache are both disposable, so they follow the writable location the
1745
+ # sandbox really granted instead of the path the parent requested.
1746
+ vendor_cache = pick_writable_dir(*vendor_candidates(args.vendor_cache, log_directory))
1747
+ 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)
1750
+ # The host surfaces these two paths in its diagnostics, so the panel shows the
1751
+ # directories actually in use rather than echoing the ones it asked for — the
1752
+ # requested ones may be exactly what the sandbox refused.
1753
+ print(json.dumps({
1754
+ "ok": True,
1755
+ "url": f"http://127.0.0.1:{args.port}",
1756
+ "editorRoot": str(editor_root),
1757
+ "logDir": str(log_directory),
1758
+ "vendorCache": str(vendor_cache),
1759
+ }, ensure_ascii=False), flush=True)
739
1760
  try:
740
1761
  server.serve_forever(poll_interval=0.25)
741
1762
  except KeyboardInterrupt:
@@ -751,6 +1772,17 @@ def command_open(args: argparse.Namespace, script_file: Path) -> int:
751
1772
  editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
752
1773
  resolve_html_file(str(target))
753
1774
  existing = health(args.port)
1775
+ if existing is not None and not is_reusable(existing):
1776
+ # Same port, older code: retire it rather than serve a new UI against a
1777
+ # stale API. Nothing is lost — the service keeps no state of its own.
1778
+ stop_service(args.port)
1779
+ existing = health(args.port)
1780
+ if existing is not None:
1781
+ raise WorkbenchError(
1782
+ "SERVER_OUTDATED",
1783
+ f"端口 {args.port} 上有旧版服务且无法自动停止,请手动结束该进程后重试。",
1784
+ 500,
1785
+ )
754
1786
  reused = existing is not None
755
1787
  pid: int | None = None
756
1788
  log_file: Path | None = None
@@ -763,7 +1795,8 @@ def command_open(args: argparse.Namespace, script_file: Path) -> int:
763
1795
  break
764
1796
  time.sleep(0.1)
765
1797
  else:
766
- raise WorkbenchError("SERVER_UNAVAILABLE", f"服务启动失败,请查看日志:{log_file}", 500)
1798
+ where = f",请查看日志:{log_file}" if log_file is not None else "(没有可写的日志目录,输出已丢弃)"
1799
+ raise WorkbenchError("SERVER_UNAVAILABLE", f"服务启动失败{where}", 500)
767
1800
  query = urllib.parse.urlencode({"file": str(target)})
768
1801
  print(json.dumps({
769
1802
  "ok": True,
@@ -796,9 +1829,28 @@ def build_parser() -> argparse.ArgumentParser:
796
1829
 
797
1830
  health_command = subparsers.add_parser("health", help="check the local service")
798
1831
  health_command.add_argument("--port", type=int, default=DEFAULT_PORT)
1832
+
1833
+ stop_command = subparsers.add_parser("stop", help="retire whatever service holds the port")
1834
+ stop_command.add_argument("--port", type=int, default=DEFAULT_PORT)
799
1835
  return parser
800
1836
 
801
1837
 
1838
+ def command_stop(args: argparse.Namespace) -> int:
1839
+ """Free the port so a fresh service can bind it.
1840
+
1841
+ Callers use this to recover from `PORT_IN_USE`, where the listener is a
1842
+ process they do not own (a leftover from an earlier run, or an outdated
1843
+ version). Succeeding when nothing is listening keeps it safe to call blindly.
1844
+ """
1845
+ if health(args.port) is None and not listeners_on_port(args.port):
1846
+ print(json.dumps({"ok": True, "port": args.port, "stopped": False, "reason": "NOTHING_LISTENING"}, ensure_ascii=False))
1847
+ return 0
1848
+ if stop_service(args.port):
1849
+ print(json.dumps({"ok": True, "port": args.port, "stopped": True}, ensure_ascii=False))
1850
+ return 0
1851
+ raise WorkbenchError("SERVER_OUTDATED", f"端口 {args.port} 上的进程无法停止,请手动结束后重试。", 500)
1852
+
1853
+
802
1854
  def main() -> int:
803
1855
  if sys.version_info < (3, 9):
804
1856
  print(json.dumps({"ok": False, "error": "PYTHON_TOO_OLD", "message": "需要 Python 3.9 或更高版本。"}, ensure_ascii=False), file=sys.stderr)
@@ -810,6 +1862,8 @@ def main() -> int:
810
1862
  return command_serve(args, script_file)
811
1863
  if args.command == "open":
812
1864
  return command_open(args, script_file)
1865
+ if args.command == "stop":
1866
+ return command_stop(args)
813
1867
  payload = health(args.port)
814
1868
  print(json.dumps(payload or {"ok": False, "error": "SERVER_UNAVAILABLE"}, ensure_ascii=False))
815
1869
  return 0 if payload else 1