diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..428fd37 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,501 @@ +name: ci +on: + push: + branches: [main] + pull_request: + branches: [main] +jobs: + test: + runs-on: ubuntu-latest + env: + GOPROXY: https://goproxy.cn,direct + GOTOOLCHAIN: local + GOWORK: "off" + GOPRIVATE: git-ssh.u1680832.nyat.app/weichen + steps: + - name: Checkout + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }} + run: &trusted_acquisition_1 | + set -euo pipefail + python3 - <<'PY_TRUSTED' + # Trusted workflow code only: never import or execute checkout scripts here. + # Token scopes, workflow provenance and runner isolation are platform NOT_RUN. + import json + import os + from pathlib import Path + import re + import shlex + import subprocess + import sys + import tempfile + from urllib.parse import urlsplit + + SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/") + parsed = urlsplit(SERVER) + if (parsed.scheme not in {"http", "https"} or not parsed.netloc or + parsed.username or parsed.password or parsed.query or parsed.fragment): + sys.exit("Acquisition requires the platform-injected http(s) server") + SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/" + GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/" + REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native", + "Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend", + "Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM", + "Development-Guidelines", "Agent-System-Documents"} + LEGACY = { + "modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend", + "modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI", + "modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core", + "deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native", + "deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent", + "deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System", + "deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend", + "pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend", + "pins/agent-system-ui": "Agent-System-UI", + } + GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"} + GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS} + GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in + ("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")} + root_token = os.environ.pop("TOKEN", "") + component_token = os.environ.pop("SUBMODULE_TOKEN", "") + # Go environment files and inherited Git tracing/config must not change acquisition. + clean = {k: v for k, v in os.environ.items() + if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and + not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))} + clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null", + GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly", + GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/")) + if os.environ.get("npm_config_cache"): + clean["npm_config_cache"] = os.environ["npm_config_cache"] + config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "", + "credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false", + "http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"} + root = Path.cwd() + mirrors = root / ".ci-mirrors" + + def process_env(values): + env = clean | {"GIT_CONFIG_COUNT": str(len(values))} + for index, (key, value) in enumerate(values.items()): + env[f"GIT_CONFIG_KEY_{index}"] = key + env[f"GIT_CONFIG_VALUE_{index}"] = value + return env + + def git(directory, *args, token="", repo=""): + values = config.copy() + if token: + if repo not in REPOS: + raise ValueError("Credential destination is outside the reviewed repository allowlist") + values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token + result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values), + check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return result.stdout + + def fixed_sha(value): + if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value): + raise ValueError("Source acquisition requires a fixed commit SHA") + return value.lower() + + def mirror(repo, sha, token): + sha = fixed_sha(sha) + destination = mirrors / (repo + ".git") + if destination.is_symlink() or mirrors.is_symlink(): + raise ValueError("Mirror directory cannot be a symlink") + mirrors.mkdir(exist_ok=True) + if not destination.exists(): + destination.mkdir() + git(destination, "init", "--bare", "-q") + url = f"{SERVER}/weichen/{repo}.git" + try: + git(destination, "cat-file", "-e", sha + "^{commit}") + except subprocess.CalledProcessError: + git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo) + git(destination, "update-ref", "refs/heads/main", sha) + git(destination, "symbolic-ref", "HEAD", "refs/heads/main") + git(destination, "update-ref", "refs/ci/" + sha, sha) + return destination + + def mirror_env(): + values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""} + # Distinct keys use repeated .insteadOf values, represented as a sequence below. + pairs = list(values.items()) + for repo in sorted(REPOS): + destination = mirrors / (repo + ".git") + if not destination.exists(): + continue + key = "url." + destination.as_uri() + ".insteadOf" + pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git", + SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo)) + env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))} + for index, (key, value) in enumerate(pairs): + env[f"GIT_CONFIG_KEY_{index}"] = key + env[f"GIT_CONFIG_VALUE_{index}"] = value + exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"} + (root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items())) + return env + + def materialize_legacy(directory, depth=0): + if depth > 16: + raise ValueError("Historical submodule depth exceeds the checkout limit") + links = {} + for entry in git(directory, "ls-tree", "-rz", "HEAD").split(b"\0"): + if entry: + metadata, path = entry.split(b"\t", 1) + mode, kind, commit = metadata.decode().split() + if mode == "160000": + links[path.decode()] = commit + if not links: + return + raw = git(directory, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list") + entries = {} + for item in raw.split(b"\0"): + if not item: + continue + key, value = item.decode().split("\n", 1) + match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key) + if not match: + raise ValueError("Unsupported candidate .gitmodules setting") + name, field = match.groups() + if field in entries.setdefault(name, {}): + raise ValueError("Duplicate candidate .gitmodules setting") + entries[name][field] = value + if set(entries) != set(links): + raise ValueError("Submodule names must match fixed gitlink paths") + for name, fields in entries.items(): + repo = LEGACY.get(name) + if not repo or fields.get("path") != name or fields.get("url") not in { + SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"}: + raise ValueError("Historical submodule source is outside the reviewed allowlist") + for name in entries: + repo = LEGACY[name] + sha = links[name] + url = f"{SERVER}/weichen/{repo}.git" + git(directory, "config", f"submodule.{name}.url", url) + # Trusted explicit checkout prevents candidate update commands from running. + git(directory, "submodule", "update", "--init", "--checkout", "--", name, token=component_token, repo=repo) + child = directory / name + if git(child, "rev-parse", "HEAD").decode().strip() != sha: + raise ValueError("Historical submodule does not match its fixed SHA") + mirror(repo, sha, component_token) + materialize_legacy(child, depth + 1) + + def materialize_manifest(directory): + manifest = directory / "components.json" + lock = directory / "components.lock.json" + if not manifest.exists() and not lock.exists(): + materialize_legacy(directory) + return + data = json.loads(manifest.read_text()) + locked = json.loads(lock.read_text()) + if data.get("schema") != 1 or locked.get("schema") != 1: + raise ValueError("Unsupported source manifest schema") + entries = {} + for item in data["components"]: + repo = item["id"] + path = ".workspace/" + repo + if (repo not in REPOS or repo in entries or item["path"] != path or + item["repo"] not in {SSH + repo + ".git", f"{SERVER}/weichen/{repo}.git"} or + item.get("role") not in {"go-library", "npm-library", "application", "guidelines", "documentation"}): + raise ValueError("Candidate source manifest is outside the reviewed allowlist") + for module in item.get("modules", []): + if module.get("path") not in GO_MODULES or not module["path"].startswith(GO_PREFIX + repo + ".git"): + raise ValueError("Candidate module mapping is outside the reviewed allowlist") + entries[repo] = item + selected = locked["components"] + if set(selected) - set(entries): + raise ValueError("Source lock refers to an unregistered repository") + for repo, item in entries.items(): + if item["role"] in {"application", "guidelines", "documentation"} and repo not in selected: + raise ValueError("Application and documentation source needs a fixed lock") + for repo, pin in selected.items(): + fixed_sha(pin["commit"]) + destination = directory / entries[repo]["path"] + if destination.exists() or destination.is_symlink() or destination.parent.is_symlink(): + raise ValueError("Source materialization requires a fresh destination") + # Validate the complete lock before any authenticated component fetch. + for repo, pin in selected.items(): + sha = fixed_sha(pin["commit"]) + destination = directory / entries[repo]["path"] + destination.mkdir(parents=True) + url = f"{SERVER}/weichen/{repo}.git" + git(destination, "init", "-q") + git(destination, "remote", "add", "origin", url) + git(destination, "fetch", "--depth=1", "origin", sha, token=component_token, repo=repo) + git(destination, "checkout", "-q", "--detach", "FETCH_HEAD") + if git(destination, "rev-parse", "HEAD").decode().strip() != sha: + raise ValueError("Materialized source differs from the fixed lock") + # Persist the registered identity; process config chooses the transport. + git(destination, "remote", "set-url", "origin", entries[repo]["repo"]) + mirror(repo, sha, component_token) + materialize_legacy(destination) + + try: + if os.environ.get("GITHUB_REPOSITORY") != "weichen/Agent-System-Frontend": + raise ValueError("Checkout repository is outside the reviewed allowlist") + sha = fixed_sha(os.environ.get("GITHUB_SHA", "")) + if not root_token: + raise ValueError("Checkout requires TOKEN") + if (root / ".git").exists(): + raise ValueError("Checkout requires a fresh workspace") + git(root, "init", "-q") + git(root, "remote", "add", "origin", SERVER + "/weichen/Agent-System-Frontend.git") + git(root, "fetch", "--depth=1", "origin", sha, token=root_token, repo="Agent-System-Frontend") + git(root, "checkout", "-q", "--detach", "FETCH_HEAD") + if git(root, "rev-parse", "HEAD").decode().strip() != sha: + raise ValueError("Checked-out commit differs from the platform commit") + if (root / ".ci-mirrors").exists() or (root / ".ci-mirrors.env").exists(): + raise ValueError("Candidate cannot provide trusted prefetch state") + materialize_manifest(root) + mirror_env() + except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error: + # Do not print child stderr or environment: a failed credential fetch stays redacted. + sys.exit("Checkout refused: " + str(error)) + print("Fixed source checkout complete; credentials were not persisted") + PY_TRUSTED + unset TOKEN SUBMODULE_TOKEN + - name: Install Node 22 + run: | + set -euo pipefail + curl -fsSL -o "$RUNNER_TEMP/node.tar.xz" https://npmmirror.com/mirrors/node/v22.23.3/node-v22.23.3-linux-x64.tar.xz + tar -C /usr/local --strip-components=1 -xJf "$RUNNER_TEMP/node.tar.xz" + - name: Prefetch npm packages without lifecycle scripts + env: + SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }} + run: &trusted_acquisition_2 | + set -euo pipefail + python3 - <<'PY_TRUSTED' + # Trusted workflow code only: never import or execute checkout scripts here. + # Token scopes, workflow provenance and runner isolation are platform NOT_RUN. + import json + import os + from pathlib import Path + import re + import shlex + import subprocess + import sys + import tempfile + from urllib.parse import urlsplit + + SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/") + parsed = urlsplit(SERVER) + if (parsed.scheme not in {"http", "https"} or not parsed.netloc or + parsed.username or parsed.password or parsed.query or parsed.fragment): + sys.exit("Acquisition requires the platform-injected http(s) server") + SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/" + GO_PREFIX = "git-ssh.u1680832.nyat.app/weichen/" + REPOS = {"Agent-System-Dev", "Logging-System", "Error-Code-System", "Agent-System-Core", "Agent-Native", + "Agent-Intent", "Agent-Command", "Agent-Squad", "Agent-System-Backend", + "Agent-System-Frontend", "Agent-System-UI", "Agent-System-RDM", + "Development-Guidelines", "Agent-System-Documents"} + LEGACY = { + "modules/core": "Agent-System-Core", "modules/backend": "Agent-System-Backend", + "modules/frontend": "Agent-System-Frontend", "modules/ui": "Agent-System-UI", + "modules/documents": "Agent-System-Documents", "deps/agent-system-core": "Agent-System-Core", + "deps/development-guidelines": "Development-Guidelines", "deps/agent-native": "Agent-Native", + "deps/agent-squad": "Agent-Squad", "deps/agent-intent": "Agent-Intent", + "deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System", + "deps/logging-system": "Logging-System", "pins/agent-system-backend": "Agent-System-Backend", + "pins/agent-system-core": "Agent-System-Core", "pins/agent-system-frontend": "Agent-System-Frontend", + "pins/agent-system-ui": "Agent-System-UI", + } + GO_REPOS = REPOS - {"Agent-System-Dev", "Agent-System-UI", "Agent-System-Frontend", "Development-Guidelines", "Agent-System-Documents"} + GO_MODULES = {GO_PREFIX + repo + ".git" for repo in GO_REPOS} + GO_MODULES |= {GO_PREFIX + "Agent-Squad.git/" + suffix for suffix in + ("adapters/native", "adapters/intent", "adapters/sqlite", "adapters/a2a", "cmd/agent-squad")} + root_token = os.environ.pop("TOKEN", "") + component_token = os.environ.pop("SUBMODULE_TOKEN", "") + # Go environment files and inherited Git tracing/config must not change acquisition. + clean = {k: v for k, v in os.environ.items() + if not k.startswith(("GIT_", "NPM_CONFIG_", "npm_config_")) and + not any(word in k.upper() for word in ("TOKEN", "PASSWORD", "SECRET"))} + clean.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null", GIT_CONFIG_GLOBAL="/dev/null", + GIT_TERMINAL_PROMPT="0", GOENV="off", GOTOOLCHAIN="local", GOWORK="off", GOFLAGS="-mod=readonly", + GOPRIVATE=GO_PREFIX.rstrip("/"), GONOPROXY=GO_PREFIX.rstrip("/"), GONOSUMDB=GO_PREFIX.rstrip("/")) + if os.environ.get("npm_config_cache"): + clean["npm_config_cache"] = os.environ["npm_config_cache"] + config = {"core.hooksPath": "/dev/null", "core.fsmonitor": "false", "init.templateDir": "", + "credential.helper": "", "fetch.recurseSubmodules": "false", "submodule.recurse": "false", + "http.followRedirects": "false", "protocol.allow": "never", "protocol." + parsed.scheme + ".allow": "always"} + root = Path.cwd() + mirrors = root / ".ci-mirrors" + + def process_env(values): + env = clean | {"GIT_CONFIG_COUNT": str(len(values))} + for index, (key, value) in enumerate(values.items()): + env[f"GIT_CONFIG_KEY_{index}"] = key + env[f"GIT_CONFIG_VALUE_{index}"] = value + return env + + def git(directory, *args, token="", repo=""): + values = config.copy() + if token: + if repo not in REPOS: + raise ValueError("Credential destination is outside the reviewed repository allowlist") + values[f"http.{SERVER}/weichen/{repo}.git.extraheader"] = "AUTHORIZATION: token " + token + result = subprocess.run(["git", "-C", str(directory), *args], env=process_env(values), + check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return result.stdout + + def fixed_sha(value): + if not isinstance(value, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", value): + raise ValueError("Source acquisition requires a fixed commit SHA") + return value.lower() + + def mirror(repo, sha, token): + sha = fixed_sha(sha) + destination = mirrors / (repo + ".git") + if destination.is_symlink() or mirrors.is_symlink(): + raise ValueError("Mirror directory cannot be a symlink") + mirrors.mkdir(exist_ok=True) + if not destination.exists(): + destination.mkdir() + git(destination, "init", "--bare", "-q") + url = f"{SERVER}/weichen/{repo}.git" + try: + git(destination, "cat-file", "-e", sha + "^{commit}") + except subprocess.CalledProcessError: + git(destination, "fetch", "--depth=200", url, sha, token=token, repo=repo) + git(destination, "update-ref", "refs/heads/main", sha) + git(destination, "symbolic-ref", "HEAD", "refs/heads/main") + git(destination, "update-ref", "refs/ci/" + sha, sha) + return destination + + def mirror_env(): + values = {"protocol.file.allow": "always", "core.hooksPath": "/dev/null", "credential.helper": ""} + # Distinct keys use repeated .insteadOf values, represented as a sequence below. + pairs = list(values.items()) + for repo in sorted(REPOS): + destination = mirrors / (repo + ".git") + if not destination.exists(): + continue + key = "url." + destination.as_uri() + ".insteadOf" + pairs.extend((key, url) for url in (SSH + repo + ".git", "https://" + GO_PREFIX + repo + ".git", + SERVER + "/weichen/" + repo + ".git", "https://" + GO_PREFIX + repo)) + env = clean | {"CI_DEPENDENCY_MIRRORS": str(mirrors), "GIT_CONFIG_COUNT": str(len(pairs))} + for index, (key, value) in enumerate(pairs): + env[f"GIT_CONFIG_KEY_{index}"] = key + env[f"GIT_CONFIG_VALUE_{index}"] = value + exports = {k: v for k, v in env.items() if k.startswith("GIT_CONFIG_") or k == "CI_DEPENDENCY_MIRRORS"} + (root / ".ci-mirrors.env").write_text("".join("export " + k + "=" + shlex.quote(v) + "\n" for k, v in exports.items())) + return env + + def source_roots(): + bases = [root] + composition = root / ".ci-composition-dev" + if composition.is_dir() and not composition.is_symlink(): + bases.append(composition) + paths = [] + for base in bases: + paths += [base, base / "rdm"] + [base / path for path in LEGACY] + [base / ".workspace" / repo for repo in sorted(REPOS)] + return [path for path in paths if path.is_dir() and not path.is_symlink()] + def git_package(value): + if not isinstance(value, str): + return None + if value.startswith(("file:", "link:", "workspace:")): + raise ValueError("Formal npm acquisition cannot consume a local package override") + if value.startswith(("http://", "https://")): + registry = urlsplit(value) + if registry.scheme != "https" or registry.hostname != "registry.npmjs.org" or registry.username or registry.password: + raise ValueError("npm tarball source is outside the reviewed public registry") + elif "/" in value and not value.startswith(("git+", "npm:")): + raise ValueError("npm repository shorthand is outside the fixed-SHA package mapping") + if value.startswith("git+") or value.startswith(("git://", "ssh://", "github:", "gitlab:", "bitbucket:")): + for repo in ("Agent-System-Core", "Agent-System-UI"): + prefix = "git+" + SSH + repo + ".git#" + if value.startswith(prefix): + return repo, fixed_sha(value[len(prefix):]) + raise ValueError("npm Git source is outside the reviewed fixed-SHA package mapping") + return None + + def registry_package(location, entry): + resolved = entry.get("resolved", "") + if not isinstance(resolved, str) or not resolved.startswith(("https://", "http://")): + return None + # A metadata spec is name@exact-version, never a candidate URL. + source = urlsplit(resolved) + if (source.scheme != "https" or source.netloc != "registry.npmjs.org" or + source.username or source.password or source.query or source.fragment): + raise ValueError("npm tarball source is outside the reviewed public registry") + relative = Path(location) + name = entry.get("name") or location.rsplit("node_modules/", 1)[-1] + version = entry.get("version", "") + if (not location.startswith("node_modules/") or relative.is_absolute() or ".." in relative.parts or + not isinstance(name, str) or not re.fullmatch(r"(?:@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*", name) or + not isinstance(version, str) or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?", version)): + raise ValueError("Invalid locked registry package name or version") + return name + "@" + version + + try: + targets = [] + selected = set() + for directory in source_roots(): + if not (directory / "package.json").is_file(): + continue + if (directory / ".npmrc").exists(): + raise ValueError("Candidate .npmrc is not allowed during private dependency acquisition") + package = json.loads((directory / "package.json").read_text()) + lock_bytes = (directory / "package-lock.json").read_bytes() + lock = json.loads(lock_bytes) + if lock.get("lockfileVersion") not in {2, 3}: + raise ValueError("npm acquisition requires a package lock") + for group in ("dependencies", "devDependencies", "optionalDependencies"): + for value in package.get(group, {}).values(): + found = git_package(value) + if found: + selected.add(found) + registry_packages = [] + for location, entry in lock.get("packages", {}).items(): + spec = registry_package(location, entry) + if spec: + registry_packages.append((location, spec)) + found = git_package(entry.get("resolved")) + if found: + selected.add(found) + for group in ("dependencies", "devDependencies", "optionalDependencies"): + for value in entry.get(group, {}).values(): + found = git_package(value) + if found: + selected.add(found) + targets.append((directory, registry_packages, lock_bytes)) + # Fetch only registered fixed Git package commits. npm never receives token config. + for repo, sha in sorted(selected): + repository = mirror(repo, sha, component_token) + package = json.loads(git(repository, "show", sha + ":package.json")) + forbidden = {"prepare", "prepack", "prepublish", "prepublishOnly", "preinstall", "install", "postinstall"} + if forbidden & set(package.get("scripts", {})): + raise ValueError("Git package provider must commit outputs without acquisition lifecycle scripts") + if not git(repository, "ls-tree", sha, "package/").strip(): + raise ValueError("Git package provider is missing committed package outputs") + no_auth = mirror_env() + empty_config = tempfile.NamedTemporaryFile(prefix="ci-npm-globalrc-") + for directory, registry_packages, lock_bytes in targets: + result = subprocess.run(["npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund", + "--userconfig=/dev/null", "--globalconfig=" + empty_config.name], + cwd=directory, env=no_auth, capture_output=True, text=True) + if result.returncode: + raise ValueError("npm dependency acquisition failed: " + result.stderr.strip()) + # npm ci caches tarballs; packed consumers also need resolver + # metadata. Only installed locked registry entries are fetched. + specs = sorted({spec for location, spec in registry_packages if (directory / location).is_dir()}) + if specs: + with tempfile.TemporaryDirectory(prefix="ci-npm-metadata-") as metadata_root: + result = subprocess.run(["npm", "cache", "add", *specs, "--ignore-scripts", + "--registry=https://registry.npmjs.org", "--userconfig=/dev/null", + "--globalconfig=" + empty_config.name], cwd=metadata_root, + env=no_auth, capture_output=True, text=True) + if result.returncode: + raise ValueError("npm metadata acquisition failed: " + result.stderr.strip()) + if (directory / "package-lock.json").read_bytes() != lock_bytes: + raise ValueError("npm acquisition changed the committed package lock") + except (ValueError, OSError, subprocess.CalledProcessError) as error: + sys.exit("npm prefetch refused: " + str(error)) + print("npm packages cached without credentials or lifecycle execution") + PY_TRUSTED + unset TOKEN SUBMODULE_TOKEN + - name: Validate package and contracts without credentials + run: | + set -euo pipefail + source .ci-mirrors.env + npm test + npm run build diff --git a/AGENTS.md b/AGENTS.md index 3749b09..d2b9a51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Agent 工作说明(Agent-System-Frontend) -本仓开发遵循 Development-Guidelines(Dev 仓 `deps/development-guidelines`,gitlink 固定版本): +本仓开发遵循 Development-Guidelines(由 Dev 固定规范来源;新布局见 Dev components.lock.json,规则目录 .workspace/Development-Guidelines;历史布局仍读原 gitlink): 核心规则见其 `core/`,按任务触发阅读 `standards/` 专题;日常交付走 `workflows/delivery.md`。 ## 任务启动门禁 @@ -15,7 +15,7 @@ ## 仓内规则 - 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。 -- 跨仓 gitlink 变更须经组合验证后在 Dev 仓推进;受保护分支经 PR 合入。 +- 跨仓语言依赖/应用源码锁变更须经固定组合验证后在消费方推进;源码提交到所属仓;受保护分支经 PR 合入。 - 验证命令见 PROJECT.md「验证」节;完成声明须附实际命令输出证据。 - 异步状态只有一个所有者。事件按 seq 处理,过期事件丢弃。 diff --git a/PROJECT.md b/PROJECT.md index 158e15d..4108951 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -2,12 +2,14 @@ - 用途:见 README。定位与边界见设计文档(Agent-System-Documents)。 - 技术栈:React 19 + Vite + TypeScript(Node 仅构建期) -- 构建/验证:见仓内 Makefile 或 Dev 仓顶层 Makefile(待 P1 起补充并保持本节同步)。 -- 状态:P0 骨架;当前阶段见 Dev 仓任务记录。 +- 构建/验证:见下节及 Dev 仓顶层 Makefile;依赖获取与无凭据执行分阶段。 +- 消费入口:UI/Core 契约由固定 Git 提交的 `@agent-system/ui` / `@agent-system/contracts` 包与 npm lock 定位。`@ui` 的运行时/CSS 别名指向 UI 包出口,类型由 `src/ui-alias.d.ts` 重导出该包;无相邻源码路径。`src/contracts.ts` 仅重导出 Core 包类型。 ## 验证(当前状态) -- `npx tsc -b` — 类型检查;`npm run build` — 产物 dist/(Dev Makefile 拷贝至 backend web/dist 内嵌)。 +- `npm ci --ignore-scripts` — 依赖获取;移除获取凭据后执行下列验证及构建。 +- `npx tsc -b` — 类型检查;`npm run build` — 产物 dist/,由 Dev 在隔离 Backend 候选中装配后 embed。 - `npm test` — vitest:契约形状测试(Core 黄金样本驱动的判别联合/事件信封/HITL impact/工具三清单断言)。 +- `npm run test:packages` — 将应用及 pack 依赖放入临时目录,无 UI/Core 相邻源码地构建真实 bundle,并检查 React/ReactDOM 各一份。联调可传 `CONTRACTS_TARBALL` / `UI_TARBALL` 的绝对路径,正式依赖文件不保留本地覆盖。 - 开发:`npm run dev`(Vite 代理 /api → 127.0.0.1:8787)。 - 依赖刻意最小:react + marked + dompurify(模型输出与文档预览先清洗再写入页面);无 UI 框架(设计令牌手写,见 styles.css)。 diff --git a/README.md b/README.md index 51884a6..a2b2aaa 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,11 @@ 对话系统前端:React 19 + Vite + TypeScript 三栏工作台(项目会话 / 流式对话 / 计划与工件面板),含 HITL 卡片、sub-agent 追踪弹窗与 schema 驱动渲染;渲染协议由 agent-system-core 定义。 契约与架构见 Agent-System-Documents 设计方案与架构文档;开发规范见 development-guidelines(Dev 仓 `development/guidelines`)。本仓为 Agent-System 多仓组合的组成部分。 + +UI 与契约分别消费 `@agent-system/ui`、`@agent-system/contracts`,由 `package.json` +的固定 Git 提交及 `package-lock.json` 锁定。现有 `@ui` 调用和 CSS 引入通过包出口适配; +无需相邻 UI/Core 源码目录。 + +获取依赖用 `npm ci --ignore-scripts`。移除获取凭据后运行 `npm test`、`npm run build` +与 `npm run test:packages`;最后一项在临时目录通过 pack 制品构建应用并检查 React 单副本。 +Frontend 的 `dist/` 是应用制品,Dev 在隔离 Backend 构建目录装配它后编译真实二进制。 diff --git a/package-lock.json b/package-lock.json index 7033c71..47f14e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "agent-system-frontend", "version": "0.1.0", "dependencies": { + "@agent-system/contracts": "git+ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git#f32d4317846f8a4b58a28fa5030002b0472761b9", + "@agent-system/ui": "git+ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git#63d289e6bc15b701356cfe2d5b8740210fac0dcc", "docx-preview": "^0.4.1", "dompurify": "^3.4.16", "jszip": "^3.10.2", @@ -30,6 +32,23 @@ "vitest": "^4.1.11" } }, + "node_modules/@agent-system/contracts": { + "version": "0.1.0", + "resolved": "git+ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git#f32d4317846f8a4b58a28fa5030002b0472761b9" + }, + "node_modules/@agent-system/ui": { + "version": "0.1.0", + "resolved": "git+ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git#63d289e6bc15b701356cfe2d5b8740210fac0dcc", + "dependencies": { + "@agent-system/contracts": "git+ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git#f32d4317846f8a4b58a28fa5030002b0472761b9", + "dompurify": "^3.4.16", + "marked": "^15.0.7" + }, + "peerDependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", diff --git a/package.json b/package.json index 4351ded..5bb4be2 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,14 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "test": "vitest run", + "test": "vitest run && npm run test:packages", + "test:packages": "node --test scripts/test-packages.mjs", "preview": "vite preview", "e2e": "playwright test" }, "dependencies": { + "@agent-system/contracts": "git+ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Core.git#f32d4317846f8a4b58a28fa5030002b0472761b9", + "@agent-system/ui": "git+ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-UI.git#63d289e6bc15b701356cfe2d5b8740210fac0dcc", "docx-preview": "^0.4.1", "dompurify": "^3.4.16", "jszip": "^3.10.2", diff --git a/scripts/test-packages.mjs b/scripts/test-packages.mjs new file mode 100644 index 0000000..e594ea4 --- /dev/null +++ b/scripts/test-packages.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { test } from 'node:test' + +const root = dirname(dirname(fileURLToPath(import.meta.url))) + +test('the application builds from packed dependencies without UI or Core source siblings', () => { + const consumer = mkdtempSync(join(tmpdir(), 'agent-frontend-packages-')) + try { + // Only the application is copied. Source checkouts and node_modules never enter the fixture. + for (const path of ['src', 'test', 'index.html', 'vite.config.ts', 'tsconfig.json', 'package.json', 'package-lock.json']) { + cpSync(join(root, path), join(consumer, path), { recursive: true }) + } + const pack = (name, envName) => { + if (process.env[envName]) return resolve(process.env[envName]) + const [artifact] = JSON.parse(execFileSync('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', consumer], { cwd: join(root, 'node_modules/@agent-system', name), encoding: 'utf8' })) + return join(consumer, artifact.filename) + } + const manifest = JSON.parse(readFileSync(join(consumer, 'package.json'), 'utf8')) + const contracts = `file:${pack('contracts', 'CONTRACTS_TARBALL')}` + manifest.dependencies['@agent-system/contracts'] = contracts + manifest.dependencies['@agent-system/ui'] = `file:${pack('ui', 'UI_TARBALL')}` + manifest.overrides = { ...manifest.overrides, '@agent-system/contracts': contracts } + writeFileSync(join(consumer, 'package.json'), JSON.stringify(manifest)) + // Overrides exist only in this disposable fixture. Formal manifests retain fixed Git commits. + execFileSync('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--offline'], { cwd: consumer, stdio: 'inherit' }) + execFileSync('npm', ['run', 'build'], { cwd: consumer, stdio: 'inherit' }) + assert.ok(existsSync(join(consumer, 'dist/index.html'))) + const copies = execFileSync('npm', ['ls', 'react', 'react-dom', '--parseable', '--all'], { cwd: consumer, encoding: 'utf8' }).trim().split('\n').filter(path => /node_modules\/(react|react-dom)$/.test(path)) + assert.equal(copies.length, 2, 'the bundle must share the host React/ReactDOM peers') + } finally { + rmSync(consumer, { recursive: true, force: true }) + } +}) diff --git a/src/contracts.ts b/src/contracts.ts index 70348ef..4bf83c0 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -1,402 +1,2 @@ -// 从 Agent-System-Core ts/types.ts 同步(make contracts 生成;Core 为权威)。 -// Agent-System-Core 契约的 TypeScript 镜像。 -// 线格式契约以 Go 类型(camelCase JSON 标签)与 testdata/golden/*.json 黄金样本为准; -// 本文件手工镜像同一契约,前端按未知 type/字段静默忽略(前向兼容原则)。 - -export type ID = string; - -// ---- 领域模型 ---- - -export interface Project { - id: ID; owner?: ID; name: string; createdAt: string; updatedAt: string; - settings?: Record; -} - -export interface Conversation { - id: ID; projectId: ID; owner?: ID; title: string; profileId?: ID; - pinned?: boolean; archived?: boolean; - allowCrossRead?: boolean; crossPrivate?: boolean; - permissionMode?: 'default' | 'plan' | 'auto_edit' | ''; - sessionNote?: string; - createdAt: string; updatedAt: string; -} - -export type Role = 'user' | 'assistant' | 'system'; - -export type PartType = - | 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'artifact_ref' - | 'hitl' | 'plan' | 'error' | 'card' | 'check'; - -export interface Part { - type: PartType; - text?: string; - toolCall?: { id: string; name: string; arguments?: string; durationMs?: number }; - toolResult?: { callId: string; status: 'ok' | 'error'; summary?: string; truncated?: boolean }; - artifact?: { artifactId: ID; name: string; kind?: string }; - hitl?: { cardId: ID; question: string; resolved: boolean; answer?: string }; - plan?: PlanSnapshot; - card?: { kind: string; data: UINode[]; cardId?: ID }; // cardId 非空 = 收件箱卡片引用部件(submit_card) - error?: { code: string; message: string }; - check?: { passed: boolean; reason?: string }; // 终轮自动检查,过程区一行 - process?: boolean; // run 终态后折叠为摘要行 - durationMs?: number; // thinking 部件的思考时长(毫秒);工具耗时在 toolCall.durationMs -} - -export interface Message { - id: ID; conversationId: ID; runId?: ID; role: Role; - parts: Part[]; createdAt: string; - excluded?: boolean; - variantGroup?: string; - variantIndex?: number; -} - -export type RunKind = 'conversation' | 'subagent' | 'tool'; -export type RunStatus = - | 'pending' | 'running' | 'waiting_hitl' | 'completed' - | 'failed' | 'cancelled' | 'interrupted'; -export type RunPhase = 'thinking' | 'acting' | 'responding' | 'finalizing' | 'done'; - -export interface Usage { - promptTokens: number; completionTokens: number; totalTokens: number; - durationMs: number; toolCalls: number; - model?: string; - // N3 计费拆分(additive;旧记录缺省为 0): - // Last* 是最近一次请求的用量,Cache* 是缓存命中累计。 - lastPromptTokens?: number; - lastCompletionTokens?: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; -} - -export interface Run { - id: ID; parentRunId?: ID; conversationId: ID; projectId: ID; owner?: ID; - kind: RunKind; profileId?: ID; status: RunStatus; phase?: RunPhase; - agentName?: string; createdAt: string; endedAt?: string; - usage?: Usage; error?: { code: string; message: string }; -} - -export interface Artifact { - id: ID; projectId: ID; owner?: ID; name: string; kind: string; mime?: string; - size: number; sourceRun?: ID; createdAt: string; updatedAt: string; -} - -export interface PlanSnapshot { - planId: ID; title: string; - steps: { id: string; text: string; status: 'pending' | 'in_progress' | 'done' | 'skipped'; agentRun?: ID }[]; -} - -export interface TodoList { - conversationId: ID; - items: { id: string; text: string; status: 'pending' | 'in_progress' | 'done' }[]; -} - -// ---- 流式事件 ---- - -export type StreamEventType = - | 'message.delta' | 'thinking.delta' - | 'tool.call.delta' | 'tool.call' | 'tool.output' | 'tool.result' - | 'run.phase' | 'run.check' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update' - | 'agent.spawn' | 'agent.progress' | 'agent.result' - | 'artifact.created' | 'artifact.updated' - | 'run.completed' | 'run.failed' | 'error' - | 'card.created' | 'card.updated' | 'card.status' | 'card.resolved' - | 'surface.updated'; - -export interface StreamEvent

