"""Toolchain preflight (architecture D3; Development Plan M4.1). A model works out what a project needs and sets it up on the build VM; code makes sure that happens, bounds it and checks the result. - **Ready or not** is the harness's: before a driver run it checks, in one round trip, the baseline it relies on itself, the workspace clone, and a marker per project (``~/.nisse/.ready``) that a finished preflight leaves. No marker means a new project, VM or wipe: preflight runs first. - **The preflight run** is the item's driver model with its own prompt and the execution tools (screened as ever), ending with ``toolchain_ready``. - **Versions are the harness's facts:** it runs each tool's version command itself (version-shaped commands only) and records what they print; it re-checks them before later runs, and a change is a fact for the driver. """ import base64 import json import shlex from dataclasses import dataclass, field from typing import Any from nisse_core.actions import split_shell, unwrap from nisse_dev.execution import SSHHost # What the harness itself runs on a build VM (L13, D4): part of the # template, never of a project's toolchain. BASELINE = ("git", "base64", "realpath", "mkdir", "ssh-agent", "ssh-add", "timeout") _VERSION_FLAGS = ("--version", "-V", "-v", "version", "-version") @dataclass(frozen=True) class Readiness: missing: tuple[str, ...] # baseline commands the VM lacks cloned: bool # the workspace clone is there marker: dict[str, Any] | None # what the last preflight left, if any @dataclass(frozen=True) class Tool: name: str command: str # version-shaped: prints the tool's version version: str = "" # what it printed, when the harness last ran it def to_data(self) -> list[str]: return [self.name, self.command, self.version] @dataclass class Report: summary: str tools: list[Tool] = field(default_factory=list) missing: list[tuple[str, str]] = field(default_factory=list) # (name, why) MARKERS = "$HOME/.nisse" # on the build VM def marker_path(project: str, markers: str = MARKERS) -> str: return f"{markers}/{project}.ready" def check( host: SSHHost, project: str, workspace: str, markers: str = MARKERS ) -> Readiness: """The baseline, the clone and the marker, in one round trip.""" probe = "; ".join( [ *( f"command -v {shlex.quote(c)} >/dev/null 2>&1 || echo missing:{c}" for c in BASELINE ), f"test -d {shlex.quote(workspace)}/.git && echo cloned", ( f'[ -f "{marker_path(project, markers)}" ] && ' f'echo marker:$(base64 -w0 < "{marker_path(project, markers)}")' ), "true", ] ) result = host.execute(probe, timeout_s=30) missing: list[str] = [] cloned = False marker: dict[str, Any] | None = None for line in result.stdout.splitlines(): if line.startswith("missing:"): missing.append(line.removeprefix("missing:")) elif line == "cloned": cloned = True elif line.startswith("marker:"): try: marker = json.loads(base64.b64decode(line.removeprefix("marker:"))) except ValueError: marker = None # unreadable: as good as none return Readiness(tuple(missing), cloned, marker) def write_marker( host: SSHHost, project: str, data: dict[str, Any], markers: str = MARKERS ) -> None: text = base64.b64encode(json.dumps(data, sort_keys=True).encode()).decode() host.execute( f'mkdir -p "{markers}" && base64 -d > "{marker_path(project, markers)}"', stdin=text, timeout_s=30, ) def version_shaped(command: str) -> bool: """One plain command that only prints a version: ``rustc --version``, ``go version``, ``python3 -V``. Nothing else is run by the harness.""" commands = split_shell(command) if commands is None or len(commands) != 1: return False argv, written = commands[0] if written or unwrap(list(argv)) != argv: return False # no redirection, no sudo or env in front return ( 2 <= len(argv) <= 3 and argv[-1] in _VERSION_FLAGS and not argv[0].startswith(("-", "/", ".")) and argv[0] not in ("sh", "bash", "zsh", "dash", "eval", "exec") ) def versions(host: SSHHost, tools: list[Tool]) -> list[Tool]: """Each tool's version, as its command prints it now (first line, from stdout or stderr): run by the harness, in one round trip.""" # The marker lives on the VM, where the model's commands could change # it: only version-shaped commands are ever run, wherever they came from. tools = [t for t in tools if version_shaped(t.command)] if not tools: return [] script = "; ".join( f"echo @@{i}; if command -v {shlex.quote(t.command.split()[0])} " f">/dev/null 2>&1; then {t.command} 2>&1 | head -n 1; fi" for i, t in enumerate(tools) ) result = host.execute(script, timeout_s=60) found: dict[int, str] = {} current = -1 for line in result.stdout.splitlines(): if line.startswith("@@") and line[2:].isdigit(): current = int(line[2:]) found[current] = "" elif current >= 0 and not found[current]: found[current] = line.strip() return [ Tool(t.name, t.command, found.get(i, "") or "not found") for i, t in enumerate(tools) ] def drift(before: list[Tool], now: list[Tool]) -> list[str]: """What changed, as "name: old → new".""" was = {t.name: t.version for t in before} return [ f"{t.name}: {was[t.name]} → {t.version}" for t in now if t.name in was and was[t.name] != t.version ] def tools_of(data: Any) -> list[Tool]: """Tools from stored data (a marker's or an item's).""" out: list[Tool] = [] for entry in data or []: if isinstance(entry, list | tuple) and len(entry) == 3: out.append(Tool(str(entry[0]), str(entry[1]), str(entry[2]))) return out def said(tools: list[Tool]) -> str: return ", ".join(f"{t.name} {t.version}" for t in tools) or "nothing recorded" TOOLCHAIN_READY = { "type": "function", "function": { "name": "toolchain_ready", "description": ( "The project's toolchain is set up on this build VM (or as far as " "it can be): report it. Ends the preflight." ), "parameters": { "type": "object", "properties": { "summary": { "type": "string", "description": "What you found and did, in a sentence or two.", }, "toolchain": { "type": "array", "description": ( "Each tool the project needs whose version matters, " "with the command that prints its version (e.g. " "'python3 --version', 'rustc -V', 'go version')." ), "items": { "type": "object", "properties": { "name": {"type": "string"}, "version_command": {"type": "string"}, }, "required": ["name", "version_command"], }, }, "installed": { "type": "array", "items": {"type": "string"}, "description": "What you installed, if anything.", }, "missing": { "type": "array", "description": "What the project needs that you couldn't set up.", "items": { "type": "object", "properties": { "name": {"type": "string"}, "why": {"type": "string"}, }, "required": ["name", "why"], }, }, }, "required": ["summary", "toolchain"], }, }, } SYSTEM = """You are nisse-dev, running a toolchain step for {repo}: making its build VM and the project's own files agree. Its clone is the workspace. {workspace} Work out from the project's own files what it needs to build, test and run: its language runtimes and compilers, package and build tools, and anything else it calls (system libraries, CLIs, a database for its tests). Honour its pins (.python-version, rust-toolchain.toml, requires-python, engines, lockfiles). {mode} Check what's there with version commands; install what's missing. The build VM is the project's and disposable: installing on it is fine. Don't install or change anything for other hosts, and never install, store or ask for credentials: the harness hands those out when they're needed. Leave out the basics every machine has (ls, cat, git, curl, ssh, tar): those are the VM template's. Leave out tools that act on other systems (gh, tea, docker push, cloud CLIs): they aren't how work is done here. When you're done, or can't get further, call toolchain_ready with what you found and did: each tool whose version matters with a command that prints its version, what you installed, and what's still missing and why. Keep it short: this is setup, not the work.""" SETUP = """This build VM is new to the project, or was rebuilt. Set it up from what the repository says: its pins, setup docs and CI configuration. Don't change the repository's files: if they're out of step with what the project needs, say so in your summary, and the work can put them right. {last}""" REQUESTED = """The work needs a toolchain change: {reason} Install what it needs, then bring the project's own files in step with it: its pins (.python-version, rust-toolchain.toml, requires-python, engines, lockfiles), its setup docs (the README, or wherever it documents setup) and its CI configuration. Those edits are part of the current work and are reviewed with it.""" def report_of(arguments: dict[str, Any]) -> Report: tools = [ Tool(str(t.get("name", "")).strip(), str(t.get("version_command", "")).strip()) for t in arguments.get("toolchain") or [] if isinstance(t, dict) ] missing = [ (str(m.get("name", "")).strip(), str(m.get("why", "")).strip()) for m in arguments.get("missing") or [] if isinstance(m, dict) ] return Report(str(arguments.get("summary", "")).strip(), tools, missing) def report_problem(arguments: dict[str, Any]) -> str | None: """A toolchain_ready the harness can't use: said back to the model.""" report = report_of(arguments) bad = [t.command for t in report.tools if not version_shaped(t.command)] if bad: return ( "these aren't plain version commands, which is all I'll run: " + "; ".join(bad) + ". Use the tool's own --version (or -V, version)." ) if any(not t.name for t in report.tools): return "every toolchain entry needs a name" return None