feat: initialize XAS test console with native execution

This commit is contained in:
AdamWen95
2026-10-14 09:38:58 +08:00
commit 438844b01a
83 changed files with 9334 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""XAS dedicated script console."""
+297
View File
@@ -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
+144
View File
@@ -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
+193
View File
@@ -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)}
+115
View File
@@ -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())
+160
View File
@@ -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
+75
View File
@@ -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": "共享宿主前后快照差异不等于资源归属证明;不会自动删除这些容器或会话。"}
+122
View File
@@ -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
+176
View File
@@ -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())
+193
View File
@@ -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}
+95
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
certifi==2026.7.22
httpcore==1.0.9
httpx==0.28.1
+16
View File
@@ -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
+62
View File
@@ -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)
+61
View File
@@ -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();
+29
View File
@@ -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)}`;
}
+37
View File
@@ -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>
+187
View File
@@ -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};
}
+63
View File
@@ -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;
}
+184
View File
@@ -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));
}
+275
View File
@@ -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;
}
+176
View File
@@ -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}}
+125
View File
@@ -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;
}
+216
View File
@@ -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}
+72
View File
@@ -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"]))
+270
View File
@@ -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()
+35
View File
@@ -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)