toolchestrator 0.6.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.
- toolchestrator/__init__.py +39 -0
- toolchestrator/client.py +1034 -0
- toolchestrator-0.6.0.dist-info/METADATA +78 -0
- toolchestrator-0.6.0.dist-info/RECORD +7 -0
- toolchestrator-0.6.0.dist-info/WHEEL +5 -0
- toolchestrator-0.6.0.dist-info/licenses/LICENSE +21 -0
- toolchestrator-0.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Toolchestrator client library.
|
|
2
|
+
|
|
3
|
+
Connects a tool running on this machine to a Toolchestrator hub:
|
|
4
|
+
|
|
5
|
+
* **Register** the tool once with a personal access token (``tcu_...``); the
|
|
6
|
+
tool receives its own API key (``tck_...``) which is written to a local
|
|
7
|
+
``.toolchestrator.json`` config file (gitignore it!).
|
|
8
|
+
* **Sync** the tool's data model schemas (versioned on the hub) and records.
|
|
9
|
+
* **Read** other visible tools' data (filtered/projected/ordered via
|
|
10
|
+
``read``), **declare connections** with a purpose (``connect_to``),
|
|
11
|
+
**enqueue** tasks on them (or **call** an action synchronously and get
|
|
12
|
+
the result back), and **subscribe** to their data changes.
|
|
13
|
+
* **Serve** actions: a blocking long-poll loop that claims tasks queued for
|
|
14
|
+
this tool and runs local handler functions (``data_handlers`` route
|
|
15
|
+
``data.changed`` notifications per source tool/model).
|
|
16
|
+
* **Serve a web UI** (``serve_web``): tunnel a local web app's full HTTP
|
|
17
|
+
through the hub so visible users reach it in their browser — this machine
|
|
18
|
+
still makes only outbound connections.
|
|
19
|
+
|
|
20
|
+
Quick start::
|
|
21
|
+
|
|
22
|
+
from toolchestrator import Toolchestrator
|
|
23
|
+
|
|
24
|
+
# one time, writes .toolchestrator.json in the current directory:
|
|
25
|
+
tc = Toolchestrator.register(
|
|
26
|
+
"http://localhost:8787", "tcu_...", "invoice-radar", "Invoice Radar",
|
|
27
|
+
description="Tracks vendor invoices", sharing_scope="private")
|
|
28
|
+
|
|
29
|
+
# every run afterwards:
|
|
30
|
+
tc = Toolchestrator() # loads .toolchestrator.json
|
|
31
|
+
tc.sync_schema("invoice", schema) # JSON Schema dict
|
|
32
|
+
tc.sync_data("invoice", rows, id_field="id")
|
|
33
|
+
tc.serve({"mark_paid": mark_paid}) # blocking task loop
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from .client import Toolchestrator, ToolchestratorError
|
|
37
|
+
|
|
38
|
+
__version__ = "0.6.0"
|
|
39
|
+
__all__ = ["Toolchestrator", "ToolchestratorError", "__version__"]
|
toolchestrator/client.py
ADDED
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
"""Core client for the Toolchestrator hub HTTP API.
|
|
2
|
+
|
|
3
|
+
Everything a connected tool does is outbound HTTP: push (register, schemas,
|
|
4
|
+
data, task results, heartbeat) and long-poll (tasks). The hub never connects
|
|
5
|
+
back to the tool's machine, so this client works from laptops behind NAT.
|
|
6
|
+
|
|
7
|
+
Compatible with Python 3.9+. The only third-party dependency is ``requests``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
19
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("toolchestrator")
|
|
24
|
+
|
|
25
|
+
CONFIG_FILENAME = ".toolchestrator.json"
|
|
26
|
+
ENV_URL = "TOOLCHESTRATOR_URL"
|
|
27
|
+
ENV_API_KEY = "TOOLCHESTRATOR_API_KEY"
|
|
28
|
+
|
|
29
|
+
# Handler signature: fn(payload, task) -> result dict (or None).
|
|
30
|
+
Handler = Callable[[Dict[str, Any], Dict[str, Any]], Optional[Dict[str, Any]]]
|
|
31
|
+
|
|
32
|
+
# data.changed routing table: (source_slug, model_name) -> handler.
|
|
33
|
+
DataHandlers = Dict[Tuple[str, str], Handler]
|
|
34
|
+
|
|
35
|
+
_UNSET = object() # sentinel so update() can distinguish "not passed" from None
|
|
36
|
+
|
|
37
|
+
# Hop-by-hop headers (RFC 7230 §6.1) plus request routing headers that must
|
|
38
|
+
# never reach the local target: the forwarded ``host`` would point at the hub,
|
|
39
|
+
# and ``content-length`` is recomputed by requests from the actual body.
|
|
40
|
+
_HOP_BY_HOP = frozenset((
|
|
41
|
+
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
42
|
+
"te", "trailers", "transfer-encoding", "upgrade",
|
|
43
|
+
))
|
|
44
|
+
_STRIP_REQUEST_HEADERS = _HOP_BY_HOP | frozenset(("host", "content-length"))
|
|
45
|
+
# On the way back, requests has already decoded the body, so length/encoding
|
|
46
|
+
# headers copied verbatim would misdescribe the bytes handed to the browser.
|
|
47
|
+
_STRIP_RESPONSE_HEADERS = _HOP_BY_HOP | frozenset(
|
|
48
|
+
("content-length", "content-encoding"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ToolchestratorError(Exception):
|
|
52
|
+
"""Raised for HTTP errors from the hub and client-side misconfiguration.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
status_code: HTTP status code, or ``None`` for client-side/network
|
|
56
|
+
errors.
|
|
57
|
+
detail: the hub's ``{"detail": ...}`` error payload when available.
|
|
58
|
+
task_id: the task id (``task_...``) when a synchronous :meth:`call`
|
|
59
|
+
did not finish in time, so the caller can poll :meth:`get_task`;
|
|
60
|
+
``None`` otherwise.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, message: str, status_code: Optional[int] = None,
|
|
64
|
+
detail: Optional[Any] = None,
|
|
65
|
+
task_id: Optional[str] = None):
|
|
66
|
+
super().__init__(message)
|
|
67
|
+
self.status_code = status_code
|
|
68
|
+
self.detail = detail
|
|
69
|
+
self.task_id = task_id
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _find_config(start_dir: str) -> Optional[str]:
|
|
73
|
+
"""Walk up from ``start_dir`` looking for a ``.toolchestrator.json`` file.
|
|
74
|
+
|
|
75
|
+
Returns the absolute path of the first one found, or ``None``.
|
|
76
|
+
"""
|
|
77
|
+
current = os.path.abspath(start_dir)
|
|
78
|
+
while True:
|
|
79
|
+
candidate = os.path.join(current, CONFIG_FILENAME)
|
|
80
|
+
if os.path.isfile(candidate):
|
|
81
|
+
return candidate
|
|
82
|
+
parent = os.path.dirname(current)
|
|
83
|
+
if parent == current: # reached filesystem root
|
|
84
|
+
return None
|
|
85
|
+
current = parent
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Toolchestrator:
|
|
89
|
+
"""A connected tool's handle on the Toolchestrator hub.
|
|
90
|
+
|
|
91
|
+
Two ways to get one:
|
|
92
|
+
|
|
93
|
+
* :meth:`Toolchestrator.register` — one-time registration with a personal
|
|
94
|
+
access token (``tcu_...``). Creates the tool on the hub, receives the
|
|
95
|
+
tool's own API key (``tck_...``), and writes ``.toolchestrator.json``
|
|
96
|
+
in the current directory.
|
|
97
|
+
* ``Toolchestrator()`` — every run afterwards. Loads
|
|
98
|
+
``.toolchestrator.json`` by walking up from the current working
|
|
99
|
+
directory; the environment variables ``TOOLCHESTRATOR_URL`` and
|
|
100
|
+
``TOOLCHESTRATOR_API_KEY`` override the file's values, and explicit
|
|
101
|
+
constructor arguments override both.
|
|
102
|
+
|
|
103
|
+
All methods raise :class:`ToolchestratorError` on failure.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self, base_url: Optional[str] = None,
|
|
107
|
+
api_key: Optional[str] = None,
|
|
108
|
+
tool_id: Optional[str] = None,
|
|
109
|
+
slug: Optional[str] = None,
|
|
110
|
+
config_path: Optional[str] = None):
|
|
111
|
+
"""Load configuration and prepare an authenticated HTTP session.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
base_url: hub URL, e.g. ``http://localhost:8787``. Optional if
|
|
115
|
+
present in the config file or ``TOOLCHESTRATOR_URL``.
|
|
116
|
+
api_key: this tool's API key (``tck_...``). Optional if present
|
|
117
|
+
in the config file or ``TOOLCHESTRATOR_API_KEY``.
|
|
118
|
+
tool_id: this tool's id (``t_...``). Normally read from the
|
|
119
|
+
config file.
|
|
120
|
+
slug: this tool's slug. Normally read from the config file.
|
|
121
|
+
config_path: explicit path to a ``.toolchestrator.json`` file;
|
|
122
|
+
when omitted the file is searched for by walking up from the
|
|
123
|
+
current working directory.
|
|
124
|
+
"""
|
|
125
|
+
cfg: Dict[str, Any] = {}
|
|
126
|
+
found = config_path or _find_config(os.getcwd())
|
|
127
|
+
if found and os.path.isfile(found):
|
|
128
|
+
try:
|
|
129
|
+
with open(found, "r", encoding="utf-8") as fh:
|
|
130
|
+
cfg = json.load(fh)
|
|
131
|
+
except (OSError, ValueError) as exc:
|
|
132
|
+
raise ToolchestratorError(
|
|
133
|
+
"could not read config file %s: %s" % (found, exc))
|
|
134
|
+
self.config_path = found if cfg else None
|
|
135
|
+
|
|
136
|
+
self.base_url = (base_url or os.environ.get(ENV_URL)
|
|
137
|
+
or cfg.get("base_url") or "").rstrip("/")
|
|
138
|
+
self.api_key = (api_key or os.environ.get(ENV_API_KEY)
|
|
139
|
+
or cfg.get("api_key"))
|
|
140
|
+
self.tool_id = tool_id or cfg.get("tool_id")
|
|
141
|
+
self.slug = slug or cfg.get("slug")
|
|
142
|
+
|
|
143
|
+
if not self.base_url or not self.api_key:
|
|
144
|
+
raise ToolchestratorError(
|
|
145
|
+
"no Toolchestrator configuration found: expected a "
|
|
146
|
+
".toolchestrator.json file in this directory or a parent "
|
|
147
|
+
"(created by Toolchestrator.register), or the environment "
|
|
148
|
+
"variables %s and %s" % (ENV_URL, ENV_API_KEY))
|
|
149
|
+
|
|
150
|
+
self._session = requests.Session()
|
|
151
|
+
self._session.headers["Authorization"] = "Bearer %s" % self.api_key
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------ #
|
|
154
|
+
# Registration #
|
|
155
|
+
# ------------------------------------------------------------------ #
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def register(cls, base_url: str, personal_token: str, slug: str,
|
|
159
|
+
name: str, description: str = "",
|
|
160
|
+
problem_statement: str = "", usage: str = "",
|
|
161
|
+
sharing_scope: str = "private",
|
|
162
|
+
department: Optional[str] = None,
|
|
163
|
+
actions: Optional[List[str]] = None,
|
|
164
|
+
reconnect: bool = False,
|
|
165
|
+
config_dir: Optional[str] = None) -> "Toolchestrator":
|
|
166
|
+
"""Register this tool on the hub (one time) and save local config.
|
|
167
|
+
|
|
168
|
+
Uses a *personal access token* (``tcu_...``, created by the user in
|
|
169
|
+
the dashboard under Settings) — never a password. On success the hub
|
|
170
|
+
returns the tool's own API key (``tck_...``), which is written
|
|
171
|
+
together with the hub URL, tool id, and slug into
|
|
172
|
+
``.toolchestrator.json`` in the current directory (add that file to
|
|
173
|
+
.gitignore — it contains a secret).
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
base_url: hub URL, e.g. ``http://localhost:8787``.
|
|
177
|
+
personal_token: the user's personal access token (``tcu_...``).
|
|
178
|
+
slug: URL-friendly unique-per-company identifier, e.g.
|
|
179
|
+
``invoice-radar``.
|
|
180
|
+
name: human-readable tool name.
|
|
181
|
+
description: what the tool does (shown in the hub catalog).
|
|
182
|
+
problem_statement: the problem this tool solves and for whom.
|
|
183
|
+
usage: how people/tools are meant to use it.
|
|
184
|
+
sharing_scope: ``"private"`` (default), ``"department"``, or
|
|
185
|
+
``"company"``. Start private; widen deliberately.
|
|
186
|
+
department: department *name* (e.g. ``"Finance"``) or ``None``.
|
|
187
|
+
actions: list of action names this tool can execute via tasks
|
|
188
|
+
(``serve`` updates this automatically from handler keys).
|
|
189
|
+
reconnect: if the slug already exists and you own it, pass
|
|
190
|
+
``True`` to rotate the API key and update metadata instead
|
|
191
|
+
of failing with 409.
|
|
192
|
+
config_dir: directory to write ``.toolchestrator.json`` into
|
|
193
|
+
(defaults to the current working directory).
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
A ready-to-use :class:`Toolchestrator` instance authenticated
|
|
197
|
+
with the new tool API key.
|
|
198
|
+
"""
|
|
199
|
+
url = base_url.rstrip("/") + "/api/tools/register"
|
|
200
|
+
body = {
|
|
201
|
+
"slug": slug,
|
|
202
|
+
"name": name,
|
|
203
|
+
"description": description,
|
|
204
|
+
"problem_statement": problem_statement,
|
|
205
|
+
"usage": usage,
|
|
206
|
+
"sharing_scope": sharing_scope,
|
|
207
|
+
"department": department,
|
|
208
|
+
"actions": list(actions) if actions else [],
|
|
209
|
+
"reconnect": bool(reconnect),
|
|
210
|
+
}
|
|
211
|
+
try:
|
|
212
|
+
resp = requests.post(
|
|
213
|
+
url, json=body, timeout=30,
|
|
214
|
+
headers={"Authorization": "Bearer %s" % personal_token})
|
|
215
|
+
except requests.RequestException as exc:
|
|
216
|
+
raise ToolchestratorError("could not reach hub at %s: %s"
|
|
217
|
+
% (base_url, exc))
|
|
218
|
+
if resp.status_code >= 400:
|
|
219
|
+
raise ToolchestratorError(
|
|
220
|
+
"registration failed (%s): %s"
|
|
221
|
+
% (resp.status_code, _extract_detail(resp)),
|
|
222
|
+
status_code=resp.status_code, detail=_extract_detail(resp))
|
|
223
|
+
|
|
224
|
+
data = resp.json()
|
|
225
|
+
tool = data["tool"]
|
|
226
|
+
api_key = data["api_key"]
|
|
227
|
+
|
|
228
|
+
target_dir = os.path.abspath(config_dir or os.getcwd())
|
|
229
|
+
config_path = os.path.join(target_dir, CONFIG_FILENAME)
|
|
230
|
+
config = {
|
|
231
|
+
"base_url": base_url.rstrip("/"),
|
|
232
|
+
"api_key": api_key,
|
|
233
|
+
"tool_id": tool["id"],
|
|
234
|
+
"slug": tool["slug"],
|
|
235
|
+
}
|
|
236
|
+
with open(config_path, "w", encoding="utf-8") as fh:
|
|
237
|
+
json.dump(config, fh, indent=2)
|
|
238
|
+
fh.write("\n")
|
|
239
|
+
try:
|
|
240
|
+
os.chmod(config_path, 0o600) # contains a secret
|
|
241
|
+
except OSError:
|
|
242
|
+
pass
|
|
243
|
+
|
|
244
|
+
client = cls(base_url=base_url, api_key=api_key,
|
|
245
|
+
tool_id=tool["id"], slug=tool["slug"])
|
|
246
|
+
client.config_path = config_path
|
|
247
|
+
return client
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------------ #
|
|
250
|
+
# Schemas & data #
|
|
251
|
+
# ------------------------------------------------------------------ #
|
|
252
|
+
|
|
253
|
+
def sync_schema(self, model_name: str,
|
|
254
|
+
json_schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
255
|
+
"""Push (or confirm) the JSON Schema for one of this tool's models.
|
|
256
|
+
|
|
257
|
+
Schema versions on the hub are immutable: pushing a schema identical
|
|
258
|
+
to the latest version is a no-op (``changed: False``); a different
|
|
259
|
+
schema creates version n+1. Call this at tool startup or whenever
|
|
260
|
+
the local storage shape changes — before the first ``sync_data`` for
|
|
261
|
+
the model.
|
|
262
|
+
|
|
263
|
+
When a push creates a new version, the hub diffs it against the
|
|
264
|
+
previous one and returns a ``warnings`` list naming any field a
|
|
265
|
+
consumer of this model depends on that was removed or whose
|
|
266
|
+
definition changed — a soft-locked contract this push breaks (the
|
|
267
|
+
version is still created; the hub never blocks). Each warning is
|
|
268
|
+
logged (``logger.warning``) so an agent or CLI running unattended
|
|
269
|
+
sees it, and the full response — warnings included — is returned so
|
|
270
|
+
the caller can surface or act on them.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
model_name: model identifier, e.g. ``"invoice"``.
|
|
274
|
+
json_schema: a JSON Schema dict describing one record's payload.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
``{"model": ..., "version": int, "changed": bool,
|
|
278
|
+
"warnings": [str]}`` (``warnings`` empty when the push is
|
|
279
|
+
additive-only or the model has no dependents).
|
|
280
|
+
"""
|
|
281
|
+
data = self._request(
|
|
282
|
+
"PUT", "/api/tools/%s/schemas/%s" % (self._self_ref(), model_name),
|
|
283
|
+
json_body={"json_schema": json_schema})
|
|
284
|
+
for warning in (data.get("warnings") or []):
|
|
285
|
+
logger.warning("schema %s: %s", model_name, warning)
|
|
286
|
+
return data
|
|
287
|
+
|
|
288
|
+
def schemas(self, tool: Optional[str] = None) -> Dict[str, Any]:
|
|
289
|
+
"""List the versioned model schemas a tool publishes on the hub.
|
|
290
|
+
|
|
291
|
+
Inspect what models and fields already exist — this tool's own, or
|
|
292
|
+
(with ``tool`` set) another visible tool's — so the agent building or
|
|
293
|
+
editing a tool **reuses** an existing model instead of duplicating
|
|
294
|
+
it. Visibility is evaluated as this tool's owner.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
tool: the tool's slug or id to inspect; ``None`` (default) means
|
|
298
|
+
this tool itself.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
``{"models": [{"name": str, "versions": [{"version": int,
|
|
302
|
+
"json_schema": dict, "created_at": str}]}]}`` — newest version
|
|
303
|
+
first per model.
|
|
304
|
+
"""
|
|
305
|
+
ref = tool if tool is not None else self._self_ref()
|
|
306
|
+
return self._request("GET", "/api/tools/%s/schemas" % ref)
|
|
307
|
+
|
|
308
|
+
def sync_data(self, model_name: str, records: Iterable[Dict[str, Any]],
|
|
309
|
+
id_field: str = "id",
|
|
310
|
+
deleted_ids: Optional[Iterable[str]] = None,
|
|
311
|
+
schema_version: Optional[int] = None) -> Dict[str, Any]:
|
|
312
|
+
"""Upsert records for one of this tool's models on the hub.
|
|
313
|
+
|
|
314
|
+
Each record is sent whole as the payload; its stable external id is
|
|
315
|
+
taken from ``record[id_field]``. Re-syncing the same external id
|
|
316
|
+
updates the stored record (upsert), so it is safe to sync the full
|
|
317
|
+
current state, or only rows changed since the last sync.
|
|
318
|
+
|
|
319
|
+
Requires a schema for the model (``sync_schema``) to have been
|
|
320
|
+
pushed first.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
model_name: model identifier, e.g. ``"invoice"``.
|
|
324
|
+
records: iterable of dicts; each must contain ``id_field``.
|
|
325
|
+
id_field: key holding each record's stable unique id
|
|
326
|
+
(default ``"id"``).
|
|
327
|
+
deleted_ids: external ids to delete from the hub, if any.
|
|
328
|
+
schema_version: pin records to a specific schema version;
|
|
329
|
+
default ``None`` uses the latest version on the hub.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
``{"upserted": int, "deleted": int, "schema_version": int}``.
|
|
333
|
+
"""
|
|
334
|
+
wire_records = []
|
|
335
|
+
for record in records:
|
|
336
|
+
if id_field not in record:
|
|
337
|
+
raise ToolchestratorError(
|
|
338
|
+
"record is missing id_field %r: %r" % (id_field, record))
|
|
339
|
+
wire_records.append({
|
|
340
|
+
"external_id": str(record[id_field]),
|
|
341
|
+
"payload": record,
|
|
342
|
+
})
|
|
343
|
+
body = {
|
|
344
|
+
"records": wire_records,
|
|
345
|
+
"schema_version": schema_version,
|
|
346
|
+
"deleted_ids": list(deleted_ids) if deleted_ids else [],
|
|
347
|
+
}
|
|
348
|
+
return self._request(
|
|
349
|
+
"POST",
|
|
350
|
+
"/api/tools/%s/data/%s/sync" % (self._self_ref(), model_name),
|
|
351
|
+
json_body=body)
|
|
352
|
+
|
|
353
|
+
def read(self, tool: str, model: str,
|
|
354
|
+
where: Optional[Dict[str, Any]] = None,
|
|
355
|
+
fields: Optional[List[str]] = None,
|
|
356
|
+
order_by: Optional[str] = None,
|
|
357
|
+
since: Optional[str] = None,
|
|
358
|
+
limit: int = 200) -> List[Dict[str, Any]]:
|
|
359
|
+
"""Query another tool's records — filtered, projected, and ordered.
|
|
360
|
+
|
|
361
|
+
The hub does the work (SQLite ``json_extract`` over payloads), so
|
|
362
|
+
read only what you need instead of dumping whole models. Visibility
|
|
363
|
+
is evaluated as this tool's owner: private tools of other users are
|
|
364
|
+
not accessible, department/company scopes apply. Reading another
|
|
365
|
+
tool's data also registers/refreshes a **connection** on the hub
|
|
366
|
+
(observability, not access control) — declare its purpose with
|
|
367
|
+
:meth:`connect_to`.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
tool: the other tool's slug or id.
|
|
371
|
+
model: model name, e.g. ``"invoice"``.
|
|
372
|
+
where: filter over top-level payload fields, AND-ed together.
|
|
373
|
+
A scalar value means equality; a dict value uses operators
|
|
374
|
+
``$eq``, ``$ne``, ``$gt``, ``$gte``, ``$lt``, ``$lte``,
|
|
375
|
+
``$in`` (list), ``$contains`` (case-insensitive substring,
|
|
376
|
+
strings only). Example:
|
|
377
|
+
``{"status": "overdue", "amount": {"$gte": 1000}}``.
|
|
378
|
+
fields: top-level payload keys to keep; other keys are dropped
|
|
379
|
+
server-side (``_external_id`` / ``_updated_at`` always
|
|
380
|
+
remain).
|
|
381
|
+
order_by: ``"<field>"``, ``"<field>:asc"``, or
|
|
382
|
+
``"<field>:desc"``; ``updated_at`` sorts on the hub column.
|
|
383
|
+
Default ``updated_at:desc``.
|
|
384
|
+
since: ISO-8601 timestamp; only records updated after it.
|
|
385
|
+
limit: maximum number of records to return (paginates
|
|
386
|
+
transparently over the server's 200-per-page cap).
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
List of record payload dicts, each with the hub bookkeeping keys
|
|
390
|
+
``_external_id`` and ``_updated_at`` merged in.
|
|
391
|
+
"""
|
|
392
|
+
base_params: Dict[str, Any] = {}
|
|
393
|
+
if where is not None:
|
|
394
|
+
base_params["where"] = json.dumps(where)
|
|
395
|
+
if fields: # an empty list means "no projection", same as None
|
|
396
|
+
base_params["fields"] = ",".join(fields)
|
|
397
|
+
if order_by is not None:
|
|
398
|
+
base_params["order_by"] = order_by
|
|
399
|
+
if since is not None:
|
|
400
|
+
base_params["since"] = since
|
|
401
|
+
|
|
402
|
+
out: List[Dict[str, Any]] = []
|
|
403
|
+
offset = 0
|
|
404
|
+
while len(out) < limit:
|
|
405
|
+
page_size = min(200, limit - len(out))
|
|
406
|
+
params = dict(base_params)
|
|
407
|
+
params["limit"] = page_size
|
|
408
|
+
params["offset"] = offset
|
|
409
|
+
data = self._request(
|
|
410
|
+
"GET", "/api/tools/%s/data/%s" % (tool, model), params=params)
|
|
411
|
+
batch = data.get("records", [])
|
|
412
|
+
for rec in batch:
|
|
413
|
+
merged = dict(rec.get("payload") or {})
|
|
414
|
+
merged["_external_id"] = rec.get("external_id")
|
|
415
|
+
merged["_updated_at"] = rec.get("updated_at")
|
|
416
|
+
out.append(merged)
|
|
417
|
+
if len(batch) < page_size:
|
|
418
|
+
break
|
|
419
|
+
offset += len(batch)
|
|
420
|
+
return out
|
|
421
|
+
|
|
422
|
+
def fetch_data(self, tool: str, model: str, since: Optional[str] = None,
|
|
423
|
+
limit: int = 200) -> List[Dict[str, Any]]:
|
|
424
|
+
"""Fetch another tool's records from the hub (visibility permitting).
|
|
425
|
+
|
|
426
|
+
Kept for compatibility; delegates to :meth:`read`, which also takes
|
|
427
|
+
``where`` / ``fields`` / ``order_by`` for filtered reads.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
tool: the other tool's slug or id.
|
|
431
|
+
model: model name, e.g. ``"invoice"``.
|
|
432
|
+
since: ISO-8601 timestamp; only records updated after it.
|
|
433
|
+
limit: maximum number of records to return (paginates
|
|
434
|
+
transparently over the server's 200-per-page cap).
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
List of record payload dicts, each with the hub bookkeeping keys
|
|
438
|
+
``_external_id`` and ``_updated_at`` merged in.
|
|
439
|
+
"""
|
|
440
|
+
return self.read(tool, model, since=since, limit=limit)
|
|
441
|
+
|
|
442
|
+
# ------------------------------------------------------------------ #
|
|
443
|
+
# Connections #
|
|
444
|
+
# ------------------------------------------------------------------ #
|
|
445
|
+
|
|
446
|
+
def connect_to(self, tool: str, model: str,
|
|
447
|
+
purpose: Optional[str] = None) -> Dict[str, Any]:
|
|
448
|
+
"""Declare that this tool reads ``tool``'s ``model`` — and say why.
|
|
449
|
+
|
|
450
|
+
Connections are observability, not access control: reading another
|
|
451
|
+
tool's data already records one automatically (``declared: false``),
|
|
452
|
+
but declaring it with a purpose makes the company data map explain
|
|
453
|
+
*why* the data flows. Idempotent upsert: calling again updates the
|
|
454
|
+
purpose and marks the connection declared.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
tool: the source tool's slug or id (must be visible, and must
|
|
458
|
+
have a schema for ``model``).
|
|
459
|
+
model: the source model name this tool reads.
|
|
460
|
+
purpose: one line on why this tool reads that data, e.g.
|
|
461
|
+
``"Correlate overdue invoices with at-risk accounts"``
|
|
462
|
+
(max 500 chars).
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
The connection dict (``{"id": "conn_...", "declared": True,
|
|
466
|
+
"reads_count": ..., ...}``).
|
|
467
|
+
"""
|
|
468
|
+
body = {
|
|
469
|
+
"source_tool_id": tool,
|
|
470
|
+
"model_name": model,
|
|
471
|
+
"consumer_tool_id": self._self_ref(),
|
|
472
|
+
"purpose": purpose,
|
|
473
|
+
}
|
|
474
|
+
return self._request("POST", "/api/connections", json_body=body)
|
|
475
|
+
|
|
476
|
+
def connections(self) -> List[Dict[str, Any]]:
|
|
477
|
+
"""List this tool's connections (as source or consumer).
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
List of connection dicts: source/consumer tool refs, model name,
|
|
481
|
+
purpose, ``declared`` flag, read stats (``reads_count``,
|
|
482
|
+
``records_read``, ``last_read_at``).
|
|
483
|
+
"""
|
|
484
|
+
data = self._request("GET", "/api/connections",
|
|
485
|
+
params={"tool": self._self_ref()})
|
|
486
|
+
return data["connections"]
|
|
487
|
+
|
|
488
|
+
def dependents(self) -> Dict[str, Any]:
|
|
489
|
+
"""List the OTHER tools that read this tool's models — its dependents.
|
|
490
|
+
|
|
491
|
+
Field-level dependency awareness: for each model this tool publishes,
|
|
492
|
+
the hub reports which other tools consume it and exactly which fields
|
|
493
|
+
they depend on (accumulated from their reads; a whole-model reader
|
|
494
|
+
depends on every current field). A field a consumer reads is a
|
|
495
|
+
soft-locked contract — removing, renaming, or changing its type
|
|
496
|
+
breaks that consumer — so call this before editing this tool's data
|
|
497
|
+
model to see who would break.
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
``{"models": [{"model": str, "latest_version": int, "dependents":
|
|
501
|
+
[{"tool": {"slug": str, "name": str}, "purpose": str,
|
|
502
|
+
"declared": bool, "fields": [str], "all_fields": bool,
|
|
503
|
+
"reads_count": int, "last_read_at": str}]}]}`` — a dependent with
|
|
504
|
+
``all_fields`` true depends on every current field of the model.
|
|
505
|
+
"""
|
|
506
|
+
return self._request(
|
|
507
|
+
"GET", "/api/tools/%s/dependents" % self._self_ref())
|
|
508
|
+
|
|
509
|
+
# ------------------------------------------------------------------ #
|
|
510
|
+
# Discovery, tasks, metadata #
|
|
511
|
+
# ------------------------------------------------------------------ #
|
|
512
|
+
|
|
513
|
+
def list_tools(self, q: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
514
|
+
"""List tools on the hub visible to this tool's owner.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
q: optional search over tool name/description.
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
List of tool dicts (id, slug, name, description, sharing_scope,
|
|
521
|
+
actions, online, models, ...).
|
|
522
|
+
"""
|
|
523
|
+
params = {"q": q} if q else None
|
|
524
|
+
return self._request("GET", "/api/tools", params=params)["tools"]
|
|
525
|
+
|
|
526
|
+
def enqueue(self, tool: str, action: str,
|
|
527
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
528
|
+
timeout_s: int = 300,
|
|
529
|
+
max_attempts: int = 3) -> Dict[str, Any]:
|
|
530
|
+
"""Queue a task for another (or this) tool to execute.
|
|
531
|
+
|
|
532
|
+
Execution is eventual, not live RPC: the target tool picks the task
|
|
533
|
+
up next time it polls (``serve``). Poll the returned task's id with
|
|
534
|
+
:meth:`get_task` to observe the result.
|
|
535
|
+
|
|
536
|
+
Args:
|
|
537
|
+
tool: target tool's slug or id (must be visible).
|
|
538
|
+
action: action name the target handles, e.g. ``"mark_paid"``.
|
|
539
|
+
payload: JSON-serializable dict passed to the target's handler.
|
|
540
|
+
timeout_s: seconds a claimed run may take before it is requeued
|
|
541
|
+
or failed (default 300).
|
|
542
|
+
max_attempts: total attempts before the task fails permanently
|
|
543
|
+
(default 3).
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
The created task dict (``{"id": "task_...", "status": "queued",
|
|
547
|
+
...}``).
|
|
548
|
+
"""
|
|
549
|
+
body = {
|
|
550
|
+
"action": action,
|
|
551
|
+
"payload": payload or {},
|
|
552
|
+
"timeout_s": timeout_s,
|
|
553
|
+
"max_attempts": max_attempts,
|
|
554
|
+
}
|
|
555
|
+
return self._request("POST", "/api/tools/%s/tasks" % tool,
|
|
556
|
+
json_body=body)
|
|
557
|
+
|
|
558
|
+
def call(self, tool: str, action: str,
|
|
559
|
+
payload: Optional[Dict[str, Any]] = None,
|
|
560
|
+
timeout_s: int = 300, wait: int = 30) -> Dict[str, Any]:
|
|
561
|
+
"""Call another (or this) tool's action synchronously and get its result.
|
|
562
|
+
|
|
563
|
+
Where :meth:`enqueue` is fire-and-forget, ``call`` queues the task and
|
|
564
|
+
then blocks on the hub while it long-polls internally up to ``wait``
|
|
565
|
+
seconds for the serving machine to finish, returning the result in one
|
|
566
|
+
step. The serving machine stays outbound-only — the hub is the meeting
|
|
567
|
+
point — so this works even for another user's shared tool behind NAT.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
tool: target tool's slug or id (must be visible).
|
|
571
|
+
action: action name the target handles, e.g. ``"mark_paid"``.
|
|
572
|
+
payload: JSON-serializable dict passed to the target's handler.
|
|
573
|
+
timeout_s: seconds a claimed run may take before it is requeued
|
|
574
|
+
or failed (default 300).
|
|
575
|
+
wait: seconds to wait for a terminal result before giving up
|
|
576
|
+
(default 30; the hub clamps to 0–60, where 0 returns as soon
|
|
577
|
+
as the task is created).
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
The action's result dict, when the task finished successfully.
|
|
581
|
+
|
|
582
|
+
Raises:
|
|
583
|
+
ToolchestratorError: if the handler failed (message is the
|
|
584
|
+
handler's error); or if the tool did not finish within
|
|
585
|
+
``wait`` because it is still running or offline — then the
|
|
586
|
+
exception's ``task_id`` attribute carries the task id so the
|
|
587
|
+
caller can poll :meth:`get_task` for the eventual result.
|
|
588
|
+
"""
|
|
589
|
+
body = {
|
|
590
|
+
"action": action,
|
|
591
|
+
"payload": payload or {},
|
|
592
|
+
"timeout_s": timeout_s,
|
|
593
|
+
"wait": wait,
|
|
594
|
+
}
|
|
595
|
+
data = self._request("POST", "/api/tools/%s/call" % tool,
|
|
596
|
+
json_body=body, timeout=wait + 15)
|
|
597
|
+
task = data.get("task") or {}
|
|
598
|
+
if data.get("finished"):
|
|
599
|
+
if task.get("status") == "succeeded":
|
|
600
|
+
result = task.get("result")
|
|
601
|
+
return result if result is not None else {}
|
|
602
|
+
raise ToolchestratorError(task.get("error") or "task failed",
|
|
603
|
+
task_id=task.get("id"))
|
|
604
|
+
state = "still running" if data.get("tool_online") else "offline"
|
|
605
|
+
raise ToolchestratorError(
|
|
606
|
+
"call to %s did not finish within %ss (%s); poll task %s with "
|
|
607
|
+
"get_task for the result" % (tool, wait, state, task.get("id")),
|
|
608
|
+
task_id=task.get("id"))
|
|
609
|
+
|
|
610
|
+
def get_task(self, task_id: str) -> Dict[str, Any]:
|
|
611
|
+
"""Fetch one task by id (``task_...``): status, result, error, etc."""
|
|
612
|
+
return self._request("GET", "/api/tasks/%s" % task_id)
|
|
613
|
+
|
|
614
|
+
def heartbeat(self) -> Dict[str, Any]:
|
|
615
|
+
"""Tell the hub this tool is alive (marks it online for ~90s).
|
|
616
|
+
|
|
617
|
+
Polling for tasks also refreshes presence, so a tool running
|
|
618
|
+
``serve`` does not need explicit heartbeats.
|
|
619
|
+
"""
|
|
620
|
+
return self._request(
|
|
621
|
+
"POST", "/api/tools/%s/heartbeat" % self._self_ref())
|
|
622
|
+
|
|
623
|
+
def update(self, name: Any = _UNSET, description: Any = _UNSET,
|
|
624
|
+
problem_statement: Any = _UNSET, usage: Any = _UNSET,
|
|
625
|
+
sharing_scope: Any = _UNSET, department: Any = _UNSET,
|
|
626
|
+
actions: Any = _UNSET, slug: Any = _UNSET) -> Dict[str, Any]:
|
|
627
|
+
"""Update this tool's metadata on the hub.
|
|
628
|
+
|
|
629
|
+
Only the arguments you pass are changed; ``None`` is a real value
|
|
630
|
+
(e.g. ``department=None`` clears the department).
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
name / description / problem_statement / usage: catalog text.
|
|
634
|
+
sharing_scope: ``"private"``, ``"department"``, or ``"company"``.
|
|
635
|
+
department: department name or ``None``.
|
|
636
|
+
actions: list of action names this tool executes.
|
|
637
|
+
slug: new slug (stays unique per company).
|
|
638
|
+
|
|
639
|
+
Returns:
|
|
640
|
+
The updated tool dict.
|
|
641
|
+
"""
|
|
642
|
+
body: Dict[str, Any] = {}
|
|
643
|
+
for key, value in (("name", name), ("description", description),
|
|
644
|
+
("problem_statement", problem_statement),
|
|
645
|
+
("usage", usage), ("sharing_scope", sharing_scope),
|
|
646
|
+
("department", department), ("actions", actions),
|
|
647
|
+
("slug", slug)):
|
|
648
|
+
if value is not _UNSET:
|
|
649
|
+
body[key] = value
|
|
650
|
+
tool = self._request("PATCH", "/api/tools/%s" % self._self_ref(),
|
|
651
|
+
json_body=body)
|
|
652
|
+
if isinstance(tool, dict) and tool.get("slug"):
|
|
653
|
+
self.slug = tool["slug"]
|
|
654
|
+
return tool
|
|
655
|
+
|
|
656
|
+
def subscribe(self, source_tool: str, model: str) -> Dict[str, Any]:
|
|
657
|
+
"""Subscribe this tool to another tool's data changes.
|
|
658
|
+
|
|
659
|
+
After the source tool syncs changes to ``model``, the hub queues a
|
|
660
|
+
``data.changed`` task for this tool with payload
|
|
661
|
+
``{"source_tool": <slug>, "model_name", "upserted", "deleted"}`` —
|
|
662
|
+
handle it in ``serve`` with a ``"data.changed"`` handler key.
|
|
663
|
+
|
|
664
|
+
Args:
|
|
665
|
+
source_tool: the source tool's slug or id (must be visible).
|
|
666
|
+
model: the source model name to watch.
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
The created subscription dict. Raises with status 409 if the
|
|
670
|
+
subscription already exists.
|
|
671
|
+
"""
|
|
672
|
+
body = {
|
|
673
|
+
"source_tool_id": source_tool,
|
|
674
|
+
"model_name": model,
|
|
675
|
+
"subscriber_tool_id": self._self_ref(),
|
|
676
|
+
}
|
|
677
|
+
return self._request("POST", "/api/subscriptions", json_body=body)
|
|
678
|
+
|
|
679
|
+
# ------------------------------------------------------------------ #
|
|
680
|
+
# Serving tasks #
|
|
681
|
+
# ------------------------------------------------------------------ #
|
|
682
|
+
|
|
683
|
+
def serve(self, handlers: Dict[str, Handler],
|
|
684
|
+
data_handlers: Optional[DataHandlers] = None,
|
|
685
|
+
poll_wait: int = 20) -> None:
|
|
686
|
+
"""Blocking loop: poll the hub for tasks and run local handlers.
|
|
687
|
+
|
|
688
|
+
On start, PATCHes this tool's ``actions`` to the handler keys so the
|
|
689
|
+
hub (and its dashboard) advertise exactly what this tool can do.
|
|
690
|
+
Then long-polls forever, claiming one task at a time and reporting
|
|
691
|
+
its result. Stop with Ctrl-C (KeyboardInterrupt propagates).
|
|
692
|
+
|
|
693
|
+
Handler contract — ``fn(payload: dict, task: dict) -> dict | None``:
|
|
694
|
+
|
|
695
|
+
* return a dict (or ``None``) → task ``succeeded`` with that result;
|
|
696
|
+
* raise any exception → task ``failed`` with the exception message
|
|
697
|
+
(the hub retries while attempts remain);
|
|
698
|
+
* a polled action with no handler → task ``failed`` with
|
|
699
|
+
``"no handler for <action>"``.
|
|
700
|
+
|
|
701
|
+
``data.changed`` tasks (subscription notifications) are routed
|
|
702
|
+
through ``data_handlers`` first, by
|
|
703
|
+
``(payload["source_tool"], payload["model_name"])``; an unmatched
|
|
704
|
+
one falls back to ``handlers["data.changed"]`` if present, else
|
|
705
|
+
fails with the normal no-handler error.
|
|
706
|
+
|
|
707
|
+
Network errors are logged and retried after a short sleep; the loop
|
|
708
|
+
never exits on its own.
|
|
709
|
+
|
|
710
|
+
Args:
|
|
711
|
+
handlers: mapping of action name → handler function. Include a
|
|
712
|
+
``"data.changed"`` key to receive subscription
|
|
713
|
+
notifications not matched by ``data_handlers``.
|
|
714
|
+
data_handlers: mapping of ``(source_slug, model_name)`` →
|
|
715
|
+
handler for that source's ``data.changed`` notifications.
|
|
716
|
+
poll_wait: long-poll wait in seconds per request (server caps
|
|
717
|
+
at 25).
|
|
718
|
+
"""
|
|
719
|
+
actions = set(handlers.keys())
|
|
720
|
+
if data_handlers:
|
|
721
|
+
actions.add("data.changed")
|
|
722
|
+
self.update(actions=sorted(actions))
|
|
723
|
+
logger.info("serving actions %s for tool %s",
|
|
724
|
+
sorted(actions), self.slug or self.tool_id)
|
|
725
|
+
while True:
|
|
726
|
+
try:
|
|
727
|
+
self.serve_once(handlers, data_handlers=data_handlers,
|
|
728
|
+
wait=poll_wait)
|
|
729
|
+
except ToolchestratorError as exc:
|
|
730
|
+
logger.warning("poll failed (%s); retrying in 3s", exc)
|
|
731
|
+
time.sleep(3)
|
|
732
|
+
|
|
733
|
+
def serve_once(self, handlers: Dict[str, Handler],
|
|
734
|
+
data_handlers: Optional[DataHandlers] = None,
|
|
735
|
+
wait: int = 5) -> int:
|
|
736
|
+
"""Single poll: claim up to one task, execute it, report the result.
|
|
737
|
+
|
|
738
|
+
Same handler contract and ``data.changed`` routing as :meth:`serve`.
|
|
739
|
+
Useful in tests and for tools that check for work on their own
|
|
740
|
+
schedule (e.g. from cron).
|
|
741
|
+
|
|
742
|
+
Args:
|
|
743
|
+
handlers: mapping of action name → handler function.
|
|
744
|
+
data_handlers: mapping of ``(source_slug, model_name)`` →
|
|
745
|
+
handler for that source's ``data.changed`` notifications.
|
|
746
|
+
wait: long-poll wait in seconds (0 returns immediately if no
|
|
747
|
+
task is queued).
|
|
748
|
+
|
|
749
|
+
Returns:
|
|
750
|
+
Number of tasks handled (0 or 1).
|
|
751
|
+
"""
|
|
752
|
+
data = self._request(
|
|
753
|
+
"GET", "/api/tools/%s/tasks/poll" % self._self_ref(),
|
|
754
|
+
params={"wait": wait, "max": 1}, timeout=wait + 15)
|
|
755
|
+
tasks = data.get("tasks", [])
|
|
756
|
+
for task in tasks:
|
|
757
|
+
self._execute_task(handlers, task, data_handlers=data_handlers)
|
|
758
|
+
return len(tasks)
|
|
759
|
+
|
|
760
|
+
def _execute_task(self, handlers: Dict[str, Handler],
|
|
761
|
+
task: Dict[str, Any],
|
|
762
|
+
data_handlers: Optional[DataHandlers] = None) -> None:
|
|
763
|
+
"""Run the handler for one claimed task and post its result."""
|
|
764
|
+
task_id = task["id"]
|
|
765
|
+
action = task.get("action")
|
|
766
|
+
payload = task.get("payload") or {}
|
|
767
|
+
handler: Optional[Handler] = None
|
|
768
|
+
if action == "data.changed" and data_handlers:
|
|
769
|
+
handler = data_handlers.get(
|
|
770
|
+
(payload.get("source_tool"), payload.get("model_name")))
|
|
771
|
+
if handler is None and action is not None:
|
|
772
|
+
handler = handlers.get(action)
|
|
773
|
+
if handler is None:
|
|
774
|
+
self._post_result(task_id, "failed",
|
|
775
|
+
error="no handler for %s" % action)
|
|
776
|
+
return
|
|
777
|
+
try:
|
|
778
|
+
result = handler(payload, task)
|
|
779
|
+
except Exception as exc: # noqa: BLE001 — any handler error fails the task
|
|
780
|
+
message = str(exc) or exc.__class__.__name__
|
|
781
|
+
logger.warning("handler for %s raised: %s", action, message)
|
|
782
|
+
self._post_result(task_id, "failed", error=message)
|
|
783
|
+
else:
|
|
784
|
+
self._post_result(task_id, "succeeded",
|
|
785
|
+
result=result if result is not None else {})
|
|
786
|
+
|
|
787
|
+
def _post_result(self, task_id: str, status: str,
|
|
788
|
+
result: Optional[Dict[str, Any]] = None,
|
|
789
|
+
error: Optional[str] = None) -> Dict[str, Any]:
|
|
790
|
+
"""Report a claimed task's outcome to the hub."""
|
|
791
|
+
if status == "succeeded":
|
|
792
|
+
body: Dict[str, Any] = {"status": "succeeded",
|
|
793
|
+
"result": result if result is not None else {}}
|
|
794
|
+
else:
|
|
795
|
+
body = {"status": "failed", "error": error or "unknown error"}
|
|
796
|
+
return self._request("POST", "/api/tasks/%s/result" % task_id,
|
|
797
|
+
json_body=body)
|
|
798
|
+
|
|
799
|
+
# ------------------------------------------------------------------ #
|
|
800
|
+
# Serving a web UI (app tunnel) #
|
|
801
|
+
# ------------------------------------------------------------------ #
|
|
802
|
+
|
|
803
|
+
def serve_web(self, target: str, workers: int = 8) -> None:
|
|
804
|
+
"""Blocking loop: tunnel a local web app's HTTP through the hub.
|
|
805
|
+
|
|
806
|
+
Where :meth:`serve` tunnels named *actions*, ``serve_web`` tunnels full
|
|
807
|
+
*HTTP*, so a tool that runs a local web UI (e.g. a Flask app on
|
|
808
|
+
``http://127.0.0.1:5000``) can be reached through the hub by any user
|
|
809
|
+
who can see the tool. This machine stays outbound-only: it opens a
|
|
810
|
+
single **outbound WebSocket** to the hub, receives forwarded browser
|
|
811
|
+
requests over it, replays each one against ``target``, and sends the
|
|
812
|
+
response back over the same socket — the hub is the only party
|
|
813
|
+
accepting inbound (browser) traffic.
|
|
814
|
+
|
|
815
|
+
Requests are handled concurrently in a small thread pool so a slow
|
|
816
|
+
response cannot stall the others; every send back over the shared
|
|
817
|
+
socket is serialized by a lock. Run this alongside :meth:`serve` in a
|
|
818
|
+
separate thread or process if the tool serves both actions and a UI.
|
|
819
|
+
|
|
820
|
+
**SSRF containment.** The outbound URL is built *strictly* from the
|
|
821
|
+
fixed local ``target`` plus the forwarded path and query — the hub
|
|
822
|
+
never sends, and this client never reads, any request-supplied host, so
|
|
823
|
+
a forwarded request can only ever reach ``target``. Paths containing
|
|
824
|
+
``..`` are refused with a 400 without touching the target. The local
|
|
825
|
+
target also never sees the hub credentials: forwarding uses a plain
|
|
826
|
+
``requests`` call, not this client's authenticated session.
|
|
827
|
+
|
|
828
|
+
The WebSocket is reconnected with exponential backoff on any drop, like
|
|
829
|
+
:meth:`serve`; the loop never exits on its own (stop with Ctrl-C).
|
|
830
|
+
|
|
831
|
+
Args:
|
|
832
|
+
target: base URL of the local web app, e.g.
|
|
833
|
+
``"http://127.0.0.1:5000"`` (any path/query on it is ignored —
|
|
834
|
+
only the scheme/host/port are used).
|
|
835
|
+
workers: size of the thread pool forwarding requests concurrently.
|
|
836
|
+
"""
|
|
837
|
+
try:
|
|
838
|
+
import websocket # type: ignore
|
|
839
|
+
except ImportError as exc: # pragma: no cover — dependency is declared
|
|
840
|
+
raise ToolchestratorError(
|
|
841
|
+
"serve_web needs the 'websocket-client' package; install it "
|
|
842
|
+
"with: pip install 'websocket-client>=1.5'") from exc
|
|
843
|
+
|
|
844
|
+
target = target.rstrip("/")
|
|
845
|
+
ws_url = self._tunnel_ws_url()
|
|
846
|
+
header = ["Authorization: Bearer %s" % self.api_key]
|
|
847
|
+
logger.info("tunnelling web UI at %s through tool %s (workers=%d)",
|
|
848
|
+
target, self.slug or self.tool_id, workers)
|
|
849
|
+
executor = ThreadPoolExecutor(max_workers=workers)
|
|
850
|
+
backoff = 1.0
|
|
851
|
+
try:
|
|
852
|
+
while True:
|
|
853
|
+
try:
|
|
854
|
+
ws = websocket.create_connection(ws_url, header=header)
|
|
855
|
+
except Exception as exc: # noqa: BLE001 — any connect error retries
|
|
856
|
+
logger.warning("tunnel connect failed (%s); retrying in "
|
|
857
|
+
"%.0fs", exc, backoff)
|
|
858
|
+
time.sleep(backoff)
|
|
859
|
+
backoff = min(backoff * 2, 30.0)
|
|
860
|
+
continue
|
|
861
|
+
backoff = 1.0 # clean connect resets the backoff
|
|
862
|
+
send_lock = threading.Lock()
|
|
863
|
+
logger.info("tunnel websocket connected for tool %s",
|
|
864
|
+
self.slug or self.tool_id)
|
|
865
|
+
try:
|
|
866
|
+
while True:
|
|
867
|
+
message = ws.recv()
|
|
868
|
+
if message is None or message == "":
|
|
869
|
+
break # server closed the socket
|
|
870
|
+
try:
|
|
871
|
+
req = json.loads(message)
|
|
872
|
+
except (ValueError, TypeError):
|
|
873
|
+
continue # ignore malformed frames
|
|
874
|
+
executor.submit(self._forward_web_request,
|
|
875
|
+
target, ws, send_lock, req)
|
|
876
|
+
except Exception as exc: # noqa: BLE001 — any drop reconnects
|
|
877
|
+
logger.warning("tunnel websocket dropped (%s); "
|
|
878
|
+
"reconnecting in %.0fs", exc, backoff)
|
|
879
|
+
finally:
|
|
880
|
+
try:
|
|
881
|
+
ws.close()
|
|
882
|
+
except Exception: # noqa: BLE001 — best-effort close
|
|
883
|
+
pass
|
|
884
|
+
time.sleep(backoff)
|
|
885
|
+
backoff = min(backoff * 2, 30.0)
|
|
886
|
+
finally:
|
|
887
|
+
executor.shutdown(wait=False)
|
|
888
|
+
|
|
889
|
+
def _tunnel_ws_url(self) -> str:
|
|
890
|
+
"""Build the tunnel WebSocket URL from the hub base URL.
|
|
891
|
+
|
|
892
|
+
Derives the scheme from ``self.base_url`` (``http`` → ``ws``,
|
|
893
|
+
``https`` → ``wss``) and appends this tool's tunnel path.
|
|
894
|
+
"""
|
|
895
|
+
base = self.base_url
|
|
896
|
+
if base.startswith("https://"):
|
|
897
|
+
ws_base = "wss://" + base[len("https://"):]
|
|
898
|
+
elif base.startswith("http://"):
|
|
899
|
+
ws_base = "ws://" + base[len("http://"):]
|
|
900
|
+
else: # already ws://, wss://, or scheme-less — use as-is
|
|
901
|
+
ws_base = base
|
|
902
|
+
return ws_base + "/api/tools/%s/tunnel/ws" % self._self_ref()
|
|
903
|
+
|
|
904
|
+
def _forward_web_request(self, target: str, ws: Any,
|
|
905
|
+
send_lock: "threading.Lock",
|
|
906
|
+
req: Dict[str, Any]) -> None:
|
|
907
|
+
"""Replay one forwarded request against ``target`` and send the reply.
|
|
908
|
+
|
|
909
|
+
Runs in the thread pool. Any failure reaching the local target is
|
|
910
|
+
turned into a 502 so the waiting browser request resolves instead of
|
|
911
|
+
hanging until the hub's timeout. The response frame is sent back over
|
|
912
|
+
the shared ``ws`` under ``send_lock`` (many workers share one socket).
|
|
913
|
+
"""
|
|
914
|
+
req_id = req.get("req_id")
|
|
915
|
+
if not req_id:
|
|
916
|
+
return
|
|
917
|
+
try:
|
|
918
|
+
status, headers, body = self._perform_web_forward(target, req)
|
|
919
|
+
except Exception as exc: # noqa: BLE001 — any error becomes a 502 reply
|
|
920
|
+
logger.warning("forwarding request %s failed: %s", req_id, exc)
|
|
921
|
+
status = 502
|
|
922
|
+
headers = {"Content-Type": "text/plain; charset=utf-8"}
|
|
923
|
+
body = ("tunnel target error: %s" % exc).encode("utf-8")
|
|
924
|
+
self._send_tunnel_response(ws, send_lock, req_id, status, headers, body)
|
|
925
|
+
|
|
926
|
+
def _perform_web_forward(self, target: str, req: Dict[str, Any]
|
|
927
|
+
) -> Tuple[int, Dict[str, str], bytes]:
|
|
928
|
+
"""Send one forwarded request to the local target; return its reply.
|
|
929
|
+
|
|
930
|
+
Returns ``(status, headers, body_bytes)``. The outbound URL is built
|
|
931
|
+
strictly as ``target + path + ("?" + query)`` — never from any
|
|
932
|
+
request-supplied host — and ``..`` paths are refused (400) up front.
|
|
933
|
+
"""
|
|
934
|
+
path = req.get("path") or "/"
|
|
935
|
+
if ".." in path:
|
|
936
|
+
return (400, {"Content-Type": "text/plain; charset=utf-8"},
|
|
937
|
+
b"refused: path contains '..'")
|
|
938
|
+
if not path.startswith("/"):
|
|
939
|
+
path = "/" + path
|
|
940
|
+
query = req.get("query") or ""
|
|
941
|
+
url = target + path + (("?" + query) if query else "")
|
|
942
|
+
method = (req.get("method") or "GET").upper()
|
|
943
|
+
headers = _filter_headers(req.get("headers") or {},
|
|
944
|
+
_STRIP_REQUEST_HEADERS)
|
|
945
|
+
body_b64 = req.get("body_b64")
|
|
946
|
+
body = base64.b64decode(body_b64) if body_b64 else None
|
|
947
|
+
# A plain requests call (not self._session) so the hub credentials are
|
|
948
|
+
# never sent to the local target; no redirect following so the browser
|
|
949
|
+
# sees 3xx responses itself.
|
|
950
|
+
resp = requests.request(method, url, headers=headers, data=body,
|
|
951
|
+
timeout=30, allow_redirects=False)
|
|
952
|
+
resp_headers = _filter_headers(resp.headers, _STRIP_RESPONSE_HEADERS)
|
|
953
|
+
return (resp.status_code, resp_headers, resp.content)
|
|
954
|
+
|
|
955
|
+
def _send_tunnel_response(self, ws: Any, send_lock: "threading.Lock",
|
|
956
|
+
req_id: str, status: int,
|
|
957
|
+
headers: Dict[str, str], body: bytes) -> None:
|
|
958
|
+
"""Send one tunnelled response frame back over the socket (best-effort).
|
|
959
|
+
|
|
960
|
+
Serialized by ``send_lock`` because worker threads share one socket.
|
|
961
|
+
"""
|
|
962
|
+
frame = {
|
|
963
|
+
"req_id": req_id,
|
|
964
|
+
"status": int(status),
|
|
965
|
+
"headers": dict(headers),
|
|
966
|
+
"body_b64": base64.b64encode(body or b"").decode("ascii"),
|
|
967
|
+
}
|
|
968
|
+
message = json.dumps(frame)
|
|
969
|
+
try:
|
|
970
|
+
with send_lock:
|
|
971
|
+
ws.send(message)
|
|
972
|
+
except Exception as exc: # noqa: BLE001 — a dead socket reconnects in the loop
|
|
973
|
+
logger.warning("sending tunnel response for %s failed: %s",
|
|
974
|
+
req_id, exc)
|
|
975
|
+
|
|
976
|
+
# ------------------------------------------------------------------ #
|
|
977
|
+
# Internals #
|
|
978
|
+
# ------------------------------------------------------------------ #
|
|
979
|
+
|
|
980
|
+
def _self_ref(self) -> str:
|
|
981
|
+
"""This tool's identifier for URL paths (id preferred, else slug)."""
|
|
982
|
+
ref = self.tool_id or self.slug
|
|
983
|
+
if not ref:
|
|
984
|
+
raise ToolchestratorError(
|
|
985
|
+
"this client has no tool identity (tool_id/slug): load it "
|
|
986
|
+
"from a .toolchestrator.json written by "
|
|
987
|
+
"Toolchestrator.register, or pass tool_id= explicitly")
|
|
988
|
+
return ref
|
|
989
|
+
|
|
990
|
+
def _request(self, method: str, path: str,
|
|
991
|
+
json_body: Optional[Dict[str, Any]] = None,
|
|
992
|
+
params: Optional[Dict[str, Any]] = None,
|
|
993
|
+
timeout: int = 30) -> Any:
|
|
994
|
+
"""Send one authenticated request; raise ToolchestratorError on failure."""
|
|
995
|
+
url = self.base_url + path
|
|
996
|
+
try:
|
|
997
|
+
resp = self._session.request(method, url, json=json_body,
|
|
998
|
+
params=params, timeout=timeout)
|
|
999
|
+
except requests.RequestException as exc:
|
|
1000
|
+
raise ToolchestratorError("request to %s failed: %s" % (url, exc))
|
|
1001
|
+
if resp.status_code >= 400:
|
|
1002
|
+
detail = _extract_detail(resp)
|
|
1003
|
+
raise ToolchestratorError(
|
|
1004
|
+
"%s %s -> %s: %s" % (method, path, resp.status_code, detail),
|
|
1005
|
+
status_code=resp.status_code, detail=detail)
|
|
1006
|
+
if resp.status_code == 204 or not resp.content:
|
|
1007
|
+
return None
|
|
1008
|
+
return resp.json()
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _filter_headers(headers: Any, drop: "frozenset") -> Dict[str, str]:
|
|
1012
|
+
"""Copy ``headers`` (dict or requests CaseInsensitiveDict), dropping any
|
|
1013
|
+
whose lower-cased name is in ``drop``. Values are coerced to ``str``."""
|
|
1014
|
+
out: Dict[str, str] = {}
|
|
1015
|
+
try:
|
|
1016
|
+
items = headers.items()
|
|
1017
|
+
except AttributeError:
|
|
1018
|
+
items = []
|
|
1019
|
+
for name, value in items:
|
|
1020
|
+
if str(name).lower() in drop:
|
|
1021
|
+
continue
|
|
1022
|
+
out[str(name)] = str(value)
|
|
1023
|
+
return out
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _extract_detail(resp: "requests.Response") -> Any:
|
|
1027
|
+
"""Best-effort extraction of the hub's ``{"detail": ...}`` error body."""
|
|
1028
|
+
try:
|
|
1029
|
+
body = resp.json()
|
|
1030
|
+
if isinstance(body, dict) and "detail" in body:
|
|
1031
|
+
return body["detail"]
|
|
1032
|
+
return body
|
|
1033
|
+
except ValueError:
|
|
1034
|
+
return resp.text[:500]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: toolchestrator
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Connect a local tool to a Toolchestrator hub: registry, versioned schema/data sync, task serving, cross-tool reads/calls, and web-UI tunnelling.
|
|
5
|
+
Author-email: Stevica Kuharski <kstevica@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://toolchestrator.com
|
|
8
|
+
Project-URL: Connect guide, https://hub.toolchestrator.com/skill
|
|
9
|
+
Keywords: toolchestrator,orchestration,internal-tools,claude-code,ai-tools,automation
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: requests>=2.25
|
|
25
|
+
Requires-Dist: websocket-client>=1.5
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# toolchestrator
|
|
29
|
+
|
|
30
|
+
The Python client for [Toolchestrator](https://toolchestrator.com) — connect a small local
|
|
31
|
+
tool to your company's hub without moving it off the machine it runs on.
|
|
32
|
+
|
|
33
|
+
With this client a connected tool can:
|
|
34
|
+
|
|
35
|
+
- register itself in the company **registry** (name, author, the problem it solves);
|
|
36
|
+
- publish **versioned JSON-Schema** data models and **sync records** to the hub;
|
|
37
|
+
- **serve remote task actions** over an outbound long-poll (the hub never connects into your
|
|
38
|
+
machine);
|
|
39
|
+
- **read other tools' data** through the hub with filtered queries;
|
|
40
|
+
- **invoke** another tool's action and get the result back (`tc.call`);
|
|
41
|
+
- **expose its own local web UI** through the hub (`tc.serve_web`).
|
|
42
|
+
|
|
43
|
+
Works on Python 3.9+; depends only on `requests` and `websocket-client`.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install toolchestrator
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quick start
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from toolchestrator import Toolchestrator
|
|
55
|
+
|
|
56
|
+
# one-time registration, with a personal token from your hub's Settings page
|
|
57
|
+
tc = Toolchestrator.register(
|
|
58
|
+
"https://hub.example.com", # your company's hub URL
|
|
59
|
+
"tcu_...", # personal access token
|
|
60
|
+
"invoice-radar", "Invoice Radar",
|
|
61
|
+
description="Tracks vendor invoices and flags overdue ones.",
|
|
62
|
+
sharing_scope="private", # start private; widen deliberately
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# afterwards, anywhere in the tool (loads .toolchestrator.json):
|
|
66
|
+
tc = Toolchestrator()
|
|
67
|
+
tc.sync_schema("invoice", invoice_schema)
|
|
68
|
+
tc.sync_data("invoice", rows, id_field="id")
|
|
69
|
+
tc.serve({"mark_paid": handle_mark_paid}) # execute tasks queued on the hub
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The easiest way to connect a tool is to let an AI coding agent do the wiring: point it at
|
|
73
|
+
your hub's connect guide (`<your-hub-url>/skill`) and ask it to *"connect this tool to
|
|
74
|
+
Toolchestrator"*. The hub URL always comes from you — the client never assumes one.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT © Stevica Kuharski
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
toolchestrator/__init__.py,sha256=EfvYag-kebRTGsS3uaSqBZmfLaF2k-EXXspExkG7JAI,1793
|
|
2
|
+
toolchestrator/client.py,sha256=LPI6FDFFzHHwj9CeK1HexMAVkDxImWDBhxp4XqLAI0Q,47568
|
|
3
|
+
toolchestrator-0.6.0.dist-info/licenses/LICENSE,sha256=ZH09wJV0YqfGR3_U7gXGsgaYOYT2eTYFbe7_Ev288gs,1073
|
|
4
|
+
toolchestrator-0.6.0.dist-info/METADATA,sha256=HiAa7K2v7Yi5DGvgc9SeZHZzl0j2JOCxHd8NUGcYrOE,3078
|
|
5
|
+
toolchestrator-0.6.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
toolchestrator-0.6.0.dist-info/top_level.txt,sha256=0XqBTq-CCPtZXfrFFGkdRtnixmm7v035Q88F6UE65-Y,15
|
|
7
|
+
toolchestrator-0.6.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stevica Kuharski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
toolchestrator
|