diff --git a/docs/agent/ops.md b/docs/agent/ops.md index c3d20237..cc22caf8 100644 --- a/docs/agent/ops.md +++ b/docs/agent/ops.md @@ -27,11 +27,11 @@ If a precondition is missing, `./stack` prints exactly what to do. Don't improvi | Command | Idempotent | Destructive | Touches | |---|---|---|---| | `./stack` | yes | no | interactive installer (first-run path) | -| `./stack up ` | yes | no | starts containers, renders `.env`, runs hooks | -| `./stack down ` | yes | no | stops containers; data preserved | +| `./stack up ...` | yes | no | starts containers, renders `.env`, runs hooks | +| `./stack down ...` | yes | no | stops containers; data preserved. Several ids: spaces or commas | | `./stack down all` | yes | no | stops every running stacklet in reverse dep order | -| `./stack restart []` | yes | no | `down` + `up`; with no argument, only the stacklets running stale code | -| `./stack destroy ` | yes | **YES** | removes containers + `~/famstack-data//` + secrets | +| `./stack restart [...]` | yes | no | `down` + `up`; several ids as for `down`; with no argument, only the stacklets running stale code | +| `./stack destroy ...` | yes | **YES** | removes containers + `~/famstack-data//` + secrets | | `./stack uninstall` | yes | **YES, EVERYTHING** | destroys every stacklet, network, all data, config | | `./stack update []` | yes | no | moves the checkout to a release; restarts nothing, prints what to restart | | `./stack list` | yes | no | reports state | diff --git a/docs/stack-reference.md b/docs/stack-reference.md index fbb3e4f6..a91a74b5 100644 --- a/docs/stack-reference.md +++ b/docs/stack-reference.md @@ -526,11 +526,19 @@ effect. means first run. Created after `on_install` completes. Deleted by destroy. -### `stack down ` +### `stack down ...` Transitions from **running** to **stopped**. Data and containers are preserved — `stack up` brings it back without re-running setup. +Several ids stop together, separated by spaces or commas +(`stack down memory code docs` or `stack down memory,code,docs`), in +reverse dependency order. Every id is checked first, so an unknown one +stops nothing. `stack up` and `stack destroy` take ids the same way (up +in dependency order, destroy in reverse, each destroy confirmed on its +own unless `--yes`). `stack restart` stops them all, then starts them in +dependency order. + Use `stack down all` to stop every currently-running stacklet in reverse dependency order (dependents first, deps last). diff --git a/lib/stack/cli.py b/lib/stack/cli.py index e61fa0a7..17c9cc04 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -346,8 +346,22 @@ def _down_all(self) -> dict: untouched. Dependents shut down before their deps so services making outbound calls don't error on a disappearing backend. """ - running = docker.running_project_ids() - order = _reverse_dependency_order(self.stack.discover(), running) + return self._down_ordered(docker.running_project_ids()) + + def down_many(self, stacklet_ids: list[str]) -> dict: + """Stop the named stacklets, dependents first. + + Every id is checked before anything stops, so a typo in one + leaves the rest running rather than half the list stopped. + """ + unknown = self._unknown(stacklet_ids) + if unknown: + return {"ok": False, "error": f"Unknown stacklet: {', '.join(unknown)}", + "stopped": [], "errors": []} + return self._down_ordered(set(stacklet_ids)) + + def _down_ordered(self, include: set[str]) -> dict: + order = _reverse_dependency_order(self.stack.discover(), include) stopped: list[str] = [] errors: list[dict] = [] @@ -375,7 +389,21 @@ def _up_all(self) -> dict: s["id"] for s in self.stack.discover() if self.stack.is_installed(s["id"]) } - order = _dependency_order(self.stack.discover(), installed) + return self._up_ordered(installed) + + def up_many(self, stacklet_ids: list[str]) -> dict: + """Bring up the named stacklets, dependencies first. + + Checked the same way as `down_many`: an unknown id starts nothing. + """ + unknown = self._unknown(stacklet_ids) + if unknown: + return {"ok": False, "error": f"Unknown stacklet: {', '.join(unknown)}", + "started": [], "errors": []} + return self._up_ordered(set(stacklet_ids)) + + def _up_ordered(self, include: set[str]) -> dict: + order = _dependency_order(self.stack.discover(), include) started: list[str] = [] errors: list[dict] = [] @@ -388,6 +416,10 @@ def _up_all(self) -> dict: return {"ok": not errors, "started": started, "errors": errors} + def _unknown(self, stacklet_ids: list[str]) -> list[str]: + known = {s["id"] for s in self.stack.discover()} + return [sid for sid in stacklet_ids if sid not in known] + def destroy(self, stacklet_id: str) -> dict: """Destroy: Docker compose down + Stack.destroy().""" stacklet = self.stack._find_stacklet(stacklet_id) @@ -411,6 +443,22 @@ def destroy(self, stacklet_id: str) -> dict: return self.stack.destroy(stacklet_id) +# ── Stacklet id arguments ───────────────────────────────────────────────── + +def _stacklet_ids(values: list[str]) -> list[str]: + """Flatten `a b`, `a,b` and any mix of the two into one id list. + + Order is kept and repeats are dropped, so `down a,b a` stops a once. + """ + ids: list[str] = [] + for value in values: + for sid in value.split(","): + sid = sid.strip() + if sid and sid not in ids: + ids.append(sid) + return ids + + # ── Topological helpers ─────────────────────────────────────────────────── def _dependency_order(stacklets: list[dict], include: set[str]) -> list[str]: @@ -772,6 +820,19 @@ def handle_up(stck, args): if getattr(args, "no_voice", False): os.environ["STACK_AI_NO_VOICE"] = "1" + ids = _many_ids(args) + if ids: + result = cli.up_many(ids) + if not result.get("ok"): + print_error({"error": result.get("error") or "Some stacklets failed to start", + "problems": [e["stacklet"] for e in result.get("errors", [])]}) + sys.exit(1) + for sid in result["started"]: + print(f" {GREEN}✓{RESET} {sid}: started") + if "core" not in ids: + _refresh_core(stck, ids[0]) + return + if args.stacklet == "all": print(f"\n Bringing up {TEAL}all installed stacklets{RESET}...\n", file=sys.stderr) @@ -804,9 +865,38 @@ def handle_up(stck, args): sys.exit(1) +def _many_ids(args): + """The ids named on the command line, or None for one id or `all`. + + One id keeps the single-stacklet path and its output. `all` cannot + be combined with other ids, since it already names every one. + """ + ids = _stacklet_ids(args.stacklet) + if len(ids) == 1: + args.stacklet = ids[0] + return None + if "all" in ids: + print_error({"error": "'all' already names every stacklet; pass it alone"}) + sys.exit(1) + return ids + + def handle_down(stck, args): cli = CLI(stck) + ids = _many_ids(args) + if ids: + result = cli.down_many(ids) + if not result.get("ok"): + print_error({"error": result.get("error") or "Some stacklets failed to stop", + "problems": [e["stacklet"] for e in result.get("errors", [])]}) + sys.exit(1) + for sid in result["stopped"]: + print(f" {GREEN}✓{RESET} {sid}: stopped") + if "core" not in ids: + _refresh_core(stck, ids[0]) + return + if args.stacklet == "all": result = cli.down("all") stopped = result.get("stopped", []) @@ -836,6 +926,22 @@ def handle_down(stck, args): def handle_destroy(stck, args): + ids = _many_ids(args) + if not ids: + _destroy_one(stck, args) + return + + unknown = [sid for sid in ids if not stck._find_stacklet(sid)] + if unknown: + print_error({"error": f"Unknown stacklet: {', '.join(unknown)}"}) + sys.exit(1) + # Dependents first, as down does. Each one keeps its own confirmation. + for sid in _reverse_dependency_order(stck.discover(), set(ids)): + args.stacklet = sid + _destroy_one(stck, args) + + +def _destroy_one(stck, args): stacklet = stck._find_stacklet(args.stacklet) if not stacklet: print_error({"error": f"Stacklet '{args.stacklet}' not found"}) @@ -1101,6 +1207,24 @@ def handle_restart(stck, args): _restart_stale(stck, cli, yes=getattr(args, "yes", False)) return + ids = _many_ids(args) + if ids: + # Stop the lot before starting any, as `all` does, so nothing comes + # back up against a dependency that is about to go down. + down_result = cli.down_many(ids) + if not down_result.get("ok"): + print_error({"error": down_result.get("error") or "Some stacklets failed to stop", + "problems": [e["stacklet"] for e in down_result.get("errors", [])]}) + sys.exit(1) + up_result = cli.up_many(ids) + if not up_result.get("ok"): + print_error({"error": "Some stacklets failed to start", + "problems": [e["stacklet"] for e in up_result.get("errors", [])]}) + sys.exit(1) + for sid in up_result["started"]: + print(f" {GREEN}✓{RESET} {sid}: restarted") + return + if args.stacklet == "all": # Whole-stack restart: stop everything running, then bring up # every installed stacklet. The two phases use their own @@ -1785,15 +1909,21 @@ def main(): sub.add_parser("help") sub.add_parser("version") - p = sub.add_parser("up"); p.add_argument("stacklet") + p = sub.add_parser("up") + p.add_argument("stacklet", nargs="+", + help="Stacklets to start, space or comma separated, or 'all'") p.add_argument("--no-voice", action="store_true", help="(ai) start without the voice container (TTS + Whisper); sets STACK_AI_NO_VOICE=1") - p = sub.add_parser("down"); p.add_argument("stacklet") - p = sub.add_parser("destroy"); p.add_argument("stacklet"); p.add_argument("--yes", action="store_true") + p = sub.add_parser("down") + p.add_argument("stacklet", nargs="+", + help="Stacklets to stop, space or comma separated, or 'all'") + p = sub.add_parser("destroy") + p.add_argument("stacklet", nargs="+", help="Stacklets to destroy, space or comma separated") + p.add_argument("--yes", action="store_true") p = sub.add_parser("restart") - p.add_argument("stacklet", nargs="?", default=None, - help="Stacklet to restart, 'all', or nothing for whatever " - "is running stale code") + p.add_argument("stacklet", nargs="*", default=None, + help="Stacklets to restart (space or comma separated), " + "'all', or nothing for whatever is running stale code") p.add_argument("--yes", action="store_true", help="Skip the confirmation") p = sub.add_parser("update") p.add_argument("tag", nargs="?", default=None, diff --git a/stacklets/messages/hooks/on_install.sh b/stacklets/messages/hooks/on_install.sh index 93acf148..64d6a96b 100755 --- a/stacklets/messages/hooks/on_install.sh +++ b/stacklets/messages/hooks/on_install.sh @@ -135,14 +135,8 @@ config = { ], "turn_allow_guests": False, - # Relaxed rate limits for a LAN-only family server. - "rc_login": { - "address": {"per_second": 1, "burst_count": 20}, - "account": {"per_second": 1, "burst_count": 20}, - "failed_attempts": {"per_second": 0.5, "burst_count": 20}, - }, - "rc_message": {"per_second": 5, "burst_count": 30}, - "rc_admin_redaction": {"per_second": 5, "burst_count": 30}, + # Rate limits are not set here: hooks/on_start.py adds them on every + # start, so a limit added later reaches servers installed before it. } with open(conf_path, 'w') as f: diff --git a/stacklets/messages/hooks/on_start.py b/stacklets/messages/hooks/on_start.py new file mode 100644 index 00000000..36d4f5c3 --- /dev/null +++ b/stacklets/messages/hooks/on_start.py @@ -0,0 +1,54 @@ +"""Keep the rate limits famstack relaxes in Synapse's config. + +Synapse's defaults are sized for a public homeserver with strangers on +it. On a family server the ones that bite are the per-account limits: +an account may receive five invites in a burst and then one every 333 +seconds, so a bot that sets up a sixth room for the same person waits +five and a half minutes before the invite goes through. + +`homeserver.yaml` is generated once, at install, and never again, so a +limit added to the installer would reach new servers only. This hook +runs on every start and adds whichever of these keys the file lacks. +A key already present is the admin's and is left exactly as it is. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +RATE_LIMITS = { + "rc_login": { + "address": {"per_second": 1, "burst_count": 20}, + "account": {"per_second": 1, "burst_count": 20}, + "failed_attempts": {"per_second": 0.5, "burst_count": 20}, + }, + "rc_message": {"per_second": 5, "burst_count": 30}, + "rc_admin_redaction": {"per_second": 5, "burst_count": 30}, + # per_room keeps Synapse's default; it guards one room, not one person. + "rc_invites": { + "per_user": {"per_second": 1, "burst_count": 20}, + "per_issuer": {"per_second": 1, "burst_count": 20}, + }, +} + + +def run(ctx): + conf = Path(ctx.stack.data) / "messages" / "synapse" / "homeserver.yaml" + if not conf.exists(): + return {"ok": True} + + # The installer writes JSON, which Synapse reads as YAML. A file an + # admin rewrote as YAML cannot be parsed without a YAML library, and + # it is theirs to maintain, so it is left as it is. + try: + config = json.loads(conf.read_text()) + except ValueError: + return {"ok": True, "message": f"{conf} is not JSON; rate limits left as they are"} + + missing = {k: v for k, v in RATE_LIMITS.items() if k not in config} + if not missing: + return {"ok": True} + + conf.write_text(json.dumps({**config, **missing}, indent=2)) + return {"ok": True, "message": f"added Synapse rate limits: {', '.join(missing)}"} diff --git a/tests/framework/test_cli_class.py b/tests/framework/test_cli_class.py index da6956dd..ebcf14d0 100644 --- a/tests/framework/test_cli_class.py +++ b/tests/framework/test_cli_class.py @@ -290,6 +290,86 @@ def test_down_all_orders_dependents_first(self, tmp_path, monkeypatch): assert result["stopped"] == ["c", "b", "a"] +class TestCLIManyStacklets: + """`stack down a b c` and `stack restart a,b` act on every id named. + + The parser used to take one id and drop the rest without a word, so + `stack down memory code docs` stopped memory and reported success. + Several ids are ordered the way `all` is: dependents stop first, + dependencies start first. + """ + + def test_ids_split_on_spaces_and_commas(self): + from stack.cli import _stacklet_ids + assert _stacklet_ids(["a,b", "c", "b,", " d "]) == ["a", "b", "c", "d"] + + def test_down_many_stops_each_named_stacklet_dependents_first(self, tmp_path): + cli, _ = _make_cli(tmp_path, { + "a": {}, + "b": {"requires": ["a"]}, + "c": {"requires": ["b"]}, + "other": {}, + }) + for sid in ("a", "b", "c", "other"): + cli.up(sid) + result = cli.down_many(["a", "c", "b"]) + assert result["ok"] + assert result["stopped"] == ["c", "b", "a"] + + def test_up_many_starts_dependencies_first(self, tmp_path): + cli, _ = _make_cli(tmp_path, { + "a": {}, + "b": {"requires": ["a"]}, + }) + result = cli.up_many(["b", "a"]) + assert result["ok"] + assert result["started"] == ["a", "b"] + + def test_unknown_id_stops_nothing(self, tmp_path, monkeypatch): + """A typo in one id must not leave the others half stopped.""" + cli, _ = _make_cli(tmp_path, {"a": {}}) + cli.up("a") + stopped = [] + monkeypatch.setattr(cli, "down", lambda sid: stopped.append(sid) or {"success": True}) + result = cli.down_many(["a", "nope"]) + assert not result["ok"] + assert "nope" in result["error"] + assert stopped == [] + + +class TestHandlersTakeManyIds: + """The command handlers read several ids off the command line.""" + + def test_up_starts_every_named_stacklet(self, tmp_path, monkeypatch): + from argparse import Namespace + from stack.cli import handle_up + monkeypatch.setattr("stack.docker.running_project_ids", lambda: set()) + cli, _ = _make_cli(tmp_path, {"a": {}, "b": {"requires": ["a"]}}) + handle_up(cli.stack, Namespace(stacklet=["b,a"], no_voice=False)) + assert cli.stack.is_installed("a") and cli.stack.is_installed("b") + + def test_destroy_removes_every_named_stacklet(self, tmp_path, monkeypatch): + from argparse import Namespace + from stack.cli import handle_destroy + monkeypatch.setattr("stack.docker.running_project_ids", lambda: set()) + cli, _ = _make_cli(tmp_path, {"a": {}, "b": {"requires": ["a"]}, "c": {}}) + for sid in ("a", "b", "c"): + cli.up(sid) + handle_destroy(cli.stack, Namespace(stacklet=["a", "b"], yes=True)) + assert not cli.stack.is_installed("a") + assert not cli.stack.is_installed("b") + assert cli.stack.is_installed("c") + + def test_destroy_with_an_unknown_id_destroys_nothing(self, tmp_path, monkeypatch): + from argparse import Namespace + from stack.cli import handle_destroy + cli, _ = _make_cli(tmp_path, {"a": {}}) + cli.up("a") + with pytest.raises(SystemExit): + handle_destroy(cli.stack, Namespace(stacklet=["a", "nope"], yes=True)) + assert cli.stack.is_installed("a") + + class TestCLIDestroy: """CLI.destroy() runs Docker compose down then Stack.destroy().""" diff --git a/tests/integration/test_demo_rig_e2e.py b/tests/integration/test_demo_rig_e2e.py index e87462a2..cee04b5e 100644 --- a/tests/integration/test_demo_rig_e2e.py +++ b/tests/integration/test_demo_rig_e2e.py @@ -385,7 +385,13 @@ async def test_demo_rig_memory_todos_stay_mutable_and_capture_items_project( topic = f"demo-rig-todo-{scope.uid}" item = f"verify B2 todo memory write {scope.uid}" fresh_item = f"verify B4 read-your-writes todo {scope.uid}" - captured_item = f"verify B2 capture todo fold {scope.uid}" + # An errand, not a QA step: the extraction prompt is told to return + # nothing unless the writer is plainly keeping a list, and a note that + # read like a test ticket was filed as information about half the time. + # The model words the item itself, so the check is on the one phrase it + # keeps; todos.md is under this run's own topic, which makes it unique. + captured_item = "pick up the dry cleaning" + captured_phrase = "dry cleaning" token = f"demo-rig-capture-todo-{scope.uid}" path = f"{MEMORY_OWNER}/{topic}/todos.md" about_path = f"{MEMORY_OWNER}/{topic}/about.md" @@ -469,11 +475,9 @@ async def test_demo_rig_memory_todos_stay_mutable_and_capture_items_project( content={ "msgtype": "m.text", "body": ( - "Todo:\n" - f"{captured_item}\n\n" - f"{token}\n" - "This is a deliberate household action list for the " - "demo-rig todo projection check." + "Before Saturday:\n" + f"- {captured_item}\n\n" + f"{token}" ), }, ), @@ -499,9 +503,9 @@ async def test_demo_rig_memory_todos_stay_mutable_and_capture_items_project( bdd.then("The capture action item is folded into memory todos.md") memory_todos = await _wait_for_repo_file_containing( - demo_code, MEMORY_REPO, path, captured_item, timeout=120, + demo_code, MEMORY_REPO, path, captured_phrase, timeout=120, ) - assert memory_todos, f"{captured_item!r} did not appear in {path}" + assert memory_todos, f"{captured_phrase!r} did not appear in {path}" # `stack memory sync` triggers the curator to mirror source AND # rebuild the wiki now, bypassing its 180s batching debounce. @@ -512,9 +516,9 @@ async def test_demo_rig_memory_todos_stay_mutable_and_capture_items_project( assert synced.returncode == 0, synced.stderr or synced.stdout brain_todos = await _wait_for_repo_file_containing( - demo_code, BRAIN_REPO, path, captured_item, timeout=60, + demo_code, BRAIN_REPO, path, captured_phrase, timeout=60, ) - assert brain_todos, f"{captured_item!r} did not appear in brain:{path}" + assert brain_todos, f"{captured_phrase!r} did not appear in brain:{path}" bdd.and_("The curator publishes the topic about page into brain") brain_about = await _wait_for_repo_file( diff --git a/tests/stacklets/test_messages_rate_limits.py b/tests/stacklets/test_messages_rate_limits.py new file mode 100644 index 00000000..d9164bdf --- /dev/null +++ b/tests/stacklets/test_messages_rate_limits.py @@ -0,0 +1,74 @@ +"""Synapse rate limits relaxed for a family server, on every install. + +Synapse's defaults are sized for a public homeserver. The one that hurt: +an account may receive five invites in a burst and then one every 333 +seconds, so the sixth room a bot sets up for the same person hangs for +five and a half minutes. The e2e rig paid that four times a run. + +`homeserver.yaml` is written once, at install, so a new default in the +installer never reaches an existing server. `on_start` adds the limits +famstack relaxes on every start instead, and only where the file has +no value of its own: an admin's setting always wins. +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(_REPO_ROOT / "lib")) + +_spec = importlib.util.spec_from_file_location( + "messages_hooks_on_start", + _REPO_ROOT / "stacklets" / "messages" / "hooks" / "on_start.py") +on_start = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(on_start) + + +def _server(tmp_path, config) -> Path: + conf = tmp_path / "messages" / "synapse" / "homeserver.yaml" + conf.parent.mkdir(parents=True) + conf.write_text(config if isinstance(config, str) else json.dumps(config)) + return conf + + +def _start(tmp_path): + return on_start.run(SimpleNamespace(stack=SimpleNamespace(data=tmp_path))) + + +def test_an_existing_server_gains_the_invite_limit(tmp_path): + conf = _server(tmp_path, {"server_name": "simpson"}) + assert _start(tmp_path)["ok"] + config = json.loads(conf.read_text()) + assert config["server_name"] == "simpson" + assert config["rc_invites"]["per_user"]["burst_count"] >= 20 + assert "rc_message" in config and "rc_login" in config + + +def test_an_admins_own_value_is_kept(tmp_path): + own = {"per_user": {"per_second": 0.01, "burst_count": 3}} + conf = _server(tmp_path, {"server_name": "simpson", "rc_invites": own}) + _start(tmp_path) + assert json.loads(conf.read_text())["rc_invites"] == own + + +def test_a_complete_config_is_not_rewritten(tmp_path): + conf = _server(tmp_path, {"server_name": "simpson"}) + _start(tmp_path) + before = conf.stat().st_mtime_ns + _start(tmp_path) + assert conf.stat().st_mtime_ns == before + + +def test_a_hand_written_yaml_config_is_left_alone(tmp_path): + """Synapse reads YAML; the installer writes JSON, which is a subset. + An admin who rewrote it as YAML keeps their file byte for byte, and + the start goes ahead.""" + text = "server_name: simpson\nrc_message:\n per_second: 1\n" + conf = _server(tmp_path, text) + assert _start(tmp_path)["ok"] + assert conf.read_text() == text