okf-agentbus 0.2.3__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.
agentbus/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AgentBus — local MCP event log and advisory lease locks for multi-agent workspaces."""
2
+
3
+ __version__ = "0.2.3"
agentbus/auth.py ADDED
@@ -0,0 +1,81 @@
1
+ """Workspace-scoped ephemeral token authentication for publish."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import secrets
7
+ from pathlib import Path
8
+
9
+ TOKEN_FILENAME = "token"
10
+
11
+
12
+ def token_path(workspace: Path) -> Path:
13
+ return workspace.resolve() / ".agentbus" / TOKEN_FILENAME
14
+
15
+
16
+ def generate_token() -> str:
17
+ return secrets.token_urlsafe(32)
18
+
19
+
20
+ def read_workspace_token(workspace: Path) -> str | None:
21
+ path = token_path(workspace)
22
+ if not path.is_file():
23
+ return None
24
+ return path.read_text(encoding="utf-8").strip()
25
+
26
+
27
+ def write_workspace_token(workspace: Path, token: str) -> Path:
28
+ path = token_path(workspace)
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+ path.write_text(token + "\n", encoding="utf-8")
31
+ os.chmod(path, 0o600)
32
+ return path
33
+
34
+
35
+ def ensure_ephemeral_token(workspace: Path, *, rotate: bool = False) -> str:
36
+ """Create or reuse workspace token. Set rotate=True to regenerate."""
37
+ existing = read_workspace_token(workspace)
38
+ if existing and not rotate:
39
+ return existing
40
+ token = generate_token()
41
+ write_workspace_token(workspace, token)
42
+ return token
43
+
44
+
45
+ def expected_publish_token(workspace: Path | None) -> str | None:
46
+ """Resolve the token required for publish operations."""
47
+ if os.environ.get("AGENTBUS_AUTH", "auto").lower() == "off":
48
+ return None
49
+ if workspace is not None:
50
+ file_token = read_workspace_token(workspace)
51
+ if file_token:
52
+ return file_token
53
+ return os.environ.get("AGENTBUS_EXPECTED_TOKEN") or None
54
+
55
+
56
+ def provided_publish_token(
57
+ auth_token: str | None = None,
58
+ workspace: Path | None = None,
59
+ ) -> str:
60
+ if auth_token:
61
+ return auth_token
62
+ if workspace is not None:
63
+ file_token = read_workspace_token(workspace)
64
+ if file_token:
65
+ return file_token
66
+ env_token = os.environ.get("AGENTBUS_TOKEN", "")
67
+ if env_token:
68
+ return env_token
69
+ return ""
70
+
71
+
72
+ def check_publish_token(
73
+ workspace: Path | None = None,
74
+ auth_token: str | None = None,
75
+ ) -> None:
76
+ """Require a valid token for publish when auth is configured."""
77
+ expected = expected_publish_token(workspace)
78
+ if not expected:
79
+ return
80
+ if provided_publish_token(auth_token, workspace) != expected:
81
+ raise ValueError("unauthorized")
agentbus/cli.py ADDED
@@ -0,0 +1,343 @@
1
+ """AgentBus CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from agentbus.auth import (
12
+ check_publish_token,
13
+ ensure_ephemeral_token,
14
+ read_workspace_token,
15
+ token_path,
16
+ )
17
+ from agentbus.leases import LeaseStore
18
+ from agentbus.project_log import project_handoffs
19
+ from agentbus.schemas import validate_payload
20
+ from agentbus.server import run_stdio
21
+ from agentbus.store import EventStore
22
+
23
+ DEFAULT_WORKSPACE = os.environ.get("AGENTBUS_WORKSPACE", str(Path.cwd()))
24
+
25
+
26
+ def _producer_id(override: str | None) -> str:
27
+ pid = override or os.environ.get("AGENTBUS_PRODUCER_ID", "")
28
+ if not pid:
29
+ raise click.ClickException("Set --producer-id or AGENTBUS_PRODUCER_ID")
30
+ return pid
31
+
32
+
33
+ def _open_store(workspace: str, retention_days: int) -> EventStore:
34
+ return EventStore(Path(workspace), retention_days=retention_days)
35
+
36
+
37
+ def _open_lease_store(workspace: str) -> LeaseStore:
38
+ return LeaseStore(Path(workspace))
39
+
40
+
41
+ def _auth(ws: Path, token: str | None) -> None:
42
+ try:
43
+ check_publish_token(ws, auth_token=token)
44
+ except ValueError as exc:
45
+ raise click.ClickException(str(exc)) from exc
46
+
47
+
48
+ @click.group()
49
+ def main() -> None:
50
+ """Local MCP event log for multi-agent workspaces."""
51
+
52
+
53
+ @main.command()
54
+ @click.option(
55
+ "--workspace",
56
+ type=click.Path(exists=True, file_okay=False, path_type=str),
57
+ default=DEFAULT_WORKSPACE,
58
+ show_default=True,
59
+ )
60
+ @click.option("--retention-days", default=7, show_default=True)
61
+ @click.option(
62
+ "--rotate-token",
63
+ is_flag=True,
64
+ help="Regenerate workspace token on startup",
65
+ )
66
+ def serve(workspace: str, retention_days: int, rotate_token: bool) -> None:
67
+ """Run MCP server on stdio."""
68
+ run_stdio(Path(workspace), retention_days=retention_days, rotate_token=rotate_token)
69
+
70
+
71
+ @main.command()
72
+ @click.option("--workspace", default=DEFAULT_WORKSPACE, show_default=True)
73
+ @click.option("--topic", required=True)
74
+ @click.option("--payload", "payload_json", default=None, help="JSON object string")
75
+ @click.option("--payload-file", type=click.Path(exists=True, dir_okay=False), default=None)
76
+ @click.option("--schema-version", default="1.0", show_default=True)
77
+ @click.option("--producer-id", default=None)
78
+ @click.option("--causation-id", type=int, default=None)
79
+ @click.option("--idempotency-key", default=None)
80
+ @click.option("--token", default=None, help="Publish auth token (default: workspace file)")
81
+ @click.option("--retention-days", default=7, show_default=True)
82
+ def publish(
83
+ workspace: str,
84
+ topic: str,
85
+ payload_json: str | None,
86
+ payload_file: str | None,
87
+ schema_version: str,
88
+ producer_id: str | None,
89
+ causation_id: int | None,
90
+ idempotency_key: str | None,
91
+ token: str | None,
92
+ retention_days: int,
93
+ ) -> None:
94
+ """Append one event (CLI fallback for non-MCP clients like Agy)."""
95
+ if payload_file:
96
+ payload = json.loads(Path(payload_file).read_text(encoding="utf-8"))
97
+ elif payload_json:
98
+ payload = json.loads(payload_json)
99
+ else:
100
+ raise click.ClickException("Provide --payload or --payload-file")
101
+
102
+ ws = Path(workspace)
103
+ _auth(ws, token)
104
+ payload = validate_payload(topic, payload)
105
+ store = _open_store(workspace, retention_days)
106
+ try:
107
+ event, duplicate = store.publish(
108
+ topic=topic,
109
+ producer_id=_producer_id(producer_id),
110
+ schema_version=schema_version,
111
+ payload=payload,
112
+ causation_id=causation_id,
113
+ idempotency_key=idempotency_key,
114
+ )
115
+ click.echo(
116
+ json.dumps(
117
+ {
118
+ "event_id": event.event_id,
119
+ "topic": event.topic,
120
+ "timestamp": event.timestamp,
121
+ "duplicate": duplicate,
122
+ }
123
+ )
124
+ )
125
+ finally:
126
+ store.close()
127
+
128
+
129
+ @main.command()
130
+ @click.option("--workspace", default=DEFAULT_WORKSPACE, show_default=True)
131
+ @click.option("--topic", required=True)
132
+ @click.option("--since-id", type=int, default=0, show_default=True)
133
+ @click.option("--limit", type=int, default=50, show_default=True)
134
+ @click.option("--retention-days", default=7, show_default=True)
135
+ def poll(
136
+ workspace: str,
137
+ topic: str,
138
+ since_id: int,
139
+ limit: int,
140
+ retention_days: int,
141
+ ) -> None:
142
+ """Fetch events after cursor."""
143
+ store = _open_store(workspace, retention_days)
144
+ try:
145
+ click.echo(json.dumps(store.poll(topic=topic, since_id=since_id, limit=limit)))
146
+ finally:
147
+ store.close()
148
+
149
+
150
+ @main.command()
151
+ @click.option("--workspace", default=DEFAULT_WORKSPACE, show_default=True)
152
+ @click.option("--producer-id", default=None)
153
+ @click.option("--retention-days", default=7, show_default=True)
154
+ def status(workspace: str, producer_id: str | None, retention_days: int) -> None:
155
+ """Workspace bus health."""
156
+ store = _open_store(workspace, retention_days)
157
+ try:
158
+ pid = producer_id or os.environ.get("AGENTBUS_PRODUCER_ID", "")
159
+ click.echo(json.dumps(store.status(producer_id=pid or None)))
160
+ finally:
161
+ store.close()
162
+
163
+
164
+ @main.group()
165
+ def lock() -> None:
166
+ """Advisory lease locks (Phase 5)."""
167
+
168
+
169
+ @lock.command("acquire")
170
+ @click.option("--workspace", default=DEFAULT_WORKSPACE, show_default=True)
171
+ @click.option("--resource", required=True, help="Absolute path within workspace")
172
+ @click.option("--owner-id", required=True)
173
+ @click.option("--ttl-seconds", type=int, default=None)
174
+ @click.option("--token", default=None)
175
+ def lock_acquire(
176
+ workspace: str,
177
+ resource: str,
178
+ owner_id: str,
179
+ ttl_seconds: int | None,
180
+ token: str | None,
181
+ ) -> None:
182
+ """Acquire a lease on a resource."""
183
+ ws = Path(workspace)
184
+ _auth(ws, token)
185
+ store = _open_lease_store(workspace)
186
+ try:
187
+ click.echo(json.dumps(store.lock_acquire(resource, owner_id, ttl_seconds)))
188
+ finally:
189
+ store.close()
190
+
191
+
192
+ @lock.command("release")
193
+ @click.option("--workspace", default=DEFAULT_WORKSPACE, show_default=True)
194
+ @click.option("--resource", required=True)
195
+ @click.option("--lease-id", required=True)
196
+ @click.option("--owner-id", required=True)
197
+ @click.option("--token", default=None)
198
+ def lock_release(
199
+ workspace: str,
200
+ resource: str,
201
+ lease_id: str,
202
+ owner_id: str,
203
+ token: str | None,
204
+ ) -> None:
205
+ """Release a held lease."""
206
+ ws = Path(workspace)
207
+ _auth(ws, token)
208
+ store = _open_lease_store(workspace)
209
+ try:
210
+ click.echo(json.dumps(store.lock_release(resource, lease_id, owner_id)))
211
+ finally:
212
+ store.close()
213
+
214
+
215
+ @lock.command("renew")
216
+ @click.option("--workspace", default=DEFAULT_WORKSPACE, show_default=True)
217
+ @click.option("--resource", required=True)
218
+ @click.option("--lease-id", required=True)
219
+ @click.option("--owner-id", required=True)
220
+ @click.option("--ttl-seconds", type=int, default=None)
221
+ @click.option("--token", default=None)
222
+ def lock_renew(
223
+ workspace: str,
224
+ resource: str,
225
+ lease_id: str,
226
+ owner_id: str,
227
+ ttl_seconds: int | None,
228
+ token: str | None,
229
+ ) -> None:
230
+ """Renew (extend) a held lease."""
231
+ ws = Path(workspace)
232
+ _auth(ws, token)
233
+ store = _open_lease_store(workspace)
234
+ try:
235
+ click.echo(json.dumps(store.lock_renew(resource, lease_id, owner_id, ttl_seconds)))
236
+ finally:
237
+ store.close()
238
+
239
+
240
+ @lock.command("status")
241
+ @click.option("--workspace", default=DEFAULT_WORKSPACE, show_default=True)
242
+ @click.option("--resource", required=True)
243
+ def lock_status(workspace: str, resource: str) -> None:
244
+ """Check lock state (no auth)."""
245
+ store = _open_lease_store(workspace)
246
+ try:
247
+ click.echo(json.dumps(store.lock_status(resource)))
248
+ finally:
249
+ store.close()
250
+
251
+
252
+ @main.command("project-log")
253
+ @click.option("--workspace", default=DEFAULT_WORKSPACE, show_default=True)
254
+ @click.option(
255
+ "--log-file",
256
+ default="log.md",
257
+ show_default=True,
258
+ help="Log file relative to workspace",
259
+ )
260
+ @click.option("--dry-run", is_flag=True, help="Print lines without writing log.md")
261
+ @click.option("--reset", is_flag=True, help="Re-project all events from event_id 0")
262
+ @click.option("--retention-days", default=7, show_default=True)
263
+ def project_log(
264
+ workspace: str,
265
+ log_file: str,
266
+ dry_run: bool,
267
+ reset: bool,
268
+ retention_days: int,
269
+ ) -> None:
270
+ """Project okf/handoff events into OKF log.md format."""
271
+ ws = Path(workspace)
272
+ log_path = ws / log_file
273
+ store = _open_store(workspace, retention_days)
274
+ try:
275
+ result = project_handoffs(
276
+ store, ws, log_path, dry_run=dry_run, reset=reset
277
+ )
278
+ click.echo(json.dumps({k: v for k, v in result.items() if k != "lines"}))
279
+ if dry_run and result["lines"]:
280
+ click.echo("---")
281
+ click.echo("\n\n".join(result["lines"]))
282
+ finally:
283
+ store.close()
284
+
285
+
286
+ @main.group()
287
+ def token() -> None:
288
+ """Workspace ephemeral token management."""
289
+
290
+
291
+ @token.command("show")
292
+ @click.option("--workspace", default=DEFAULT_WORKSPACE, show_default=True)
293
+ @click.option("--quiet", is_flag=True, help="Print token only (no path)")
294
+ def token_show(workspace: str, quiet: bool) -> None:
295
+ """Print the workspace publish token."""
296
+ ws = Path(workspace)
297
+ value = read_workspace_token(ws)
298
+ if not value:
299
+ raise click.ClickException(
300
+ f"No token at {token_path(ws)} — run: agentbus token ensure"
301
+ )
302
+ if quiet:
303
+ click.echo(value)
304
+ else:
305
+ click.echo(json.dumps({"path": str(token_path(ws)), "token": value}))
306
+
307
+
308
+ @token.command("ensure")
309
+ @click.option("--workspace", default=DEFAULT_WORKSPACE, show_default=True)
310
+ @click.option("--quiet", is_flag=True, help="Print token only")
311
+ def token_ensure(workspace: str, quiet: bool) -> None:
312
+ """Create workspace token if missing."""
313
+ ws = Path(workspace)
314
+ value = ensure_ephemeral_token(ws, rotate=False)
315
+ if quiet:
316
+ click.echo(value)
317
+ else:
318
+ click.echo(
319
+ json.dumps(
320
+ {"path": str(token_path(ws)), "token": value, "created": True}
321
+ )
322
+ )
323
+
324
+
325
+ @token.command("rotate")
326
+ @click.option("--workspace", default=DEFAULT_WORKSPACE, show_default=True)
327
+ @click.option("--quiet", is_flag=True, help="Print token only")
328
+ def token_rotate(workspace: str, quiet: bool) -> None:
329
+ """Regenerate workspace publish token."""
330
+ ws = Path(workspace)
331
+ value = ensure_ephemeral_token(ws, rotate=True)
332
+ if quiet:
333
+ click.echo(value)
334
+ else:
335
+ click.echo(
336
+ json.dumps(
337
+ {"path": str(token_path(ws)), "token": value, "rotated": True}
338
+ )
339
+ )
340
+
341
+
342
+ if __name__ == "__main__":
343
+ main()
agentbus/leases.py ADDED
@@ -0,0 +1,202 @@
1
+ """Advisory lease locks — Phase 5 (persisted in events.db)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sqlite3
7
+ import uuid
8
+ from datetime import datetime, timedelta, timezone
9
+ from pathlib import Path
10
+
11
+ DEFAULT_TTL_SECONDS = 300
12
+ MAX_TTL_SECONDS = 3600
13
+ OWNER_PATTERN = re.compile(r"^[a-z][a-z0-9_-]*$")
14
+
15
+
16
+ def validate_owner_id(owner_id: str) -> None:
17
+ if not owner_id or not OWNER_PATTERN.match(owner_id):
18
+ raise ValueError(f"invalid_owner_id: {owner_id}")
19
+
20
+
21
+ def normalize_resource(workspace: Path, resource: str) -> str:
22
+ if not resource:
23
+ raise ValueError("invalid_resource: empty path")
24
+ path = Path(resource).expanduser().resolve()
25
+ ws = workspace.resolve()
26
+ try:
27
+ path.relative_to(ws)
28
+ except ValueError as exc:
29
+ raise ValueError(f"resource_outside_workspace: {resource}") from exc
30
+ return str(path)
31
+
32
+
33
+ def clamp_ttl(ttl_seconds: int | None, default: int = DEFAULT_TTL_SECONDS) -> int:
34
+ if ttl_seconds is None:
35
+ return default
36
+ if ttl_seconds < 1:
37
+ raise ValueError("invalid_ttl: must be >= 1")
38
+ if ttl_seconds > MAX_TTL_SECONDS:
39
+ raise ValueError(f"invalid_ttl: max {MAX_TTL_SECONDS}")
40
+ return ttl_seconds
41
+
42
+
43
+ def _utc_now() -> datetime:
44
+ return datetime.now(timezone.utc)
45
+
46
+
47
+ def _fmt(dt: datetime) -> str:
48
+ return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
49
+
50
+
51
+ def _parse(ts: str) -> datetime:
52
+ return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
53
+
54
+
55
+ class LeaseStore:
56
+ """Lease locks stored in the workspace events.db `leases` table."""
57
+
58
+ def __init__(self, workspace: Path, default_ttl: int = DEFAULT_TTL_SECONDS) -> None:
59
+ self.workspace = workspace.resolve()
60
+ self.default_ttl = default_ttl
61
+ db_dir = self.workspace / ".agentbus"
62
+ db_dir.mkdir(parents=True, exist_ok=True)
63
+ self.db_path = db_dir / "events.db"
64
+ self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
65
+ self._conn.row_factory = sqlite3.Row
66
+ self._init_schema()
67
+
68
+ def _init_schema(self) -> None:
69
+ self._conn.executescript(
70
+ """
71
+ CREATE TABLE IF NOT EXISTS leases (
72
+ lease_id TEXT PRIMARY KEY,
73
+ resource TEXT NOT NULL UNIQUE,
74
+ owner_id TEXT NOT NULL,
75
+ acquired_at TEXT NOT NULL,
76
+ expires_at TEXT NOT NULL
77
+ );
78
+ CREATE INDEX IF NOT EXISTS idx_leases_resource ON leases(resource);
79
+ CREATE INDEX IF NOT EXISTS idx_leases_expires ON leases(expires_at);
80
+ """
81
+ )
82
+ self._conn.commit()
83
+
84
+ def close(self) -> None:
85
+ self._conn.close()
86
+
87
+ def _purge_expired(self) -> None:
88
+ cutoff = _fmt(_utc_now())
89
+ self._conn.execute("DELETE FROM leases WHERE expires_at <= ?", (cutoff,))
90
+ self._conn.commit()
91
+
92
+ def _active_row(self, resource: str) -> sqlite3.Row | None:
93
+ self._purge_expired()
94
+ return self._conn.execute(
95
+ "SELECT * FROM leases WHERE resource = ?",
96
+ (resource,),
97
+ ).fetchone()
98
+
99
+ def lock_acquire(
100
+ self,
101
+ resource: str,
102
+ owner_id: str,
103
+ ttl_seconds: int | None = None,
104
+ ) -> dict:
105
+ validate_owner_id(owner_id)
106
+ path = normalize_resource(self.workspace, resource)
107
+ ttl = clamp_ttl(ttl_seconds, self.default_ttl)
108
+ now = _utc_now()
109
+ expires = now + timedelta(seconds=ttl)
110
+
111
+ existing = self._active_row(path)
112
+ if existing:
113
+ if existing["owner_id"] == owner_id:
114
+ return {
115
+ "acquired": True,
116
+ "lease_id": existing["lease_id"],
117
+ "expires_at": existing["expires_at"],
118
+ "resource": path,
119
+ }
120
+ return {
121
+ "acquired": False,
122
+ "current_owner": existing["owner_id"],
123
+ "expires_at": existing["expires_at"],
124
+ "resource": path,
125
+ }
126
+
127
+ lease_id = str(uuid.uuid4())
128
+ self._conn.execute(
129
+ """
130
+ INSERT INTO leases (lease_id, resource, owner_id, acquired_at, expires_at)
131
+ VALUES (?, ?, ?, ?, ?)
132
+ """,
133
+ (lease_id, path, owner_id, _fmt(now), _fmt(expires)),
134
+ )
135
+ self._conn.commit()
136
+ return {
137
+ "acquired": True,
138
+ "lease_id": lease_id,
139
+ "expires_at": _fmt(expires),
140
+ "resource": path,
141
+ }
142
+
143
+ def lock_release(self, resource: str, lease_id: str, owner_id: str) -> dict:
144
+ validate_owner_id(owner_id)
145
+ path = normalize_resource(self.workspace, resource)
146
+ self._purge_expired()
147
+ row = self._conn.execute(
148
+ "SELECT * FROM leases WHERE resource = ? AND lease_id = ?",
149
+ (path, lease_id),
150
+ ).fetchone()
151
+ if not row:
152
+ return {"released": True, "resource": path}
153
+ if row["owner_id"] != owner_id:
154
+ raise ValueError("invalid_owner: owner_id does not hold this lease")
155
+ self._conn.execute(
156
+ "DELETE FROM leases WHERE lease_id = ?",
157
+ (lease_id,),
158
+ )
159
+ self._conn.commit()
160
+ return {"released": True, "resource": path}
161
+
162
+ def lock_renew(
163
+ self,
164
+ resource: str,
165
+ lease_id: str,
166
+ owner_id: str,
167
+ ttl_seconds: int | None = None,
168
+ ) -> dict:
169
+ validate_owner_id(owner_id)
170
+ path = normalize_resource(self.workspace, resource)
171
+ ttl = clamp_ttl(ttl_seconds, self.default_ttl)
172
+ self._purge_expired()
173
+ row = self._conn.execute(
174
+ "SELECT * FROM leases WHERE resource = ? AND lease_id = ?",
175
+ (path, lease_id),
176
+ ).fetchone()
177
+ if not row or row["owner_id"] != owner_id:
178
+ return {"renewed": False, "resource": path}
179
+ expires = _utc_now() + timedelta(seconds=ttl)
180
+ self._conn.execute(
181
+ "UPDATE leases SET expires_at = ? WHERE lease_id = ?",
182
+ (_fmt(expires), lease_id),
183
+ )
184
+ self._conn.commit()
185
+ return {"renewed": True, "expires_at": _fmt(expires), "resource": path}
186
+
187
+ def lock_status(self, resource: str) -> dict:
188
+ path = normalize_resource(self.workspace, resource)
189
+ row = self._active_row(path)
190
+ if not row:
191
+ return {
192
+ "locked": False,
193
+ "resource": path,
194
+ }
195
+ return {
196
+ "locked": True,
197
+ "resource": path,
198
+ "lease_id": row["lease_id"],
199
+ "current_owner": row["owner_id"],
200
+ "acquired_at": row["acquired_at"],
201
+ "expires_at": row["expires_at"],
202
+ }