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 +3 -0
- agentbus/auth.py +81 -0
- agentbus/cli.py +343 -0
- agentbus/leases.py +202 -0
- agentbus/project_log.py +117 -0
- agentbus/schemas.py +85 -0
- agentbus/server.py +160 -0
- agentbus/store.py +172 -0
- okf_agentbus-0.2.3.dist-info/METADATA +195 -0
- okf_agentbus-0.2.3.dist-info/RECORD +13 -0
- okf_agentbus-0.2.3.dist-info/WHEEL +4 -0
- okf_agentbus-0.2.3.dist-info/entry_points.txt +2 -0
- okf_agentbus-0.2.3.dist-info/licenses/LICENSE +21 -0
agentbus/__init__.py
ADDED
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
|
+
}
|