From 5343733c3eb26b8e3b111d01565fb35ed94d78cd Mon Sep 17 00:00:00 2001 From: "kiloloop-release[bot]" <269344367+kiloloop-release[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:41:33 -0700 Subject: [PATCH] feat: agent-memory v0.1.2 Adds `status --json`: the status readout as one JSON object, with an `ok` flag and a `prerequisites` block. Fixes two readout behaviours. `status` and `doctor` exit non-zero with a reason code when a prerequisite such as git fails, and `doctor --json` carries the same `prerequisites` block. `doctor` and `status --fetch` name a sandbox-blocked credential helper, with its remedy, instead of reporting an unreachable remote. Bounds the build backend to the Hatchling 1.32 series; wheel installs are unaffected. --- CHANGELOG.md | 11 ++ docs/commands.md | 41 +++++++- pyproject.toml | 2 +- src/agent_memory/__init__.py | 2 +- src/agent_memory/cli.py | 44 ++++++-- src/agent_memory/doctor.py | 78 +++++++++++++-- src/agent_memory/prerequisites.py | 151 ++++++++++++++++++++++++++++ src/agent_memory/status.py | 95 +++++++++++++++--- src/agent_memory/sync.py | 7 +- tests/conftest.py | 56 ++++++++++- tests/test_debrief.py | 2 +- tests/test_doctor.py | 83 ++++++++++++++- tests/test_events.py | 6 +- tests/test_prerequisites.py | 161 ++++++++++++++++++++++++++++++ tests/test_setup.py | 2 +- tests/test_status.py | 101 ++++++++++++++++++- tests/test_sync.py | 38 +------ 17 files changed, 789 insertions(+), 91 deletions(-) create mode 100644 src/agent_memory/prerequisites.py create mode 100644 tests/test_prerequisites.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 686de2b..872bca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to agent-memory are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.1.2 - 2026-09-17 + +### Added + +- `status --json` prints the readout as one object, with an `ok` flag and a `prerequisites` block ([status](docs/commands.md#status)). + +### Fixed + +- `status` and `doctor` exit non-zero with a reason code when a prerequisite such as git fails ([prerequisites](docs/commands.md#prerequisites)). +- `doctor` and `status --fetch` name a sandbox-blocked credential helper, not an unreachable remote ([doctor](docs/commands.md#doctor)). + ## 0.1.1 - 2026-09-12 ### Added diff --git a/docs/commands.md b/docs/commands.md index 4e1e0a4..8b51ff9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -13,7 +13,39 @@ is `/projects//workspace.json`), then `~/agent-memory`. `status` prints where the home resolves and where its sync stands: the rule that chose the home, the marker, the allowlist, the tiers, then the tree and the upstream. It contacts the remote only with `--fetch`, and it exits 1 when -the tree is dirty or diverged (ahead and behind are reported, not failed). +the tree is dirty or diverged (ahead and behind are reported, not failed) or +when a prerequisite failed. + +`status --json` prints the same readout as one object, with the same exit +code: + +| Key | Holds | +| --- | --- | +| `ok` | `true` exactly when the exit code is 0 | +| `home`, `source`, `project`, `exists` | the resolved path, the rule that chose it, the bound project, whether the path is a directory | +| `layout` | `marker`, `gitignore`, `org_memory`, `projects`; `null` when the home does not exist | +| `sync` | `configured`, `repository`, `enclosing`, `state`, `state_text`, `remote`, `upstream`, `fetched`, `ahead`, `behind`, `dirty`, `diverged`; `null` when the home does not exist, and the fields git reports are `null` when git was not read | +| `prerequisites` | `ok`, `reason`, `message`, `remedy`, `git` (`present`, `version`), `credential_helper` (`checked`, `blocked`) | + +`sync.state` is one of `synced`, `ahead`, `behind`, `diverged`, +`no_upstream`, `local_only` or `fetch_failed`; `state_text` is the phrase the +table prints. + +### Prerequisites + +`status` and `doctor` share three prerequisite failures. Each exits +non-zero, puts its code in `prerequisites.reason`, and prints the same +message and remedy in the table: + +| `reason` | When | +| --- | --- | +| `home_unresolved` | The resolved home is not a directory (exit 1), or the resolution itself failed, e.g. on a broken binding (exit 2, as on every verb; `--json` still prints the readout) | +| `git_missing` | Git does not run, and the home carries the sync marker. Without the marker, `git.present` is reported but nothing fails | +| `credential_helper_blocked` | A fetch failed because a sandbox blocked the git credential helper. The helper runs only when git has no cached credential for the remote, so after an unsandboxed fetch or push the same sandboxed run passes. Judged only when a fetch ran: always for `doctor`, with `--fetch` for `status` | + +On a blocked helper, the sync row names the cause instead of repeating the +helper's output, which can name the secret it was after. Other fetch failures +still print git's output, which is their only diagnostic. ## Doctor @@ -23,9 +55,10 @@ a record, and a traversal it cannot finish is an error row, never a pass) and the sync repository (marker, allowlist, tracked and untracked memory files, the tree with memory changes told apart from changes outside the allowlist, upstream, remote, last-commit age, per-instance state, overlay ignores). -It reads no memory content, repairs nothing, exits 1 only on an error row, -and points at `memory-lint` when that is installed; `--json` emits the same -rows as data. +It reads no memory content, repairs nothing, exits 1 on an error row or a +failed [prerequisite](#prerequisites), and points at `memory-lint` when that +is installed; `--json` emits the same rows as data, followed by the +`prerequisites` block that `status --json` carries. ## Init diff --git a/pyproject.toml b/pyproject.toml index 1865f50..cf31834 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling>=1.27"] +requires = ["hatchling>=1.32,<2"] build-backend = "hatchling.build" [project] diff --git a/src/agent_memory/__init__.py b/src/agent_memory/__init__.py index 023dc31..dc62c23 100644 --- a/src/agent_memory/__init__.py +++ b/src/agent_memory/__init__.py @@ -4,6 +4,6 @@ from .home import HomeError, HomeResolution, resolve_home -__version__ = "0.1.1" +__version__ = "0.1.2" __all__ = ["HomeError", "HomeResolution", "__version__", "resolve_home"] diff --git a/src/agent_memory/cli.py b/src/agent_memory/cli.py index 7c4c79a..df1bfaf 100644 --- a/src/agent_memory/cli.py +++ b/src/agent_memory/cli.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Callable, List, Optional, Sequence, Tuple -from . import __version__, archive, debrief, doctor, events, org, setup, startup, status, sync, workflow +from . import __version__, archive, debrief, doctor, events, org, prerequisites, setup, startup, status, sync, workflow from .home import BINDING_FILE, HomeError, HomeResolution, find_project, resolve_home EXIT_OK = 0 @@ -51,16 +51,17 @@ def add(name: str, handler: Callable[[argparse.Namespace], int], help_text: str, "status", _status, "Show which home resolves, which rule chose it, whether the layout is in place, and where the sync stands. " - "Exit 1 when the tree is dirty or diverged.", + "Exit 1 when the tree is dirty or diverged, or a prerequisite (git, the home, the credential helper) failed.", ) status_.add_argument( "--fetch", action="store_true", help="contact the remote before counting ahead/behind (no network otherwise)" ) + status_.add_argument("--json", dest="json_output", action="store_true", help="emit the readout as JSON") doctor_ = add( "doctor", _doctor, "Check the home's setup and health: the debrief store's layout and the sync repository. " - "Reads no memory content; repairs nothing. Exit 1 on an error row.", + "Reads no memory content; repairs nothing. Exit 1 on an error row or a failed prerequisite.", ) doctor_.add_argument("--json", dest="json_output", action="store_true", help="emit the report as JSON") init_ = add( @@ -235,23 +236,44 @@ def _max_chars(value: str) -> int: def _status(args: argparse.Namespace) -> int: - readout = status.inspect(resolve_home(args.home), fetch=args.fetch) - print("\n".join(readout.lines())) + try: + resolution = resolve_home(args.home) + except HomeError as exc: + if not args.json_output: + raise + print(json.dumps(status.unresolved(exc), indent=2)) + return EXIT_USAGE + readout = status.inspect(resolution, fetch=args.fetch) + if args.json_output: + print(json.dumps(readout.to_json(), indent=2)) + else: + print("\n".join(readout.lines())) return readout.exit_code def _doctor(args: argparse.Namespace) -> int: - home = resolve_home(args.home).path + memory_lint = doctor.find_memory_lint() + try: + home = resolve_home(args.home).path + except HomeError as exc: + if not args.json_output: + raise + unresolved = prerequisites.home_error(exc) + print(json.dumps(doctor.to_json([], memory_lint=memory_lint, prerequisites=unresolved), indent=2)) + return EXIT_USAGE if not home.is_dir(): - print(f"agent-memory: error: {home} is not a directory", file=sys.stderr) + missing = prerequisites.home_missing(home) + if args.json_output: + print(json.dumps(doctor.to_json([], memory_lint=memory_lint, prerequisites=missing), indent=2)) + elif missing.failure: + print(f"agent-memory: error: {missing.failure.line()}", file=sys.stderr) return EXIT_FAILED - categories = doctor.run_doctor(home) - memory_lint = doctor.find_memory_lint() + categories, readout = doctor.diagnose(home) if args.json_output: - print(json.dumps(doctor.to_json(categories, memory_lint=memory_lint), indent=2)) + print(json.dumps(doctor.to_json(categories, memory_lint=memory_lint, prerequisites=readout), indent=2)) else: sys.stdout.write(doctor.report(categories, memory_lint=memory_lint)) - return EXIT_FAILED if doctor.has_errors(categories) else EXIT_OK + return EXIT_FAILED if doctor.has_errors(categories) or not readout.ok else EXIT_OK def _init(args: argparse.Namespace) -> int: diff --git a/src/agent_memory/doctor.py b/src/agent_memory/doctor.py index 24abd0e..f38cb2d 100644 --- a/src/agent_memory/doctor.py +++ b/src/agent_memory/doctor.py @@ -36,8 +36,9 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple -from . import layout, sync +from . import layout, prerequisites, sync from .git_runner import GitResult, GitRunner, run_git +from .prerequisites import Prerequisites from .sync import GitState #: A last commit older than this many days is reported stale. @@ -109,7 +110,15 @@ def run_doctor( home: Path, *, runner: Optional[GitRunner] = None, now: Optional[dt.datetime] = None ) -> List[Category]: """Both memory categories for ``home``, in report order.""" - return [check_org_memory(home), check_memory_sync(home, runner=runner, now=now)] + return diagnose(home, runner=runner, now=now)[0] + + +def diagnose( + home: Path, *, runner: Optional[GitRunner] = None, now: Optional[dt.datetime] = None +) -> Tuple[List[Category], Prerequisites]: + """Both memory categories in report order, and the prerequisite readout the sync checks took.""" + memory_sync, readout = _memory_sync(home, runner=runner, now=now) + return [check_org_memory(home), memory_sync], readout def find_memory_lint(which: Callable[[str], Optional[str]] = shutil.which) -> Optional[str]: @@ -135,10 +144,16 @@ def report(categories: Sequence[Category], *, memory_lint: Optional[str] = None) return "\n".join(lines) + "\n" -def to_json(categories: Sequence[Category], *, memory_lint: Optional[str] = None) -> Dict[str, Any]: - """The report as data, in the same order as the text.""" +def to_json( + categories: Sequence[Category], + *, + memory_lint: Optional[str] = None, + prerequisites: Optional[Prerequisites] = None, +) -> Dict[str, Any]: + """The report as data, in the same order as the text; ``prerequisites`` goes last, so the + rows before it read as they did without it.""" output: Dict[str, Any] = { - "has_errors": has_errors(categories), + "has_errors": has_errors(categories) or (prerequisites is not None and not prerequisites.ok), "memory_lint": memory_lint, "categories": [], } @@ -152,6 +167,8 @@ def to_json(categories: Sequence[Category], *, memory_lint: Optional[str] = None output["categories"].append( {"name": category.name, "worst_severity": category.worst_severity.value, "results": rows} ) + if prerequisites is not None: + output["prerequisites"] = prerequisites.to_json() return output @@ -325,14 +342,21 @@ def check_memory_sync( home: Path, *, runner: Optional[GitRunner] = None, now: Optional[dt.datetime] = None ) -> Category: """The sync setup and the repository's state, through git alone; nothing is changed.""" + return _memory_sync(home, runner=runner, now=now)[0] + + +def _memory_sync( + home: Path, *, runner: Optional[GitRunner], now: Optional[dt.datetime] +) -> Tuple[Category, Prerequisites]: cat = Category("Memory Sync") marker = layout.MARKER_FILE + git = prerequisites.probe_git(runner) try: configured = _is_file(sync.marker_path(home)) except OSError as exc: cat.add("memory-marker", Severity.warn, f"{marker} — {_not_inspected(exc)}") - return cat + return cat, prerequisites.assess(git, configured=False) if not configured: cat.add( "memory-marker", @@ -340,9 +364,18 @@ def check_memory_sync( f"{marker} — not configured; memory sync hooks are disabled", "Run: agent-memory enable [--remote URL]", ) - return cat + return cat, prerequisites.assess(git, configured=False) cat.add("memory-marker", Severity.ok, f"{marker} — present") + if not git.present: + # Without git every readout below fails, and "not a git repository" would be the wrong finding. + cat.add( + "memory-git", + Severity.error, + f"{marker} — present, but {prerequisites.GIT_MISSING_TEXT}", + prerequisites.GIT_MISSING_REMEDY, + ) + return cat, prerequisites.assess(git, configured=True) if not sync.is_git_repo(home, runner): cat.add( "memory-git", @@ -350,7 +383,7 @@ def check_memory_sync( f"{marker} — present, but {home} is not a git repository", "Run `agent-memory enable`, or `agent-memory disable` to remove the marker", ) - return cat + return cat, prerequisites.assess(git, configured=True) enclosing = enclosing_repository(home, runner) if enclosing is not None: # The sync verbs refuse this home; reading the enclosing repository's state as the home's would be wrong. @@ -361,7 +394,7 @@ def check_memory_sync( "a memory home must be the root of its own repository", "Move the home out of the enclosing repository, or `agent-memory disable` to remove the marker", ) - return cat + return cat, prerequisites.assess(git, configured=True) root_gitignore = home / layout.GITIGNORE_FILE try: @@ -450,6 +483,10 @@ def check_memory_sync( if not state.has_remote: cat.add("sync-state", Severity.ok, text) cat.add("remote", Severity.skip, "remote — skipped; local-only memory repo") + elif prerequisites.helper_blocked(state): + # Not the network and not the remote's permissions: git never had credentials to try with. + cat.add("sync-state", Severity.error, text, prerequisites.HELPER_BLOCKED_REMEDY) + cat.add("remote", Severity.skip, "remote — not checked; the sandbox blocked the git credential helper") elif state.fetch_failed: cat.add("sync-state", Severity.warn, text, "Check network access and remote permissions") cat.add("remote", Severity.warn, "remote — not reachable", "Check network access and remote permissions") @@ -523,7 +560,8 @@ def check_memory_sync( else: cat.add("memory-overlays", Severity.ok, f"memory .gitignore overlays — {len(overlays)} safe") - return cat + fetched = state is not None and state.has_remote + return cat, prerequisites.assess(git, configured=True, state=state, fetched=fetched) def _add_working_tree(cat: Category, home: Path, runner: Optional[GitRunner], state: GitState) -> None: @@ -568,10 +606,30 @@ def enclosing_repository(home: Path, runner: Optional[GitRunner] = None) -> Opti return root +def sync_state(state: GitState) -> str: + """Where the repository stands, as the code ``status --json`` carries beside :func:`sync_state_text`.""" + if not state.has_remote: + return "local_only" + if state.fetch_failed: + return "fetch_failed" + if not state.has_upstream: + return "no_upstream" + if state.diverged: + return "diverged" + if state.behind: + return "behind" + if state.ahead: + return "ahead" + return "synced" + + def sync_state_text(state: GitState) -> str: """One phrase for where the repository stands; ``status`` and ``doctor`` share it.""" if not state.has_remote: return "local-only; no remote configured" + if prerequisites.helper_blocked(state): + # The helper's own output names the secret it was after; the phrase names the cause instead. + return f"remote fetch failed: {prerequisites.HELPER_BLOCKED_TEXT}" if state.fetch_failed: return f"remote fetch failed: {state.fetch_output}" if not state.has_upstream: diff --git a/src/agent_memory/prerequisites.py b/src/agent_memory/prerequisites.py new file mode 100644 index 0000000..beba1c6 --- /dev/null +++ b/src/agent_memory/prerequisites.py @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""What ``status`` and ``doctor`` need before their readout means anything. + +Three prerequisite failures, each with a stable reason code. The JSON carries +the code and the message, the table prints the same message, and either verb +exits non-zero on any of them: + +* ``home_unresolved``: no memory home. The resolver failed, or the path it + chose is not a directory. +* ``git_missing``: git does not run, on a home that carries the sync marker. A + home without the marker needs no git, so there its absence is reported, not + failed. Without git a marked home would read as "not a git repository", + which is a layout finding it is not. +* ``credential_helper_blocked``: a fetch failed with the signature + :func:`agent_memory.sync.credential_helper_blocked` detects, a git + credential helper that a sandbox stopped. The helper runs only when git has + no cached credential for the remote, so the failure needs a cold cache as + well as the sandbox. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Optional + +from . import sync +from .git_runner import EXIT_NOT_FOUND, GitRunner, run_git +from .sync import GitState + +HOME_UNRESOLVED = "home_unresolved" +GIT_MISSING = "git_missing" +CREDENTIAL_HELPER_BLOCKED = "credential_helper_blocked" +#: Every reason code, in the order the checks run. +REASONS = (HOME_UNRESOLVED, GIT_MISSING, CREDENTIAL_HELPER_BLOCKED) + +GIT_MISSING_TEXT = "git could not be run from PATH" +GIT_MISSING_REMEDY = "Install git, or put it on PATH" +HELPER_BLOCKED_TEXT = ( + "the git credential helper could not run under the sandbox, so git had no credentials for the remote" +) +HELPER_BLOCKED_REMEDY = "Rerun with the sandbox off" +HOME_MISSING_REMEDY = "Pass --home or set $AGENT_MEMORY_HOME to an existing home, or create one with `agent-memory init`" +HOME_ERROR_REMEDY = "Fix the binding or path it names, or pass --home" + + +@dataclass(frozen=True) +class Failure: + """One failed prerequisite: its reason code, what happened, and what to do.""" + + reason: str + message: str + remedy: str + + def line(self) -> str: + return f"{self.reason} — {self.message}. {self.remedy}." + + +@dataclass(frozen=True) +class GitProbe: + """Whether git runs, and the version it reports (empty when it reported none).""" + + present: bool + version: str = "" + + +@dataclass(frozen=True) +class Prerequisites: + """The prerequisite readout both verbs print.""" + + #: ``None`` when git was not probed, because there was no home to probe it in. + git: Optional[GitProbe] = None + #: Whether a fetch ran, the only time the credential helper can be judged. + helper_checked: bool = False + helper_blocked: bool = False + failure: Optional[Failure] = None + + @property + def ok(self) -> bool: + return self.failure is None + + @property + def reason(self) -> Optional[str]: + return self.failure.reason if self.failure else None + + def to_json(self) -> Dict[str, Any]: + failure = self.failure + return { + "ok": self.ok, + "reason": failure.reason if failure else None, + "message": failure.message if failure else None, + "remedy": failure.remedy if failure else None, + "git": { + "present": self.git.present if self.git else None, + "version": (self.git.version or None) if self.git else None, + }, + "credential_helper": { + "checked": self.helper_checked, + "blocked": self.helper_blocked if self.helper_checked else None, + }, + } + + +#: Where the probe runs git. ``git --version`` reads nothing from its working directory, and the +#: filesystem root is always there, so a missing or unreadable home cannot pass for a missing git. +_PROBE_CWD = Path(os.path.abspath(os.sep)) + + +def probe_git(runner: Optional[GitRunner] = None) -> GitProbe: + """Run ``git --version``. A git absent from PATH, or one the system will not execute, does not run.""" + try: + result = (runner or run_git)(["--version"], cwd=_PROBE_CWD) + except OSError: + # The runner reports an absent git itself; this is a git it found and could not execute. + return GitProbe(False) + if result.returncode == EXIT_NOT_FOUND: + return GitProbe(False) + words = result.stdout.split() + version = words[2] if result.ok and words[:2] == ["git", "version"] and len(words) > 2 else "" + return GitProbe(True, version) + + +def helper_blocked(state: Optional[GitState]) -> bool: + """Did this readout's fetch fail because a sandbox blocked the credential helper?""" + return state is not None and state.fetch_failed and sync.credential_helper_blocked(state.fetch_output) + + +def home_missing(home: Path) -> Prerequisites: + return Prerequisites(failure=Failure(HOME_UNRESOLVED, f"{home} is not a directory", HOME_MISSING_REMEDY)) + + +def home_error(error: Exception) -> Prerequisites: + return Prerequisites( + failure=Failure(HOME_UNRESOLVED, f"the memory home could not be resolved: {error}", HOME_ERROR_REMEDY) + ) + + +def assess( + git: GitProbe, *, configured: bool, state: Optional[GitState] = None, fetched: bool = False +) -> Prerequisites: + """The readout for a home that exists. Git is required once the home carries the sync + marker; the credential helper is judged only when ``fetched``.""" + blocked = fetched and helper_blocked(state) + failure: Optional[Failure] = None + if configured and not git.present: + failure = Failure(GIT_MISSING, GIT_MISSING_TEXT, GIT_MISSING_REMEDY) + elif blocked: + failure = Failure(CREDENTIAL_HELPER_BLOCKED, HELPER_BLOCKED_TEXT, HELPER_BLOCKED_REMEDY) + return Prerequisites(git, fetched, blocked, failure) diff --git a/src/agent_memory/status.py b/src/agent_memory/status.py index 28989f9..e9d3e55 100644 --- a/src/agent_memory/status.py +++ b/src/agent_memory/status.py @@ -7,20 +7,23 @@ repository, its :class:`~agent_memory.sync.GitState`. The remote is contacted only with ``--fetch``; otherwise ahead/behind count against the last fetched upstream. Exit 0 when the tree is clean and not diverged, 1 when it is dirty -or diverged (or the home does not exist). Ahead and behind are reported, not -failed: they are what ``push`` and ``pull`` are for. +or diverged, or a prerequisite failed (:mod:`agent_memory.prerequisites`). +Ahead and behind are reported, not failed: they are what ``push`` and ``pull`` +are for. ``--json`` prints the same readout as data, with ``ok`` true exactly +when the exit is 0. """ from __future__ import annotations from dataclasses import dataclass from pathlib import Path -from typing import List, Optional +from typing import Any, Dict, List, Optional -from . import layout, sync -from .doctor import enclosing_repository, sync_state_text +from . import layout, prerequisites, sync +from .doctor import enclosing_repository, sync_state, sync_state_text from .git_runner import GitRunner from .home import HomeResolution +from .prerequisites import Prerequisites from .sync import GitState EXIT_OK = 0 @@ -39,13 +42,14 @@ class Status: gitignore: str = "" org_memory: bool = False projects: int = 0 - #: ``None`` without the marker; otherwise whether the home is a git repository. + #: ``None`` without the marker or without git; otherwise whether the home is a git repository. repository: Optional[bool] = None #: Set when the home is a repository only by sitting inside another one. enclosing: Optional[Path] = None git: Optional[GitState] = None #: Whether the remote was contacted for this readout. fetched: bool = False + prerequisites: Prerequisites = Prerequisites() @property def clean(self) -> bool: @@ -54,9 +58,13 @@ def clean(self) -> bool: return False return self.git is None or not (self.git.dirty or self.git.diverged) + @property + def ok(self) -> bool: + return self.prerequisites.ok and self.clean + @property def exit_code(self) -> int: - return EXIT_OK if self.clean else EXIT_FAILED + return EXIT_OK if self.ok else EXIT_FAILED def lines(self) -> List[str]: lines = [f"home: {self.home}", f"source: {self.source}"] @@ -64,7 +72,7 @@ def lines(self) -> List[str]: lines.append(f"project: {self.project}") if not self.exists: lines.append("exists: no") - return lines + return self._with_failure(lines) lines.extend( [ "exists: yes", @@ -74,8 +82,10 @@ def lines(self) -> List[str]: f"projects: {self.projects} with a memory dir", ] ) - if self.repository is None: + if not self.marker: lines.append("sync: not configured") + elif self.prerequisites.reason == prerequisites.GIT_MISSING: + lines.append(f"sync: not read ({prerequisites.GIT_MISSING})") elif not self.repository: lines.append("sync: marker present, but the home is not a git repository") elif self.enclosing is not None: @@ -85,26 +95,70 @@ def lines(self) -> List[str]: if self.git.has_remote: lines.append("fetch: done" if self.fetched else "fetch: skipped (pass --fetch to contact the remote)") lines.append(f"tree: {'dirty' if self.git.dirty else 'clean'}") - return lines + return self._with_failure(lines) + + def _with_failure(self, lines: List[str]) -> List[str]: + failure = self.prerequisites.failure + return lines + [f"prerequisite failed: {failure.line()}"] if failure else lines + + def to_json(self) -> Dict[str, Any]: + """The readout as data: the fields the lines show, and ``ok`` for the exit code.""" + git = self.git + return { + "ok": self.ok, + "home": str(self.home), + "source": self.source, + "project": self.project, + "exists": self.exists, + "layout": { + "marker": self.marker, + "gitignore": self.gitignore, + "org_memory": self.org_memory, + "projects": self.projects, + } + if self.exists + else None, + "sync": { + "configured": self.marker, + "repository": self.repository, + "enclosing": str(self.enclosing) if self.enclosing is not None else None, + "state": sync_state(git) if git else None, + "state_text": sync_state_text(git) if git else None, + "remote": git.has_remote if git else None, + "upstream": (git.upstream or None) if git else None, + "fetched": self.fetched, + "ahead": git.ahead if git else None, + "behind": git.behind if git else None, + "dirty": git.dirty if git else None, + "diverged": git.diverged if git else None, + } + if self.exists + else None, + "prerequisites": self.prerequisites.to_json(), + } def inspect(resolution: HomeResolution, *, fetch: bool = False, runner: Optional[GitRunner] = None) -> Status: """Read the home ``resolution`` names; nothing is changed, and no network without ``fetch``.""" home = resolution.path if not home.is_dir(): - return Status(home, resolution.source, resolution.project, exists=False) + return Status( + home, resolution.source, resolution.project, exists=False, prerequisites=prerequisites.home_missing(home) + ) tiers = layout.allowed_memory_dirs(home) org = layout.org_memory_dir(home) marker = sync.is_configured(home) + probe = prerequisites.probe_git(runner) repository: Optional[bool] = None enclosing: Optional[Path] = None git: Optional[GitState] = None - if marker: + if marker and probe.present: repository = sync.is_git_repo(home, runner) if repository: enclosing = enclosing_repository(home, runner) if repository and enclosing is None: git = sync.git_state(home, runner=runner, fetch=fetch) + fetched = fetch and git is not None and git.has_remote return Status( home, resolution.source, @@ -117,10 +171,25 @@ def inspect(resolution: HomeResolution, *, fetch: bool = False, runner: Optional repository=repository, enclosing=enclosing, git=git, - fetched=fetch and git is not None and git.has_remote, + fetched=fetched, + prerequisites=prerequisites.assess(probe, configured=marker, state=git, fetched=fetched), ) +def unresolved(error: Exception) -> Dict[str, Any]: + """The JSON readout when the resolver itself failed, so there is no home to read.""" + return { + "ok": False, + "home": None, + "source": None, + "project": None, + "exists": False, + "layout": None, + "sync": None, + "prerequisites": prerequisites.home_error(error).to_json(), + } + + def gitignore_state(home: Path) -> str: """How the root ``.gitignore`` relates to the canonical allowlist, in a few words.""" path = home / layout.GITIGNORE_FILE diff --git a/src/agent_memory/sync.py b/src/agent_memory/sync.py index a76f78d..18e4f63 100644 --- a/src/agent_memory/sync.py +++ b/src/agent_memory/sync.py @@ -547,7 +547,7 @@ def _deliver( receipt: Optional[GitignoreReceipt] = None, ) -> Outcome: if state.fetch_failed: - if _credential_helper_blocked(state.fetch_output): + if credential_helper_blocked(state.fetch_output): return _helper_blocked(lines, published, receipt) failure = _fetch_failure(state, "memory push") lines.append(f"{failure.lines[0]} The commit remains local.") @@ -557,7 +557,7 @@ def _deliver( return Outcome("up_to_date", True, tuple(lines), (), published.preserved, receipt) result = _push_remote(home, state, runner) if not result.ok: - if _credential_helper_blocked(result.output): + if credential_helper_blocked(result.output): return _helper_blocked(lines, published, receipt) what = "timed out" if result.timed_out else "was rejected" lines.append(f"memory push: the push {what}; the commit remains local. {result.output}".rstrip()) @@ -575,9 +575,10 @@ def _push_remote(home: Path, state: GitState, runner: Optional[GitRunner]) -> Gi return _git(home, ["push", "--quiet", "-u", remote, branch], runner, timeout=NETWORK_TIMEOUT_SECONDS) -def _credential_helper_blocked(output: str) -> bool: +def credential_helper_blocked(output: str) -> bool: """Does this git failure carry the blocked-credential-helper signature? + The one detector: ``doctor`` and ``status`` read fetch output through it too. git falls back to prompting when a helper hands it nothing, and under a sandbox that prompt has no terminal to read from either. Both halves are required, and the second must come from the helper: a prompt failure on diff --git a/tests/conftest.py b/tests/conftest.py index 7ce049e..e666b7b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,19 @@ # SPDX-FileCopyrightText: 2026 Kiloloop # SPDX-License-Identifier: Apache-2.0 -"""Shared fixtures: an isolated git environment, and a home that mirrors the golden.""" +"""Shared fixtures: an isolated git environment, a home that mirrors the golden, git taken off +PATH, and a fetch that fails the way a sandbox-blocked credential helper makes it fail.""" from __future__ import annotations import os import subprocess from pathlib import Path -from typing import Tuple +from typing import Optional, Sequence, Tuple import pytest from agent_memory import layout +from agent_memory.git_runner import GitResult, run_git @pytest.fixture @@ -27,6 +29,56 @@ def git_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv(f"GIT_{role}_EMAIL", "memory-test@example.invalid") +@pytest.fixture +def no_git(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Call the returned function to take git off PATH: from then on git does not run at all. + Build the home first; the fixtures that build it need git.""" + empty = tmp_path / "no-git-bin" + empty.mkdir() + + def shim() -> None: + monkeypatch.setenv("PATH", str(empty)) + + return shim + + +class FetchBlockedByCredentialHelper: + """The real runner, except that the fetch fails the way a sandboxed `op` call does. + + The shape is a real acceptance transcript's -- the helper's own + TLS failure, then git's fallback prompt failing for want of a terminal -- with + the vault and item names replaced by placeholders. The predicate keys off + "1password document" and "osstatus", so no assertion here depends on what the + blocked secret was called, and this file ships to a public repository. + + Faking the fetch is what makes the failure reproducible. Live, it needs the + sandbox AND a cold credential cache: the helper runs only when git has no + cached credential for the remote, so after any unsandboxed fetch or push a + sandboxed run reads green. + """ + + OUTPUT = ( + "Error: could not read 1Password document 'example-signing-key' from vault " + "'example': [ERROR] failed to request.DoUnencrypted: Post \"/api/v3/auth/start\": " + "tls: failed to verify certificate: x509: OSStatus -26276\n" + "fatal: could not read Username for 'https://github.com': Device not configured" + ) + + def __call__(self, args: Sequence[str], *, cwd: Path, timeout: Optional[float] = None) -> GitResult: + if args and args[0] == "fetch": + return GitResult(128, "", self.OUTPUT) + return run_git(args, cwd=cwd, timeout=timeout) + + +#: git's prompt failure with no helper in play, as each platform spells the errno. +#: macOS's "Device not configured" is git's own terminal fallback failing, not a +#: helper tell -- it rides along with every prompt failure on the platform. +PROMPT_ONLY_FAILURES = { + "macos": "fatal: could not read Username for 'http://127.0.0.1:62548': Device not configured", + "linux": "fatal: could not read Username for 'https://github.com': No such device or address", +} + + def git(*args: str, cwd: Path) -> str: completed = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, text=True, check=False) assert completed.returncode == 0, f"git {' '.join(args)} failed ({completed.returncode}): {completed.stdout}\n{completed.stderr}" diff --git a/tests/test_debrief.py b/tests/test_debrief.py index e0c7558..866b8f0 100644 --- a/tests/test_debrief.py +++ b/tests/test_debrief.py @@ -425,7 +425,7 @@ def hijacking_link(src, dst, **kw): assert imposter.read_bytes() == _record(), "the imposter's own name is untouched" -# ---- the staging name swapped at the link (codex PR #19 r1, F-001) --------- +# ---- the staging name swapped at the link ---------------------------------- # Between the fstat that verified the stage and the link that publishes it, an # actor with write access to the directory can turn the staging NAME into a # hard link or a symlink to a foreign partial file. Foreign bytes are never the diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 7f3e543..d7a187e 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -19,16 +19,16 @@ import re import shutil from pathlib import Path -from typing import Dict, List, Optional, Sequence, Tuple +from typing import Callable, Dict, List, Optional, Sequence, Tuple import pytest -from agent_memory import doctor, layout, sync +from agent_memory import doctor, layout, prerequisites, sync from agent_memory.cli import main from agent_memory.doctor import Severity, check_memory_sync, check_org_memory, run_doctor from agent_memory.git_runner import EXIT_TIMEOUT, GitResult, run_git -from conftest import git, synced_home, write +from conftest import PROMPT_ONLY_FAILURES, FetchBlockedByCredentialHelper, git, synced_home, write TESTS = Path(__file__).resolve().parent GOLDEN_TEXT = TESTS / "golden" / "doctor_memory_0.4.5.txt" @@ -471,6 +471,64 @@ def test_memory_sync_state_rows( assert results["remote"].severity is remote_severity +def test_memory_sync_names_a_blocked_helper_with_its_remedy_not_as_an_unreachable_remote(tmp_path: Path) -> None: + # The fetch output is faked; a live repro needs the sandbox AND a cold credential cache + # (FetchBlockedByCredentialHelper says why), which is how this went unnoticed. + home = _scripted_home(tmp_path) + runner = ScriptedRunner({**_clean(home), ("fetch", "--quiet"): (128, FetchBlockedByCredentialHelper.OUTPUT)}) + (_, category), readout = doctor.diagnose(home, runner=runner) + results = _by_name(category) + assert (results["sync-state"].severity, results["sync-state"].message, results["sync-state"].fix_hint) == ( + Severity.error, + f"sync state — remote fetch failed: {prerequisites.HELPER_BLOCKED_TEXT}", + prerequisites.HELPER_BLOCKED_REMEDY, + ) + assert results["remote"].severity is Severity.skip + assert "not reachable" not in results["remote"].message + shown = doctor.report([category]) + json.dumps(doctor.to_json([category], prerequisites=readout)) + assert "1Password" not in shown and "example-signing-key" not in shown + assert "Check network access" not in shown + assert readout.reason == prerequisites.CREDENTIAL_HELPER_BLOCKED + + +@pytest.mark.parametrize( + "output", + [ + *(PROMPT_ONLY_FAILURES[platform] for platform in sorted(PROMPT_ONLY_FAILURES)), + "fatal: unable to access 'https://example.invalid/memory.git/': Could not resolve host: example.invalid", + "remote: Permission to example-org/memory.git denied to example-user.\n" + "fatal: unable to access 'https://example.invalid/memory.git/': The requested URL returned error: 403", + ], + ids=["prompt-only-linux", "prompt-only-macos", "unreachable", "permissions"], +) +def test_memory_sync_fetch_failures_without_the_signature_keep_todays_wording(tmp_path: Path, output: str) -> None: + home = _scripted_home(tmp_path) + runner = ScriptedRunner({**_clean(home), ("fetch", "--quiet"): (128, output)}) + (_, category), readout = doctor.diagnose(home, runner=runner) + results = _by_name(category) + assert (results["sync-state"].severity, results["sync-state"].message, results["sync-state"].fix_hint) == ( + Severity.warn, + f"sync state — remote fetch failed: {output}", + "Check network access and remote permissions", + ) + assert (results["remote"].severity, results["remote"].message) == (Severity.warn, "remote — not reachable") + assert readout.ok + assert readout.to_json()["credential_helper"] == {"checked": True, "blocked": False} + + +def test_memory_sync_without_git_is_an_error_row_not_a_layout_finding( + tmp_path: Path, git_env: None, no_git: Callable[[], None] +) -> None: + home, _ = synced_home(tmp_path) + no_git() + (_, category), readout = doctor.diagnose(home) + assert _rows(category) == [("memory-marker", Severity.ok), ("memory-git", Severity.error)] + row = category.results[1] + assert prerequisites.GIT_MISSING_TEXT in row.message and "not a git repository" not in row.message + assert row.fix_hint == prerequisites.GIT_MISSING_REMEDY + assert (readout.reason, readout.git) == (prerequisites.GIT_MISSING, prerequisites.GitProbe(False)) + + def test_memory_sync_last_commit_age(tmp_path: Path, git_env: None) -> None: home, _ = synced_home(tmp_path) fresh = _by_name(check_memory_sync(home))["last-commit"] @@ -801,6 +859,25 @@ def test_cli_json_output(tmp_path: Path, git_env: None, capsys: pytest.CaptureFi assert [category["name"] for category in data["categories"]] == list(MEMORY_CATEGORIES) +def test_json_moves_only_by_the_prerequisites_block(tmp_path: Path, git_env: None, capsys: pytest.CaptureFixture[str]) -> None: + home, _ = synced_home(tmp_path) + categories, readout = doctor.diagnose(home) + before = json.dumps(doctor.to_json(categories), indent=2) + after = json.dumps(doctor.to_json(categories, prerequisites=readout), indent=2) + # The block is appended last, so everything before it is the same bytes up to the comma that joins it. + assert after.startswith(before[: -len("\n}")] + ",\n \"prerequisites\": {") + assert readout.to_json() == { + "ok": True, + "reason": None, + "message": None, + "remedy": None, + "git": {"present": True, "version": readout.git.version if readout.git else None}, + "credential_helper": {"checked": True, "blocked": False}, + } + assert main(["doctor", "--home", str(home), "--json"]) == 0 + assert list(json.loads(capsys.readouterr().out)) == ["has_errors", "memory_lint", "categories", "prerequisites"] + + def test_cli_refuses_a_missing_home(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: assert main(["doctor", "--home", str(tmp_path / "nope")]) == 1 assert "is not a directory" in capsys.readouterr().err diff --git a/tests/test_events.py b/tests/test_events.py index 9fde5d1..488f061 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -492,9 +492,9 @@ def test_cli_reads_the_body_from_a_file_and_drops_trailing_newlines(tmp_path, ca # The kernel script reads a body FILE as text (universal newlines: CRLF and CR -# become LF) and takes stdin and --body as given; probed against the script at -# oacp-dev 9fe2f76 on 2026-09-10: file CRLF -> 'First line\nSecond line', stdin -# CRLF -> 'A\r\nB\r\n\r', inline CRLF -> 'a\r\nb\r'. Round-1 F-001 (codex). +# become LF) and takes stdin and --body as given; probed against the script on +# 2026-09-10: file CRLF -> 'First line\nSecond line', stdin CRLF -> +# 'A\r\nB\r\n\r', inline CRLF -> 'a\r\nb\r'. KERNEL_CRLF_FILE = b"First line\r\nSecond line\r\n\r\n" KERNEL_CRLF_FILE_BODY = b"First line\nSecond line" diff --git a/tests/test_prerequisites.py b/tests/test_prerequisites.py new file mode 100644 index 0000000..a9f0793 --- /dev/null +++ b/tests/test_prerequisites.py @@ -0,0 +1,161 @@ +# SPDX-FileCopyrightText: 2026 Kiloloop +# SPDX-License-Identifier: Apache-2.0 +"""The prerequisite grammar ``status`` and ``doctor`` share: three failures, three reason codes. + +Every failure exits non-zero from both verbs, carries its code in the JSON, and +prints the JSON's message in the table. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Callable, Dict, List + +import pytest + +from agent_memory import prerequisites, sync +from agent_memory.cli import main +from agent_memory.git_runner import EXIT_NOT_FOUND, GitResult, run_git +from agent_memory.home import BINDING_FILE, ENV_COMPAT_HOME, ENV_HOME +from agent_memory.prerequisites import CREDENTIAL_HELPER_BLOCKED, GIT_MISSING, HOME_UNRESOLVED + +from conftest import FetchBlockedByCredentialHelper, synced_home + +VERBS = ("status", "doctor") + + +def test_the_grammar_is_three_reasons() -> None: + assert prerequisites.REASONS == (HOME_UNRESOLVED, GIT_MISSING, CREDENTIAL_HELPER_BLOCKED) + + +def _missing_home(tmp_path: Path, no_git: Callable[[], None], monkeypatch: pytest.MonkeyPatch) -> Path: + return tmp_path / "nope" + + +def _home_without_git(tmp_path: Path, no_git: Callable[[], None], monkeypatch: pytest.MonkeyPatch) -> Path: + home, _ = synced_home(tmp_path) + no_git() + return home + + +def _home_behind_a_blocked_helper(tmp_path: Path, no_git: Callable[[], None], monkeypatch: pytest.MonkeyPatch) -> Path: + home, _ = synced_home(tmp_path) + monkeypatch.setattr(sync, "run_git", FetchBlockedByCredentialHelper()) + return home + + +#: Each reason, the home that produces it, and the arguments that reach it (the helper is judged only on a fetch). +CASES: Dict[str, tuple] = { + HOME_UNRESOLVED: (_missing_home, []), + GIT_MISSING: (_home_without_git, []), + CREDENTIAL_HELPER_BLOCKED: (_home_behind_a_blocked_helper, ["--fetch"]), +} + + +@pytest.mark.parametrize("verb", VERBS) +@pytest.mark.parametrize("reason", prerequisites.REASONS) +def test_each_failure_exits_non_zero_with_its_reason_in_the_json_and_its_message_in_the_table( + tmp_path: Path, + git_env: None, + no_git: Callable[[], None], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + verb: str, + reason: str, +) -> None: + make_home, extra = CASES[reason] + home = make_home(tmp_path, no_git, monkeypatch) + argv: List[str] = [verb, "--home", str(home), *(extra if verb == "status" else [])] + + assert main([*argv, "--json"]) == 1 + data = json.loads(capsys.readouterr().out) + readout = data["prerequisites"] + assert (readout["ok"], readout["reason"]) == (False, reason) + assert data["ok" if verb == "status" else "has_errors"] is (verb != "status") + + assert main(argv) == 1 + captured = capsys.readouterr() + table = captured.out + captured.err + assert readout["message"] in table + assert readout["remedy"] in table + + +@pytest.mark.parametrize("verb", VERBS) +def test_a_resolver_failure_is_a_usage_error_that_still_prints_the_json( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], verb: str +) -> None: + monkeypatch.delenv(ENV_HOME, raising=False) + monkeypatch.delenv(ENV_COMPAT_HOME, raising=False) + monkeypatch.chdir(tmp_path) + (tmp_path / BINDING_FILE).write_text("{", encoding="utf-8") + + assert main([verb, "--json"]) == 2 + readout = json.loads(capsys.readouterr().out)["prerequisites"] + assert readout["reason"] == HOME_UNRESOLVED + assert readout["message"].startswith("the memory home could not be resolved: ") + assert readout["git"] == {"present": None, "version": None} + + assert main([verb]) == 2 # the table path keeps every verb's usage error + assert "agent-memory: error:" in capsys.readouterr().err + + +@pytest.mark.parametrize("verb", VERBS) +def test_git_off_path_on_a_home_without_the_marker_is_reported_not_failed( + tmp_path: Path, no_git: Callable[[], None], capsys: pytest.CaptureFixture[str], verb: str +) -> None: + home = tmp_path / "home" + home.mkdir() + no_git() + assert main([verb, "--home", str(home), "--json"]) == 0 + readout = json.loads(capsys.readouterr().out)["prerequisites"] + assert readout["ok"] is True + assert readout["git"] == {"present": False, "version": None} + + +def test_the_probe_reads_the_version_and_tells_a_missing_git_from_a_failing_one() -> None: + def answering(code: int, out: str): + return lambda args, *, cwd, timeout=None: GitResult(code, out) + + assert prerequisites.probe_git(answering(0, "git version 2.39.5 (Apple Git-154)\n")) == prerequisites.GitProbe( + True, "2.39.5" + ) + assert prerequisites.probe_git(answering(EXIT_NOT_FOUND, "")) == prerequisites.GitProbe(False) + assert prerequisites.probe_git(answering(1, "")) == prerequisites.GitProbe(True, "") + assert prerequisites.probe_git().present is True + + +@pytest.mark.parametrize("verb", VERBS) +def test_a_git_on_path_that_cannot_be_executed_is_missing_git( + tmp_path: Path, git_env: None, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], verb: str +) -> None: + home, _ = synced_home(tmp_path) + shim = tmp_path / "unrunnable-bin" + shim.mkdir() + (shim / "git").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") # no execute bit + monkeypatch.setenv("PATH", str(shim)) + assert main([verb, "--home", str(home), "--json"]) == 1 + assert json.loads(capsys.readouterr().out)["prerequisites"]["reason"] == GIT_MISSING + + +def test_the_probe_runs_git_outside_the_home() -> None: + # A missing or unreadable home must not read as missing git, so the probe never runs git in it. + calls: List[Path] = [] + + def recording(args, *, cwd, timeout=None): + calls.append(cwd) + return run_git(args, cwd=cwd, timeout=timeout) + + assert prerequisites.probe_git(recording).present is True + assert calls == [Path(Path.cwd().anchor)] + + +def test_the_helper_is_judged_only_when_a_fetch_ran() -> None: + blocked = sync.GitState( + has_remote=True, has_upstream=True, fetch_failed=True, fetch_output=FetchBlockedByCredentialHelper.OUTPUT + ) + git = prerequisites.GitProbe(True, "2.55.0") + assert prerequisites.assess(git, configured=True, state=blocked, fetched=True).reason == CREDENTIAL_HELPER_BLOCKED + unchecked = prerequisites.assess(git, configured=True, state=blocked, fetched=False) + assert unchecked.ok + assert unchecked.to_json()["credential_helper"] == {"checked": False, "blocked": None} diff --git a/tests/test_setup.py b/tests/test_setup.py index 3d04aa0..fcb5064 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -791,7 +791,7 @@ def test_setup_receipts_never_sync(tmp_path: Path, git_env: None) -> None: assert ignored.returncode == 0 -# --- the workflow file beside the hook (AM-07) ----------------------------------- +# --- the workflow file beside the hook ------------------------------------------- def test_workflow_paths_are_the_runtimes_repository_skill_files() -> None: diff --git a/tests/test_status.py b/tests/test_status.py index bf78644..6e893d8 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -1,20 +1,22 @@ # SPDX-FileCopyrightText: 2026 Kiloloop # SPDX-License-Identifier: Apache-2.0 -"""``agent-memory status``: the readout and the exit contract (0 clean, 1 dirty or diverged).""" +"""``agent-memory status``: the readout, its JSON, and the exit contract (0 clean; 1 dirty, diverged, or a +failed prerequisite).""" from __future__ import annotations +import json from pathlib import Path -from typing import Dict, List, Optional, Sequence, Tuple +from typing import Callable, Dict, List, Optional, Sequence, Tuple import pytest -from agent_memory import layout, status, sync +from agent_memory import layout, prerequisites, status, sync from agent_memory.cli import main from agent_memory.git_runner import GitResult, run_git from agent_memory.home import HomeResolution, resolve_home -from conftest import git, synced_home, write +from conftest import FetchBlockedByCredentialHelper, git, synced_home, write @pytest.fixture @@ -156,6 +158,97 @@ def test_fetch_is_opt_in_and_carries_the_network_timeout(tmp_path: Path, git_env assert readout.exit_code == 0 +# --- the JSON readout and the prerequisites -------------------------------- + + +def test_json_round_trips_with_the_fields_the_table_shows(tmp_path: Path, git_env: None, capsys) -> None: + home, _ = synced_home(tmp_path) + assert main(["status", "--home", str(home), "--json"]) == 0 + data = json.loads(capsys.readouterr().out) + assert list(data) == ["ok", "home", "source", "project", "exists", "layout", "sync", "prerequisites"] + assert (data["ok"], data["home"], data["source"], data["exists"]) == (True, str(home), "flag", True) + assert data["layout"] == {"marker": True, "gitignore": "canonical", "org_memory": True, "projects": 0} + assert data["sync"] == { + "configured": True, + "repository": True, + "enclosing": None, + "state": "synced", + "state_text": "synced with upstream", + "remote": True, + "upstream": "origin/main", + "fetched": False, + "ahead": 0, + "behind": 0, + "dirty": False, + "diverged": False, + } + readout = data["prerequisites"] + assert (readout["ok"], readout["reason"], readout["message"], readout["remedy"]) == (True, None, None, None) + assert readout["git"]["present"] is True and readout["git"]["version"] + assert readout["credential_helper"] == {"checked": False, "blocked": None} + + +def test_json_ok_is_the_exit_code(tmp_path: Path, git_env: None, capsys) -> None: + home, _ = synced_home(tmp_path) + write(home / layout.ORG.pattern / "recent.md", "unpublished\n") + assert main(["status", "--home", str(home), "--json"]) == 1 + data = json.loads(capsys.readouterr().out) + assert (data["ok"], data["sync"]["dirty"], data["prerequisites"]["ok"]) == (False, True, True) + + +def test_a_missing_home_has_no_layout_or_sync_block(tmp_path: Path, capsys) -> None: + assert main(["status", "--home", str(tmp_path / "nope"), "--json"]) == 1 + data = json.loads(capsys.readouterr().out) + assert (data["exists"], data["layout"], data["sync"]) == (False, None, None) + assert data["prerequisites"]["reason"] == prerequisites.HOME_UNRESOLVED + + +def test_git_off_path_is_a_prerequisite_failure_not_a_layout_finding( + tmp_path: Path, git_env: None, no_git: Callable[[], None], out +) -> None: + home, _ = synced_home(tmp_path) + no_git() + readout = status.inspect(_resolution(home)) + assert readout.exit_code == 1 + assert readout.lines()[-2:] == [ + f"sync: not read ({prerequisites.GIT_MISSING})", + f"prerequisite failed: {prerequisites.GIT_MISSING} — {prerequisites.GIT_MISSING_TEXT}. " + f"{prerequisites.GIT_MISSING_REMEDY}.", + ] + assert not any("not a git repository" in line for line in readout.lines()) + assert readout.to_json()["sync"]["repository"] is None + + +def test_a_blocked_helper_is_named_and_its_output_stays_out_of_the_readout(tmp_path: Path, git_env: None) -> None: + # The runner fakes the helper's failure; see FetchBlockedByCredentialHelper for why a live repro needs a + # cold credential cache as well as the sandbox. + home, _ = synced_home(tmp_path) + readout = status.inspect(_resolution(home), fetch=True, runner=FetchBlockedByCredentialHelper()) + assert readout.exit_code == 1 + lines = readout.lines() + assert f"sync: remote fetch failed: {prerequisites.HELPER_BLOCKED_TEXT}" in lines + assert lines[-1].startswith(f"prerequisite failed: {prerequisites.CREDENTIAL_HELPER_BLOCKED} — ") + data = readout.to_json() + assert data["sync"]["state"] == "fetch_failed" + assert data["prerequisites"]["credential_helper"] == {"checked": True, "blocked": True} + shown = "\n".join(lines) + json.dumps(data) + assert "1Password" not in shown and "example-signing-key" not in shown + + # Without --fetch the helper never runs, so it is not judged. + quiet = status.inspect(_resolution(home), runner=FetchBlockedByCredentialHelper()) + assert quiet.exit_code == 0 + assert quiet.to_json()["prerequisites"]["credential_helper"] == {"checked": False, "blocked": None} + + +def test_a_fetch_failure_without_the_signature_is_not_a_prerequisite(tmp_path: Path, git_env: None) -> None: + home, _ = synced_home(tmp_path) + git("remote", "set-url", "origin", str(tmp_path / "gone.git"), cwd=home) + readout = status.inspect(_resolution(home), fetch=True) + assert readout.exit_code == 0 + assert readout.prerequisites.to_json()["credential_helper"] == {"checked": True, "blocked": False} + assert any(line.startswith("sync: remote fetch failed: ") and "gone.git" in line for line in readout.lines()) + + def test_gitignore_states(tmp_path: Path) -> None: home = tmp_path / "home" layout.scaffold_home(home) diff --git a/tests/test_sync.py b/tests/test_sync.py index baf9449..3db313d 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -10,6 +10,8 @@ from agent_memory import layout, sync from agent_memory.git_runner import EXIT_TIMEOUT, GitResult, run_git +from conftest import PROMPT_ONLY_FAILURES, FetchBlockedByCredentialHelper + GOLDEN = Path(__file__).resolve().parent / "golden" / "canonical_memory_gitignore.txt" CANONICAL = layout.gitignore_text() MARKER = layout.MARKER_FILE @@ -129,29 +131,6 @@ def __call__(self, args: Sequence[str], *, cwd: Path, timeout: Optional[float] = return run_git(args, cwd=cwd, timeout=timeout) -class FetchBlockedByCredentialHelper: - """The real runner, except that the fetch fails the way a sandboxed `op` call does. - - The shape is the 2026-09-09 AM-09 acceptance transcript's -- the helper's own - TLS failure, then git's fallback prompt failing for want of a terminal -- with - the vault and item names replaced by placeholders. The predicate keys off - "1password document" and "osstatus", so no assertion here depends on what the - blocked secret was called, and this file ships to a public repository. - """ - - OUTPUT = ( - "Error: could not read 1Password document 'example-signing-key' from vault " - "'example': [ERROR] failed to request.DoUnencrypted: Post \"/api/v3/auth/start\": " - "tls: failed to verify certificate: x509: OSStatus -26276\n" - "fatal: could not read Username for 'https://github.com': Device not configured" - ) - - def __call__(self, args: Sequence[str], *, cwd: Path, timeout: Optional[float] = None) -> GitResult: - if args and args[0] == "fetch": - return GitResult(128, "", self.OUTPUT) - return run_git(args, cwd=cwd, timeout=timeout) - - # --- the network verbs carry the timeout ----------------------------------- @@ -235,23 +214,14 @@ def test_a_sandboxed_credential_helper_is_named_as_its_own_failure(tmp_path: Pat assert not _remote_has(remote, "org-memory/local.md") -#: git's prompt failure with no helper in play, as each platform spells the errno. -#: macOS's "Device not configured" is git's own terminal fallback failing, not a -#: helper tell -- it rides along with every prompt failure on the platform. -PROMPT_ONLY_FAILURES = { - "macos": "fatal: could not read Username for 'http://127.0.0.1:62548': Device not configured", - "linux": "fatal: could not read Username for 'https://github.com': No such device or address", -} - - @pytest.mark.parametrize("platform", sorted(PROMPT_ONLY_FAILURES)) def test_a_prompt_failure_without_helper_evidence_is_not_a_blocked_helper(platform: str) -> None: """Both halves are required, and the second one has to come from the helper.""" - assert not sync._credential_helper_blocked(PROMPT_ONLY_FAILURES[platform]) + assert not sync.credential_helper_blocked(PROMPT_ONLY_FAILURES[platform]) def test_helper_evidence_alongside_the_prompt_failure_is_the_signature() -> None: - assert sync._credential_helper_blocked(FetchBlockedByCredentialHelper.OUTPUT) + assert sync.credential_helper_blocked(FetchBlockedByCredentialHelper.OUTPUT) @pytest.mark.parametrize("platform", sorted(PROMPT_ONLY_FAILURES))