diff --git a/.dev/status/current-handoff.md b/.dev/status/current-handoff.md index b088378..4697f23 100644 --- a/.dev/status/current-handoff.md +++ b/.dev/status/current-handoff.md @@ -1,7 +1,7 @@ # agent-memory current handoff Status: AI-authored draft. Not yet human-approved. -Last updated: 2026-04-30 22:21 KST +Last updated: 2026-04-30 23:00 KST ## Trigger for the next session @@ -16,9 +16,7 @@ read this file first. Do not ask the user to restate context. Verify repo state, ## Ready-to-say answer -지금 agent-memory는 OSS 기본 메모리 레이어 신뢰도 작업 Priority 1~4의 주요 truth lifecycle 조각, retrieval-eval read-only hardening, protected-main release fallback 자동화를 v0.1.32까지 완료했고, 현재 slice는 release fallback rerun idempotency 보강이야. - -최신 검증 완료 릴리스는 v0.1.32야. v0.1.27에서 status transition history, v0.1.28에서 npm wrapper stdin forwarding과 published Hermes hook smoke, v0.1.29에서 fact supersession/replacement relation, v0.1.30에서 `agent-memory review explain fact ...` decision explanation UX, v0.1.31에서 retrieval eval read-only behavior, v0.1.32에서 protected-main release-sync PR/tag/publish automation이 들어갔어. 로컬 Hermes hook도 v0.1.32 runtime으로 업데이트되어 doctor/hook smoke가 통과한 상태야. +지금 agent-memory는 v0.1.33까지 release fallback rerun idempotency와 Hermes v0.1.33 QA까지 끝났고, 현재 진행 중인 통합 slice는 v0.1.34 후보야. 세 가지를 한 PR로 묶어 진행 중이야: published smoke propagation/backoff hardening, release-sync PR CI validation dispatch, read-only relation graph inspect CLI. ## Current repo state @@ -35,18 +33,15 @@ Expected GitHub identity: Verified base before this slice: - branch: `main` -- HEAD: `654c5d8 chore: release v0.1.32 [skip release] (#32)` -- tag/release: `v0.1.32` -- GitHub Release: `https://github.com/cafitac/agent-memory/releases/tag/v0.1.32` -- npm: `@cafitac/agent-memory@0.1.32` -- PyPI: `cafitac-agent-memory==0.1.32` -- v0.1.32 published smoke artifact: passed; includes npm/uvx/pipx Hermes hook commands. -- repo Actions workflow setting: `can_approve_pull_request_reviews=true`, needed so `GITHUB_TOKEN` can create release-sync PRs. +- latest completed release: `v0.1.33` +- v0.1.33 included release-sync fallback rerun idempotency. +- local Hermes hook uses `/Users/reddit/.agent-memory/runtime/v0.1.33/.venv/bin/agent-memory` against `/Users/reddit/.agent-memory/memory.db`. Active slice/worktree: -- branch: `ci/release-fallback-idempotency` -- worktree: `/Users/reddit/Project/agent-memory/.worktrees/release-fallback-idempotency` +- branch: `feat/release-graph-hardening` +- worktree: `/Users/reddit/Project/agent-memory/.worktrees/release-graph-hardening` +- intended release after merge: likely `v0.1.34` Expected local untracked artifacts to preserve in the root checkout: @@ -58,7 +53,7 @@ Expected local untracked artifacts to preserve in the root checkout: Do not delete or commit these unless the user explicitly asks. -## What is complete through v0.1.32 +## What is complete through v0.1.33 ### Distribution and release automation @@ -68,11 +63,12 @@ Do not delete or commit these unless the user explicitly asks. - Published smoke uploads `published-install-smoke-result` JSON artifact with success/failure diagnostics. - v0.1.28+ smoke covers npm/npx/npm-exec/uvx/pipx and Hermes hook stdin payload handling. - Protected `main` fallback is automated: auto-release creates `release-sync/vX.Y.Z` PR when direct metadata write-back is rejected; after merge, auto-release tags and dispatches publish. +- v0.1.33 made that fallback safe to rerun when the branch or PR already exists. ### Runtime adapter readiness - Hermes bootstrap/doctor/install flow exists and defaults to the conservative preset. -- This local Hermes setup has agent-memory enabled via `/Users/reddit/.agent-memory/runtime/v0.1.32/.venv/bin/agent-memory` against `/Users/reddit/.agent-memory/memory.db`. +- This local Hermes setup has agent-memory enabled via `/Users/reddit/.agent-memory/runtime/v0.1.33/.venv/bin/agent-memory` against `/Users/reddit/.agent-memory/memory.db`. - Hermes hook fails closed: unavailable DB/schema returns `{}` and exit 0 instead of breaking prompt flow. - Conservative preset remains default: small prompt budgets, one top memory, no alternative-memory detail, no reason-code noise. - `--preset balanced` is explicit opt-in for more context/noise. @@ -90,36 +86,98 @@ Do not delete or commit these unless the user explicitly asks. - `agent-memory review explain fact ...` explains status, default retrieval visibility, same claim-slot alternatives, replacement chain, and review follow-up commands. - Retrieval eval calls the real retrieval path but suppresses retrieval bookkeeping writes (`retrieval_count`, `reinforcement_count`, `last_accessed_at`). -## Current slice: release fallback rerun idempotency +## Current slice: release/package/graph hardening + +User asked to do all three next recommended tasks: + +1. Published smoke propagation/backoff improvement. +2. release-sync PR CI dispatch/status automation. +3. Graph foundation first safe slice: read-only relation graph inspect CLI. + +Current implementation direction: + +### Published smoke propagation/backoff + +Files: + +- `scripts/smoke_published_install.py` +- `tests/test_published_install_smoke.py` +- `.github/workflows/publish.yml` +- `.github/workflows/published-install-smoke.yml` + +Behavior: + +- Detect resolver/package-index propagation-like failures such as `No solution found`, `No matching distribution found`, npm 404/ETARGET/NOTARGET, and exact `cafitac-agent-memory==X.Y.Z` misses. +- Apply a separate longer retry budget only for propagation-like failures: + - normal attempts remain bounded + - propagation attempts can extend with exponential backoff +- Failure artifacts include registry probe diagnostics: + - npm version present/latest + - PyPI JSON release present + - PyPI simple index mentions version + - probe errors +- `publish.yml` uses `--attempts 12`, `--propagation-attempts 36`, `--propagation-delay-seconds 20`. +- Manual `published-install-smoke.yml` exposes propagation attempt/delay inputs. + +### release-sync PR CI validation dispatch + +Files: -Why this slice exists: +- `.github/workflows/auto-release.yml` +- `tests/test_release_workflows.py` -- During the first v0.1.32 live fallback run, GitHub Actions created `release-sync/v0.1.32` but failed to create the PR because the repository Actions setting initially disallowed GitHub Actions from creating PRs. -- After enabling that setting, rerunning the failed job hit a non-fast-forward branch push because the release-sync branch already existed. -- The fallback should be safe to rerun after this kind of partial success. +Behavior: -Planned behavior: +- When fallback creates a new `release-sync/vX.Y.Z` PR, capture the PR URL. +- Dispatch `ci.yml` explicitly on `release-sync/vX.Y.Z` with `gh workflow run ci.yml --ref "${RELEASE_SYNC_BRANCH}"`. +- Comment on the PR explaining that bot-created refs may suppress automatic PR checks and that maintainers should wait for the dispatched `ci.yml` run before merging. -- When protected-main fallback starts, check whether `release-sync/vX.Y.Z` already exists on origin. -- If the branch exists, reuse it instead of pushing and failing with non-fast-forward. -- Check whether an open PR already exists for the release-sync branch. -- If the PR exists, log the URL and exit successfully instead of opening a duplicate PR. -- If neither exists, keep the existing branch push + `gh pr create` behavior. +### read-only relation graph inspect CLI -Implementation direction: +Files: -- Update `.github/workflows/auto-release.yml` fallback step with `git ls-remote --heads` branch detection. -- Add `gh pr list --head ... --state open --json url --jq '.[0].url // empty'` before `gh pr create`. -- Keep the direct path and release-sync tag/publish follow-up unchanged. -- Add/keep tests in `tests/test_release_workflows.py` proving idempotency markers exist in the workflow. +- `src/agent_memory/api/cli.py` +- `src/agent_memory/storage/sqlite.py` +- `tests/test_cli.py` +- `README.md` + +New command: + +```bash +agent-memory graph inspect --depth 1 --limit 100 +``` + +Example: + +```bash +agent-memory graph inspect ~/.agent-memory/memory.db fact:1 --depth 2 --limit 50 +``` + +Behavior: + +- Traverses stored `Relation` edges only. +- JSON output includes: + - `kind: relation_graph_inspection` + - `start_ref` + - `depth` + - `limit` + - `read_only: true` + - `nodes` + - `edges` + - `truncated` +- Does not change retrieval behavior. +- Does not mutate memory state. +- Intended as the first safe graph-foundation slice before default retrieval graph traversal. ## Verification checklist for this slice Run from the active worktree: ```bash -uv run pytest tests/test_release_workflows.py -q uv run pytest tests/test_published_install_smoke.py -q +uv run pytest tests/test_release_workflows.py -q +uv run pytest tests/test_cli.py::test_python_module_cli_graph_inspect_returns_read_only_relation_neighborhood -q +uv run pytest tests/test_published_install_smoke.py tests/test_release_workflows.py tests/test_cli.py -q uv run pytest tests/ -q uv run python scripts/check_release_metadata.py uv run python scripts/smoke_release_readiness.py @@ -132,21 +190,22 @@ Before PR, run a static diff secret scan and confirm finding_count 0. ## PR/release notes -This slice changes only release automation/docs/tests, but it affects the release path and should be treated as a patch release candidate, likely v0.1.33 after PR merge. +This slice affects release automation, published install smoke, and a new read-only CLI command. Treat it as a patch release candidate, likely v0.1.34 after PR merge. Expected live verification after merge: -1. PR merge should trigger auto-release and bump metadata to v0.1.33. +1. PR merge should trigger auto-release and bump metadata to v0.1.34. 2. Protected `main` should trigger fallback. -3. Fallback should create `release-sync/v0.1.33` PR or reuse it if a partial rerun already created it. -4. Merge the release-sync PR. -5. Confirm release-sync follow-up creates tag `v0.1.33`, dispatches publish, and published smoke passes. -6. Verify GitHub Release/npm/PyPI/published-install-smoke artifact. -7. Update local Hermes runtime to v0.1.33 only after package release is verified. +3. Fallback should create `release-sync/v0.1.34` PR and dispatch `ci.yml` on that branch. +4. Wait for the dispatched CI run before merging release-sync PR. +5. Merge the release-sync PR. +6. Confirm release-sync follow-up creates tag `v0.1.34`, dispatches publish, and published smoke passes. +7. Verify GitHub Release/npm/PyPI/published-install-smoke artifact. +8. Update local Hermes runtime to v0.1.34 only after package release is verified. ## Next likely slices after this -1. Published smoke propagation handling improvement: make first-run simple-index lag less noisy. -2. Actual Hermes dogfood observations and noise/latency notes. -3. Graph foundation read-only slice: graph inspection CLI or bounded relation traversal eval fixtures. +1. Actual Hermes dogfood observations and noise/latency notes. +2. Expand graph inspection with node metadata/status summaries, still read-only. +3. Later graph retrieval eval fixtures before any default graph expansion. 4. PyPI Trusted Publisher later; user deferred it. diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 55419ec..2bb7a0a 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -126,12 +126,17 @@ jobs: ## Next automation Publish workflow will run after the release sync PR is merged and the tag is pushed. EOF - gh pr create \ + release_sync_pr_url=$(gh pr create \ --repo "${{ github.repository }}" \ --base main \ --head "${RELEASE_SYNC_BRANCH}" \ --title "chore: release ${{ steps.bump.outputs.tag }} [skip release]" \ - --body-file /tmp/release-sync-pr.md + --body-file /tmp/release-sync-pr.md) + echo "Release sync PR created: ${release_sync_pr_url}" + gh workflow run ci.yml --ref "${RELEASE_SYNC_BRANCH}" + gh pr comment "${release_sync_pr_url}" \ + --repo "${{ github.repository }}" \ + --body "Release sync validation CI was dispatched for ${RELEASE_SYNC_BRANCH}. Because this PR is created by GitHub Actions, automatic PR checks may be suppressed; wait for the dispatched `ci.yml` run before merging." - name: Dispatch publish workflow for bot-created tag if: steps.push_release.outputs.release_sync_required == 'false' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c47e16a..9e1bee3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -149,8 +149,10 @@ jobs: VERSION="${GITHUB_REF_NAME#v}" uv run python scripts/smoke_published_install.py \ --version "$VERSION" \ - --attempts 36 \ + --attempts 12 \ --delay-seconds 10 \ + --propagation-attempts 36 \ + --propagation-delay-seconds 20 \ --output-json .artifacts/published-install-smoke.json - name: Upload published install smoke result diff --git a/.github/workflows/published-install-smoke.yml b/.github/workflows/published-install-smoke.yml index e3cacf1..9110b0c 100644 --- a/.github/workflows/published-install-smoke.yml +++ b/.github/workflows/published-install-smoke.yml @@ -12,10 +12,15 @@ on: required: false default: '18' type: string - delay_seconds: - description: 'Delay between retry attempts' + propagation_attempts: + description: 'Longer retry budget for propagation-like resolver failures' required: false - default: '10' + default: '36' + type: string + propagation_delay_seconds: + description: 'Base exponential backoff delay for propagation-like resolver failures' + required: false + default: '20' type: string jobs: @@ -50,7 +55,9 @@ jobs: uv run python scripts/smoke_published_install.py \ --version "${{ inputs.version }}" \ --attempts "${{ inputs.attempts }}" \ - --delay-seconds "${{ inputs.delay_seconds }}" \ + --delay-seconds 10 \ + --propagation-attempts "${{ inputs.propagation_attempts }}" \ + --propagation-delay-seconds "${{ inputs.propagation_delay_seconds }}" \ --output-json .artifacts/published-install-smoke.json - name: Upload published install smoke result diff --git a/README.md b/README.md index cf7610f..2ec02a6 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,15 @@ agent-memory review explain fact "$DB" 1 `review explain` combines the current status, default retrieval visibility, transition history, same claim-slot alternatives, and replacement chain into one decision context so a reviewer can see why a stale or conflicting fact is hidden. +For read-only relation graph inspection, use `graph inspect`. This is an operator/debug view over stored `Relation` edges; it does not change retrieval behavior or mutate memory state: + +```bash +agent-memory graph inspect "$DB" fact:1 --depth 1 +agent-memory graph inspect "$DB" fact:1 --depth 2 --limit 50 +``` + +The JSON output includes the start ref, visited node refs, relation edges, traversal depth per edge, and a `read_only: true` marker. It is intended as a safe graph-foundation slice before enabling any broader graph traversal in default retrieval. + ## Hermes quickstart For most Hermes users: @@ -246,9 +255,9 @@ uv run pytest tests/test_published_install_smoke.py -q npm pack --dry-run ``` -After a release publishes, the `published-install-smoke` workflow verifies the exact npm/PyPI version through npm registry lookup, `npx`, `npm exec`, `uvx`, and `pipx`. Maintainers can also run it manually with `gh workflow run published-install-smoke.yml -f version=`. +After a release publishes, the `published-install-smoke` workflow verifies the exact npm/PyPI version through npm registry lookup, `npx`, `npm exec`, `uvx`, and `pipx`. Maintainers can also run it manually with `gh workflow run published-install-smoke.yml -f version=`. The smoke script treats early resolver/package-index misses as propagation-like failures and applies a longer exponential backoff before failing; failure artifacts include npm/PyPI registry probe diagnostics so maintainers can tell whether metadata is visible while installers are still stale. -Release automation expects protected `main`: if the auto-release workflow cannot push its bumped metadata commit directly, it opens a `release-sync/vX.Y.Z` PR instead. After that PR is merged, the same workflow tags the synced version and dispatches `publish.yml`, keeping the release path automated without requiring a permanent branch-protection bypass. The fallback is safe to rerun: if the `release-sync/vX.Y.Z` branch or PR already exists, the workflow reuses it instead of failing on a non-fast-forward push or opening a duplicate PR. +Release automation expects protected `main`: if the auto-release workflow cannot push its bumped metadata commit directly, it opens a `release-sync/vX.Y.Z` PR instead. After that PR is merged, the same workflow tags the synced version and dispatches `publish.yml`, keeping the release path automated without requiring a permanent branch-protection bypass. The fallback is safe to rerun: if the `release-sync/vX.Y.Z` branch or PR already exists, the workflow reuses it instead of failing on a non-fast-forward push or opening a duplicate PR. When it creates a new release-sync PR, it also dispatches `ci.yml` on that bot-created branch and comments with the validation handoff because GitHub can suppress automatic PR checks for bot-created refs. Useful source-checkout commands: diff --git a/scripts/smoke_published_install.py b/scripts/smoke_published_install.py index 1981694..c4e07bb 100644 --- a/scripts/smoke_published_install.py +++ b/scripts/smoke_published_install.py @@ -3,11 +3,15 @@ import argparse import json import os +import re import shutil import subprocess import sys import tempfile import time +import urllib.error +import urllib.parse +import urllib.request from dataclasses import asdict, dataclass from pathlib import Path from typing import Sequence @@ -112,6 +116,88 @@ def _assert_registry_version(step: PublishedSmokeStep, version: str) -> None: raise RuntimeError(f"registry returned {step.stdout.strip()!r}, expected {version!r}") +def _fetch_text(url: str, *, timeout: int) -> str: + request = urllib.request.Request(url, headers={"User-Agent": "agent-memory-published-smoke"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read().decode("utf-8", errors="replace") + + +def _fetch_json(url: str, *, timeout: int) -> dict[str, object]: + payload = json.loads(_fetch_text(url, timeout=timeout)) + if not isinstance(payload, dict): + raise RuntimeError(f"registry endpoint returned non-object JSON: {url}") + return payload + + +def probe_registry_propagation(version: str, *, timeout: int) -> dict[str, object]: + npm_url = f"https://registry.npmjs.org/{urllib.parse.quote(NPM_PACKAGE, safe='')}" + pypi_json_url = f"https://pypi.org/pypi/{PYTHON_PACKAGE}/json" + pypi_simple_url = f"https://pypi.org/simple/{PYTHON_PACKAGE}/" + probe: dict[str, object] = { + "version": version, + "npm_package": NPM_PACKAGE, + "python_package": PYTHON_PACKAGE, + "npm_version_present": False, + "npm_latest_version": None, + "pypi_json_version_present": False, + "pypi_simple_mentions_version": False, + "errors": [], + } + errors: list[str] = [] + try: + npm_payload = _fetch_json(npm_url, timeout=timeout) + versions = npm_payload.get("versions") + dist_tags = npm_payload.get("dist-tags") + if isinstance(versions, dict): + probe["npm_version_present"] = version in versions + if isinstance(dist_tags, dict): + latest = dist_tags.get("latest") + if isinstance(latest, str): + probe["npm_latest_version"] = latest + except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + errors.append(f"npm registry probe failed: {exc}") + + try: + pypi_payload = _fetch_json(pypi_json_url, timeout=timeout) + releases = pypi_payload.get("releases") + if isinstance(releases, dict): + probe["pypi_json_version_present"] = version in releases + except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + errors.append(f"PyPI JSON probe failed: {exc}") + + try: + simple_text = _fetch_text(pypi_simple_url, timeout=timeout) + probe["pypi_simple_mentions_version"] = version in simple_text + except (OSError, urllib.error.URLError, TimeoutError) as exc: + errors.append(f"PyPI simple probe failed: {exc}") + + probe["errors"] = errors + return probe + + +def _looks_like_propagation_failure(exc: Exception) -> bool: + text = str(exc).lower() + patterns = [ + "no solution found", + "no matching distribution found", + "could not find a version", + "not found in the package registry", + "npm error code e404", + "404 not found", + "notarget", + "no matching version found", + ] + if any(pattern in text for pattern in patterns): + return True + return bool(re.search(r"cafitac-agent-memory==\d+\.\d+\.\d+", text)) + + +def _retry_delay(attempt: int, *, base_delay_seconds: int, propagation_retry: bool) -> int: + if not propagation_retry: + return base_delay_seconds + return base_delay_seconds * (2 ** max(0, attempt - 1)) + + def build_command_matrix(version: str, *, include_pipx: bool = True, python_executable: str | None = None) -> list[SmokeCommand]: npm_spec = f"{NPM_PACKAGE}@{version}" python_spec = f"{PYTHON_PACKAGE}=={version}" @@ -236,19 +322,44 @@ def _run_once(version: str, *, timeout: int, include_pipx: bool) -> dict[str, ob } -def run_with_retries(version: str, *, attempts: int, delay_seconds: int, timeout: int, include_pipx: bool) -> dict[str, object]: +def run_with_retries( + version: str, + *, + attempts: int, + delay_seconds: int, + timeout: int, + include_pipx: bool, + propagation_attempts: int | None = None, + propagation_delay_seconds: int | None = None, +) -> dict[str, object]: + max_attempts = max(attempts, propagation_attempts or attempts) + normal_attempts_remaining = attempts + propagation_retry_used = False failures: list[str] = [] - for attempt in range(1, attempts + 1): + for attempt in range(1, max_attempts + 1): try: summary = _run_once(version, timeout=timeout, include_pipx=include_pipx) summary["attempt"] = attempt - summary["attempts"] = attempts + summary["attempts"] = max_attempts if propagation_retry_used else attempts + summary["propagation_retry_used"] = propagation_retry_used return summary except Exception as exc: # noqa: BLE001 - CLI should preserve the full smoke failure. + propagation_failure = _looks_like_propagation_failure(exc) + if propagation_failure and propagation_attempts is not None: + propagation_retry_used = True + elif normal_attempts_remaining <= 1: + failures.append(f"attempt {attempt}: {exc}") + break + normal_attempts_remaining -= 1 failures.append(f"attempt {attempt}: {exc}") - if attempt == attempts: + if attempt == max_attempts: break - time.sleep(delay_seconds) + delay = _retry_delay( + attempt, + base_delay_seconds=propagation_delay_seconds or delay_seconds, + propagation_retry=propagation_retry_used and propagation_failure, + ) + time.sleep(delay) raise PublishedSmokeFailure(failures) @@ -262,6 +373,18 @@ def main(argv: Sequence[str] | None = None) -> int: parser.add_argument("--version", default=None, help="Package version to verify. Defaults to package.json version.") parser.add_argument("--attempts", type=int, default=3, help="Retry attempts for registry propagation.") parser.add_argument("--delay-seconds", type=int, default=10, help="Delay between retry attempts.") + parser.add_argument( + "--propagation-attempts", + type=int, + default=None, + help="Longer retry budget used only for package registry propagation-like resolver failures.", + ) + parser.add_argument( + "--propagation-delay-seconds", + type=int, + default=30, + help="Base exponential backoff delay for propagation-like resolver failures.", + ) parser.add_argument("--timeout", type=int, default=180, help="Timeout per command in seconds.") parser.add_argument("--skip-pipx", action="store_true", help="Skip pipx smoke commands when the runner cannot provide pipx.") parser.add_argument("--output-json", type=Path, help="Write a success or failure summary artifact to this path.") @@ -276,13 +399,17 @@ def main(argv: Sequence[str] | None = None) -> int: delay_seconds=args.delay_seconds, timeout=args.timeout, include_pipx=not args.skip_pipx, + propagation_attempts=args.propagation_attempts, + propagation_delay_seconds=args.propagation_delay_seconds, ) except PublishedSmokeFailure as exc: + registry_probe = probe_registry_propagation(version, timeout=min(args.timeout, 30)) failure_summary: dict[str, object] = { "status": "failed", "version": version, - "attempts": args.attempts, + "attempts": args.propagation_attempts or args.attempts, "failures": exc.failures, + "registry_probe": registry_probe, } if args.output_json is not None: _write_json_artifact(args.output_json, failure_summary) diff --git a/src/agent_memory/api/cli.py b/src/agent_memory/api/cli.py index 6f8e393..d23fd40 100644 --- a/src/agent_memory/api/cli.py +++ b/src/agent_memory/api/cli.py @@ -49,6 +49,7 @@ list_fact_replacement_relations, list_facts_by_claim_slot, list_memory_status_history, + list_relations_for_node, ) @@ -121,6 +122,60 @@ def _status_counts_for_facts(facts) -> dict[str, int]: return counts +def _inspect_relation_graph(db_path: Path, *, start_ref: str, depth: int, limit: int) -> dict[str, Any]: + if depth < 0: + raise ValueError("graph inspect depth must be >= 0") + if limit < 1: + raise ValueError("graph inspect limit must be >= 1") + nodes: list[str] = [start_ref] + seen_nodes = {start_ref} + seen_edge_ids: set[int] = set() + edges: list[dict[str, Any]] = [] + frontier = [start_ref] + truncated = False + for current_depth in range(1, depth + 1): + next_frontier: list[str] = [] + for node_ref in frontier: + for relation in list_relations_for_node(db_path, node_ref=node_ref): + if relation.id in seen_edge_ids: + continue + if len(edges) >= limit: + truncated = True + break + seen_edge_ids.add(relation.id) + if relation.from_ref == node_ref: + neighbor_ref = relation.to_ref + direction = "outbound" + else: + neighbor_ref = relation.from_ref + direction = "inbound" + if neighbor_ref not in seen_nodes: + seen_nodes.add(neighbor_ref) + nodes.append(neighbor_ref) + next_frontier.append(neighbor_ref) + edge_payload = relation.model_dump(mode="json") + edge_payload["depth"] = current_depth + edge_payload["via_ref"] = node_ref + edge_payload["neighbor_ref"] = neighbor_ref + edge_payload["direction_from_start"] = direction + edges.append(edge_payload) + if truncated: + break + if truncated or not next_frontier: + break + frontier = next_frontier + return { + "kind": "relation_graph_inspection", + "start_ref": start_ref, + "depth": depth, + "limit": limit, + "read_only": True, + "nodes": nodes, + "edges": edges, + "truncated": truncated, + } + + def _retrieve_packet_for_prompt(args: argparse.Namespace): return retrieve_memory_packet( db_path=args.db_path, @@ -362,6 +417,14 @@ def _build_parser() -> argparse.ArgumentParser: help="Memory status to retrieve. Defaults to approved; use all for forensic/debug review.", ) + graph_parser = subparsers.add_parser("graph") + graph_subparsers = graph_parser.add_subparsers(dest="graph_action", required=True) + graph_inspect_parser = graph_subparsers.add_parser("inspect") + graph_inspect_parser.add_argument("db_path", type=Path) + graph_inspect_parser.add_argument("start_ref") + graph_inspect_parser.add_argument("--depth", type=int, default=1) + graph_inspect_parser.add_argument("--limit", type=int, default=100) + eval_parser = subparsers.add_parser("eval") eval_subparsers = eval_parser.add_subparsers(dest="eval_action", required=True) eval_retrieval_parser = eval_subparsers.add_parser("retrieval") @@ -756,6 +819,17 @@ def main() -> None: print(packet.model_dump_json(indent=2)) return + if args.command == "graph": + if args.graph_action == "inspect": + print( + json.dumps( + _inspect_relation_graph(args.db_path, start_ref=args.start_ref, depth=args.depth, limit=args.limit), + indent=2, + ) + ) + return + raise ValueError(f"Unsupported graph action: {args.graph_action}") + if args.command == "eval": if args.eval_action == "retrieval": try: diff --git a/src/agent_memory/storage/sqlite.py b/src/agent_memory/storage/sqlite.py index 17eb47f..c08633e 100644 --- a/src/agent_memory/storage/sqlite.py +++ b/src/agent_memory/storage/sqlite.py @@ -346,6 +346,20 @@ def get_fact(db_path: Path | str, *, fact_id: int) -> Fact: return fact_from_row(row) +def list_relations_for_node(db_path: Path | str, *, node_ref: str) -> list[Relation]: + with connect(db_path) as connection: + rows = connection.execute( + """ + SELECT * + FROM relations + WHERE from_ref = ? OR to_ref = ? + ORDER BY id ASC + """, + (node_ref, node_ref), + ).fetchall() + return [relation_from_row(row) for row in rows] + + def list_fact_replacement_relations(db_path: Path | str, *, fact_id: int) -> list[Relation]: fact_ref = f"fact:{fact_id}" with connect(db_path) as connection: diff --git a/tests/test_cli.py b/tests/test_cli.py index 25cccc2..5d3da27 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,7 +8,66 @@ from agent_memory.core.curation import approve_fact, create_candidate_fact from agent_memory.core.ingestion import ingest_source_text from agent_memory.integrations.hermes_hooks import scope_from_cwd -from agent_memory.storage.sqlite import initialize_database +from agent_memory.storage.sqlite import initialize_database, insert_relation + + +def test_python_module_cli_graph_inspect_returns_read_only_relation_neighborhood(tmp_path: Path) -> None: + db_path = tmp_path / "graph-inspect.db" + initialize_database(db_path) + first = insert_relation( + db_path, + from_ref="fact:1", + relation_type="superseded_by", + to_ref="fact:2", + evidence_ids=[11], + confidence=0.9, + ) + second = insert_relation( + db_path, + from_ref="fact:2", + relation_type="supports", + to_ref="procedure:7", + evidence_ids=[12], + confidence=0.8, + ) + insert_relation( + db_path, + from_ref="episode:3", + relation_type="mentions", + to_ref="fact:99", + evidence_ids=[], + ) + + env = {**os.environ, "PYTHONPATH": "src"} + result = subprocess.run( + [ + sys.executable, + "-m", + "agent_memory.api.cli", + "graph", + "inspect", + str(db_path), + "fact:1", + "--depth", + "2", + ], + cwd=Path(__file__).resolve().parents[1], + env=env, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["kind"] == "relation_graph_inspection" + assert payload["start_ref"] == "fact:1" + assert payload["depth"] == 2 + assert payload["read_only"] is True + assert payload["nodes"] == ["fact:1", "fact:2", "procedure:7"] + assert [edge["id"] for edge in payload["edges"]] == [first.id, second.id] + assert payload["edges"][0]["direction_from_start"] == "outbound" + assert payload["edges"][1]["direction_from_start"] == "outbound" + assert payload["truncated"] is False def test_cli_init_creates_database(tmp_path: Path, monkeypatch) -> None: diff --git a/tests/test_published_install_smoke.py b/tests/test_published_install_smoke.py index 4b991b8..c4c78f2 100644 --- a/tests/test_published_install_smoke.py +++ b/tests/test_published_install_smoke.py @@ -140,12 +140,16 @@ def test_published_install_workflow_runs_script_after_publish() -> None: assert "published-install-smoke" in workflow assert "scripts/smoke_published_install.py" in workflow assert "--output-json .artifacts/published-install-smoke.json" in workflow + assert "--propagation-attempts" in workflow + assert "--propagation-delay-seconds" in workflow assert "actions/upload-artifact" in workflow assert "published-install-smoke-result" in workflow assert "needs:" in workflow assert "publish-pypi" in workflow assert "publish-npm" in workflow - assert "--attempts 36" in workflow + assert "--attempts 12" in workflow + assert "--propagation-attempts 36" in workflow + assert "--propagation-delay-seconds 20" in workflow def test_standalone_published_install_workflow_is_manual() -> None: @@ -158,6 +162,8 @@ def test_standalone_published_install_workflow_is_manual() -> None: assert "default: '18'" in workflow assert "uv pip install pipx" in workflow assert "--output-json .artifacts/published-install-smoke.json" in workflow + assert "--propagation-attempts" in workflow + assert "--propagation-delay-seconds" in workflow assert "actions/upload-artifact" in workflow @@ -188,6 +194,80 @@ def fail_once(*_args: object, **_kwargs: object) -> dict[str, object]: assert "attempt 2: registry not ready" in str(error.value) +def test_run_with_retries_waits_longer_for_propagation_failures(monkeypatch: pytest.MonkeyPatch) -> None: + calls = 0 + sleeps: list[int] = [] + + def flaky_propagation(*_args: object, **_kwargs: object) -> dict[str, object]: + nonlocal calls + calls += 1 + if calls < 3: + raise RuntimeError("No solution found when resolving dependencies: cafitac-agent-memory==1.2.3") + return {"version": "1.2.3"} + + monkeypatch.setattr(smoke_published_install, "_run_once", flaky_propagation) + monkeypatch.setattr(smoke_published_install.time, "sleep", lambda seconds: sleeps.append(seconds)) + + summary = smoke_published_install.run_with_retries( + "1.2.3", + attempts=2, + delay_seconds=10, + timeout=1, + include_pipx=False, + propagation_attempts=4, + propagation_delay_seconds=30, + ) + + assert summary["attempt"] == 3 + assert summary["attempts"] == 4 + assert summary["propagation_retry_used"] is True + assert sleeps == [30, 60] + + +def test_published_install_failure_artifact_includes_registry_probe_diagnostics( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + def fail_once(*_args: object, **_kwargs: object) -> dict[str, object]: + raise RuntimeError("No matching distribution found for cafitac-agent-memory==1.2.3") + + def fake_probe(version: str, *, timeout: int) -> dict[str, object]: + return { + "version": version, + "timeout": timeout, + "npm_version": "1.2.3", + "pypi_json_version_present": True, + "pypi_simple_mentions_version": False, + } + + output_path = tmp_path / "published-install-smoke.json" + monkeypatch.setattr(smoke_published_install, "_run_once", fail_once) + monkeypatch.setattr(smoke_published_install, "probe_registry_propagation", fake_probe) + monkeypatch.setattr(smoke_published_install.time, "sleep", lambda _seconds: None) + + exit_code = smoke_published_install.main( + [ + "--version", + "1.2.3", + "--attempts", + "1", + "--propagation-attempts", + "1", + "--delay-seconds", + "0", + "--output-json", + str(output_path), + ] + ) + + captured = capsys.readouterr() + payload = json.loads(output_path.read_text()) + assert exit_code == 1 + assert "published install smoke failed" in captured.err + assert payload["registry_probe"]["npm_version"] == "1.2.3" + assert payload["registry_probe"]["pypi_json_version_present"] is True + assert payload["registry_probe"]["pypi_simple_mentions_version"] is False + + def test_published_install_cli_writes_failure_artifact(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: def fail_once(*_args: object, **_kwargs: object) -> dict[str, object]: diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index 09fe526..495af55 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -46,6 +46,15 @@ def test_auto_release_fallback_is_idempotent_when_release_sync_branch_or_pr_exis assert "gh pr create" in workflow +def test_auto_release_fallback_dispatches_release_sync_ci_validation() -> None: + workflow = (PROJECT_ROOT / ".github" / "workflows" / "auto-release.yml").read_text() + + assert "gh workflow run ci.yml --ref \"${RELEASE_SYNC_BRANCH}\"" in workflow + assert "release_sync_pr_url" in workflow + assert "Release sync validation CI was dispatched" in workflow + assert "wait for the dispatched `ci.yml` run before merging" in workflow + + def test_publish_workflow_remains_tag_driven_only() -> None: workflow = (PROJECT_ROOT / ".github" / "workflows" / "publish.yml").read_text()