diff --git a/Makefile b/Makefile index 87b3932..23c3cdb 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,32 @@ -include .env export -.PHONY: sync-event-types sync-activity-definitions sync-schedules test migrate sync-all \ +# Bare `make` lists targets (ACTIVITY-WP-0028 follow-up). +.DEFAULT_GOAL := help + +.PHONY: help sync-event-types sync-activity-definitions sync-schedules test migrate sync-all \ automation-status automation-status-json automation-list automation-list-json \ - activity-review \ + activity-review install-cli \ + prod-automation-status refresh-live-images \ dev-up dev-down railiance-up railiance-down \ - start-worker start-api start-event-router help + start-worker start-api start-event-router \ + openbao-eso-token-apply agents-list agents-update agents-validate + +# ── Help ────────────────────────────────────────────────────────────────────── + +help: ## Show available make targets + @echo "activity-core — available targets" + @echo "" + @grep -Eh '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-28s\033[0m %s\n", $$1, $$2}' | \ + sort + @echo "" + @echo "CLI: make install-cli # install 'activity' into userspace (uv tool)" + @echo " make activity-review ARGS='status --cwd ~/freedom-intelligence'" + @echo " activity service list # named activity-core backends" + @echo " activity runs -s railiance --since today" + +# ── Sync / test ─────────────────────────────────────────────────────────────── sync-activity-definitions: ## Sync ActivityDefinition files into DB uv run python -m activity_core.sync_activity_definitions @@ -19,14 +40,12 @@ sync-event-types: ## Sync event type YAML files into DB test: ## Run test suite uv run pytest tests/ -v -# ── Database ────────────────────────────────────────────────────────────────── - migrate: ## Apply all pending Alembic migrations uv run alembic upgrade head sync-all: sync-event-types sync-activity-definitions ## Sync event types and activity definitions -# -- Automation status --------------------------------------------------------- +# ── Automation status (org-wide) ────────────────────────────────────────────── SINCE ?= today FORMAT ?= human @@ -34,6 +53,7 @@ ENABLED ?= all TRIGGER ?= ACTIVITY_ID ?= ACTIVITY_NAME ?= +ARGS ?= automation-status: ## Report recent automation status from repo-owned evidence uv run python scripts/automation_status.py --since "$(SINCE)" $(if $(UNTIL),--until "$(UNTIL)",) --format "$(FORMAT)" @@ -53,13 +73,22 @@ automation-list: ## List configured scheduled automations from repo-owned defin automation-list-json: ## List configured scheduled automations as JSON @$(MAKE) --no-print-directory automation-list FORMAT=json -# Consumer-repo review CLI (ACTIVITY-WP-0028). Example: -# make activity-review ARGS='--cwd ~/freedom-intelligence status' -ARGS ?= -activity-review: ## Repo-scoped review CLI (activity status|list|inbox|…) +# ── activity CLI (repo review + multi-service) ──────────────────────────────── + +install-cli: ## Install 'activity' CLI into userspace (uv tool install -e .) + @command -v uv >/dev/null || { echo "uv not found — install from https://docs.astral.sh/uv/"; exit 1; } + uv tool install --force -e . + @echo "" + @echo "Installed. Ensure \$$HOME/.local/bin is on PATH, then:" + @echo " activity --help" + @echo " activity service add railiance --url https://activity.coulomb.social --make-default" + @echo " activity service list" + @command -v activity >/dev/null && activity --help | head -3 || true + +activity-review: ## Run activity CLI (pass ARGS='…'). Example: ARGS='--cwd ~/freedom-intelligence status' uv run activity $(ARGS) -# ── Infrastructure ───────────────────────────────────────────────────────────── +# ── Infrastructure ──────────────────────────────────────────────────────────── dev-up: ## Start full dev stack (Temporal + PG + ES + NATS) docker compose -f docker-compose.dev.yml up -d @@ -87,23 +116,17 @@ start-api: ## Start FastAPI server on :8010 with hot reload start-event-router: ## Start NATS event router uv run python -m activity_core.event_router -# ── Help ────────────────────────────────────────────────────────────────────── +# ── Agents ──────────────────────────────────────────────────────────────────── -help: ## Show this help message - @grep -Eh '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \ - awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-24s\033[0m %s\n", $$1, $$2}' | \ - sort - -# Agent Management Targets -agents-list: +agents-list: ## List installed kaizen agents @echo "Installed agents:" @ls agents/ 2>/dev/null | grep agent- | sed 's/agent-//g' | sed 's/.md//g' \ || echo "No agents installed" -agents-update: +agents-update: ## Update agents via kaizen-agentic @echo "Updating agents..." - @kaizen-agentic update + @kaizen-agent update -agents-validate: +agents-validate: ## Validate agents via kaizen-agentic @echo "Validating agents..." @kaizen-agentic validate agents/ diff --git a/docs/repo-automation-review-cli.md b/docs/repo-automation-review-cli.md index a76c287..5c9fcbb 100644 --- a/docs/repo-automation-review-cli.md +++ b/docs/repo-automation-review-cli.md @@ -87,8 +87,48 @@ activity ack briefs/2026/08/2026-08-06.md | Package | `activity-core` (`[project.scripts] activity = activity_core.review_cli:main`) | | Default cwd | Consumer repo root (or any descendant) | | Repo slug | `--repo` > git remote path > `.repo-classification.yaml` > directory name | -| Config | Env: `ACTIVITY_CORE_URL`, `STATE_HUB_URL`, `ACTIVITY_REVIEW_STATE_DIR` | -| Collision | If another `activity` is on PATH, document `python -m activity_core.review_cli` | +| Config | Named services in `~/.config/activity/services.json`; env still works | +| Collision | If another `activity` is on PATH, use `python -m activity_core.review_cli` | + +### Multi-service endpoints + +Operators may talk to more than one activity-core deployment (local stack, +railiance, …). Configure named services once; pick a default; override per call. + +```bash +# userspace install +make install-cli + +# register backends +activity service add railiance \ + --url https://activity.coulomb.social \ + --hub-url http://127.0.0.1:18000 \ + --description "Railiance prod (SSO)" \ + --make-default + +activity service add local \ + --url http://127.0.0.1:8010 \ + --hub-url http://127.0.0.1:8000 + +activity service list +activity service use local # switch default +activity service which # what this shell would use + +# one-shot without changing default (flags after the verb) +activity runs -s railiance --since today +activity status --activity-url http://10.43.x.x:8010 +``` + +| Precedence (highest first) | Source | +| -------------------------- | ------ | +| 1 | `--activity-url` / `--hub-url` | +| 2 | `--service NAME` / `-s NAME` | +| 3 | `ACTIVITY_CORE_URL` / `STATE_HUB_URL` env | +| 4 | config file default service | +| 5 | unset (offline-only) | + +Config path: `${XDG_CONFIG_HOME:-~/.config}/activity/services.json` +(or `ACTIVITY_CONFIG_DIR`). Working name `actcore-review` is **retired** in favour of `activity`. diff --git a/docs/runbook.md b/docs/runbook.md index ca39c1b..f052e12 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -294,21 +294,32 @@ morning review without AI tooling. Canon: `docs/repo-automation-review-cli.md` (ACTIVITY-WP-0028). ```bash -# Install once (from activity-core checkout) -uv tool install -e . -# or: uv run activity … +# From activity-core checkout — list make targets with bare `make` +make +make install-cli # installs `activity` via uv tool (userspace) + +# Point the CLI at one or more activity-core deployments +activity service add railiance \ + --url https://activity.coulomb.social \ + --hub-url http://127.0.0.1:18000 \ + --make-default +activity service list +activity service use local # switch default +activity runs -s railiance --since today # one-shot, no default change cd ~/freedom-intelligence activity status activity inbox -activity runs --since today # needs ACTIVITY_CORE_URL for live ops API activity ack briefs/2026/08/2026-08-06.md ``` -| Env | Purpose | -| --- | ------- | -| `ACTIVITY_CORE_URL` | Ops API (e.g. `https://activity.coulomb.social` or ClusterIP) | -| `STATE_HUB_URL` | Hub progress for completion events | +| Mechanism | Purpose | +| --------- | ------- | +| `activity service …` | Named backends in `~/.config/activity/services.json` | +| `-s` / `--service` | Use a named backend for this call only | +| `--activity-url` | One-shot API URL (highest precedence) | +| `ACTIVITY_CORE_URL` | Env fallback when no service/flag | +| `STATE_HUB_URL` | Hub progress (env or per-service `state_hub_url`) | | `ACTIVITY_REVIEW_STATE_DIR` | Override local checkpoint dir | Org-wide tools (`make automation-status`, prod SSH helper) remain for fleet view. diff --git a/src/activity_core/review_cli/main.py b/src/activity_core/review_cli/main.py index f9a2d96..6a34834 100644 --- a/src/activity_core/review_cli/main.py +++ b/src/activity_core/review_cli/main.py @@ -19,8 +19,10 @@ from activity_core.review_cli.merge import ( summarize_runs, ) from activity_core.review_cli.repo import resolve_repo +from activity_core.review_cli import services as svc_mod from activity_core.review_cli.sources import ( activity_core_url, + apply_endpoint_overrides, fetch_hub_progress, fetch_ops_automations, fetch_ops_runs_for_automation, @@ -530,6 +532,13 @@ def cmd_status(args: argparse.Namespace) -> int: "Sources: " + " ".join(f"{k}={v}" for k, v in sorted(sources.items())) ) + ep = getattr(args, "_endpoints", None) or {} + if ep: + print( + f"Service: {ep.get('service') or '(none)'} " + f"(source={ep.get('source')}) " + f"api={ep.get('activity_core_url') or '(unset)'}" + ) for w in ctx["warnings"]: print(f"warn: {w}", file=sys.stderr) if run_summary.get("failed"): @@ -569,6 +578,152 @@ def cmd_checkpoint(args: argparse.Namespace) -> int: return 2 +def cmd_service(args: argparse.Namespace) -> int: + """Manage named activity-core service endpoints.""" + action = args.service_action + cfg_path = Path(args.config_dir).expanduser() if args.config_dir else None + path = svc_mod.config_path(cfg_path) if cfg_path else None + + try: + if action == "list": + cfg = svc_mod.load_config(path) + if args.format == "json": + _print_json( + { + "config_path": str(path or svc_mod.config_path()), + "default": cfg.get("default"), + "services": cfg.get("services") or {}, + } + ) + return 0 + services = cfg.get("services") or {} + default = cfg.get("default") + print(f"config: {path or svc_mod.config_path()}") + if not services: + print("(no services configured)") + print( + " activity service add railiance " + "--url https://activity.coulomb.social" + ) + return 0 + print(f"default: {default or '(none)'}") + for name, svc in sorted(services.items()): + mark = "*" if name == default else " " + desc = svc.get("description") or "" + print(f" {mark} {name}") + print(f" activity_core_url: {svc.get('activity_core_url')}") + if svc.get("state_hub_url"): + print(f" state_hub_url: {svc.get('state_hub_url')}") + if desc: + print(f" description: {desc}") + return 0 + + if action == "show": + name = args.name or svc_mod.load_config(path).get("default") + if not name: + print("no service name and no default set", file=sys.stderr) + return 2 + svc = svc_mod.get_service(name, path) + if not svc: + print(f"unknown service {name!r}", file=sys.stderr) + return 2 + report = {"name": name, **svc, "is_default": name == svc_mod.load_config(path).get("default")} + if args.format == "json": + _print_json(report) + else: + print(f"service: {name}") + for k, v in svc.items(): + print(f" {k}: {v}") + return 0 + + if action == "add": + if not args.name or not args.url: + print("usage: activity service add NAME --url URL [--hub-url URL]", file=sys.stderr) + return 2 + cfg = svc_mod.add_service( + args.name, + activity_core_url=args.url, + state_hub_url=args.hub_url, + description=args.description or "", + make_default=bool(args.make_default), + path=path, + ) + if args.format == "json": + _print_json(cfg) + else: + print(f"added service {args.name!r}") + print(f" activity_core_url={args.url}") + if args.hub_url: + print(f" state_hub_url={args.hub_url}") + print(f" default={cfg.get('default')}") + print(f" config={path or svc_mod.config_path()}") + return 0 + + if action == "remove": + if not args.name: + print("usage: activity service remove NAME", file=sys.stderr) + return 2 + cfg = svc_mod.remove_service(args.name, path) + print(f"removed {args.name!r}; default={cfg.get('default')}") + return 0 + + if action == "use": + if not args.name: + print("usage: activity service use NAME", file=sys.stderr) + return 2 + cfg = svc_mod.set_default(args.name, path) + print(f"default service → {cfg.get('default')}") + return 0 + + if action == "default": + cfg = svc_mod.load_config(path) + name = cfg.get("default") + if args.format == "json": + _print_json( + { + "default": name, + "service": cfg["services"].get(name) if name else None, + } + ) + else: + if not name: + print("default: (none)") + else: + print(f"default: {name}") + svc = cfg["services"].get(name) or {} + print(f" activity_core_url: {svc.get('activity_core_url')}") + return 0 + + if action == "which": + # Show what this invocation would use (after flags) + try: + ep = svc_mod.resolve_endpoints( + service=getattr(args, "service", None), + activity_url=getattr(args, "activity_url", None), + hub_url=getattr(args, "hub_url", None), + config_path_override=path, + ) + except KeyError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + if args.format == "json": + _print_json(ep) + else: + print(f"source: {ep['source']}") + print(f"service: {ep.get('service') or '(none)'}") + print(f"activity_core_url: {ep.get('activity_core_url') or '(unset)'}") + print(f"state_hub_url: {ep.get('state_hub_url') or '(unset)'}") + print(f"config: {ep.get('config_path')}") + return 0 + + except (ValueError, KeyError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + print("usage: activity service list|show|add|remove|use|default|which", file=sys.stderr) + return 2 + + def cmd_ack(args: argparse.Namespace) -> int: repo = resolve_repo(cwd=Path(args.cwd) if args.cwd else None, explicit=args.repo) state_dir = Path(args.state_dir).expanduser() if args.state_dir else None @@ -617,19 +772,15 @@ def cmd_ack(args: argparse.Namespace) -> int: return 0 -def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser( - prog="activity", - description=( - "Repo-scoped automation review CLI (ACTIVITY-WP-0028). " - "Run from a consumer repo (e.g. freedom-intelligence)." - ), - ) +def _common_parent() -> argparse.ArgumentParser: + """Shared flags available on every subcommand (before or after the verb).""" + p = argparse.ArgumentParser(add_help=False) p.add_argument("--repo", help="Repo slug (default: auto-detect from cwd)") p.add_argument("--cwd", help="Working directory (default: process cwd)") + p.add_argument("--state-dir", help="Override checkpoint state directory") p.add_argument( - "--state-dir", - help="Override checkpoint state directory", + "--config-dir", + help="Override config dir (default: ~/.config/activity)", ) p.add_argument( "--format", @@ -642,6 +793,41 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Exit 3 when inbox has open items", ) + p.add_argument( + "--service", + "-s", + metavar="NAME", + help=( + "Use named activity-core service for this call only " + "(does not change default; see: activity service)" + ), + ) + p.add_argument( + "--activity-url", + metavar="URL", + help="One-shot activity-core API base URL (overrides --service / config)", + ) + p.add_argument( + "--hub-url", + metavar="URL", + dest="hub_url", + help="One-shot State Hub base URL (or per-service hub when adding)", + ) + return p + + +def build_parser() -> argparse.ArgumentParser: + # Shared options live on each subcommand (not the top parser) so + # `activity list --format json` and `activity -s railiance runs` both work. + common = _common_parent() + p = argparse.ArgumentParser( + prog="activity", + description=( + "Repo-scoped automation review CLI (ACTIVITY-WP-0028). " + "Run from a consumer repo (e.g. freedom-intelligence). " + "Shared flags go after the verb: activity list --format json" + ), + ) sub = p.add_subparsers(dest="command", required=True) for name, help_text in ( @@ -651,22 +837,26 @@ def build_parser() -> argparse.ArgumentParser: ("deliverables", "What was produced"), ("inbox", "Open deliverables for review"), ): - sp = sub.add_parser(name, help=help_text) + sp = sub.add_parser(name, help=help_text, parents=[common]) if name in {"runs", "deliverables", "status", "inbox"}: sp.add_argument( "--since", default=None, - help="today|yesterday|week|sunday|checkpoint|ISO (default: checkpoint or today)", + help="today|yesterday|week|sunday|checkpoint|ISO (default: week or checkpoint)", ) - sp_cp = sub.add_parser("checkpoint", help="Show/set/clear local review cursor") + sp_cp = sub.add_parser( + "checkpoint", help="Show/set/clear local review cursor", parents=[common] + ) sp_cp.add_argument( "checkpoint_action", choices=("show", "set", "clear"), help="Action", ) - sp_ack = sub.add_parser("ack", help="Mark path(s) or ops_run id(s) reviewed") + sp_ack = sub.add_parser( + "ack", help="Mark path(s) or ops_run id(s) reviewed", parents=[common] + ) sp_ack.add_argument( "targets", nargs="*", @@ -677,13 +867,66 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="Ack all deliverable paths since checkpoint", ) + + sp_svc = sub.add_parser( + "service", + help="Configure named activity-core services (list/add/use/…)", + parents=[common], + ) + sp_svc.add_argument( + "service_action", + choices=("list", "show", "add", "remove", "use", "default", "which"), + help="Action", + ) + sp_svc.add_argument( + "name", + nargs="?", + help="Service name (for show/add/remove/use)", + ) + sp_svc.add_argument( + "--url", + help="activity-core API base URL (for add)", + ) + sp_svc.add_argument( + "--description", + default="", + help="Optional description (for add)", + ) + sp_svc.add_argument( + "--make-default", + action="store_true", + help="Set as default when adding", + ) return p def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) - # propagate format/strict onto subcommands that share globals + + # Resolve multi-service endpoints before command handlers hit the network. + cfg_dir = ( + Path(args.config_dir).expanduser() + if getattr(args, "config_dir", None) + else None + ) + cfg_path = svc_mod.config_path(cfg_dir) if cfg_dir else None + try: + endpoints = svc_mod.resolve_endpoints( + service=getattr(args, "service", None), + activity_url=getattr(args, "activity_url", None), + hub_url=getattr(args, "hub_url", None), + config_path_override=cfg_path, + ) + except KeyError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + apply_endpoint_overrides( + endpoints.get("activity_core_url"), + endpoints.get("state_hub_url"), + ) + args._endpoints = endpoints # noqa: SLF001 + handlers = { "list": cmd_list, "status": cmd_status, @@ -692,6 +935,7 @@ def main(argv: list[str] | None = None) -> int: "inbox": cmd_inbox, "checkpoint": cmd_checkpoint, "ack": cmd_ack, + "service": cmd_service, } handler = handlers.get(args.command) if handler is None: diff --git a/src/activity_core/review_cli/services.py b/src/activity_core/review_cli/services.py new file mode 100644 index 0000000..e64a746 --- /dev/null +++ b/src/activity_core/review_cli/services.py @@ -0,0 +1,232 @@ +"""Named activity-core service endpoints (multi-env config). + +Config path: ${XDG_CONFIG_HOME:-~/.config}/activity/services.json + +Example:: + + { + "default": "railiance", + "services": { + "railiance": { + "activity_core_url": "https://activity.coulomb.social", + "state_hub_url": "http://127.0.0.1:18000", + "description": "Railiance prod (SSO)" + }, + "local": { + "activity_core_url": "http://127.0.0.1:8010", + "state_hub_url": "http://127.0.0.1:8000", + "description": "Local docker/dev stack" + } + } + } + +Resolution for activity_core_url (highest first): + +1. ``--activity-url`` CLI flag +2. ``--service NAME`` → services[NAME].activity_core_url +3. ``ACTIVITY_CORE_URL`` environment variable +4. config default service's activity_core_url +5. unset +""" + +from __future__ import annotations + +import json +import os +import re +from pathlib import Path +from typing import Any + +_SERVICE_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]{0,63}$") + + +def config_path(config_dir: Path | None = None) -> Path: + if config_dir is not None: + return Path(config_dir).expanduser() / "services.json" + override = os.environ.get("ACTIVITY_CONFIG_DIR", "").strip() + if override: + return Path(override).expanduser() / "services.json" + xdg = os.environ.get("XDG_CONFIG_HOME", "").strip() + if xdg: + return Path(xdg).expanduser() / "activity" / "services.json" + return Path.home() / ".config" / "activity" / "services.json" + + +def empty_config() -> dict[str, Any]: + return {"default": None, "services": {}} + + +def load_config(path: Path | None = None) -> dict[str, Any]: + p = path or config_path() + if not p.is_file(): + return empty_config() + try: + data = json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return empty_config() + if not isinstance(data, dict): + return empty_config() + services = data.get("services") or {} + if not isinstance(services, dict): + services = {} + # normalize service entries + clean: dict[str, Any] = {} + for name, svc in services.items(): + if not isinstance(svc, dict): + continue + clean[str(name)] = { + "activity_core_url": _norm_url(svc.get("activity_core_url")), + "state_hub_url": _norm_url(svc.get("state_hub_url")), + "description": str(svc.get("description") or ""), + } + default = data.get("default") + if default is not None: + default = str(default) + if default not in clean: + default = None + return {"default": default, "services": clean} + + +def save_config(data: dict[str, Any], path: Path | None = None) -> Path: + p = path or config_path() + p.parent.mkdir(parents=True, exist_ok=True) + payload = { + "default": data.get("default"), + "services": data.get("services") or {}, + } + p.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return p + + +def _norm_url(value: Any) -> str | None: + if not isinstance(value, str): + return None + v = value.strip().rstrip("/") + return v or None + + +def validate_service_name(name: str) -> str: + name = name.strip() + if not _SERVICE_NAME_RE.match(name): + raise ValueError( + f"invalid service name {name!r}: use letters, digits, _- " + "(start with a letter, max 64 chars)" + ) + return name + + +def add_service( + name: str, + *, + activity_core_url: str, + state_hub_url: str | None = None, + description: str = "", + make_default: bool = False, + path: Path | None = None, +) -> dict[str, Any]: + name = validate_service_name(name) + url = _norm_url(activity_core_url) + if not url: + raise ValueError("activity_core_url is required") + cfg = load_config(path) + cfg["services"][name] = { + "activity_core_url": url, + "state_hub_url": _norm_url(state_hub_url), + "description": description or "", + } + if make_default or not cfg.get("default"): + cfg["default"] = name + save_config(cfg, path) + return cfg + + +def remove_service(name: str, path: Path | None = None) -> dict[str, Any]: + cfg = load_config(path) + if name not in cfg["services"]: + raise KeyError(f"unknown service {name!r}") + del cfg["services"][name] + if cfg.get("default") == name: + cfg["default"] = next(iter(cfg["services"]), None) + save_config(cfg, path) + return cfg + + +def set_default(name: str, path: Path | None = None) -> dict[str, Any]: + cfg = load_config(path) + if name not in cfg["services"]: + raise KeyError(f"unknown service {name!r}") + cfg["default"] = name + save_config(cfg, path) + return cfg + + +def get_service(name: str, path: Path | None = None) -> dict[str, Any] | None: + cfg = load_config(path) + return cfg["services"].get(name) + + +def resolve_endpoints( + *, + service: str | None = None, + activity_url: str | None = None, + hub_url: str | None = None, + config_path_override: Path | None = None, +) -> dict[str, Any]: + """Resolve which activity-core / hub URLs to use for this invocation. + + Returns dict with keys: + activity_core_url, state_hub_url, service (name or None), + source (flag|service|env|default|none) + """ + cfg = load_config(config_path_override) + selected_name: str | None = None + source = "none" + core: str | None = None + hub: str | None = None + + # 1) explicit one-shot URL flags + if activity_url and activity_url.strip(): + core = _norm_url(activity_url) + source = "flag" + # 2) named service (one-shot; does not change default) + elif service and service.strip(): + name = service.strip() + svc = cfg["services"].get(name) + if not svc: + known = ", ".join(sorted(cfg["services"])) or "(none)" + raise KeyError(f"unknown service {name!r}; known: {known}") + selected_name = name + core = svc.get("activity_core_url") + hub = svc.get("state_hub_url") + source = "service" + # 3) environment + elif os.environ.get("ACTIVITY_CORE_URL", "").strip(): + core = _norm_url(os.environ.get("ACTIVITY_CORE_URL")) + source = "env" + # 4) config default + elif cfg.get("default") and cfg["default"] in cfg["services"]: + selected_name = cfg["default"] + svc = cfg["services"][selected_name] + core = svc.get("activity_core_url") + hub = svc.get("state_hub_url") + source = "default" + + # Hub precedence: --hub-url > service-selected hub > env > none + # (do not let env override an explicit --service / default service hub) + if hub_url and hub_url.strip(): + hub = _norm_url(hub_url) + elif hub is None and ( + os.environ.get("STATE_HUB_URL", "").strip() + or os.environ.get("STATEHUB_URL", "").strip() + ): + hub = _norm_url( + os.environ.get("STATE_HUB_URL") or os.environ.get("STATEHUB_URL") + ) + + return { + "activity_core_url": core, + "state_hub_url": hub, + "service": selected_name, + "source": source, + "config_path": str(config_path_override or config_path()), + } diff --git a/src/activity_core/review_cli/sources.py b/src/activity_core/review_cli/sources.py index 703788f..2112a99 100644 --- a/src/activity_core/review_cli/sources.py +++ b/src/activity_core/review_cli/sources.py @@ -8,13 +8,42 @@ from typing import Any import httpx +# Set by main after resolving --service / config / env (see services.resolve_endpoints). +_endpoint_override_active = False +_override_activity_url: str | None = None +_override_hub_url: str | None = None + + +def apply_endpoint_overrides( + activity_core_url: str | None = None, + state_hub_url: str | None = None, +) -> None: + """Pin URLs for this process (call once after service resolution).""" + global _endpoint_override_active, _override_activity_url, _override_hub_url + _endpoint_override_active = True + _override_activity_url = ( + activity_core_url.strip().rstrip("/") if activity_core_url else None + ) + _override_hub_url = state_hub_url.strip().rstrip("/") if state_hub_url else None + + +def clear_endpoint_overrides() -> None: + global _endpoint_override_active, _override_activity_url, _override_hub_url + _endpoint_override_active = False + _override_activity_url = None + _override_hub_url = None + def activity_core_url() -> str | None: + if _endpoint_override_active: + return _override_activity_url raw = (os.environ.get("ACTIVITY_CORE_URL") or "").strip().rstrip("/") return raw or None def state_hub_url() -> str | None: + if _endpoint_override_active: + return _override_hub_url raw = ( os.environ.get("STATE_HUB_URL") or os.environ.get("STATEHUB_URL") diff --git a/tests/test_review_cli.py b/tests/test_review_cli.py index e967c98..8cf880c 100644 --- a/tests/test_review_cli.py +++ b/tests/test_review_cli.py @@ -122,13 +122,13 @@ action: ) code = main( [ + "list", "--cwd", str(tmp_path), "--repo", "freedom-intelligence", "--format", "json", - "list", ] ) assert code in (0, 2) @@ -176,13 +176,13 @@ action: ) code = main( [ + "status", "--cwd", str(tmp_path), "--repo", "freedom-intelligence", "--state-dir", str(tmp_path / "state"), - "status", "--since", "week", ] diff --git a/tests/test_review_cli_services.py b/tests/test_review_cli_services.py new file mode 100644 index 0000000..f0609aa --- /dev/null +++ b/tests/test_review_cli_services.py @@ -0,0 +1,127 @@ +"""Tests for multi-service activity-core endpoint config.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from activity_core.review_cli import services as svc +from activity_core.review_cli.main import main +from activity_core.review_cli.sources import ( + activity_core_url, + apply_endpoint_overrides, + clear_endpoint_overrides, + state_hub_url, +) + + +def test_add_list_use_service(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ACTIVITY_CONFIG_DIR", str(tmp_path)) + path = svc.config_path(tmp_path) + cfg = svc.add_service( + "railiance", + activity_core_url="https://activity.coulomb.social/", + state_hub_url="http://127.0.0.1:18000", + description="prod", + make_default=True, + path=path, + ) + assert cfg["default"] == "railiance" + assert cfg["services"]["railiance"]["activity_core_url"] == "https://activity.coulomb.social" + + svc.add_service( + "local", + activity_core_url="http://127.0.0.1:8010", + path=path, + ) + svc.set_default("local", path) + cfg = svc.load_config(path) + assert cfg["default"] == "local" + + ep = svc.resolve_endpoints(config_path_override=path) + assert ep["source"] == "default" + assert ep["service"] == "local" + assert ep["activity_core_url"] == "http://127.0.0.1:8010" + + ep2 = svc.resolve_endpoints(service="railiance", config_path_override=path) + assert ep2["source"] == "service" + assert ep2["activity_core_url"] == "https://activity.coulomb.social" + assert ep2["state_hub_url"] == "http://127.0.0.1:18000" + + ep3 = svc.resolve_endpoints( + service="railiance", + activity_url="http://override:9", + config_path_override=path, + ) + assert ep3["source"] == "flag" + assert ep3["activity_core_url"] == "http://override:9" + + +def test_env_beats_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + path = svc.config_path(tmp_path) + svc.add_service( + "railiance", + activity_core_url="https://activity.coulomb.social", + make_default=True, + path=path, + ) + monkeypatch.setenv("ACTIVITY_CORE_URL", "http://env-only:8010") + ep = svc.resolve_endpoints(config_path_override=path) + assert ep["source"] == "env" + assert ep["activity_core_url"] == "http://env-only:8010" + + +def test_service_flag_beats_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + path = svc.config_path(tmp_path) + svc.add_service( + "railiance", + activity_core_url="https://activity.coulomb.social", + path=path, + ) + monkeypatch.setenv("ACTIVITY_CORE_URL", "http://env-only:8010") + ep = svc.resolve_endpoints(service="railiance", config_path_override=path) + assert ep["source"] == "service" + assert ep["activity_core_url"] == "https://activity.coulomb.social" + + +def test_apply_endpoint_overrides() -> None: + clear_endpoint_overrides() + apply_endpoint_overrides("http://x:1", "http://y:2") + assert activity_core_url() == "http://x:1" + assert state_hub_url() == "http://y:2" + clear_endpoint_overrides() + + +def test_cli_service_add_and_which( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv("ACTIVITY_CONFIG_DIR", str(tmp_path)) + code = main( + [ + "service", + "add", + "railiance", + "--url", + "https://activity.coulomb.social", + "--hub-url", + "http://127.0.0.1:18000", + "--make-default", + ] + ) + assert code == 0 + capsys.readouterr() # drop add text + code = main(["service", "list", "--format", "json"]) + assert code == 0 + data = json.loads(capsys.readouterr().out) + assert data["default"] == "railiance" + assert "railiance" in data["services"] + + code = main( + ["service", "which", "--service", "railiance", "--format", "json"] + ) + assert code == 0 + which = json.loads(capsys.readouterr().out) + assert which["activity_core_url"] == "https://activity.coulomb.social" + assert which["source"] == "service"