Projects as configuration entries (M1.4, D8)
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 5m7s
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 5m7s
- Config: `projects: {name: entry}` with repo, base_branch, workspace,
build_host, push credential, labels (defaults added to every issue),
secrets by ${VAR} reference with visibility (commands | harness), and
egress (recorded, not enforced). Every problem in every entry is
reported at once; an inline secret, a bad visibility, a repeated repo or
a non-slug name is an error.
- The old loop runs exactly one project (only_project) and refuses more.
- The new loop is per project: each its own clone, workspace (with its
commands-visible secrets, redacted on return), push credential and
milestones; intake polls every project's repository; items are scoped
to their project for every run, publish and PR watch. Issues are named
project#14 when there is more than one project, and a bare #14 that
matches two is asked about. Switching projects parks the one worked on
last (all others after a boot).
- A restatement that ends in a question is sent back to the driver, not
posted: the e2e driver kept asking "Is this the right interpretation?",
which a plain yes can't answer.
Tests: config entries and errors, two-project assembly, two-project loop
tests (intake, own workspaces and repos, #14 in both, do-next, label
defaults, parking across projects), the property walk over two projects
with a new own_repository_only property (an item using the first
project's repo fails 15 of 16 seeds), and the restatement check.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
1 parent
4ecff19933
commit
064d9bf554
17 files changed
+1139
-315
No files matched your search
@@ -17,7 +17,7 @@ silently ignored would look exactly like a knob that has no effect.
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field, fields
|
||||
from dataclasses import dataclass, field, fields, replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -30,8 +30,6 @@ DEFAULT_CONFIG_FILE = "nisse-dev.yaml"
|
||||
|
||||
REQUIRED_KEYS = (
|
||||
"state_db",
|
||||
"workspace",
|
||||
"project",
|
||||
"llm.base_url",
|
||||
"llm.token",
|
||||
"roles.executor",
|
||||
@@ -45,22 +43,31 @@ REQUIRED_KEYS = (
|
||||
"matrix.operators",
|
||||
"gitea.url",
|
||||
"gitea.token",
|
||||
"gitea.repo",
|
||||
"gitea.operators",
|
||||
"build_host.address",
|
||||
"build_host.key",
|
||||
)
|
||||
OPTIONAL_KEYS = (
|
||||
"llm.max_output_tokens",
|
||||
"llm.request_timeout_s",
|
||||
"gitea.base_branch",
|
||||
"gitea.push_key",
|
||||
"gitea.push_user",
|
||||
"gitea.push_token",
|
||||
"build_host.user",
|
||||
"build_host.port",
|
||||
"loop",
|
||||
)
|
||||
# A project entry (D8), under `projects: {name: {...}}`.
|
||||
PROJECT_REQUIRED = ("repo", "workspace", "build_host.address", "build_host.key")
|
||||
PROJECT_OPTIONAL = (
|
||||
"base_branch",
|
||||
"push_key",
|
||||
"push_user",
|
||||
"push_token",
|
||||
"build_host.user",
|
||||
"build_host.port",
|
||||
"labels",
|
||||
"secrets",
|
||||
"egress",
|
||||
)
|
||||
# Who may see a project secret (D7, D8): the model's commands get it as an
|
||||
# environment variable, redacted from what comes back; harness-only ones are
|
||||
# never in a command the model chose.
|
||||
VISIBILITY = ("commands", "harness")
|
||||
_PROJECT_NAME = re.compile(r"[a-z0-9][a-z0-9-]*")
|
||||
|
||||
_REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
@@ -225,6 +232,136 @@ def _read_file(path: Path, errors: list[str]) -> dict[str, Any]:
|
||||
return loaded
|
||||
|
||||
|
||||
def _parse_projects(
|
||||
raw: Any, env: Mapping[str, str], errors: list[str]
|
||||
) -> tuple["Project", ...]:
|
||||
"""`projects:`, a mapping of name to entry (D8). Every problem in every
|
||||
entry is reported, like the rest of the file."""
|
||||
if not isinstance(raw, dict) or not raw:
|
||||
errors.append("projects: must name at least one project")
|
||||
return ()
|
||||
projects: list[Project] = []
|
||||
repos: dict[str, str] = {}
|
||||
for name, entry in raw.items():
|
||||
where = f"projects.{name}"
|
||||
if not isinstance(name, str) or not _PROJECT_NAME.fullmatch(name):
|
||||
errors.append(f"{where}: a project name is lowercase letters, digits, -")
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
errors.append(f"{where}: must be a mapping")
|
||||
continue
|
||||
project = _parse_project(name, entry, env, errors)
|
||||
if project is None:
|
||||
continue
|
||||
if project.repo in repos:
|
||||
errors.append(
|
||||
f"{where}.repo: {project.repo} is also project {repos[project.repo]}'s"
|
||||
)
|
||||
repos[project.repo] = name
|
||||
projects.append(project)
|
||||
return tuple(projects)
|
||||
|
||||
|
||||
def _parse_project(
|
||||
name: str, entry: Mapping[str, Any], env: Mapping[str, str], errors: list[str]
|
||||
) -> "Project | None":
|
||||
where = f"projects.{name}"
|
||||
# labels, secrets and egress are structured; the rest flattens.
|
||||
flat = _flatten({k: v for k, v in entry.items() if k not in ("secrets",)})
|
||||
known = set(PROJECT_REQUIRED) | set(PROJECT_OPTIONAL)
|
||||
for key in sorted(set(flat) - known):
|
||||
errors.append(f"{where}.{key}: unknown setting")
|
||||
values = {
|
||||
key: _resolve(value, f"{where}.{key}", env, errors)
|
||||
for key, value in flat.items()
|
||||
if key in known
|
||||
}
|
||||
missing = [
|
||||
key
|
||||
for key in PROJECT_REQUIRED
|
||||
if values.get(key) is None
|
||||
or (isinstance(values[key], str) and not values[key].strip())
|
||||
]
|
||||
if missing:
|
||||
errors.append(f"{where}: unset or empty — " + ", ".join(missing))
|
||||
return None
|
||||
port = values.get("build_host.port", 22)
|
||||
try:
|
||||
port = int(port)
|
||||
if not 0 < port < 65536:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"{where}.build_host.port: must be a TCP port number")
|
||||
push_user = values.get("push_user")
|
||||
push_token = values.get("push_token")
|
||||
push_key = values.get("push_key")
|
||||
if bool(push_user) != bool(push_token):
|
||||
errors.append(f"{where}: push_user and push_token: set both or neither")
|
||||
if push_token and push_key:
|
||||
errors.append(f"{where}: set a push token or a push key, not both")
|
||||
labels = _strings(values.get("labels", []), f"{where}.labels", errors)
|
||||
egress = _strings(values.get("egress", []), f"{where}.egress", errors)
|
||||
secrets = _parse_secrets(entry.get("secrets"), where, env, errors)
|
||||
return Project(
|
||||
name=name,
|
||||
repo=str(values["repo"]),
|
||||
workspace=Path(values["workspace"]).expanduser(),
|
||||
build_host=BuildHost(
|
||||
address=str(values["build_host.address"]),
|
||||
key=Path(values["build_host.key"]).expanduser(),
|
||||
user=str(values.get("build_host.user") or "root"),
|
||||
port=port if isinstance(port, int) else 22,
|
||||
),
|
||||
base_branch=str(values.get("base_branch") or "main"),
|
||||
push_key=Path(push_key).expanduser() if push_key else None,
|
||||
push_user=str(push_user) if push_user else None,
|
||||
push_token=str(push_token) if push_token else None,
|
||||
labels=labels,
|
||||
secrets=secrets,
|
||||
egress=egress,
|
||||
)
|
||||
|
||||
|
||||
def _strings(raw: Any, where: str, errors: list[str]) -> tuple[str, ...]:
|
||||
if not isinstance(raw, list) or not all(
|
||||
isinstance(x, str) and x.strip() for x in raw
|
||||
):
|
||||
errors.append(f"{where}: must be a list of names")
|
||||
return ()
|
||||
return tuple(x.strip() for x in raw)
|
||||
|
||||
|
||||
def _parse_secrets(
|
||||
raw: Any, where: str, env: Mapping[str, str], errors: list[str]
|
||||
) -> dict[str, "Secret"]:
|
||||
"""name → {value: ${VAR}, visible: commands | harness}. A value must be
|
||||
a reference: secrets live in secret stores, never in this file."""
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
errors.append(f"{where}.secrets: must be a mapping of name to secret")
|
||||
return {}
|
||||
secrets: dict[str, Secret] = {}
|
||||
for name, spec in raw.items():
|
||||
at = f"{where}.secrets.{name}"
|
||||
if not isinstance(name, str) or not re.fullmatch(r"[A-Z_][A-Z0-9_]*", name):
|
||||
errors.append(f"{at}: a secret's name is an environment variable name")
|
||||
continue
|
||||
if not isinstance(spec, dict) or set(spec) - {"value", "visible"}:
|
||||
errors.append(f"{at}: give value and visible, nothing else")
|
||||
continue
|
||||
visible = spec.get("visible")
|
||||
if visible not in VISIBILITY:
|
||||
errors.append(f"{at}.visible: must be one of {', '.join(VISIBILITY)}")
|
||||
continue
|
||||
value = spec.get("value")
|
||||
if not isinstance(value, str) or not _REFERENCE.fullmatch(value.strip()):
|
||||
errors.append(f"{at}.value: must be a ${{VAR}} reference, never inline")
|
||||
continue
|
||||
secrets[name] = Secret(_resolve(value.strip(), at, env, errors), visible)
|
||||
return secrets
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuildHost:
|
||||
"""The tool VM the workspace lives on (L13, D3). ``key`` is the path to
|
||||
@@ -236,11 +373,42 @@ class BuildHost:
|
||||
port: int = 22
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Secret:
|
||||
value: str = field(repr=False)
|
||||
visible: str = "harness" # one of VISIBILITY
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Project:
|
||||
"""A project entry (D8): what makes a project known to the agent, and
|
||||
everything per-project the rest of the agent refers to."""
|
||||
|
||||
name: str
|
||||
repo: str
|
||||
workspace: Path # on the build VM
|
||||
build_host: BuildHost
|
||||
base_branch: str = "main"
|
||||
# What may push to the project repository, handed to each push on the
|
||||
# build VM and to nothing else (D4): a push-only account's HTTPS token
|
||||
# (push_user, push_token), or the path to a deploy key (push_key).
|
||||
push_key: Path | None = None
|
||||
push_user: str | None = None
|
||||
push_token: str | None = field(default=None, repr=False)
|
||||
labels: tuple[str, ...] = () # added to every issue's own (D1)
|
||||
secrets: Mapping[str, Secret] = field(default_factory=dict)
|
||||
egress: tuple[str, ...] = () # beyond the default; recorded, not yet enforced
|
||||
|
||||
def command_secrets(self) -> dict[str, str]:
|
||||
"""The secrets the model's commands may see, by name."""
|
||||
return {
|
||||
name: s.value for name, s in self.secrets.items() if s.visible == "commands"
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentConfig:
|
||||
state_db: Path
|
||||
workspace: Path
|
||||
project: str
|
||||
llm_base_url: str
|
||||
# Credentials never appear in a repr: a failing test or a log line
|
||||
# would print them.
|
||||
@@ -250,16 +418,8 @@ class AgentConfig:
|
||||
operators: frozenset[str]
|
||||
gitea_url: str
|
||||
gitea_token: str = field(repr=False)
|
||||
gitea_repo: str
|
||||
gitea_operators: frozenset[str]
|
||||
build_host: BuildHost
|
||||
base_branch: str = "main"
|
||||
# What may push to the project repository, handed to each push on the
|
||||
# build VM and to nothing else (D4): a push-only account's HTTPS token
|
||||
# (push_user, push_token), or the path to a deploy key (push_key).
|
||||
push_key: Path | None = None
|
||||
push_user: str | None = None
|
||||
push_token: str | None = field(default=None, repr=False)
|
||||
projects: tuple[Project, ...]
|
||||
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
|
||||
request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S
|
||||
tuning: Tuning = field(default_factory=Tuning)
|
||||
@@ -268,6 +428,55 @@ class AgentConfig:
|
||||
loop: str = "old"
|
||||
config_file: Path | None = None
|
||||
|
||||
# -- one project, for the old loop ----------------------------------------
|
||||
# control.py (until M2.8) knows one project; these read it, and refuse a
|
||||
# configuration with more.
|
||||
|
||||
def only_project(self) -> Project:
|
||||
if len(self.projects) != 1:
|
||||
names = ", ".join(p.name for p in self.projects)
|
||||
raise ConfigError(
|
||||
f"cannot start: the old loop runs exactly one project, but "
|
||||
f"{len(self.projects)} are configured ({names}); use `loop: new`"
|
||||
)
|
||||
return self.projects[0]
|
||||
|
||||
def with_project(self, **changes: Any) -> "AgentConfig":
|
||||
"""This configuration with its one project changed (for tests)."""
|
||||
return replace(self, projects=(replace(self.only_project(), **changes),))
|
||||
|
||||
@property
|
||||
def project(self) -> str:
|
||||
return self.only_project().name
|
||||
|
||||
@property
|
||||
def gitea_repo(self) -> str:
|
||||
return self.only_project().repo
|
||||
|
||||
@property
|
||||
def workspace(self) -> Path:
|
||||
return self.only_project().workspace
|
||||
|
||||
@property
|
||||
def build_host(self) -> BuildHost:
|
||||
return self.only_project().build_host
|
||||
|
||||
@property
|
||||
def base_branch(self) -> str:
|
||||
return self.only_project().base_branch
|
||||
|
||||
@property
|
||||
def push_key(self) -> Path | None:
|
||||
return self.only_project().push_key
|
||||
|
||||
@property
|
||||
def push_user(self) -> str | None:
|
||||
return self.only_project().push_user
|
||||
|
||||
@property
|
||||
def push_token(self) -> str | None:
|
||||
return self.only_project().push_token
|
||||
|
||||
@classmethod
|
||||
def load(cls, env: Mapping[str, str] = os.environ) -> "AgentConfig":
|
||||
"""The file named by NISSE_CONFIG, or ./nisse-dev.yaml."""
|
||||
@@ -304,7 +513,8 @@ class AgentConfig:
|
||||
config_file: Path | None,
|
||||
) -> "AgentConfig":
|
||||
tuning = _parse_tuning(tree.get("tuning"), errors)
|
||||
flat = _flatten(tree)
|
||||
projects = _parse_projects(tree.get("projects"), env, errors)
|
||||
flat = _flatten({k: v for k, v in tree.items() if k != "projects"})
|
||||
flat.pop("tuning", None)
|
||||
|
||||
known = set(REQUIRED_KEYS) | set(OPTIONAL_KEYS)
|
||||
@@ -343,30 +553,14 @@ class AgentConfig:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
errors.append("llm.request_timeout_s: must be a positive number of seconds")
|
||||
port = values.get("build_host.port", 22)
|
||||
try:
|
||||
port = int(port)
|
||||
if not 0 < port < 65536:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
errors.append("build_host.port: must be a TCP port number")
|
||||
loop = str(values.get("loop") or "old")
|
||||
if loop not in ("old", "new"):
|
||||
errors.append("loop: must be old or new")
|
||||
push_user = values.get("gitea.push_user")
|
||||
push_token = values.get("gitea.push_token")
|
||||
if bool(push_user) != bool(push_token):
|
||||
errors.append("gitea.push_user and gitea.push_token: set both or neither")
|
||||
if push_token and values.get("gitea.push_key"):
|
||||
errors.append("gitea: set a push token or a push key, not both")
|
||||
if errors:
|
||||
raise ConfigError("cannot start: " + "; ".join(errors))
|
||||
|
||||
push_key = values.get("gitea.push_key")
|
||||
config = cls(
|
||||
state_db=Path(values["state_db"]).expanduser(),
|
||||
workspace=Path(values["workspace"]).expanduser(),
|
||||
project=str(values["project"]),
|
||||
llm_base_url=str(values["llm.base_url"]),
|
||||
llm_token=str(values["llm.token"]),
|
||||
roles=RoleMap(
|
||||
@@ -384,18 +578,8 @@ class AgentConfig:
|
||||
operators=_people(values["matrix.operators"]),
|
||||
gitea_url=str(values["gitea.url"]),
|
||||
gitea_token=str(values["gitea.token"]),
|
||||
gitea_repo=str(values["gitea.repo"]),
|
||||
gitea_operators=_people(values["gitea.operators"]),
|
||||
build_host=BuildHost(
|
||||
address=str(values["build_host.address"]),
|
||||
key=Path(values["build_host.key"]).expanduser(),
|
||||
user=str(values.get("build_host.user") or "root"),
|
||||
port=port,
|
||||
),
|
||||
push_key=Path(push_key).expanduser() if push_key else None,
|
||||
push_user=str(push_user) if push_user else None,
|
||||
push_token=str(push_token) if push_token else None,
|
||||
base_branch=str(values.get("gitea.base_branch") or "main"),
|
||||
projects=projects,
|
||||
max_output_tokens=max_output,
|
||||
request_timeout_s=request_timeout,
|
||||
tuning=tuning,
|
||||
@@ -416,31 +600,34 @@ class AgentConfig:
|
||||
from and how the agent is deployed, which is not the project's
|
||||
business either.
|
||||
"""
|
||||
workspace = self.workspace.expanduser().resolve()
|
||||
candidates = [
|
||||
("state_db", self.state_db),
|
||||
("NISSE_ENV", Path(env.get("NISSE_ENV", ".env"))),
|
||||
]
|
||||
if self.config_file is not None:
|
||||
candidates.append(("NISSE_CONFIG", self.config_file))
|
||||
offenders = []
|
||||
for label, path in candidates:
|
||||
resolved = path.expanduser().resolve()
|
||||
if resolved == workspace or workspace in resolved.parents:
|
||||
offenders.append(f"{label} ({resolved})")
|
||||
if offenders:
|
||||
raise ConfigError(
|
||||
"refusing to start: these sit inside the workspace clone at "
|
||||
f"{workspace} and would be committed and pushed to the project "
|
||||
"repository — " + ", ".join(offenders)
|
||||
)
|
||||
for project in self.projects:
|
||||
workspace = project.workspace.expanduser().resolve()
|
||||
offenders = []
|
||||
for label, path in candidates:
|
||||
resolved = path.expanduser().resolve()
|
||||
if resolved == workspace or workspace in resolved.parents:
|
||||
offenders.append(f"{label} ({resolved})")
|
||||
if offenders:
|
||||
raise ConfigError(
|
||||
"refusing to start: these sit inside the workspace clone at "
|
||||
f"{workspace} (project {project.name}) and would be "
|
||||
"committed and pushed to the project repository — "
|
||||
+ ", ".join(offenders)
|
||||
)
|
||||
|
||||
def redacted(self) -> dict[str, str]:
|
||||
"""What the agent may safely announce about how it is configured."""
|
||||
return {
|
||||
"project": self.project,
|
||||
"workspace": f"{self.build_host.address}:{self.workspace}",
|
||||
"repo": self.gitea_repo,
|
||||
"projects": ", ".join(
|
||||
f"{p.name} ({p.repo}, {p.build_host.address}:{p.workspace})"
|
||||
for p in self.projects
|
||||
),
|
||||
"planner": self.roles.planner,
|
||||
"executor": self.roles.executor,
|
||||
"helper": self.roles.helper,
|
||||
|
||||
Reference in new issue
Block a user