feat(activity): multi-service config, make help default, install-cli
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 21s

Bare make lists targets. make install-cli installs the activity tool via uv.
Named activity-core backends live in ~/.config/activity/services.json with
list/add/use/default/which; -s/--service selects one call without changing
the default; --activity-url is a one-shot override.
This commit is contained in:
tegwick 2026-08-06 17:25:54 +02:00
parent 9f4993d9e7
commit 192942244e
8 changed files with 755 additions and 49 deletions

View file

@ -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/

View file

@ -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`.

View file

@ -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.

View file

@ -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:

View file

@ -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()),
}

View file

@ -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")

View file

@ -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",
]

View file

@ -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"