halfabyte-synapse 0.2.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.
- halfabyte_synapse-0.2.0.dist-info/METADATA +254 -0
- halfabyte_synapse-0.2.0.dist-info/RECORD +102 -0
- halfabyte_synapse-0.2.0.dist-info/WHEEL +5 -0
- halfabyte_synapse-0.2.0.dist-info/entry_points.txt +2 -0
- halfabyte_synapse-0.2.0.dist-info/licenses/LICENSE +21 -0
- halfabyte_synapse-0.2.0.dist-info/top_level.txt +1 -0
- synapse/__init__.py +57 -0
- synapse/__main__.py +14 -0
- synapse/alerts.py +227 -0
- synapse/api/__init__.py +0 -0
- synapse/api/main.py +967 -0
- synapse/bot.py +161 -0
- synapse/capsule.py +58 -0
- synapse/chaos_panel.py +138 -0
- synapse/chatops.py +363 -0
- synapse/ci.py +109 -0
- synapse/cli.py +1321 -0
- synapse/core/__init__.py +0 -0
- synapse/core/agents.py +70 -0
- synapse/core/effects.py +345 -0
- synapse/core/hashing.py +85 -0
- synapse/core/lattice.py +60 -0
- synapse/core/retention.py +87 -0
- synapse/core/sir.py +654 -0
- synapse/core/store.py +568 -0
- synapse/core/store_mongo.py +420 -0
- synapse/correctness/__init__.py +6 -0
- synapse/correctness/audit.py +107 -0
- synapse/correctness/chaos.py +180 -0
- synapse/correctness/equivalence.py +114 -0
- synapse/correctness/firewall.py +274 -0
- synapse/correctness/net_rule.py +114 -0
- synapse/correctness/preconditions.py +212 -0
- synapse/correctness/receipt.py +80 -0
- synapse/correctness/runner.py +199 -0
- synapse/correctness/splits.py +39 -0
- synapse/correctness/verify.py +240 -0
- synapse/data/models.yaml +85 -0
- synapse/demo.py +477 -0
- synapse/doctor.py +80 -0
- synapse/export.py +51 -0
- synapse/frontend/__init__.py +0 -0
- synapse/frontend/adapters/__init__.py +0 -0
- synapse/frontend/adapters/adk.py +300 -0
- synapse/frontend/adapters/crewai.py +441 -0
- synapse/frontend/adapters/langgraph.py +290 -0
- synapse/frontend/dsl.py +602 -0
- synapse/frontend/importers/__init__.py +45 -0
- synapse/frontend/importers/_common.py +102 -0
- synapse/frontend/importers/blackbox.py +41 -0
- synapse/frontend/importers/langfuse.py +64 -0
- synapse/frontend/importers/otel.py +105 -0
- synapse/frontend/lift.py +376 -0
- synapse/insights.py +205 -0
- synapse/mcp/__init__.py +22 -0
- synapse/mcp/__main__.py +5 -0
- synapse/mcp/prompts.py +88 -0
- synapse/mcp/protocol.py +35 -0
- synapse/mcp/registry.py +123 -0
- synapse/mcp/resources.py +84 -0
- synapse/mcp/server.py +124 -0
- synapse/mcp/tools.py +531 -0
- synapse/menus.py +146 -0
- synapse/monitor.py +144 -0
- synapse/passes/__init__.py +5 -0
- synapse/passes/base.py +414 -0
- synapse/passes/batch.py +189 -0
- synapse/passes/constfold.py +204 -0
- synapse/passes/context_prune.py +214 -0
- synapse/passes/context_trim.py +96 -0
- synapse/passes/cse.py +195 -0
- synapse/passes/dce.py +125 -0
- synapse/passes/downgrade.py +241 -0
- synapse/passes/early_exit.py +232 -0
- synapse/passes/loop_cutoff.py +140 -0
- synapse/passes/manager.py +439 -0
- synapse/passes/memoize.py +161 -0
- synapse/passes/parallelize.py +325 -0
- synapse/passes/speculate.py +178 -0
- synapse/profile/__init__.py +20 -0
- synapse/profile/collect.py +275 -0
- synapse/profile/costmodel.py +85 -0
- synapse/profile/critical_path.py +98 -0
- synapse/profile/ledger.py +145 -0
- synapse/profile/presentation.py +65 -0
- synapse/profile/pricing.py +25 -0
- synapse/profile/waste.py +64 -0
- synapse/proxy.py +123 -0
- synapse/quarantine.py +24 -0
- synapse/runtime/__init__.py +0 -0
- synapse/runtime/cache.py +157 -0
- synapse/runtime/executor.py +667 -0
- synapse/runtime/meter.py +89 -0
- synapse/runtime/providers.py +434 -0
- synapse/runtime/rate_gate.py +71 -0
- synapse/runtime/recording.py +199 -0
- synapse/runtime/scheduler.py +686 -0
- synapse/runtime/semantic.py +97 -0
- synapse/search.py +183 -0
- synapse/skills/__init__.py +32 -0
- synapse/skills/synapse/SKILL.md +73 -0
- synapse/skills/synapse/references/effects.md +24 -0
synapse/alerts.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Tell the owner when something needs them: WhatsApp, Slack, and email through EmailJS.
|
|
2
|
+
|
|
3
|
+
Ported from Black Box `blackbox/guardian/channels.py`, cut down to these channels. A channel is
|
|
4
|
+
used only when its settings exist in `.env`, and otherwise says what is missing. The outbox always
|
|
5
|
+
keeps a copy. One channel failing never stops the others, and never changes the result of the
|
|
6
|
+
command that raised the alert.
|
|
7
|
+
|
|
8
|
+
Alerts go out only when asked (`--notify`) or from `synapse alerts test`. The dashboard runs the
|
|
9
|
+
firewall whenever a page scrolls into view, so it must never page anyone.
|
|
10
|
+
|
|
11
|
+
Slack SLACK_WEBHOOK_URL
|
|
12
|
+
EmailJS EMAILJS_SERVICE_ID, EMAILJS_TEMPLATE_ID, EMAILJS_PUBLIC_KEY, EMAILJS_PRIVATE_KEY, ALERT_EMAIL_TO
|
|
13
|
+
WhatsApp TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_WHATSAPP_FROM, ALERT_WHATSAPP_TO
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import urllib.request
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from html import escape
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
USER_AGENT = "Synapse-Alerts/0.1" # Cloudflare refuses Python's default User-Agent
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ChannelError(RuntimeError):
|
|
33
|
+
"""A delivery the service refused, with the service's own explanation."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Alert:
|
|
38
|
+
title: str
|
|
39
|
+
lines: list[str] = field(default_factory=list)
|
|
40
|
+
severity: str = "warning" # info | warning | critical
|
|
41
|
+
source: str = "synapse"
|
|
42
|
+
actions: list[tuple[str, str]] = field(default_factory=list) # (button label, chat command)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def subject(self) -> str:
|
|
46
|
+
return f"[Synapse {self.severity}] {self.title}"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def text(self) -> str:
|
|
50
|
+
return "\n".join([self.subject, "", *self.lines])
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def html(self) -> str:
|
|
54
|
+
items = "".join(f"<li>{escape(line)}</li>" for line in self.lines)
|
|
55
|
+
return f"<h2>{escape(self.title)}</h2><p>From {escape(self.source)}</p><ul>{items}</ul>"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _post(url: str, data: bytes, headers: dict) -> tuple[int, bytes]:
|
|
59
|
+
request = urllib.request.Request(url, data=data, headers={"User-Agent": USER_AGENT, **headers})
|
|
60
|
+
try:
|
|
61
|
+
with urllib.request.urlopen(request, timeout=20) as reply:
|
|
62
|
+
return reply.status, reply.read()
|
|
63
|
+
except urllib.error.HTTPError as exc:
|
|
64
|
+
raise ChannelError(f"HTTP {exc.code}: {exc.read().decode(errors='replace').strip()[:300]}") from None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _post_json(url: str, payload: dict) -> tuple[int, bytes]:
|
|
68
|
+
return _post(url, json.dumps(payload).encode(), {"Content-Type": "application/json"})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _twilio(resource: str, fields: dict) -> str:
|
|
72
|
+
sid = os.environ["TWILIO_ACCOUNT_SID"]
|
|
73
|
+
auth = base64.b64encode(f"{sid}:{os.environ['TWILIO_AUTH_TOKEN']}".encode()).decode()
|
|
74
|
+
_, reply = _post(f"https://api.twilio.com/2010-04-01/Accounts/{sid}/{resource}.json",
|
|
75
|
+
urllib.parse.urlencode(fields).encode(), {"Authorization": f"Basic {auth}"})
|
|
76
|
+
return str(json.loads(reply).get("sid"))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _twilio_message(sid: str) -> dict:
|
|
80
|
+
account = os.environ["TWILIO_ACCOUNT_SID"]
|
|
81
|
+
auth = base64.b64encode(f"{account}:{os.environ['TWILIO_AUTH_TOKEN']}".encode()).decode()
|
|
82
|
+
request = urllib.request.Request(f"https://api.twilio.com/2010-04-01/Accounts/{account}/Messages/{sid}.json",
|
|
83
|
+
headers={"User-Agent": USER_AGENT, "Authorization": f"Basic {auth}"})
|
|
84
|
+
with urllib.request.urlopen(request, timeout=20) as reply:
|
|
85
|
+
return json.load(reply)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Channel:
|
|
89
|
+
name = "channel"
|
|
90
|
+
required: tuple[str, ...] = ()
|
|
91
|
+
|
|
92
|
+
def missing(self) -> list[str]:
|
|
93
|
+
return [n for n in self.required if not os.environ.get(n)]
|
|
94
|
+
|
|
95
|
+
def wants(self, alert: Alert) -> bool: # noqa: ARG002 - subclasses decide by the alert
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
def send(self, alert: Alert) -> str:
|
|
99
|
+
raise NotImplementedError
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Slack(Channel):
|
|
103
|
+
name = "slack"
|
|
104
|
+
required = ("SLACK_WEBHOOK_URL",)
|
|
105
|
+
|
|
106
|
+
def send(self, alert: Alert) -> str:
|
|
107
|
+
payload: dict = {"text": alert.text}
|
|
108
|
+
if alert.actions:
|
|
109
|
+
payload["blocks"] = [
|
|
110
|
+
{"type": "section", "text": {"type": "mrkdwn", "text": alert.text[:2900]}},
|
|
111
|
+
{"type": "actions", "elements": [
|
|
112
|
+
{"type": "button", "text": {"type": "plain_text", "text": label}, "value": command,
|
|
113
|
+
"action_id": f"synapse_{i}"}
|
|
114
|
+
for i, (label, command) in enumerate(alert.actions)]},
|
|
115
|
+
]
|
|
116
|
+
status, _ = _post_json(os.environ["SLACK_WEBHOOK_URL"], payload)
|
|
117
|
+
return f"posted ({status})"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class EmailJS(Channel):
|
|
121
|
+
"""The template must use {{subject}}, {{to_email}} and {{{html_message}}}."""
|
|
122
|
+
|
|
123
|
+
name = "email-emailjs"
|
|
124
|
+
required = ("EMAILJS_SERVICE_ID", "EMAILJS_TEMPLATE_ID", "EMAILJS_PUBLIC_KEY", "EMAILJS_PRIVATE_KEY",
|
|
125
|
+
"ALERT_EMAIL_TO")
|
|
126
|
+
|
|
127
|
+
def send(self, alert: Alert) -> str:
|
|
128
|
+
status, _ = _post_json("https://api.emailjs.com/api/v1.0/email/send", {
|
|
129
|
+
"service_id": os.environ["EMAILJS_SERVICE_ID"],
|
|
130
|
+
"template_id": os.environ["EMAILJS_TEMPLATE_ID"],
|
|
131
|
+
"user_id": os.environ["EMAILJS_PUBLIC_KEY"],
|
|
132
|
+
"accessToken": os.environ["EMAILJS_PRIVATE_KEY"],
|
|
133
|
+
"template_params": {"to_email": os.environ["ALERT_EMAIL_TO"], "subject": alert.subject,
|
|
134
|
+
"html_message": alert.html, "text_message": alert.text,
|
|
135
|
+
"severity": alert.severity},
|
|
136
|
+
})
|
|
137
|
+
return f"sent ({status})"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class WhatsApp(Channel):
|
|
141
|
+
"""Twilio accepting a message is not delivery, so this waits for the real outcome."""
|
|
142
|
+
|
|
143
|
+
name = "whatsapp-twilio"
|
|
144
|
+
required = ("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_WHATSAPP_FROM", "ALERT_WHATSAPP_TO")
|
|
145
|
+
WAIT_S = 15.0
|
|
146
|
+
|
|
147
|
+
def send(self, alert: Alert) -> str:
|
|
148
|
+
sid = _twilio("Messages", {"From": os.environ["TWILIO_WHATSAPP_FROM"], "To": os.environ["ALERT_WHATSAPP_TO"],
|
|
149
|
+
"Body": alert.text[:1500]})
|
|
150
|
+
deadline = time.monotonic() + self.WAIT_S
|
|
151
|
+
state: dict = {}
|
|
152
|
+
while time.monotonic() < deadline:
|
|
153
|
+
state = _twilio_message(sid)
|
|
154
|
+
if state.get("status") in ("delivered", "read"):
|
|
155
|
+
return str(state["status"])
|
|
156
|
+
if state.get("status") in ("failed", "undelivered"):
|
|
157
|
+
code = state.get("error_code")
|
|
158
|
+
raise ChannelError(f"{state['status']}, Twilio error {code}. {WHY.get(code, '')}".strip())
|
|
159
|
+
time.sleep(1.0)
|
|
160
|
+
return f"not confirmed after {self.WAIT_S:.0f} s, status {state.get('status')}"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
#: What the owner has to do about Twilio's WhatsApp errors.
|
|
164
|
+
WHY = {
|
|
165
|
+
63015: "This number has not joined the sandbox: send the join code to the sandbox number on WhatsApp.",
|
|
166
|
+
63016: "Outside the 24-hour window: send any message to the sandbox number on WhatsApp, then try again.",
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
CHANNELS: tuple[Channel, ...] = (WhatsApp(), Slack(), EmailJS())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _outbox(alert: Alert) -> Path:
|
|
174
|
+
root = Path(os.environ.get("SYNAPSE_DATA", "data")) / "outbox"
|
|
175
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
path = root / f"{time.strftime('%Y%m%d-%H%M%S')}-{alert.severity}.txt"
|
|
177
|
+
path.write_text(alert.text, encoding="utf-8")
|
|
178
|
+
return path
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def status() -> list[tuple[str, str]]:
|
|
182
|
+
"""Every channel and whether it is ready. Names settings, never their values."""
|
|
183
|
+
return [(c.name, f"needs {', '.join(c.missing())}" if c.missing() else "ready") for c in CHANNELS]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def send(alert: Alert, channels: tuple[Channel, ...] = CHANNELS) -> list[tuple[str, str, str]]:
|
|
187
|
+
"""Deliver everywhere it can. Returns (channel, sent | skipped | failed, detail)."""
|
|
188
|
+
results = [("outbox", "sent", str(_outbox(alert)))]
|
|
189
|
+
for channel in channels:
|
|
190
|
+
if channel.missing():
|
|
191
|
+
results.append((channel.name, "skipped", f"needs {', '.join(channel.missing())}"))
|
|
192
|
+
elif not channel.wants(alert):
|
|
193
|
+
results.append((channel.name, "skipped", "not for this alert"))
|
|
194
|
+
else:
|
|
195
|
+
try:
|
|
196
|
+
results.append((channel.name, "sent", channel.send(alert)))
|
|
197
|
+
except (ChannelError, OSError) as exc:
|
|
198
|
+
results.append((channel.name, "failed", str(exc)))
|
|
199
|
+
return results
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def from_verdict(workflow: str, verdict: dict, graph_id: str | None = None) -> Alert:
|
|
203
|
+
decision = verdict["decision"]
|
|
204
|
+
return Alert(
|
|
205
|
+
title=f"{workflow}: firewall said {decision}",
|
|
206
|
+
severity="warning" if decision == "BLOCK" else "info",
|
|
207
|
+
source=graph_id or workflow,
|
|
208
|
+
lines=[
|
|
209
|
+
str(verdict.get("reason", "")),
|
|
210
|
+
f"{verdict.get('broken', 0)} broke, {verdict.get('fixed', 0)} fixed, "
|
|
211
|
+
f"{verdict.get('unchanged', 0)} unchanged, on {verdict.get('split', 'an unnamed split')}",
|
|
212
|
+
f"Reply: why {workflow}, receipt {workflow}, or hold {workflow}",
|
|
213
|
+
],
|
|
214
|
+
actions=[("Why", f"why {workflow}"), ("Receipt", f"receipt {workflow}"), ("Savings", f"savings {workflow}"),
|
|
215
|
+
("Hold", f"hold {workflow}"), *([("Ship", f"ship {workflow}")] if decision == "APPROVE" else [])],
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def from_regression(workflow: str, problems: list[str]) -> Alert:
|
|
220
|
+
return Alert(title=f"{workflow} regressed", severity="critical", source="synapse ci", lines=list(problems))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def from_audit(model: str, result: dict) -> Alert:
|
|
224
|
+
return Alert(title=f"{model}: answers are not stable", severity="warning", source="synapse audit",
|
|
225
|
+
lines=[f"Varied one at a time: {result['varied_one_at_a_time']} of {result['prompts']}",
|
|
226
|
+
f"Varied under concurrency: {result['varied_under_concurrency']} of {result['prompts']}",
|
|
227
|
+
result["advice"]])
|
synapse/api/__init__.py
ADDED
|
File without changes
|