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.
Files changed (102) hide show
  1. halfabyte_synapse-0.2.0.dist-info/METADATA +254 -0
  2. halfabyte_synapse-0.2.0.dist-info/RECORD +102 -0
  3. halfabyte_synapse-0.2.0.dist-info/WHEEL +5 -0
  4. halfabyte_synapse-0.2.0.dist-info/entry_points.txt +2 -0
  5. halfabyte_synapse-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. halfabyte_synapse-0.2.0.dist-info/top_level.txt +1 -0
  7. synapse/__init__.py +57 -0
  8. synapse/__main__.py +14 -0
  9. synapse/alerts.py +227 -0
  10. synapse/api/__init__.py +0 -0
  11. synapse/api/main.py +967 -0
  12. synapse/bot.py +161 -0
  13. synapse/capsule.py +58 -0
  14. synapse/chaos_panel.py +138 -0
  15. synapse/chatops.py +363 -0
  16. synapse/ci.py +109 -0
  17. synapse/cli.py +1321 -0
  18. synapse/core/__init__.py +0 -0
  19. synapse/core/agents.py +70 -0
  20. synapse/core/effects.py +345 -0
  21. synapse/core/hashing.py +85 -0
  22. synapse/core/lattice.py +60 -0
  23. synapse/core/retention.py +87 -0
  24. synapse/core/sir.py +654 -0
  25. synapse/core/store.py +568 -0
  26. synapse/core/store_mongo.py +420 -0
  27. synapse/correctness/__init__.py +6 -0
  28. synapse/correctness/audit.py +107 -0
  29. synapse/correctness/chaos.py +180 -0
  30. synapse/correctness/equivalence.py +114 -0
  31. synapse/correctness/firewall.py +274 -0
  32. synapse/correctness/net_rule.py +114 -0
  33. synapse/correctness/preconditions.py +212 -0
  34. synapse/correctness/receipt.py +80 -0
  35. synapse/correctness/runner.py +199 -0
  36. synapse/correctness/splits.py +39 -0
  37. synapse/correctness/verify.py +240 -0
  38. synapse/data/models.yaml +85 -0
  39. synapse/demo.py +477 -0
  40. synapse/doctor.py +80 -0
  41. synapse/export.py +51 -0
  42. synapse/frontend/__init__.py +0 -0
  43. synapse/frontend/adapters/__init__.py +0 -0
  44. synapse/frontend/adapters/adk.py +300 -0
  45. synapse/frontend/adapters/crewai.py +441 -0
  46. synapse/frontend/adapters/langgraph.py +290 -0
  47. synapse/frontend/dsl.py +602 -0
  48. synapse/frontend/importers/__init__.py +45 -0
  49. synapse/frontend/importers/_common.py +102 -0
  50. synapse/frontend/importers/blackbox.py +41 -0
  51. synapse/frontend/importers/langfuse.py +64 -0
  52. synapse/frontend/importers/otel.py +105 -0
  53. synapse/frontend/lift.py +376 -0
  54. synapse/insights.py +205 -0
  55. synapse/mcp/__init__.py +22 -0
  56. synapse/mcp/__main__.py +5 -0
  57. synapse/mcp/prompts.py +88 -0
  58. synapse/mcp/protocol.py +35 -0
  59. synapse/mcp/registry.py +123 -0
  60. synapse/mcp/resources.py +84 -0
  61. synapse/mcp/server.py +124 -0
  62. synapse/mcp/tools.py +531 -0
  63. synapse/menus.py +146 -0
  64. synapse/monitor.py +144 -0
  65. synapse/passes/__init__.py +5 -0
  66. synapse/passes/base.py +414 -0
  67. synapse/passes/batch.py +189 -0
  68. synapse/passes/constfold.py +204 -0
  69. synapse/passes/context_prune.py +214 -0
  70. synapse/passes/context_trim.py +96 -0
  71. synapse/passes/cse.py +195 -0
  72. synapse/passes/dce.py +125 -0
  73. synapse/passes/downgrade.py +241 -0
  74. synapse/passes/early_exit.py +232 -0
  75. synapse/passes/loop_cutoff.py +140 -0
  76. synapse/passes/manager.py +439 -0
  77. synapse/passes/memoize.py +161 -0
  78. synapse/passes/parallelize.py +325 -0
  79. synapse/passes/speculate.py +178 -0
  80. synapse/profile/__init__.py +20 -0
  81. synapse/profile/collect.py +275 -0
  82. synapse/profile/costmodel.py +85 -0
  83. synapse/profile/critical_path.py +98 -0
  84. synapse/profile/ledger.py +145 -0
  85. synapse/profile/presentation.py +65 -0
  86. synapse/profile/pricing.py +25 -0
  87. synapse/profile/waste.py +64 -0
  88. synapse/proxy.py +123 -0
  89. synapse/quarantine.py +24 -0
  90. synapse/runtime/__init__.py +0 -0
  91. synapse/runtime/cache.py +157 -0
  92. synapse/runtime/executor.py +667 -0
  93. synapse/runtime/meter.py +89 -0
  94. synapse/runtime/providers.py +434 -0
  95. synapse/runtime/rate_gate.py +71 -0
  96. synapse/runtime/recording.py +199 -0
  97. synapse/runtime/scheduler.py +686 -0
  98. synapse/runtime/semantic.py +97 -0
  99. synapse/search.py +183 -0
  100. synapse/skills/__init__.py +32 -0
  101. synapse/skills/synapse/SKILL.md +73 -0
  102. 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"]])
File without changes