intermesh 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {intermesh-0.3.0 → intermesh-0.4.0}/PKG-INFO +3 -1
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/__init__.py +1 -1
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/admin.py +98 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/agent.py +66 -8
- intermesh-0.4.0/intermesh/approval.py +186 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/hub.py +617 -46
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/message.py +8 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/peering.py +47 -3
- intermesh-0.4.0/intermesh/store.py +452 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/task.py +14 -1
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh.egg-info/PKG-INFO +3 -1
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh.egg-info/SOURCES.txt +1 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh.egg-info/requires.txt +3 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/pyproject.toml +6 -1
- intermesh-0.3.0/intermesh/store.py +0 -177
- {intermesh-0.3.0 → intermesh-0.4.0}/README.md +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/adapters.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/apikeys.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/audit.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/bridge.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/cli.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/config.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/crypto.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/egress.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/escrow.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/guardrails.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/hardware.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/health.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/identity.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/logger.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/metrics.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/pipeline.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/policy.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/ratelimit.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/schema.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/secret.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/signing.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh/snapshot.py +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh.egg-info/dependency_links.txt +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh.egg-info/entry_points.txt +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/intermesh.egg-info/top_level.txt +0 -0
- {intermesh-0.3.0 → intermesh-0.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: intermesh
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Universal coordination protocol for AI agents — E2E encrypted, cross-language, federated
|
|
5
5
|
Author: InterMesh Protocol Authors
|
|
6
6
|
License: Apache-2.0
|
|
@@ -26,6 +26,8 @@ Description-Content-Type: text/markdown
|
|
|
26
26
|
Requires-Dist: websockets>=12.0
|
|
27
27
|
Requires-Dist: PyJWT>=2.8.0
|
|
28
28
|
Requires-Dist: cryptography>=42.0.0
|
|
29
|
+
Provides-Extra: postgres
|
|
30
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
|
|
29
31
|
|
|
30
32
|
# InterMesh — Python SDK
|
|
31
33
|
|
|
@@ -57,6 +57,8 @@ MUTATING = {
|
|
|
57
57
|
"task.retry",
|
|
58
58
|
"apikey.create",
|
|
59
59
|
"apikey.revoke",
|
|
60
|
+
"approval.approve",
|
|
61
|
+
"approval.deny",
|
|
60
62
|
"guardrails.set_policy",
|
|
61
63
|
"escrow.grant",
|
|
62
64
|
"escrow.release",
|
|
@@ -93,7 +95,14 @@ class AdminContext:
|
|
|
93
95
|
asimov_engine: Any = None,
|
|
94
96
|
escrow_manager: Any = None,
|
|
95
97
|
snapshot_dir: Any = None,
|
|
98
|
+
pending_approvals: Any = None,
|
|
99
|
+
finalize_task: Callable | None = None,
|
|
96
100
|
):
|
|
101
|
+
# Tâches retenues en attente d'arbitrage humain, et le moyen de les
|
|
102
|
+
# relâcher. `finalize_task` est le même chemin que celui d'une
|
|
103
|
+
# soumission ordinaire : approuver ne doit pas livrer autrement.
|
|
104
|
+
self.pending_approvals = pending_approvals if pending_approvals is not None else {}
|
|
105
|
+
self.finalize_task = finalize_task
|
|
97
106
|
self.snapshot_dir = snapshot_dir
|
|
98
107
|
self.agents = agents
|
|
99
108
|
self.identity_registry = identity_registry
|
|
@@ -274,6 +283,7 @@ def _tasks_list(ctx: AdminContext, params: dict) -> dict:
|
|
|
274
283
|
{
|
|
275
284
|
"task_id": t.task_id,
|
|
276
285
|
"title": t.title,
|
|
286
|
+
"summary": t.summary,
|
|
277
287
|
"orchestrator": t.orchestrator,
|
|
278
288
|
"assignee": t.assignee,
|
|
279
289
|
"status": t.status.value,
|
|
@@ -694,6 +704,91 @@ def _snapshot_restore(ctx: AdminContext, params: dict) -> dict:
|
|
|
694
704
|
}
|
|
695
705
|
|
|
696
706
|
|
|
707
|
+
def _pending_in_scope(ctx: AdminContext, entry: dict) -> bool:
|
|
708
|
+
if not ctx.scoped:
|
|
709
|
+
return True
|
|
710
|
+
task = entry.get("task") or {}
|
|
711
|
+
return ctx.caller_org in (_org_of(entry.get("submitter")), _org_of(task.get("assignee")))
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _approvals_list(ctx: AdminContext, params: dict) -> dict:
|
|
715
|
+
"""Tâches en attente d'un arbitrage humain.
|
|
716
|
+
|
|
717
|
+
La charge utile de la tâche n'est pas renvoyée : elle peut contenir les
|
|
718
|
+
données mêmes que l'organisation ne veut pas voir circuler. Ce qui est
|
|
719
|
+
montré est ce qu'il faut pour décider — qui demande, pour qui, et la
|
|
720
|
+
raison écrite dans la règle.
|
|
721
|
+
"""
|
|
722
|
+
out = []
|
|
723
|
+
for task_id, entry in ctx.pending_approvals.items():
|
|
724
|
+
if not _pending_in_scope(ctx, entry):
|
|
725
|
+
continue
|
|
726
|
+
task = entry.get("task") or {}
|
|
727
|
+
out.append({
|
|
728
|
+
"task_id": task_id,
|
|
729
|
+
"title": task.get("title"),
|
|
730
|
+
"submitter": entry.get("submitter"),
|
|
731
|
+
"assignee": task.get("assignee"),
|
|
732
|
+
"rule": entry.get("rule"),
|
|
733
|
+
"reason": entry.get("reason"),
|
|
734
|
+
"estimated_cost": task.get("estimated_cost"),
|
|
735
|
+
"submitted_at": entry.get("submitted_at"),
|
|
736
|
+
})
|
|
737
|
+
out.sort(key=lambda e: e.get("submitted_at") or 0)
|
|
738
|
+
return {"pending": out, "count": len(out)}
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _pop_pending(ctx: AdminContext, params: dict) -> tuple[str, dict]:
|
|
742
|
+
task_id = params.get("task_id")
|
|
743
|
+
entry = ctx.pending_approvals.get(task_id)
|
|
744
|
+
if entry is None:
|
|
745
|
+
raise AdminError("Aucune tâche en attente d'approbation sous cet identifiant.")
|
|
746
|
+
if not _pending_in_scope(ctx, entry):
|
|
747
|
+
raise AdminError("Cette tâche est hors de votre organisation.")
|
|
748
|
+
return task_id, ctx.pending_approvals.pop(task_id)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
async def _approval_approve(ctx: AdminContext, params: dict) -> dict:
|
|
752
|
+
"""Relâche une tâche retenue, par le même chemin qu'une soumission normale.
|
|
753
|
+
|
|
754
|
+
Le séquestre est constitué maintenant, pas au moment de la soumission :
|
|
755
|
+
rien n'aura été immobilisé pendant l'attente. Le revers est qu'il peut
|
|
756
|
+
échouer ici, faute de fonds — l'erreur est alors renvoyée à
|
|
757
|
+
l'approbateur plutôt que masquée.
|
|
758
|
+
"""
|
|
759
|
+
task_id, entry = _pop_pending(ctx, params)
|
|
760
|
+
approver = params.get("_approver") or "admin"
|
|
761
|
+
|
|
762
|
+
if ctx.finalize_task is None:
|
|
763
|
+
raise AdminError("Ce Hub ne sait pas relâcher une tâche approuvée.")
|
|
764
|
+
|
|
765
|
+
err = await ctx.finalize_task(entry["task"], entry["submitter"], entry.get("token"))
|
|
766
|
+
if err:
|
|
767
|
+
# La tâche ressort de la file : elle n'a pas été livrée, et la
|
|
768
|
+
# remettre en attente laisse une chance de réapprouver une fois le
|
|
769
|
+
# solde reconstitué, au lieu de la perdre.
|
|
770
|
+
ctx.pending_approvals[task_id] = entry
|
|
771
|
+
raise AdminError(f"Approuvée, mais non livrée : {err}")
|
|
772
|
+
|
|
773
|
+
ctx.audit_log.log("APPROVAL_GRANTED", approver, entry["task"].get("assignee"), {
|
|
774
|
+
"task_id": task_id, "rule": entry.get("rule"), "submitter": entry.get("submitter"),
|
|
775
|
+
})
|
|
776
|
+
return {"task_id": task_id, "status": "approved"}
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _approval_deny(ctx: AdminContext, params: dict) -> dict:
|
|
780
|
+
"""Refuse une tâche retenue. Aucun séquestre n'existe, rien à défaire."""
|
|
781
|
+
task_id, entry = _pop_pending(ctx, params)
|
|
782
|
+
approver = params.get("_approver") or "admin"
|
|
783
|
+
note = params.get("note") or ""
|
|
784
|
+
|
|
785
|
+
ctx.audit_log.log("APPROVAL_DENIED", approver, entry["task"].get("assignee"), {
|
|
786
|
+
"task_id": task_id, "rule": entry.get("rule"),
|
|
787
|
+
"submitter": entry.get("submitter"), "note": note,
|
|
788
|
+
})
|
|
789
|
+
return {"task_id": task_id, "status": "denied", "note": note}
|
|
790
|
+
|
|
791
|
+
|
|
697
792
|
HANDLERS: dict[str, Callable[..., Any]] = {
|
|
698
793
|
"hub.info": _hub_info,
|
|
699
794
|
"agents.list": _agents_list,
|
|
@@ -706,6 +801,9 @@ HANDLERS: dict[str, Callable[..., Any]] = {
|
|
|
706
801
|
"apikeys.list": _apikeys_list,
|
|
707
802
|
"apikey.create": _apikey_create,
|
|
708
803
|
"apikey.revoke": _apikey_revoke,
|
|
804
|
+
"approvals.list": _approvals_list,
|
|
805
|
+
"approval.approve": _approval_approve,
|
|
806
|
+
"approval.deny": _approval_deny,
|
|
709
807
|
"guardrails.policy": _guardrails_policy,
|
|
710
808
|
"guardrails.set_policy": _guardrails_set_policy,
|
|
711
809
|
"escrow.balance": _escrow_balance,
|
|
@@ -136,12 +136,34 @@ class InterMeshAgent:
|
|
|
136
136
|
if "org_id" in res.content:
|
|
137
137
|
self.identity.org_id = res.content["org_id"]
|
|
138
138
|
self.org_id = res.content["org_id"]
|
|
139
|
+
self._learn_siblings(res.content.get("cluster_hubs"))
|
|
139
140
|
print(f"✅ [{self.qualified_name}] Connecté sur {url} | E2E: {'🔒 ON' if self.encrypt else '🔓 OFF'}")
|
|
140
141
|
elif res.type == MessageType.ERROR:
|
|
141
142
|
print(f"❌ [{self.name}] Refusé : {res.content}")
|
|
142
143
|
await ws.close()
|
|
143
144
|
raise PermissionError(res.content)
|
|
144
145
|
|
|
146
|
+
def _learn_siblings(self, urls) -> None:
|
|
147
|
+
"""Ajoute les Hubs frères annoncés par le Hub à la liste de repli.
|
|
148
|
+
|
|
149
|
+
Un agent ne connaît que l'adresse qu'on lui a donnée. Si ce Hub
|
|
150
|
+
meurt, la boucle de reconnexion rejoue indéfiniment la même adresse
|
|
151
|
+
morte, alors qu'un frère de la même grappe l'accepterait — c'est
|
|
152
|
+
mesurable, et c'était le comportement avant ceci.
|
|
153
|
+
|
|
154
|
+
Les énumérer à la main dans chaque agent serait possible, mais ne
|
|
155
|
+
survivrait pas à l'ajout d'un Hub : la liste vieillirait en silence,
|
|
156
|
+
ce qui revient à ne pas l'avoir. Le Hub, lui, sait qui est vivant.
|
|
157
|
+
|
|
158
|
+
Les adresses reçues sont *ajoutées*, jamais substituées : celle que
|
|
159
|
+
l'exploitant a écrite reste la première essayée.
|
|
160
|
+
"""
|
|
161
|
+
if not isinstance(urls, list):
|
|
162
|
+
return
|
|
163
|
+
for candidate in urls:
|
|
164
|
+
if isinstance(candidate, str) and candidate and candidate not in self._hub_candidates:
|
|
165
|
+
self._hub_candidates.append(candidate)
|
|
166
|
+
|
|
145
167
|
async def connect(self):
|
|
146
168
|
"""
|
|
147
169
|
Connexion initiale. Avec plusieurs URLs de Hub, essaie chacune dans
|
|
@@ -311,6 +333,35 @@ class InterMeshAgent:
|
|
|
311
333
|
try: return json.loads(encrypted_content)
|
|
312
334
|
except json.JSONDecodeError: return encrypted_content
|
|
313
335
|
|
|
336
|
+
async def _respond_to_request(self, msg) -> None:
|
|
337
|
+
"""Exécute le gestionnaire de requêtes et renvoie la réponse.
|
|
338
|
+
|
|
339
|
+
Séparée de la boucle d'écoute pour que celle-ci reste disponible :
|
|
340
|
+
le chiffrement de la réponse a besoin d'un aller-retour WHO_IS, que
|
|
341
|
+
la boucle doit pouvoir traiter pendant que cette tâche l'attend.
|
|
342
|
+
"""
|
|
343
|
+
try:
|
|
344
|
+
handler = self._request_handler
|
|
345
|
+
reply = await handler(msg) if inspect.iscoroutinefunction(handler) else handler(msg)
|
|
346
|
+
reply = self._apply_egress(msg.sender, reply)
|
|
347
|
+
if self.encrypt:
|
|
348
|
+
pk = await self._fetch_public_key(msg.sender)
|
|
349
|
+
if pk:
|
|
350
|
+
reply = self._encrypt_content(pk, reply)
|
|
351
|
+
else:
|
|
352
|
+
# Sans clé, la réponse partirait en clair alors que
|
|
353
|
+
# l'appelant a demandé le chiffrement. Le dire est le
|
|
354
|
+
# minimum : une dégradation silencieuse vers le clair
|
|
355
|
+
# est pire qu'une erreur.
|
|
356
|
+
print(f"⚠️ [{self.qualified_name}] Clé publique de "
|
|
357
|
+
f"{msg.sender} introuvable : réponse NON chiffrée.")
|
|
358
|
+
await self.ws.send(InterMeshMessage(
|
|
359
|
+
type=MessageType.RESPONSE, sender=self.qualified_name, to=msg.sender,
|
|
360
|
+
reply_to=msg.id, content=reply, token=self.token,
|
|
361
|
+
).to_json())
|
|
362
|
+
except Exception as exc:
|
|
363
|
+
print(f"⚠️ [{self.qualified_name}] Réponse impossible : {exc}")
|
|
364
|
+
|
|
314
365
|
async def _listen_loop(self):
|
|
315
366
|
try:
|
|
316
367
|
async for raw in self.ws:
|
|
@@ -348,12 +399,15 @@ class InterMeshAgent:
|
|
|
348
399
|
content = self._decrypt_content(content)
|
|
349
400
|
msg.content = content
|
|
350
401
|
if self._request_handler:
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
402
|
+
# Hors de la boucle, comme TASK_ASSIGN. Répondre ici
|
|
403
|
+
# même conduisait à un interblocage : chiffrer la
|
|
404
|
+
# réponse exige la clé publique du demandeur, obtenue
|
|
405
|
+
# par un WHO_IS dont la réponse ne peut être traitée
|
|
406
|
+
# que par cette boucle — alors bloquée à l'attendre.
|
|
407
|
+
# Le WHO_IS expirait donc systématiquement (3 s par
|
|
408
|
+
# requête) et, faute de clé, la réponse repartait en
|
|
409
|
+
# clair alors que le chiffrement était demandé.
|
|
410
|
+
asyncio.create_task(self._respond_to_request(msg))
|
|
357
411
|
|
|
358
412
|
elif msg.type == MessageType.MESSAGE:
|
|
359
413
|
content = msg.content
|
|
@@ -408,9 +462,13 @@ class InterMeshAgent:
|
|
|
408
462
|
if self.encrypt:
|
|
409
463
|
pk = await self._fetch_public_key(task.orchestrator)
|
|
410
464
|
if pk: encrypted_output = self._encrypt_content(pk, encrypted_output)
|
|
411
|
-
task.
|
|
465
|
+
# task.summary peut avoir été renseigné par le handler (en clair,
|
|
466
|
+
# contrairement à output_data qui peut être chiffré) : sinon on
|
|
467
|
+
# retombe sur un résumé générique pour la page de résumés.
|
|
468
|
+
summary = task.summary or f"Tâche « {task.title} » terminée par {self.qualified_name}."
|
|
469
|
+
task.update_status(TaskStatus.COMPLETED, output_data=encrypted_output, summary=summary)
|
|
412
470
|
except Exception as e:
|
|
413
|
-
task.update_status(TaskStatus.FAILED, error_message=str(e))
|
|
471
|
+
task.update_status(TaskStatus.FAILED, error_message=str(e), summary=task.summary or f"Échec de « {task.title} » : {e}")
|
|
414
472
|
|
|
415
473
|
await self.ws.send(InterMeshMessage(type=MessageType.TASK_UPDATE, sender=self.qualified_name, content=task.to_dict(), token=self.token).to_json())
|
|
416
474
|
print(f"✅ [{self.qualified_name}] Tâche terminée: {task.status.value.upper()}")
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Validation humaine : les tâches qu'un agent n'a pas le droit de lancer seul.
|
|
3
|
+
|
|
4
|
+
Les garde-fous Asimov répondent à « cette tâche est-elle légitime ? » et
|
|
5
|
+
refusent celles qui ne le sont pas. Ils ne répondent pas à « celle-ci est
|
|
6
|
+
légitime, mais quelqu'un doit-il la regarder avant ? ». Signer un contrat,
|
|
7
|
+
virer de l'argent, supprimer une base : ce sont des actions qu'un agent
|
|
8
|
+
peut vouloir légitimement, et qu'une organisation veut néanmoins tenir
|
|
9
|
+
sous décision humaine.
|
|
10
|
+
|
|
11
|
+
C'est la différence entre un refus et une suspension. Une règle qui
|
|
12
|
+
correspond ici ne rejette rien : elle met la tâche en attente jusqu'à ce
|
|
13
|
+
qu'une personne tranche.
|
|
14
|
+
|
|
15
|
+
Politique vide par défaut. Rien n'est suspendu tant que rien n'est
|
|
16
|
+
déclaré — un système qui met en attente ce qu'on ne lui a pas demandé de
|
|
17
|
+
retenir est un système qu'on désactive.
|
|
18
|
+
|
|
19
|
+
Portée : ce module décide *si* une tâche doit être approuvée. Il ne dit
|
|
20
|
+
rien de *qui* approuve — cette autorisation relève des rôles admin, au
|
|
21
|
+
même titre que les autres commandes d'administration.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import re
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
ANY = "*"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ApprovalRule:
|
|
37
|
+
"""Une règle de mise en attente.
|
|
38
|
+
|
|
39
|
+
Tous les critères renseignés doivent correspondre — ils se cumulent au
|
|
40
|
+
lieu de s'additionner. Une règle sans aucun critère correspondrait à
|
|
41
|
+
toutes les tâches, ce que `__post_init__` refuse : le plus probable est
|
|
42
|
+
qu'on ait oublié de la remplir, pas qu'on veuille tout suspendre.
|
|
43
|
+
|
|
44
|
+
`reason` n'est pas décoratif : c'est le texte que lit la personne à qui
|
|
45
|
+
l'on demande de trancher. Sans lui, elle voit une tâche en attente sans
|
|
46
|
+
savoir ce qui l'y a mise.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
reason: str
|
|
51
|
+
assignees: list[str] = field(default_factory=lambda: [ANY])
|
|
52
|
+
capabilities: list[str] = field(default_factory=lambda: [ANY])
|
|
53
|
+
pattern: str | None = None
|
|
54
|
+
min_cost: float | None = None
|
|
55
|
+
cross_org_only: bool = False
|
|
56
|
+
|
|
57
|
+
def __post_init__(self):
|
|
58
|
+
if not self.name:
|
|
59
|
+
raise ValueError("une règle d'approbation doit avoir un `name`")
|
|
60
|
+
if not self.reason:
|
|
61
|
+
raise ValueError(f"règle '{self.name}' : `reason` est obligatoire — "
|
|
62
|
+
"c'est ce que lit la personne qui approuve")
|
|
63
|
+
specific = (
|
|
64
|
+
self.assignees != [ANY]
|
|
65
|
+
or self.capabilities != [ANY]
|
|
66
|
+
or self.pattern is not None
|
|
67
|
+
or self.min_cost is not None
|
|
68
|
+
or self.cross_org_only
|
|
69
|
+
)
|
|
70
|
+
if not specific:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"règle '{self.name}' : aucun critère, elle suspendrait toutes les "
|
|
73
|
+
"tâches. Précisez au moins assignees, capabilities, pattern, "
|
|
74
|
+
"min_cost ou cross_org_only."
|
|
75
|
+
)
|
|
76
|
+
self._compiled = re.compile(self.pattern, re.IGNORECASE) if self.pattern else None
|
|
77
|
+
|
|
78
|
+
def matches(
|
|
79
|
+
self,
|
|
80
|
+
assignee: str,
|
|
81
|
+
capabilities: list[str],
|
|
82
|
+
payload_text: str,
|
|
83
|
+
estimated_cost: float,
|
|
84
|
+
is_cross_org: bool,
|
|
85
|
+
) -> bool:
|
|
86
|
+
if self.cross_org_only and not is_cross_org:
|
|
87
|
+
return False
|
|
88
|
+
if self.assignees != [ANY] and assignee not in self.assignees:
|
|
89
|
+
return False
|
|
90
|
+
if self.capabilities != [ANY] and not any(c in self.capabilities for c in capabilities):
|
|
91
|
+
return False
|
|
92
|
+
if self.min_cost is not None and estimated_cost < self.min_cost:
|
|
93
|
+
return False
|
|
94
|
+
if self._compiled is not None and not self._compiled.search(payload_text or ""):
|
|
95
|
+
return False
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_dict(cls, raw: dict) -> "ApprovalRule":
|
|
100
|
+
return cls(
|
|
101
|
+
name=raw["name"],
|
|
102
|
+
reason=raw["reason"],
|
|
103
|
+
assignees=raw.get("assignees", [ANY]),
|
|
104
|
+
capabilities=raw.get("capabilities", [ANY]),
|
|
105
|
+
pattern=raw.get("pattern"),
|
|
106
|
+
min_cost=raw.get("min_cost"),
|
|
107
|
+
cross_org_only=bool(raw.get("cross_org_only", False)),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def to_dict(self) -> dict:
|
|
111
|
+
return {
|
|
112
|
+
"name": self.name,
|
|
113
|
+
"reason": self.reason,
|
|
114
|
+
"assignees": self.assignees,
|
|
115
|
+
"capabilities": self.capabilities,
|
|
116
|
+
"pattern": self.pattern,
|
|
117
|
+
"min_cost": self.min_cost,
|
|
118
|
+
"cross_org_only": self.cross_org_only,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class ApprovalPolicy:
|
|
124
|
+
name: str = "empty"
|
|
125
|
+
rules: list[ApprovalRule] = field(default_factory=list)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def is_empty(self) -> bool:
|
|
129
|
+
return not self.rules
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_dict(cls, raw: dict) -> "ApprovalPolicy":
|
|
133
|
+
return cls(
|
|
134
|
+
name=raw.get("name", "unnamed"),
|
|
135
|
+
rules=[ApprovalRule.from_dict(r) for r in raw.get("rules", [])],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def to_dict(self) -> dict:
|
|
139
|
+
return {"name": self.name, "rules": [r.to_dict() for r in self.rules]}
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def load(cls, path: str | Path) -> "ApprovalPolicy":
|
|
143
|
+
return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _payload_text(task: dict) -> str:
|
|
147
|
+
"""Texte sur lequel `pattern` est évalué.
|
|
148
|
+
|
|
149
|
+
Le titre et les données d'entrée sont concaténés : une règle qui
|
|
150
|
+
cherche « contrat » doit correspondre que le mot soit dans l'intitulé
|
|
151
|
+
ou dans la charge utile.
|
|
152
|
+
"""
|
|
153
|
+
parts = [str(task.get("title") or "")]
|
|
154
|
+
data = task.get("input_data")
|
|
155
|
+
if data is not None:
|
|
156
|
+
parts.append(data if isinstance(data, str) else json.dumps(data, ensure_ascii=False))
|
|
157
|
+
return "\n".join(parts)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def requires_approval(
|
|
161
|
+
task: dict,
|
|
162
|
+
policy: ApprovalPolicy | None,
|
|
163
|
+
*,
|
|
164
|
+
capabilities: list[str] | None = None,
|
|
165
|
+
is_cross_org: bool = False,
|
|
166
|
+
) -> ApprovalRule | None:
|
|
167
|
+
"""Retourne la première règle qui met cette tâche en attente, sinon None.
|
|
168
|
+
|
|
169
|
+
La première suffit : une tâche est suspendue ou ne l'est pas, et c'est
|
|
170
|
+
le motif de cette règle qui sera montré à la personne qui tranche.
|
|
171
|
+
"""
|
|
172
|
+
if policy is None or policy.is_empty:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
assignee = str(task.get("assignee") or "")
|
|
176
|
+
try:
|
|
177
|
+
cost = float(task.get("estimated_cost") or 0.0)
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
cost = 0.0
|
|
180
|
+
text = _payload_text(task)
|
|
181
|
+
caps = capabilities or []
|
|
182
|
+
|
|
183
|
+
for rule in policy.rules:
|
|
184
|
+
if rule.matches(assignee, caps, text, cost, is_cross_org):
|
|
185
|
+
return rule
|
|
186
|
+
return None
|