feat: initialize XAS test console with native execution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""XAS dedicated script console."""
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
"""LAN-only HTTP interface; does not spawn processes or access Docker."""
|
||||
import hashlib
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from urllib.parse import urlsplit
|
||||
import uuid
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from . import catalog
|
||||
from . import test_catalog
|
||||
from . import native_plans, native_io
|
||||
from .settings import Settings
|
||||
from .store import Store, TERMINAL, now
|
||||
|
||||
|
||||
class ConfigPatch(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
args: list[str] | None = None
|
||||
cwd: str | None = None
|
||||
output_paths: list[str] | None = None
|
||||
timeout_seconds: Annotated[int, Field(strict=True)] | None = None
|
||||
native_options: dict[str, str | int | float | bool] | None = None
|
||||
|
||||
|
||||
class ScriptPatch(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
description: str | None = Field(default=None, max_length=4000)
|
||||
enabled: bool | None = None
|
||||
config: ConfigPatch | None = None
|
||||
|
||||
|
||||
class RunRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
request_id: uuid.UUID
|
||||
plan_token: str | None = Field(default=None, max_length=64)
|
||||
impact_acknowledged: bool = False
|
||||
|
||||
|
||||
class NativeInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
text: str = Field(max_length=1024)
|
||||
|
||||
|
||||
def create_app(settings=None):
|
||||
settings = settings or Settings.from_env()
|
||||
store = Store(settings)
|
||||
app = FastAPI(title="XAS 测试控制台", docs_url=None, redoc_url=None, openapi_url=None)
|
||||
app.state.store = store
|
||||
static = Path(__file__).parent / "static"
|
||||
|
||||
@app.middleware("http")
|
||||
async def boundaries(request, call_next):
|
||||
host = request.headers.get("host", "")
|
||||
try:
|
||||
address = ipaddress.ip_address(request.client.host)
|
||||
allowed = any(address in ipaddress.ip_network(n) for n in settings.allowed_networks)
|
||||
except ValueError:
|
||||
allowed = False
|
||||
if host not in settings.allowed_hosts or not allowed:
|
||||
return JSONResponse({"detail": "仅允许公司内网访问"}, status_code=403)
|
||||
if request.method not in {"GET", "HEAD", "OPTIONS"}:
|
||||
origin = request.headers.get("origin")
|
||||
valid_origin = not origin or origin == f"{request.url.scheme}://{host}"
|
||||
if (not valid_origin or request.headers.get("x-xas-console") != "1"
|
||||
or request.headers.get("sec-fetch-site") == "cross-site"):
|
||||
return JSONResponse({"detail": "请求来源校验失败,请从控制台页面操作"}, status_code=403)
|
||||
try:
|
||||
size = int(request.headers.get("content-length", "0"))
|
||||
except ValueError:
|
||||
size = -1
|
||||
if size < 0 or size > settings.max_upload + 65536 or "transfer-encoding" in request.headers:
|
||||
return JSONResponse({"detail": "请求超过大小限制"}, status_code=413)
|
||||
response = await call_next(request)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Referrer-Policy"] = "same-origin"
|
||||
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
|
||||
return response
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
async def value_error(request, exc):
|
||||
return JSONResponse({"detail": str(exc)}, status_code=400)
|
||||
|
||||
@app.exception_handler(KeyError)
|
||||
async def missing(request, exc):
|
||||
return JSONResponse({"detail": str(exc.args[0])}, status_code=404)
|
||||
|
||||
@app.get("/api/summary")
|
||||
def summary():
|
||||
inventory, runs = store.scripts(), store.runs(limit=10000)
|
||||
scripts = test_catalog.tests(settings, inventory)
|
||||
# No directory recursion on every page refresh; size derives from recorded artifacts.
|
||||
size = sum(a["size"] for r in runs for a in r.get("artifacts", []))
|
||||
return {"scripts": len(scripts), "source_files": len(inventory),
|
||||
"examples": len(test_catalog.tests(settings, inventory, True)) - len(scripts),
|
||||
"ready": sum(s["enabled"] for s in scripts), "runs": len(runs),
|
||||
"active": sum(r["state"] in {"queued", "running"} for r in runs), "worker": store.health(),
|
||||
"storage": {"run_bytes": size}, "limits": {"upload_bytes": settings.max_upload,
|
||||
"log_bytes": settings.max_log, "concurrency": 1, "timeout_seconds": 7200}}
|
||||
|
||||
@app.get("/api/scripts")
|
||||
def scripts(include_examples: bool = False):
|
||||
return {"items": test_catalog.tests(settings, store.scripts(), include_examples)}
|
||||
|
||||
@app.post("/api/scripts/upload")
|
||||
async def new_script(file: UploadFile, name: Annotated[str, Form()] = "", description: Annotated[str, Form()] = ""):
|
||||
try:
|
||||
item = catalog.upload(store, await file.read(settings.max_upload + 1), file.filename, name, description)
|
||||
return store.script(item["id"], versions=True)
|
||||
finally:
|
||||
await file.close()
|
||||
|
||||
@app.get("/api/scripts/{identity}")
|
||||
def script(identity: str):
|
||||
return test_catalog.describe(settings, store.script(identity, versions=True), store.scripts())
|
||||
|
||||
@app.get("/api/scripts/{identity}/files")
|
||||
def script_files(identity: str):
|
||||
return {"items": test_catalog.members(settings, store.script(identity), store.scripts())}
|
||||
|
||||
@app.post("/api/scripts/{identity}/versions")
|
||||
async def new_version(identity: str, file: UploadFile):
|
||||
try:
|
||||
catalog.upload(store, await file.read(settings.max_upload + 1), file.filename, identity=identity)
|
||||
return store.script(identity, versions=True)
|
||||
finally:
|
||||
await file.close()
|
||||
|
||||
def source_version(identity, version):
|
||||
item = store.script(identity)
|
||||
if item["kind"] == "managed" and version:
|
||||
v = store.version(identity, version)
|
||||
item.update(source_path=v["path"], sha256=v["sha256"])
|
||||
elif version and version != item["version"]:
|
||||
raise ValueError("原位置脚本仅展示当前文件,历史运行请查看运行归档")
|
||||
return item
|
||||
|
||||
@app.get("/api/scripts/{identity}/code")
|
||||
def code(identity: str, version: int | None = None):
|
||||
return catalog.read_code(settings, source_version(identity, version))
|
||||
|
||||
@app.get("/api/scripts/{identity}/download")
|
||||
def download_source(identity: str, version: int | None = None):
|
||||
item = source_version(identity, version)
|
||||
source = catalog.read_code(settings, item)
|
||||
if source["redacted"]:
|
||||
raise ValueError("包含疑似凭据的原文件不能下载")
|
||||
return FileResponse(catalog.script_path(settings, item), filename=source["filename"], media_type="text/plain")
|
||||
|
||||
@app.patch("/api/scripts/{identity}")
|
||||
def update_script(identity: str, patch: ScriptPatch):
|
||||
# Serialize with version uploads to avoid restoring an obsolete current-version pointer.
|
||||
import json
|
||||
with store.connect(write=True) as db:
|
||||
row = db.execute("SELECT data FROM scripts WHERE id=?", (identity,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError("脚本不存在")
|
||||
item = json.loads(row[0])
|
||||
if not test_catalog.is_entry(settings, item):
|
||||
raise ValueError("此文件属于内部源码,请从测试项目入口配置和运行")
|
||||
values = patch.model_dump(exclude_none=True)
|
||||
if "config" in values:
|
||||
config_patch = values.pop("config")
|
||||
if native_plans.profile_for(settings, item) and set(config_patch) - {"native_options", "timeout_seconds"}:
|
||||
raise ValueError("原环境入口、目录和输出规则由已登记方案管理")
|
||||
item["config"] = catalog.validate_config(settings, item, {**item["config"], **config_patch})
|
||||
if values.get("enabled"):
|
||||
native = native_plans.profile_for(settings, item)
|
||||
blocked = not native.get("available", True) if native else item.get("blocked")
|
||||
if blocked or item["status"] == "missing":
|
||||
raise ValueError(item["reason"] or "此脚本暂不能启用")
|
||||
catalog.script_path(settings, item)
|
||||
item["config"] = catalog.validate_config(settings, item, item["config"])
|
||||
item.update(values, updated_at=now())
|
||||
item["status"] = "ready" if item["enabled"] else "needs_configuration"
|
||||
item["reason"] = "" if item["enabled"] else (item.get("reason") or "已停用")
|
||||
db.execute("UPDATE scripts SET data=? WHERE id=?", (json.dumps(item), identity))
|
||||
return store.script(identity, versions=True)
|
||||
|
||||
@app.post("/api/discover")
|
||||
def discover():
|
||||
result = catalog.discover(store)
|
||||
result["tests"] = len(test_catalog.tests(settings, store.scripts()))
|
||||
return result
|
||||
|
||||
@app.post("/api/scripts/{identity}/runs")
|
||||
def run_script(identity: str, body: RunRequest, request: Request):
|
||||
script = test_catalog.describe(settings, store.script(identity), store.scripts())
|
||||
if not script["is_test"]:
|
||||
raise ValueError("此文件属于内部源码,不能作为独立测试运行")
|
||||
if not script["enabled"] or script["status"] != "ready":
|
||||
raise ValueError(script["reason"] or "请先配置并启用脚本")
|
||||
if native_plans.profile_for(settings, script):
|
||||
return native_io.request(settings, {"op": "queue", "script_id": identity,
|
||||
"request_id": str(body.request_id), "plan_token": body.plan_token,
|
||||
"impact_acknowledged": body.impact_acknowledged, "source_ip": request.client.host})
|
||||
source = catalog.script_path(settings, script).read_bytes()
|
||||
if hashlib.sha256(source).hexdigest() != script["sha256"]:
|
||||
raise ValueError("原脚本已变动,请刷新目录并重新确认配置")
|
||||
if catalog.SECRET.search(catalog.clean_source(source)):
|
||||
raise ValueError("源文件包含疑似凭据,暂不执行")
|
||||
return store.queue(script, str(body.request_id), request.client.host)
|
||||
|
||||
@app.post("/api/scripts/{identity}/preflight")
|
||||
def preflight(identity: str):
|
||||
item = store.script(identity)
|
||||
if not native_plans.profile_for(settings, item):
|
||||
raise ValueError("此项目使用隔离容器,无原环境启动方案")
|
||||
return native_io.request(settings, {"op": "preflight", "script_id": identity})
|
||||
|
||||
@app.post("/api/runs/{identity}/input")
|
||||
def native_input(identity: str, body: NativeInput):
|
||||
return native_io.request(settings, {"op": "input", "run_id": identity, "text": body.text})
|
||||
|
||||
@app.get("/api/runs")
|
||||
def runs(script_id: str | None = None, limit: int = Query(default=100, ge=1, le=500)):
|
||||
return {"items": store.runs(script_id, limit)}
|
||||
|
||||
@app.get("/api/runs/{identity}")
|
||||
def run(identity: str):
|
||||
return store.run(identity)
|
||||
|
||||
@app.post("/api/runs/{identity}/cancel")
|
||||
def cancel(identity: str):
|
||||
return store.cancel(identity)
|
||||
|
||||
def run_path(identity, filename):
|
||||
store.run(identity)
|
||||
root = catalog.safe_path(settings.runs / identity, settings.runs, must_exist=False)
|
||||
return catalog.safe_path(root / filename, root, must_exist=False)
|
||||
|
||||
@app.get("/api/runs/{identity}/log")
|
||||
def log(identity: str, offset: int = Query(default=0, ge=0)):
|
||||
item = store.run(identity)
|
||||
path = run_path(identity, "console.log")
|
||||
data = b""
|
||||
at_eof = True
|
||||
if path.exists():
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(min(offset, path.stat().st_size))
|
||||
offset = handle.tell()
|
||||
data = handle.read(65536)
|
||||
at_eof = offset + len(data) >= path.stat().st_size
|
||||
# Keep any incomplete UTF-8 tail for the next poll.
|
||||
import codecs
|
||||
decoder = codecs.getincrementaldecoder("utf-8")("replace")
|
||||
text = decoder.decode(data, final=item["state"] in TERMINAL and at_eof)
|
||||
pending = len(decoder.getstate()[0])
|
||||
else:
|
||||
text, pending = "", 0
|
||||
return {"text": text, "next_offset": offset + len(data) - pending,
|
||||
"done": item["state"] in TERMINAL and len(data) < 65536,
|
||||
"truncated": path.exists() and path.stat().st_size >= settings.max_log}
|
||||
|
||||
@app.get("/api/runs/{identity}/log/download")
|
||||
def log_download(identity: str):
|
||||
path = run_path(identity, "console.log")
|
||||
if not path.is_file():
|
||||
raise KeyError("日志尚未生成")
|
||||
return FileResponse(path, filename=f"{identity}.log", media_type="text/plain")
|
||||
|
||||
def artifact_path(identity, artifact_id):
|
||||
item = store.run(identity)
|
||||
artifact = next((a for a in item.get("artifacts", []) if a["id"] == artifact_id), None)
|
||||
if not artifact:
|
||||
raise KeyError("结果文件不存在")
|
||||
return run_path(identity, "artifacts/" + artifact["id"]), artifact
|
||||
|
||||
@app.get("/api/runs/{identity}/artifacts/{artifact_id}/preview")
|
||||
def artifact_preview(identity: str, artifact_id: str):
|
||||
path, artifact = artifact_path(identity, artifact_id)
|
||||
if artifact["kind"] not in {"text", "json"}:
|
||||
raise ValueError("此文件只支持下载")
|
||||
with path.open("rb") as handle:
|
||||
data = handle.read(262145)
|
||||
return {"text": data[:262144].decode("utf-8", errors="replace"), "truncated": len(data) > 262144}
|
||||
|
||||
@app.get("/api/runs/{identity}/artifacts/{artifact_id}")
|
||||
def artifact_download(identity: str, artifact_id: str):
|
||||
path, artifact = artifact_path(identity, artifact_id)
|
||||
return FileResponse(path, filename=artifact["name"], media_type="application/octet-stream")
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse(static / "index.html")
|
||||
|
||||
@app.get("/project/xas-test-library/{legacy_path:path}")
|
||||
def old_bookmark(legacy_path: str):
|
||||
return RedirectResponse("/#/scripts", status_code=307)
|
||||
|
||||
if static.exists():
|
||||
app.mount("/static", StaticFiles(directory=static), name="static")
|
||||
return app
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Bounded log following and result snapshots; never modify original outputs."""
|
||||
import hashlib
|
||||
import codecs
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
from .catalog import safe_path, SECRET
|
||||
|
||||
|
||||
def files_under(paths, limit=256):
|
||||
items = []
|
||||
deadline = time.monotonic() + 2
|
||||
for root in map(Path, paths):
|
||||
if root.is_symlink():
|
||||
continue
|
||||
if root.is_file():
|
||||
items.append(root)
|
||||
elif root.is_dir():
|
||||
for directory, dirs, files in os.walk(root, followlinks=False):
|
||||
dirs[:] = sorted(d for d in dirs if not (Path(directory) / d).is_symlink())
|
||||
for name in sorted(files):
|
||||
path = Path(directory) / name
|
||||
if not path.is_symlink() and path.is_file():
|
||||
items.append(path)
|
||||
if len(items) >= limit or time.monotonic() > deadline:
|
||||
return items[:limit]
|
||||
return items[:limit]
|
||||
|
||||
|
||||
def signature(path):
|
||||
try:
|
||||
stat = path.stat()
|
||||
return (stat.st_ino, stat.st_size, stat.st_mtime_ns)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
class Collector:
|
||||
def __init__(self, settings, directory, output_paths, redactor=None, stream_filter_factory=None, file_limit=256):
|
||||
self.settings, self.directory = settings, directory
|
||||
self.file_limit = file_limit
|
||||
self.output_provider = output_paths if callable(output_paths) else lambda: [Path(p) for p in output_paths]
|
||||
self.redactor = redactor
|
||||
self.stream_filter_factory = stream_filter_factory
|
||||
self.log_filters = {}
|
||||
self.before = {str(p): signature(p) for p in files_under(self.outputs, self.file_limit)}
|
||||
self.positions = {str(p): p.stat().st_size for p in files_under(self.outputs, self.file_limit) if p.suffix == ".log"}
|
||||
self.notices = []
|
||||
self.decoders = {}
|
||||
self.last_file = None
|
||||
|
||||
@property
|
||||
def outputs(self):
|
||||
return self.output_provider()
|
||||
|
||||
def follow(self, emit):
|
||||
total = 0
|
||||
for path in files_under([*self.outputs, self.directory / "work"], self.file_limit):
|
||||
if path.suffix != ".log":
|
||||
continue
|
||||
try:
|
||||
start = self.positions.get(str(path), 0)
|
||||
if path.stat().st_size < start:
|
||||
start = 0
|
||||
self.decoders.pop(str(path), None)
|
||||
with path.open("rb") as handle:
|
||||
handle.seek(start)
|
||||
data = handle.read(32768)
|
||||
self.positions[str(path)] = handle.tell()
|
||||
if data:
|
||||
total += len(data)
|
||||
if self.stream_filter_factory:
|
||||
log_filter = self.log_filters.setdefault(str(path), self.stream_filter_factory())
|
||||
data = log_filter.feed(data)
|
||||
decoder = self.decoders.setdefault(str(path), codecs.getincrementaldecoder("utf-8")("replace"))
|
||||
text = decoder.decode(data)
|
||||
prefix = f"\n[文件日志 {path.name}]\n" if self.last_file != str(path) else ""
|
||||
emit((prefix + text).encode("utf-8"))
|
||||
self.last_file = str(path)
|
||||
except OSError:
|
||||
self.notices.append(f"日志读取失败:{path.name}")
|
||||
return total
|
||||
|
||||
def finish_follow(self, emit):
|
||||
total = 0
|
||||
while total < self.settings.max_log:
|
||||
count = self.follow(emit)
|
||||
if count == 0:
|
||||
break
|
||||
total += count
|
||||
for name, decoder in self.decoders.items():
|
||||
extra = self.log_filters[name].finish() if name in self.log_filters else b""
|
||||
tail = decoder.decode(extra, final=True)
|
||||
if tail:
|
||||
emit(tail.encode("utf-8"))
|
||||
self.decoders.clear()
|
||||
self.log_filters.clear()
|
||||
|
||||
def external_growth(self):
|
||||
growth = 0
|
||||
for path in files_under(self.outputs, limit=1025):
|
||||
stat = signature(path)
|
||||
before = self.before.get(str(path))
|
||||
if stat:
|
||||
growth += max(0, stat[1] - (before[1] if before and before[0] == stat[0] else 0))
|
||||
return growth
|
||||
|
||||
def collect(self):
|
||||
destination = self.directory / "artifacts"
|
||||
destination.mkdir(exist_ok=True)
|
||||
local = files_under([self.directory / "work"])
|
||||
changed = [p for p in files_under(self.outputs, self.file_limit) if signature(p) != self.before.get(str(p))]
|
||||
sources = [self.directory / "source" / "script.txt", *local, *changed]
|
||||
items, total = [], 0
|
||||
for path in sources[:self.settings.max_run_files - 1]:
|
||||
try:
|
||||
if not path.is_file() or path.is_symlink():
|
||||
continue
|
||||
if path.stat().st_size > self.settings.max_artifact:
|
||||
retention = "临时文件将在收尾时释放" if path in local else "原位置文件保留"
|
||||
self.notices.append(f"未归档 {path.name}:超过单文件 32 MiB 上限,{retention}")
|
||||
continue
|
||||
data = path.read_bytes()
|
||||
if len(data) > self.settings.max_artifact or total + len(data) > 64 * 1024 * 1024:
|
||||
self.notices.append("结果超过归档容量限制:原目录文件保留,未归档的临时结果将在收尾时释放")
|
||||
continue
|
||||
suffix = path.suffix.lower()
|
||||
kind = "json" if suffix == ".json" else ("text" if suffix in {".log", ".txt", ".py", ".sh", ".csv", ".md", ".jsonl"} else "file")
|
||||
if kind != "file":
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
data = SECRET.sub("[已隐藏疑似凭据]", text).encode("utf-8")
|
||||
if self.redactor:
|
||||
data = self.redactor(data)
|
||||
identity = hashlib.sha256(str(path).encode()).hexdigest()[:24]
|
||||
target = destination / identity
|
||||
with target.open("xb") as handle:
|
||||
handle.write(data)
|
||||
items.append({"id": identity, "name": path.name, "size": len(data), "kind": kind})
|
||||
total += len(data)
|
||||
except OSError:
|
||||
self.notices.append(f"未能归档:{path.name}")
|
||||
if len(sources) > self.settings.max_run_files - 1:
|
||||
self.notices.append("结果超过文件数上限:保留 63 个文件及运行摘要,未归档临时结果将释放")
|
||||
return items
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Source registration and filesystem boundaries shared by API and worker."""
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import uuid
|
||||
from .store import now
|
||||
|
||||
SECRET = re.compile(r"(?im)(?:[\w-]*(?:api[_-]?key|password|token|secret)[\w-]*)\s*[:=]\s*['\"][^'\"\r\n]{8,}['\"]|-----BEGIN [A-Z ]*PRIVATE KEY-----|Bearer\s+[A-Za-z0-9._-]{16,}")
|
||||
SERVICE = re.compile(r"(?im)\b(?:docker|podman|systemctl|sudo|pkill|killall|shutdown|reboot|nvidia-smi)\b|\brm\s+-[\w]*r|\bkill\s+-")
|
||||
SMOKE_HASH = "be858e914d87454240791da773578cb3f80f380e740f919546db347b7715a13d"
|
||||
|
||||
|
||||
def clean_source(content):
|
||||
try:
|
||||
text = content.decode("utf-8-sig")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError("脚本必须使用 UTF-8 编码") from exc
|
||||
if "\x00" in text:
|
||||
raise ValueError("不支持二进制脚本")
|
||||
return text
|
||||
|
||||
|
||||
def safe_path(path, root, must_exist=True):
|
||||
candidate, base = Path(path), Path(root)
|
||||
if not candidate.is_absolute():
|
||||
raise ValueError("路径必须为绝对路径")
|
||||
# Never follow a symlink, including symlink ancestors under the allowed root.
|
||||
resolved_base = base.resolve()
|
||||
resolved = candidate.resolve()
|
||||
if not resolved.is_relative_to(resolved_base):
|
||||
raise ValueError("路径超出允许目录")
|
||||
for component in [candidate, *candidate.parents]:
|
||||
if component.is_symlink():
|
||||
raise ValueError("不允许符号链接")
|
||||
if component == base:
|
||||
break
|
||||
if must_exist and not candidate.exists():
|
||||
raise ValueError("文件或目录不存在")
|
||||
return candidate
|
||||
|
||||
|
||||
def source_for(settings, group):
|
||||
for source in settings.sources:
|
||||
if source["id"] == group:
|
||||
return Path(source["path"])
|
||||
raise ValueError("未知来源目录")
|
||||
|
||||
|
||||
def validate_config(settings, item, config):
|
||||
from . import native_plans
|
||||
if native_plans.profile_for(settings, item):
|
||||
return native_plans.validate_config(settings, item, config)
|
||||
if config.get("native_options"):
|
||||
raise ValueError("此项目不支持原环境参数")
|
||||
defaults = {"args": [], "cwd": "", "output_paths": [], "timeout_seconds": 300, "profile": "standard"}
|
||||
config = {**defaults, **config}
|
||||
if config["profile"] not in {"standard", "legacy_smoke"}:
|
||||
raise ValueError("未知执行环境")
|
||||
if not isinstance(config["args"], list) or len(config["args"]) > 40 or any(
|
||||
not isinstance(a, str) or len(a) > 2048 or "\x00" in a for a in config["args"]):
|
||||
raise ValueError("参数需为最多 40 项的字符串数组")
|
||||
if SECRET.search("\n".join(config["args"])):
|
||||
raise ValueError("参数不能包含明文凭据")
|
||||
if config["profile"] == "legacy_smoke" and config["args"]:
|
||||
raise ValueError("轻量模型兼容环境使用已核对的固定参数,请新建脚本以配置其他测试")
|
||||
if type(config["timeout_seconds"]) is not int or not 5 <= config["timeout_seconds"] <= 7200:
|
||||
raise ValueError("运行时限须为 5 至 7200 秒")
|
||||
if not isinstance(config["output_paths"], list) or len(config["output_paths"]) > 8:
|
||||
raise ValueError("最多登记 8 个日志或结果路径")
|
||||
if not isinstance(config["cwd"], str):
|
||||
raise ValueError("工作目录格式错误")
|
||||
if item["kind"] == "managed":
|
||||
if config["cwd"] or config["output_paths"]:
|
||||
raise ValueError("上传脚本使用本次独立运行目录;通过 XAS_RUN_DIR 写入结果")
|
||||
else:
|
||||
source = source_for(settings, item["source_group"])
|
||||
base = source.parent if source.name == "scripts" else source
|
||||
cwd = safe_path(config["cwd"] or str(base), base)
|
||||
if not cwd.is_dir():
|
||||
raise ValueError("工作目录不存在")
|
||||
config["cwd"] = str(cwd)
|
||||
for raw in config["output_paths"]:
|
||||
if not isinstance(raw, str):
|
||||
raise ValueError("输出路径格式错误")
|
||||
# Only logs/results directories in that script family may become writable.
|
||||
if not any(Path(raw).resolve().is_relative_to((base / name).resolve()) for name in ("logs", "results")):
|
||||
raise ValueError("原目录输出只允许该来源的 logs/ 或 results/ 下路径")
|
||||
safe_path(raw, base)
|
||||
return config
|
||||
|
||||
|
||||
def script_path(settings, item):
|
||||
root = settings.library if item["kind"] == "managed" else source_for(settings, item["source_group"])
|
||||
path = safe_path(item["source_path"], root)
|
||||
if not path.is_file() or path.suffix not in {".py", ".sh"} or path.stat().st_size > settings.max_upload:
|
||||
raise ValueError("脚本缺失、类型不支持或大于 1 MiB")
|
||||
return path
|
||||
|
||||
|
||||
def read_code(settings, item):
|
||||
data = script_path(settings, item).read_bytes()
|
||||
text = clean_source(data)
|
||||
redacted = bool(SECRET.search(text))
|
||||
if redacted:
|
||||
text = SECRET.sub("[已隐藏疑似凭据]", text)
|
||||
return {"code": text, "filename": Path(item["source_path"]).name,
|
||||
"sha256": hashlib.sha256(data).hexdigest(), "redacted": redacted, "truncated": False}
|
||||
|
||||
|
||||
def upload(store, data, filename, name="", description="", identity=None):
|
||||
if not filename or Path(filename).name != filename or "/" in filename or "\\" in filename or not re.fullmatch(r"[\w.\- ]{1,120}\.(py|sh)", filename):
|
||||
raise ValueError("只支持文件名安全的 .py 或 .sh 脚本")
|
||||
if len(data) > store.settings.max_upload:
|
||||
raise ValueError("脚本不能超过 1 MiB")
|
||||
if not data:
|
||||
raise ValueError("脚本不能为空")
|
||||
text = clean_source(data)
|
||||
if SECRET.search(text):
|
||||
raise ValueError("脚本包含疑似明文凭据,请移除后上传")
|
||||
if identity:
|
||||
item = store.script(identity)
|
||||
if item["kind"] != "managed":
|
||||
raise ValueError("原位置脚本不支持上传覆盖,请新建脚本")
|
||||
if item["config"]["profile"] == "legacy_smoke" and hashlib.sha256(data).hexdigest() != SMOKE_HASH:
|
||||
raise ValueError("此兼容环境绑定已核对的原脚本;修改代码请新建脚本并另行配置认证")
|
||||
if (Path(filename).suffix == ".py") != (item["language"] == "python"):
|
||||
raise ValueError("新版本的语言必须与原脚本一致")
|
||||
else:
|
||||
item = {"id": uuid.uuid4().hex, "name": (name or filename)[:120], "description": description[:4000],
|
||||
"kind": "managed", "language": "python" if filename.endswith(".py") else "shell",
|
||||
"source_group": "uploaded", "source_path": "", "status": "ready", "enabled": True,
|
||||
"reason": "", "version": 0, "sha256": "", "updated_at": now(), "synthetic": False,
|
||||
"config": {"args": [], "cwd": "", "output_paths": [], "timeout_seconds": 300, "profile": "standard"}}
|
||||
return store.upload_version(item, data, filename)
|
||||
|
||||
|
||||
def discover(store):
|
||||
existing = {s["id"]: s for s in store.scripts() if s["kind"] == "external"}
|
||||
found, registered = set(), 0
|
||||
for source in store.settings.sources:
|
||||
root = Path(source["path"])
|
||||
if not root.is_dir():
|
||||
continue
|
||||
count = 0
|
||||
for directory, dirs, files in os.walk(root, followlinks=False):
|
||||
dirs[:] = [d for d in dirs if d not in {".git", "__pycache__", ".venv", "node_modules", "logs", "results"}
|
||||
and not (Path(directory) / d).is_symlink()]
|
||||
for filename in sorted(files):
|
||||
path = Path(directory) / filename
|
||||
if path.suffix not in {".py", ".sh"} or path.is_symlink():
|
||||
continue
|
||||
count += 1
|
||||
if count > 2000:
|
||||
raise ValueError("来源脚本超过扫描上限 2000,请收窄来源目录")
|
||||
identity = hashlib.sha256(str(path).encode()).hexdigest()[:32]
|
||||
found.add(identity)
|
||||
before = existing.get(identity)
|
||||
try:
|
||||
if path.stat().st_size > store.settings.max_upload:
|
||||
raise ValueError("大于 1 MiB,仅登记")
|
||||
raw = path.read_bytes()
|
||||
code = clean_source(raw)
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
blocked = bool(SERVICE.search(code) or SECRET.search(code))
|
||||
reason = "涉及服务、设备管理或疑似凭据,需管理员审查后接入" if blocked else "请确认参数、依赖和输出路径后启用"
|
||||
except (OSError, ValueError):
|
||||
digest, blocked, reason = "", True, "文件不可读取或格式不支持"
|
||||
item = before or {"id": identity, "name": filename, "description": "",
|
||||
"kind": "external", "language": "python" if path.suffix == ".py" else "shell",
|
||||
"source_group": source["id"], "source_path": str(path), "version": 1, "enabled": False,
|
||||
"config": {"args": [], "cwd": str(root.parent if root.name == "scripts" else root),
|
||||
"output_paths": [], "timeout_seconds": 300, "profile": "standard"}}
|
||||
from .native_plans import profile_for
|
||||
native = profile_for(store.settings, item)
|
||||
if native:
|
||||
blocked = not native.get("available", True)
|
||||
reason = native.get("unavailable_reason", "原环境方案已登记,请确认参数后启用")
|
||||
if before and before["sha256"] != digest:
|
||||
item["version"] += 1
|
||||
item["enabled"] = False
|
||||
reason = "源文件已变动,请重新确认配置并启用"
|
||||
if blocked:
|
||||
item["enabled"] = False
|
||||
item.update(sha256=digest, blocked=blocked, reason="" if item["enabled"] else reason,
|
||||
status="ready" if item["enabled"] else "needs_configuration", updated_at=now())
|
||||
store.save_script(item)
|
||||
registered += int(before is None)
|
||||
for identity, item in existing.items():
|
||||
if identity not in found:
|
||||
item.update(status="missing", enabled=False, reason="原脚本当前不存在或来源不可访问", updated_at=now())
|
||||
store.save_script(item)
|
||||
return {"registered": registered, "missing": len(set(existing) - found), "total": len(found)}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""In-container bootstrap. Input contains optional credentials; never persist it."""
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, request, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
class BudgetRelay(HTTPServer):
|
||||
def __init__(self, address, upstream, key):
|
||||
super().__init__(address, RelayHandler)
|
||||
self.upstream, self.key = upstream, key
|
||||
self.gets, self.posts = 0, 0
|
||||
self.records = []
|
||||
|
||||
|
||||
class RelayHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path != "/v1/models" or self.server.gets >= 1:
|
||||
self.send_error(403, "Request outside run budget")
|
||||
return
|
||||
self.server.gets += 1
|
||||
self.forward(None, 10)
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != "/v1/chat/completions" or self.server.posts >= 3:
|
||||
self.send_error(403, "Request outside run budget")
|
||||
return
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 < length <= 8192:
|
||||
raise ValueError()
|
||||
data = self.rfile.read(length)
|
||||
body = json.loads(data)
|
||||
if body.get("model") != "deepseek-v4.1-flash" or body.get("max_tokens") != 512 or body.get("stream"):
|
||||
raise ValueError()
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
self.send_error(400, "Unexpected smoke request")
|
||||
return
|
||||
self.server.posts += 1 # Count before I/O: timeouts never replenish the budget.
|
||||
self.forward(data, 600)
|
||||
|
||||
def forward(self, data, timeout):
|
||||
started = time.monotonic()
|
||||
record = {"path": self.path, "status": None}
|
||||
try:
|
||||
req = urllib.request.Request(self.server.upstream + self.path, data=data, headers={
|
||||
"Content-Type": "application/json", "Authorization": "Bearer " + self.server.key})
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
|
||||
with opener.open(req, timeout=timeout) as response:
|
||||
status, payload = response.status, response.read(2 * 1024 * 1024 + 1)
|
||||
if len(payload) > 2 * 1024 * 1024:
|
||||
raise ValueError("Response exceeded limit")
|
||||
record["status"] = status
|
||||
decoded = json.loads(payload)
|
||||
if self.path.endswith("completions"):
|
||||
record["response"] = decoded
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
except urllib.error.HTTPError as exc:
|
||||
record["status"] = exc.code
|
||||
self.send_error(exc.code, "Upstream request failed")
|
||||
except (OSError, ValueError) as exc:
|
||||
record["error"] = type(exc).__name__
|
||||
try:
|
||||
self.send_error(502, "Upstream outcome uncertain; no retry")
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
record["seconds"] = round(time.monotonic() - started, 3)
|
||||
self.server.records.append(record)
|
||||
print(json.dumps({"event": "model_request", **{k: v for k, v in record.items() if k != "response"}}), flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
payload = json.loads(sys.stdin.buffer.readline(16384))
|
||||
relay = None
|
||||
if payload.get("profile") == "legacy_smoke":
|
||||
relay = BudgetRelay(("127.0.0.1", 18100), payload.pop("upstream"), payload.pop("key"))
|
||||
threading.Thread(target=relay.serve_forever, daemon=True).start()
|
||||
command = (["python3", "-u"] if payload["language"] == "python" else ["/bin/bash"]) + [payload["script"]] + payload["args"]
|
||||
print("[控制台] 测试进程已启动,输出将持续显示。", flush=True)
|
||||
status = subprocess.call(command, stdin=subprocess.DEVNULL)
|
||||
if relay:
|
||||
report = {"synthetic": False, "scope": "接口冒烟检查,非模型质量评测", "get_attempts": relay.gets,
|
||||
"completion_attempts": relay.posts, "records": relay.records, "script_exit_code": status}
|
||||
valid = len(relay.records) == 4 and all(r.get("status") == 200 for r in relay.records)
|
||||
report["requests_completed"] = valid
|
||||
report["message"] = "请求均返回成功;请结合实际回答与 finish_reason 判断内容" if valid else "存在失败或未完成请求;未自动重试"
|
||||
Path(os.environ["XAS_RUN_DIR"], "smoke-response.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
if not valid:
|
||||
status = status or 1
|
||||
relay.shutdown()
|
||||
print(f"[控制台] 测试进程结束,退出码 {status}。", flush=True)
|
||||
return status
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Manage only this console's transient units; reattach after worker restarts."""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from .native_plans import build_plan, public_plan
|
||||
from .store import now
|
||||
|
||||
|
||||
def unit_name(identity):
|
||||
if not re.fullmatch(r"[a-f0-9]{32}", identity):raise ValueError("运行编号不合法")
|
||||
return "adamwen-native-" + identity + ".service"
|
||||
|
||||
|
||||
def inspect(identity):
|
||||
unit = unit_name(identity)
|
||||
result = subprocess.run(["/usr/bin/systemctl", "show", unit, "--property=Id,LoadState,ActiveState,SubState,Description,Result,ExecMainStatus,MainPID,TasksCurrent"],
|
||||
capture_output=True, timeout=8, text=True)
|
||||
values = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line)
|
||||
if values.get("LoadState") == "not-found":return None
|
||||
if result.returncode or not values.get("LoadState"):
|
||||
raise OSError("systemd 状态查询失败,任务状态未知")
|
||||
if values.get("Id") != unit or values.get("Description") != "XAS native " + identity:
|
||||
raise ValueError("原环境服务归属核验失败")
|
||||
return values
|
||||
|
||||
|
||||
def stop_unit(identity):
|
||||
if inspect(identity):
|
||||
subprocess.run(["/usr/bin/systemctl", "--no-block", "stop", unit_name(identity)], check=True, capture_output=True, timeout=8)
|
||||
|
||||
|
||||
class NativeExecutor:
|
||||
def __init__(self, worker):
|
||||
self.worker, self.store, self.settings = worker, worker.store, worker.settings
|
||||
|
||||
def execute(self, run):
|
||||
if self.store.run(run["id"])["cancel_requested"]:
|
||||
self.store.finish(run["id"], "cancelled", message="启动前已取消,原脚本未执行")
|
||||
return
|
||||
directory = self.settings.runs / run["id"]
|
||||
directory.mkdir(mode=0o750, exist_ok=False)
|
||||
try:
|
||||
script = {**run, "id": run["script_id"]}
|
||||
plan = build_plan(self.settings, script, run_id=run["id"])
|
||||
if plan["plan_token"] != run["execution"]["plan_token"] or not plan["ready"]:
|
||||
raise ValueError("执行前原入口或环境已变化,请重新预检;未启动原测试")
|
||||
if plan["impact"]["requires_confirmation"] and not run["execution"].get("impact_acknowledged"):
|
||||
raise ValueError("缺少本次运行影响确认")
|
||||
if inspect(run["id"]):raise ValueError("本次原环境服务已存在,拒绝重复启动")
|
||||
run = self.store.update_execution(run["id"], {**public_plan(plan), "unit": unit_name(run["id"])})
|
||||
project = Path(self.worker.config["project"])
|
||||
log = directory / "console.log"
|
||||
log.touch(mode=0o600)
|
||||
with (directory / "native-permit").open("x") as permit:
|
||||
permit.write(run["execution"]["plan_token"])
|
||||
command = ["/usr/bin/systemd-run", "--unit=" + unit_name(run["id"]), "--description=XAS native " + run["id"],
|
||||
"--service-type=exec", "--property=RemainAfterExit=yes", "--property=KillMode=control-group",
|
||||
"--property=TimeoutStopSec=120", "--property=RuntimeMaxSec=" + str(plan["timeout_seconds"]),
|
||||
"--property=WorkingDirectory=" + str(project), "--property=User=root", "--property=UMask=0022",
|
||||
"--property=StandardOutput=append:" + str(log), "--property=StandardError=append:" + str(log),
|
||||
"--setenv=XAS_CONSOLE_ROOT=" + str(self.settings.root), "--setenv=PYTHONDONTWRITEBYTECODE=1",
|
||||
"--", "/usr/bin/python3", "-m", "console.native_job", run["id"]]
|
||||
if self.store.run(run["id"])["cancel_requested"]:
|
||||
(directory / "native-permit").unlink(missing_ok=True)
|
||||
self.store.finish(run["id"], "cancelled", message="启动前已取消,原脚本未执行")
|
||||
return
|
||||
result = subprocess.run(command, capture_output=True, timeout=15, text=True)
|
||||
if result.returncode:
|
||||
raise ValueError("systemd 未接受原环境任务,原脚本未启动")
|
||||
except Exception as exc:
|
||||
message = str(exc) if isinstance(exc, ValueError) else "原环境启动失败,未自动重试"
|
||||
# A timeout can leave a submitted unit; inspect it instead of submitting again.
|
||||
if inspect(run["id"]):
|
||||
return self.monitor(run)
|
||||
(directory / "native-permit").unlink(missing_ok=True)
|
||||
if (directory / "native-started").exists():
|
||||
self.worker.stopping = True
|
||||
self.store.finish(run["id"], "interrupted", message="原入口可能已启动但服务状态未知,执行器停止接单;请核对后再操作")
|
||||
return
|
||||
(directory / "console.log").write_text(message + "\n", encoding="utf-8")
|
||||
self.store.finish(run["id"], "failed", message=message)
|
||||
return
|
||||
self.monitor(run)
|
||||
|
||||
def monitor(self, run):
|
||||
requested = False
|
||||
failures = 0
|
||||
while not self.worker.stopping:
|
||||
self.store.heartbeat()
|
||||
try:
|
||||
state = inspect(run["id"])
|
||||
failures = 0
|
||||
except (OSError, ValueError, subprocess.TimeoutExpired):
|
||||
failures += 1
|
||||
self.store.running_message(run["id"], "无法确认原环境服务状态,保留运行占位,不启动下一项")
|
||||
if failures >= 10:self.worker.stopping = True
|
||||
time.sleep(1)
|
||||
continue
|
||||
if state is None or state.get("SubState") in {"exited", "dead", "failed"}:
|
||||
return self.finish(run, state)
|
||||
if self.store.run(run["id"])["cancel_requested"] and not requested:
|
||||
stop_unit(run["id"])
|
||||
requested = True
|
||||
time.sleep(.5)
|
||||
# The independent unit keeps running. The next worker attaches without replaying it.
|
||||
|
||||
def finish(self, run, unit):
|
||||
directory = self.settings.runs / run["id"]
|
||||
directory.mkdir(mode=0o750, exist_ok=True)
|
||||
outcome_file = directory / "unit-outcome.json"
|
||||
outcome = unit
|
||||
if outcome_file.exists():outcome = json.loads(outcome_file.read_text())
|
||||
elif unit:outcome_file.write_text(json.dumps(unit))
|
||||
result_file = directory / "native-result.json"
|
||||
result = json.loads(result_file.read_text()) if result_file.is_file() else {}
|
||||
current = self.store.run(run["id"])
|
||||
code = result.get("exit_code")
|
||||
if current["cancel_requested"]:state, message = "cancelled", "已取消本次宿主入口;请核对原脚本启动的 Docker/tmux 任务和模型恢复情况"
|
||||
elif outcome and outcome.get("Result") == "timeout":state, message = "timed_out", "原环境运行超过时限;原服务是否恢复需结合收尾记录核对"
|
||||
elif not result:state, message = "interrupted", "原环境任务结束但完整结果未取得;未自动重跑,请查看日志和原服务状态"
|
||||
elif result.get("stopped"):state, message = "interrupted", "原环境入口收到外部停止信号,服务恢复情况未确认"
|
||||
elif code == 0:state, message = "succeeded", "原入口正常退出;业务结论和共享资源状态请查看报告"
|
||||
else:state, message = "failed", f"原入口退出码 {code},请查看日志"
|
||||
sessions = result.get("runtime_changes", {}).get("new_tmux_sessions", [])
|
||||
if sessions and state == "succeeded":
|
||||
state, message = "interrupted", "原入口已退出,但检测到新增 tmux 会话;后台任务完成状态未确认,请核对原工具"
|
||||
artifacts = result.get("artifacts", [])
|
||||
summary = {"run_id": run["id"], "state": state, "exit_code": code, "message": message,
|
||||
"finished_at": now(), "source_sha256": run["sha256"], "execution": run["execution"],
|
||||
"runtime_changes": result.get("runtime_changes"), "archive_notices": result.get("archive_notices", []),
|
||||
"log_truncated": result.get("log_truncated", False)}
|
||||
directory.mkdir(mode=0o750, exist_ok=True)
|
||||
(directory / "artifacts").mkdir(exist_ok=True)
|
||||
data = json.dumps(summary, ensure_ascii=False, indent=2).encode()
|
||||
identity = hashlib.sha256((str(directory) + "/summary.json").encode()).hexdigest()[:24]
|
||||
(directory / "artifacts" / identity).write_bytes(data)
|
||||
artifacts.append({"id": identity, "name": "summary.json", "kind": "json", "size": len(data)})
|
||||
if unit:
|
||||
if not self.wait_stopped(run):return
|
||||
subprocess.run(["/usr/bin/systemctl", "reset-failed", unit_name(run["id"])], capture_output=True, timeout=8)
|
||||
self.store.finish(run["id"], state, message=message, exit_code=code, artifacts=artifacts)
|
||||
|
||||
def wait_stopped(self, run):
|
||||
try:
|
||||
stop_unit(run["id"])
|
||||
deadline = time.monotonic() + 130
|
||||
while time.monotonic() < deadline and not self.worker.stopping:
|
||||
self.store.heartbeat()
|
||||
current = inspect(run["id"])
|
||||
if current is None:return True
|
||||
if current.get("ActiveState") in {"inactive", "failed"} and current.get("MainPID", "0") == "0" and current.get("TasksCurrent", "0") in {"0", "[not set]"}:
|
||||
return True
|
||||
time.sleep(.5)
|
||||
except (OSError, ValueError, subprocess.TimeoutExpired, subprocess.CalledProcessError):pass
|
||||
self.store.running_message(run["id"], "本次进程收尾未确认,保留运行占位,执行器停止接单")
|
||||
self.worker.stopping = True
|
||||
return False
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Read-only output discovery, history preservation and small runtime snapshots."""
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
from .artifacts import files_under
|
||||
|
||||
|
||||
def outputs(patterns):
|
||||
paths = []
|
||||
for pattern in patterns:
|
||||
expanded = glob.iglob(pattern) if glob.has_magic(pattern) else [pattern]
|
||||
for name in expanded:
|
||||
path = Path(name)
|
||||
if path.is_symlink():
|
||||
continue
|
||||
if path.is_file():paths.append(path)
|
||||
elif path.is_dir():paths.extend(files_under([path], limit=4097))
|
||||
if len(paths) > 4096:
|
||||
raise ValueError("原输出文件超过 4096 项,请收窄登记输出范围")
|
||||
return sorted(set(paths))
|
||||
|
||||
|
||||
def preserve(paths, directory, maximum=64 * 1024**3):
|
||||
sizes = [path.stat().st_size for path in paths]
|
||||
total = sum(sizes)
|
||||
if total > maximum or shutil.disk_usage(directory).free < total + 10 * 1024**3:
|
||||
raise ValueError("保护原有同名结果所需空间不足或超过 64 GiB,本次未启动")
|
||||
destination = directory / "previous-outputs"
|
||||
destination.mkdir(mode=0o700)
|
||||
manifest = []
|
||||
for path in paths:
|
||||
identity = hashlib.sha256(str(path).encode()).hexdigest()
|
||||
target = destination / identity
|
||||
before = path.stat()
|
||||
with path.open("rb") as source, target.open("xb") as output:
|
||||
shutil.copyfileobj(source, output, length=1024 * 1024)
|
||||
after = path.stat()
|
||||
if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
|
||||
raise ValueError("原输出仍被其他任务写入,请等待后再试")
|
||||
manifest.append({"path": str(path), "backup": identity, "size": before.st_size})
|
||||
(destination / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return len(manifest)
|
||||
|
||||
|
||||
def runtime_snapshot():
|
||||
result = {"containers": [], "tmux_sessions": [], "errors": []}
|
||||
try:
|
||||
raw = subprocess.run(["/usr/bin/docker", "ps", "-a", "--format", "{{json .}}"], capture_output=True, timeout=10, text=True)
|
||||
if raw.returncode == 0:
|
||||
for line in raw.stdout.splitlines():
|
||||
value = json.loads(line)
|
||||
result["containers"].append({key: value.get(key) for key in ("ID", "Names", "Image", "State", "Status")})
|
||||
else:result["errors"].append("容器状态读取失败")
|
||||
except (OSError, ValueError, subprocess.TimeoutExpired):result["errors"].append("容器状态不可用")
|
||||
try:
|
||||
raw = subprocess.run(["/usr/bin/tmux", "list-sessions", "-F", "#{session_id}\t#{session_name}"], capture_output=True, timeout=5, text=True)
|
||||
if raw.returncode == 0:
|
||||
result["tmux_sessions"] = [dict(zip(("id", "name"), line.split("\t", 1))) for line in raw.stdout.splitlines() if "\t" in line]
|
||||
except (OSError, subprocess.TimeoutExpired):result["errors"].append("tmux 状态不可用")
|
||||
return result
|
||||
|
||||
|
||||
def differences(before, after):
|
||||
old = {v["ID"]: v for v in before["containers"]}
|
||||
new = {v["ID"]: v for v in after["containers"]}
|
||||
sessions = {v["id"] for v in before["tmux_sessions"]}
|
||||
return {"created_containers": [v for k, v in new.items() if k not in old],
|
||||
"removed_containers": [v for k, v in old.items() if k not in new],
|
||||
"changed_containers": [v for k, v in new.items() if k in old and v["State"] != old[k]["State"]],
|
||||
"new_tmux_sessions": [v for v in after["tmux_sessions"] if v["id"] not in sessions],
|
||||
"note": "共享宿主前后快照差异不等于资源归属证明;不会自动删除这些容器或会话。"}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Bounded IPC and streaming redaction for original-environment runs."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import subprocess
|
||||
|
||||
ANSI = re.compile(rb"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[()][0-2A-Z]|[@-_])")
|
||||
SENSITIVE_NAME = re.compile(r"(?i)(?:api_?key|password|passwd|secret|access_token|auth_token|credential)")
|
||||
|
||||
|
||||
def strip_terminal(data, state="text"):
|
||||
output = bytearray()
|
||||
for value in data:
|
||||
if state == "text":
|
||||
if value == 27:state = "escape"
|
||||
else:output.append(value)
|
||||
elif state == "escape":
|
||||
state = "csi" if value == 91 else "osc" if value == 93 else "charset" if value in (40, 41) else "text"
|
||||
elif state == "csi":
|
||||
if 64 <= value <= 126:state = "text"
|
||||
elif value == 27:state = "escape"
|
||||
elif state == "osc":
|
||||
if value == 7:state = "text"
|
||||
elif value == 27:state = "osc_escape"
|
||||
elif state == "osc_escape":
|
||||
state = "text" if value in (92, 7) else "osc"
|
||||
else:state = "text"
|
||||
return bytes(output), state
|
||||
|
||||
|
||||
class SecretFilter:
|
||||
def __init__(self, secrets):
|
||||
values = set()
|
||||
for value in secrets:
|
||||
values.update(v.encode() for v in [value, *value.splitlines()] if len(v) >= 4)
|
||||
self.secrets = sorted(values, key=len, reverse=True)
|
||||
self.keep = max([256, *[len(v) for v in self.secrets]])
|
||||
self.pending = b""
|
||||
self.terminal_state = "text"
|
||||
|
||||
def clean(self, data):
|
||||
data, _ = strip_terminal(data)
|
||||
for secret in self.secrets:
|
||||
data = data.replace(secret, b"[REDACTED]")
|
||||
return data
|
||||
|
||||
def feed(self, data):
|
||||
plain, self.terminal_state = strip_terminal(data, self.terminal_state)
|
||||
self.pending += plain
|
||||
for secret in self.secrets:self.pending = self.pending.replace(secret, b"[REDACTED]")
|
||||
# Emit prompts without requiring a newline, retaining only a possible secret prefix.
|
||||
hold = 0
|
||||
for secret in self.secrets:
|
||||
for size in range(min(len(secret) - 1, len(self.pending)), hold, -1):
|
||||
if self.pending.endswith(secret[:size]):
|
||||
hold = size
|
||||
break
|
||||
cut = len(self.pending) - hold
|
||||
result, self.pending = self.pending[:cut], self.pending[cut:]
|
||||
return result
|
||||
|
||||
def finish(self):
|
||||
partial = len(self.pending) >= 4 and any(s.startswith(self.pending) for s in self.secrets)
|
||||
result = b"[REDACTED]" if partial else self.clean(self.pending)
|
||||
self.pending = b""
|
||||
self.terminal_state = "text"
|
||||
return result
|
||||
|
||||
|
||||
def load_environment(plan):
|
||||
import pwd
|
||||
account = pwd.getpwnam(plan["user"])
|
||||
env = {"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME": account.pw_dir,
|
||||
"USER": account.pw_name, "LOGNAME": account.pw_name, "LANG": "C.UTF-8", "TERM": "xterm",
|
||||
"PYTHONUNBUFFERED": "1", "PYTHONDONTWRITEBYTECODE": "1"}
|
||||
secrets = []
|
||||
for path in plan.get("env_setup_files", []):
|
||||
# Only server-owned recipe paths reach this command; output stays in private process memory.
|
||||
command = ["/bin/bash", "-c", 'set -a; source "$1" >/dev/null; /usr/bin/env -0', "xas-env", path]
|
||||
result = subprocess.run(command, env=env, cwd=plan["cwd"], capture_output=True, timeout=10)
|
||||
if result.returncode:
|
||||
raise ValueError("原环境配置加载失败")
|
||||
env.update(dict(v.decode().split("=", 1) for v in result.stdout.split(b"\0") if b"=" in v))
|
||||
secrets.extend(v for k, v in env.items() if SENSITIVE_NAME.search(k) and v)
|
||||
for path in plan.get("credential_files", []):
|
||||
for line in Path(path).read_text().splitlines():
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
match = re.match(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$", line.strip())
|
||||
if not match:
|
||||
raise ValueError("原认证配置格式不支持")
|
||||
parts = shlex.split(match[2], comments=True)
|
||||
if len(parts) > 1:
|
||||
raise ValueError("原认证配置格式不支持")
|
||||
env[match[1]] = parts[0] if parts else ""
|
||||
if SENSITIVE_NAME.search(match[1]) and env[match[1]]:secrets.append(env[match[1]])
|
||||
env.update(plan.get("environment", {}))
|
||||
secrets.extend(v for k, v in env.items() if SENSITIVE_NAME.search(k) and v)
|
||||
return env, secrets
|
||||
|
||||
|
||||
def request(settings, payload):
|
||||
if not hasattr(socket, "AF_UNIX"):
|
||||
raise ValueError("原环境执行器仅在 Linux 服务器可用")
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(15)
|
||||
connection.connect(str(settings.native_socket))
|
||||
connection.sendall(json.dumps(payload).encode() + b"\n")
|
||||
stream = connection.makefile("rb")
|
||||
data = stream.readline(131073)
|
||||
if len(data) > 131072:
|
||||
raise ValueError("原环境执行器响应过大")
|
||||
result = json.loads(data)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("原环境执行器暂不可用,请稍后重试") from exc
|
||||
if "error" in result:
|
||||
raise ValueError(result["error"])
|
||||
return result
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Original-environment launcher, supervised by a per-run systemd service."""
|
||||
import errno
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pty
|
||||
import pwd
|
||||
import selectors
|
||||
import signal
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
from .artifacts import Collector
|
||||
from .native_files import differences, outputs, preserve, runtime_snapshot
|
||||
from .native_io import SecretFilter, load_environment
|
||||
from .native_plans import build_plan, profile_for
|
||||
from .settings import Settings
|
||||
from .store import Store, now
|
||||
|
||||
|
||||
def acquire_terminal():
|
||||
# Popen has already created the session and duplicated the slave onto stdin.
|
||||
# The original tmux/menu tools need a controlling terminal, not just isatty().
|
||||
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
|
||||
|
||||
|
||||
def execute(settings, run):
|
||||
script = {**run, "id": run["script_id"]}
|
||||
plan = build_plan(settings, script, run_id=run["id"])
|
||||
if plan["plan_token"] != run["execution"]["plan_token"] or not plan["ready"]:
|
||||
raise ValueError("执行前环境或参数发生变化,未启动原测试")
|
||||
if plan["impact"]["requires_confirmation"] and not run["execution"].get("impact_acknowledged"):
|
||||
raise ValueError("缺少本次影响确认")
|
||||
directory = settings.runs / run["id"]
|
||||
(directory / "work").mkdir(mode=0o700, exist_ok=True)
|
||||
env, secrets = load_environment(plan)
|
||||
redactor = SecretFilter(secrets)
|
||||
file_redactor = SecretFilter(secrets)
|
||||
limit, written, truncated = settings.max_log, 0, False
|
||||
def emit(data):
|
||||
nonlocal written, truncated
|
||||
remaining = limit - written
|
||||
if len(data) > remaining:truncated = True
|
||||
if remaining > 0:
|
||||
sys.stdout.buffer.write(data[:remaining]);sys.stdout.buffer.flush()
|
||||
written += min(len(data), remaining)
|
||||
def message(text):emit(("[原环境] " + text + "\n").encode())
|
||||
collector = Collector(settings, directory, lambda: outputs(plan["output_paths"]), redactor=file_redactor.clean,
|
||||
stream_filter_factory=lambda: SecretFilter(secrets), file_limit=4096)
|
||||
profile = profile_for(settings, script)
|
||||
if profile.get("preserve_existing_outputs"):
|
||||
message("正在备份原有同名结果,原测试尚未开始。")
|
||||
count = preserve(collector.outputs, directory)
|
||||
message(f"已保护 {count} 个原有结果文件。")
|
||||
before = runtime_snapshot()
|
||||
(directory / "runtime-before.json").write_text(json.dumps(before, ensure_ascii=False), encoding="utf-8")
|
||||
stopped = False
|
||||
def stop(signum, frame):
|
||||
nonlocal stopped
|
||||
stopped = True # systemd sends the same signal to the full cgroup and enforces the grace period.
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
signal.signal(signal.SIGINT, stop)
|
||||
fifo = settings.native_socket.parent / ("input-" + run["id"])
|
||||
master = slave = input_fd = None
|
||||
selector = selectors.DefaultSelector()
|
||||
process = None
|
||||
code = 1
|
||||
try:
|
||||
account = pwd.getpwnam(plan["user"])
|
||||
options = {"cwd": plan["cwd"], "env": env, "start_new_session": True,
|
||||
"user": account.pw_uid, "group": account.pw_gid, "extra_groups": []}
|
||||
if stopped or Store(settings).run(run["id"])["cancel_requested"]:
|
||||
message("已在创建原入口前取消,原测试未启动。")
|
||||
return 1
|
||||
if plan["interactive"]:
|
||||
master, slave = pty.openpty()
|
||||
attributes = termios.tcgetattr(slave)
|
||||
attributes[3] &= ~(termios.ECHO | termios.ECHONL)
|
||||
termios.tcsetattr(slave, termios.TCSANOW, attributes)
|
||||
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 36, 120, 0, 0))
|
||||
process = subprocess.Popen(plan["argv"], stdin=slave, stdout=slave, stderr=slave,
|
||||
preexec_fn=acquire_terminal, **options)
|
||||
os.close(slave);slave = None
|
||||
os.mkfifo(fifo, 0o600)
|
||||
input_fd = os.open(fifo, os.O_RDWR | os.O_NONBLOCK)
|
||||
selector.register(input_fd, selectors.EVENT_READ, "input")
|
||||
output_fd = master
|
||||
else:
|
||||
process = subprocess.Popen(plan["argv"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **options)
|
||||
output_fd = process.stdout.fileno()
|
||||
os.set_blocking(output_fd, False)
|
||||
selector.register(output_fd, selectors.EVENT_READ, "output")
|
||||
message("已在原目录启动登记入口。取消仅处理本次宿主进程,Docker/tmux 或远端推理需结合收尾记录核对。")
|
||||
last_follow = time.monotonic()
|
||||
while selector.get_map():
|
||||
for key, _ in selector.select(.3):
|
||||
try:data = os.read(key.fd, 4096)
|
||||
except OSError as exc:
|
||||
if exc.errno in (errno.EIO, errno.EBADF):data = b""
|
||||
elif exc.errno == errno.EAGAIN:continue
|
||||
else:raise
|
||||
if key.data == "input":
|
||||
if data and process.poll() is None:
|
||||
os.write(master, data)
|
||||
elif data:emit(redactor.feed(data))
|
||||
else:selector.unregister(key.fd)
|
||||
if time.monotonic() - last_follow > 1:
|
||||
collector.follow(lambda data: emit(file_redactor.clean(data)))
|
||||
last_follow = time.monotonic()
|
||||
if process.poll() is not None:
|
||||
if input_fd is not None:
|
||||
try:selector.unregister(input_fd)
|
||||
except KeyError:pass
|
||||
if not any(k.data == "output" for k in selector.get_map().values()):break
|
||||
code = process.wait()
|
||||
emit(redactor.finish())
|
||||
finally:
|
||||
selector.close()
|
||||
if process and process.poll() is None:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
try:process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:os.killpg(process.pid, signal.SIGKILL);process.wait(timeout=5)
|
||||
for descriptor in (input_fd, master, slave):
|
||||
if descriptor is not None:
|
||||
try:os.close(descriptor)
|
||||
except OSError:pass
|
||||
if process and process.stdout:process.stdout.close()
|
||||
if fifo.exists() and stat.S_ISFIFO(fifo.lstat().st_mode):fifo.unlink()
|
||||
after = runtime_snapshot()
|
||||
changes = differences(before, after)
|
||||
if plan["interactive"] and code == 0 and changes["new_tmux_sessions"] and not stopped:
|
||||
sessions = {item["id"] for item in changes["new_tmux_sessions"]}
|
||||
message("原菜单已退出,观察到新增 tmux 会话;继续跟踪其结束,不把入口退出当作后台测试完成。")
|
||||
while sessions and not stopped:
|
||||
collector.follow(lambda data: emit(file_redactor.clean(data)))
|
||||
time.sleep(2)
|
||||
current = runtime_snapshot()
|
||||
sessions &= {item["id"] for item in current["tmux_sessions"]}
|
||||
after = runtime_snapshot()
|
||||
changes = differences(before, after)
|
||||
(directory / "work/runtime-changes.json").write_text(json.dumps(changes, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
collector.finish_follow(lambda data: emit(file_redactor.clean(data)))
|
||||
artifacts = collector.collect()
|
||||
result = {"exit_code": code, "stopped": stopped, "finished_at": now(), "artifacts": artifacts,
|
||||
"log_truncated": truncated, "archive_notices": list(dict.fromkeys(collector.notices))[:32],
|
||||
"runtime_changes": changes, "command_preview": plan["command_preview"]}
|
||||
tmp = directory / "native-result.tmp"
|
||||
tmp.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8")
|
||||
os.replace(tmp, directory / "native-result.json")
|
||||
message(f"原入口进程退出,退出码 {code}。共享资源前后变化已记录;不代表原模型已恢复。")
|
||||
return code
|
||||
|
||||
|
||||
def main():
|
||||
identity = sys.argv[1]
|
||||
import re
|
||||
if not re.fullmatch(r"[a-f0-9]{32}", identity):return 2
|
||||
settings = Settings.from_env()
|
||||
run = Store(settings).run(identity)
|
||||
if run["state"] != "running" or run.get("execution", {}).get("mode") != "native":return 2
|
||||
directory = settings.runs / identity
|
||||
try:os.rename(directory / "native-permit", directory / "native-started")
|
||||
except OSError:return 2
|
||||
try:return execute(settings, run)
|
||||
except Exception as exc:
|
||||
print(f"[原环境] 启动或收尾失败:{type(exc).__name__}。未自动重试。", flush=True)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Trusted original-environment recipes; user input never supplies executable code."""
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
from urllib.parse import urlsplit
|
||||
from .catalog import script_path, safe_path
|
||||
|
||||
MAX_TIMEOUT = 72 * 3600
|
||||
PLACEHOLDER = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
|
||||
|
||||
|
||||
def profile_for(settings, script):
|
||||
if script["kind"] != "external":
|
||||
return None
|
||||
source = next((s for s in settings.sources if s["id"] == script["source_group"]), None)
|
||||
if not source:
|
||||
return None
|
||||
expected = Path(script["source_path"])
|
||||
for profile in settings.native_catalog.get("profiles", []):
|
||||
if profile["source_group"] == source["id"] and Path(source["path"]) / profile["entry"] == expected:
|
||||
return profile
|
||||
return None
|
||||
|
||||
|
||||
def validate_options(profile, options):
|
||||
if not isinstance(options, dict):
|
||||
raise ValueError("原环境参数必须是对象")
|
||||
fields = {f["key"]: f for f in profile.get("parameters", [])}
|
||||
if set(options) - set(fields):
|
||||
raise ValueError("包含启动方案未登记的参数")
|
||||
result = {}
|
||||
for key, field in fields.items():
|
||||
value = options.get(key, field.get("default"))
|
||||
label = field.get("label", key)
|
||||
if value is None or value == "":
|
||||
if field.get("required", True):
|
||||
raise ValueError(f"请填写{label}")
|
||||
result[key] = ""
|
||||
continue
|
||||
kind = field["type"]
|
||||
if kind in {"integer", "number"}:
|
||||
valid = type(value) is int if kind == "integer" else type(value) in (int, float)
|
||||
if not valid or not math.isfinite(value) or value < field.get("min", 0) or value > field.get("max", 10000000):
|
||||
raise ValueError(f"{label}超出允许范围或类型不正确")
|
||||
elif kind == "select":
|
||||
if value not in [o["value"] for o in field["options"]]:
|
||||
raise ValueError(f"{label}必须从登记选项中选择")
|
||||
elif kind == "text":
|
||||
if not isinstance(value, str) or len(value) > 1024 or re.search(r"[\x00-\x1f;$`|&<>\\]", value):
|
||||
raise ValueError(f"{label}格式不正确")
|
||||
if "pattern" in field and not re.fullmatch(field["pattern"], value):
|
||||
raise ValueError(f"{label}格式不正确")
|
||||
else:
|
||||
raise ValueError("启动方案包含未知参数类型")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def resolve_target(target):
|
||||
target = {**target, "environment": dict(target.get("environment", {}))}
|
||||
path = target.get("config_file")
|
||||
if not path:
|
||||
return target
|
||||
values = {}
|
||||
for line in Path(path).read_text().splitlines():
|
||||
match = re.match(r"^(PORT|SERVED|CT_OFF|CT_ON)=(.*)$", line.strip())
|
||||
if not match:continue
|
||||
parts = shlex.split(match[2], comments=True)
|
||||
if len(parts) != 1 or "$" in parts[0] or "`" in parts[0]:
|
||||
raise ValueError("原配置的模型/端口包含动态表达式,需管理员核对")
|
||||
values[match[1]] = parts[0]
|
||||
if "PORT" in values:
|
||||
port = int(values["PORT"])
|
||||
if not 1 <= port <= 65535:raise ValueError("原模型端口不合法")
|
||||
target["url"] = "http://127.0.0.1:" + str(port)
|
||||
if "SERVED" in values:target["model"] = values["SERVED"]
|
||||
for key in ("CT_OFF", "CT_ON"):
|
||||
if key in values:target["environment"][key] = values[key]
|
||||
return target
|
||||
|
||||
|
||||
def public_execution(settings, script):
|
||||
profile = profile_for(settings, script)
|
||||
if profile is None:
|
||||
return {"mode": "container", "interactive": False, "max_timeout_seconds": 7200}
|
||||
return {"mode": "native", "profile_id": profile["id"], "interpreter": profile["interpreter"],
|
||||
"user": profile["user"], "cwd": profile["cwd"], "description": "使用服务器原有环境和原始入口,不迁移依赖或修改源文件。",
|
||||
"impact": profile["impact"], "parameters": profile.get("parameters", []),
|
||||
"interactive": profile.get("interactive", False), "max_timeout_seconds": MAX_TIMEOUT,
|
||||
"notes": profile.get("notes", []), "verification": "启动方案已接入,未完成本轮 GPU/长稳业务验收"}
|
||||
|
||||
|
||||
def validate_config(settings, script, values):
|
||||
profile = profile_for(settings, script)
|
||||
if not profile:
|
||||
raise ValueError("未登记原环境方案")
|
||||
config = dict(values)
|
||||
config["native_options"] = validate_options(profile, config.get("native_options", {}))
|
||||
timeout = config.get("timeout_seconds", profile.get("timeout_seconds", 3600))
|
||||
if type(timeout) is not int or not 5 <= timeout <= MAX_TIMEOUT:
|
||||
raise ValueError("原环境时限须为 5 秒至 72 小时")
|
||||
config.update(timeout_seconds=timeout, cwd=profile["cwd"], args=[], output_paths=[], profile="native")
|
||||
return config
|
||||
|
||||
|
||||
def build_plan(settings, script, run_id=None, check_host=True):
|
||||
profile = profile_for(settings, script)
|
||||
if not profile:
|
||||
raise ValueError("该入口未登记原环境方案")
|
||||
source = script_path(settings, script)
|
||||
source_hash = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
if source_hash != script["sha256"]:
|
||||
raise ValueError("原入口已变更,请刷新来源并重新确认后运行")
|
||||
options = validate_options(profile, script["config"].get("native_options", {}))
|
||||
target_id = options.get("target")
|
||||
target = next((t for t in settings.native_catalog.get("targets", []) if t["id"] == target_id), {})
|
||||
if target_id and not target:
|
||||
raise ValueError("目标服务未登记")
|
||||
if target:
|
||||
target = resolve_target(target)
|
||||
if run_id and not re.fullmatch(r"[a-f0-9]{32}", run_id):
|
||||
raise ValueError("运行编号格式错误")
|
||||
identity = run_id or "{run_id}"
|
||||
directory = settings.runs / identity
|
||||
variables = {**options, "run_id": identity, "run_label": "console-" + identity,
|
||||
"run_dir": str(directory), "output_file": str(Path(profile["cwd"]) / "logs" / f"console-{identity}-{profile['id']}.json"),
|
||||
"target_url": target.get("url", ""), "target_model": target.get("model", ""),
|
||||
"target_port": urlsplit(target.get("url", "")).port or "", "target_config": target.get("config_name", "")}
|
||||
def render(value):
|
||||
def replace(match):
|
||||
if match[1] not in variables:
|
||||
raise ValueError("原环境方案包含未定义的占位参数")
|
||||
return str(variables[match[1]])
|
||||
return PLACEHOLDER.sub(replace, str(value))
|
||||
argv = [profile["interpreter"], str(source), *[render(a) for a in profile.get("arguments", [])]]
|
||||
environment = {**target.get("environment", {}), **{k: render(v) for k, v in profile.get("environment", {}).items()}}
|
||||
credential_files = list(dict.fromkeys(profile.get("credential_files", []) + target.get("credential_files", [])))
|
||||
setup_files = [render(p) for p in profile.get("env_setup_files", [])]
|
||||
required_files = [render(p) for p in profile.get("required_files", [])]
|
||||
if target.get("config_file"):
|
||||
required_files.append(target["config_file"])
|
||||
timeout = script["config"].get("timeout_seconds", profile.get("timeout_seconds", 3600))
|
||||
if type(timeout) is not int or not 5 <= timeout <= MAX_TIMEOUT:
|
||||
raise ValueError("原环境时限不合法")
|
||||
checks = []
|
||||
def check(name, ok, message):
|
||||
checks.append({"name": name, "ok": bool(ok), "message": message})
|
||||
check("启动方案", profile.get("available", True), profile.get("unavailable_reason", "已登记原环境入口"))
|
||||
if check_host:
|
||||
check("原工作目录", Path(profile["cwd"]).is_dir(), profile["cwd"])
|
||||
tools = list(dict.fromkeys([profile["interpreter"], "/usr/bin/systemd-run", *profile.get("required_tools", [])]))
|
||||
for tool in tools:
|
||||
check("工具", Path(tool).is_file() and os.access(tool, os.X_OK), tool)
|
||||
for path in required_files + credential_files + setup_files:
|
||||
check("原配置或素材", Path(path).is_file(), path)
|
||||
for value in profile.get("check_ports", []):
|
||||
port = int(render(value))
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError("原服务端口不合法")
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=.3):pass
|
||||
available = True
|
||||
except OSError:available = False
|
||||
check("目标服务端口", available, f"127.0.0.1:{port}" + (" 可连接" if available else " 未监听,请先准备原服务"))
|
||||
# Binds acknowledgment to saved configuration, source and recipe, not to a mutable browser flag.
|
||||
dependency_hashes = {}
|
||||
for name in required_files + setup_files:
|
||||
path = Path(name)
|
||||
if name not in credential_files and path.is_file() and path.stat().st_size <= 16 * 1024**2:
|
||||
dependency_hashes[name] = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
fingerprint = {"profile": profile, "target": target, "options": options, "source": source_hash,
|
||||
"dependencies": dependency_hashes,
|
||||
"script_id": script["id"], "timeout_seconds": timeout}
|
||||
token = hashlib.sha256(json.dumps(fingerprint, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
||||
return {"mode": "native", "profile_id": profile["id"], "plan_token": token,
|
||||
"ready": all(c["ok"] for c in checks), "checks": checks, "argv": argv, "command_preview": shlex.join(argv),
|
||||
"cwd": profile["cwd"], "user": profile["user"], "environment": environment,
|
||||
"credential_files": credential_files, "env_setup_files": setup_files,
|
||||
"impact": profile["impact"], "interactive": profile.get("interactive", False),
|
||||
"timeout_seconds": timeout, "source_sha256": source_hash, "dependency_hashes": dependency_hashes,
|
||||
"output_paths": [render(p) for p in profile.get("output_paths", [])],
|
||||
"notes": profile.get("notes", []) + ([f"目标服务:{target['url']};模型:{target['model']}(原配置已复核)"] if target else [])}
|
||||
|
||||
|
||||
def public_plan(plan):
|
||||
keys = ("mode", "profile_id", "plan_token", "ready", "checks", "command_preview", "cwd", "user",
|
||||
"impact", "interactive", "output_paths", "notes", "timeout_seconds", "source_sha256")
|
||||
return {key: plan[key] for key in keys}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Private, bounded worker RPC. It exposes registered plans, never shell commands."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import socketserver
|
||||
import stat
|
||||
import threading
|
||||
import uuid
|
||||
from . import native_plans, test_catalog
|
||||
from .catalog import safe_path
|
||||
from .store import Store
|
||||
|
||||
|
||||
def handle(settings, store, data):
|
||||
operation = data.get("op")
|
||||
if operation == "input":
|
||||
identity, text = data.get("run_id", ""), data.get("text")
|
||||
if not re.fullmatch(r"[a-f0-9]{32}", identity) or not isinstance(text, str) or "\x00" in text or len(text.encode()) > 1024:
|
||||
raise ValueError("菜单输入格式不正确")
|
||||
if not re.fullmatch(r"(?:[0-9 ,:./+\-]*|[yYnNqQ])", text):
|
||||
raise ValueError("菜单通道仅接受数字选项、数字列表或 y/n/q;不接受命令、控制键或凭据")
|
||||
run = store.run(identity)
|
||||
execution = run.get("execution", {})
|
||||
if run["state"] != "running" or execution.get("mode") != "native" or not execution.get("interactive"):
|
||||
raise ValueError("当前任务不接受菜单输入")
|
||||
path = safe_path(settings.native_socket.parent / ("input-" + identity), settings.native_socket.parent)
|
||||
if not stat.S_ISFIFO(path.lstat().st_mode):
|
||||
raise ValueError("菜单输入通道不可用")
|
||||
try:
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
|
||||
try:
|
||||
payload = (text.rstrip("\r\n") + "\n").encode()
|
||||
if os.write(descriptor, payload) != len(payload):
|
||||
raise ValueError("菜单输入未完整送达")
|
||||
finally:os.close(descriptor)
|
||||
except OSError as exc:
|
||||
raise ValueError("菜单暂未就绪或已结束") from exc
|
||||
return {"accepted": True}
|
||||
if operation not in {"preflight", "queue"}:
|
||||
raise ValueError("未知原环境操作")
|
||||
identity = data.get("script_id", "")
|
||||
if not re.fullmatch(r"[a-f0-9]{32}", identity):
|
||||
raise ValueError("测试项目编号不合法")
|
||||
if operation == "queue":
|
||||
request_id = str(uuid.UUID(data.get("request_id", "")))
|
||||
previous = store.run_for_request(request_id)
|
||||
if previous:
|
||||
if previous["script_id"] != identity:
|
||||
raise ValueError("请求编号已被其他测试使用")
|
||||
return previous
|
||||
script = test_catalog.describe(settings, store.script(identity), store.scripts())
|
||||
plan = native_plans.build_plan(settings, script)
|
||||
if operation == "preflight":
|
||||
return native_plans.public_plan(plan)
|
||||
if not script["enabled"] or script["status"] != "ready":
|
||||
raise ValueError("请先配置并启用测试项目")
|
||||
if not plan["ready"]:
|
||||
raise ValueError("原环境预检未通过,请查看缺失条件")
|
||||
if data.get("plan_token") != plan["plan_token"]:
|
||||
raise ValueError("原入口、参数或配置已变化,请重新预检并确认")
|
||||
acknowledged = data.get("impact_acknowledged") is True
|
||||
if plan["impact"]["requires_confirmation"] and not acknowledged:
|
||||
raise ValueError("此测试会使用共享资源或切换服务,需要本次运行的明确确认")
|
||||
execution = native_plans.public_plan(plan)
|
||||
execution.update(impact_acknowledged=acknowledged, verification="启动方案已核对;本次结果以真实运行记录为准")
|
||||
return store.queue(script, request_id, data.get("source_ip", "unknown"), execution=execution)
|
||||
|
||||
|
||||
def start(settings, store):
|
||||
folder = settings.native_socket.parent
|
||||
safe_path(folder, settings.root, must_exist=False).mkdir(mode=0o750, exist_ok=True)
|
||||
if settings.native_socket.exists():
|
||||
if not stat.S_ISSOCK(settings.native_socket.lstat().st_mode):
|
||||
raise ValueError("原环境 IPC 路径不是 socket")
|
||||
settings.native_socket.unlink()
|
||||
class Handler(socketserver.StreamRequestHandler):
|
||||
def handle(self):
|
||||
self.connection.settimeout(15)
|
||||
try:
|
||||
raw = self.rfile.readline(8193)
|
||||
if len(raw) > 8192:
|
||||
raise ValueError("原环境请求过大")
|
||||
result = handle(settings, store, json.loads(raw))
|
||||
except (ValueError, KeyError) as exc:
|
||||
result = {"error": str(exc.args[0])}
|
||||
except Exception:
|
||||
result = {"error": "原环境操作失败,请检查执行器状态"}
|
||||
try:self.wfile.write(json.dumps(result).encode() + b"\n")
|
||||
except OSError:pass
|
||||
server = socketserver.UnixStreamServer(str(settings.native_socket), Handler)
|
||||
os.chmod(settings.native_socket, 0o600)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
return server
|
||||
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
certifi==2026.7.22
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
@@ -0,0 +1,16 @@
|
||||
# Resolved and verified in the isolated project environment on 2026-10-13.
|
||||
annotated-doc==0.0.5
|
||||
annotated-types==0.8.0
|
||||
anyio==4.15.1
|
||||
click==8.5.0
|
||||
fastapi==0.143.0
|
||||
h11==0.16.0
|
||||
idna==3.20
|
||||
opentelemetry-api==1.45.1
|
||||
pydantic==2.14.1
|
||||
pydantic_core==2.50.1
|
||||
python-multipart==0.0.32
|
||||
starlette==1.8.0
|
||||
typing-inspection==0.4.4
|
||||
typing_extensions==4.16.0
|
||||
uvicorn==0.54.0
|
||||
@@ -0,0 +1,62 @@
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
root: Path
|
||||
sources: list
|
||||
allowed_hosts: list = field(default_factory=lambda: ["10.88.118.188:14442", "10.88.118.188:14440"])
|
||||
allowed_networks: list = field(default_factory=lambda: ["10.88.0.0/17", "10.20.0.0/21"])
|
||||
test_mode: bool = False
|
||||
max_upload: int = 1024 * 1024
|
||||
max_log: int = 16 * 1024 * 1024
|
||||
max_artifact: int = 32 * 1024 * 1024
|
||||
max_run_files: int = 64
|
||||
test_definitions: list = field(default_factory=list)
|
||||
example_script_ids: list = field(default_factory=list)
|
||||
native_catalog: dict = field(default_factory=lambda: {"profiles": [], "targets": []})
|
||||
|
||||
@property
|
||||
def native_socket(self):
|
||||
return self.root / "native-ipc" / "control.sock"
|
||||
|
||||
@property
|
||||
def library(self):
|
||||
return self.root / "library"
|
||||
|
||||
@property
|
||||
def runs(self):
|
||||
return self.root / "runs"
|
||||
|
||||
@property
|
||||
def db(self):
|
||||
return self.root / "console-data" / "console.sqlite3"
|
||||
|
||||
def prepare(self):
|
||||
self.validate_paths()
|
||||
for directory in (self.root, self.library, self.runs, self.db.parent):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def validate_paths(self):
|
||||
for directory in (self.root, self.library, self.runs, self.db.parent):
|
||||
if not directory.is_absolute():
|
||||
raise ValueError("Runtime must be an absolute path")
|
||||
for part in (directory, *directory.parents):
|
||||
if part.is_symlink():
|
||||
raise ValueError("Runtime paths must not contain symlinks")
|
||||
for path in (self.db, Path(str(self.db) + "-wal"), Path(str(self.db) + "-shm")):
|
||||
if path.is_symlink():
|
||||
raise ValueError("Database symlinks are not supported")
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
project = Path(__file__).resolve().parents[1]
|
||||
catalog = json.loads((project / "catalog/external-sources.json").read_text(encoding="utf-8"))
|
||||
tests = json.loads((project / "catalog/test-definitions.json").read_text(encoding="utf-8"))
|
||||
native_file = project / "catalog/native-profiles.json"
|
||||
native = json.loads(native_file.read_text(encoding="utf-8")) if native_file.exists() else {"profiles": [], "targets": []}
|
||||
return cls(Path(os.environ.get("XAS_CONSOLE_ROOT", str(project / "runtime"))), catalog["sources"],
|
||||
test_definitions=tests["tests"], example_script_ids=tests.get("example_script_ids", []), native_catalog=native)
|
||||
@@ -0,0 +1,61 @@
|
||||
import {api, el, note, heading, errorBox, pageContext, bytes, date} from './ui.js';
|
||||
import {scriptList, scriptDetail} from './scripts.js';
|
||||
import {runList, runDetail} from './runs.js';
|
||||
|
||||
const main = document.querySelector('#main');
|
||||
let context;
|
||||
let summaryBusy = false;
|
||||
|
||||
async function summary() {
|
||||
if (summaryBusy || document.visibilityState !== 'visible') return;
|
||||
summaryBusy = true;
|
||||
const health = document.querySelector('#worker-status');
|
||||
const notice = document.querySelector('#global-notice');
|
||||
try {
|
||||
const data = await api('/api/summary');
|
||||
document.querySelector('#nav-count').textContent = data.scripts;
|
||||
document.querySelector('#summary').replaceChildren(...[
|
||||
['测试项目', data.scripts, '按测试行为统计', ''], ['可运行', data.ready, '业务测试已就绪', 'accent'],
|
||||
['进行中', data.active, '排队 / 执行', ''], ['运行记录', data.runs, `归档 ${bytes(data.storage.run_bytes)}`, '']
|
||||
].map(([label, value, hint, style]) => el('div', {class: `stat ${style}`}, el('span', {class: 'stat-label'}, label), el('strong', {class: 'stat-value'}, value), el('span', {class: 'stat-foot'}, hint))));
|
||||
health.replaceChildren(el('span', {class: `worker-light ${data.worker.online ? '' : 'offline'}`, 'aria-hidden': 'true'}), data.worker.online ? '执行器在线 · 单任务队列' : '执行器离线');
|
||||
health.title = `最近心跳:${date(data.worker.last_seen)}`;
|
||||
notice.hidden = data.worker.online;
|
||||
notice.textContent = data.worker.online ? '' : `执行器当前离线,排队任务需要等待执行器恢复。最近心跳:${date(data.worker.last_seen)}。`;
|
||||
} catch (error) {
|
||||
health.replaceChildren(el('span', {class: 'worker-light offline', 'aria-hidden': 'true'}), '连接状态未知');
|
||||
notice.hidden = false;
|
||||
notice.textContent = `工作台状态更新失败:${error.message} 显示的数据可能已过期。`;
|
||||
} finally { summaryBusy = false; }
|
||||
}
|
||||
|
||||
async function route() {
|
||||
context?.dispose();
|
||||
context = pageContext(main);
|
||||
main.setAttribute('aria-busy', 'true');
|
||||
const value = location.hash.slice(1) || '/scripts';
|
||||
const [path, query = ''] = value.split('?');
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const current = segments[0] || 'scripts';
|
||||
document.querySelectorAll('[data-nav]').forEach(nav => {
|
||||
if (nav.dataset.nav === current) nav.setAttribute('aria-current', 'page');
|
||||
else nav.removeAttribute('aria-current');
|
||||
});
|
||||
document.title = `${current === 'runs' ? '运行记录' : '测试项目'} · XAS 测试控制台`;
|
||||
const ctx = context;
|
||||
summary();
|
||||
try {
|
||||
if (segments.length > 2) throw new Error('页面地址无效。');
|
||||
const id = segments[1] ? decodeURIComponent(segments[1]) : null;
|
||||
if (current === 'scripts') await (id ? scriptDetail(ctx, id) : scriptList(ctx));
|
||||
else if (current === 'runs') await (id ? runDetail(ctx, id) : runList(ctx, new URLSearchParams(query).get('script_id') || ''));
|
||||
else ctx.show(heading('找不到此页面', '请选择左侧的测试项目或运行记录。'), note('页面地址不存在。'));
|
||||
} catch (error) { if (ctx.alive) ctx.show(errorBox(error, route)); }
|
||||
}
|
||||
|
||||
document.querySelector('.skip-link').addEventListener('click', event => { event.preventDefault(); main.focus(); });
|
||||
window.addEventListener('hashchange', route);
|
||||
window.addEventListener('pagehide', () => context?.dispose());
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') summary(); });
|
||||
setInterval(summary, 15000);
|
||||
route();
|
||||
@@ -0,0 +1,29 @@
|
||||
export function parseLines(value) {
|
||||
return value.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function parseArgs(value) {
|
||||
if (!value.trim().startsWith('[')) return parseLines(value);
|
||||
let args;
|
||||
try { args = JSON.parse(value); }
|
||||
catch { throw new Error('参数 JSON 格式不正确,请填写字符串数组。'); }
|
||||
if (!Array.isArray(args) || args.some(arg => typeof arg !== 'string')) {
|
||||
throw new Error('参数必须是 JSON 字符串数组,或每行一个参数。');
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function appendLog(previous, incoming, limit = 240000) {
|
||||
const all = previous + incoming;
|
||||
return {text: all.slice(-limit), trimmed: all.length > limit};
|
||||
}
|
||||
|
||||
export function requestId() {
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||
// LAN HTTP can lack randomUUID; getRandomValues remains available there.
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 15) | 64;
|
||||
bytes[8] = (bytes[8] & 63) | 128;
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="theme-color" content="#14231e">
|
||||
<title>XAS · 测试控制台</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">跳转到内容</a>
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="#/scripts" aria-label="XAS 测试控制台首页">
|
||||
<span class="brand-mark" aria-hidden="true">X</span>
|
||||
<span><strong>XAS</strong><small>TEST CONSOLE</small></span>
|
||||
</a>
|
||||
<div class="sidebar-label">工作空间</div>
|
||||
<nav aria-label="主导航">
|
||||
<a href="#/scripts" data-nav="scripts"><span aria-hidden="true">▦</span>测试项目<span class="nav-count" id="nav-count">—</span></a>
|
||||
<a href="#/runs" data-nav="runs"><span aria-hidden="true">↗</span>运行记录</a>
|
||||
</nav>
|
||||
<div class="sidebar-note"><span class="dot"></span>公司内网 · 共享工作台<p>从脚本到结果,每次运行都有迹可循。</p></div>
|
||||
<div class="sidebar-bottom">新爱算 <span>测试与验证</span></div>
|
||||
</aside>
|
||||
<div class="workspace">
|
||||
<header class="topbar"><div><span class="eyebrow">ENGINEERING /</span><span>测试控制台</span></div><div id="worker-status" role="status">正在连接执行器…</div></header>
|
||||
<div id="summary" class="summary" aria-label="工作台概览"></div>
|
||||
<div id="global-notice" class="global-notice" role="status" hidden></div>
|
||||
<main id="main" tabindex="-1" aria-busy="true"><div class="loading">正在加载工作台…</div></main>
|
||||
<footer class="page-footer"><span>脚本版本可追溯 · 运行结果独立归档</span><span>时间按本地时区显示</span></footer>
|
||||
</div>
|
||||
<div id="toast" class="toast" role="status" hidden></div>
|
||||
<noscript><p class="noscript">请启用 JavaScript 以使用测试控制台。</p></noscript>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,187 @@
|
||||
import {api, el, button, badge, note, loading, field, input, select, kv, errorBox, dialog, scriptUrl, runUrl} from './ui.js';
|
||||
|
||||
export const isNative = record => record.execution?.mode === 'native';
|
||||
export function maxTimeout(script) {
|
||||
if (!isNative(script)) return 7200;
|
||||
const limit = Number(script.execution.max_timeout_seconds);
|
||||
return Number.isSafeInteger(limit) && limit >= 5 ? Math.min(limit, 259200) : 259200;
|
||||
}
|
||||
|
||||
export function nativeOptions(parameters, values) {
|
||||
const entries = [];
|
||||
for (const parameter of parameters) {
|
||||
const {key, label, type, required, min, max} = parameter;
|
||||
const raw = values[key];
|
||||
const blank = raw === undefined || raw === null || String(raw).trim() === '';
|
||||
if (required && blank) throw new Error(`请填写${label}。`);
|
||||
if (!['integer', 'number', 'select', 'text'].includes(type)) throw new Error(`${label}的参数类型不支持。`);
|
||||
if (blank && type !== 'text') continue;
|
||||
let value = raw ?? '';
|
||||
if (type === 'integer' || type === 'number') {
|
||||
value = Number(raw);
|
||||
if (!Number.isFinite(value)) throw new Error(`${label}必须是有效数字。`);
|
||||
if (type === 'integer' && !Number.isSafeInteger(value)) throw new Error(`${label}必须是整数。`);
|
||||
if ((min !== undefined && value < min) || (max !== undefined && value > max)) throw new Error(`${label}超出允许范围。`);
|
||||
} else if (type === 'select') {
|
||||
const option = (parameter.options || []).find(item => String(item.value) === String(raw));
|
||||
if (!option) throw new Error(`请选择有效的${label}。`);
|
||||
value = option.value;
|
||||
} else value = String(value);
|
||||
entries.push([key, value]);
|
||||
}
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
export function nativeParameterForm(script) {
|
||||
const parameters = script.execution.parameters || [];
|
||||
const saved = script.config.native_options || {};
|
||||
const controls = new Map();
|
||||
const node = el('div', {class: 'native-parameters'});
|
||||
for (const parameter of parameters) {
|
||||
const value = Object.hasOwn(saved, parameter.key) ? saved[parameter.key] : parameter.default;
|
||||
let control;
|
||||
if (parameter.type === 'select') {
|
||||
const options = (parameter.options || []).map(item => [item.value, item.label]);
|
||||
if (value === undefined || value === null || value === '' || !options.some(([id]) => String(id) === String(value))) options.unshift(['', '请选择…']);
|
||||
control = select(parameter.label, options, value ?? '');
|
||||
} else {
|
||||
const numeric = ['integer', 'number'].includes(parameter.type);
|
||||
control = input({type: numeric ? 'number' : 'text', value: value ?? '',
|
||||
min: numeric ? parameter.min : undefined, max: numeric ? parameter.max : undefined,
|
||||
step: parameter.type === 'integer' ? '1' : numeric ? 'any' : undefined,
|
||||
autocomplete: 'off', spellcheck: 'false'});
|
||||
}
|
||||
if (parameter.required) control.required = true;
|
||||
controls.set(parameter.key, control);
|
||||
const bounds = parameter.min !== undefined || parameter.max !== undefined
|
||||
? `允许范围:${parameter.min ?? '不限'} 至 ${parameter.max ?? '不限'}。` : '';
|
||||
node.append(field(parameter.label, control, [parameter.help, bounds].filter(Boolean).join(' ')));
|
||||
}
|
||||
if (!parameters.length) node.append(note('此原环境方案使用固定参数。'));
|
||||
return {node, read: () => nativeOptions(parameters, Object.fromEntries(Array.from(controls, ([key, control]) => [key, control.value])))};
|
||||
}
|
||||
|
||||
function impactView(impact) {
|
||||
if (!impact) return null;
|
||||
return el('div', {class: `native-impact ${impact.requires_confirmation ? 'attention' : ''}`},
|
||||
el('h3', {}, impact.title || '运行影响'),
|
||||
(impact.details || []).length ? el('ul', {}, impact.details.map(detail => el('li', {}, detail))) : null,
|
||||
el('p', {class: 'muted'}, impact.requires_confirmation ? '每次启动前须确认本次影响。' : '普通测试,无额外影响确认。'));
|
||||
}
|
||||
|
||||
export function nativeScriptInfo(script) {
|
||||
if (!isNative(script)) return null;
|
||||
const execution = script.execution;
|
||||
return el('section', {class: 'panel native-plan'}, el('div', {class: 'panel-header'}, el('h2', {}, '原环境执行方案'), badge('running', '原环境')),
|
||||
kv([['运行账号', execution.user], ['原工作目录', execution.cwd], ['解释器', execution.interpreter], ['方案编号', execution.profile_id],
|
||||
['交互方式', execution.interactive ? '原菜单 · 可发送菜单选项' : '自动执行']]),
|
||||
execution.description ? el('p', {class: 'native-description'}, execution.description) : null,
|
||||
impactView(execution.impact), note('启动方案已接入,未完成本轮业务验收。', 'info'));
|
||||
}
|
||||
|
||||
export async function preflightNativeRun(ctx, script) {
|
||||
const content = el('div', {}, loading('正在只读预检原环境…'));
|
||||
const panel = dialog('原环境运行预检', content);
|
||||
panel.classList.add('native-dialog');
|
||||
let plan;
|
||||
try {
|
||||
plan = await api(`${scriptUrl(script.id)}/preflight`, {method: 'POST', body: {}}, ctx.signal);
|
||||
if (!ctx.alive || !panel.open) return null;
|
||||
const command = el('pre', {class: 'native-command mono', tabindex: '0', 'aria-label': '本次启动命令'}, plan.command_preview || '未生成启动命令');
|
||||
content.replaceChildren(...[note(plan.ready ? '只读预检通过,原脚本尚未执行。' : '预检未通过,本次不会执行。', plan.ready ? 'success' : 'danger'),
|
||||
kv([['执行模式', plan.mode === 'native' ? '原环境' : plan.mode], ['运行账号', plan.user], ['原工作目录', plan.cwd]]),
|
||||
el('h3', {class: 'native-subtitle'}, '本次启动命令'), command,
|
||||
el('ul', {class: 'preflight-checks'}, (plan.checks || []).map(check => el('li', {},
|
||||
badge(check.ok ? 'ready' : 'failed', check.ok ? '通过' : '未通过'), el('div', {}, el('strong', {}, check.name), el('p', {}, check.message || ''))))),
|
||||
impactView(plan.impact),
|
||||
(plan.output_paths || []).length ? el('details', {class: 'disclosure'}, el('summary', {}, '查看原输出规则'), el('pre', {}, plan.output_paths.join('\n'))) : null,
|
||||
...(plan.notes || []).map(text => note(text, 'info'))].filter(node => node !== null));
|
||||
if (!plan.ready) return null;
|
||||
if (plan.mode !== 'native' || typeof plan.plan_token !== 'string' || !plan.plan_token) throw new Error('预检未返回有效的原环境计划,请关闭窗口后重新预检。');
|
||||
if (!plan.impact?.requires_confirmation) {
|
||||
content.append(note('正在创建本次运行记录…', 'info'));
|
||||
return {payload: {plan_token: plan.plan_token}, close: () => panel.close()};
|
||||
}
|
||||
} catch (error) {
|
||||
if (ctx.alive && panel.open) content.replaceChildren(errorBox(error), note('本次没有提交运行。关闭窗口后可重新预检。'));
|
||||
return null;
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
let acknowledged = false;
|
||||
const confirm = button('确认影响并开始本次运行', () => {
|
||||
if (!ctx.alive || !panel.open || acknowledged) return;
|
||||
acknowledged = true;
|
||||
confirm.disabled = true;
|
||||
panel.close();
|
||||
resolve({payload: {plan_token: plan.plan_token, impact_acknowledged: true}, close: () => {}});
|
||||
}, 'primary');
|
||||
panel.addEventListener('close', () => { if (!acknowledged) resolve(null); }, {once: true});
|
||||
content.append(note('确认仅适用于上面显示的源码、参数和方案;每次重新启动都会重新预检。'),
|
||||
el('div', {class: 'form-footer'}, button('暂不运行', () => panel.close()), confirm));
|
||||
});
|
||||
}
|
||||
|
||||
export function validateMenuInput(text) {
|
||||
if (text.includes('\0')) throw new Error('菜单输入不能包含 NUL 字符。');
|
||||
if (new TextEncoder().encode(text).length > 1024) throw new Error('单次菜单输入不能超过 1024 UTF-8 字节。');
|
||||
if (!/^(?:[0-9 ,:./+\-]*|[yYnNqQ])$/.test(text)) throw new Error('仅支持数字选项、数字列表或 y/n/q;不接受命令、控制键或凭据。');
|
||||
}
|
||||
|
||||
export function nativeRunPanel(ctx, initialRun) {
|
||||
let current = initialRun;
|
||||
let blocked = false;
|
||||
let sending = false;
|
||||
let executionKey = '';
|
||||
const details = el('div');
|
||||
const control = input({type: 'text', autocomplete: 'off', spellcheck: 'false', maxlength: '1024', placeholder: '输入原菜单选项后发送'});
|
||||
const feedback = el('div', {'aria-live': 'polite'});
|
||||
const submit = el('button', {type: 'submit', class: 'button primary'}, '发送菜单选项');
|
||||
const menu = el('form', {class: 'native-menu', autocomplete: 'off', hidden: true},
|
||||
el('h3', {}, '原菜单输入'), note('仅支持数字选项、数字列表或 y/n/q,不接受命令和控制键。不要输入密码或令牌;发送后清空,不保存输入历史。'),
|
||||
field('菜单选项', control, '留空发送回车以接受原菜单默认值;单次最多 1024 UTF-8 字节。'),
|
||||
feedback, el('div', {class: 'form-footer'}, submit));
|
||||
const node = el('section', {class: 'panel native-plan'}, details, menu);
|
||||
const eligible = () => isNative(current) && current.execution.interactive && current.state === 'running';
|
||||
function update(run, options = {}) {
|
||||
current = run;
|
||||
blocked = Boolean(options.blocked);
|
||||
const execution = run.execution || {mode: 'container'};
|
||||
const key = JSON.stringify(execution);
|
||||
if (key !== executionKey) {
|
||||
executionKey = key;
|
||||
details.replaceChildren(el('div', {class: 'panel-header'}, el('h2', {}, '执行环境'), badge('running', isNative(run) ? '原环境' : '隔离容器')));
|
||||
if (isNative(run)) details.append(...[
|
||||
kv([['运行账号', execution.user], ['原工作目录', execution.cwd], ['方案编号', execution.profile_id],
|
||||
['影响确认', execution.impact?.requires_confirmation ? execution.impact_acknowledged ? '已确认本次影响' : '未记录确认' : '无需额外确认']]),
|
||||
el('h3', {class: 'native-subtitle'}, '实际启动命令'), el('pre', {class: 'native-command mono', tabindex: '0'}, execution.command_preview || '尚未记录启动命令'),
|
||||
impactView(execution.impact),
|
||||
execution.verification ? el('details', {class: 'disclosure'}, el('summary', {}, '查看执行核对信息'), el('pre', {}, JSON.stringify(execution.verification, null, 2))) : null].filter(node => node !== null));
|
||||
else details.append(el('p', {class: 'muted'}, '本次运行使用隔离容器。'));
|
||||
}
|
||||
menu.hidden = !eligible();
|
||||
if (!eligible()) control.value = '';
|
||||
control.disabled = blocked || sending || !eligible();
|
||||
submit.disabled = control.disabled;
|
||||
}
|
||||
menu.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (!ctx.alive || sending || blocked || !eligible()) return;
|
||||
let text = control.value;
|
||||
control.value = '';
|
||||
feedback.replaceChildren();
|
||||
try { validateMenuInput(text); }
|
||||
catch (error) { feedback.replaceChildren(errorBox(error)); return; }
|
||||
sending = true;
|
||||
update(current, {blocked});
|
||||
try {
|
||||
const response = api(`${runUrl(current.id)}/input`, {method: 'POST', body: {text}}, ctx.signal);
|
||||
text = '';
|
||||
const result = await response;
|
||||
if (ctx.alive) feedback.replaceChildren(note(result.accepted === true ? '输入已送达本次运行。' : '输入结果未确认,请先核对当前状态和日志。', result.accepted === true ? 'success' : ''));
|
||||
} catch {
|
||||
if (ctx.alive) feedback.replaceChildren(note('输入未送达或结果尚未确认。请核对运行状态和最新日志,再决定是否重新输入。', 'danger'));
|
||||
} finally { text = ''; sending = false; if (ctx.alive) update(current, {blocked}); }
|
||||
});
|
||||
update(initialRun);
|
||||
return {node, update};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {api, el, select, input, loading, note, errorBox, link, scriptUrl} from './ui.js';
|
||||
|
||||
const roles = {entry: '执行入口', support: '配套文件', self_test: '套件内部验证', variant: '相关实现 / 场景'};
|
||||
|
||||
export function relatedFiles(ctx, script) {
|
||||
const content = el('div', {class: 'related-files'});
|
||||
const panel = el('details', {class: 'panel disclosure'},
|
||||
el('summary', {}, `相关文件 · ${script.file_count || 1} 个`), content);
|
||||
let loaded = false;
|
||||
let busy = false;
|
||||
async function load() {
|
||||
if (busy || loaded || !panel.open) return;
|
||||
busy = true;
|
||||
content.replaceChildren(loading('正在读取项目文件…'));
|
||||
try {
|
||||
const {items} = await api(`${scriptUrl(script.id)}/files`, {}, ctx.signal);
|
||||
if (!ctx.alive) return;
|
||||
const search = input({type: 'search', placeholder: '按文件名或路径查找', 'aria-label': '搜索相关文件'});
|
||||
const choice = select('查看相关文件', []);
|
||||
const info = el('p', {class: 'source-info mono'});
|
||||
const feedback = el('div');
|
||||
const download = link('下载此文件', '', 'button small');
|
||||
const code = el('pre', {class: 'code', tabindex: '0', 'aria-label': '相关文件源代码,只读'});
|
||||
content.replaceChildren(note('这里展示测试入口、配套模块和相关实现。查看文件不会改变本次测试的执行入口。'),
|
||||
search, choice, info, download, feedback, code);
|
||||
let generation = 0;
|
||||
async function show() {
|
||||
const ticket = ++generation;
|
||||
const file = items.find(item => item.id === choice.value);
|
||||
download.hidden = true;
|
||||
feedback.replaceChildren();
|
||||
info.textContent = file ? `${roles[file.role] || '配套文件'} · ${file.source_path}` : '';
|
||||
code.textContent = file ? '正在读取…' : '没有匹配的文件。';
|
||||
if (!file) return;
|
||||
try {
|
||||
const data = await api(`${scriptUrl(file.id)}/code`, {}, ctx.signal);
|
||||
if (!ctx.alive || ticket !== generation) return;
|
||||
code.textContent = data.code || '(空文件)';
|
||||
download.href = `${scriptUrl(file.id)}/download`;
|
||||
download.hidden = Boolean(data.redacted);
|
||||
if (data.redacted) feedback.append(note('疑似凭据已隐藏,该文件不可下载。'));
|
||||
} catch (error) {
|
||||
if (ctx.alive && ticket === generation) { code.textContent = ''; feedback.replaceChildren(errorBox(error, show)); }
|
||||
}
|
||||
}
|
||||
function filter() {
|
||||
const term = search.value.toLowerCase();
|
||||
const selected = choice.value;
|
||||
const matching = items.filter(file => (file.name + file.source_path).toLowerCase().includes(term));
|
||||
choice.replaceChildren(...matching.map(file => el('option', {value: file.id}, `${roles[file.role]} · ${file.relative_path}`)));
|
||||
if (matching.some(file => file.id === selected)) choice.value = selected;
|
||||
show();
|
||||
}
|
||||
choice.addEventListener('change', show);
|
||||
search.addEventListener('input', filter);
|
||||
loaded = true;
|
||||
filter();
|
||||
} catch (error) { if (ctx.alive) content.replaceChildren(errorBox(error, load)); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
panel.addEventListener('toggle', load);
|
||||
return panel;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import {api, el, button, link, badge, note, empty, loading, select, heading, kv, errorBox, dialog, date, bytes, enc, runUrl, active} from './ui.js';
|
||||
import {appendLog} from './core.js';
|
||||
import {isNative, nativeRunPanel} from './native-ui.js';
|
||||
|
||||
export function runTable(items) {
|
||||
return el('div', {class: 'table-wrap'}, el('table', {},
|
||||
el('thead', {}, el('tr', {}, ['脚本 / 运行编号', '状态', '版本', '创建时间', '退出码', ''].map(title => el('th', {scope: 'col'}, title)))),
|
||||
el('tbody', {}, items.map(run => el('tr', {class: 'run-row'},
|
||||
el('td', {}, el('span', {class: 'run-name'}, run.script_name), el('div', {class: 'source-info mono'}, run.id)),
|
||||
el('td', {}, badge(run.state), run.synthetic ? badge('synthetic', '模拟验收') : null),
|
||||
el('td', {class: 'mono'}, `v${run.version}`), el('td', {}, date(run.created_at)),
|
||||
el('td', {class: 'mono'}, run.exit_code ?? '—'), el('td', {}, el('a', {
|
||||
href: `#/runs/${enc(run.id)}`, class: 'button detail-link run-open',
|
||||
'aria-label': `查看运行详情:${run.script_name},${run.id}`}, '查看详情 →')))))));
|
||||
}
|
||||
|
||||
export async function runList(ctx, scriptId = '') {
|
||||
const status = select('运行状态', [['', '全部状态'], ['running', '运行中'], ['queued', '排队中'], ['succeeded', '进程成功'], ['failed', '失败'], ['cancelled', '已取消'], ['timed_out', '已超时'], ['interrupted', '已中断']]);
|
||||
const origin = select('记录类型', [['', '全部记录'], ['real', '普通脚本运行'], ['synthetic', '模拟验收']]);
|
||||
const results = el('div', {}, loading('正在读取运行记录…'));
|
||||
const feedback = el('div', {'aria-live': 'polite'});
|
||||
const updated = el('span', {class: 'muted'});
|
||||
let items = [];
|
||||
let busy = false;
|
||||
const refresh = button('↻ 刷新', () => update());
|
||||
ctx.show(heading('运行记录', scriptId ? '查看此脚本最近 100 次运行的状态、日志与结果。' : '查看最近 100 次运行。每次执行使用固定版本和独立结果目录。',
|
||||
[scriptId ? link('查看全部记录', '#/runs', 'button') : null, refresh]),
|
||||
el('div', {class: 'filters'}, status, origin, scriptId ? link('返回脚本', `#/scripts/${enc(scriptId)}`, 'button small') : null),
|
||||
el('div', {class: 'section-title'}, el('span', {class: 'muted'}, '退出码 0 表示进程正常结束;业务结论以脚本报告为准。'), updated), feedback, results);
|
||||
const render = () => {
|
||||
const filtered = items.filter(run => (!status.value || run.state === status.value)
|
||||
&& (!origin.value || Boolean(run.synthetic) === (origin.value === 'synthetic')));
|
||||
results.replaceChildren(filtered.length ? runTable(filtered) : empty('没有符合条件的运行记录', '调整筛选条件,或从脚本详情开始一次运行。'));
|
||||
};
|
||||
async function update() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
refresh.disabled = true;
|
||||
try {
|
||||
const data = await api(`/api/runs?limit=100${scriptId ? `&script_id=${enc(scriptId)}` : ''}`, {}, ctx.signal);
|
||||
if (!ctx.alive) return;
|
||||
items = data.items;
|
||||
render();
|
||||
feedback.replaceChildren();
|
||||
updated.textContent = `更新于 ${new Date().toLocaleTimeString('zh-CN', {hour12: false})}`;
|
||||
} catch (error) {
|
||||
if (ctx.alive) { feedback.replaceChildren(errorBox(error, update)); if (!items.length) results.replaceChildren(); }
|
||||
} finally { busy = false; refresh.disabled = false; }
|
||||
}
|
||||
[status, origin].forEach(control => control.addEventListener('change', render));
|
||||
ctx.repeat(update, 6000);
|
||||
}
|
||||
|
||||
export async function runDetail(ctx, id) {
|
||||
const navigation = () => el('div', {class: 'detail-navigation'}, link('← 返回运行记录', '#/runs', 'button back-button'));
|
||||
ctx.show(navigation(), loading('正在读取运行状态…'));
|
||||
let run;
|
||||
try { run = await api(runUrl(id), {}, ctx.signal); }
|
||||
catch (error) { if (ctx.alive) ctx.show(navigation(), heading('运行详情', '运行记录读取失败。'), errorBox(error, () => runDetail(ctx, id))); return; }
|
||||
if (!ctx.alive) return;
|
||||
let offset = 0;
|
||||
let logText = '';
|
||||
let retainedTrimmed = false;
|
||||
let serverTruncated = false;
|
||||
let busy = false;
|
||||
let logDone = false;
|
||||
let cancelling = false;
|
||||
let cancelRequested = false;
|
||||
let mutation = 0;
|
||||
let artifactsKey = '';
|
||||
const feedback = el('div', {'aria-live': 'polite'});
|
||||
const execution = nativeRunPanel(ctx, run);
|
||||
const status = el('div', {class: 'actions'});
|
||||
const metadata = el('div', {class: 'run-summary'});
|
||||
const message = el('div');
|
||||
const log = el('pre', {class: 'log', tabindex: '0', 'aria-label': '运行标准输出与错误日志'}, '正在读取日志…');
|
||||
const logStatus = el('span', {}, '等待日志');
|
||||
const logHint = el('span', {}, '标准输出 / 标准错误');
|
||||
const follow = el('input', {type: 'checkbox', checked: true});
|
||||
const files = el('div', {class: 'artifact-list'});
|
||||
const retry = button('立即刷新', () => tick(), 'small');
|
||||
const cancel = button('取消运行', async () => {
|
||||
if (cancelling || !active(run)) return;
|
||||
cancelling = true;
|
||||
execution.update(run, {blocked: true});
|
||||
mutation++;
|
||||
cancel.disabled = true;
|
||||
cancel.textContent = '正在取消…';
|
||||
try {
|
||||
run = await api(`${runUrl(id)}/cancel`, {method: 'POST'}, ctx.signal);
|
||||
cancelRequested = true;
|
||||
if (ctx.alive) { feedback.replaceChildren(); renderRun(); }
|
||||
} catch (error) { if (ctx.alive) feedback.replaceChildren(errorBox(error)); }
|
||||
finally { cancelling = false; if (ctx.alive) { cancel.textContent = '取消运行'; cancel.disabled = !active(run); execution.update(run, {blocked: cancelRequested}); } }
|
||||
}, 'danger');
|
||||
ctx.show(navigation(), el('div', {class: 'breadcrumbs'}, link('运行记录', '#/runs'), ' / ', id),
|
||||
heading(run.script_name, `运行 ${id}`, [link('查看测试项目', `#/scripts/${enc(run.script_id)}`, 'button back-button'), cancel]),
|
||||
feedback, el('section', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '运行概况'), status), metadata, message,
|
||||
kv([['脚本版本', `v${run.version}`], ['源码摘要', run.sha256], ['创建时间', date(run.created_at)], ['请求来源', run.source_ip || '—']]),
|
||||
el('details', {class: 'disclosure'}, el('summary', {}, '查看本次运行配置快照'), el('pre', {}, JSON.stringify(run.config, null, 2)))),
|
||||
execution.node,
|
||||
el('section', {class: 'panel log-panel'}, el('div', {class: 'log-toolbar'}, el('h2', {}, '实时日志'),
|
||||
el('div', {class: 'actions'}, el('label', {}, follow, '跟随最新日志'), retry, link('下载完整日志', `${runUrl(id)}/log/download`, 'button small'))),
|
||||
log, el('div', {class: 'log-hint'}, logStatus, logHint)),
|
||||
el('section', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '结果文件')), files),
|
||||
note(isNative(run) ? '取消与超时仅处理本次原环境任务的本机进程。原脚本通过 Docker/tmux 启动的任务可能仍在运行;请核对收尾记录,不能据此认定原模型已恢复。'
|
||||
: '取消与超时只终止本次执行容器,不能证明外部模型服务已停止推理。'));
|
||||
|
||||
function renderRun() {
|
||||
execution.update(run, {blocked: cancelling || cancelRequested});
|
||||
status.replaceChildren(badge(run.state));
|
||||
if (run.synthetic) status.append(badge('synthetic', '模拟验收'));
|
||||
cancel.hidden = !active(run);
|
||||
cancel.disabled = cancelling || !active(run);
|
||||
metadata.replaceChildren(...[
|
||||
['开始时间', date(run.started_at)], ['结束时间', date(run.finished_at)],
|
||||
['退出码', run.exit_code ?? '—'], ['执行类型', run.synthetic ? '模拟验收' : '脚本测试']
|
||||
].map(([label, value]) => el('div', {}, el('small', {}, label), el('strong', {}, value))));
|
||||
message.replaceChildren();
|
||||
if (run.message) message.append(note(run.message, ['failed', 'timed_out', 'interrupted'].includes(run.state) ? 'danger' : 'info'));
|
||||
if (run.state === 'succeeded') message.append(note('进程正常结束。是否达到测试目标,请查看脚本产生的报告。', 'success'));
|
||||
if (run.synthetic) message.append(note('这是模拟验收记录,不代表真实模型服务或测试业务通过。', 'info'));
|
||||
const key = JSON.stringify([run.state, run.artifacts]);
|
||||
if (key !== artifactsKey) {
|
||||
artifactsKey = key;
|
||||
files.replaceChildren(...((run.artifacts || []).length ? run.artifacts.map(artifact => artifactRow(ctx, run, artifact)) :
|
||||
[empty(active(run) ? '结果文件等待归档' : '本次运行没有归档文件', active(run) ? '执行结束后,登记的结果文件会出现在这里。' : '脚本可能没有生成文件,或输出路径未登记;请查看日志和运行配置。')]));
|
||||
}
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (busy || cancelling) return;
|
||||
if (!active(run) && logDone) return false;
|
||||
busy = true;
|
||||
retry.disabled = true;
|
||||
const ticket = mutation;
|
||||
try {
|
||||
const [current, chunk] = await Promise.all([
|
||||
api(runUrl(id), {}, ctx.signal), api(`${runUrl(id)}/log?offset=${offset}`, {}, ctx.signal)
|
||||
]);
|
||||
if (!ctx.alive) return;
|
||||
if (ticket !== mutation) return true;
|
||||
run = current;
|
||||
if (!Number.isSafeInteger(chunk.next_offset) || chunk.next_offset < offset) throw new Error('日志游标异常,请重新打开此运行。');
|
||||
if (chunk.next_offset > offset) {
|
||||
const retained = appendLog(logText, chunk.text);
|
||||
logText = retained.text;
|
||||
retainedTrimmed ||= retained.trimmed;
|
||||
}
|
||||
offset = chunk.next_offset;
|
||||
logDone = chunk.done;
|
||||
serverTruncated ||= chunk.truncated;
|
||||
log.textContent = logText || (active(run) ? '等待脚本输出…' : '本次运行没有标准输出或标准错误。');
|
||||
if (follow.checked) log.scrollTop = log.scrollHeight;
|
||||
logStatus.textContent = active(run) ? (run.state === 'queued' ? '已排队 · 等待执行器' : '持续更新中 · 每 2.5 秒刷新') : logDone ? '日志读取完成' : '正在读取剩余日志…';
|
||||
logHint.textContent = serverTruncated ? '日志达到服务端限制,后续输出可能未保留。' : retainedTrimmed ? '页面仅显示最近 240,000 字符;完整内容请下载日志。' : `已读取 ${bytes(offset)}`;
|
||||
feedback.replaceChildren();
|
||||
renderRun();
|
||||
return active(run) || !logDone;
|
||||
} catch (error) {
|
||||
if (ctx.alive) { execution.update(run, {blocked: true}); feedback.replaceChildren(errorBox(error, tick)); logStatus.textContent = '连接暂时中断,保留已读取日志并继续重试'; }
|
||||
} finally { busy = false; retry.disabled = false; }
|
||||
}
|
||||
follow.addEventListener('change', () => { if (follow.checked) log.scrollTop = log.scrollHeight; });
|
||||
renderRun();
|
||||
ctx.repeat(tick);
|
||||
}
|
||||
|
||||
function artifactRow(ctx, run, artifact) {
|
||||
const url = `${runUrl(run.id)}/artifacts/${enc(artifact.id)}`;
|
||||
const actions = [link('下载', url, 'button small')];
|
||||
if (['text', 'json'].includes(artifact.kind)) actions.unshift(button('预览', async () => {
|
||||
const content = el('div', {}, loading('正在读取文件…'));
|
||||
const panel = dialog(artifact.name, content);
|
||||
try {
|
||||
const preview = await api(`${url}/preview`, {}, ctx.signal);
|
||||
if (!ctx.alive || !panel.open) return;
|
||||
content.replaceChildren(el('pre', {class: 'code', tabindex: '0'}, preview.text || '(空文件)'));
|
||||
if (preview.truncated) content.prepend(note('仅显示文件开头部分,请下载查看完整内容。'));
|
||||
} catch (error) { if (ctx.alive && panel.open) content.replaceChildren(errorBox(error)); }
|
||||
}, 'small'));
|
||||
return el('div', {class: 'artifact'}, el('div', {class: 'artifact-name'}, artifact.name,
|
||||
el('small', {}, `${bytes(artifact.size)} · ${artifact.kind === 'json' ? 'JSON' : artifact.kind === 'text' ? '文本' : '文件'}`)), el('div', {class: 'actions'}, actions));
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import {api, el, button, link, badge, note, empty, loading, field, input, textarea, select, heading, kv, errorBox, toast, dialog, date, enc, scriptUrl} from './ui.js';
|
||||
import {parseArgs, parseLines, requestId} from './core.js';
|
||||
import {runTable} from './runs.js';
|
||||
import {relatedFiles} from './related-files.js';
|
||||
import {isNative, maxTimeout, nativeParameterForm, nativeScriptInfo, preflightNativeRun} from './native-ui.js';
|
||||
|
||||
const pendingRuns = new Map();
|
||||
function pendingRequest(id) {
|
||||
if (pendingRuns.has(id)) return pendingRuns.get(id);
|
||||
let value;
|
||||
try { value = sessionStorage.getItem(`xas.pending-run.${id}`); } catch { /* Private browser settings can disable storage. */ }
|
||||
if (!value || !/^[0-9a-f-]{36}$/.test(value)) value = requestId();
|
||||
pendingRuns.set(id, value);
|
||||
try { sessionStorage.setItem(`xas.pending-run.${id}`, value); } catch { /* The in-memory request still supports safe retry. */ }
|
||||
return value;
|
||||
}
|
||||
function clearRequest(id) {
|
||||
pendingRuns.delete(id);
|
||||
try { sessionStorage.removeItem(`xas.pending-run.${id}`); } catch { /* Storage may be unavailable. */ }
|
||||
}
|
||||
const scriptState = script => !script.enabled && script.status === 'ready' ? 'disabled' : script.status;
|
||||
const scriptBadge = script => badge(scriptState(script), scriptState(script) === 'disabled' ? '未启用' : undefined);
|
||||
const sourceLabel = script => script.kind === 'managed' ? '托管脚本' : '外部原文件';
|
||||
const profileLabel = script => isNative(script) ? '服务器原环境' : script.config.profile === 'legacy_smoke' ? '旧版 Smoke 兼容环境' : '标准运行环境';
|
||||
const typeLabel = script => script.category === 'example' ? '验收示例' : script.test_kind === 'suite' ? '测试套件' : '单项测试';
|
||||
const groupLabels = new Map([
|
||||
['uploaded', '上传的脚本'], ['company-gpu-suite', '公司 GPU 测试套件'],
|
||||
['dsv41-trial-20261010', 'DeepSeek 试验脚本(10/10)'], ['model-trials-20261011', '模型试验脚本(10/11)']
|
||||
]);
|
||||
|
||||
export async function scriptList(ctx) {
|
||||
ctx.show(heading('测试项目', '按实际测试行为管理,一项对应一次完整测试。'), loading());
|
||||
try {
|
||||
const {items} = await api('/api/scripts?include_examples=true', {}, ctx.signal);
|
||||
if (!ctx.alive) return;
|
||||
const search = input({type: 'search', placeholder: '搜索测试名称、用途或入口', 'aria-label': '搜索测试'});
|
||||
const group = select('来源目录', [['', '全部来源'], ...Array.from(new Set(items.map(item => item.source_group || '未分组'))).sort().map(name => [name, groupLabels.get(name) || name])]);
|
||||
const status = select('脚本状态', [['', '全部状态'], ['ready', '可运行'], ['needs_configuration', '待配置'], ['missing', '源文件缺失'], ['disabled', '未启用']]);
|
||||
const kind = select('测试类型', [['', '全部类型'], ['suite', '测试套件'], ['test', '单项测试']]);
|
||||
const purpose = select('项目用途', [['test', '业务测试'], ['example', '验收示例'], ['', '全部项目']], 'test');
|
||||
const results = el('div');
|
||||
const count = el('span', {class: 'muted', role: 'status'});
|
||||
const discover = button('↻ 刷新来源', async () => {
|
||||
discover.disabled = true;
|
||||
try {
|
||||
const result = await api('/api/discover', {method: 'POST'}, ctx.signal);
|
||||
if (ctx.alive) {
|
||||
toast(`目录已刷新:${result.tests} 项测试。内部文件更新不会自动新增测试项目。`);
|
||||
await scriptList(ctx);
|
||||
}
|
||||
} catch (error) { if (ctx.alive) { toast(error.message); discover.disabled = false; } }
|
||||
});
|
||||
ctx.show(heading('测试项目', '一项对应一次测试;套件内部的多个脚本统一在详情中查看。', [discover, button('+ 新增测试', () => uploadDialog(ctx), 'primary')]),
|
||||
el('div', {class: 'filters'}, el('div', {class: 'search'}, search), purpose, kind, status, group),
|
||||
el('div', {class: 'section-title'}, el('span', {class: 'muted'}, '按测试行为组织 · 验收示例默认隐藏'), count), results);
|
||||
const render = () => {
|
||||
const term = search.value.trim().toLowerCase();
|
||||
const filtered = items.filter(item => (!group.value || (item.source_group || '未分组') === group.value)
|
||||
&& (!status.value || scriptState(item) === status.value) && (!kind.value || item.test_kind === kind.value)
|
||||
&& (!purpose.value || item.category === purpose.value)
|
||||
&& (!term || [item.name, item.description, item.source_path].some(value => (value || '').toLowerCase().includes(term))));
|
||||
count.textContent = `${filtered.length} 项测试`;
|
||||
results.replaceChildren();
|
||||
if (!filtered.length) { results.append(empty(items.length ? '没有匹配的测试项目' : '从第一个测试开始', items.length ? '调整关键词或筛选条件。' : '上传一个测试的入口脚本,创建测试项目。')); return; }
|
||||
const groups = new Map();
|
||||
for (const item of filtered) {
|
||||
const name = item.category === 'example' ? '控制台验收示例' : item.test_kind === 'suite' ? '测试套件' : '单项测试';
|
||||
if (!groups.has(name)) groups.set(name, []);
|
||||
groups.get(name).push(item);
|
||||
}
|
||||
for (const [name, scripts] of groups) results.append(el('section', {},
|
||||
el('div', {class: 'section-title'}, el('h2', {}, name, el('span', {class: 'count'}, `${scripts.length} 项`))),
|
||||
el('div', {class: 'catalog-grid'}, scripts.map(scriptCard))));
|
||||
};
|
||||
[search, group, status, kind, purpose].forEach(control => control.addEventListener('input', render));
|
||||
render();
|
||||
} catch (error) { if (ctx.alive) ctx.show(heading('测试项目', '查看、配置与运行测试。'), errorBox(error, () => scriptList(ctx))); }
|
||||
}
|
||||
|
||||
function scriptCard(script) {
|
||||
return el('a', {class: 'script-card', href: `#/scripts/${enc(script.id)}`, 'aria-label': script.name},
|
||||
el('div', {class: 'card-top'}, el('span', {class: `language ${script.language}`}, typeLabel(script)), scriptBadge(script)),
|
||||
el('h3', {}, script.name),
|
||||
el('p', {class: 'script-description'}, script.description || '尚未填写说明。'),
|
||||
el('p', {class: 'card-source', title: script.source_path}, script.source_path),
|
||||
script.reason ? el('p', {class: 'card-reason'}, script.reason) : null,
|
||||
el('div', {class: 'card-bottom'}, el('span', {}, `${script.file_count} 个相关文件 · v${script.version}`), el('span', {class: 'button detail-link'}, '查看项目详情 →')));
|
||||
}
|
||||
|
||||
function uploadDialog(ctx, script) {
|
||||
const file = input({type: 'file', accept: '.py,.sh', required: '', name: 'file'});
|
||||
const name = input({name: 'name', required: '', maxlength: 120, placeholder: '例如:模型接口连通性检查'});
|
||||
const description = textarea({name: 'description', placeholder: '说明测试目标、运行条件和输出结果'});
|
||||
const feedback = el('div', {'aria-live': 'polite'});
|
||||
const submit = el('button', {class: 'button primary', type: 'submit'}, script ? '上传新版本' : '创建脚本');
|
||||
const form = el('form', {}, note(script ? '新版本单独保存,不覆盖历史源码。后续运行使用最新版本,已有运行记录保持原版本。' : '支持 Python 和 Shell。脚本代码只读;后续修改请上传新版本。'),
|
||||
field('脚本文件', file, '选择 .py 或 .sh 文件。'),
|
||||
script ? null : field('脚本名称', name), script ? null : field('说明', description), feedback,
|
||||
el('div', {class: 'form-footer'}, submit));
|
||||
const panel = dialog(script ? `上传新版本 · ${script.name}` : '上传新脚本', form);
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (submit.disabled || !form.reportValidity()) return;
|
||||
const data = new FormData(form);
|
||||
submit.disabled = true;
|
||||
submit.textContent = '正在上传…';
|
||||
feedback.replaceChildren();
|
||||
try {
|
||||
const saved = await api(script ? `${scriptUrl(script.id)}/versions` : '/api/scripts/upload', {method: 'POST', body: data}, ctx.signal);
|
||||
if (!ctx.alive) return;
|
||||
panel.close();
|
||||
toast(script ? '新版本已保存。' : '脚本已创建。');
|
||||
if (location.hash === `#/scripts/${enc(saved.id)}`) await scriptDetail(ctx, saved.id);
|
||||
else location.hash = `/scripts/${enc(saved.id)}`;
|
||||
} catch (error) {
|
||||
if (ctx.alive) feedback.replaceChildren(errorBox(error), note('如果连接中断,请先关闭窗口并刷新脚本库,核对是否已保存,再决定重新上传。'));
|
||||
} finally { submit.disabled = false; submit.textContent = script ? '上传新版本' : '创建脚本'; }
|
||||
});
|
||||
}
|
||||
|
||||
export async function scriptDetail(ctx, id) {
|
||||
const navigation = () => el('div', {class: 'detail-navigation'}, link('← 返回测试项目', '#/scripts', 'button back-button'));
|
||||
ctx.show(navigation(), loading('正在读取脚本与版本…'));
|
||||
try {
|
||||
const script = await api(scriptUrl(id), {}, ctx.signal);
|
||||
if (!ctx.alive) return;
|
||||
if (!script.is_test) {
|
||||
const source = el('section', {class: 'panel'});
|
||||
ctx.show(navigation(), heading(script.name, '这是测试内部的源码文件。'), note('请从测试项目的执行入口配置和运行。'), source);
|
||||
sourcePanel(ctx, source, script);
|
||||
return;
|
||||
}
|
||||
let dirty = false;
|
||||
const feedback = el('div', {'aria-live': 'polite'});
|
||||
const start = button('▶ 开始运行', async () => {
|
||||
if (start.disabled) return;
|
||||
start.disabled = true;
|
||||
start.textContent = '正在提交…';
|
||||
feedback.replaceChildren();
|
||||
let prepared;
|
||||
try {
|
||||
if (isNative(script)) {
|
||||
start.textContent = '正在预检…';
|
||||
prepared = await preflightNativeRun(ctx, script);
|
||||
if (!prepared || !ctx.alive || dirty) return;
|
||||
start.textContent = '正在提交…';
|
||||
}
|
||||
const run = await api(`${scriptUrl(id)}/runs`, {method: 'POST', body: {request_id: pendingRequest(id), ...prepared?.payload}}, ctx.signal);
|
||||
clearRequest(id);
|
||||
if (ctx.alive) location.hash = `/runs/${enc(run.id)}`;
|
||||
} catch (error) {
|
||||
if (ctx.alive) feedback.replaceChildren(errorBox(error), note(isNative(script)
|
||||
? '再次点击会重新预检并重新确认影响,同时复用本次请求编号核对原记录,避免重复排队。'
|
||||
: '再次点击会复用本次请求编号,核对原运行记录,避免重复排队。'));
|
||||
} finally {
|
||||
prepared?.close();
|
||||
if (ctx.alive) { start.disabled = dirty || !script.enabled || script.status !== 'ready'; start.textContent = '▶ 开始运行'; }
|
||||
}
|
||||
}, 'primary');
|
||||
start.disabled = !script.enabled || script.status !== 'ready';
|
||||
const actions = [link('运行记录', `#/runs?script_id=${enc(id)}`, 'button')];
|
||||
if (script.kind === 'managed') actions.push(button('上传新版本', () => uploadDialog(ctx, script)));
|
||||
actions.push(start);
|
||||
const source = el('section', {class: 'panel'});
|
||||
const history = el('section', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '近期运行')), loading());
|
||||
ctx.show(navigation(), el('div', {class: 'breadcrumbs'}, link('测试项目', '#/scripts'), ' / ', script.name),
|
||||
heading(script.name, script.description || '查看源代码、配置参数并开始一次独立运行。', actions),
|
||||
feedback,
|
||||
script.reason ? note(script.reason, script.status === 'missing' ? 'danger' : '') : null,
|
||||
!script.enabled ? note('当前脚本未启用。确认运行参数和环境要求后,在配置中启用并保存。') : null,
|
||||
nativeScriptInfo(script),
|
||||
el('div', {class: 'detail-grid'}, source, configForm(ctx, script, () => {
|
||||
dirty = true;
|
||||
start.disabled = true;
|
||||
start.title = '请先保存修改后的运行配置。';
|
||||
})),
|
||||
relatedFiles(ctx, script), el('div', {class: 'section-title'}, el('h2', {}, '执行记录')), history);
|
||||
sourcePanel(ctx, source, script);
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const {items} = await api(`/api/runs?script_id=${enc(id)}&limit=10`, {}, ctx.signal);
|
||||
if (ctx.alive) history.replaceChildren(items.length ? runTable(items) : empty('还没有运行记录', '首次运行后,可在这里回看状态、日志和结果文件。'));
|
||||
} catch (error) { if (ctx.alive) history.replaceChildren(errorBox(error, loadHistory)); }
|
||||
};
|
||||
await loadHistory();
|
||||
} catch (error) { if (ctx.alive) ctx.show(navigation(), heading('脚本详情', '读取脚本信息失败。'), errorBox(error, () => scriptDetail(ctx, id))); }
|
||||
}
|
||||
|
||||
function sourcePanel(ctx, target, script) {
|
||||
const versions = script.versions?.length ? script.versions : [{version: script.version}];
|
||||
const version = select('查看源码版本', versions.map(item => [item.version, `版本 ${item.version}${String(item.version) === String(script.version) ? ' · 当前' : ''}`]), script.version);
|
||||
const download = link('下载源码', '', 'button small');
|
||||
const code = el('pre', {class: 'code', tabindex: '0', 'aria-label': '脚本源代码,只读'}, '正在读取源码…');
|
||||
const info = el('div', {class: 'source-info mono'});
|
||||
const feedback = el('div');
|
||||
target.append(el('div', {class: 'panel-header'}, el('h2', {}, script.is_test ? '执行入口(代码只读)' : '内部源码(只读)'), scriptBadge(script)),
|
||||
kv([['来源类型', sourceLabel(script)], ['源文件', script.source_path], ['运行环境', profileLabel(script)], ['更新时间', date(script.updated_at)]]),
|
||||
el('div', {class: 'source-header'}, version, download), info, feedback, code,
|
||||
el('p', {class: 'source-info'}, '代码只读。查看历史版本不改变运行版本;“开始运行”使用当前已保存版本与配置。'));
|
||||
let generation = 0;
|
||||
const load = async () => {
|
||||
const ticket = ++generation;
|
||||
code.textContent = '正在读取源码…';
|
||||
download.hidden = true;
|
||||
info.textContent = '';
|
||||
feedback.replaceChildren();
|
||||
try {
|
||||
const data = await api(`${scriptUrl(script.id)}/code?version=${enc(version.value)}`, {}, ctx.signal);
|
||||
if (!ctx.alive || ticket !== generation) return;
|
||||
code.textContent = data.code || '(空文件)';
|
||||
info.textContent = `${data.filename} · v${version.value}\nSHA-256 ${data.sha256}`;
|
||||
download.href = `${scriptUrl(script.id)}/download?version=${enc(version.value)}`;
|
||||
download.hidden = Boolean(data.redacted);
|
||||
if (data.redacted) feedback.append(note('源码中包含敏感内容,预览已脱敏,下载不可用。'));
|
||||
if (data.truncated) feedback.append(note(data.redacted ? '文件较大,当前仅显示部分已脱敏源码。' : '文件较大,当前仅显示部分源码。可下载完整文件。'));
|
||||
} catch (error) {
|
||||
if (ctx.alive && ticket === generation) { code.textContent = ''; feedback.replaceChildren(errorBox(error, load)); }
|
||||
}
|
||||
};
|
||||
version.addEventListener('change', load);
|
||||
load();
|
||||
}
|
||||
|
||||
function configForm(ctx, script, onDirty) {
|
||||
const config = script.config;
|
||||
const native = isNative(script);
|
||||
const limit = maxTimeout(script);
|
||||
const parameters = native ? nativeParameterForm(script) : null;
|
||||
const name = input({value: script.name, required: '', maxlength: 120});
|
||||
const description = textarea({value: script.description || ''});
|
||||
const args = textarea({value: JSON.stringify(config.args, null, 2), rows: 4, class: 'input mono', spellcheck: 'false'});
|
||||
const cwd = input({value: native ? script.execution.cwd || config.cwd || '' : config.cwd || '', placeholder: script.kind === 'managed' ? '留空:使用本次运行目录' : '原脚本工作目录'});
|
||||
const outputs = textarea({value: (config.output_paths || []).join('\n'), class: 'input mono', spellcheck: 'false', placeholder: '每行一个日志或结果路径'});
|
||||
if (native) [args, cwd, outputs].forEach(control => control.setAttribute('readonly', ''));
|
||||
const timeout = input({type: 'number', value: config.timeout_seconds, required: '', min: '5', max: String(limit), step: '1'});
|
||||
const enabled = input({type: 'checkbox', checked: script.enabled});
|
||||
const feedback = el('div', {'aria-live': 'polite'});
|
||||
const submit = el('button', {type: 'submit', class: 'button primary'}, '保存配置');
|
||||
const form = el('form', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '运行配置')),
|
||||
field('脚本名称', name), field('说明', description), parameters?.node,
|
||||
field(native ? '原入口参数(只读)' : '参数', args, native ? '参数由原环境方案计算;可调整的选项在上方填写,实际命令以本次预检为准。' : 'JSON 字符串数组,或每行一个参数;不会交给 shell 拼接。'),
|
||||
field(native ? '原工作目录(只读)' : '工作目录', cwd, native ? '固定使用登记的原目录。' : script.kind === 'managed' ? '托管脚本留空。可通过 XAS_RUN_DIR 获取本次结果目录。' : '必须位于已登记的来源目录内。'),
|
||||
field(native ? '原输出规则(只读)' : '归档输出路径', outputs, native ? '由固定方案管理;运行前预检显示本次输出规则,保留原脚本的输出行为。' : script.kind === 'managed' ? '托管脚本留空;本次结果目录统一归档,.log 文件的新增内容进入实时日志。' : '登记该来源 logs/ 或 results/ 内已存在的文件或目录,每行一项。.log 新增内容进入实时日志,其他结果结束后归档。'),
|
||||
field('超时时间(秒)', timeout, `填写 5–${limit} 秒之间的整数。${native ? '原环境方案最长支持 72 小时。' : ''}`), el('label', {class: 'checkbox'}, enabled, '启用此脚本'),
|
||||
native ? note('复用原账号、解释器、目录与已有配置,不修改原项目文件。实际运行按原脚本逻辑产生输出与影响。')
|
||||
: note(`${profileLabel(script)}。提供 Python 标准库、Bash 和 curl;额外依赖不会自动安装。`),
|
||||
!native && script.config.profile === 'legacy_smoke' ? note('兼容配置由服务端管理,只读。代理请求有固定上限,不能在此调整服务或模型。', 'info') : null,
|
||||
feedback, el('div', {class: 'form-footer'}, submit));
|
||||
form.addEventListener('input', () => {
|
||||
onDirty();
|
||||
feedback.replaceChildren(note('配置有未保存的修改。保存后才能开始运行。'));
|
||||
});
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (submit.disabled || !form.reportValidity()) return;
|
||||
feedback.replaceChildren();
|
||||
let payload;
|
||||
try {
|
||||
const seconds = Number(timeout.value);
|
||||
if (!Number.isSafeInteger(seconds) || seconds < 5 || seconds > limit) throw new Error(`超时时间必须是 5–${limit} 秒之间的整数。`);
|
||||
if (!name.value.trim()) throw new Error('请填写脚本名称。');
|
||||
payload = {name: name.value.trim(), description: description.value, enabled: enabled.checked,
|
||||
config: native ? {native_options: parameters.read(), timeout_seconds: seconds}
|
||||
: {args: parseArgs(args.value), cwd: cwd.value.trim(), output_paths: parseLines(outputs.value), timeout_seconds: seconds}};
|
||||
} catch (error) { feedback.replaceChildren(errorBox(error)); return; }
|
||||
submit.disabled = true;
|
||||
submit.textContent = '正在保存…';
|
||||
try {
|
||||
await api(scriptUrl(script.id), {method: 'PATCH', body: payload}, ctx.signal);
|
||||
if (ctx.alive) { toast('配置已保存,后续运行将使用新配置。'); await scriptDetail(ctx, script.id); }
|
||||
} catch (error) { if (ctx.alive) feedback.replaceChildren(errorBox(error)); }
|
||||
finally { submit.disabled = false; submit.textContent = '保存配置'; }
|
||||
});
|
||||
return form;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
@charset "UTF-8";
|
||||
:root{font-family:Inter,"Segoe UI","Microsoft YaHei",sans-serif;color:#22332c;background:#f4f6f3;font-synthesis:none;--ink:#22332c;--muted:#748079;--line:#e3e8e1;--green:#237349;--light:#ecf4ec;--danger:#b43d35;--radius:12px}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font-size:14px;line-height:1.55}
|
||||
button,input,textarea,select{font:inherit}
|
||||
button,a,input,textarea,select{touch-action:manipulation}
|
||||
a{color:var(--green);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
button{cursor:pointer}
|
||||
button:disabled{opacity:.5;cursor:not-allowed}
|
||||
:focus-visible{outline:3px solid #73b48a;outline-offset:3px}
|
||||
[hidden]{display:none!important}
|
||||
h1,h2,h3,p{margin:0}
|
||||
h1{font-size:28px;letter-spacing:-.8px;line-height:1.25;font-weight:650;overflow-wrap:anywhere}
|
||||
h2{font-size:17px;font-weight:650}
|
||||
h3{font-size:15px;font-weight:600}
|
||||
.sidebar{position:fixed;inset:0 auto 0 0;width:220px;background:#14231e;color:#b4c0b8;padding:31px 20px;display:flex;flex-direction:column;z-index:5}
|
||||
.brand{display:flex;align-items:center;gap:12px;color:#f6faf4;padding:0 12px;text-decoration:none!important;margin-bottom:60px}
|
||||
.brand-mark{display:grid;place-items:center;background:#b9db91;color:#173522;width:37px;height:39px;border-radius:10px;font-size:25px;font-weight:800;transform:rotate(-5deg)}
|
||||
.brand strong{font-size:25px;letter-spacing:3px;line-height:1;display:block}
|
||||
.brand small{font-size:9px;letter-spacing:1.6px;color:#a2b5a7;display:block;margin-top:5px}
|
||||
.sidebar-label{font-size:11px;padding:0 15px;margin-bottom:12px;color:#7e9487;letter-spacing:2px}
|
||||
nav{display:grid;gap:8px}
|
||||
nav a{border:1px solid transparent;display:flex;align-items:center;gap:13px;color:#bac8bf;padding:12px 14px;border-radius:8px;font-weight:550}
|
||||
nav a:hover{background:#1e342a;text-decoration:none}
|
||||
nav a[aria-current=page]{background:#2b4334;color:#d4edb1;border-color:#3b5642}
|
||||
nav a>span:first-child{font-size:20px;line-height:1;opacity:.85}
|
||||
.nav-count{font-size:11px;border-radius:4px;background:#3e5844;min-width:24px;padding:0 5px;text-align:center;margin-left:auto}
|
||||
.sidebar-note{font-size:11px;border-top:1px solid #304034;padding:21px 12px 0;margin-top:auto;color:#becdbf}
|
||||
.sidebar-note p{font-size:11px;color:#7d9383;margin-top:9px;line-height:1.9}
|
||||
.dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#adc888;margin-right:7px;vertical-align:2px}
|
||||
.sidebar-bottom{padding:29px 12px 0;color:#879e8d;font-size:12px}
|
||||
.sidebar-bottom span{font-size:10px;margin-left:7px;color:#617a69}
|
||||
.workspace{margin-left:220px;min-height:100vh;display:flex;flex-direction:column}
|
||||
.topbar{height:73px;border-bottom:1px solid var(--line);background:#fbfcf9;display:flex;align-items:center;justify-content:space-between;padding:0 38px;font-size:12px}
|
||||
.topbar>div:first-child{display:flex;align-items:center;gap:14px}
|
||||
.eyebrow{font-size:10px;letter-spacing:1.5px;color:#98a298}
|
||||
#worker-status{display:flex;align-items:center;gap:8px;color:#748477}
|
||||
.worker-light{width:7px;height:7px;display:inline-block;border-radius:100%;background:#2e955d}
|
||||
.worker-light.offline{background:#cd8b36}
|
||||
.summary{padding:26px 38px 0;display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
|
||||
.stat{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px 20px;position:relative}
|
||||
.stat-label{font-size:12px;color:var(--muted)}
|
||||
.stat-value{font-size:27px;font-weight:620;letter-spacing:-1px;line-height:1.2;margin-top:6px;display:block}
|
||||
.stat-foot{font-size:10px;color:#859286;position:absolute;bottom:19px;right:18px}
|
||||
.stat.accent{background:#edf4e7;border-color:#dce9d3}
|
||||
.stat.accent .stat-value{color:#477129}
|
||||
main{padding:30px 38px 36px;flex:1;min-width:0}
|
||||
.page-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:26px}
|
||||
.page-heading p{color:var(--muted);font-size:12px;margin-top:9px;max-width:650px}
|
||||
.actions{display:flex;gap:9px;align-items:center;flex-wrap:wrap}
|
||||
.button{border:1px solid #dbe3d8;background:#fff;color:var(--ink);border-radius:7px;padding:9px 15px;min-height:38px;display:inline-flex;justify-content:center;align-items:center;gap:7px;white-space:nowrap;font-size:12px;font-weight:550;text-decoration:none!important;line-height:1.4}
|
||||
.button:hover:enabled{background:#f1f5ed;border-color:#b8cbb8}
|
||||
.button.primary{background:#2b703d;border-color:#2b703d;color:white;box-shadow:0 2px 3px #234d2710}
|
||||
.button.primary:hover:enabled{background:#235e32}
|
||||
.button.danger{color:#a64236;border-color:#e8c7bf}
|
||||
.button.small{padding:5px 10px;min-height:29px;font-size:11px}
|
||||
.button.ghost{border-color:transparent;background:transparent;color:#618063}
|
||||
.button.detail-link{min-height:46px;min-width:112px;padding:11px 18px;font-size:14px;font-weight:600;color:var(--green);border-color:#bed5c4;background:#f2f8f2}
|
||||
.button.detail-link:hover,.button.back-button:hover{background:#e6f1e7;border-color:#79a586}
|
||||
.button.back-button{min-height:44px;padding:10px 17px;font-size:14px;border-color:#b8cdbb;color:var(--green);box-shadow:0 2px 8px #1839230c}
|
||||
.detail-navigation{position:sticky;top:12px;z-index:3;display:flex;width:fit-content;margin-bottom:20px}
|
||||
.filters{display:flex;gap:11px;align-items:center;flex-wrap:wrap;background:#fff;border:1px solid var(--line);padding:15px 17px;border-radius:var(--radius);margin-bottom:22px}
|
||||
.input{border:1px solid #dce3d9;border-radius:7px;padding:9px 11px;background:#fff;color:var(--ink);min-height:38px;min-width:0;width:100%;font-size:12px;transition:border-color .15s}
|
||||
.input:hover{border-color:#acbfaa}
|
||||
.input:focus{border-color:#55905e}
|
||||
.input::placeholder{color:#9aa69a}
|
||||
textarea.input{resize:vertical;line-height:1.7}
|
||||
.search{flex:1;min-width:200px;position:relative}
|
||||
.search .input{padding-left:34px;background:#f8faf6}
|
||||
.search:before{content:'⌕';position:absolute;left:13px;top:6px;font-size:20px;color:#8b9c89}
|
||||
.filters select{width:auto;max-width:220px}
|
||||
.section-title{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:23px 0 13px;font-size:12px}
|
||||
.section-title h2{font-size:14px;overflow-wrap:anywhere}
|
||||
.section-title .count{font-size:11px;font-weight:400;color:#8c9b8b;margin-left:9px}
|
||||
.muted{color:var(--muted);font-size:12px}
|
||||
.catalog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(275px,1fr));gap:16px}
|
||||
.script-card{min-width:0;background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:19px 20px 16px;display:flex;flex-direction:column;transition:box-shadow .15s,border-color .15s;text-decoration:none!important;color:inherit}
|
||||
.script-card:hover{border-color:#b5caae;box-shadow:0 5px 18px #233c2910}
|
||||
.card-top{display:flex;justify-content:space-between;gap:14px;align-items:center;margin-bottom:16px}
|
||||
.language{font-family:Consolas,monospace;font-size:11px;font-weight:bold;padding:5px 8px;border-radius:6px;color:#557b34;background:#f0f5e6;letter-spacing:.4px}
|
||||
.language.shell{background:#eef1f7;color:#607087}
|
||||
.script-card h3{color:#263c2b;font-size:15px;overflow-wrap:anywhere}
|
||||
.script-description{color:#7a877b;line-height:1.8;font-size:12px;margin:9px 0 14px;min-height:43px;overflow-wrap:anywhere}
|
||||
.card-source{color:#93a090;font-family:Consolas,monospace;font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:14px}
|
||||
.card-bottom{border-top:1px solid #ecf0e8;padding-top:12px;display:flex;flex-wrap:wrap;gap:12px;justify-content:space-between;align-items:center;margin-top:auto;font-size:10px;color:#879481}
|
||||
.card-bottom .detail-link{width:100%}.script-card:hover .detail-link{background:#e6f1e7;border-color:#79a586}
|
||||
.card-reason{color:#9c7737;font-size:11px;line-height:1.7;margin:-1px 0 14px}
|
||||
.badge{font-size:10px;white-space:nowrap;display:inline-flex;align-items:center;border-radius:5px;padding:3px 7px;line-height:1.5;background:#eef0ed;color:#69786a;font-weight:500}
|
||||
.badge:before{content:'';width:5px;height:5px;border-radius:50%;background:currentColor;margin-right:5px}
|
||||
.badge.ready,.badge.succeeded{color:#458344;background:#edf6e9}
|
||||
.badge.running{color:#3472a3;background:#eaf3fa}
|
||||
.badge.queued,.badge.needs_configuration,.badge.timed_out{color:#a17a26;background:#fcf4df}
|
||||
.badge.failed,.badge.missing,.badge.interrupted{color:#b55248;background:#fbeeea}
|
||||
.badge.synthetic{color:#7f648e;background:#f2edf7}
|
||||
.badge.disabled,.badge.cancelled{color:#7b867a;background:#f0f2ee}
|
||||
.panel{border:1px solid var(--line);border-radius:var(--radius);background:white;padding:22px;margin-bottom:20px;min-width:0}
|
||||
.panel-header{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:20px}
|
||||
.detail-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(310px,1fr);gap:22px;align-items:start}
|
||||
.detail-grid>.panel{margin-bottom:0}
|
||||
.breadcrumbs{display:flex;flex-wrap:wrap;align-items:center;gap:10px;font-size:11px;color:#8d9c89;margin:-4px 0 17px;overflow-wrap:anywhere}
|
||||
.metadata{display:grid;grid-template-columns:90px minmax(0,1fr);column-gap:16px;row-gap:10px;font-size:11px;margin:0}
|
||||
.metadata dt{color:#8c9a87}
|
||||
.metadata dd{margin:0;overflow-wrap:anywhere;color:#475840}
|
||||
.metadata dd.mono,.mono{font-family:"Cascadia Code",Consolas,monospace}
|
||||
.source-header{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:18px 0 11px}
|
||||
.source-header select{width:auto;min-width:160px;flex:1}
|
||||
.source-info{font-size:10px;color:#82917d;overflow-wrap:anywhere;line-height:1.9;margin:12px 0}
|
||||
pre{white-space:pre;tab-size:4;overflow:auto;margin:0;font-family:"Cascadia Code",Consolas,monospace;font-size:12px;line-height:1.8}
|
||||
.code{background:#f6f8f3;border:1px solid #e4eadf;border-radius:8px;min-height:240px;max-height:580px;padding:18px;color:#425637}
|
||||
.code[tabindex]:focus{outline-offset:2px}
|
||||
.field{display:flex;flex-direction:column;gap:7px;margin-bottom:17px}
|
||||
.field-label{font-size:12px;font-weight:550}
|
||||
.field small{color:#8b9885;font-size:10px;line-height:1.75}
|
||||
.field-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.checkbox{display:flex;gap:9px;align-items:center;margin:18px 0;font-size:12px}
|
||||
.checkbox input{accent-color:#477e38;width:15px;height:15px;min-height:auto}
|
||||
.form-footer{border-top:1px solid #e9eee5;padding-top:17px;display:flex;align-items:center;justify-content:flex-end;gap:10px}
|
||||
.notice{padding:12px 14px;border-radius:8px;border:1px solid #e5e8d4;background:#f7f8ed;color:#7c8055;font-size:12px;margin-bottom:17px;line-height:1.8;display:flex;align-items:center;gap:13px;justify-content:space-between;overflow-wrap:anywhere}
|
||||
.notice.danger{background:#fff1ee;border-color:#f0d7d0;color:#a34c3d}
|
||||
.notice.success{background:#eff7e9;border-color:#dfebd4;color:#56783d}
|
||||
.notice.info{background:#edf3f6;border-color:#dae5eb;color:#647f8d}
|
||||
.global-notice{margin:18px 38px 0;padding:12px 15px;border:1px solid #ebdac1;background:#fcf5e7;color:#8c744b;border-radius:8px;font-size:12px}
|
||||
.loading,.empty{padding:65px 25px;text-align:center;color:#819078;grid-column:1/-1}
|
||||
.loading:before{content:'';display:inline-block;border:2px solid #d7e4cf;border-top-color:#618d51;border-radius:50%;width:14px;height:14px;margin-right:10px;vertical-align:-3px;animation:spin .9s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.empty-icon{font-size:33px;color:#a5b49a;display:block;margin-bottom:8px}
|
||||
.empty p{font-size:12px;margin-top:8px;color:#9ca795}
|
||||
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:var(--radius);background:white}
|
||||
table{border-collapse:collapse;width:100%;text-align:left;white-space:nowrap;font-size:12px}
|
||||
th{color:#93a087;font-size:10px;font-weight:500;background:#f9fbf6;padding:13px 18px}
|
||||
td{padding:16px 18px;border-top:1px solid #edf0e8;color:#6c7c60}
|
||||
td:first-child{color:#354b2c;font-weight:550;max-width:360px;white-space:normal;overflow-wrap:anywhere}
|
||||
td .badge{margin-right:6px}
|
||||
tbody tr:hover{background:#fcfdf9}
|
||||
.run-row{position:relative;cursor:pointer}.run-row:hover,.run-row:focus-within{background:#f1f7ef}.run-row:focus-within{box-shadow:inset 0 0 0 2px #73b48a}
|
||||
.run-name{font-size:13px;color:#354b2c}.run-open:after{content:'';position:absolute;inset:0}
|
||||
.run-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:15px;margin-bottom:20px}
|
||||
.run-summary>div{border-right:1px solid var(--line);padding-right:15px}
|
||||
.run-summary>div:last-child{border-right:0}
|
||||
.run-summary small{display:block;color:#94a18b;font-size:10px;margin-bottom:7px}
|
||||
.run-summary strong{font-size:12px;font-weight:550;overflow-wrap:anywhere}
|
||||
.log-panel{padding:0;overflow:hidden}
|
||||
.log-toolbar{padding:15px 19px;display:flex;justify-content:space-between;align-items:center;gap:16px;border-bottom:1px solid #e0e6d9}
|
||||
.log-toolbar h2{font-size:14px}
|
||||
.log{background:#1b281f;color:#cedcbd;padding:22px;min-height:320px;max-height:590px;font-size:12px;line-height:1.8;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.log-hint{font-size:10px;color:#8b9b80;padding:10px 19px;border-top:1px solid #e8eddf;display:flex;justify-content:space-between;gap:12px}
|
||||
.log-toolbar label{font-size:11px;display:flex;align-items:center;gap:6px;color:#879779}
|
||||
.artifact-list{display:grid;gap:9px}
|
||||
.artifact{display:flex;justify-content:space-between;align-items:center;padding:13px 15px;border:1px solid #e6ecde;border-radius:8px;gap:16px}
|
||||
.artifact-name{font-size:12px;overflow-wrap:anywhere}
|
||||
.artifact-name small{display:block;font-size:10px;color:#9aa68f;margin-top:3px}
|
||||
.disclosure{margin-top:17px}
|
||||
.disclosure summary{cursor:pointer;font-size:12px;color:#71845f;padding:8px 0}
|
||||
.disclosure pre{max-height:300px;background:#f5f8ef;border-radius:7px;padding:16px;font-size:11px;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.dialog{border:1px solid #dce5d0;border-radius:14px;padding:25px;width:min(580px,calc(100% - 30px));max-height:90vh;color:var(--ink);box-shadow:0 20px 80px #17250d35}
|
||||
.dialog::backdrop{background:#15201070;backdrop-filter:blur(2px)}
|
||||
.related-files{display:grid;gap:12px;margin-top:18px}.related-files .code{max-height:420px}.related-files>.button{justify-self:start}
|
||||
.native-plan .notice:last-child{margin-bottom:0}.native-description{font-size:12px;color:#748079;margin-top:17px;line-height:1.8}
|
||||
.native-impact{margin:18px 0;padding:14px 16px;background:#f5f8f0;border:1px solid #e4eada;border-radius:8px}.native-impact.attention{background:#fdf6e7;border-color:#ecdcba}
|
||||
.native-impact ul{padding-left:20px;margin:9px 0;font-size:12px;line-height:1.9}.native-impact h3{font-size:13px}.native-impact .muted{font-size:11px}
|
||||
.native-command{margin:12px 0;padding:15px;background:#f2f6ee;border:1px solid #dfe7d7;border-radius:8px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:280px;font-size:11px}
|
||||
.native-subtitle{margin-top:20px;font-size:12px}.native-dialog{width:min(720px,calc(100% - 30px))}
|
||||
.preflight-checks{padding:0;margin:15px 0;list-style:none;display:grid;gap:10px}.preflight-checks li{display:flex;align-items:flex-start;gap:10px;font-size:12px}.preflight-checks p{font-size:11px;color:#7f8c74;margin-top:3px;overflow-wrap:anywhere}
|
||||
.native-menu{border-top:1px solid var(--line);margin-top:20px;padding-top:20px}.native-menu h3{margin-bottom:12px}.native-parameters{border-top:1px solid var(--line);padding-top:17px}.input[readonly]{background:#f6f8f3;color:#73816a}
|
||||
.dialog-header{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:23px}
|
||||
.dialog .code{max-height:55vh;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.page-footer{display:flex;justify-content:space-between;gap:20px;padding:17px 38px;border-top:1px solid var(--line);color:#a0ac94;font-size:10px}
|
||||
.toast{position:fixed;bottom:26px;left:calc(50% + 110px);transform:translateX(-50%);z-index:20;background:#263e21;color:#e0edcf;padding:13px 22px;border-radius:9px;font-size:12px;box-shadow:0 5px 25px #19311425;max-width:calc(100% - 40px)}
|
||||
.skip-link{position:fixed;top:-100px;left:15px;padding:10px;background:white;z-index:100}
|
||||
.skip-link:focus{top:10px}
|
||||
.noscript{position:fixed;top:80px;left:240px;background:#fff3df;padding:20px}
|
||||
@media(min-width:1600px){main,.summary{padding-left:55px;padding-right:55px}.catalog-grid{grid-template-columns:repeat(3,1fr)}}
|
||||
@media(max-width:1100px){.sidebar{width:184px;padding:29px 14px}.workspace{margin-left:184px}.topbar{padding:0 25px}main{padding:26px 25px}.summary{padding:22px 25px 0;gap:10px}.stat{padding:14px}.stat-foot{display:none}.detail-grid{grid-template-columns:1fr}.toast{left:calc(50% + 92px)}.global-notice{margin-left:25px;margin-right:25px}}
|
||||
@media(max-width:720px){.sidebar{position:static;width:auto;padding:16px 19px;flex-direction:row;align-items:center;gap:20px}.brand{padding:0;margin:0;gap:8px}.brand strong{font-size:20px}.brand small{font-size:7px}.brand-mark{width:30px;height:33px;font-size:21px}.sidebar-label,.sidebar-note,.sidebar-bottom{display:none}nav{display:flex;gap:5px;margin-left:auto}nav a{padding:8px 10px;font-size:12px;gap:6px}nav a>span:first-child,.nav-count{display:none}.workspace{margin:0}.topbar{height:48px;padding:0 20px;font-size:10px}.eyebrow{display:none}.summary{padding:19px 18px 0;grid-template-columns:repeat(2,1fr);gap:9px}.stat{padding:12px 15px}.stat-label{font-size:10px}.stat-value{font-size:23px}.stat-foot{display:block;font-size:9px;right:12px;bottom:15px}main{padding:24px 18px}.page-heading{align-items:flex-start;flex-direction:column;gap:16px;margin-bottom:20px}.page-heading h1{font-size:24px}.page-heading p{font-size:11px}.page-heading>.actions{width:100%}.filters{gap:8px;padding:12px}.search{flex-basis:100%}.filters select{flex:1;max-width:none;width:calc(50% - 8px)}.catalog-grid{grid-template-columns:1fr}.panel{padding:17px}.log-panel{padding:0}.run-summary{grid-template-columns:1fr 1fr;row-gap:20px}.run-summary>div:nth-child(2){border:0}.log-toolbar{align-items:flex-start;flex-direction:column;gap:10px}.log-toolbar .actions{width:100%;justify-content:space-between}.log{padding:17px;font-size:11px}.log-hint{flex-direction:column;gap:3px}.artifact{align-items:flex-start;flex-direction:column}.artifact>.actions{align-self:flex-end}.field-grid{grid-template-columns:1fr;gap:0}.page-footer{padding:15px 18px;font-size:9px;flex-direction:column;gap:4px}.toast{left:50%;width:max-content}.global-notice{margin:15px 18px 0}.noscript{left:20px;right:20px}.metadata{grid-template-columns:75px minmax(0,1fr);gap:9px}.source-header{align-items:stretch}}
|
||||
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
||||
@@ -0,0 +1,125 @@
|
||||
export const stateNames = {
|
||||
queued: '排队中', running: '运行中', succeeded: '进程成功', failed: '失败',
|
||||
cancelled: '已取消', timed_out: '已超时', interrupted: '已中断',
|
||||
ready: '可运行', needs_configuration: '待配置', missing: '源文件缺失'
|
||||
};
|
||||
export const active = run => ['queued', 'running'].includes(run.state);
|
||||
export const enc = value => encodeURIComponent(value);
|
||||
export const scriptUrl = id => `/api/scripts/${enc(id)}`;
|
||||
export const runUrl = id => `/api/runs/${enc(id)}`;
|
||||
export const date = value => value ? new Date(value).toLocaleString('zh-CN', {hour12: false}) : '—';
|
||||
export function bytes(value) {
|
||||
if (!Number.isFinite(Number(value))) return '—';
|
||||
const size = Number(value);
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1048576) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
export function el(tag, attributes = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (key === 'class') node.className = value;
|
||||
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
|
||||
else if (key === 'text') node.textContent = value;
|
||||
else if (['checked', 'disabled', 'hidden', 'selected', 'value'].includes(key)) node[key] = value;
|
||||
else if (value !== undefined && value !== null) node.setAttribute(key, value);
|
||||
}
|
||||
for (const child of children.flat(Infinity)) {
|
||||
if (child !== null && child !== undefined) node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
export const button = (text, onClick, style = 'secondary') => el('button', {type: 'button', class: `button ${style}`, onclick: onClick}, text);
|
||||
export const link = (text, href, className = '') => el('a', {href, class: className}, text);
|
||||
export const badge = (state, text = stateNames[state] || state) => el('span', {class: `badge ${state}`}, text);
|
||||
export const note = (text, tone = '') => el('div', {class: `notice ${tone}`}, text);
|
||||
export const empty = (title, detail) => el('div', {class: 'empty'}, el('span', {class: 'empty-icon', 'aria-hidden': 'true'}, '▤'), el('h3', {}, title), el('p', {}, detail));
|
||||
export const loading = (text = '正在加载…') => el('div', {class: 'loading', role: 'status'}, text);
|
||||
let fieldCounter = 0;
|
||||
export function field(text, control, hint = '') {
|
||||
const hintId = `field-hint-${++fieldCounter}`;
|
||||
control.setAttribute('aria-label', text);
|
||||
if (hint) control.setAttribute('aria-describedby', hintId);
|
||||
return el('label', {class: 'field'}, el('span', {class: 'field-label'}, text), control, hint ? el('small', {id: hintId}, hint) : null);
|
||||
}
|
||||
export const input = (attributes = {}) => el('input', {class: 'input', ...attributes});
|
||||
export const textarea = (attributes = {}) => el('textarea', {class: 'input', rows: 3, ...attributes});
|
||||
export const select = (label, options, value = '') => el('select', {class: 'input', 'aria-label': label}, options.map(([id, text]) => el('option', {value: id, selected: String(id) === String(value)}, text)));
|
||||
export function heading(title, subtitle, actions = []) {
|
||||
return el('div', {class: 'page-heading'}, el('div', {}, el('h1', {}, title), el('p', {}, subtitle)), el('div', {class: 'actions'}, actions));
|
||||
}
|
||||
export function kv(items) {
|
||||
return el('dl', {class: 'metadata'}, items.map(([label, value]) => [el('dt', {}, label), el('dd', {}, value ?? '—')]));
|
||||
}
|
||||
export function errorBox(error, retry) {
|
||||
return el('div', {class: 'notice danger', role: 'alert'}, el('span', {}, error.message || '请求失败,请稍后重试。'), retry ? button('重试', retry, 'small') : null);
|
||||
}
|
||||
export async function api(path, options = {}, signal) {
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
if (signal?.aborted) controller.abort();
|
||||
else signal?.addEventListener('abort', abort, {once: true});
|
||||
const timer = setTimeout(abort, options.body instanceof FormData ? 120000 : 20000);
|
||||
const headers = {...options.headers};
|
||||
if (options.method && options.method !== 'GET') headers['X-XAS-Console'] = '1';
|
||||
let body = options.body;
|
||||
if (body && !(body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
body = JSON.stringify(body);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(path, {...options, body, headers, signal: controller.signal, credentials: 'same-origin'});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(typeof data?.detail === 'string' ? data.detail : `请求未完成(HTTP ${response.status})。`);
|
||||
if (data === null) throw new Error('服务器返回了无法读取的数据。');
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
if (error.name === 'AbortError') throw new Error('请求超时,操作结果尚未确认。请先核对记录;运行请求重试会复用原编号。');
|
||||
if (error instanceof TypeError) throw new Error('无法连接控制台,请检查网络后重试。');
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
let toastTimer;
|
||||
export function toast(text) {
|
||||
const target = document.querySelector('#toast');
|
||||
target.textContent = text;
|
||||
target.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { target.hidden = true; }, 5000);
|
||||
}
|
||||
export function dialog(title, content) {
|
||||
const close = button('关闭', () => panel.close(), 'small');
|
||||
close.setAttribute('aria-label', '关闭对话框');
|
||||
const panel = el('dialog', {class: 'dialog', 'aria-label': title}, el('div', {class: 'dialog-header'}, el('h2', {}, title), close), content);
|
||||
panel.addEventListener('close', () => panel.remove(), {once: true});
|
||||
document.body.append(panel);
|
||||
panel.showModal();
|
||||
return panel;
|
||||
}
|
||||
export function pageContext(main) {
|
||||
const controller = new AbortController();
|
||||
const timers = new Set();
|
||||
const context = {
|
||||
main, signal: controller.signal,
|
||||
get alive() { return !controller.signal.aborted; },
|
||||
show(...nodes) { if (context.alive) { main.replaceChildren(...nodes.filter(node => node !== null && node !== undefined)); main.setAttribute('aria-busy', 'false'); } },
|
||||
async repeat(callback, interval = 2500) {
|
||||
if (!context.alive) return;
|
||||
if (document.visibilityState === 'visible' && await callback() === false) return;
|
||||
if (context.alive) {
|
||||
const timer = setTimeout(() => { timers.delete(timer); context.repeat(callback, interval); }, interval);
|
||||
timers.add(timer);
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
controller.abort();
|
||||
timers.forEach(clearTimeout);
|
||||
document.querySelectorAll('dialog').forEach(panel => panel.close());
|
||||
}
|
||||
};
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Small SQLite repository. Queue transitions are serialized transactions."""
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
|
||||
TERMINAL = {"succeeded", "failed", "cancelled", "timed_out", "interrupted"}
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, settings):
|
||||
self.settings = settings
|
||||
settings.prepare()
|
||||
with self.connect() as db:
|
||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||
if version not in (0, 1):
|
||||
raise RuntimeError("Unsupported database version")
|
||||
db.executescript("""
|
||||
PRAGMA journal_mode=WAL;
|
||||
CREATE TABLE IF NOT EXISTS scripts(id TEXT PRIMARY KEY, data TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS versions(
|
||||
script_id TEXT NOT NULL, version INTEGER NOT NULL, data TEXT NOT NULL,
|
||||
PRIMARY KEY(script_id,version));
|
||||
CREATE TABLE IF NOT EXISTS runs(
|
||||
id TEXT PRIMARY KEY, request_id TEXT NOT NULL UNIQUE,
|
||||
script_id TEXT NOT NULL, state TEXT NOT NULL,
|
||||
cancel_requested INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS health(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
PRAGMA user_version=1;
|
||||
""")
|
||||
|
||||
@contextmanager
|
||||
def connect(self, write=False):
|
||||
db = sqlite3.connect(self.settings.db, timeout=10)
|
||||
db.row_factory = sqlite3.Row
|
||||
try:
|
||||
if write:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
yield db
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def scripts(self):
|
||||
with self.connect() as db:
|
||||
return [json.loads(r[0]) for r in db.execute("SELECT data FROM scripts ORDER BY id")]
|
||||
|
||||
def script(self, identity, versions=False):
|
||||
with self.connect() as db:
|
||||
row = db.execute("SELECT data FROM scripts WHERE id=?", (identity,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError("脚本不存在")
|
||||
item = json.loads(row[0])
|
||||
if versions:
|
||||
item["versions"] = [json.loads(r[0]) for r in db.execute(
|
||||
"SELECT data FROM versions WHERE script_id=? ORDER BY version DESC", (identity,))]
|
||||
return item
|
||||
|
||||
def save_script(self, item):
|
||||
with self.connect(write=True) as db:
|
||||
db.execute("INSERT OR REPLACE INTO scripts VALUES(?,?)", (item["id"], json.dumps(item)))
|
||||
|
||||
def version(self, identity, version):
|
||||
with self.connect() as db:
|
||||
row = db.execute("SELECT data FROM versions WHERE script_id=? AND version=?", (identity, version)).fetchone()
|
||||
if not row:
|
||||
raise KeyError("版本不存在")
|
||||
return json.loads(row[0])
|
||||
|
||||
def upload_version(self, item, content, filename):
|
||||
# A transaction prevents two simultaneous uploads from assigning the same version.
|
||||
with self.connect(write=True) as db:
|
||||
current = db.execute("SELECT data FROM scripts WHERE id=?", (item["id"],)).fetchone()
|
||||
if current:
|
||||
item = json.loads(current[0])
|
||||
version = item["version"] + 1
|
||||
target = self.settings.library / item["id"] / "versions" / str(version) / filename
|
||||
target.parent.mkdir(parents=True, exist_ok=False)
|
||||
try:
|
||||
with target.open("xb") as handle:
|
||||
handle.write(content)
|
||||
import hashlib
|
||||
value = {"version": version, "sha256": hashlib.sha256(content).hexdigest(),
|
||||
"created_at": now(), "filename": filename, "path": str(target)}
|
||||
item.update(version=version, sha256=value["sha256"], source_path=str(target), updated_at=now())
|
||||
db.execute("INSERT INTO versions VALUES(?,?,?)", (item["id"], version, json.dumps(value)))
|
||||
db.execute("INSERT OR REPLACE INTO scripts VALUES(?,?)", (item["id"], json.dumps(item)))
|
||||
except Exception:
|
||||
target.unlink(missing_ok=True)
|
||||
target.parent.rmdir()
|
||||
raise
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
def unpack_run(row):
|
||||
data = json.loads(row["data"])
|
||||
data.update(state=row["state"], cancel_requested=bool(row["cancel_requested"]))
|
||||
return data
|
||||
|
||||
def runs(self, script_id=None, limit=100):
|
||||
with self.connect() as db:
|
||||
query = "SELECT * FROM runs"
|
||||
values = []
|
||||
if script_id:
|
||||
query += " WHERE script_id=?"
|
||||
values.append(script_id)
|
||||
query += " ORDER BY rowid DESC LIMIT ?"
|
||||
values.append(limit)
|
||||
return [self.unpack_run(r) for r in db.execute(query, values)]
|
||||
|
||||
def run(self, identity):
|
||||
with self.connect() as db:
|
||||
row = db.execute("SELECT * FROM runs WHERE id=?", (identity,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError("运行记录不存在")
|
||||
return self.unpack_run(row)
|
||||
|
||||
def run_for_request(self, request_id):
|
||||
with self.connect() as db:
|
||||
row = db.execute("SELECT * FROM runs WHERE request_id=?", (request_id,)).fetchone()
|
||||
return self.unpack_run(row) if row else None
|
||||
|
||||
def queue(self, script, request_id, ip, execution=None):
|
||||
with self.connect(write=True) as db:
|
||||
old = db.execute("SELECT * FROM runs WHERE request_id=?", (request_id,)).fetchone()
|
||||
if old:
|
||||
if old["script_id"] != script["id"]:
|
||||
raise ValueError("请求编号已用于其他脚本")
|
||||
return self.unpack_run(old)
|
||||
pending = db.execute("SELECT count(*) FROM runs WHERE state IN ('queued','running')").fetchone()[0]
|
||||
if pending >= 20:
|
||||
raise ValueError("队列已满,请等待已有测试结束")
|
||||
item = {"id": uuid.uuid4().hex, "script_id": script["id"], "script_name": script["name"],
|
||||
"version": script["version"], "sha256": script["sha256"], "state": "queued",
|
||||
"created_at": now(), "started_at": None, "finished_at": None, "exit_code": None,
|
||||
"message": "等待执行", "config": script["config"], "artifacts": [],
|
||||
"synthetic": script.get("synthetic", False), "source_ip": ip,
|
||||
"source_path": script["source_path"], "language": script["language"],
|
||||
"kind": script["kind"], "source_group": script["source_group"]}
|
||||
if execution:
|
||||
item["execution"] = execution
|
||||
db.execute("INSERT INTO runs(id,request_id,script_id,state,data) VALUES(?,?,?,?,?)",
|
||||
(item["id"], request_id, script["id"], "queued", json.dumps(item)))
|
||||
return item
|
||||
|
||||
def claim(self):
|
||||
with self.connect(write=True) as db:
|
||||
if db.execute("SELECT 1 FROM runs WHERE state='running'").fetchone():
|
||||
return None
|
||||
row = db.execute("SELECT * FROM runs WHERE state='queued' ORDER BY rowid LIMIT 1").fetchone()
|
||||
if not row:
|
||||
return None
|
||||
item = self.unpack_run(row)
|
||||
item.update(started_at=now(), state="running", message="正在启动测试")
|
||||
db.execute("UPDATE runs SET state='running',data=? WHERE id=?", (json.dumps(item), item["id"]))
|
||||
return item
|
||||
|
||||
def finish(self, identity, state, **values):
|
||||
assert state in TERMINAL
|
||||
with self.connect(write=True) as db:
|
||||
row = db.execute("SELECT * FROM runs WHERE id=?", (identity,)).fetchone()
|
||||
item = self.unpack_run(row)
|
||||
item.update(values, state=state, finished_at=now())
|
||||
db.execute("UPDATE runs SET state=?,data=? WHERE id=?", (state, json.dumps(item), identity))
|
||||
return item
|
||||
|
||||
def running_message(self, identity, message):
|
||||
with self.connect(write=True) as db:
|
||||
row = db.execute("SELECT * FROM runs WHERE id=? AND state='running'", (identity,)).fetchone()
|
||||
if row:
|
||||
item = self.unpack_run(row)
|
||||
item["message"] = message
|
||||
db.execute("UPDATE runs SET data=? WHERE id=?", (json.dumps(item), identity))
|
||||
|
||||
def update_execution(self, identity, values):
|
||||
with self.connect(write=True) as db:
|
||||
row = db.execute("SELECT * FROM runs WHERE id=? AND state='running'", (identity,)).fetchone()
|
||||
if not row:
|
||||
raise ValueError("运行状态已变化")
|
||||
item = self.unpack_run(row)
|
||||
item["execution"] = {**item.get("execution", {}), **values}
|
||||
db.execute("UPDATE runs SET data=? WHERE id=?", (json.dumps(item), identity))
|
||||
return item
|
||||
|
||||
def cancel(self, identity):
|
||||
with self.connect(write=True) as db:
|
||||
row = db.execute("SELECT * FROM runs WHERE id=?", (identity,)).fetchone()
|
||||
if not row:
|
||||
raise KeyError("运行记录不存在")
|
||||
item = self.unpack_run(row)
|
||||
if row["state"] == "queued":
|
||||
item.update(state="cancelled", finished_at=now(), message="已取消排队")
|
||||
db.execute("UPDATE runs SET state='cancelled',cancel_requested=1,data=? WHERE id=?",
|
||||
(json.dumps(item), identity))
|
||||
elif row["state"] == "running":
|
||||
db.execute("UPDATE runs SET cancel_requested=1 WHERE id=?", (identity,))
|
||||
return self.run(identity)
|
||||
|
||||
def heartbeat(self):
|
||||
with self.connect(write=True) as db:
|
||||
db.execute("INSERT OR REPLACE INTO health VALUES('worker',?)", (now(),))
|
||||
|
||||
def health(self):
|
||||
with self.connect() as db:
|
||||
row = db.execute("SELECT value FROM health WHERE key='worker'").fetchone()
|
||||
last = row[0] if row else None
|
||||
online = bool(last and (datetime.now(timezone.utc) - datetime.fromisoformat(last)).total_seconds() < 15)
|
||||
return {"online": online, "last_seen": last}
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Project a file inventory into reviewed, runnable test entry points."""
|
||||
import fnmatch
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def relative_path(settings, item):
|
||||
source = next((s for s in settings.sources if s["id"] == item["source_group"]), None)
|
||||
if not source:
|
||||
return None
|
||||
try:
|
||||
return Path(item["source_path"]).relative_to(Path(source["path"])).as_posix()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def definition(settings, item):
|
||||
if item["kind"] == "managed":
|
||||
return next((d for d in settings.test_definitions
|
||||
if d.get("managed_profile") == item["config"]["profile"]), {})
|
||||
path = relative_path(settings, item)
|
||||
return next((d for d in settings.test_definitions
|
||||
if d.get("source_group") == item["source_group"] and d.get("entry") == path), None)
|
||||
|
||||
|
||||
def is_entry(settings, item):
|
||||
return definition(settings, item) is not None
|
||||
|
||||
|
||||
def members(settings, item, inventory):
|
||||
spec = definition(settings, item)
|
||||
if spec is None:
|
||||
raise ValueError("此文件是内部源码,请从测试项目入口查看相关文件")
|
||||
result = []
|
||||
for candidate in inventory:
|
||||
path = relative_path(settings, candidate)
|
||||
match = next((m for m in spec.get("members", [])
|
||||
if candidate["source_group"] == m["source_group"] and path is not None
|
||||
and (m["pattern"] == "**/*" or fnmatch.fnmatchcase(path, m["pattern"]))), None)
|
||||
if candidate["id"] != item["id"] and match is None:
|
||||
continue
|
||||
role = "entry" if candidate["id"] == item["id"] else (match or {}).get("role", "support")
|
||||
if role == "support" and path and ("tests" in Path(path).parts or Path(path).name.startswith("release-")):
|
||||
role = "self_test"
|
||||
result.append({"id": candidate["id"], "name": Path(candidate["source_path"]).name,
|
||||
"source_path": candidate["source_path"], "relative_path": path or Path(candidate["source_path"]).name,
|
||||
"source_group": candidate["source_group"], "role": role, "status": candidate["status"]})
|
||||
return sorted(result, key=lambda f: (f["role"] != "entry", f["source_group"], f["relative_path"]))
|
||||
|
||||
|
||||
def describe(settings, item, inventory):
|
||||
from .native_plans import public_execution
|
||||
result = dict(item)
|
||||
spec = definition(settings, item)
|
||||
result["is_test"] = spec is not None
|
||||
result["execution"] = public_execution(settings, item)
|
||||
if spec is None:
|
||||
result.update(test_kind="source", category="source", file_count=1, enabled=False)
|
||||
return result
|
||||
example = item.get("synthetic", False) or item["id"] in settings.example_script_ids
|
||||
result.update(test_kind=spec.get("kind", "test"), category="example" if example else "test",
|
||||
file_count=len(members(settings, item, inventory)))
|
||||
if item["kind"] == "external" and item["name"] == Path(item["source_path"]).name:
|
||||
result["name"] = spec.get("name", item["name"])
|
||||
if not item.get("description"):
|
||||
result["description"] = spec.get("description", "")
|
||||
return result
|
||||
|
||||
|
||||
def tests(settings, inventory, include_examples=False):
|
||||
result = [describe(settings, item, inventory) for item in inventory if is_entry(settings, item)]
|
||||
result = [item for item in result if include_examples or item["category"] != "example"]
|
||||
return sorted(result, key=lambda s: (not s["enabled"], s["category"] == "example", s["source_group"], s["name"]))
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Private dispatcher with a local Unix socket and one active test at a time."""
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from .artifacts import Collector
|
||||
from .catalog import clean_source, safe_path, script_path, source_for, validate_config, SECRET, SERVICE, SMOKE_HASH
|
||||
from .settings import Settings
|
||||
from .store import Store, now
|
||||
from . import workspace
|
||||
|
||||
LABEL = "xas.console=dedicated"
|
||||
|
||||
|
||||
def docker(*args, timeout=20):
|
||||
result = subprocess.run(["docker", *args], capture_output=True, text=True, timeout=timeout)
|
||||
if result.returncode:
|
||||
# Arguments and secrets are never included in public error details.
|
||||
raise RuntimeError("Docker operation failed: " + args[0])
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
class Worker:
|
||||
def __init__(self, settings, config):
|
||||
self.settings, self.config = settings, config
|
||||
self.store = Store(settings)
|
||||
self.stopping = False
|
||||
self.container = None
|
||||
|
||||
def stop(self, *args):
|
||||
self.stopping = True
|
||||
|
||||
def inspect_owned(self, container):
|
||||
info = json.loads(docker("inspect", container))[0]
|
||||
if info["Config"]["Labels"].get("xas.console") != "dedicated":
|
||||
raise RuntimeError("Refusing to manage a foreign container")
|
||||
return info
|
||||
|
||||
def remove(self, container):
|
||||
self.inspect_owned(container)
|
||||
docker("rm", "-f", container)
|
||||
|
||||
def recover(self):
|
||||
for container in docker("ps", "-aq", "--filter", "label=" + LABEL).splitlines():
|
||||
if container:
|
||||
self.remove(container)
|
||||
for mount in workspace.mounts(self.settings):
|
||||
workspace.remove(self.settings, mount)
|
||||
for run in self.store.runs(limit=10000):
|
||||
if run["state"] == "running":
|
||||
if run.get("execution", {}).get("mode") == "native":
|
||||
from .native_executor import NativeExecutor
|
||||
NativeExecutor(self).monitor(run)
|
||||
else:
|
||||
self.store.finish(run["id"], "interrupted", message="执行器重启,测试容器已停止;未自动补跑,远端请求结果可能未知")
|
||||
|
||||
def command(self, run, directory):
|
||||
if not re.fullmatch(r"[a-f0-9]{32}", run["id"]):
|
||||
raise ValueError("Invalid run identity")
|
||||
source = script_path(self.settings, run)
|
||||
raw = source.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest() != run["sha256"]:
|
||||
raise ValueError("脚本在排队后发生变化,本次未执行")
|
||||
if SECRET.search(clean_source(raw)):
|
||||
raise ValueError("脚本包含疑似凭据,未执行")
|
||||
if run["kind"] == "external" and SERVICE.search(clean_source(raw)):
|
||||
raise ValueError("涉及服务或设备管理的脚本尚未接入")
|
||||
config = validate_config(self.settings, run, run["config"])
|
||||
if run["language"] not in {"shell", "python"}:
|
||||
raise ValueError("Unsupported language")
|
||||
snapshot = directory / "source"
|
||||
snapshot.mkdir(mode=0o750)
|
||||
(snapshot / "script.txt").write_bytes(raw)
|
||||
(snapshot / "script.txt").chmod(0o444)
|
||||
work = directory / "work"
|
||||
work.mkdir(mode=0o770)
|
||||
uid = 0 if run["kind"] == "external" else 1000
|
||||
os.chown(work, uid, uid)
|
||||
workspace.create(self.settings, run["id"], uid)
|
||||
project = Path(self.config["project"])
|
||||
cmd = ["create", "--name", "adamwen-test-run-" + run["id"], "--label", LABEL,
|
||||
"--label", "xas.run=" + run["id"], "--runtime", "runc", "--network", "bridge",
|
||||
"--user", f"{uid}:{uid}", "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
|
||||
"--pids-limit", "64", "--memory", "512m", "--cpus", "1", "--read-only",
|
||||
"--tmpfs", "/tmp:rw,nosuid,nodev,noexec,size=67108864", "--log-driver", "local",
|
||||
"--log-opt", "max-size=10m", "--log-opt", "max-file=2", "--interactive",
|
||||
"--env", "PYTHONUNBUFFERED=1", "--env", "PYTHONDONTWRITEBYTECODE=1",
|
||||
"--env", "XAS_RUN_DIR=/work", "--mount", f"type=bind,src={work},dst=/work",
|
||||
"--mount", f"type=bind,src={project / 'console'},dst=/opt/xas-console,readonly",
|
||||
"--mount", f"type=bind,src={snapshot / 'script.txt'},dst=/input/script{source.suffix},readonly"]
|
||||
cwd, execution_path = "/work", "/input/script" + source.suffix
|
||||
if run["kind"] == "external":
|
||||
root = source_for(self.settings, run["source_group"])
|
||||
cmd += ["--mount", f"type=bind,src={root},dst={root},readonly"]
|
||||
cwd = config["cwd"]
|
||||
# Preserve __file__ and sibling imports. Hash is rechecked immediately before launch.
|
||||
execution_path = str(source)
|
||||
cmd += ["--mount", f"type=bind,src={snapshot / 'script.txt'},dst={source},readonly"]
|
||||
for output in config["output_paths"]:
|
||||
target = Path(output)
|
||||
cmd += ["--mount", f"type=bind,src={target},dst={target}"]
|
||||
cmd += ["--workdir", cwd, "--entrypoint", "python3", self.config["runner_image"], "/opt/xas-console/job_main.py"]
|
||||
payload = {"language": run["language"], "script": execution_path, "args": config["args"], "profile": config["profile"]}
|
||||
if config["profile"] == "legacy_smoke":
|
||||
if run["sha256"] != SMOKE_HASH or config["args"] or run["id"] in self.config.get("forbidden_runs", []):
|
||||
raise ValueError("轻量模型测试必须使用已核对的原始 smoke.sh,不接受额外参数")
|
||||
credential = Path(self.config["smoke_credential"])
|
||||
pairs = [line.split("=", 1)[1].strip().strip("\"'") for line in credential.read_text().splitlines()
|
||||
if line.startswith("VLLM_API_KEY=")]
|
||||
if len(pairs) != 1 or not pairs[0]:
|
||||
raise ValueError("模型认证配置不可用")
|
||||
payload.update(key=pairs[0], upstream=self.config["smoke_upstream"])
|
||||
return cmd, payload
|
||||
|
||||
def execute(self, run):
|
||||
directory = safe_path(self.settings.runs / run["id"], self.settings.runs, must_exist=False)
|
||||
directory.mkdir(mode=0o750, exist_ok=False)
|
||||
log = (directory / "console.log").open("wb", buffering=0)
|
||||
log_lock = threading.Lock()
|
||||
overflow = threading.Event()
|
||||
process = None
|
||||
collector = None
|
||||
state, exit_code, message = "failed", None, "执行器发生错误"
|
||||
|
||||
def emit(data):
|
||||
with log_lock:
|
||||
remaining = self.settings.max_log - log.tell()
|
||||
if remaining <= len(data):
|
||||
overflow.set()
|
||||
if remaining > 0:
|
||||
log.write(data[:remaining])
|
||||
|
||||
try:
|
||||
if shutil.disk_usage(self.settings.root).free < 10 * 1024**3:
|
||||
raise ValueError("服务器可用磁盘低于 10 GiB,未启动测试")
|
||||
cmd, payload = self.command(run, directory)
|
||||
collector = Collector(self.settings, directory, run["config"]["output_paths"])
|
||||
self.container = docker(*cmd)
|
||||
process = subprocess.Popen(["docker", "start", "-ai", self.container], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
process.stdin.write((json.dumps(payload) + "\n").encode())
|
||||
process.stdin.close()
|
||||
payload.clear()
|
||||
|
||||
def drain():
|
||||
while True:
|
||||
chunk = os.read(process.stdout.fileno(), 4096)
|
||||
if not chunk:
|
||||
break
|
||||
emit(chunk)
|
||||
reader = threading.Thread(target=drain, daemon=True)
|
||||
reader.start()
|
||||
started = time.monotonic()
|
||||
while process.poll() is None:
|
||||
self.store.heartbeat()
|
||||
collector.follow(emit)
|
||||
cancel = self.store.run(run["id"])["cancel_requested"]
|
||||
timed_out = time.monotonic() - started > run["config"]["timeout_seconds"]
|
||||
disk_full = shutil.disk_usage(self.settings.root).free < 10 * 1024**3 or collector.external_growth() > 128 * 1024**2
|
||||
if cancel or timed_out or overflow.is_set() or self.stopping or disk_full:
|
||||
self.inspect_owned(self.container)
|
||||
docker("kill", self.container)
|
||||
state = "cancelled" if cancel else ("timed_out" if timed_out else ("interrupted" if self.stopping else "failed"))
|
||||
message = "本次容器已停止;若已发送模型请求,远端推理是否结束需另行核对"
|
||||
if overflow.is_set():
|
||||
message = "日志达到 16 MiB 上限,本次测试已停止"
|
||||
if disk_full:
|
||||
message = "可用空间低于 10 GiB 或原目录输出新增超过 128 MiB,本次测试已停止;原文件保留"
|
||||
break
|
||||
time.sleep(0.5)
|
||||
process.wait(timeout=15)
|
||||
reader.join(timeout=5)
|
||||
if reader.is_alive():
|
||||
raise RuntimeError("Log reader did not stop")
|
||||
info = self.inspect_owned(self.container)
|
||||
exit_code = info["State"]["ExitCode"]
|
||||
if state == "failed" and message == "执行器发生错误":
|
||||
state = "succeeded" if exit_code == 0 else "failed"
|
||||
message = "脚本正常退出;模型质量请结合报告判断" if exit_code == 0 else f"脚本退出码 {exit_code},请查看日志"
|
||||
if info["State"].get("OOMKilled"):
|
||||
message = "测试超过 512 MiB 内存限制"
|
||||
if info["State"].get("Error") and info["State"].get("StartedAt", "").startswith("0001-"):
|
||||
exit_code, state, message = None, "failed", "容器未成功启动,脚本未执行,请查看日志"
|
||||
except Exception as exc:
|
||||
message = str(exc) if isinstance(exc, ValueError) else f"执行器错误:{type(exc).__name__};未自动重试"
|
||||
emit(("\n[控制台] " + message + "\n").encode())
|
||||
finally:
|
||||
if self.container:
|
||||
try:
|
||||
self.remove(self.container)
|
||||
self.container = None
|
||||
except Exception:
|
||||
state, message = "interrupted", "容器清理未确认,执行器停止接单,请管理员检查"
|
||||
self.stopping = True
|
||||
if process and process.poll() is None:
|
||||
process.terminate()
|
||||
process.wait(timeout=10)
|
||||
if collector:
|
||||
collector.finish_follow(emit)
|
||||
artifacts = collector.collect() if collector else []
|
||||
work = directory / "work"
|
||||
if work in workspace.mounts(self.settings):
|
||||
try:
|
||||
workspace.remove(self.settings, work)
|
||||
except Exception:
|
||||
state, message = "interrupted", "临时目录卸载未确认,执行器停止接单,请管理员检查"
|
||||
self.stopping = True
|
||||
if overflow.is_set() and state == "succeeded":
|
||||
state, message = "failed", "日志超过 16 MiB 上限,日志记录不完整,请查看已归档结果"
|
||||
notices = list(dict.fromkeys(collector.notices))[:32] if collector else []
|
||||
summary = {"run_id": run["id"], "script_name": run["script_name"], "state": state,
|
||||
"exit_code": exit_code, "sha256": run["sha256"], "version": run["version"],
|
||||
"synthetic": run["synthetic"], "message": message, "finished_at": now(),
|
||||
"archive_notices": notices, "log_truncated": overflow.is_set()}
|
||||
data = json.dumps(summary, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
summary_path = directory / "summary.json"
|
||||
summary_path.write_bytes(data)
|
||||
artifact_id = hashlib.sha256(str(summary_path).encode()).hexdigest()[:24]
|
||||
(directory / "artifacts").mkdir(exist_ok=True)
|
||||
(directory / "artifacts" / artifact_id).write_bytes(data)
|
||||
artifacts.append({"id": artifact_id, "name": "summary.json", "size": len(data), "kind": "json"})
|
||||
for notice in notices:
|
||||
emit(("\n[归档] " + notice + "\n").encode())
|
||||
emit(("\n[控制台] " + message + "\n").encode())
|
||||
log.close()
|
||||
if process:
|
||||
process.stdout.close()
|
||||
self.store.finish(run["id"], state, exit_code=exit_code, message=message, artifacts=artifacts)
|
||||
|
||||
def loop(self):
|
||||
self.recover()
|
||||
while not self.stopping:
|
||||
self.store.heartbeat()
|
||||
run = self.store.claim()
|
||||
if run:
|
||||
if run.get("execution", {}).get("mode") == "native":
|
||||
from .native_executor import NativeExecutor
|
||||
NativeExecutor(self).execute(run)
|
||||
else:
|
||||
self.execute(run)
|
||||
else:
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def main():
|
||||
config_path = Path(os.environ["XAS_WORKER_CONFIG"])
|
||||
config = json.loads(config_path.read_text())
|
||||
settings = Settings.from_env()
|
||||
settings.prepare()
|
||||
lock = (settings.db.parent / "worker.lock").open("a")
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
worker = Worker(settings, config)
|
||||
from . import native_rpc
|
||||
rpc = native_rpc.start(settings, worker.store)
|
||||
signal.signal(signal.SIGTERM, worker.stop)
|
||||
signal.signal(signal.SIGINT, worker.stop)
|
||||
try:worker.loop()
|
||||
finally:
|
||||
rpc.shutdown();rpc.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Bounded temporary work files on a private per-run tmpfs mount."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
from .catalog import safe_path
|
||||
|
||||
|
||||
def mounts(settings):
|
||||
result = []
|
||||
for line in Path("/proc/mounts").read_text().splitlines():
|
||||
device, target, kind, *_ = line.split()
|
||||
if not device.startswith("xas-run-") or kind != "tmpfs":
|
||||
continue
|
||||
identity = device.removeprefix("xas-run-")
|
||||
if not re.fullmatch(r"[a-f0-9]{32}", identity):
|
||||
continue
|
||||
expected = settings.runs / identity / "work"
|
||||
if target != str(expected):
|
||||
continue
|
||||
result.append(safe_path(expected, settings.runs))
|
||||
return result
|
||||
|
||||
|
||||
def create(settings, identity, uid):
|
||||
target = safe_path(settings.runs / identity / "work", settings.runs)
|
||||
if target in mounts(settings):
|
||||
raise ValueError("本次临时目录已经挂载,拒绝覆盖")
|
||||
subprocess.run(["mount", "-t", "tmpfs", "-o", f"size=134217728,nodev,nosuid,noexec,mode=0770,uid={uid},gid={uid}",
|
||||
"xas-run-" + identity, str(target)], check=True, capture_output=True, timeout=10)
|
||||
|
||||
|
||||
def remove(settings, target):
|
||||
if target not in mounts(settings):
|
||||
raise ValueError("临时挂载归属核验失败")
|
||||
subprocess.run(["umount", "--", str(target)], check=True, capture_output=True, timeout=10)
|
||||
Reference in New Issue
Block a user