{ - seq: number; runId?: ID; conversationId: ID; ts: string; v: number; - type: StreamEventType; payload?: P; -} - -// payload 形状(按 type 判别;未知字段忽略) -export interface MessageDeltaPayload { index: number; text: string } -export interface ToolCallDeltaPayload { index: number; callId?: string; name?: string; delta: string } -export interface ToolOutputPayload { callId: string; stream: 'stdout' | 'stderr'; text: string } -export interface ToolResultPayload { callId: string; status: string; summary?: string; truncated?: boolean; durationMs?: number } -export interface AgentSpawnPayload { agentRun: ID; name: string; task: string } -export interface AgentProgressPayload { agentRun: ID; note?: string; phase?: RunPhase } -export interface AgentResultPayload { agentRun: ID; status: RunStatus; summary?: string } -export interface HITLResolvedPayload { cardId: ID; answer?: string; kind: 'choice' | 'free' | 'timeout' | 'cancelled' } -export interface RunCompletedPayload { - usage: Usage - context?: ContextObservation -} - -interface ContextSection { - name: string - estTokens: number -} - -export interface ContextObservation { - items: string[] - truncations?: string[] - estChars: number - sections?: ContextSection[] - prefixHash?: string -} -export interface RunCheckPayload { passed: boolean; reason?: string } -export interface RunFailedPayload { code: string; message: string } - -// ---- 收件箱卡片 ---- - -export type CardKind = - | 'progress' | 'confirm' | 'compare' | 'review-list' - | 'result' | 'error' | 'status'; - -export type CardStatus = - | 'active' | 'action_required' | 'done' - | 'resolved' | 'failed' | 'dismissed'; - -export interface ActivityCard { - id: ID; projectId: ID; conversationId?: ID; - sourceRunId?: ID; anchorMessageId?: ID; - kind: CardKind; title: string; status: CardStatus; tone?: string; - body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[]; - pinned?: boolean; createdAt: string; updatedAt: string; -} - -export interface CardPayload { card: ActivityCard } -export interface CardStatusPayload { cardId: ID; status: CardStatus; tone?: string; note?: string } -export interface CardResolvedPayload { cardId: ID; actionId?: string; answer?: string } - -// ---- HITL ---- - -export type HITLKind = 'choice' | 'input' | 'confirm' | 'form'; - -export interface HITLChoice { - id: string; label: string; description: string; impact: string; recommended?: boolean; -} - -export interface HITLCard { - id: ID; runId: ID; conversationId: ID; question: string; kind: HITLKind; - choices?: HITLChoice[]; allowFreeInput: boolean; freeInputHint?: string; - timeoutSec?: number | null; form?: UINode[]; createdAt: string; resolved: boolean; - anchorMessageId?: ID; blocked?: boolean; blockReason?: string; - detail?: string; -} - -export interface HITLResponse { - cardId: ID; choiceId?: string; freeText?: string; formData?: unknown; -} - -// ---- UI 词汇 ---- - -export type UINodeType = - | 'section' | 'list' | 'kv' | 'form' | 'markdown' | 'doc-ref' | 'actions' | 'chart-lite' - | 'avatar' | 'progress' | 'compare' | 'tree' | 'table' | 'steps' | 'tabs' | 'timeline'; - -export interface UINode { - type: UINodeType; - /** Surface 层节点级标注(Generative UI U2):整页唯一稳定 id 与事实来源标识,旧路径不填。 */ - id?: string; - source?: string[]; - title?: string; - items?: string[]; - kv?: [string, string][]; - fields?: UIField[]; - markdown?: string; - docId?: ID; - actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger' }[]; - chart?: { kind: 'bar' | 'line'; labels: string[]; series: number[] }; - children?: UINode[]; - - avatar?: { name?: string; sub?: string; tone?: string; stack?: { name?: string; sub?: string; tone?: string }[] }; - progress?: { label?: string; value: number; tone?: string }; - compare?: { - left: { title?: string; tone?: string; nodes?: UINode[] }; - right: { title?: string; tone?: string; nodes?: UINode[] }; - }; - tree?: { - nodes: { - id: string; label: string; tone?: string; badge?: string; count?: number; - selected?: boolean; children?: unknown[]; - }[]; - }; - table?: { - columns: { key: string; label: string }[]; - rows: { id?: string; cells: [string, string][]; actions?: UINode['actions'] }[]; - }; - steps?: { current: number; steps: { label: string; status?: 'pending' | 'active' | 'done' | 'failed'; tone?: string }[] }; - tabs?: { active?: string; tabs: { id: string; label: string; nodes?: UINode[] }[] }; - timeline?: { items: { time?: string; title: string; tone?: string; markdown?: string; ref?: string }[] }; -} - -export interface UIField { - key: string; label: string; - type: 'text' | 'textarea' | 'number' | 'boolean' | 'select'; - options?: string[]; required?: boolean; placeholder?: string; help?: string; default?: string; -} - -// ---- 提示词库(prompt-library)---- - -export interface PromptTrigger { - kind: 'intent' | 'command' | 'attachment' | 'manual'; - keywords?: string[]; command?: string; -} - -export interface PromptModule { - id: string; layer: 'base' | 'env' | 'task' | 'agent' | 'tool' | 'aux' | 'style'; - version: number; title: string; description?: string; priority?: number; - triggers?: PromptTrigger[]; requiresAttachments?: boolean; - body: string; outputContract?: string; - source?: 'builtin' | 'plugin' | 'user'; owner?: string; - basedOnVersion?: number; drifted?: boolean; disabled?: boolean; - /** 子代理 profile。只对 agent.sub.* 有意义;空字段表示沿用内置。 */ - tools?: string[]; model?: string; maxTurns?: number; -} - -export interface PromptBlock { moduleId: string; version: number; layer: string; body: string; } -export interface PromptHitRecord { moduleId: string; layer: string; reason: string; injected: boolean; } - -// ---- 画像 / 设置 ---- - -export interface AgentProfile { - id: string; owner?: string; name: string; description?: string; builtin?: boolean; source?: string; - systemPrompt: string; - /** 提示词库模块引用(有序;空 = 默认 base+env 全集)。 */ - promptModules?: string[]; - model: { chat?: string; lightweight?: string; embedding?: string; rerank?: string }; - tools: { allow: string[]; deny?: string[]; confirm?: string[] }; - context: { - maxHistoryMessages: number; summarizeBeyond?: boolean; - memoryBudgetPct?: number; knowledgeBudgetPct?: number; maxToolResultChars?: number; - }; - hitl: { askOnAmbiguity: boolean; timeoutSec?: number | null }; - budget: { maxTokens?: number | null; maxToolCalls?: number | null; maxTurns?: number | null }; -} - -export interface SettingDesc { - key: string; group: string; label: string; description?: string; - type: 'string' | 'number' | 'boolean' | 'enum' | 'secret'; - default?: unknown; options?: string[]; - effect: 'hot' | 'new-run' | 'restart'; - secret?: boolean; - /** P2 受管:system = 系统受管项,仅系统管理面可写(用户/项目层拒绝)。 */ - scope?: 'system'; -} - -// ---- 插件 ---- - -export interface PluginManifest { - id: string; name: string; version: string; apiVersion: string; - requires?: { network?: string[] }; - provides: { - tools?: string[]; workflows?: string[]; intents?: string[]; - prompts?: string[]; panels?: string[]; cards?: string[]; settings?: string[]; - commands?: string[]; workspaces?: string[]; - skills?: string[]; mcpServers?: string[]; knowledge?: string[]; - parsers?: string[]; agents?: string[]; channels?: string[]; pages?: string[]; - }; - scope?: { tenants?: string[]; projects?: string[] }; - backend?: { mode?: 'inproc' | 'sidecar'; command?: string; args?: string[]; schemaVersion?: number }; -} - -export interface PluginPanel { - id: string; title: string; icon?: string; body: UINode[]; refreshTool?: string; -} - -/** 模板占位符:{{input}}、{{slots.}}、{{steps..output}}。 */ -export interface WorkflowDef { - id: string; title: string; description?: string; intentId?: string; - /** 整条工作流的 critic 重试上限。 */ - maxRetries?: number; - steps: WorkflowStep[]; -} - -export interface StepCritic { - criteria: string; - /** 本步重试上限;缺省 1 次。 */ - maxRetries?: number; -} - -export interface WorkflowStep { - id: string; title: string; - kind: 'prompt' | 'tool' | 'hitl' | 'artifact' | 'card' | 'script'; - prompt?: string; - /** prompt 步骤的子代理 profile,缺省 general。 */ - agent?: string; - tool?: string; - args?: unknown; - hitl?: { question: string; kind: HITLKind; choices?: HITLChoice[]; allowFreeInput: boolean; freeInputHint?: string }; - artifact?: string; - card?: { kind: CardKind; title: string; tone?: string; body?: UINode[]; actions?: { id: string; label: string; style?: 'primary' | 'default' | 'danger'; command?: string }[] }; - /** script 步骤在插件目录内的相对路径。 */ - script?: string; - critic?: StepCritic; -} - -export interface PluginWorkspace { - id: string; title: string; icon?: string; shortcut?: string; - badgeTool?: string; body: UINode[]; refreshTool?: string; -} - -export interface PluginPageDecl { - id: string; title: string; module: string; -} - -// ---- 摄取 / 检索 ---- - -export interface ParsedDocument { - meta: { - filename: string; mime?: string; parser: string; confidence?: number; - crossCheck?: { with: string; diffBlocks: number; verdict: 'agree' | 'differ' }; - parsedAt: string; - }; - blocks: { - type: 'heading' | 'para' | 'list' | 'table' | 'code' | 'image-ref'; - level?: number; text?: string; items?: string[]; - rows?: string[][]; ref?: string; confidence?: number; - }[]; - warnings?: string[]; -} - -export interface RetrievalHit { - doc: { id: ID; owner?: ID; projectId?: ID; source: string; title: string; ref?: string; chunk: string; createdAt: string }; - score: number; kind: 'fulltext' | 'vector' | 'plugin'; -} - -export interface UploadInfo { - artifactId: ID; filename: string; mime?: string; size: number; - parsed?: ParsedDocument; -} - -// ---- Generative UI Surface(提案 U2/U4;与 Go surface.go 同步)---- - -export interface SurfaceCompat { - schemaVersion: number; - catalogVersion: string; - pluginVersion?: string; -} - -export interface SurfaceIdentity { - surfaceId: ID; - owner: string; - projectId?: ID; - conversationId?: ID; - sourceRunId?: ID; - pluginId: string; -} - -export interface SurfaceSpec { - title: string; - nodes: UINode[]; -} - -export interface SurfaceSnapshot { - identity: SurfaceIdentity; - compat: SurfaceCompat; - revision: number; - baseRevision?: number; - contextVersion?: string; - activationEpoch: number; - spec: SurfaceSpec; - semanticText?: string; -} - -export interface SurfaceMeta { - surfaceId: ID; - title: string; - pluginId: string; - revision: number; - conversationId?: ID; - updatedAt: string; -} +// Core is the sole contract source; this compatibility import never copies types. +export type * from "@agent-system/contracts" diff --git a/src/ui-alias.d.ts b/src/ui-alias.d.ts new file mode 100644 index 0000000..7c33792 --- /dev/null +++ b/src/ui-alias.d.ts @@ -0,0 +1,4 @@ +// Runtime/CSS resolution is the matching Vite alias. Types follow the public package export. +declare module '@ui' { + export * from '@agent-system/ui' +} diff --git a/test/package-consumption.test.ts b/test/package-consumption.test.ts new file mode 100644 index 0000000..044150d --- /dev/null +++ b/test/package-consumption.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { Collapsible, UIRenderer } from '@ui' +import { UIRenderer as PackageRenderer } from '@agent-system/ui' +import type { UINode } from '@agent-system/contracts' + +describe('versioned UI package consumption', () => { + it('existing @ui calls use the installed package and keep the canonical renderer behavior', () => { + const node: UINode = { type: 'kv', kv: [['Package consumer', 'available']] } + expect(UIRenderer).toBe(PackageRenderer) + const html = renderToStaticMarkup(createElement(Collapsible, { trigger: 'Details', defaultOpen: true }, createElement(UIRenderer, { nodes: [node] }))) + expect(html).toContain('Package consumer') + expect(html).toContain('Details') + expect(html).toContain('mk-col') + }) +}) diff --git a/tsconfig.json b/tsconfig.json index ffdd18d..356aa7a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,15 +12,7 @@ "skipLibCheck": true, "isolatedModules": true, "noEmit": true, - "types": ["vite/client", "node"], - "baseUrl": ".", - "paths": { - "@ui": ["../ui/src/index.ts"], - "@ui/*": ["../ui/src/*"], - "react": ["./node_modules/@types/react"], - "react-dom": ["./node_modules/@types/react-dom"], - "react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"] - } + "types": ["vite/client", "node"] }, "include": ["src", "test"] } diff --git a/vite.config.ts b/vite.config.ts index fb4b47d..8937d84 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,15 +1,12 @@ -import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -// UI 组件库源码组合(modules/ui,源码级别名;见 Agent-System-Documents 提案 D29)。 -const uiSrc = fileURLToPath(new URL('../ui/src', import.meta.url)) - export default defineConfig({ plugins: [react()], resolve: { - alias: [{ find: '@ui', replacement: uiSrc }], - // 源码组合下 ui 仓有自己的 node_modules;dedupe 强制最终 bundle 用 frontend 的单副本。 + // Keep existing call sites while resolving components and CSS through package exports. + alias: [{ find: /^@ui(?=\/|$)/, replacement: '@agent-system/ui' }], + // UI declares React/ReactDOM peers; the host owns their single runtime copy. dedupe: ['react', 'react-dom', 'marked', 'dompurify'], }, server: {