feat(activity): multi-service config, make help default, install-cli
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:
parent
9f4993d9e7
commit
192942244e
8 changed files with 755 additions and 49 deletions
67
Makefile
67
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/
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
232
src/activity_core/review_cli/services.py
Normal file
232
src/activity_core/review_cli/services.py
Normal 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()),
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
127
tests/test_review_cli_services.py
Normal file
127
tests/test_review_cli_services.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue