[WIP] Build Frontend from versioned UI and contract packages #84
@@ -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
|
||||
@@ -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 处理,过期事件丢弃。
|
||||
|
||||
|
||||
+5
-3
@@ -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)。
|
||||
|
||||
@@ -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 构建目录装配它后编译真实二进制。
|
||||
|
||||
Generated
+19
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
+2
-402
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<P = unknown> {
|
||||
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.<key>}}、{{steps.<id>.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"
|
||||
|
||||
Vendored
+4
@@ -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'
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
+1
-9
@@ -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"]
|
||||
}
|
||||
|
||||
+3
-6
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user