From 37492c96aec7596f15b8bc755b27589bebc91581 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sat, 26 Sep 2026 22:05:01 +0200 Subject: [PATCH 1/5] fix(cli): print what a stacklet command returns when asked for --json A stacklet command returns a dict, and the plugin contract says the framework renders it. It never did: on success the dict was dropped, and `--json` was passed on to the command as an argument, so `stack messages send room "hi" --json` posted "hi --json" and printed nothing, not even the event id it had sent. With `--json` the returned dict is now printed as JSON on stdout, anything the command prints for people goes to stderr, and the flag no longer reaches the command. Without `--json` nothing changes. --- docs/agent/dev.md | 2 +- lib/stack/cli.py | 15 +++++ tests/unit/framework/test_cli_plugin_json.py | 70 ++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/unit/framework/test_cli_plugin_json.py diff --git a/docs/agent/dev.md b/docs/agent/dev.md index cb39f9b5..b3168660 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -145,7 +145,7 @@ def run(args, stacklet, config): ``` Rules: -- Return a dict. The framework decides JSON vs pretty. +- Return a dict. With `--json` the framework prints it as JSON on stdout and sends whatever the command printed to stderr; without it, the command's own output is what people see. `--json` never reaches the command's arguments. - On error, return `{"error": "..."}` - framework translates to non-zero exit. - **Never bypass the CLI from another stacklet.** Use `ctx.stack.run_cli_command(, , ...)`. diff --git a/lib/stack/cli.py b/lib/stack/cli.py index d3380840..df444fbd 100644 --- a/lib/stack/cli.py +++ b/lib/stack/cli.py @@ -2105,6 +2105,21 @@ def main(): action = getattr(args, "action", None) if not action: parser.parse_args([args.command, "--help"]); return + if json_mode: + # The plugin contract: a command returns a dict and the framework + # renders it. With --json that dict is the only thing on stdout; + # what the command prints for people goes to stderr, and the flag + # is taken out of the arguments before the command reads them. + import contextlib + _remaining = [a for a in _remaining if a != "--json"] + sys.argv = [a for a in sys.argv if a != "--json"] + with contextlib.redirect_stdout(sys.stderr): + result = stck.run_cli_command(args.command, action, _remaining) + json.dump(result or {}, sys.stdout, indent=2, default=str) + print() + if result and "error" in result: + sys.exit(1) + return result = stck.run_cli_command(args.command, action, _remaining) if result and "error" in result: print_error(result); sys.exit(1) diff --git a/tests/unit/framework/test_cli_plugin_json.py b/tests/unit/framework/test_cli_plugin_json.py new file mode 100644 index 00000000..c662eb9d --- /dev/null +++ b/tests/unit/framework/test_cli_plugin_json.py @@ -0,0 +1,70 @@ +"""`--json` on a stacklet command prints what the command returns. + +The plugin contract (docs/agent/dev.md) is that a stacklet command returns a +dict and the framework decides between JSON and text. Scripts and agents drive +the stack through that answer: `stack messages send ... --json` has to hand +back the event id it posted. So with `--json` the returned dict is the only +thing on stdout, whatever the command prints for people goes to stderr, and +the flag itself never reaches the command as an argument. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] + +HELLO = ''' +HELP = "Say hello" +import sys + +def run(args, stacklet, config): + print("hello, for people") + if "fail" in args: + return {"error": "asked to fail"} + return {"ok": True, "args": list(args), "argv": sys.argv[3:]} +''' + + +def _instance(tmp_path: Path) -> dict: + extensions = tmp_path / "extensions" + cli = extensions / "echo" / "cli" + cli.mkdir(parents=True) + (extensions / "echo" / "stacklet.toml").write_text( + 'id = "echo"\nname = "Echo"\nversion = "0.1.0"\ncategory = "test"\n') + (cli / "hello.py").write_text(HELLO) + (tmp_path / "stack.toml").write_text( + f'[core]\nname = "stack"\ndata_dir = "{tmp_path / "data"}"\n' + f'extension_dirs = ["{extensions}"]\n') + return {**os.environ, "STACK_DIR": str(tmp_path)} + + +def _stack(env, *args): + return subprocess.run([str(REPO / "stack"), *args], env=env, + capture_output=True, text=True, timeout=60) + + +def test_json_prints_the_returned_answer_and_nothing_else(tmp_path): + result = _stack(_instance(tmp_path), "echo", "hello", "world", "--json") + + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == {"ok": True, "args": ["world"], "argv": ["world"]} + assert "hello, for people" in result.stderr + + +def test_without_json_the_command_speaks_for_itself(tmp_path): + result = _stack(_instance(tmp_path), "echo", "hello", "world") + + assert result.returncode == 0, result.stderr + assert "hello, for people" in result.stdout + assert "{" not in result.stdout + + +def test_an_error_is_json_too_and_fails_the_command(tmp_path): + result = _stack(_instance(tmp_path), "echo", "hello", "fail", "--json") + + assert result.returncode == 1 + assert json.loads(result.stdout) == {"error": "asked to fail"} From 7e024c44027307f76a12089e1e8789da6a52b42e Mon Sep 17 00:00:00 2001 From: Arthur Date: Sat, 26 Sep 2026 22:09:00 +0200 Subject: [PATCH 2/5] feat(messages): hold a conversation with the bots from the terminal An admin or an agent checking a bot had no way to answer one message, react to it, or read the bot's answer to the message just sent: `send` did not say what it posted, and `read` showed the whole room. - `stack messages send ... --reply-to ` answers one message without starting a thread, and `send` prints the id of what it posted. - `stack messages react [--as ]` reacts as the system account or a family member. - `stack messages read` takes `--after `, `--from ` and `--wait `, so it can wait for the archivist's answer to one message; with `--json` each message names its thread or the message it answers. --- stacklets/messages/cli/_matrix.py | 24 ++- stacklets/messages/cli/react.py | 54 ++++++ stacklets/messages/cli/read.py | 177 +++++++++++++----- stacklets/messages/cli/send.py | 22 ++- .../stacklets/test_messages_conversation.py | 117 ++++++++++++ .../unit/stacklets/test_messages_threading.py | 17 +- 6 files changed, 350 insertions(+), 61 deletions(-) create mode 100644 stacklets/messages/cli/react.py create mode 100644 tests/unit/stacklets/test_messages_conversation.py diff --git a/stacklets/messages/cli/_matrix.py b/stacklets/messages/cli/_matrix.py index ab2d0c5b..e92164ee 100644 --- a/stacklets/messages/cli/_matrix.py +++ b/stacklets/messages/cli/_matrix.py @@ -450,7 +450,7 @@ def list_users(self): # ── Messaging ──────────────────────────────────────────────────────── - def send(self, room, message, html=None, mentions=None, thread_root=None): + def send(self, room, message, html=None, mentions=None, thread_root=None, reply_to=None): """Send a text message to a room (by alias or ID). Resolves aliases automatically. If html is provided, sends a @@ -464,6 +464,10 @@ def send(self, room, message, html=None, mentions=None, thread_root=None): event id, the way a client's "Reply in thread" does. A thread is a conversation the agent treats as addressed to it once it is part of one, so this is how that path is exercised from the terminal. + + `reply_to` answers one message without starting a thread, the way a + client's "Reply" does. For the archivist a reply to its filing is a + correction, so this is how that path is exercised. """ if room.startswith("!"): room_id = room @@ -488,6 +492,8 @@ def send(self, room, message, html=None, mentions=None, thread_root=None): "is_falling_back": True, "m.in_reply_to": {"event_id": thread_root}, } + elif reply_to: + body["m.relates_to"] = {"m.in_reply_to": {"event_id": reply_to}} status, resp = _put( self._url(f"/_matrix/client/v3/rooms/{room_id}/send/m.room.message/{txn}"), body, @@ -497,6 +503,22 @@ def send(self, room, message, html=None, mentions=None, thread_root=None): return True, resp.get("event_id", "sent") return False, resp.get("error", "unknown error") + def react(self, room, event_id, key): + """React to a message with `key` (an emoji), as the logged-in user. + Returns (ok, event id or error).""" + room_id = room if room.startswith("!") else self.resolve_room(room) + if not room_id: + return False, f"Room '{room}' not found" + txn = f"{int(time.time() * 1000)}_{random.randint(0, 0xFFFFFFFF):08x}" + status, resp = _put( + self._url(f"/_matrix/client/v3/rooms/{room_id}/send/m.reaction/{txn}"), + {"m.relates_to": {"rel_type": "m.annotation", "event_id": event_id, "key": key}}, + token=self.token, + ) + if status == 200: + return True, resp.get("event_id", "sent") + return False, resp.get("error", "unknown error") + def join(self, room): """Join a room by alias or ID as the logged-in user. Idempotent — Synapse returns 200 if you are already a member.""" diff --git a/stacklets/messages/cli/react.py b/stacklets/messages/cli/react.py new file mode 100644 index 00000000..42df2a50 --- /dev/null +++ b/stacklets/messages/cli/react.py @@ -0,0 +1,54 @@ +""" +stack messages react [--as ] — react to a message + +Adds a reaction to a message, as stacker-bot or, with `--as `, as a +family member. Reactions are how the family tells the archivist what to do +with one message: 📌 keeps it, 📎 archives its source, 🔁 retries a filing. +The event id comes from `stack messages read --ids` or from the send that +posted the message. + +Examples: + stack messages react picnic '$abc...' 📌 --as marge +""" + +HELP = "React to a message in a chat room" + +import sys +from pathlib import Path + +_here = Path(__file__).parent +sys.path.insert(0, str(_here)) +from _matrix import MatrixClient, resolve_login + + +def run(args, stacklet, config): + if not config["is_healthy"](): + return {"error": "Messages is not running — start it with 'stack up messages'"} + + argv = list(args or []) + sender = None + if "--as" in argv: + i = argv.index("--as") + if i + 1 >= len(argv): + return {"error": "--as needs a username"} + sender = argv[i + 1] + del argv[i:i + 2] + if len(argv) != 3: + return {"error": "Usage: stack messages react [--as ]"} + room, event_id, emoji = argv + + username, password, err = resolve_login(sender, config.get("secrets", {})) + if err: + return {"error": err} + server_name = config.get("stack", {}).get("messages", {}).get("server_name", "home") + synapse_port = config.get("manifest", {}).get("ports", {}).get("synapse", 42031) + client = MatrixClient(f"http://localhost:{synapse_port}", server_name, + config.get("instance_dir", config.get("repo_root", "."))) + if not client.login(username, password): + return {"error": f"{username} can't log in — check the password in secrets."} + + ok, detail = client.react(room, event_id, emoji) + if not ok: + return {"error": f"Failed to react: {detail}"} + print(f" Reacted {emoji} in {room}: {detail}") + return {"ok": True, "room": room, "event_id": detail, "reacted_to": event_id} diff --git a/stacklets/messages/cli/read.py b/stacklets/messages/cli/read.py index 4eb824fe..f463438e 100644 --- a/stacklets/messages/cli/read.py +++ b/stacklets/messages/cli/read.py @@ -1,5 +1,5 @@ """ -stack messages read [--limit N] [--ids] — show a room's recent messages +stack messages read [--limit N] [--ids] [--after ] [--from ] [--wait ] — show a room's recent messages Prints the last N messages (default 20) in a room, oldest-first, as `HH:MM sender: text`. Reads through the Synapse admin API, so it works for @@ -10,6 +10,17 @@ `stack messages send --thread ` takes, so the two together let you reply in a thread on a message you did not send — the bot's own answer, say. +`--after ` shows only what came after that message, `--from ` +only what one sender posted (`archivist` finds `archivist-bot`), and +`--wait ` waits up to that long until such a message is there. Together +they answer "what did the archivist say to the message I just sent": + + id=$(stack messages send picnic "Archivist, where do we meet?" --as marge --json | jq -r .event_id) + stack messages read picnic --after "$id" --from archivist --wait 120 + +With `--json` each message carries its event id, sender, time, and the thread +or message it answers. + Examples: stack messages read chat stack messages read topic-camping --limit 5 @@ -23,6 +34,7 @@ HELP = "Show a room's recent messages" import sys +import time from datetime import datetime from pathlib import Path from urllib.parse import quote @@ -32,79 +44,146 @@ from _matrix import _get # noqa: E402 from room import _connect # noqa: E402 +# Reading after a message needs that message inside the window read back. +AFTER_WINDOW = 50 +# How often a waiting read looks again; a bot answers in seconds, not less. +POLL_SECONDS = 2 + +USAGE = ("Usage: stack messages read [--limit N] [--ids] " + "[--after ] [--from ] [--wait ]") + def _parse_args(argv): - """(room, limit, show_ids, error) from the raw arg list.""" - limit = 20 - show_ids = False + """(options, error) from the raw arg list.""" + opts = {"room": None, "limit": 20, "ids": False, "after": None, "sender": None, "wait": 0} + valued = {"--limit": ("limit", int), "--after": ("after", str), + "--from": ("sender", str), "--wait": ("wait", int)} rest = [] i = 0 while i < len(argv): - if argv[i] == "--limit": + flag = argv[i] + if flag in valued: + key, kind = valued[flag] if i + 1 >= len(argv): - return None, None, None, "--limit needs a number" + return None, f"{flag} needs a value" try: - limit = int(argv[i + 1]) + opts[key] = kind(argv[i + 1]) except ValueError: - return None, None, None, f"--limit wants a number, got {argv[i + 1]!r}" + return None, f"{flag} wants a number, got {argv[i + 1]!r}" i += 2 continue - if argv[i] == "--ids": - show_ids = True + if flag == "--ids": + opts["ids"] = True i += 1 continue - rest.append(argv[i]) + rest.append(flag) i += 1 if not rest: - return None, None, None, "Usage: stack messages read [--limit N] [--ids]" - return rest[0], limit, show_ids, None - - -def run(args, stacklet, config): - room, limit, show_ids, err = _parse_args(args or []) - if err: - return {"error": err} - - client, base_url, _ = _connect(config) - if client is None: - return {"error": "Can't authenticate as a server admin — is core up?"} - - # A room ID is usable as-is; an alias needs a directory lookup first. - room_id = room if room.startswith("!") else client.resolve_room(room) - if not room_id: - return {"error": f"No such room: {room!r}"} - - # The admin API reads any room without membership and returns newest-first. + return None, USAGE + opts["room"] = rest[0] + return opts, None + + +# ── What a message is, and which ones were asked for ───────────────────── + +def message_of(event: dict) -> dict: + """The parts of a room message a reader needs, its relation included.""" + content = event.get("content", {}) or {} + relates = content.get("m.relates_to") or {} + thread = relates.get("event_id") if relates.get("rel_type") == "m.thread" else None + reply_to = None if thread else (relates.get("m.in_reply_to") or {}).get("event_id") + return { + "event_id": event.get("event_id", ""), + "sender": event.get("sender", "?").split(":")[0].lstrip("@"), + "ts": event.get("origin_server_ts"), + "msgtype": content.get("msgtype", ""), + "body": content.get("body", ""), + "thread": thread, + "reply_to": reply_to, + } + + +def select(messages: list[dict], *, after: str | None, sender: str | None) -> list[dict]: + """The messages after event `after`, from `sender` (with or without -bot).""" + if after: + index = next((i for i, m in enumerate(messages) if m["event_id"] == after), None) + messages = messages[index + 1:] if index is not None else [] + if sender: + base = sender.removesuffix("-bot") + messages = [m for m in messages if m["sender"] in {base, f"{base}-bot"}] + return messages + + +def _fetch(client, base_url, room_id, limit): status, resp = _get( f"{base_url}/_synapse/admin/v1/rooms/{quote(room_id)}" f"/messages?dir=b&limit={limit}", token=client.token, ) if status != 200: - return {"error": f"Couldn't read {room!r}: {resp.get('error', status)}"} - - messages = [] + return None, resp.get("error", status) # Flip to reading order (oldest-first) and keep only chat messages. - for ev in reversed(resp.get("chunk", [])): - if ev.get("type") != "m.room.message": - continue - sender = ev.get("sender", "?").split(":")[0].lstrip("@") - body = ev.get("content", {}).get("body", "") - ts = ev.get("origin_server_ts") - event_id = ev.get("event_id", "") - when = datetime.fromtimestamp(ts / 1000).strftime("%H:%M") if ts else "--:--" - first, *more = body.split("\n") - print(f"{when} {sender}: {first}") + return [message_of(ev) for ev in reversed(resp.get("chunk", [])) + if ev.get("type") == "m.room.message"], None + + +def _print(messages, show_ids): + for m in messages: + when = datetime.fromtimestamp(m["ts"] / 1000).strftime("%H:%M") if m["ts"] else "--:--" + first, *more = m["body"].split("\n") + print(f"{when} {m['sender']}: {first}") for line in more: # indent continuation lines so replies stay readable print(f" {line}") if show_ids: # The id is what `send --thread` takes, so print it where you can # copy it: under the message it belongs to, not in a separate list. - print(f" [{event_id}]") - messages.append( - {"sender": sender, "body": body, "ts": ts, "event_id": event_id}, - ) + print(f" [{m['event_id']}]") + +def run(args, stacklet, config): + opts, err = _parse_args(args or []) + if opts is None: + return {"error": err} + reader, err = _reader(config, opts["room"]) + if reader is None: + return {"error": err} + chosen, err = _await(reader, opts) + if err: + return {"error": err} + _show(chosen, opts) + return {"ok": True, "room": opts["room"], "count": len(chosen), "messages": chosen} + + +def _reader(config, room): + """A function reading the room's latest messages, or the reason there is none.""" + client, base_url, _ = _connect(config) + if client is None: + return None, "Can't authenticate as a server admin — is core up?" + # A room ID is usable as-is; an alias needs a directory lookup first. + room_id = room if room.startswith("!") else client.resolve_room(room) + if not room_id: + return None, f"No such room: {room!r}" + return (lambda limit: _fetch(client, base_url, room_id, limit)), None + + +def _await(reader, opts): + """The messages asked for, waiting up to `--wait` seconds for them to appear.""" + limit = max(opts["limit"], AFTER_WINDOW) if opts["after"] else opts["limit"] + deadline = time.time() + opts["wait"] + while True: + messages, err = reader(limit) + if messages is None: + return [], f"Couldn't read {opts['room']!r}: {err}" + chosen = select(messages, after=opts["after"], sender=opts["sender"]) + if chosen or time.time() >= deadline: + break + time.sleep(POLL_SECONDS) + if not chosen and opts["wait"]: + return [], f"Nothing from {opts['sender'] or 'anyone'} in {opts['room']} within {opts['wait']}s" + return chosen, None + + +def _show(messages, opts): + _print(messages, opts["ids"]) if not messages: - print(f"(no messages in {room})") - return {"ok": True, "room": room, "count": len(messages), "messages": messages} + print(f"(no messages in {opts['room']})") diff --git a/stacklets/messages/cli/send.py b/stacklets/messages/cli/send.py index 3748b5c0..4c2946a1 100644 --- a/stacklets/messages/cli/send.py +++ b/stacklets/messages/cli/send.py @@ -1,5 +1,5 @@ """ -stack messages send "message" [--as ] [--mention ] [--thread ] — send a message to a room +stack messages send "message" [--as ] [--mention ] [--thread ] [--reply-to ] — send a message to a room Sends a plain text message to the specified room. By default it posts as stacker-bot (the system account); pass `--as ` to post as a family @@ -18,6 +18,13 @@ treats a thread it is part of as addressed to it, so this is also how you check that a follow-up needs no mention at all. +Pass `--reply-to ` to answer one message without starting a +thread, the way a client's "Reply" does. For the archivist, a reply to its +filing is a correction. + +The id of the message sent is printed, and returned with `--json`: it is what +`--thread`, `--reply-to` and `stack messages read --after` take. + This is the building block other stacklets use for notifications: - photos could notify #notifications when a backup completes - docs could notify when a new document is archived @@ -101,6 +108,7 @@ def run(args, stacklet, config): sender = None mentions = [] thread_root = None + reply_to = None rest = [] argv = sys.argv[3:] # skip 'stack', 'messages', 'send' i = 0 @@ -117,6 +125,12 @@ def run(args, stacklet, config): mentions.append(argv[i + 1]) i += 2 continue + if argv[i] == "--reply-to": + if i + 1 >= len(argv): + return {"error": "--reply-to needs an event id"} + reply_to = argv[i + 1] + i += 2 + continue if argv[i] == "--thread": if i + 1 >= len(argv): return {"error": "--thread needs an event id"} @@ -127,7 +141,7 @@ def run(args, stacklet, config): i += 1 if len(rest) < 2: return {"error": 'Usage: stack messages send "message" [--as ] ' - '[--mention ] [--thread ]'} + '[--mention ] [--thread ] [--reply-to ]'} room = rest[0] message = " ".join(rest[1:]) @@ -158,9 +172,11 @@ def run(args, stacklet, config): ok, detail = client.send( room, message, html=html, mentions=mentions or None, - thread_root=thread_root, + thread_root=thread_root, reply_to=reply_to, ) if ok: + # The id is what `--thread`, `--reply-to` and `read --after` take. + print(f" Sent to {room}: {detail}") return {"ok": True, "room": room, "event_id": detail} else: return {"error": f"Failed to send: {detail}"} diff --git a/tests/unit/stacklets/test_messages_conversation.py b/tests/unit/stacklets/test_messages_conversation.py new file mode 100644 index 00000000..d54b760a --- /dev/null +++ b/tests/unit/stacklets/test_messages_conversation.py @@ -0,0 +1,117 @@ +"""Driving a conversation from the terminal with `stack messages`. + +`send` answers a message (a reply, or inside a thread) and hands back the id +of what it posted; `react` puts a reaction on a message as a family member; +`read` tells what came after a given message and from whom. Together they let +an admin or an agent hold the same exchange with the bots a phone does: send +"Archivist, where do we meet?", then read the archivist's answer to exactly +that message. + +Synapse is replaced at its HTTP boundary by pytest-httpserver; what is checked +is the event a real client would send, and what `read` makes of real events. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "messages" / "cli")) +sys.path.insert(0, str(_REPO_ROOT / "lib")) + +from _matrix import MatrixClient # noqa: E402 +from read import message_of, select # noqa: E402 + +ROOM = "!picnic:simpson" + + +def _event(eid, sender, body, relates=None): + content = {"msgtype": "m.text", "body": body} + if relates: + content["m.relates_to"] = relates + return {"event_id": eid, "sender": f"@{sender}:simpson", "origin_server_ts": 1, + "type": "m.room.message", "content": content} + + +class TestReadingAConversation: + + def test_a_message_says_which_thread_or_message_it_answers(self): + in_thread = message_of(_event("$b", "archivist-bot", "✅ Filed", { + "rel_type": "m.thread", "event_id": "$a", "m.in_reply_to": {"event_id": "$a"}})) + a_reply = message_of(_event("$c", "marge", "this is Marge's car", + {"m.in_reply_to": {"event_id": "$b"}})) + + assert (in_thread["thread"], in_thread["reply_to"]) == ("$a", None) + assert (a_reply["thread"], a_reply["reply_to"]) == (None, "$b") + assert a_reply["sender"] == "marge" + + def test_the_answer_to_a_message_is_what_a_bot_said_after_it(self): + messages = [message_of(e) for e in [ + _event("$old", "archivist-bot", "an earlier answer"), + _event("$ask", "marge", "Archivist, where do we meet?"), + _event("$chat", "homer", "mmm, picnic"), + _event("$ans", "archivist-bot", "At the north entrance"), + ]] + + answer = select(messages, after="$ask", sender="archivist") + + assert [m["event_id"] for m in answer] == ["$ans"] + + def test_a_message_not_in_the_window_selects_nothing(self): + messages = [message_of(_event("$x", "archivist-bot", "hi"))] + assert select(messages, after="$gone", sender=None) == [] + + +def _client(httpserver): + client = MatrixClient(httpserver.url_for("").rstrip("/"), "simpson", str(_REPO_ROOT)) + client.token = "t" + return client + + +def _prefix_matching(httpserver): + # Transaction ids end the path; match on the part before them. + from pytest_httpserver import URIPattern + + class Prefix(URIPattern): + def __init__(self, prefix): + self.prefix = prefix + + def match(self, uri): + return uri.startswith(self.prefix) + return Prefix + + +class TestSendingIntoAConversation: + + def _recorded(self, httpserver): + Prefix = _prefix_matching(httpserver) + sent = [] + from werkzeug import Response + + def record(request): + sent.append({"path": request.path, "body": json.loads(request.data)}) + return Response(json.dumps({"event_id": "$new"}), content_type="application/json") + for kind in ("m.room.message", "m.reaction"): + httpserver.expect_request(Prefix(f"/_matrix/client/v3/rooms/{ROOM}/send/{kind}/"), + method="PUT").respond_with_handler(record) + return sent + + def test_a_reply_answers_one_message_without_a_thread(self, httpserver): + sent = self._recorded(httpserver) + + ok, event_id = _client(httpserver).send(ROOM, "this is Marge's car", reply_to="$filed") + + assert (ok, event_id) == (True, "$new") + assert sent[0]["body"]["m.relates_to"] == {"m.in_reply_to": {"event_id": "$filed"}} + + def test_a_reaction_is_an_annotation_on_the_message(self, httpserver): + sent = self._recorded(httpserver) + + ok, _ = _client(httpserver).react(ROOM, "$note", "📌") + + assert ok + assert "/send/m.reaction/" in sent[0]["path"] + assert sent[0]["body"] == {"m.relates_to": { + "rel_type": "m.annotation", "event_id": "$note", "key": "📌"}} diff --git a/tests/unit/stacklets/test_messages_threading.py b/tests/unit/stacklets/test_messages_threading.py index e667a4cf..ebf2ae76 100644 --- a/tests/unit/stacklets/test_messages_threading.py +++ b/tests/unit/stacklets/test_messages_threading.py @@ -105,16 +105,17 @@ class TestAskingForEventIds: message you did not send, which is exactly the message you want to thread onto: the bot's own answer.""" + def _asked(self, argv): + opts, err = read._parse_args(argv) + assert err is None + return opts["room"], opts["limit"], opts["ids"] + def test_ids_are_off_unless_asked_for(self): - room, limit, show_ids, err = read._parse_args(["chat"]) - assert (room, limit, show_ids, err) == ("chat", 20, False, None) + assert self._asked(["chat"]) == ("chat", 20, False) def test_the_flag_turns_them_on_without_eating_the_room(self): - room, _, show_ids, err = read._parse_args(["chat", "--ids"]) - assert (room, show_ids, err) == ("chat", True, None) + assert self._asked(["chat", "--ids"]) == ("chat", 20, True) def test_it_composes_with_limit_in_either_order(self): - assert read._parse_args(["--ids", "chat", "--limit", "3"])[:3] == \ - ("chat", 3, True) - assert read._parse_args(["chat", "--limit", "3", "--ids"])[:3] == \ - ("chat", 3, True) + assert self._asked(["--ids", "chat", "--limit", "3"]) == ("chat", 3, True) + assert self._asked(["chat", "--limit", "3", "--ids"]) == ("chat", 3, True) From 9c266c274d1b6257a27f366ec6674d846718e618 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sat, 26 Sep 2026 22:11:02 +0200 Subject: [PATCH 3/5] test(driver): drive a running instance from its own surfaces Trying something on a real instance meant hand-written Matrix calls and ssh one-liners in every session. `tests/driver/driver` acts as a family member (say, voice, send, react), reads and awaits what the bots answered, runs `./stack` as the admin, answers an interactive command such as the installer prompt by prompt with a transcript and an asciinema recording, and opens a stacklet's web UI in Playwright, signed in to Element, on this Mac or another over ssh. It uses only the stack's own surfaces, the CLI and the chat through `stack messages`; what it adds is the target, text turned into speech, copying a file to the instance, and the event ids between steps. --- docs/agent/dev.md | 2 + tests/driver/README.md | 117 +++++++++++ tests/driver/browser.py | 154 ++++++++++++++ tests/driver/chat.py | 65 ++++++ tests/driver/commands.py | 235 ++++++++++++++++++++++ tests/driver/driver | 21 ++ tests/driver/instance.py | 83 ++++++++ tests/driver/narration.py | 51 +++++ tests/driver/speech.py | 58 ++++++ tests/driver/terminal.py | 153 ++++++++++++++ tests/unit/driver/test_driver_terminal.py | 83 ++++++++ 11 files changed, 1022 insertions(+) create mode 100644 tests/driver/README.md create mode 100644 tests/driver/browser.py create mode 100644 tests/driver/chat.py create mode 100644 tests/driver/commands.py create mode 100755 tests/driver/driver create mode 100644 tests/driver/instance.py create mode 100644 tests/driver/narration.py create mode 100644 tests/driver/speech.py create mode 100644 tests/driver/terminal.py create mode 100644 tests/unit/driver/test_driver_terminal.py diff --git a/docs/agent/dev.md b/docs/agent/dev.md index b3168660..777be64a 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -223,6 +223,8 @@ make test-smoke # quick managed-rig e2e subset. make test-e2e # full managed-rig e2e suite. ``` +To try something on a running instance by hand (say something as Marge, read what the archivist answered, run `./stack` on the test Mac), use `tests/driver/driver`; see its README. It drives the stack through its own surfaces only. + Run the cheapest lane that proves what you changed. Every lane but `test-unit` owns fixed container names and ports, so exactly one runs at a time on a Mac: check nothing else is mid-run first. One name per lane, no diff --git a/tests/driver/README.md b/tests/driver/README.md new file mode 100644 index 00000000..5b7ce778 --- /dev/null +++ b/tests/driver/README.md @@ -0,0 +1,117 @@ +# driver + +Operate a running famstack instance from its own surfaces: act as a family +member in the chat, run `./stack` as the admin, read what the bots answered. +It is for agents and people trying things on a real instance, not a test +suite: it acts and reports, and asserts nothing. + +## The one rule + +The driver only uses what the stack offers its users: the `stack` CLI, and +the chat through `stack messages`. It never talks to Matrix, a database or a +config file itself. When an action needs more than those surfaces give, the +gap is in the surface, and that is where it gets fixed. `stack messages react`, +`send --reply-to` and `read --after/--from/--wait` came from exactly that. + +What the driver adds is only what the CLI rightly does not do: + +| | | +|---|---| +| Where | this checkout, or another Mac with `--host ` | +| Speech | text to a voice message through a TTS server, as a phone records one | +| Files | a local photo or PDF copied to the instance's Mac before it is posted | +| Ids | every action prints the event id it produced, for the next step | +| A terminal | interactive commands such as the installer, answered prompt by prompt, with a transcript and an asciinema recording | +| A browser | a stacklet's web UI in Playwright, signed in as a family member, as a screenshot or a video | + +## Setup + +```sh +export DRIVER_HOST=famstack-e2e # omit to drive this checkout +export DRIVER_ROOT=~/famstack # the checkout on that Mac (default) +export DRIVER_TTS_URL=https://tts.example # for `voice` without --file +``` + +Runs with `uv` (it declares `pexpect` and `playwright` itself) and Chrome for +the browser. Needs `ssh` for a remote instance, and `ffmpeg` for voice +messages in the phone's format (ogg/opus; mp3 without it). + +## Commands + +```sh +driver as marge say picnic "Archivist, where do we meet?" # prints the event id +driver as marge say picnic "this is Marge's car" --reply-to +driver as marge say picnic "and the grill?" --thread +driver as marge voice picnic "Stacky, add sunscreen to the list" +driver as marge voice picnic --file memo.ogg +driver as lisa send documents report-card.pdf +driver as marge react picnic 📌 + +driver read picnic [--limit 20] # recent messages, with ids +driver answer picnic --from archivist --after [--first] [--timeout 180] # its answer to + +driver stack up agent # ./stack on the instance +driver tty "./stack" --answer "Family name=Simpson" --answer "Your first name=Homer" \ + --answer "Name (leave empty=Marge" --answer "Name (leave empty=" \ + --answer "Ready?=y" --record install.cast # the installer, answered +driver browse messages --as lisa --room "Lisa Notes" --shot lisa.png +driver browse memory --shot wiki.png --video videos/ +driver logs archivist [--grep search] # a bot's lines from its log +``` + +A room is its alias (`picnic`, `documents`, `memories`) or a room id. A family +member is their user name (`marge`). `--json` on `as`, `read` and `answer` +gives the stack's own answer. + +## An exchange + +```sh +id=$(driver as marge say picnic "Archivist, where do we meet for the picnic?") +driver answer picnic --from archivist --after "$id" --first +``` + +``` +archivist-bot $iDKNM5... [reply $5O23YtaZH] + 💡 **Antwort:** Wir treffen uns am Nordeingang des Springfield Park [1]. +``` + +The same with speech is `driver as marge voice picnic "..."`: the bots hear it +the way they hear a phone's voice message. + +## The terminal + +`driver tty ""` runs an interactive command where the instance lives +and answers its prompts: `› [default]` and `? [Y/n]`. An +`--answer` matches a prompt by how it starts; one given several times is used +in order, for a prompt that repeats. A prompt no answer matches **stops the +command** and is reported by name, because pressing Enter on an unexpected +"Switch to the local engine? [Y/n]" changes the instance. `--take-defaults` +presses Enter instead. The transcript comes back with the prompts asked; +`--record` writes an asciinema recording. + +## The browser + +`driver browse ` opens the web UI in Chrome through +Playwright, on this Mac. For Element on another Mac it opens an ssh tunnel and +browses `localhost`, the only address Element runs on over plain HTTP. `--as` +signs in to Element (password: the user name unless `--password`), keeping one +browser profile per person so the next run is the same device. Element asks a +second device to confirm its identity; the driver then stops and says so, +with a screenshot. + +## The protocol + +Every command tells on stderr what it does and what came of it, in the +Given / When / Then form of the e2e tests (`tests/e2e/bdd.py`), with a +timestamp and, for an answer, how long the bot took. A failure is a `✗` line. +stdout stays the command's answer, so scripts are not affected. + +``` +[20:10:13.402] WHEN marge says in picnic: 'Archivist, where do we meet?' +[20:10:14.017] . sent $5O23YtaZHKj8... +[20:10:14.018] THEN archivist answers in picnic to $5O23YtaZHKj +[20:10:20.431] ✓ after 6.4s: ✨ Gesucht nach: Picknick, Treffen, Ort | 💡 Antwort: ... +``` + +`DRIVER_LOG=run.log` also appends the lines to a file, so the driver calls of +one scenario leave one protocol behind. diff --git a/tests/driver/browser.py b/tests/driver/browser.py new file mode 100644 index 00000000..41ca997d --- /dev/null +++ b/tests/driver/browser.py @@ -0,0 +1,154 @@ +"""A stacklet's web UI in Chrome, through Playwright, on this Mac. + +The address is the stacklet's port on the instance's Mac. Element runs in a +browser only on localhost or HTTPS, so for Element on another Mac the driver +opens an ssh tunnel and browses localhost, as the admin guide tells a family +to. One browser profile per person and instance keeps a signed-in member the +same device on the next run: Element asks a second device to confirm itself, +and that cannot be done unattended. +""" + +from __future__ import annotations + +import subprocess +import sys +import tempfile +import time +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path + +from instance import Instance + +ELEMENT_PORT = 42030 +PROFILES = Path.home() / ".cache" / "famstack-driver" / "browsers" +DISMISSABLE = ("Dismiss", "Later", "Not now", "OK", "Ok") + + +@dataclass +class Visit: + """One look at a web UI: which one, as whom, and what to bring back. + + `browse` turns it into a browser session: it finds the address, signs in + to Element as `member` when asked, opens `room`, and saves a screenshot or + a video. + + browse(rig, Visit(target="messages", member="lisa", room="Lisa Notes", shot="lisa.png")) + browse(rig, Visit(target="memory", shot="wiki.png")) + """ + + target: str # a stacklet id or a URL + member: str | None = None # signs in to Element as this family member + password: str | None = None # default: the member's name, the installer's default + room: str | None = None # opened after signing in + shot: str | None = None + video: str | None = None + settle: float = 2.0 # seconds the page gets before the screenshot + width: int = 1280 + height: int = 820 + headed: bool = False + + +def browse(instance: Instance, visit: Visit) -> dict: + url, port = _address(instance, visit.target) + with _tunnel(instance, port): + return _open(instance, visit, url, port) + + +def _address(instance: Instance, target: str) -> tuple[str, int | None]: + if target.startswith("http"): + return target, None + port = next((s["port"] for s in instance.stack("list")["stacklets"] + if s["id"] == target and s.get("port")), None) + if port is None: + sys.exit(f"driver: stacklet {target!r} has no web port") + host = "localhost" if port == ELEMENT_PORT else instance.address() + return f"http://{host}:{port}", port + + +@contextmanager +def _tunnel(instance: Instance, port: int | None): + if not (instance.host and port == ELEMENT_PORT): + yield + return + tunnel = subprocess.Popen(["ssh", "-N", "-L", f"{port}:127.0.0.1:{port}", instance.host]) + time.sleep(2) + try: + yield + finally: + tunnel.terminate() + + +def _open(instance: Instance, visit: Visit, url: str, port: int | None) -> dict: + from playwright.sync_api import sync_playwright + + with sync_playwright() as playwright: + context = _context(playwright, instance, visit) + page = context.pages[0] if context.pages else context.new_page() + page.goto(url) + if visit.member and port == ELEMENT_PORT: + _sign_in(page, visit.member, visit.password or visit.member) + if visit.room: + _open_room(page, visit.room) + time.sleep(visit.settle) + if visit.shot: + page.screenshot(path=visit.shot) + report = {"url": url, "shot": visit.shot, "text": page.inner_text("body")} + video = page.video + context.close() + report["video"] = video.path() if visit.video and video else None + return report + + +def _context(playwright, instance: Instance, visit: Visit): + profile = PROFILES / instance.name / (visit.member or "anonymous") + size = {"width": visit.width, "height": visit.height} + recording = {"record_video_dir": visit.video, "record_video_size": size} if visit.video else {} + return playwright.chromium.launch_persistent_context( + str(profile), channel="chrome", headless=not visit.headed, viewport=size, **recording) + + +# ── Element ────────────────────────────────────────────────────────────── + +def _sign_in(page, member: str, password: str) -> None: + """Sign in, unless this profile already is.""" + signed_in = page.locator(".mx_SpacePanel") + sign_in = page.get_by_text("Sign in", exact=True) + signed_in.or_(sign_in).first.wait_for(timeout=40000) + if signed_in.is_visible(): + return + sign_in.first.click() + page.get_by_label("Username").fill(member) + page.get_by_label("Password").fill(password) + page.get_by_role("button", name="Sign in").click() + _wait_until_in(page, member) + _dismiss_prompts(page) + + +def _wait_until_in(page, member: str) -> None: + confirm = page.get_by_text("Confirm your digital identity") + page.locator(".mx_SpacePanel").or_(confirm).first.wait_for(timeout=60000) + if not confirm.is_visible(): + return + shot = Path(tempfile.gettempdir()) / f"driver-element-{member}-confirm.png" + page.screenshot(path=str(shot)) + sys.exit(f"driver: Element asks {member} to confirm this device (\"Confirm your digital " + f"identity\"), which cannot be done unattended. Screenshot: {shot}") + + +def _dismiss_prompts(page) -> None: + for label in DISMISSABLE: + button = page.get_by_role("button", name=label, exact=True) + if button.count() and button.first.is_visible(): + button.first.click() + + +def _open_room(page, room: str) -> None: + """Open a room by its name in the room list, inside the family space if need be.""" + item = page.locator(".mx_LeftPanel").get_by_text(room, exact=True).first + time.sleep(1.5) + if not item.is_visible(): + page.locator(".mx_SpacePanel").get_by_label("Family", exact=True).first.click() + time.sleep(1.5) + item.click() + time.sleep(2) diff --git a/tests/driver/chat.py b/tests/driver/chat.py new file mode 100644 index 00000000..0d5dd195 --- /dev/null +++ b/tests/driver/chat.py @@ -0,0 +1,65 @@ +"""The family's side: what a member does in a room, and what the bots answered. + +Every action goes through `stack messages` on the instance, as that member. +Each returns the stack's own answer; the event id in it is what the next step +(`--thread`, `--reply-to`, `answer --after`) takes. +""" + +from __future__ import annotations + +from pathlib import Path + +from instance import Instance +from speech import voice_message + +PREVIEW = 140 # characters of a message shown in a listing + + +def say(instance: Instance, who: str, room: str, text: str, *, + thread: str | None = None, reply_to: str | None = None) -> dict: + relation = (["--thread", thread] if thread else + ["--reply-to", reply_to] if reply_to else []) + return instance.stack("messages", "send", room, text, "--as", who, *relation) + + +def speak(instance: Instance, who: str, room: str, text: str, recording: str | None) -> dict: + audio = Path(recording) if recording else voice_message(text) + return send(instance, who, room, audio) + + +def send(instance: Instance, who: str, room: str, path: Path) -> dict: + return instance.stack("messages", "upload", room, instance.put(path), "--as", who) + + +def react(instance: Instance, who: str, room: str, event: str, emoji: str) -> dict: + return instance.stack("messages", "react", room, event, emoji, "--as", who) + + +def recent(instance: Instance, room: str, limit: int) -> list[dict]: + return instance.stack("messages", "read", room, "--limit", str(limit))["messages"] + + +def answers(instance: Instance, room: str, bot: str, after: str, timeout: int) -> list[dict]: + """What `bot` posted after event `after`, waiting up to `timeout` seconds for it.""" + return instance.stack("messages", "read", room, "--from", bot, "--wait", str(timeout), + "--after", after)["messages"] + + +# ── Showing messages ───────────────────────────────────────────────────── + +def listing(messages: list[dict], *, whole: bool = False) -> str: + return "\n".join(_entry(m, whole) for m in messages) + + +def _entry(message: dict, whole: bool) -> str: + body = message["body"] if whole else (message["body"].splitlines() or [""])[0][:PREVIEW] + return (f"{message['sender']:14} {message['event_id']}{_relation(message)}\n " + + body.replace("\n", "\n ")) + + +def _relation(message: dict) -> str: + if message.get("thread"): + return f" [thread {message['thread'][:10]}]" + if message.get("reply_to"): + return f" [reply {message['reply_to'][:10]}]" + return "" diff --git a/tests/driver/commands.py b/tests/driver/commands.py new file mode 100644 index 00000000..2461f03f --- /dev/null +++ b/tests/driver/commands.py @@ -0,0 +1,235 @@ +"""The driver's command line: one small handler per command. + +Each handler reads its arguments, calls one module (chat, terminal, browser, +or the instance itself), and prints the result for a person or, with --json, +for a script. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import sys +import time +from pathlib import Path + +import chat +import terminal +from browser import Visit, browse +from instance import Instance +from narration import Narrator, first_line + +log = Narrator() + + +# ── Handlers ───────────────────────────────────────────────────────────── + +def as_member(args, instance: Instance) -> None: + log.when(_telling(args)) + action = { + "say": lambda: chat.say(instance, args.who, args.room, args.text, + thread=args.thread, reply_to=args.reply_to), + "voice": lambda: chat.speak(instance, args.who, args.room, args.text, args.file), + "send": lambda: chat.send(instance, args.who, args.room, Path(args.file)), + "react": lambda: chat.react(instance, args.who, args.room, args.event, args.emoji), + }[args.action] + answer = action() + log.detail(f"sent {answer['event_id']}") + print(json.dumps(answer) if args.json else answer["event_id"]) + + +def _telling(args) -> str: + """What a member does, the way the protocol says it.""" + what = {"say": f"says in {args.room}", "voice": f"speaks in {args.room}", + "send": f"sends {getattr(args, 'file', '')} to {args.room}", + "react": f"reacts {getattr(args, 'emoji', '')} in {args.room}"}[args.action] + words = getattr(args, "text", "") if args.action in ("say", "voice") else "" + return f"{args.who} {what}" + (f": {words!r}" if words else "") + + +def read(args, instance: Instance) -> None: + _print_messages(chat.recent(instance, args.room, args.limit), args.json) + + +def answer(args, instance: Instance) -> None: + log.then(f"{args.frm} answers in {args.room} to {args.after[:12]}") + started = time.time() + messages = chat.answers(instance, args.room, args.frm, args.after, args.timeout) + if not messages: + log.detail(f"nothing after {time.time() - started:.1f}s") + else: + log.ok(f"after {time.time() - started:.1f}s: {first_line(messages[0]['body'])}") + _print_messages(messages[:1] if args.first else messages, args.json, whole=True) + + +def _print_messages(messages: list[dict], as_json: bool, whole: bool = False) -> None: + print(json.dumps(messages, indent=2) if as_json else chat.listing(messages, whole=whole)) + + +def stack(args, instance: Instance) -> None: + line = "./stack " + " ".join(shlex.quote(a) for a in args.args) + log.when(f"the admin runs {line} on {instance.name}") + result = instance.run(line) + print(result.stdout + result.stderr, end="") + (log.ok if result.returncode == 0 else log.detail)(f"exit {result.returncode}") + sys.exit(result.returncode) + + +def logs(args, instance: Instance) -> None: + """A bot's lines: in core's log (the bot-runner), tagged `[name]`; the agent's own.""" + name = args.name.lower().removesuffix("-bot") + agent = name in ("agent", "stacky") + command = f"./stack logs {'agent' if agent else 'core'} --tail {args.tail}" + if not agent: + command += f" --grep {shlex.quote('[' + name)}" + result = instance.run(command) + lines = (result.stdout + result.stderr).splitlines() + wanted = args.grep.lower() if args.grep else "" + print("\n".join(line for line in lines if wanted in line.lower())) + + +def tty(args, instance: Instance) -> None: + log.when(f"the admin runs {args.line} on {instance.name}, answering its prompts") + limits = terminal.Limits(quiet=args.quiet, timeout=args.timeout, cols=args.cols, rows=args.rows) + session = terminal.interact(instance, args.line, terminal.Answers(args.answer), + take_defaults=args.take_defaults, limits=limits) + if args.record: + terminal.write_recording(session, Path(args.record), limits) + report = session.report() + for p in report["prompts"]: + log.detail(f"asked {p['prompt']!r} -> {p['answer']!r}" + (f" ({p['note']})" if p.get("note") else "")) + log.ok(f"exit {report['exit']} after {report['seconds']}s") + if args.json: + return print(json.dumps(report, indent=2)) + print(report["transcript"]) + for p in report["prompts"]: + note = f" ({p['note']})" if p.get("note") else "" + print(f" asked: {p['prompt']!r} -> {p['answer']!r}{note}") + print(f" exit {report['exit']} after {report['seconds']}s") + + +def browse_ui(args, instance: Instance) -> None: + visit = Visit(target=args.target, member=args.as_, password=args.password, room=args.room, + shot=args.shot, video=args.video, settle=args.wait, headed=args.headed) + log.when(f"{args.as_ or 'someone'} opens {args.target}" + (f", room {args.room}" if args.room else "")) + report = browse(instance, visit) + log.ok(" ".join(f"{k} {report[k]}" for k in ("url", "shot", "video") if report.get(k))) + if args.json: + return print(json.dumps(report, indent=2)) + saved = [f"{kind} {path}" for kind, path in (("screenshot", report["shot"]), + ("video", report["video"])) if path] + print("\n".join([report["url"], *saved, report["text"][:1500]])) + + +HANDLERS = {"as": as_member, "read": read, "answer": answer, "stack": stack, + "logs": logs, "tty": tty, "browse": browse_ui} + + +# ── Arguments ──────────────────────────────────────────────────────────── + +def parser() -> argparse.ArgumentParser: + top = argparse.ArgumentParser(prog="driver", description="Operate a famstack instance from its own surfaces.") + top.add_argument("--host", default=os.environ.get("DRIVER_HOST"), + help="ssh host of the instance's Mac (default: this checkout)") + top.add_argument("--root", default=os.environ.get("DRIVER_ROOT"), + help="checkout on that Mac (default: ~/famstack over ssh)") + sub = top.add_subparsers(dest="command", required=True) + json_commands = [*_member_actions(sub), _read(sub), _answer(sub), _tty(sub), _browse(sub)] + _stack(sub) + _logs(sub) + for command in json_commands: + command.add_argument("--json", action="store_true", help="machine-readable output") + return top + + +def _member_actions(sub) -> list: + member = sub.add_parser("as", help="a family member acts in a room") + member.add_argument("who") + acts = member.add_subparsers(dest="action", required=True) + say = acts.add_parser("say", help="send a text message") + say.add_argument("room") + say.add_argument("text") + say.add_argument("--reply-to", help="event id this answers") + say.add_argument("--thread", help="root event id of the thread to post in") + voice = acts.add_parser("voice", help="send a voice message") + voice.add_argument("room") + voice.add_argument("text", nargs="?", default="", help="spoken through DRIVER_TTS_URL") + voice.add_argument("--file", help="an existing recording instead") + send = acts.add_parser("send", help="send a file: photo, PDF, audio") + send.add_argument("room") + send.add_argument("file") + react = acts.add_parser("react", help="react to a message") + react.add_argument("room") + react.add_argument("event") + react.add_argument("emoji") + return [say, voice, send, react] + + +def _read(sub): + read_ = sub.add_parser("read", help="a room's recent messages, with event ids") + read_.add_argument("room") + read_.add_argument("--limit", type=int, default=20) + return read_ + + +def _answer(sub): + answer_ = sub.add_parser("answer", help="wait for a bot's message in a room") + answer_.add_argument("room") + answer_.add_argument("--from", dest="frm", required=True, help="archivist, stacker, stacky, ...") + answer_.add_argument("--after", required=True, help="the message it answers (its event id)") + answer_.add_argument("--timeout", type=int, default=180) + answer_.add_argument("--first", action="store_true", help="only the first answer") + return answer_ + + +def _tty(sub): + tty_ = sub.add_parser("tty", help="run an interactive command, answering its prompts") + tty_.add_argument("line", help='the command, e.g. "./stack" (the installer)') + tty_.add_argument("--answer", action="append", default=[], + help='"Prompt start=value", repeatable; a repeated prompt takes them in order') + tty_.add_argument("--take-defaults", action="store_true", + help="press Enter on a prompt not in --answer, instead of stopping") + tty_.add_argument("--record", help="write an asciinema recording (.cast) here") + tty_.add_argument("--timeout", type=int, default=terminal.Limits.timeout) + tty_.add_argument("--quiet", type=float, default=terminal.Limits.quiet) + tty_.add_argument("--cols", type=int, default=terminal.Limits.cols) + tty_.add_argument("--rows", type=int, default=terminal.Limits.rows) + return tty_ + + +def _browse(sub): + browse_ = sub.add_parser("browse", help="open a stacklet's web UI in a browser") + browse_.add_argument("target", help="a stacklet (messages, docs, memory, ...) or a URL") + browse_.add_argument("--as", dest="as_", help="sign in to Element as this family member") + browse_.add_argument("--password", help="default: the user name, the installer's default") + browse_.add_argument("--room", help="open this room (by name) after signing in") + browse_.add_argument("--shot", help="save a screenshot here") + browse_.add_argument("--video", help="record a video into this folder") + browse_.add_argument("--wait", type=float, default=2, help="seconds to let the page settle") + browse_.add_argument("--headed", action="store_true", help="show the browser window") + return browse_ + + +def _stack(sub) -> None: + stack_ = sub.add_parser("stack", help="run ./stack on the instance, as the admin") + stack_.add_argument("args", nargs=argparse.REMAINDER) + + +def _logs(sub) -> None: + logs_ = sub.add_parser("logs", help="a bot's lines from its stacklet's log") + logs_.add_argument("name", help="archivist, stacker, mail, scribe, or agent") + logs_.add_argument("--grep") + logs_.add_argument("--tail", type=int, default=400) + + +def main() -> None: + args = parser().parse_args() + try: + HANDLERS[args.command](args, Instance(args.host, args.root)) + except SystemExit as stop: + # A failure is part of the story: into the protocol, then out as before. + if isinstance(stop.code, str): + log.failed(stop.code.removeprefix("driver: ")) + raise diff --git a/tests/driver/driver b/tests/driver/driver new file mode 100755 index 00000000..e9fa0098 --- /dev/null +++ b/tests/driver/driver @@ -0,0 +1,21 @@ +#!/usr/bin/env -S uv run --quiet --script +# /// script +# requires-python = ">=3.11" +# dependencies = ["pexpect", "playwright"] +# /// +"""driver: operate a running famstack instance from its own surfaces. + +Acts as a family member in the chat, runs `./stack` as the admin, answers the +installer, opens the web UIs, and reads what the bots answered: through the +stack's own CLI and chat only, on this Mac or another over ssh. See README.md. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from commands import main # noqa: E402 + +if __name__ == "__main__": + main() diff --git a/tests/driver/instance.py b/tests/driver/instance.py new file mode 100644 index 00000000..8de2c8be --- /dev/null +++ b/tests/driver/instance.py @@ -0,0 +1,83 @@ +"""Where the instance lives, and how to reach its surfaces. + +Commands run on the instance's Mac in a login shell, so the admin's own PATH +applies: in this checkout, or over ssh in the checkout named by `root`. The +only surface this module touches is `./stack`. +""" + +from __future__ import annotations + +import json +import shlex +import subprocess +import sys +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent.parent +REMOTE_CHECKOUT = "~/famstack" + + +class Instance: + """The one famstack instance a driver command acts on. + + Every other module reaches the stack through it: it knows whether the + instance is this checkout or another Mac over ssh, runs `./stack` there + with `--json` and hands back the answer, and copies a local file over + when a command needs one on that Mac. + + rig = Instance(host="famstack-e2e", root=None) + rig.stack("messages", "send", "picnic", "hi", "--as", "marge")["event_id"] + """ + + def __init__(self, host: str | None, root: str | None): + self.host = host + self.root = root or (REMOTE_CHECKOUT if host else str(REPO)) + + @property + def name(self) -> str: + return self.host or "local" + + def argv(self, command: str, *, tty: bool = False) -> list[str]: + """The process that runs `command` in the instance's checkout.""" + full = f"cd {self.root} && {command}" + if not self.host: + return ["zsh", "-lc", full] + return ["ssh", *(["-tt"] if tty else []), self.host, f"zsh -lc {shlex.quote(full)}"] + + def run(self, command: str) -> subprocess.CompletedProcess: + return subprocess.run(self.argv(command), capture_output=True, text=True) + + def stack(self, *args: str) -> dict: + """`./stack --json` on the instance; its answer, or exit with its error.""" + line = "./stack " + " ".join(shlex.quote(a) for a in args) + result = self.run(line + " --json") + answer = _json_or_exit(line, result) + if result.returncode != 0 or "error" in answer: + sys.exit(f"driver: `{line}` failed: {answer.get('error') or result.stderr.strip()}") + return answer + + def put(self, local: Path) -> str: + """Copy a file to the instance's Mac; the path it has there.""" + if not self.host: + return str(local.resolve()) + remote = f"/tmp/driver-{time.time_ns()}-{local.name}" + subprocess.run(["scp", "-q", str(local), f"{self.host}:{remote}"], check=True) + return remote + + def address(self) -> str: + """The name other machines reach the instance's Mac by.""" + if not self.host: + return "localhost" + config = subprocess.run(["ssh", "-G", self.host], capture_output=True, text=True).stdout + return next((line.split()[1] for line in config.splitlines() + if line.startswith("hostname ")), self.host) + + +def _json_or_exit(line: str, result: subprocess.CompletedProcess) -> dict: + if not result.stdout.strip(): + return {} + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + sys.exit(f"driver: `{line}` gave no JSON:\n{result.stdout}{result.stderr}") diff --git a/tests/driver/narration.py b/tests/driver/narration.py new file mode 100644 index 00000000..b2a263f5 --- /dev/null +++ b/tests/driver/narration.py @@ -0,0 +1,51 @@ +"""The protocol of a driver run, in the e2e tests' Given / When / Then form. + +Each action and what came of it is one timestamped line on stderr, so a run +reads as a story while stdout stays the command's answer for scripts: + + [20:10:13.402] WHEN marge says in picnic: 'Archivist, where do we meet?' + [20:10:14.017] . sent $5O23YtaZHKj8... + [20:10:14.018] THEN archivist answers in picnic + [20:10:22.431] ✓ after 8.4s: 💡 Antwort: Wir treffen uns am Nordeingang ... + +With DRIVER_LOG set, the lines are also appended to that file, so the driver +calls of one scenario leave one protocol behind. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "e2e")) + +from bdd import BDDLog # noqa: E402 + + +class Narrator(BDDLog): + """The e2e tests' BDD log, also written to DRIVER_LOG when that is set. + + One narrator per driver command; each command tells what it does and what + came back, and nothing else prints to stderr. + + log = Narrator() + log.when("marge says in picnic: 'hi'") + log.detail("sent $abc") + """ + + def _emit(self, verb: str, msg: str, indent: bool = False) -> None: + super()._emit(verb, msg, indent) + target = os.environ.get("DRIVER_LOG") + if target: + with open(target, "a", encoding="utf-8") as protocol: + protocol.write(self.steps[-1] + "\n") + + def failed(self, msg: str) -> None: + self._emit("\u2717", msg, indent=True) + + +def first_line(text: str, width: int = 100, lines: int = 2) -> str: + """A message on one protocol line: its first non-empty lines, shortened.""" + kept = [line.strip() for line in text.splitlines() if line.strip()][:lines] + return " | ".join(kept)[:width] diff --git a/tests/driver/speech.py b/tests/driver/speech.py new file mode 100644 index 00000000..200b3aa2 --- /dev/null +++ b/tests/driver/speech.py @@ -0,0 +1,58 @@ +"""Text turned into a voice message, the way a phone records one. + +Test material, not product: an OpenAI-compatible TTS server (DRIVER_TTS_URL) +speaks the text, and ffmpeg, when present, turns it into ogg/opus like a +phone's recording. Without ffmpeg the mp3 is sent as it is. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from pathlib import Path + +# The TTS server this household uses answers a few requests with 502 before +# serving them; three tries cover that without hiding a server that is down. +ATTEMPTS = 3 + + +def voice_message(text: str) -> Path: + mp3 = Path(tempfile.mkdtemp(prefix="driver-voice-")) / "voice.mp3" + mp3.write_bytes(_synthesize(text)) + return _as_phone_recording(mp3) + + +def _synthesize(text: str) -> bytes: + url = os.environ.get("DRIVER_TTS_URL", "").rstrip("/") + if not url: + sys.exit("driver: set DRIVER_TTS_URL to an OpenAI-compatible TTS server, or pass --file") + body = json.dumps({"model": os.environ.get("DRIVER_TTS_MODEL", "tts-1"), + "voice": os.environ.get("DRIVER_TTS_VOICE", "nova"), + "input": text, "response_format": "mp3"}).encode() + request = urllib.request.Request(f"{url}/v1/audio/speech", data=body, + headers={"Content-Type": "application/json"}) + for attempt in range(1, ATTEMPTS + 1): + try: + with urllib.request.urlopen(request, timeout=120) as response: + return response.read() + except urllib.error.URLError: + if attempt == ATTEMPTS: + raise + time.sleep(2) + raise AssertionError("unreachable") + + +def _as_phone_recording(mp3: Path) -> Path: + if not shutil.which("ffmpeg"): + return mp3 + ogg = mp3.with_suffix(".ogg") + subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-i", str(mp3), + "-c:a", "libopus", "-b:a", "32k", str(ogg)], check=True) + return ogg diff --git a/tests/driver/terminal.py b/tests/driver/terminal.py new file mode 100644 index 00000000..404248fd --- /dev/null +++ b/tests/driver/terminal.py @@ -0,0 +1,153 @@ +"""The admin's terminal: an interactive command, answered prompt by prompt. + +The wizard and the setup hooks ask with `› [default]` or +`? [Y/n]`. A prompt is output that stops, so the last line is read +once nothing has arrived for a moment. An answer given with `--answer` is +matched on how the prompt starts. A prompt without an answer stops the +command and is named, because Enter on an unexpected "Switch to the local +engine? [Y/n]" changes the instance; `take_defaults` presses Enter instead. +""" + +from __future__ import annotations + +import json +import re +import time +from dataclasses import dataclass, field +from pathlib import Path + +from instance import Instance + +PROMPT = re.compile(r"^\s*[›?]\s+(?P.+?)\s*(?:\[[^\]]*\])?\s*$") +ESCAPES = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[()][0-9A-Za-z]|[@-Z\\-_])") + + +def plain(text: str) -> str: + """Terminal output as text: escape sequences gone, redraws on their own lines.""" + return ESCAPES.sub("", text).replace("\r\n", "\n").replace("\r", "\n") + + +def prompt_in(output: str) -> str | None: + """The question the output stops at, if it stops at one.""" + last = re.split(r"[\r\n]", ESCAPES.sub("", output))[-1] + match = PROMPT.match(last) + return match.group("text") if match else None + + +class Answers: + """The answers the admin would type, looked up by the question asked. + + Built from `--answer "Prompt start=value"` pairs. A prompt is matched on + how it starts, and a prompt that repeats (the wizard's "Name (leave empty + to continue)") takes its answers in the order given. No answer means + None, which is where `interact` stops. + + answers = Answers(["Family name=Simpson", "Name (leave empty=Marge", "Name (leave empty="]) + answers.for_prompt("Family name") # "Simpson" + answers.for_prompt("Name (leave empty to continue)") # "Marge", then "" + """ + + def __init__(self, pairs: list[str]): + self._queues: dict[str, list[str]] = {} + for pair in pairs: + prompt, _, value = pair.partition("=") + self._queues.setdefault(prompt, []).append(value) + + def for_prompt(self, question: str) -> str | None: + queue = next((q for start, q in self._queues.items() + if question.startswith(start) and q), None) + return queue.pop(0) if queue else None + + +@dataclass +class Session: + """What happened while a command ran: output, the prompts asked, a recording. + + `interact` fills it as output arrives; `report()` is what a person or a + script gets back, and `cast` becomes an asciinema recording. + + session = interact(rig, "./stack", answers, take_defaults=False, limits=Limits()) + session.report()["prompts"] # [{"prompt": "Family name", "answer": "Simpson", ...}] + """ + + started: float = field(default_factory=time.time) + output: str = "" + prompts: list[dict] = field(default_factory=list) + cast: list[str] = field(default_factory=list) + exit: int | None = None + + def heard(self, chunk: str) -> None: + self.output += chunk + self.cast.append(json.dumps([round(time.time() - self.started, 3), "o", chunk])) + + def report(self) -> dict: + return {"exit": self.exit, "seconds": round(time.time() - self.started), + "prompts": self.prompts, "transcript": plain(self.output)} + + +@dataclass +class Limits: + """How long `interact` waits, and how big the terminal it draws in is. + + Limits(timeout=120) # give up on a command after two minutes + """ + + quiet: float = 1.5 # seconds of silence that end a prompt + timeout: int = 2400 # seconds before giving up; the installer pulls images + cols: int = 110 + rows: int = 32 + + +def interact(instance: Instance, command: str, answers: Answers, *, + take_defaults: bool, limits: Limits) -> Session: + import pexpect + + argv = instance.argv(command, tty=True) + child = pexpect.spawn(argv[0], argv[1:], encoding="utf-8", timeout=None, + dimensions=(limits.rows, limits.cols)) + session, pending = Session(), "" + while _running(child, session, limits): + try: + chunk = child.read_nonblocking(65536, timeout=limits.quiet) + except pexpect.TIMEOUT: + pending = _respond(child, session, pending, answers, take_defaults) + continue + except pexpect.EOF: + break + session.heard(chunk) + pending += chunk + child.close() + session.exit = child.exitstatus + return session + + +def _running(child, session: Session, limits: Limits) -> bool: + if not child.isalive() and child.eof(): + return False + if time.time() - session.started <= limits.timeout: + return True + session.prompts.append({"prompt": None, "answer": None, "note": f"stopped after {limits.timeout}s"}) + child.terminate(force=True) + return False + + +def _respond(child, session: Session, pending: str, answers: Answers, take_defaults: bool) -> str: + """Answer the prompt the output stopped at; what is still unanswered.""" + question = prompt_in(pending) + if question is None: + return pending + answer = answers.for_prompt(question) + if answer is None and not take_defaults: + session.prompts.append({"prompt": question, "answer": None, "note": "not in --answer: stopped here"}) + child.terminate(force=True) + return "" + session.prompts.append({"prompt": question, "answer": answer or "", + "note": "" if answer is not None else "took the default"}) + child.send((answer or "") + "\r") + return "" + + +def write_recording(session: Session, path: Path, limits: Limits) -> None: + header = json.dumps({"version": 2, "width": limits.cols, "height": limits.rows, + "timestamp": int(session.started)}) + path.write_text("\n".join([header, *session.cast]) + "\n") diff --git a/tests/unit/driver/test_driver_terminal.py b/tests/unit/driver/test_driver_terminal.py new file mode 100644 index 00000000..d9b631a5 --- /dev/null +++ b/tests/unit/driver/test_driver_terminal.py @@ -0,0 +1,83 @@ +"""How the driver reads the installer's terminal and answers it. + +The wizard asks with `› [default]` and `? [Y/n]`, drawn +with colour codes and spinners. The driver has to see a question where the +output stops at one, answer the ones it was given answers for, in order when +a prompt repeats, and know when it has no answer: that is where it stops, +instead of pressing Enter on a question nobody meant to confirm. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "driver")) + +from chat import listing # noqa: E402 +from terminal import Answers, plain, prompt_in # noqa: E402 + +COLOUR = "\x1b[38;5;208m" +RESET = "\x1b[0m" + + +class TestSeeingAQuestion: + + def test_a_prompt_with_a_default_is_a_question(self): + assert prompt_in(f"\n {COLOUR}›{RESET} Family name {COLOUR}[Simpson]{RESET} ") == "Family name" + + def test_a_confirmation_is_a_question(self): + assert prompt_in("\n ? Are you sure? [y/N] ") == "Are you sure?" + + def test_output_that_goes_on_is_not(self): + assert prompt_in(" ✓ Starting containers\n") is None + assert prompt_in(" ⠙ Building service") is None + + def test_the_transcript_is_text(self): + assert plain(f"{COLOUR}ok{RESET}\r\nnext\rredraw") == "ok\nnext\nredraw" + + +class TestAnswering: + + def test_an_answer_matches_how_the_prompt_starts(self): + answers = Answers(["Family name=Simpson"]) + assert answers.for_prompt("Family name") == "Simpson" + + def test_a_repeated_prompt_takes_its_answers_in_order(self): + answers = Answers(["Name (leave empty=Marge", "Name (leave empty=Bart", + "Name (leave empty="]) + asked = [answers.for_prompt("Name (leave empty to continue)") for _ in range(4)] + assert asked == ["Marge", "Bart", "", None] + + def test_a_question_without_an_answer_is_none(self): + assert Answers(["Family name=Simpson"]).for_prompt("Are you sure?") is None + + +class TestShowingMessages: + + def test_a_listing_says_which_thread_or_message_an_answer_belongs_to(self): + text = listing([ + {"sender": "archivist-bot", "event_id": "$b", "body": "Filed\nmore", + "thread": "$a1234567890", "reply_to": None}, + {"sender": "marge", "event_id": "$c", "body": "this is Marge's car", + "thread": None, "reply_to": "$b1234567890"}, + ]) + assert "[thread $a12345678]" in text and "more" not in text + assert "[reply $b12345678]" in text + + +class TestTheProtocol: + """Each step is a line on stderr, and in DRIVER_LOG when that names a file, + so the driver calls of one scenario leave one protocol behind.""" + + def test_steps_are_appended_to_the_protocol_file(self, tmp_path, monkeypatch): + from narration import Narrator + protocol = tmp_path / "run.log" + monkeypatch.setenv("DRIVER_LOG", str(protocol)) + + Narrator().when("marge says in picnic: 'hi'") + Narrator().then("archivist answers in picnic") + + lines = protocol.read_text().splitlines() + assert [line.split("] ", 1)[1].split()[0] for line in lines] == ["WHEN", "THEN"] + assert "marge says in picnic" in lines[0] From 7863e8081e0624e021262d60197640258b597c8a Mon Sep 17 00:00:00 2001 From: Arthur Date: Sat, 26 Sep 2026 23:30:49 +0200 Subject: [PATCH 4/5] docs: set the clean code rules a reviewer checks Reviews of larger changes went into reverse-engineering long functions that mixed parsing, deciding and I/O. The engineer guide now lists eight structural rules, after Robert C. Martin's Clean Code, that new and touched code follows, and the pr skill checks a diff against them before a PR is opened. Existing code is left for its own day. --- .claude/skills/pr/SKILL.md | 6 ++++++ docs/agent/dev.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/.claude/skills/pr/SKILL.md b/.claude/skills/pr/SKILL.md index dfc1dbce..0f5f1add 100644 --- a/.claude/skills/pr/SKILL.md +++ b/.claude/skills/pr/SKILL.md @@ -64,6 +64,12 @@ Refs: #127 - No `Co-Authored-By`, no "Generated with" line, no tool link: this repository is public and commit-lint rejects all three. +## 4b. Read the diff as a reviewer + +Check the new and touched code against `docs/agent/dev.md`, "Clean code: +what a reviewer checks". Fix what breaks a rule before opening the PR, or +say in the handover which rule it breaks and why. + ## 5. Check ```bash diff --git a/docs/agent/dev.md b/docs/agent/dev.md index 777be64a..9b58a990 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -262,6 +262,39 @@ Testing rules: - **Re-read the full error line before calling a failure a duplicate.** Check sender, target, specific IDs. Two errors that look similar at a glance often differ in the load-bearing field. - **No backwards-compatibility shims** in pre-1.0 code. Change the code, update callers, ship. +### Clean code: what a reviewer checks + +Structure a reader can follow without running the code, so a review reads the +change instead of reverse-engineering it. After Robert C. Martin's *Clean +Code*, trimmed to what this codebase needs. + +1. **A function does one thing, at one level of abstraction.** The top level + reads like a table of contents. A description that needs "and then" is two + functions. +2. **Small.** A function fits on a screen (about 30 lines), a file holds one + topic (about 300 lines). Past that, splitting is the rule. +3. **Names say what, not how.** `answer_to(message)`, not + `select(messages, after=...)`. No abbreviations beyond the domain's own. +4. **No flag arguments that switch behaviour.** `as_json=True` deep in the + logic is two functions, or rendering that belongs at the edge. +5. **Return early instead of nesting.** At most two levels of indentation in + the logic. +6. **I/O at the edge, decisions in the middle.** Parsing, deciding and + formatting are pure functions; only the outer layer talks to ssh, Matrix, + a database or the terminal (principle 5 in AGENTS.md, made concrete). +7. **Name the magic.** A regex, a port, a timeout becomes a named constant + with a one-line reason. +8. **No dead code.** Delete instead of commenting out; a comment says why, + never what the code already says. + +9. **A class says what it is for.** Its docstring gives its purpose within + the module and one short example of using it. +10. **Leave it cleaner than you found it.** Code you visit or change leaves + more readable and more testable than it came, until it follows these + rules. Much of the existing code predates them; this is how it gets there, + one touched function at a time, with its behaviour pinned by a test before + it moves. + ## Commits: the subject is the changelog The release log is the changelog. `stack update` prints it, the GitHub release From b0f485cd986997e2c2c9d1fe34ba126fa9e8c949 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 27 Sep 2026 00:05:15 +0200 Subject: [PATCH 5/5] test: run the curator's timed work from the driver `driver cycle curator` and `driver cycle nightly` run the work the curator otherwise does on a timer (pages after a filing, the nightly sweep) through `stack memory sync --pages` and `stack memory nightly`, and log it as a CYCLE line, apart from what family members do. --- tests/driver/README.md | 21 +++++++++++++++++++-- tests/driver/commands.py | 30 ++++++++++++++++++++++++++++-- tests/driver/narration.py | 4 ++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/tests/driver/README.md b/tests/driver/README.md index 5b7ce778..89098082 100644 --- a/tests/driver/README.md +++ b/tests/driver/README.md @@ -50,6 +50,8 @@ driver as marge react picnic 📌 driver read picnic [--limit 20] # recent messages, with ids driver answer picnic --from archivist --after [--first] [--timeout 180] # its answer to +driver cycle curator # the pages a filing touched, now +driver cycle nightly # tonight's sweep, now driver stack up agent # ./stack on the instance driver tty "./stack" --answer "Family name=Simpson" --answer "Your first name=Homer" \ --answer "Name (leave empty=Marge" --answer "Name (leave empty=" \ @@ -60,8 +62,23 @@ driver logs archivist [--grep search] # a bot's lines from its log ``` A room is its alias (`picnic`, `documents`, `memories`) or a room id. A family -member is their user name (`marge`). `--json` on `as`, `read` and `answer` -gives the stack's own answer. +member is their user name (`marge`). `--json` on `as`, `read`, `answer` and +`cycle` gives the stack's own answer. + +## Timed work + +Some of the chain from a message to the wiki runs on a timer: the curator +regenerates pages after a quiet window following a filing, and sweeps +everything once a night. `driver cycle` runs that work now and returns when it +is done, through `stack memory sync --pages` and `stack memory nightly`. In +the protocol it is a `CYCLE` line, so a reader tells what the family did from +what the stack did on its own: + +``` +[20:14:02.118] WHEN lisa sends report-card.pdf to documents +[20:14:40.905] CYCLE the curator regenerates the pages the last filing touched (...) +[20:14:52.330] ✓ done after 11.4s +``` ## An exchange diff --git a/tests/driver/commands.py b/tests/driver/commands.py index 2461f03f..cb1fe937 100644 --- a/tests/driver/commands.py +++ b/tests/driver/commands.py @@ -77,6 +77,25 @@ def stack(args, instance: Instance) -> None: sys.exit(result.returncode) +# Work the stack otherwise does on a timer, each with the stack command that +# runs it now and waits until it is done. +CYCLES = { + "curator": ("the curator regenerates the pages the last filing touched " + "(on its own after a quiet window)", ("memory", "sync", "--pages")), + "nightly": ("the curator's nightly sweep: diary, source reconcile, every page " + "(on its own at 03:30)", ("memory", "nightly")), +} + + +def cycle(args, instance: Instance) -> None: + what, command = CYCLES[args.which] + log.cycle(what) + started = time.time() + answer = instance.stack(*command) + log.ok(f"done after {time.time() - started:.1f}s") + print(json.dumps(answer) if args.json else f"{args.which} cycle done") + + def logs(args, instance: Instance) -> None: """A bot's lines: in core's log (the bot-runner), tagged `[name]`; the agent's own.""" name = args.name.lower().removesuffix("-bot") @@ -124,7 +143,7 @@ def browse_ui(args, instance: Instance) -> None: HANDLERS = {"as": as_member, "read": read, "answer": answer, "stack": stack, - "logs": logs, "tty": tty, "browse": browse_ui} + "logs": logs, "cycle": cycle, "tty": tty, "browse": browse_ui} # ── Arguments ──────────────────────────────────────────────────────────── @@ -136,7 +155,8 @@ def parser() -> argparse.ArgumentParser: top.add_argument("--root", default=os.environ.get("DRIVER_ROOT"), help="checkout on that Mac (default: ~/famstack over ssh)") sub = top.add_subparsers(dest="command", required=True) - json_commands = [*_member_actions(sub), _read(sub), _answer(sub), _tty(sub), _browse(sub)] + json_commands = [*_member_actions(sub), _read(sub), _answer(sub), _cycle(sub), + _tty(sub), _browse(sub)] _stack(sub) _logs(sub) for command in json_commands: @@ -184,6 +204,12 @@ def _answer(sub): return answer_ +def _cycle(sub): + cycle_ = sub.add_parser("cycle", help="run timed work now: the curator's pages, the nightly sweep") + cycle_.add_argument("which", choices=sorted(CYCLES)) + return cycle_ + + def _tty(sub): tty_ = sub.add_parser("tty", help="run an interactive command, answering its prompts") tty_.add_argument("line", help='the command, e.g. "./stack" (the installer)') diff --git a/tests/driver/narration.py b/tests/driver/narration.py index b2a263f5..1c7cfb41 100644 --- a/tests/driver/narration.py +++ b/tests/driver/narration.py @@ -44,6 +44,10 @@ def _emit(self, verb: str, msg: str, indent: bool = False) -> None: def failed(self, msg: str) -> None: self._emit("\u2717", msg, indent=True) + def cycle(self, msg: str) -> None: + """Work the stack does on a timer, run now: named apart from what people do.""" + self._emit("CYCLE", msg) + def first_line(text: str, width: int = 100, lines: int = 2) -> str: """A message on one protocol line: its first non-empty lines, shortened."""