scientific-protocol 0.1.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.
- scientific_protocol-0.1.0.dist-info/METADATA +103 -0
- scientific_protocol-0.1.0.dist-info/RECORD +6 -0
- scientific_protocol-0.1.0.dist-info/WHEEL +5 -0
- scientific_protocol-0.1.0.dist-info/entry_points.txt +2 -0
- scientific_protocol-0.1.0.dist-info/top_level.txt +1 -0
- scientific_protocol_client.py +609 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scientific-protocol
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client and operator CLI for Scientific Protocol.
|
|
5
|
+
Author: Scientific Protocol
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://scientificprotocol.org
|
|
8
|
+
Project-URL: Documentation, https://scientificprotocol.org
|
|
9
|
+
Project-URL: Repository, https://github.com/emgun/scientific-protocol
|
|
10
|
+
Project-URL: Issues, https://github.com/emgun/scientific-protocol/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/emgun/scientific-protocol/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: ethereum,protocol,replication,science,scientific-computing,web3
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# Scientific Protocol Python Client
|
|
27
|
+
|
|
28
|
+
This package provides a lightweight Python client for the Scientific Protocol API plus a small
|
|
29
|
+
operator CLI for the hybrid agent runtime.
|
|
30
|
+
|
|
31
|
+
It is intentionally stdlib-first. The only external runtime dependency for signed actions is
|
|
32
|
+
Foundry `cast` when you use `CastAgentSigner`.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
From PyPI:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install scientific-protocol
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or from the repo:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
cd /Users/emerygunselman/Code/scientific-protocol
|
|
46
|
+
python3 -m pip install -e python
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## CLI
|
|
50
|
+
|
|
51
|
+
The package installs `sp-agent-client`.
|
|
52
|
+
|
|
53
|
+
Examples:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
SP_API_BASE_URL=http://127.0.0.1:3000 sp-agent-client health
|
|
57
|
+
SP_API_BASE_URL=http://127.0.0.1:3000 sp-agent-client list-work-items --claimable --limit 10
|
|
58
|
+
SP_API_BASE_URL=http://127.0.0.1:3000 sp-agent-client runtime-events --agent-id 1 --limit 25
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Webhook subscription creation is also available:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
SP_API_BASE_URL=http://127.0.0.1:3000 \
|
|
65
|
+
sp-agent-client create-webhook-subscription \
|
|
66
|
+
--agent-id 1 \
|
|
67
|
+
--private-key 0x... \
|
|
68
|
+
--target-url https://example.com/sp-webhooks \
|
|
69
|
+
--event-type review.submitted \
|
|
70
|
+
--event-type work.claimed
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Webhook signature verification
|
|
74
|
+
|
|
75
|
+
The module exposes `verify_webhook_signature(...)` for receivers.
|
|
76
|
+
|
|
77
|
+
You can also verify a payload directly from the CLI:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
sp-agent-client verify-webhook-signature \
|
|
81
|
+
--secret ospwhsec_... \
|
|
82
|
+
--timestamp 2026-04-13T12:00:00.000Z \
|
|
83
|
+
--signature v1=... \
|
|
84
|
+
--payload-file payload.json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Python API
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from scientific_protocol_client import ScientificProtocolClient
|
|
91
|
+
|
|
92
|
+
client = ScientificProtocolClient("http://127.0.0.1:3000")
|
|
93
|
+
items = client.list_work_items(claimable=True, limit=10)
|
|
94
|
+
print(items["items"][0]["itemId"])
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
For signed agent requests, use:
|
|
98
|
+
|
|
99
|
+
- `create_signed_agent_request(...)`
|
|
100
|
+
- `CastAgentSigner`
|
|
101
|
+
- `claim_work_item(...)`
|
|
102
|
+
- `heartbeat_work_item(...)`
|
|
103
|
+
- `submit_work_results(...)`
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
scientific_protocol_client.py,sha256=0_-F1nrioc1hQtTVUNhkQyvHa1jDywNlZfHxaIYrMps,20621
|
|
2
|
+
scientific_protocol-0.1.0.dist-info/METADATA,sha256=l_ZQ3rd0etH_8u-Em4E-7s7X3XDhAlzPFmTG68ja0lU,3044
|
|
3
|
+
scientific_protocol-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
4
|
+
scientific_protocol-0.1.0.dist-info/entry_points.txt,sha256=HB1dK08UVbX9DfNzHiU9IClTJA5n30sHcOFi4Kf-qoo,68
|
|
5
|
+
scientific_protocol-0.1.0.dist-info/top_level.txt,sha256=vpMahqauMcxEnL4Ka5B-g5iTzSxL7EDrbf80iLw73WY,27
|
|
6
|
+
scientific_protocol-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
scientific_protocol_client
|
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import hmac
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
from urllib.error import HTTPError, URLError
|
|
15
|
+
from urllib.parse import quote, urlencode
|
|
16
|
+
from urllib.request import Request, urlopen
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ScientificProtocolApiError(Exception):
|
|
21
|
+
status: int
|
|
22
|
+
body: Any
|
|
23
|
+
|
|
24
|
+
def __str__(self) -> str:
|
|
25
|
+
return f"Scientific Protocol API request failed with status {self.status}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AgentRequestSigner(Protocol):
|
|
29
|
+
def get_address(self) -> str: ...
|
|
30
|
+
|
|
31
|
+
def sign_message(self, message: str) -> str: ...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CastAgentSigner:
|
|
36
|
+
private_key: str
|
|
37
|
+
cast_binary: str = "cast"
|
|
38
|
+
|
|
39
|
+
def get_address(self) -> str:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
[self.cast_binary, "wallet", "address", "--private-key", self.private_key],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
check=True,
|
|
44
|
+
text=True,
|
|
45
|
+
)
|
|
46
|
+
return result.stdout.strip()
|
|
47
|
+
|
|
48
|
+
def sign_message(self, message: str) -> str:
|
|
49
|
+
result = subprocess.run(
|
|
50
|
+
[self.cast_binary, "wallet", "sign", "--private-key", self.private_key, message],
|
|
51
|
+
capture_output=True,
|
|
52
|
+
check=True,
|
|
53
|
+
text=True,
|
|
54
|
+
)
|
|
55
|
+
return result.stdout.strip()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _stable_serialize(value: Any) -> str:
|
|
59
|
+
if isinstance(value, list):
|
|
60
|
+
return "[" + ",".join(_stable_serialize(item) for item in value) + "]"
|
|
61
|
+
if isinstance(value, dict):
|
|
62
|
+
entries: list[str] = []
|
|
63
|
+
for key in sorted(value.keys()):
|
|
64
|
+
entries.append(json.dumps(str(key)) + ":" + _stable_serialize(value[key]))
|
|
65
|
+
return "{" + ",".join(entries) + "}"
|
|
66
|
+
if isinstance(value, bool) or value is None:
|
|
67
|
+
return json.dumps(value)
|
|
68
|
+
if isinstance(value, int):
|
|
69
|
+
return json.dumps(str(value))
|
|
70
|
+
return json.dumps(value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def hash_agent_request_envelope(envelope: dict[str, Any]) -> str:
|
|
74
|
+
serialized = _stable_serialize(envelope).encode("utf-8")
|
|
75
|
+
return "0x" + hashlib.sha256(serialized).hexdigest()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def sign_webhook_payload(*, payload_body: str, secret: str, timestamp: str) -> str:
|
|
79
|
+
digest = hmac.new(
|
|
80
|
+
secret.encode("utf-8"),
|
|
81
|
+
f"{timestamp}.{payload_body}".encode("utf-8"),
|
|
82
|
+
hashlib.sha256,
|
|
83
|
+
).hexdigest()
|
|
84
|
+
return f"v1={digest}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def verify_webhook_signature(
|
|
88
|
+
*,
|
|
89
|
+
payload_body: str,
|
|
90
|
+
secret: str,
|
|
91
|
+
signature: str,
|
|
92
|
+
timestamp: str,
|
|
93
|
+
) -> bool:
|
|
94
|
+
expected = sign_webhook_payload(payload_body=payload_body, secret=secret, timestamp=timestamp)
|
|
95
|
+
return hmac.compare_digest(expected, signature)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def create_signed_agent_request(
|
|
99
|
+
*,
|
|
100
|
+
action_type: str,
|
|
101
|
+
agent_id: str | int,
|
|
102
|
+
payload: dict[str, Any],
|
|
103
|
+
scope_key: str,
|
|
104
|
+
signer: AgentRequestSigner,
|
|
105
|
+
actor_address: str | None = None,
|
|
106
|
+
issued_at: str | None = None,
|
|
107
|
+
request_nonce: str | None = None,
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
resolved_actor = actor_address or signer.get_address()
|
|
110
|
+
envelope = {
|
|
111
|
+
"actionType": action_type,
|
|
112
|
+
"actorAddress": resolved_actor,
|
|
113
|
+
"agentId": str(agent_id),
|
|
114
|
+
"issuedAt": issued_at or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
115
|
+
"payload": payload,
|
|
116
|
+
"requestNonce": request_nonce or secrets.token_hex(16),
|
|
117
|
+
"scopeKey": scope_key,
|
|
118
|
+
}
|
|
119
|
+
request_hash = hash_agent_request_envelope(envelope)
|
|
120
|
+
signature = signer.sign_message(request_hash)
|
|
121
|
+
return {
|
|
122
|
+
"envelope": envelope,
|
|
123
|
+
"signature": signature,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_work_item_id(item_id: str) -> tuple[str, str]:
|
|
128
|
+
separator = item_id.find(":")
|
|
129
|
+
if separator <= 0 or separator >= len(item_id) - 1:
|
|
130
|
+
raise ValueError(f"unsupported_work_item_id:{item_id}")
|
|
131
|
+
return item_id[:separator], item_id[separator + 1 :]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _claim_route_for_item(item_id: str) -> str:
|
|
135
|
+
item_key, source_id = _parse_work_item_id(item_id)
|
|
136
|
+
if item_key == "review-task":
|
|
137
|
+
return f"/agent/review-tasks/{source_id}/claim"
|
|
138
|
+
if item_key == "artifact-maintenance":
|
|
139
|
+
return f"/agent/artifact-maintenance-tasks/{source_id}/claim"
|
|
140
|
+
if item_key == "replication-job":
|
|
141
|
+
return f"/agent/replication-jobs/{source_id}/claim"
|
|
142
|
+
raise ValueError(f"unsupported_claimable_work_item:{item_id}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _heartbeat_route_for_item(item_id: str) -> str:
|
|
146
|
+
item_key, source_id = _parse_work_item_id(item_id)
|
|
147
|
+
if item_key == "review-task":
|
|
148
|
+
return f"/agent/review-tasks/{source_id}/heartbeat"
|
|
149
|
+
if item_key == "artifact-maintenance":
|
|
150
|
+
return f"/agent/artifact-maintenance-tasks/{source_id}/heartbeat"
|
|
151
|
+
if item_key == "replication-job":
|
|
152
|
+
return f"/agent/replication-jobs/{source_id}/heartbeat"
|
|
153
|
+
raise ValueError(f"unsupported_heartbeatable_work_item:{item_id}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _submission_route_for_item(item_id: str, action_type: str) -> str:
|
|
157
|
+
item_key, source_id = _parse_work_item_id(item_id)
|
|
158
|
+
if item_key == "review-task" and action_type == "review_task_submission":
|
|
159
|
+
return f"/agent/review-tasks/{source_id}/submissions"
|
|
160
|
+
if item_key == "artifact-maintenance":
|
|
161
|
+
if action_type == "artifact_task_audit_submission":
|
|
162
|
+
return f"/agent/artifact-maintenance-tasks/{source_id}/audit-results"
|
|
163
|
+
if action_type == "artifact_task_repair_submission":
|
|
164
|
+
return f"/agent/artifact-maintenance-tasks/{source_id}/repair-results"
|
|
165
|
+
if item_key == "replication-job" and action_type == "replication_job_submission":
|
|
166
|
+
return f"/agent/replication-jobs/{source_id}/submissions"
|
|
167
|
+
raise ValueError(f"unsupported_work_result_submission:{item_id}:{action_type}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class ScientificProtocolClient:
|
|
171
|
+
def __init__(self, base_url: str, timeout: float = 30.0) -> None:
|
|
172
|
+
self.base_url = base_url.rstrip("/")
|
|
173
|
+
self.timeout = timeout
|
|
174
|
+
|
|
175
|
+
def get_health(self) -> dict[str, Any]:
|
|
176
|
+
return self._request("GET", "/health")
|
|
177
|
+
|
|
178
|
+
def get_claim(self, claim_id: str | int, view: str = "full") -> dict[str, Any]:
|
|
179
|
+
return self._request("GET", f"/claims/{claim_id}", {"view": view})
|
|
180
|
+
|
|
181
|
+
def get_work_item(
|
|
182
|
+
self,
|
|
183
|
+
item_id: str,
|
|
184
|
+
claim_id: str | int | None = None,
|
|
185
|
+
) -> dict[str, Any]:
|
|
186
|
+
query: dict[str, Any] = {}
|
|
187
|
+
if claim_id is not None:
|
|
188
|
+
query["claimId"] = claim_id
|
|
189
|
+
encoded_item_id = quote(item_id, safe="")
|
|
190
|
+
return self._request("GET", f"/work-items/{encoded_item_id}", query)
|
|
191
|
+
|
|
192
|
+
def list_work_items(
|
|
193
|
+
self,
|
|
194
|
+
*,
|
|
195
|
+
claim_id: str | int | None = None,
|
|
196
|
+
claimable: bool | None = None,
|
|
197
|
+
kind: str | None = None,
|
|
198
|
+
lane: str | None = None,
|
|
199
|
+
limit: int | None = None,
|
|
200
|
+
offset: int | None = None,
|
|
201
|
+
status: str | None = None,
|
|
202
|
+
) -> dict[str, Any]:
|
|
203
|
+
return self._request(
|
|
204
|
+
"GET",
|
|
205
|
+
"/work-items",
|
|
206
|
+
{
|
|
207
|
+
"claimId": claim_id,
|
|
208
|
+
"claimable": claimable,
|
|
209
|
+
"kind": kind,
|
|
210
|
+
"lane": lane,
|
|
211
|
+
"limit": limit,
|
|
212
|
+
"offset": offset,
|
|
213
|
+
"status": status,
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def get_agent_review_calibration(
|
|
218
|
+
self,
|
|
219
|
+
agent_id: str | int,
|
|
220
|
+
*,
|
|
221
|
+
limit: int | None = None,
|
|
222
|
+
offset: int | None = None,
|
|
223
|
+
) -> dict[str, Any]:
|
|
224
|
+
return self._request(
|
|
225
|
+
"GET",
|
|
226
|
+
f"/agents/{agent_id}/review-calibration",
|
|
227
|
+
{
|
|
228
|
+
"limit": limit,
|
|
229
|
+
"offset": offset,
|
|
230
|
+
},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def get_agent_work_summary(
|
|
234
|
+
self,
|
|
235
|
+
agent_id: str | int,
|
|
236
|
+
*,
|
|
237
|
+
domain_id: int | None = None,
|
|
238
|
+
) -> dict[str, Any]:
|
|
239
|
+
return self._request(
|
|
240
|
+
"GET",
|
|
241
|
+
f"/agents/{agent_id}/work-summary",
|
|
242
|
+
{
|
|
243
|
+
"domainId": domain_id,
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def get_agent_runtime_events(
|
|
248
|
+
self,
|
|
249
|
+
*,
|
|
250
|
+
agent_id: str | int | None = None,
|
|
251
|
+
claim_id: str | int | None = None,
|
|
252
|
+
limit: int | None = None,
|
|
253
|
+
offset: int | None = None,
|
|
254
|
+
since: str | None = None,
|
|
255
|
+
) -> dict[str, Any]:
|
|
256
|
+
return self._request(
|
|
257
|
+
"GET",
|
|
258
|
+
"/agent-runtime/events",
|
|
259
|
+
{
|
|
260
|
+
"agentId": agent_id,
|
|
261
|
+
"claimId": claim_id,
|
|
262
|
+
"limit": limit,
|
|
263
|
+
"offset": offset,
|
|
264
|
+
"since": since,
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def get_agent_webhook_subscription(self, subscription_id: str | int) -> dict[str, Any]:
|
|
269
|
+
return self._request("GET", f"/agent-webhook-subscriptions/{subscription_id}")
|
|
270
|
+
|
|
271
|
+
def get_agent_webhook_deliveries(
|
|
272
|
+
self,
|
|
273
|
+
*,
|
|
274
|
+
agent_id: str | int | None = None,
|
|
275
|
+
limit: int | None = None,
|
|
276
|
+
offset: int | None = None,
|
|
277
|
+
status: str | None = None,
|
|
278
|
+
subscription_id: str | int | None = None,
|
|
279
|
+
) -> dict[str, Any]:
|
|
280
|
+
return self._request(
|
|
281
|
+
"GET",
|
|
282
|
+
"/agent-webhook-deliveries",
|
|
283
|
+
{
|
|
284
|
+
"agentId": agent_id,
|
|
285
|
+
"limit": limit,
|
|
286
|
+
"offset": offset,
|
|
287
|
+
"status": status,
|
|
288
|
+
"subscriptionId": subscription_id,
|
|
289
|
+
},
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def get_agent_webhook_delivery(self, delivery_id: str | int) -> dict[str, Any]:
|
|
293
|
+
return self._request("GET", f"/agent-webhook-deliveries/{delivery_id}")
|
|
294
|
+
|
|
295
|
+
def get_agent_webhook_subscriptions(
|
|
296
|
+
self,
|
|
297
|
+
*,
|
|
298
|
+
agent_id: str | int | None = None,
|
|
299
|
+
limit: int | None = None,
|
|
300
|
+
offset: int | None = None,
|
|
301
|
+
status: str | None = None,
|
|
302
|
+
) -> dict[str, Any]:
|
|
303
|
+
return self._request(
|
|
304
|
+
"GET",
|
|
305
|
+
"/agent-webhook-subscriptions",
|
|
306
|
+
{
|
|
307
|
+
"agentId": agent_id,
|
|
308
|
+
"limit": limit,
|
|
309
|
+
"offset": offset,
|
|
310
|
+
"status": status,
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def claim_work_item(self, item_id: str, signed_request: dict[str, Any]) -> dict[str, Any]:
|
|
315
|
+
return self._request("POST", _claim_route_for_item(item_id), body=signed_request)
|
|
316
|
+
|
|
317
|
+
def heartbeat_work_item(self, item_id: str, signed_request: dict[str, Any]) -> dict[str, Any]:
|
|
318
|
+
return self._request("POST", _heartbeat_route_for_item(item_id), body=signed_request)
|
|
319
|
+
|
|
320
|
+
def submit_work_results(self, item_id: str, signed_request: dict[str, Any]) -> dict[str, Any]:
|
|
321
|
+
action_type = str(signed_request["envelope"]["actionType"])
|
|
322
|
+
return self._request(
|
|
323
|
+
"POST",
|
|
324
|
+
_submission_route_for_item(item_id, action_type),
|
|
325
|
+
body=signed_request,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def create_agent_webhook_subscription(
|
|
329
|
+
self,
|
|
330
|
+
*,
|
|
331
|
+
agent_id: str | int,
|
|
332
|
+
signer: AgentRequestSigner,
|
|
333
|
+
target_url: str,
|
|
334
|
+
event_types: list[str] | None = None,
|
|
335
|
+
label: str | None = None,
|
|
336
|
+
signing_secret: str | None = None,
|
|
337
|
+
actor_address: str | None = None,
|
|
338
|
+
issued_at: str | None = None,
|
|
339
|
+
request_nonce: str | None = None,
|
|
340
|
+
) -> dict[str, Any]:
|
|
341
|
+
signed_request = create_signed_agent_request(
|
|
342
|
+
action_type="webhook_subscription_create",
|
|
343
|
+
actor_address=actor_address,
|
|
344
|
+
agent_id=agent_id,
|
|
345
|
+
issued_at=issued_at,
|
|
346
|
+
payload={
|
|
347
|
+
"eventTypes": event_types,
|
|
348
|
+
"label": label,
|
|
349
|
+
"signingSecret": signing_secret,
|
|
350
|
+
"targetUrl": target_url,
|
|
351
|
+
},
|
|
352
|
+
request_nonce=request_nonce,
|
|
353
|
+
scope_key=f"agent-webhook-subscriptions:{agent_id}",
|
|
354
|
+
signer=signer,
|
|
355
|
+
)
|
|
356
|
+
return self._request("POST", "/agent/webhook-subscriptions", body=signed_request)
|
|
357
|
+
|
|
358
|
+
def delete_agent_webhook_subscription(
|
|
359
|
+
self,
|
|
360
|
+
*,
|
|
361
|
+
agent_id: str | int,
|
|
362
|
+
signer: AgentRequestSigner,
|
|
363
|
+
subscription_id: str | int,
|
|
364
|
+
actor_address: str | None = None,
|
|
365
|
+
issued_at: str | None = None,
|
|
366
|
+
request_nonce: str | None = None,
|
|
367
|
+
) -> dict[str, Any]:
|
|
368
|
+
signed_request = create_signed_agent_request(
|
|
369
|
+
action_type="webhook_subscription_delete",
|
|
370
|
+
actor_address=actor_address,
|
|
371
|
+
agent_id=agent_id,
|
|
372
|
+
issued_at=issued_at,
|
|
373
|
+
payload={},
|
|
374
|
+
request_nonce=request_nonce,
|
|
375
|
+
scope_key=f"agent-webhook-subscription:{subscription_id}",
|
|
376
|
+
signer=signer,
|
|
377
|
+
)
|
|
378
|
+
return self._request(
|
|
379
|
+
"POST",
|
|
380
|
+
f"/agent/webhook-subscriptions/{subscription_id}/delete",
|
|
381
|
+
body=signed_request,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
def ping_agent_webhook_subscription(
|
|
385
|
+
self,
|
|
386
|
+
*,
|
|
387
|
+
agent_id: str | int,
|
|
388
|
+
signer: AgentRequestSigner,
|
|
389
|
+
subscription_id: str | int,
|
|
390
|
+
actor_address: str | None = None,
|
|
391
|
+
issued_at: str | None = None,
|
|
392
|
+
request_nonce: str | None = None,
|
|
393
|
+
) -> dict[str, Any]:
|
|
394
|
+
signed_request = create_signed_agent_request(
|
|
395
|
+
action_type="webhook_subscription_ping",
|
|
396
|
+
actor_address=actor_address,
|
|
397
|
+
agent_id=agent_id,
|
|
398
|
+
issued_at=issued_at,
|
|
399
|
+
payload={},
|
|
400
|
+
request_nonce=request_nonce,
|
|
401
|
+
scope_key=f"agent-webhook-subscription:{subscription_id}",
|
|
402
|
+
signer=signer,
|
|
403
|
+
)
|
|
404
|
+
return self._request(
|
|
405
|
+
"POST",
|
|
406
|
+
f"/agent/webhook-subscriptions/{subscription_id}/ping",
|
|
407
|
+
body=signed_request,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
def _request(
|
|
411
|
+
self,
|
|
412
|
+
method: str,
|
|
413
|
+
path: str,
|
|
414
|
+
query: dict[str, Any] | None = None,
|
|
415
|
+
*,
|
|
416
|
+
body: Any | None = None,
|
|
417
|
+
) -> dict[str, Any]:
|
|
418
|
+
cleaned_query = {
|
|
419
|
+
key: value
|
|
420
|
+
for key, value in (query or {}).items()
|
|
421
|
+
if value is not None and value != ""
|
|
422
|
+
}
|
|
423
|
+
query_string = urlencode(cleaned_query)
|
|
424
|
+
url = f"{self.base_url}{path}"
|
|
425
|
+
if query_string:
|
|
426
|
+
url = f"{url}?{query_string}"
|
|
427
|
+
|
|
428
|
+
headers = {"accept": "application/json"}
|
|
429
|
+
encoded_body: bytes | None = None
|
|
430
|
+
if body is not None:
|
|
431
|
+
encoded_body = json.dumps(body).encode("utf-8")
|
|
432
|
+
headers["content-type"] = "application/json"
|
|
433
|
+
|
|
434
|
+
request = Request(url, data=encoded_body, headers=headers, method=method.upper())
|
|
435
|
+
try:
|
|
436
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
437
|
+
raw_body = response.read().decode("utf-8")
|
|
438
|
+
except HTTPError as error:
|
|
439
|
+
raise ScientificProtocolApiError(error.code, self._decode_error_body(error)) from error
|
|
440
|
+
except URLError as error:
|
|
441
|
+
raise RuntimeError(f"Scientific Protocol API request failed: {error.reason}") from error
|
|
442
|
+
|
|
443
|
+
if not raw_body:
|
|
444
|
+
return {}
|
|
445
|
+
return json.loads(raw_body)
|
|
446
|
+
|
|
447
|
+
@staticmethod
|
|
448
|
+
def _decode_error_body(error: HTTPError) -> Any:
|
|
449
|
+
raw = error.read().decode("utf-8")
|
|
450
|
+
if not raw:
|
|
451
|
+
return None
|
|
452
|
+
try:
|
|
453
|
+
return json.loads(raw)
|
|
454
|
+
except json.JSONDecodeError:
|
|
455
|
+
return raw
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _print_json(value: Any) -> None:
|
|
459
|
+
print(json.dumps(value, indent=2, sort_keys=True))
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _required_base_url(value: str | None) -> str:
|
|
463
|
+
resolved = value or os.environ.get("SP_API_BASE_URL")
|
|
464
|
+
if not resolved:
|
|
465
|
+
raise SystemExit("missing_api_base_url: pass --base-url or set SP_API_BASE_URL")
|
|
466
|
+
return resolved
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def build_cli_parser() -> argparse.ArgumentParser:
|
|
470
|
+
parser = argparse.ArgumentParser(prog="sp-agent-client")
|
|
471
|
+
parser.add_argument("--base-url", help="Scientific Protocol API base URL")
|
|
472
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
473
|
+
|
|
474
|
+
subparsers.add_parser("health")
|
|
475
|
+
|
|
476
|
+
list_work = subparsers.add_parser("list-work-items")
|
|
477
|
+
list_work.add_argument("--claim-id")
|
|
478
|
+
list_work.add_argument("--claimable", action="store_true")
|
|
479
|
+
list_work.add_argument("--kind")
|
|
480
|
+
list_work.add_argument("--lane")
|
|
481
|
+
list_work.add_argument("--limit", type=int)
|
|
482
|
+
list_work.add_argument("--offset", type=int)
|
|
483
|
+
list_work.add_argument("--status")
|
|
484
|
+
|
|
485
|
+
get_work = subparsers.add_parser("get-work-item")
|
|
486
|
+
get_work.add_argument("item_id")
|
|
487
|
+
get_work.add_argument("--claim-id")
|
|
488
|
+
|
|
489
|
+
runtime_events = subparsers.add_parser("runtime-events")
|
|
490
|
+
runtime_events.add_argument("--agent-id")
|
|
491
|
+
runtime_events.add_argument("--claim-id")
|
|
492
|
+
runtime_events.add_argument("--limit", type=int)
|
|
493
|
+
runtime_events.add_argument("--offset", type=int)
|
|
494
|
+
runtime_events.add_argument("--since")
|
|
495
|
+
|
|
496
|
+
agent_summary = subparsers.add_parser("agent-work-summary")
|
|
497
|
+
agent_summary.add_argument("agent_id")
|
|
498
|
+
agent_summary.add_argument("--domain-id", type=int)
|
|
499
|
+
|
|
500
|
+
agent_calibration = subparsers.add_parser("agent-review-calibration")
|
|
501
|
+
agent_calibration.add_argument("agent_id")
|
|
502
|
+
agent_calibration.add_argument("--limit", type=int)
|
|
503
|
+
agent_calibration.add_argument("--offset", type=int)
|
|
504
|
+
|
|
505
|
+
create_webhook = subparsers.add_parser("create-webhook-subscription")
|
|
506
|
+
create_webhook.add_argument("--agent-id", required=True)
|
|
507
|
+
create_webhook.add_argument("--private-key", required=True)
|
|
508
|
+
create_webhook.add_argument("--target-url", required=True)
|
|
509
|
+
create_webhook.add_argument("--event-type", action="append", dest="event_types")
|
|
510
|
+
create_webhook.add_argument("--label")
|
|
511
|
+
create_webhook.add_argument("--signing-secret")
|
|
512
|
+
create_webhook.add_argument("--cast-binary", default="cast")
|
|
513
|
+
|
|
514
|
+
verify_webhook = subparsers.add_parser("verify-webhook-signature")
|
|
515
|
+
verify_webhook.add_argument("--secret", required=True)
|
|
516
|
+
verify_webhook.add_argument("--timestamp", required=True)
|
|
517
|
+
verify_webhook.add_argument("--signature", required=True)
|
|
518
|
+
verify_webhook.add_argument("--payload-body")
|
|
519
|
+
verify_webhook.add_argument("--payload-file")
|
|
520
|
+
|
|
521
|
+
return parser
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def main(argv: list[str] | None = None) -> int:
|
|
525
|
+
parser = build_cli_parser()
|
|
526
|
+
args = parser.parse_args(argv)
|
|
527
|
+
|
|
528
|
+
if args.command == "verify-webhook-signature":
|
|
529
|
+
if bool(args.payload_body) == bool(args.payload_file):
|
|
530
|
+
raise SystemExit("provide exactly one of --payload-body or --payload-file")
|
|
531
|
+
payload_body = (
|
|
532
|
+
args.payload_body
|
|
533
|
+
if args.payload_body is not None
|
|
534
|
+
else open(args.payload_file, "r", encoding="utf-8").read()
|
|
535
|
+
)
|
|
536
|
+
_print_json(
|
|
537
|
+
{
|
|
538
|
+
"ok": verify_webhook_signature(
|
|
539
|
+
payload_body=payload_body,
|
|
540
|
+
secret=args.secret,
|
|
541
|
+
signature=args.signature,
|
|
542
|
+
timestamp=args.timestamp,
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
)
|
|
546
|
+
return 0
|
|
547
|
+
|
|
548
|
+
client = ScientificProtocolClient(_required_base_url(args.base_url))
|
|
549
|
+
|
|
550
|
+
if args.command == "health":
|
|
551
|
+
_print_json(client.get_health())
|
|
552
|
+
return 0
|
|
553
|
+
if args.command == "list-work-items":
|
|
554
|
+
_print_json(
|
|
555
|
+
client.list_work_items(
|
|
556
|
+
claim_id=args.claim_id,
|
|
557
|
+
claimable=args.claimable or None,
|
|
558
|
+
kind=args.kind,
|
|
559
|
+
lane=args.lane,
|
|
560
|
+
limit=args.limit,
|
|
561
|
+
offset=args.offset,
|
|
562
|
+
status=args.status,
|
|
563
|
+
)
|
|
564
|
+
)
|
|
565
|
+
return 0
|
|
566
|
+
if args.command == "get-work-item":
|
|
567
|
+
_print_json(client.get_work_item(args.item_id, claim_id=args.claim_id))
|
|
568
|
+
return 0
|
|
569
|
+
if args.command == "runtime-events":
|
|
570
|
+
_print_json(
|
|
571
|
+
client.get_agent_runtime_events(
|
|
572
|
+
agent_id=args.agent_id,
|
|
573
|
+
claim_id=args.claim_id,
|
|
574
|
+
limit=args.limit,
|
|
575
|
+
offset=args.offset,
|
|
576
|
+
since=args.since,
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
return 0
|
|
580
|
+
if args.command == "agent-work-summary":
|
|
581
|
+
_print_json(client.get_agent_work_summary(args.agent_id, domain_id=args.domain_id))
|
|
582
|
+
return 0
|
|
583
|
+
if args.command == "agent-review-calibration":
|
|
584
|
+
_print_json(
|
|
585
|
+
client.get_agent_review_calibration(
|
|
586
|
+
args.agent_id,
|
|
587
|
+
limit=args.limit,
|
|
588
|
+
offset=args.offset,
|
|
589
|
+
)
|
|
590
|
+
)
|
|
591
|
+
return 0
|
|
592
|
+
if args.command == "create-webhook-subscription":
|
|
593
|
+
signer = CastAgentSigner(private_key=args.private_key, cast_binary=args.cast_binary)
|
|
594
|
+
_print_json(
|
|
595
|
+
client.create_agent_webhook_subscription(
|
|
596
|
+
agent_id=args.agent_id,
|
|
597
|
+
signer=signer,
|
|
598
|
+
target_url=args.target_url,
|
|
599
|
+
event_types=args.event_types,
|
|
600
|
+
label=args.label,
|
|
601
|
+
signing_secret=args.signing_secret,
|
|
602
|
+
)
|
|
603
|
+
)
|
|
604
|
+
return 0
|
|
605
|
+
|
|
606
|
+
raise SystemExit(f"unsupported_command:{args.command}")
|
|
607
|
+
|
|
608
|
+
if __name__ == "__main__":
|
|
609
|
+
raise SystemExit(main(sys.argv[1:]))
|