team-core 0.4.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.
- team/__init__.py +17 -0
- team/_version.py +1 -0
- team/beliefs.py +259 -0
- team/bridge.py +197 -0
- team/bridge_client.py +157 -0
- team/bridge_server.py +322 -0
- team/bus.py +155 -0
- team/checks.py +208 -0
- team/cli.py +1050 -0
- team/config.py +437 -0
- team/container.py +384 -0
- team/export.py +168 -0
- team/member.py +399 -0
- team/memory.py +203 -0
- team/ollama_client.py +537 -0
- team/orchestrator.py +220 -0
- team/persona_library.py +175 -0
- team/personas.py +170 -0
- team/skills.py +304 -0
- team/templates/report.html.j2 +153 -0
- team/tools.py +1007 -0
- team/visualize.py +230 -0
- team/wizard.py +197 -0
- team/workflows.py +556 -0
- team/workspace.py +283 -0
- team_core-0.4.0.dist-info/METADATA +2214 -0
- team_core-0.4.0.dist-info/RECORD +31 -0
- team_core-0.4.0.dist-info/WHEEL +5 -0
- team_core-0.4.0.dist-info/entry_points.txt +2 -0
- team_core-0.4.0.dist-info/licenses/LICENSE +21 -0
- team_core-0.4.0.dist-info/top_level.txt +1 -0
team/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""team — orchestrate a cluster of containerized local LLMs.
|
|
2
|
+
|
|
3
|
+
A `team` is a set of `Member`s (each backed by an Ollama model running in its
|
|
4
|
+
own Docker container) that collaborate to accomplish a `goal` according to a
|
|
5
|
+
chosen `workflow` (round-robin, manager-driven, review-loop, ...).
|
|
6
|
+
|
|
7
|
+
Public entry points:
|
|
8
|
+
|
|
9
|
+
* :func:`team.config.load_team` — parse a YAML team spec.
|
|
10
|
+
* :class:`team.orchestrator.Orchestrator` — bring members up, run a workflow,
|
|
11
|
+
tear them down.
|
|
12
|
+
* :mod:`team.cli` — the ``team`` command-line interface.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from team._version import __version__
|
|
16
|
+
|
|
17
|
+
__all__ = ["__version__"]
|
team/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
team/beliefs.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Shared team belief board — structured collective knowledge.
|
|
2
|
+
|
|
3
|
+
The :class:`BeliefBoard` lets team members collaboratively build and maintain
|
|
4
|
+
a structured record of what the team collectively knows, believes, and contests.
|
|
5
|
+
Each belief is a versioned claim with:
|
|
6
|
+
|
|
7
|
+
* an author and confidence score (0.0 – 1.0)
|
|
8
|
+
* optional supporting evidence
|
|
9
|
+
* a **voting record**: members can accept or contest each belief
|
|
10
|
+
* a status driven by consensus: ``pending`` → ``accepted`` / ``contested``
|
|
11
|
+
|
|
12
|
+
The board is stored as ``<workspace>/beliefs.json`` so it persists across
|
|
13
|
+
sessions and can be inspected with ``team beliefs myteam.yaml``.
|
|
14
|
+
|
|
15
|
+
Consensus rules
|
|
16
|
+
---------------
|
|
17
|
+
A belief transitions from ``pending`` to ``accepted`` when the fraction of
|
|
18
|
+
members who voted *for* it is ≥ ``consensus_threshold`` (default 0.5). Any
|
|
19
|
+
member can contest a belief, which immediately moves it to ``contested`` —
|
|
20
|
+
regardless of prior votes — forcing the team to re-evaluate.
|
|
21
|
+
|
|
22
|
+
Members can accept a previously contested belief, which re-triggers the
|
|
23
|
+
consensus check. A rejected belief stays ``rejected`` permanently unless
|
|
24
|
+
explicitly re-asserted with a new claim.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import time
|
|
31
|
+
import uuid
|
|
32
|
+
from dataclasses import asdict, dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --------------------------------------------------------------------------- #
|
|
38
|
+
# Data model
|
|
39
|
+
# --------------------------------------------------------------------------- #
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Belief:
|
|
44
|
+
id: str
|
|
45
|
+
claim: str
|
|
46
|
+
author: str
|
|
47
|
+
confidence: float # 0.0 – 1.0
|
|
48
|
+
evidence: str
|
|
49
|
+
status: str # pending | accepted | contested | rejected
|
|
50
|
+
votes_for: list[str] # member names who accepted
|
|
51
|
+
votes_against: list[str] # member names who contested
|
|
52
|
+
reasons: dict[str, str] # member → reason for contesting
|
|
53
|
+
created_at: float
|
|
54
|
+
updated_at: float
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --------------------------------------------------------------------------- #
|
|
58
|
+
# BeliefBoard
|
|
59
|
+
# --------------------------------------------------------------------------- #
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BeliefBoard:
|
|
63
|
+
"""Shared, persistent belief store for a team."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
path: Path,
|
|
68
|
+
member_names: list[str],
|
|
69
|
+
consensus_threshold: float = 0.5,
|
|
70
|
+
) -> None:
|
|
71
|
+
self.path = Path(path)
|
|
72
|
+
self.member_names = list(member_names)
|
|
73
|
+
self.consensus_threshold = max(0.0, min(1.0, consensus_threshold))
|
|
74
|
+
self._beliefs: dict[str, Belief] = {}
|
|
75
|
+
self._load()
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------ #
|
|
78
|
+
# Persistence
|
|
79
|
+
# ------------------------------------------------------------------ #
|
|
80
|
+
|
|
81
|
+
def _load(self) -> None:
|
|
82
|
+
if not self.path.exists():
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
86
|
+
for item in raw:
|
|
87
|
+
b = Belief(**item)
|
|
88
|
+
self._beliefs[b.id] = b
|
|
89
|
+
except (json.JSONDecodeError, TypeError, KeyError):
|
|
90
|
+
# Corrupt file — start fresh rather than crashing.
|
|
91
|
+
self._beliefs = {}
|
|
92
|
+
|
|
93
|
+
def _save(self) -> None:
|
|
94
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
data = [asdict(b) for b in self._beliefs.values()]
|
|
96
|
+
self.path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------ #
|
|
99
|
+
# Consensus check (internal)
|
|
100
|
+
# ------------------------------------------------------------------ #
|
|
101
|
+
|
|
102
|
+
def _check_consensus(self, b: Belief) -> None:
|
|
103
|
+
"""Update ``b.status`` based on current votes (modifies in place)."""
|
|
104
|
+
n = len(self.member_names)
|
|
105
|
+
if n == 0:
|
|
106
|
+
return
|
|
107
|
+
ratio_for = len(b.votes_for) / n
|
|
108
|
+
# A contested belief only un-contests if enough votes_for accumulate.
|
|
109
|
+
if ratio_for >= self.consensus_threshold:
|
|
110
|
+
if b.status != "rejected":
|
|
111
|
+
b.status = "accepted"
|
|
112
|
+
elif b.status == "accepted":
|
|
113
|
+
# Acceptance lost (e.g. a new member was added or a vote was removed).
|
|
114
|
+
b.status = "pending"
|
|
115
|
+
# Contested status is only cleared by explicit accept calls that move
|
|
116
|
+
# it to accepted — staying contested is fine.
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------------ #
|
|
119
|
+
# Public API
|
|
120
|
+
# ------------------------------------------------------------------ #
|
|
121
|
+
|
|
122
|
+
def assert_belief(
|
|
123
|
+
self,
|
|
124
|
+
claim: str,
|
|
125
|
+
author: str,
|
|
126
|
+
confidence: float = 0.5,
|
|
127
|
+
evidence: str = "",
|
|
128
|
+
) -> Belief:
|
|
129
|
+
"""Create a new belief.
|
|
130
|
+
|
|
131
|
+
The author implicitly casts an *accept* vote. Raises
|
|
132
|
+
:class:`ValueError` if *author* is not in ``member_names``.
|
|
133
|
+
"""
|
|
134
|
+
belief_id = str(uuid.uuid4())[:8]
|
|
135
|
+
now = time.time()
|
|
136
|
+
b = Belief(
|
|
137
|
+
id=belief_id,
|
|
138
|
+
claim=claim,
|
|
139
|
+
author=author,
|
|
140
|
+
confidence=confidence,
|
|
141
|
+
evidence=evidence,
|
|
142
|
+
status="pending",
|
|
143
|
+
votes_for=[author],
|
|
144
|
+
votes_against=[],
|
|
145
|
+
reasons={},
|
|
146
|
+
created_at=now,
|
|
147
|
+
updated_at=now,
|
|
148
|
+
)
|
|
149
|
+
self._beliefs[belief_id] = b
|
|
150
|
+
self._check_consensus(b)
|
|
151
|
+
self._save()
|
|
152
|
+
return b
|
|
153
|
+
|
|
154
|
+
def accept_belief(self, belief_id: str, voter: str) -> Belief:
|
|
155
|
+
"""Cast an *accept* vote for an existing belief.
|
|
156
|
+
|
|
157
|
+
Removes any previous *against* vote by the same member. Re-checks
|
|
158
|
+
consensus after the vote, which may transition the belief to
|
|
159
|
+
``accepted``. Raises :class:`KeyError` if the belief does not exist.
|
|
160
|
+
"""
|
|
161
|
+
b = self._beliefs[belief_id]
|
|
162
|
+
if voter not in b.votes_for:
|
|
163
|
+
b.votes_for.append(voter)
|
|
164
|
+
if voter in b.votes_against:
|
|
165
|
+
b.votes_against.remove(voter)
|
|
166
|
+
if voter in b.reasons:
|
|
167
|
+
del b.reasons[voter]
|
|
168
|
+
b.updated_at = time.time()
|
|
169
|
+
# Accepting can pull a belief out of contested status.
|
|
170
|
+
if b.status == "contested":
|
|
171
|
+
self._check_consensus(b)
|
|
172
|
+
else:
|
|
173
|
+
self._check_consensus(b)
|
|
174
|
+
self._save()
|
|
175
|
+
return b
|
|
176
|
+
|
|
177
|
+
def contest_belief(
|
|
178
|
+
self,
|
|
179
|
+
belief_id: str,
|
|
180
|
+
voter: str,
|
|
181
|
+
reason: str = "",
|
|
182
|
+
) -> Belief:
|
|
183
|
+
"""Contest a belief, moving it to ``contested`` status.
|
|
184
|
+
|
|
185
|
+
Any previous *accept* vote by *voter* is removed. Raises
|
|
186
|
+
:class:`KeyError` if the belief does not exist.
|
|
187
|
+
"""
|
|
188
|
+
b = self._beliefs[belief_id]
|
|
189
|
+
if voter not in b.votes_against:
|
|
190
|
+
b.votes_against.append(voter)
|
|
191
|
+
if voter in b.votes_for:
|
|
192
|
+
b.votes_for.remove(voter)
|
|
193
|
+
if reason:
|
|
194
|
+
b.reasons[voter] = reason
|
|
195
|
+
b.status = "contested"
|
|
196
|
+
b.updated_at = time.time()
|
|
197
|
+
self._save()
|
|
198
|
+
return b
|
|
199
|
+
|
|
200
|
+
def reject_belief(self, belief_id: str) -> Belief:
|
|
201
|
+
"""Permanently reject a belief.
|
|
202
|
+
|
|
203
|
+
Only useful for orchestrator-level overrides; normally beliefs are
|
|
204
|
+
contested by individual members rather than rejected outright.
|
|
205
|
+
"""
|
|
206
|
+
b = self._beliefs[belief_id]
|
|
207
|
+
b.status = "rejected"
|
|
208
|
+
b.updated_at = time.time()
|
|
209
|
+
self._save()
|
|
210
|
+
return b
|
|
211
|
+
|
|
212
|
+
def get_belief(self, belief_id: str) -> Belief | None:
|
|
213
|
+
"""Return the belief with *belief_id*, or ``None``."""
|
|
214
|
+
return self._beliefs.get(belief_id)
|
|
215
|
+
|
|
216
|
+
def list_beliefs(
|
|
217
|
+
self,
|
|
218
|
+
status: str | None = None,
|
|
219
|
+
) -> list[Belief]:
|
|
220
|
+
"""Return all beliefs, optionally filtered by *status*.
|
|
221
|
+
|
|
222
|
+
Results are ordered by recency (most recent first).
|
|
223
|
+
"""
|
|
224
|
+
results = list(self._beliefs.values())
|
|
225
|
+
if status:
|
|
226
|
+
results = [b for b in results if b.status == status]
|
|
227
|
+
return sorted(results, key=lambda b: b.updated_at, reverse=True)
|
|
228
|
+
|
|
229
|
+
def count(self) -> int:
|
|
230
|
+
"""Return the total number of beliefs."""
|
|
231
|
+
return len(self._beliefs)
|
|
232
|
+
|
|
233
|
+
def summary_for_prompt(self, limit: int = 10) -> str:
|
|
234
|
+
"""Compact Markdown summary of the belief board for context injection.
|
|
235
|
+
|
|
236
|
+
Returns an empty string when the board is empty.
|
|
237
|
+
"""
|
|
238
|
+
beliefs = self.list_beliefs()[:limit]
|
|
239
|
+
if not beliefs:
|
|
240
|
+
return ""
|
|
241
|
+
_ICONS: dict[str, str] = {
|
|
242
|
+
"accepted": "✓",
|
|
243
|
+
"contested": "⚡",
|
|
244
|
+
"rejected": "✗",
|
|
245
|
+
"pending": "?",
|
|
246
|
+
}
|
|
247
|
+
lines = [f"## Shared team belief board ({len(beliefs)} entries)"]
|
|
248
|
+
for b in beliefs:
|
|
249
|
+
icon = _ICONS.get(b.status, "?")
|
|
250
|
+
conf = f"{b.confidence:.0%}"
|
|
251
|
+
lines.append(
|
|
252
|
+
f"- [{icon}] `{b.id}` {b.claim} "
|
|
253
|
+
f"(conf: {conf}, by @{b.author}, status: {b.status})"
|
|
254
|
+
)
|
|
255
|
+
return "\n".join(lines)
|
|
256
|
+
|
|
257
|
+
def to_dict_list(self) -> list[dict[str, Any]]:
|
|
258
|
+
"""Return all beliefs as a list of plain dicts (for serialisation)."""
|
|
259
|
+
return [asdict(b) for b in self._beliefs.values()]
|
team/bridge.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Cross-team collaboration bridge — protocol layer.
|
|
2
|
+
|
|
3
|
+
Two ``team`` clusters running on separate machines can collaborate on a
|
|
4
|
+
shared goal through the bridge protocol. One cluster acts as the *client*
|
|
5
|
+
(it delegates a sub-task) and the other acts as the *server* (it receives
|
|
6
|
+
the task, runs its full team workflow, and returns the results).
|
|
7
|
+
|
|
8
|
+
Protocol objects
|
|
9
|
+
----------------
|
|
10
|
+
:class:`BridgeTask`
|
|
11
|
+
Sent by the client when it wants to delegate work. Carries the goal,
|
|
12
|
+
an optional free-text context blurb, and any workspace files the remote
|
|
13
|
+
team needs to start from.
|
|
14
|
+
|
|
15
|
+
:class:`BridgeResult`
|
|
16
|
+
Returned by the server. Contains a free-text summary of what the remote
|
|
17
|
+
team accomplished, the files it produced, and a status field.
|
|
18
|
+
|
|
19
|
+
:class:`TaskStore`
|
|
20
|
+
Server-side, thread-safe registry of in-flight and completed tasks.
|
|
21
|
+
The HTTP server layer reads and writes through this store.
|
|
22
|
+
|
|
23
|
+
Wire format
|
|
24
|
+
-----------
|
|
25
|
+
All objects are serialised as plain JSON so any language/tool can interact
|
|
26
|
+
with a bridge server — no shared code required.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
import uuid
|
|
34
|
+
from dataclasses import asdict, dataclass, field
|
|
35
|
+
from typing import Any, Literal
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --------------------------------------------------------------------------- #
|
|
39
|
+
# Protocol dataclasses
|
|
40
|
+
# --------------------------------------------------------------------------- #
|
|
41
|
+
|
|
42
|
+
TaskStatus = Literal["pending", "running", "complete", "error"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class BridgeTask:
|
|
47
|
+
"""A unit of work delegated from one team cluster to another.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
goal:
|
|
52
|
+
What the remote team should accomplish (free text; becomes the
|
|
53
|
+
``goal`` for a one-shot Orchestrator run on the server side).
|
|
54
|
+
context:
|
|
55
|
+
Optional background information to include in the remote team's
|
|
56
|
+
kickoff prompt.
|
|
57
|
+
files:
|
|
58
|
+
Workspace files to transfer to the remote team, keyed by their
|
|
59
|
+
relative path. The server writes them into the remote team's
|
|
60
|
+
shared workspace before running the workflow.
|
|
61
|
+
sender:
|
|
62
|
+
Name of the sending team (informational; logged by the server).
|
|
63
|
+
task_id:
|
|
64
|
+
Stable identifier for this task. Auto-generated if not provided.
|
|
65
|
+
created_at:
|
|
66
|
+
Unix timestamp. Auto-set if not provided.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
goal: str
|
|
70
|
+
context: str = ""
|
|
71
|
+
files: dict[str, str] = field(default_factory=dict)
|
|
72
|
+
sender: str = "unknown"
|
|
73
|
+
task_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
74
|
+
created_at: float = field(default_factory=time.time)
|
|
75
|
+
|
|
76
|
+
# ------------------------------------------------------------------ #
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict[str, Any]:
|
|
79
|
+
return asdict(self)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_dict(cls, data: dict[str, Any]) -> "BridgeTask":
|
|
83
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class BridgeResult:
|
|
88
|
+
"""The outcome of a delegated task, returned by the bridge server.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
task_id:
|
|
93
|
+
Matches the originating :class:`BridgeTask`.
|
|
94
|
+
status:
|
|
95
|
+
``"pending"`` — task queued but not started.
|
|
96
|
+
``"running"`` — team workflow in progress.
|
|
97
|
+
``"complete"`` — workflow finished; inspect *summary* and *files*.
|
|
98
|
+
``"error"`` — workflow raised an exception; inspect *error*.
|
|
99
|
+
summary:
|
|
100
|
+
Free-text description of what the remote team accomplished.
|
|
101
|
+
files:
|
|
102
|
+
Files produced by the remote team's shared workspace, keyed by
|
|
103
|
+
relative path. Written into the local workspace by the client.
|
|
104
|
+
error:
|
|
105
|
+
Set when ``status == "error"``; contains the exception message.
|
|
106
|
+
completed_at:
|
|
107
|
+
Unix timestamp of when the workflow finished (or ``None``).
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
task_id: str
|
|
111
|
+
status: TaskStatus = "pending"
|
|
112
|
+
summary: str = ""
|
|
113
|
+
files: dict[str, str] = field(default_factory=dict)
|
|
114
|
+
error: str | None = None
|
|
115
|
+
completed_at: float | None = None
|
|
116
|
+
|
|
117
|
+
# ------------------------------------------------------------------ #
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> dict[str, Any]:
|
|
120
|
+
return asdict(self)
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_dict(cls, data: dict[str, Any]) -> "BridgeResult":
|
|
124
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --------------------------------------------------------------------------- #
|
|
128
|
+
# Server-side task store
|
|
129
|
+
# --------------------------------------------------------------------------- #
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class TaskStore:
|
|
133
|
+
"""Thread-safe registry of tasks and their results.
|
|
134
|
+
|
|
135
|
+
The HTTP server layer submits tasks here and workers update them.
|
|
136
|
+
Clients poll through the same store.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self) -> None:
|
|
140
|
+
self._lock = threading.Lock()
|
|
141
|
+
self._tasks: dict[str, BridgeTask] = {}
|
|
142
|
+
self._results: dict[str, BridgeResult] = {}
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------ #
|
|
145
|
+
# Writing
|
|
146
|
+
# ------------------------------------------------------------------ #
|
|
147
|
+
|
|
148
|
+
def add_task(self, task: BridgeTask) -> None:
|
|
149
|
+
"""Register a new task (status: pending)."""
|
|
150
|
+
with self._lock:
|
|
151
|
+
self._tasks[task.task_id] = task
|
|
152
|
+
self._results[task.task_id] = BridgeResult(
|
|
153
|
+
task_id=task.task_id, status="pending"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def mark_running(self, task_id: str) -> None:
|
|
157
|
+
with self._lock:
|
|
158
|
+
if task_id in self._results:
|
|
159
|
+
self._results[task_id].status = "running"
|
|
160
|
+
|
|
161
|
+
def mark_complete(
|
|
162
|
+
self,
|
|
163
|
+
task_id: str,
|
|
164
|
+
summary: str,
|
|
165
|
+
files: dict[str, str],
|
|
166
|
+
) -> None:
|
|
167
|
+
with self._lock:
|
|
168
|
+
if task_id in self._results:
|
|
169
|
+
r = self._results[task_id]
|
|
170
|
+
r.status = "complete"
|
|
171
|
+
r.summary = summary
|
|
172
|
+
r.files = files
|
|
173
|
+
r.completed_at = time.time()
|
|
174
|
+
|
|
175
|
+
def mark_error(self, task_id: str, error: str) -> None:
|
|
176
|
+
with self._lock:
|
|
177
|
+
if task_id in self._results:
|
|
178
|
+
r = self._results[task_id]
|
|
179
|
+
r.status = "error"
|
|
180
|
+
r.error = error
|
|
181
|
+
r.completed_at = time.time()
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------ #
|
|
184
|
+
# Reading
|
|
185
|
+
# ------------------------------------------------------------------ #
|
|
186
|
+
|
|
187
|
+
def get_task(self, task_id: str) -> BridgeTask | None:
|
|
188
|
+
with self._lock:
|
|
189
|
+
return self._tasks.get(task_id)
|
|
190
|
+
|
|
191
|
+
def get_result(self, task_id: str) -> BridgeResult | None:
|
|
192
|
+
with self._lock:
|
|
193
|
+
return self._results.get(task_id)
|
|
194
|
+
|
|
195
|
+
def list_task_ids(self) -> list[str]:
|
|
196
|
+
with self._lock:
|
|
197
|
+
return list(self._tasks.keys())
|
team/bridge_client.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Bridge HTTP client — submit tasks to a remote team cluster and collect results.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from team.bridge_client import BridgeClient
|
|
6
|
+
from team.bridge import BridgeTask
|
|
7
|
+
|
|
8
|
+
client = BridgeClient("http://lab-b.example.com:7001")
|
|
9
|
+
|
|
10
|
+
task = BridgeTask(
|
|
11
|
+
goal="Run the survival analysis on the preprocessed BRCA data.",
|
|
12
|
+
context="Preprocessing complete; data.csv contains 1 142 samples.",
|
|
13
|
+
files={"data/preprocessed.csv": "<csv content>"},
|
|
14
|
+
sender="lab-a",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
task_id = client.submit_task(task)
|
|
18
|
+
result = client.wait_for_result(task_id, timeout=600)
|
|
19
|
+
|
|
20
|
+
if result.status == "complete":
|
|
21
|
+
for path, content in result.files.items():
|
|
22
|
+
print(f"received: {path} ({len(content)} chars)")
|
|
23
|
+
else:
|
|
24
|
+
print(f"task failed: {result.error}")
|
|
25
|
+
|
|
26
|
+
All network calls use ``requests`` (already a project dependency) and raise
|
|
27
|
+
:class:`BridgeClientError` on HTTP or connection failures.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
import requests
|
|
36
|
+
|
|
37
|
+
from team.bridge import BridgeResult, BridgeTask
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
_DEFAULT_POLL_INTERVAL = 5.0 # seconds between status polls
|
|
42
|
+
_DEFAULT_TIMEOUT = 600 # seconds to wait for a result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BridgeClientError(RuntimeError):
|
|
46
|
+
"""Raised when a bridge HTTP call fails."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BridgeClient:
|
|
50
|
+
"""HTTP client for the cross-team bridge protocol.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
base_url:
|
|
55
|
+
Root URL of the remote bridge server, e.g.
|
|
56
|
+
``"http://lab-b.example.com:7001"``. Trailing slashes are stripped.
|
|
57
|
+
request_timeout:
|
|
58
|
+
Per-HTTP-call timeout in seconds (default: 30).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, base_url: str, *, request_timeout: int = 30) -> None:
|
|
62
|
+
self.base_url = base_url.rstrip("/")
|
|
63
|
+
self._timeout = request_timeout
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------ #
|
|
66
|
+
# Public API
|
|
67
|
+
# ------------------------------------------------------------------ #
|
|
68
|
+
|
|
69
|
+
def health(self) -> dict:
|
|
70
|
+
"""Return the server's health payload or raise :class:`BridgeClientError`."""
|
|
71
|
+
return self._get("/health")
|
|
72
|
+
|
|
73
|
+
def submit_task(self, task: BridgeTask) -> str:
|
|
74
|
+
"""Submit a task and return the assigned ``task_id``.
|
|
75
|
+
|
|
76
|
+
Raises :class:`BridgeClientError` on any HTTP or network error.
|
|
77
|
+
"""
|
|
78
|
+
resp = self._post("/tasks", task.to_dict())
|
|
79
|
+
task_id = resp.get("task_id")
|
|
80
|
+
if not task_id:
|
|
81
|
+
raise BridgeClientError(f"server returned no task_id: {resp}")
|
|
82
|
+
log.info("bridge client: submitted task %s to %s", task_id, self.base_url)
|
|
83
|
+
return task_id
|
|
84
|
+
|
|
85
|
+
def poll_result(self, task_id: str) -> BridgeResult:
|
|
86
|
+
"""Fetch the current result/status for *task_id* (single call, no waiting).
|
|
87
|
+
|
|
88
|
+
Raises :class:`BridgeClientError` if the task is not found or the
|
|
89
|
+
server returns an error.
|
|
90
|
+
"""
|
|
91
|
+
data = self._get(f"/tasks/{task_id}")
|
|
92
|
+
return BridgeResult.from_dict(data)
|
|
93
|
+
|
|
94
|
+
def wait_for_result(
|
|
95
|
+
self,
|
|
96
|
+
task_id: str,
|
|
97
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
98
|
+
poll_interval: float = _DEFAULT_POLL_INTERVAL,
|
|
99
|
+
) -> BridgeResult:
|
|
100
|
+
"""Poll until the task reaches a terminal state and return the result.
|
|
101
|
+
|
|
102
|
+
Terminal states are ``"complete"`` and ``"error"``.
|
|
103
|
+
|
|
104
|
+
Parameters
|
|
105
|
+
----------
|
|
106
|
+
task_id:
|
|
107
|
+
The task to wait for.
|
|
108
|
+
timeout:
|
|
109
|
+
Maximum total seconds to wait before raising :class:`BridgeClientError`.
|
|
110
|
+
poll_interval:
|
|
111
|
+
Seconds to sleep between polls (default: 5).
|
|
112
|
+
|
|
113
|
+
Raises :class:`BridgeClientError` if the timeout expires or if a
|
|
114
|
+
network error occurs during polling.
|
|
115
|
+
"""
|
|
116
|
+
deadline = time.monotonic() + timeout
|
|
117
|
+
while True:
|
|
118
|
+
result = self.poll_result(task_id)
|
|
119
|
+
log.debug(
|
|
120
|
+
"bridge client: task %s status=%s", task_id, result.status
|
|
121
|
+
)
|
|
122
|
+
if result.status in ("complete", "error"):
|
|
123
|
+
return result
|
|
124
|
+
remaining = deadline - time.monotonic()
|
|
125
|
+
if remaining <= 0:
|
|
126
|
+
raise BridgeClientError(
|
|
127
|
+
f"timed out waiting for task {task_id!r} after {timeout}s "
|
|
128
|
+
f"(last status: {result.status!r})"
|
|
129
|
+
)
|
|
130
|
+
time.sleep(min(poll_interval, max(0, remaining)))
|
|
131
|
+
|
|
132
|
+
# ------------------------------------------------------------------ #
|
|
133
|
+
# Low-level HTTP helpers
|
|
134
|
+
# ------------------------------------------------------------------ #
|
|
135
|
+
|
|
136
|
+
def _get(self, path: str) -> dict:
|
|
137
|
+
url = self.base_url + path
|
|
138
|
+
try:
|
|
139
|
+
resp = requests.get(url, timeout=self._timeout)
|
|
140
|
+
resp.raise_for_status()
|
|
141
|
+
return resp.json()
|
|
142
|
+
except requests.RequestException as exc:
|
|
143
|
+
raise BridgeClientError(f"GET {url} failed: {exc}") from exc
|
|
144
|
+
|
|
145
|
+
def _post(self, path: str, payload: dict) -> dict:
|
|
146
|
+
url = self.base_url + path
|
|
147
|
+
try:
|
|
148
|
+
resp = requests.post(
|
|
149
|
+
url,
|
|
150
|
+
json=payload,
|
|
151
|
+
timeout=self._timeout,
|
|
152
|
+
headers={"Content-Type": "application/json"},
|
|
153
|
+
)
|
|
154
|
+
resp.raise_for_status()
|
|
155
|
+
return resp.json()
|
|
156
|
+
except requests.RequestException as exc:
|
|
157
|
+
raise BridgeClientError(f"POST {url} failed: {exc}") from exc
|