sys-buddy 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sys_buddy/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """sys-buddy — an authenticated, contract-enforcing MCP broker for cross-human
2
+ AI agent collaboration.
3
+
4
+ ``__version__`` is derived from the installed package metadata (which pip/uv write
5
+ from ``pyproject.toml`` at install time), NOT a second hand-edited literal. There is
6
+ therefore ONE place to bump the version — ``pyproject.toml`` — and the two can never
7
+ silently disagree. ``tests/test_version.py`` asserts the two stay in lockstep.
8
+
9
+ The fallback matters: running straight from a source checkout that was never installed
10
+ (no ``.dist-info``) raises ``PackageNotFoundError``. That should never happen in the
11
+ uv workflow (``uv run`` syncs first), but a bare ``python src/...`` would hit it, and a
12
+ version banner is not worth crashing the import over.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from importlib.metadata import PackageNotFoundError, version
18
+
19
+ try:
20
+ __version__ = version("sys-buddy")
21
+ except PackageNotFoundError: # running from an uninstalled source tree
22
+ __version__ = "0.0.0+unknown"
sys_buddy/admin.py ADDED
@@ -0,0 +1,358 @@
1
+ """Host-side admin operations (SPEC §9): create tasks, mint invites, issue and
2
+ revoke credentials, close tasks.
3
+
4
+ These run on the *host's* machine, against the same SQLite file the broker serves,
5
+ so — unlike the messaging tools, which are handed an open connection and a resolved
6
+ identity — each function opens its own connection via ``db.connect()``. That mirrors
7
+ the ``tools.py`` ``_op_*`` helpers and matches how ``cli.py`` calls them (no ``conn``
8
+ argument threaded through the CLI).
9
+
10
+ Guiding principle: the broker enforces, agents request. Raw tokens and invite codes
11
+ never touch the database — only their sha256 (SPEC §9). A leaked db reveals nothing
12
+ that can be replayed.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ import secrets
20
+ import sqlite3
21
+ import time
22
+
23
+ from . import audit, service, todos
24
+ from .db import connect
25
+ from .identity import new_invite_code, new_viewer_token, sha256_hex
26
+
27
+ # Single-use invites live 15 minutes (SPEC §9). Short enough that a code lingering
28
+ # in a Slack scrollback is dead by the time anyone scans for it.
29
+ INVITE_TTL_SECONDS = 15 * 60
30
+
31
+ # Cap on the slug part of an auto-derived id — keeps ids short enough to read and
32
+ # type while still recognisably echoing the title.
33
+ _SLUG_MAX = 40
34
+
35
+
36
+ def new_task_id(title: str) -> str:
37
+ """Derive a task id from a human ``title``: a slug plus a short random suffix.
38
+
39
+ Humans should only have to type a Title; the id is machine-friendly and unique.
40
+ The slug is lowercase with non-alphanumerics collapsed to single hyphens (e.g.
41
+ "new API" → "new-api"); an empty slug (a title with no word characters) falls
42
+ back to a generic base. The 4-hex-char suffix (16 bits of entropy) keeps two
43
+ same-titled tasks apart without the caller having to invent an id.
44
+ """
45
+ slug = re.sub(r"[^a-z0-9]+", "-", (title or "").lower()).strip("-")[:_SLUG_MAX].strip("-")
46
+ base = slug or "task"
47
+ return f"{base}-{secrets.token_hex(2)}"
48
+
49
+
50
+ def _fmt_time(ts: float) -> str:
51
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
52
+
53
+
54
+ def _write_event(conn: sqlite3.Connection, task_id: str, kind: str, detail: dict) -> None:
55
+ """Append an audit row. Every state-changing admin action leaves a trace so the
56
+ dashboard's event log (SPEC §11) and both humans can reconstruct what happened."""
57
+ conn.execute(
58
+ "INSERT INTO events (task_id, kind, detail_json, created_at) VALUES (?,?,?,?)",
59
+ (task_id, kind, json.dumps(detail), time.time()),
60
+ )
61
+
62
+
63
+ def create_task(
64
+ id: str | None,
65
+ *,
66
+ title: str,
67
+ roles: list[str],
68
+ mode: str = "contract",
69
+ same_machine: bool = False,
70
+ staging_url: str | None = None,
71
+ ) -> dict:
72
+ """Create a task in the ``open`` state with the given fixed cast of roles.
73
+
74
+ ``mode`` selects the workflow: ``'contract'`` (the default) runs the full
75
+ propose/lock/deploy state machine; ``'debug'`` is a lightweight mode where two
76
+ buddies just fix a problem and mark it resolved, with no contract required.
77
+
78
+ ``same_machine`` records the task's CONNECTIVITY (not the broker's auth mode):
79
+ True only when the host proved everything lives on one box. It relaxes the
80
+ ``staging_url`` rules for this task, so it defaults to False — a caller that
81
+ doesn't say gets the strict remote validation. ``staging_url`` is the host-chosen
82
+ deployment target the producer agent inherits when it proposes a contract.
83
+
84
+ ``id`` may be falsy (``None``/``""``): the id is then derived from ``title`` via
85
+ :func:`new_task_id`, so a human only has to supply a Title. An explicit id is
86
+ used verbatim, and a duplicate explicit id is rejected explicitly (rather than
87
+ surfacing a raw sqlite IntegrityError) so the CLI can print an actionable message.
88
+ """
89
+ if mode not in ("contract", "debug"):
90
+ raise ValueError(f"unknown mode {mode!r}; expected 'contract' or 'debug'")
91
+ # Normalise + validate the cast: trim, no blanks, no duplicates. The fixed-cast
92
+ # rule allows one live agent per role, so a duplicate role is nonsensical — reject
93
+ # it at creation rather than silently storing a role that can never be filled twice.
94
+ roles = [r.strip() for r in roles]
95
+ if not roles or any(not r for r in roles):
96
+ raise ValueError("a task needs at least one non-empty role")
97
+ if len(roles) != len(set(roles)):
98
+ raise ValueError("task roles must be unique (no duplicates)")
99
+ # `broker` is the broker's OWN voice: it authors pushes like contract_locked, and
100
+ # both the agent envelope and the dashboard thread attribute them to that role. A
101
+ # seat literally named 'broker' would be indistinguishable from the broker itself,
102
+ # so the name is reserved.
103
+ if any(r.lower() == service.BROKER_ROLE for r in roles):
104
+ raise ValueError(
105
+ f"'{service.BROKER_ROLE}' is reserved for the broker's own notifications — "
106
+ f"pick another role name"
107
+ )
108
+ if mode == "contract" and len(roles) < 2:
109
+ # Model B: the producer is whoever proposes the contract (no hardcoded role).
110
+ # A contract still needs at least two roles — one to produce and one to build
111
+ # against it — else the workflow can never reach a check/verify. Debug tasks
112
+ # skip the state machine, so a single role is fine there.
113
+ raise ValueError("a contract task needs at least two roles (a producer and someone who builds against it)")
114
+ conn = connect()
115
+ try:
116
+ if not id:
117
+ # Derive from the title. Regenerate on the (vanishingly unlikely) suffix
118
+ # collision so an auto-id never fails the way an explicit duplicate does.
119
+ id = new_task_id(title)
120
+ while conn.execute("SELECT 1 FROM tasks WHERE id = ?", (id,)).fetchone() is not None:
121
+ id = new_task_id(title)
122
+ elif conn.execute("SELECT 1 FROM tasks WHERE id = ?", (id,)).fetchone() is not None:
123
+ raise ValueError(f"task '{id}' already exists")
124
+ now = time.time()
125
+ staging_url = (staging_url or "").strip() or None
126
+ conn.execute(
127
+ "INSERT INTO tasks (id, title, state, mode, roles_json, same_machine, staging_url, "
128
+ "created_at) VALUES (?,?,?,?,?,?,?,?)",
129
+ (
130
+ id, title, "open", mode, json.dumps(list(roles)),
131
+ 1 if same_machine else 0, staging_url, now,
132
+ ),
133
+ )
134
+ _write_event(conn, id, "task", {"text": f"Task created: {id}"})
135
+ conn.commit()
136
+ return {
137
+ "id": id,
138
+ "state": "open",
139
+ "title": title,
140
+ "roles": list(roles),
141
+ "mode": mode,
142
+ "same_machine": bool(same_machine),
143
+ "staging_url": staging_url,
144
+ }
145
+ finally:
146
+ conn.close()
147
+
148
+
149
+ def mint_invite(task: str, role: str) -> tuple[str, str]:
150
+ """Generate a single-use invite code for ``role`` on ``task``.
151
+
152
+ Only the code's sha256 is stored; the raw code is returned to the caller once
153
+ and never persisted. Validates that the task exists and the role is one the task
154
+ actually declared — you cannot invite a role into a cast it has no seat for.
155
+
156
+ Returns ``(raw_code, human_readable_expiry)``.
157
+ """
158
+ conn = connect()
159
+ try:
160
+ row = conn.execute("SELECT roles_json FROM tasks WHERE id = ?", (task,)).fetchone()
161
+ if row is None:
162
+ raise ValueError(f"unknown task '{task}'")
163
+ roles = json.loads(row["roles_json"])
164
+ if role not in roles:
165
+ raise ValueError(f"role '{role}' is not one of task '{task}' roles: {', '.join(roles)}")
166
+
167
+ code = new_invite_code(task)
168
+ now = time.time()
169
+ expires_at = now + INVITE_TTL_SECONDS
170
+ conn.execute(
171
+ "INSERT INTO invites (task_id, role, code_hash, created_at, expires_at, used_at) "
172
+ "VALUES (?,?,?,?,?,NULL)",
173
+ (task, role, sha256_hex(code), now, expires_at),
174
+ )
175
+ conn.commit()
176
+ return code, _fmt_time(expires_at)
177
+ finally:
178
+ conn.close()
179
+
180
+
181
+ def issue_host_viewer(label: str) -> str:
182
+ """Create an all-tasks (``task_id = NULL``) viewer and return its RAW token.
183
+
184
+ The host holds a distinct credential that sees every task, as opposed to a
185
+ buddy's per-task viewer (SPEC §9). Only the sha256 is stored.
186
+ """
187
+ conn = connect()
188
+ try:
189
+ token = new_viewer_token()
190
+ conn.execute(
191
+ "INSERT INTO viewers (task_id, label, token_hash, created_at) VALUES (NULL,?,?,?)",
192
+ (label, sha256_hex(token), time.time()),
193
+ )
194
+ conn.commit()
195
+ return token
196
+ finally:
197
+ conn.close()
198
+
199
+
200
+ def revoke_agent(name: str, task: str | None = None) -> int:
201
+ """Revoke live agents named ``name``. Returns how many were revoked.
202
+
203
+ Because ``name`` is buddy-chosen at pairing, the same name can exist on more than
204
+ one task; pass ``task`` to scope the revocation so a host doesn't collaterally
205
+ kill a same-named agent on an unrelated task. Only live agents
206
+ (``revoked_at IS NULL``) are touched, so re-running is a no-op.
207
+ """
208
+ conn = connect()
209
+ try:
210
+ now = time.time()
211
+ if task is None:
212
+ cur = conn.execute(
213
+ "UPDATE agents SET revoked_at = ? WHERE name = ? AND revoked_at IS NULL",
214
+ (now, name),
215
+ )
216
+ else:
217
+ cur = conn.execute(
218
+ "UPDATE agents SET revoked_at = ? WHERE name = ? AND task_id = ? "
219
+ "AND revoked_at IS NULL",
220
+ (now, name, task),
221
+ )
222
+ conn.commit()
223
+ audit.event("revoke_agent", name=name, task=task or "*", count=cur.rowcount)
224
+ return cur.rowcount
225
+ finally:
226
+ conn.close()
227
+
228
+
229
+ def revoke_viewer(label: str, task: str | None = None) -> int:
230
+ """Revoke live viewers with ``label`` (optionally scoped to ``task``). Returns
231
+ how many were revoked."""
232
+ conn = connect()
233
+ try:
234
+ now = time.time()
235
+ if task is None:
236
+ cur = conn.execute(
237
+ "UPDATE viewers SET revoked_at = ? WHERE label = ? AND revoked_at IS NULL",
238
+ (now, label),
239
+ )
240
+ else:
241
+ cur = conn.execute(
242
+ "UPDATE viewers SET revoked_at = ? WHERE label = ? AND task_id = ? "
243
+ "AND revoked_at IS NULL",
244
+ (now, label, task),
245
+ )
246
+ conn.commit()
247
+ audit.event("revoke_viewer", label=label, task=task or "*", count=cur.rowcount)
248
+ return cur.rowcount
249
+ finally:
250
+ conn.close()
251
+
252
+
253
+ def close_task(task: str) -> None:
254
+ """Close a task and revoke ALL its agents and viewers (SPEC §9: "close kills
255
+ everything for that task").
256
+
257
+ One atomic sweep: stamp ``closed_at``, then revoke every still-live agent and
258
+ per-task viewer. Instant and total — no credential scoped to this task survives.
259
+ """
260
+ conn = connect()
261
+ try:
262
+ if conn.execute("SELECT 1 FROM tasks WHERE id = ?", (task,)).fetchone() is None:
263
+ raise ValueError(f"unknown task '{task}'")
264
+ now = time.time()
265
+ conn.execute("UPDATE tasks SET closed_at = ? WHERE id = ?", (now, task))
266
+ conn.execute(
267
+ "UPDATE agents SET revoked_at = ? WHERE task_id = ? AND revoked_at IS NULL",
268
+ (now, task),
269
+ )
270
+ conn.execute(
271
+ "UPDATE viewers SET revoked_at = ? WHERE task_id = ? AND revoked_at IS NULL",
272
+ (now, task),
273
+ )
274
+ # Burn any invite that hasn't been redeemed yet — otherwise a buddy could
275
+ # redeem a still-valid invite AFTER close and get live access to a closed
276
+ # task (SPEC §9: close kills everything for that task).
277
+ conn.execute(
278
+ "UPDATE invites SET used_at = ? WHERE task_id = ? AND used_at IS NULL",
279
+ (now, task),
280
+ )
281
+ _write_event(conn, task, "task", {"text": f"Task closed: {task}"})
282
+ conn.commit()
283
+ finally:
284
+ conn.close()
285
+ audit.event("task_closed", task=task)
286
+
287
+
288
+ def _assert_task(conn: sqlite3.Connection, task: str) -> None:
289
+ """Fail with the task id, not with "no todo N" — the host mistyped one of two args
290
+ and has to be told which."""
291
+ if conn.execute("SELECT 1 FROM tasks WHERE id = ?", (task,)).fetchone() is None:
292
+ raise ValueError(f"unknown task '{task}'")
293
+
294
+
295
+ def list_todos(task: str) -> tuple[list[dict], dict | None]:
296
+ """Every todo on ``task`` plus its rollup, for the host's CLI view.
297
+
298
+ Returns ``(todos, rollup)``; the rollup is ``None`` when the task has no live todos
299
+ (``todos.rollup``), i.e. exactly when the task still runs its own state machine.
300
+ Read-only — the host's one WRITE here is :func:`host_drop_todo`.
301
+ """
302
+ conn = connect()
303
+ try:
304
+ _assert_task(conn, task)
305
+ return todos.get_todos(conn, task), todos.rollup(conn, task)
306
+ finally:
307
+ conn.close()
308
+
309
+
310
+ def host_drop_todo(task: str, todo: int, reason: str) -> tuple[dict, dict | None]:
311
+ """Drop a todo unilaterally, as the HOST. The escape hatch, and the only one.
312
+
313
+ A mutual ``drop_todo`` needs every named party's consent — including the party whose
314
+ human went offline and is the whole reason you want it gone — so that path deadlocks
315
+ on exactly the person who is missing. This is why the escape hatch is HUMAN and lives
316
+ here and in the desktop app rather than as a tool: no peer may ever remove a peer,
317
+ or the moment one objects to a shape the other removes it and locks without the
318
+ dissent ("both sides sign" quietly becomes "whoever proposes wins").
319
+
320
+ ``todos.host_drop_todo`` posts the who/why to the task thread as the BROKER's own
321
+ seat, so the absent party's agent finds an explanation instead of vanished work.
322
+
323
+ Returns ``(dropped_todo, task_rollup)`` — the rollup so the caller can print what the
324
+ task now reports, and ``None`` there when the last live todo just went (the task is
325
+ back on its own state machine).
326
+ """
327
+ conn = connect()
328
+ try:
329
+ _assert_task(conn, task)
330
+ result = todos.host_drop_todo(conn, task, todo, reason)
331
+ roll = todos.rollup(conn, task)
332
+ finally:
333
+ conn.close()
334
+ audit.event("todo_dropped", task=task, todo=result["id"], by=todos.HOST)
335
+ return result, roll
336
+
337
+
338
+ def list_tasks() -> list[dict]:
339
+ """All tasks, newest first, with the fields the CLI printer needs."""
340
+ conn = connect()
341
+ try:
342
+ rows = conn.execute(
343
+ "SELECT id, title, state, roles_json, strikes, created_at, closed_at "
344
+ "FROM tasks ORDER BY created_at DESC"
345
+ ).fetchall()
346
+ return [
347
+ {
348
+ "id": r["id"],
349
+ "title": r["title"],
350
+ "state": r["state"],
351
+ "roles": json.loads(r["roles_json"]),
352
+ "strikes": r["strikes"],
353
+ "closed": r["closed_at"] is not None,
354
+ }
355
+ for r in rows
356
+ ]
357
+ finally:
358
+ conn.close()