dos-kernel 0.22.0__py3-none-win_amd64.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.
- dos/__init__.py +261 -0
- dos/_bin/dos-hook.exe +0 -0
- dos/_filelock.py +255 -0
- dos/_job_policy.py +97 -0
- dos/_tree.py +145 -0
- dos/admission.py +433 -0
- dos/answer_shape.py +299 -0
- dos/arbiter.py +859 -0
- dos/archive_lock.py +266 -0
- dos/arg_provenance.py +814 -0
- dos/attest.py +472 -0
- dos/breaker.py +311 -0
- dos/churn.py +226 -0
- dos/claim_extract.py +229 -0
- dos/claim_ttl.py +150 -0
- dos/cli.py +8721 -0
- dos/commit_audit.py +666 -0
- dos/completion.py +466 -0
- dos/concurrency_class.py +154 -0
- dos/config.py +1380 -0
- dos/config_lint.py +464 -0
- dos/cooldown.py +390 -0
- dos/coverage.py +387 -0
- dos/dangling_intent.py +287 -0
- dos/data_class.py +397 -0
- dos/decisions.py +1274 -0
- dos/decisions_tui.py +251 -0
- dos/dispatch_top.py +740 -0
- dos/dispatch_top_tui.py +116 -0
- dos/drivers/__init__.py +40 -0
- dos/drivers/ci_status.py +630 -0
- dos/drivers/citation_resolve.py +703 -0
- dos/drivers/decision_stop.py +98 -0
- dos/drivers/export_file.py +173 -0
- dos/drivers/export_otlp.py +275 -0
- dos/drivers/export_statsd.py +242 -0
- dos/drivers/hook_dialects.py +391 -0
- dos/drivers/job.py +47 -0
- dos/drivers/llm_judge.py +360 -0
- dos/drivers/memory_recall.py +1231 -0
- dos/drivers/notify_slack.py +373 -0
- dos/drivers/notify_webhook.py +251 -0
- dos/drivers/operator_judge.py +114 -0
- dos/drivers/os_acceptance.py +228 -0
- dos/drivers/paste_log.py +132 -0
- dos/drivers/plan_scope.py +133 -0
- dos/drivers/self_improve.py +375 -0
- dos/drivers/similarity_judge.py +249 -0
- dos/drivers/state_diff.py +274 -0
- dos/drivers/supervisor.py +347 -0
- dos/drivers/watchdog.py +363 -0
- dos/drivers/workshop.py +160 -0
- dos/durable_schema.py +344 -0
- dos/effect_witness.py +393 -0
- dos/efficiency.py +318 -0
- dos/enforce.py +414 -0
- dos/enumerate.py +776 -0
- dos/env_print.py +378 -0
- dos/event_severity.py +258 -0
- dos/evidence.py +692 -0
- dos/exec_capability.py +256 -0
- dos/export_cursor.py +143 -0
- dos/exporter.py +320 -0
- dos/firing_label.py +353 -0
- dos/fleet_roll.py +226 -0
- dos/gate_classify.py +827 -0
- dos/gh4_coverage.py +179 -0
- dos/git_delta.py +122 -0
- dos/guard.py +215 -0
- dos/health.py +552 -0
- dos/help_summary.py +519 -0
- dos/home.py +934 -0
- dos/hook_binary.py +194 -0
- dos/hook_dialect.py +271 -0
- dos/hook_exit.py +191 -0
- dos/hook_install.py +437 -0
- dos/id_alloc.py +304 -0
- dos/improve.py +499 -0
- dos/intent_ledger.py +635 -0
- dos/interpret.py +176 -0
- dos/intervention.py +769 -0
- dos/intervention_eval.py +371 -0
- dos/journal_delta.py +308 -0
- dos/judge_eval.py +328 -0
- dos/judges.py +366 -0
- dos/lane_infer.py +127 -0
- dos/lane_journal.py +1001 -0
- dos/lane_lease.py +952 -0
- dos/lane_overlap.py +228 -0
- dos/lease_health.py +282 -0
- dos/lifecycle.py +211 -0
- dos/liveness.py +352 -0
- dos/lock_modes.py +185 -0
- dos/log_source.py +395 -0
- dos/loop_decide.py +1746 -0
- dos/marker_gate.py +254 -0
- dos/marker_sensor.py +396 -0
- dos/noop_streak.py +280 -0
- dos/notify.py +479 -0
- dos/observe.py +175 -0
- dos/oracle.py +1661 -0
- dos/overlap_eval.py +214 -0
- dos/overlap_policy.py +342 -0
- dos/packet_sidecar.py +267 -0
- dos/phase_shipped.py +1985 -0
- dos/pick_priority.py +225 -0
- dos/pickable.py +369 -0
- dos/picker_oracle.py +1037 -0
- dos/plan_board.py +513 -0
- dos/plan_board_tui.py +113 -0
- dos/plan_source.py +455 -0
- dos/posttool_sensor.py +528 -0
- dos/precursor_gate.py +499 -0
- dos/precursor_gate_eval.py +239 -0
- dos/preflight.py +825 -0
- dos/pretool_sensor.py +490 -0
- dos/proc_delta.py +181 -0
- dos/productivity.py +296 -0
- dos/provider_limit.py +242 -0
- dos/py.typed +4 -0
- dos/reason_morphology.py +299 -0
- dos/reasons.py +449 -0
- dos/reconcile.py +173 -0
- dos/recurring_wedge.py +206 -0
- dos/render.py +393 -0
- dos/result_state.py +468 -0
- dos/resume.py +578 -0
- dos/resume_evidence.py +293 -0
- dos/retention.py +344 -0
- dos/reward.py +372 -0
- dos/rewind.py +587 -0
- dos/rewind_evidence.py +168 -0
- dos/rewind_tokens.py +252 -0
- dos/run_id.py +342 -0
- dos/scope.py +520 -0
- dos/scope_source.py +382 -0
- dos/scout.py +982 -0
- dos/self_modify.py +209 -0
- dos/sibling_scan.py +569 -0
- dos/skills/EXAMPLES.md +584 -0
- dos/skills/dos-class-cycle/SKILL.md +107 -0
- dos/skills/dos-dispatch/SKILL.md +177 -0
- dos/skills/dos-dispatch-loop/SKILL.md +254 -0
- dos/skills/dos-goal-gate/SKILL.md +269 -0
- dos/skills/dos-next-up/SKILL.md +231 -0
- dos/skills/dos-promote/SKILL.md +114 -0
- dos/skills/dos-replan/SKILL.md +159 -0
- dos/skills/dos-replan-loop/SKILL.md +114 -0
- dos/skills/dos-self-improve/SKILL.md +213 -0
- dos/skills/dos-supervise-loop/SKILL.md +180 -0
- dos/skills/dos-unstick/SKILL.md +108 -0
- dos/skills/dos-witness-claim/SKILL.md +251 -0
- dos/stamp.py +1002 -0
- dos/state_health.py +387 -0
- dos/status.py +114 -0
- dos/stop_policy.py +334 -0
- dos/supervise.py +1014 -0
- dos/testwitness.py +392 -0
- dos/timeline.py +1027 -0
- dos/tokens.py +485 -0
- dos/tool_stream.py +393 -0
- dos/tool_stream_eval.py +226 -0
- dos/trace.py +524 -0
- dos/verdict.py +140 -0
- dos/verdict_cli.py +189 -0
- dos/verdict_journal.py +497 -0
- dos/verdict_rollup.py +217 -0
- dos/verdicts.py +181 -0
- dos/wedge_reason.py +282 -0
- dos_kernel-0.22.0.dist-info/METADATA +859 -0
- dos_kernel-0.22.0.dist-info/RECORD +178 -0
- dos_kernel-0.22.0.dist-info/WHEEL +5 -0
- dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
- dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
- dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
- dos_mcp/__init__.py +52 -0
- dos_mcp/py.typed +2 -0
- dos_mcp/server.py +779 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""dos.drivers.notify_slack — the Slack occupant of the `dos.notify` seam (docs/225).
|
|
2
|
+
|
|
3
|
+
The first transport behind the notification spine. Where the kernel seam
|
|
4
|
+
(`dos.notify`) is transport-agnostic and names no vendor, THIS is where "Slack" is
|
|
5
|
+
allowed to be code (a `SlackNotifier` is inherently Slack-specific — the
|
|
6
|
+
`GeminiDialect` / `LlmJudge` rule). It registers through the `dos.notifiers`
|
|
7
|
+
entry-point group, so `resolve_notifier("slack")` finds it by name and no kernel
|
|
8
|
+
module imports it.
|
|
9
|
+
|
|
10
|
+
What it delivers
|
|
11
|
+
================
|
|
12
|
+
|
|
13
|
+
A `Notification` → Slack, in one of two shapes the operator picked (docs/225):
|
|
14
|
+
|
|
15
|
+
* **decisions digest** (`source="decisions"`): a fresh Block-Kit POST per run —
|
|
16
|
+
`chat.postMessage`. Cron/event-driven; each push is its own message.
|
|
17
|
+
* **live fleet status** (`source="top"`): ONE message EDITED in place as the
|
|
18
|
+
fleet moves — a `slack_helpers.LiveMessage` keyed on `note.key` so the status
|
|
19
|
+
stream updates a single message instead of spamming the channel (the
|
|
20
|
+
`LiveMessage` reason-to-exist). The kernel `note.key` is the edit handle.
|
|
21
|
+
|
|
22
|
+
`edit_in_place` overrides the source-based default (None = auto: a `top`
|
|
23
|
+
notification edits, a `decisions` digest posts).
|
|
24
|
+
|
|
25
|
+
Disciplines (inherited from the seam)
|
|
26
|
+
=====================================
|
|
27
|
+
|
|
28
|
+
* **Fail-soft.** `send` returns a `NotifyResult`, never raises — a missing token,
|
|
29
|
+
an absent `slack_helpers` extra, or a transport error all degrade to
|
|
30
|
+
`delivered=False` with a one-line reason. (The seam's `send_safely` is the
|
|
31
|
+
outer net; this is the inner one, so even a direct `SlackNotifier().send(...)`
|
|
32
|
+
is crash-free.)
|
|
33
|
+
* **Advisory only.** It renders a projection → push. It mutates no DOS state, takes
|
|
34
|
+
no lease, stops no run. A LIVENESS-halt field CARRIES the paste-to-stop command
|
|
35
|
+
(built by the seam); it never enacts it.
|
|
36
|
+
* **Lazy import + optional dep.** `slack_helpers` (which pulls `requests`) is in
|
|
37
|
+
the `[notify-slack]` extra, NOT the core. It is imported INSIDE `send`/the
|
|
38
|
+
client builder; absent → a `NotifyResult` with an install hint, never an
|
|
39
|
+
`ImportError` at module load (the `dos_mcp` posture). So importing this driver —
|
|
40
|
+
which entry-point discovery does — never fails for lack of the extra.
|
|
41
|
+
|
|
42
|
+
Credentials / routing (the `slack_helpers` convention)
|
|
43
|
+
=======================================================
|
|
44
|
+
|
|
45
|
+
* **token**: explicit arg › `$SLACK_BOT_TOKEN` › the workspace `.env`
|
|
46
|
+
(`<root>/.env`, the file `slack_helpers` itself reads).
|
|
47
|
+
* **channel**: a logical name resolved through `slack_helpers/slack_config.json`
|
|
48
|
+
(`{"channels": {...}}`), or a raw channel id (`C0…`) passed straight through.
|
|
49
|
+
|
|
50
|
+
Block Kit is built HERE, locally — a small DOS-shaped builder (the spine's analogue
|
|
51
|
+
of `slack_helpers.build_upload_blocks`), so the kernel seam stays Block-Kit-free.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import os
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
|
|
59
|
+
from dos.notify import Notification, NotifyResult
|
|
60
|
+
|
|
61
|
+
# The severity → header glyph map. Matches `dispatch_top`'s chip glyphs so the two
|
|
62
|
+
# surfaces read the same in a channel (🟢/🟡/🔴), with a neutral bell for a plain
|
|
63
|
+
# INFO digest that is not a fleet-status frame.
|
|
64
|
+
_SEV_EMOJI = {
|
|
65
|
+
"INFO": ":large_blue_circle:",
|
|
66
|
+
"WARN": ":large_yellow_circle:",
|
|
67
|
+
"URGENT": ":red_circle:",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Slack section `fields` cap at 10; a header/section text caps well under 3000.
|
|
71
|
+
_MAX_FIELDS = 10
|
|
72
|
+
_MAX_SUMMARY = 2800
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_blocks(note: Notification) -> list[dict]:
|
|
76
|
+
"""A `Notification` → Slack Block Kit blocks (pure; no I/O).
|
|
77
|
+
|
|
78
|
+
A `header` (severity emoji + title), a `section` of the TOP `fields` as
|
|
79
|
+
mrkdwn pairs (capped at Slack's 10), the plain-text `summary` in a fenced code
|
|
80
|
+
block (so a notifier with no rich surface still says everything), and a
|
|
81
|
+
`context` line naming the source projection. Pure — the spine's local analogue
|
|
82
|
+
of `slack_helpers.build_upload_blocks`, kept out of the kernel seam.
|
|
83
|
+
"""
|
|
84
|
+
emoji = _SEV_EMOJI.get(note.severity.value, "")
|
|
85
|
+
head = f"{emoji} {note.title}".strip()
|
|
86
|
+
blocks: list[dict] = [
|
|
87
|
+
{"type": "header",
|
|
88
|
+
"text": {"type": "plain_text", "text": head[:150], "emoji": True}},
|
|
89
|
+
]
|
|
90
|
+
if note.fields:
|
|
91
|
+
blocks.append({
|
|
92
|
+
"type": "section",
|
|
93
|
+
"fields": [
|
|
94
|
+
{"type": "mrkdwn", "text": f"*{label}*\n{value}"}
|
|
95
|
+
for label, value in note.fields[:_MAX_FIELDS]
|
|
96
|
+
],
|
|
97
|
+
})
|
|
98
|
+
if note.summary:
|
|
99
|
+
body = note.summary[:_MAX_SUMMARY]
|
|
100
|
+
blocks.append({
|
|
101
|
+
"type": "section",
|
|
102
|
+
"text": {"type": "mrkdwn", "text": f"```\n{body}\n```"},
|
|
103
|
+
})
|
|
104
|
+
blocks.append({
|
|
105
|
+
"type": "context",
|
|
106
|
+
"elements": [{"type": "mrkdwn",
|
|
107
|
+
"text": f"dos notify · source=`{note.source or '?'}` · "
|
|
108
|
+
f"severity=`{note.severity.value}`"}],
|
|
109
|
+
})
|
|
110
|
+
return blocks
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Credential / routing resolution — the boundary I/O, kept off the pure builder.
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _read_env_file(root: Path) -> dict[str, str]:
|
|
119
|
+
"""Best-effort parse of `<root>/.env` → {KEY: value}. Never raises."""
|
|
120
|
+
out: dict[str, str] = {}
|
|
121
|
+
try:
|
|
122
|
+
text = (root / ".env").read_text(encoding="utf-8")
|
|
123
|
+
except OSError:
|
|
124
|
+
return out
|
|
125
|
+
for line in text.splitlines():
|
|
126
|
+
line = line.strip()
|
|
127
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
128
|
+
continue
|
|
129
|
+
k, _, v = line.partition("=")
|
|
130
|
+
out[k.strip()] = v.strip().strip('"').strip("'")
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def resolve_token(explicit: str | None, *, root: Path | None) -> str:
|
|
135
|
+
"""Bot token: explicit arg › `$SLACK_BOT_TOKEN` › `<root>/.env`. "" if none."""
|
|
136
|
+
if explicit:
|
|
137
|
+
return explicit
|
|
138
|
+
env = os.environ.get("SLACK_BOT_TOKEN")
|
|
139
|
+
if env:
|
|
140
|
+
return env
|
|
141
|
+
if root is not None:
|
|
142
|
+
return _read_env_file(root).get("SLACK_BOT_TOKEN", "")
|
|
143
|
+
return ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _slack_config_channels() -> dict[str, str]:
|
|
147
|
+
"""The `slack_helpers/slack_config.json` name→id map, or {} if unavailable."""
|
|
148
|
+
try:
|
|
149
|
+
import importlib.util
|
|
150
|
+
import json
|
|
151
|
+
|
|
152
|
+
spec = importlib.util.find_spec("slack_helpers")
|
|
153
|
+
if spec is None or not spec.origin:
|
|
154
|
+
return {}
|
|
155
|
+
cfg = Path(spec.origin).parent / "slack_config.json"
|
|
156
|
+
data = json.loads(cfg.read_text(encoding="utf-8"))
|
|
157
|
+
chans = data.get("channels", {})
|
|
158
|
+
return {str(k): str(v) for k, v in chans.items()} if isinstance(chans, dict) else {}
|
|
159
|
+
except Exception:
|
|
160
|
+
return {}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def resolve_channel(channel: str) -> str:
|
|
164
|
+
"""A channel NAME → its id via `slack_config.json`; a raw id passes through.
|
|
165
|
+
|
|
166
|
+
A value that is already a Slack channel id (starts with `C`/`G`/`D` and is
|
|
167
|
+
upper-case-ish) is returned as-is; otherwise it is looked up as a logical name
|
|
168
|
+
in the config map. An unknown name returns "" (the caller skips, fail-soft).
|
|
169
|
+
"""
|
|
170
|
+
channel = (channel or "").strip()
|
|
171
|
+
if not channel:
|
|
172
|
+
return ""
|
|
173
|
+
# Raw id heuristic: Slack ids are like C0AJ37QHMFB / G… / D… — letters+digits,
|
|
174
|
+
# no lowercase. A logical name ("ops", "zip-files") has lowercase or a dash.
|
|
175
|
+
if channel[0] in "CGD" and channel.isupper() and channel.isalnum():
|
|
176
|
+
return channel
|
|
177
|
+
return _slack_config_channels().get(channel, "")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# A dependency-free edit-in-place fallback — used when slack_helpers is absent but
|
|
182
|
+
# a client exists (an injected fake, or a hand-built one). Mirrors the slice of
|
|
183
|
+
# `slack_helpers.LiveMessage` the driver uses (`.update(text, force=)` + `.ts`),
|
|
184
|
+
# minus the throttle (correctness is "one edited message"; the throttle is a
|
|
185
|
+
# rate-limit nicety the real LiveMessage adds). Like LiveMessage, transport errors
|
|
186
|
+
# are swallowed so a streaming UI never crashes its producer.
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class _InlineLive:
|
|
191
|
+
"""Minimal post-then-update: first `update` posts, later ones edit in place."""
|
|
192
|
+
|
|
193
|
+
def __init__(self, client, channel: str):
|
|
194
|
+
self._client = client
|
|
195
|
+
self._channel = channel
|
|
196
|
+
self._ts: str | None = None
|
|
197
|
+
self._sent: str | None = None
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def ts(self) -> str | None:
|
|
201
|
+
return self._ts
|
|
202
|
+
|
|
203
|
+
def update(self, text: str, *, force: bool = False) -> None: # noqa: ARG002 - parity
|
|
204
|
+
if self._ts is None:
|
|
205
|
+
try:
|
|
206
|
+
resp = self._client.post_message(self._channel, text)
|
|
207
|
+
except Exception: # noqa: BLE001 - streaming must not crash the producer
|
|
208
|
+
return
|
|
209
|
+
self._ts = str((resp or {}).get("ts") or "")
|
|
210
|
+
self._sent = text
|
|
211
|
+
return
|
|
212
|
+
if text == self._sent: # nothing changed — skip the round-trip
|
|
213
|
+
return
|
|
214
|
+
try:
|
|
215
|
+
self._client.update_message(self._channel, self._ts, text)
|
|
216
|
+
except Exception: # noqa: BLE001
|
|
217
|
+
return
|
|
218
|
+
self._sent = text
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# The notifier.
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class SlackNotifier:
|
|
227
|
+
"""Deliver a `Notification` to Slack — post (digest) or edit-in-place (status).
|
|
228
|
+
|
|
229
|
+
Parameters
|
|
230
|
+
----------
|
|
231
|
+
channel:
|
|
232
|
+
A logical name (resolved via `slack_config.json`) or a raw channel id.
|
|
233
|
+
token:
|
|
234
|
+
Bot token; defaults to `$SLACK_BOT_TOKEN` / the workspace `.env`
|
|
235
|
+
(`resolve_token`).
|
|
236
|
+
root:
|
|
237
|
+
Workspace root for `.env` resolution (the `SubstrateConfig.root`).
|
|
238
|
+
dry_run:
|
|
239
|
+
Render + report, send NOTHING (no `post_message`/`update_message` call).
|
|
240
|
+
edit_in_place:
|
|
241
|
+
None = auto (a `source="top"` notification edits one message; everything
|
|
242
|
+
else posts). True/False forces.
|
|
243
|
+
client:
|
|
244
|
+
Inject a fake `SlackClient` in tests; None builds a real one lazily from the
|
|
245
|
+
token at first `send`.
|
|
246
|
+
min_interval:
|
|
247
|
+
The `LiveMessage` throttle (seconds) for the edit-in-place surface.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
name = "slack"
|
|
251
|
+
|
|
252
|
+
def __init__(self, *, channel: str = "", token: str | None = None,
|
|
253
|
+
root: "os.PathLike[str] | str | None" = None,
|
|
254
|
+
dry_run: bool = False, edit_in_place: bool | None = None,
|
|
255
|
+
client=None, min_interval: float = 0.0):
|
|
256
|
+
self._channel_arg = channel
|
|
257
|
+
self._token = token
|
|
258
|
+
self._root = Path(root) if root is not None else None
|
|
259
|
+
self._dry_run = bool(dry_run)
|
|
260
|
+
self._edit_in_place = edit_in_place
|
|
261
|
+
self._client = client # injected fake, or built lazily
|
|
262
|
+
self._client_built = client is not None
|
|
263
|
+
self._min_interval = max(0.0, float(min_interval))
|
|
264
|
+
# Per-key LiveMessage cache for the edit-in-place surface (one re-edited
|
|
265
|
+
# message per note.key for the lifetime of this notifier instance).
|
|
266
|
+
self._live: dict[str, object] = {}
|
|
267
|
+
|
|
268
|
+
# -- transport construction (lazy; the only place slack_helpers loads) ------
|
|
269
|
+
|
|
270
|
+
def _ensure_client(self) -> tuple[object | None, str]:
|
|
271
|
+
"""(client, "") on success, or (None, reason) — never raises (fail-soft)."""
|
|
272
|
+
if self._client is not None:
|
|
273
|
+
return self._client, ""
|
|
274
|
+
if self._client_built: # we already tried and failed
|
|
275
|
+
return None, "no slack client"
|
|
276
|
+
token = resolve_token(self._token, root=self._root)
|
|
277
|
+
if not token:
|
|
278
|
+
self._client_built = True
|
|
279
|
+
return None, "no SLACK_BOT_TOKEN (set it in env or the workspace .env)"
|
|
280
|
+
try:
|
|
281
|
+
from slack_helpers import SlackClient
|
|
282
|
+
except Exception:
|
|
283
|
+
self._client_built = True
|
|
284
|
+
return None, ("slack_helpers not installed — "
|
|
285
|
+
"`pip install dos-kernel[notify-slack]`")
|
|
286
|
+
try:
|
|
287
|
+
self._client = SlackClient(token)
|
|
288
|
+
except Exception as e: # pragma: no cover - defensive
|
|
289
|
+
self._client_built = True
|
|
290
|
+
return None, f"slack client init failed: {e}"
|
|
291
|
+
self._client_built = True
|
|
292
|
+
return self._client, ""
|
|
293
|
+
|
|
294
|
+
def _live_message(self, channel: str, key: str):
|
|
295
|
+
"""A per-key edit-in-place handle. None if no client (fail-soft).
|
|
296
|
+
|
|
297
|
+
Prefers `slack_helpers.LiveMessage` (it adds the throttle that keeps a
|
|
298
|
+
high-frequency status stream under Slack's `chat.update` rate limit). When
|
|
299
|
+
the extra is absent but a client EXISTS (e.g. an injected fake, or a
|
|
300
|
+
hand-built client), it falls back to `_InlineLive` — a minimal
|
|
301
|
+
post-then-update with the same surface — so the edit path never
|
|
302
|
+
hard-depends on the optional dependency once a transport is in hand.
|
|
303
|
+
"""
|
|
304
|
+
if key in self._live:
|
|
305
|
+
return self._live[key]
|
|
306
|
+
client, _ = self._ensure_client()
|
|
307
|
+
if client is None:
|
|
308
|
+
return None
|
|
309
|
+
try:
|
|
310
|
+
from slack_helpers import LiveMessage
|
|
311
|
+
lm: object = LiveMessage(client, channel, min_interval=self._min_interval)
|
|
312
|
+
except Exception:
|
|
313
|
+
lm = _InlineLive(client, channel)
|
|
314
|
+
self._live[key] = lm
|
|
315
|
+
return lm
|
|
316
|
+
|
|
317
|
+
# -- delivery ---------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
def _wants_edit(self, note: Notification) -> bool:
|
|
320
|
+
if self._edit_in_place is not None:
|
|
321
|
+
return self._edit_in_place
|
|
322
|
+
return note.source == "top" # the live-status surface edits by default
|
|
323
|
+
|
|
324
|
+
def send(self, note: Notification) -> NotifyResult:
|
|
325
|
+
"""Deliver `note`. Returns a `NotifyResult`; NEVER raises (fail-soft)."""
|
|
326
|
+
channel = resolve_channel(self._channel_arg)
|
|
327
|
+
if not channel:
|
|
328
|
+
return NotifyResult(
|
|
329
|
+
delivered=False,
|
|
330
|
+
detail=f"no channel (got {self._channel_arg!r}; "
|
|
331
|
+
f"name not in slack_config.json or not a raw id)",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
edit = self._wants_edit(note)
|
|
335
|
+
|
|
336
|
+
if self._dry_run:
|
|
337
|
+
how = "edit-in-place" if edit else "post"
|
|
338
|
+
return NotifyResult(
|
|
339
|
+
delivered=False,
|
|
340
|
+
detail=f"[dry-run] would {how} to {channel} "
|
|
341
|
+
f"({note.severity.value}: {note.title})",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if edit:
|
|
345
|
+
return self._send_edit(channel, note)
|
|
346
|
+
return self._send_post(channel, note)
|
|
347
|
+
|
|
348
|
+
def _send_post(self, channel: str, note: Notification) -> NotifyResult:
|
|
349
|
+
client, reason = self._ensure_client()
|
|
350
|
+
if client is None:
|
|
351
|
+
return NotifyResult(delivered=False, detail=reason)
|
|
352
|
+
blocks = build_blocks(note)
|
|
353
|
+
try:
|
|
354
|
+
resp = client.post_message(channel, note.title, blocks=blocks)
|
|
355
|
+
except Exception as e: # noqa: BLE001 - advisory; report, don't crash
|
|
356
|
+
return NotifyResult(delivered=False, detail=f"error: {e}")
|
|
357
|
+
ts = str((resp or {}).get("ts") or "")
|
|
358
|
+
return NotifyResult(delivered=True, detail=f"posted ts={ts}", ref=ts)
|
|
359
|
+
|
|
360
|
+
def _send_edit(self, channel: str, note: Notification) -> NotifyResult:
|
|
361
|
+
lm = self._live_message(channel, note.key or "dos-notify")
|
|
362
|
+
if lm is None:
|
|
363
|
+
client, reason = self._ensure_client()
|
|
364
|
+
return NotifyResult(delivered=False, detail=reason or "no live message")
|
|
365
|
+
# LiveMessage streams TEXT (its body is the running log); we feed the
|
|
366
|
+
# title + summary, which is the at-a-glance line plus the full screen.
|
|
367
|
+
text = note.title if not note.summary else f"{note.title}\n{note.summary}"
|
|
368
|
+
try:
|
|
369
|
+
lm.update(text, force=True)
|
|
370
|
+
except Exception as e: # noqa: BLE001 - LiveMessage already swallows, double-net
|
|
371
|
+
return NotifyResult(delivered=False, detail=f"error: {e}")
|
|
372
|
+
ts = str(getattr(lm, "ts", "") or "")
|
|
373
|
+
return NotifyResult(delivered=True, detail="edited" if ts else "edit queued", ref=ts)
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""dos.drivers.notify_webhook — the generic HTTP-POST occupant of `dos.notify` (docs/267).
|
|
2
|
+
|
|
3
|
+
The second transport behind the notification spine, and the *universal* one. Where
|
|
4
|
+
the kernel seam (`dos.notify`) is transport-agnostic and names no vendor, THIS is
|
|
5
|
+
where "an HTTP endpoint" is allowed to be code — but it names no SPECIFIC vendor: it
|
|
6
|
+
renders a `Notification` to a portable JSON body and POSTs it to a configured URL, so
|
|
7
|
+
the one driver reaches every chat platform's incoming webhook (Teams / Discord /
|
|
8
|
+
Mattermost / Slack-incoming render the top-level `text`), every incident bus
|
|
9
|
+
(PagerDuty / Opsgenie / incident.io ingest a POST + routing key), and every automation
|
|
10
|
+
hook (Zapier / n8n / a Lambda Function URL). It registers through the `dos.notifiers`
|
|
11
|
+
entry-point group, so `resolve_notifier("webhook")` finds it by name and no kernel
|
|
12
|
+
module imports it.
|
|
13
|
+
|
|
14
|
+
Why it ships in the core (no extra)
|
|
15
|
+
===================================
|
|
16
|
+
|
|
17
|
+
Unlike `notify_slack` (which pulls `slack_helpers` → `requests` in the
|
|
18
|
+
`[notify-slack]` extra), a webhook needs only `urllib.request` from the standard
|
|
19
|
+
library. So this driver adds NO dependency and ships in the core install — a
|
|
20
|
+
`pip install dos-kernel` can already deliver notifications to any webhook URL.
|
|
21
|
+
|
|
22
|
+
Disciplines (inherited from the seam — the `notify_slack` posture, verbatim)
|
|
23
|
+
============================================================================
|
|
24
|
+
|
|
25
|
+
* **Fail-soft.** `send` returns a `NotifyResult`, never raises — no URL, a non-2xx
|
|
26
|
+
response, a network error, or a malformed body all degrade to `delivered=False`
|
|
27
|
+
with a one-line reason. (The seam's `send_safely` is the outer net; this is the
|
|
28
|
+
inner one, so even a direct `WebhookNotifier().send(...)` is crash-free.)
|
|
29
|
+
* **Advisory only.** It renders a projection → POST. It mutates no DOS state, takes
|
|
30
|
+
no lease, stops no run. A LIVENESS-halt field CARRIES the paste-to-stop command
|
|
31
|
+
(built by the seam); it never enacts it. It does NOT retry or queue — a failed
|
|
32
|
+
POST is reported, and the host decides whether to re-`dos notify` (DOS reports; it
|
|
33
|
+
does not own a delivery SLA).
|
|
34
|
+
|
|
35
|
+
Credentials / routing (the `notify_slack.resolve_token` ladder, generalized to a URL)
|
|
36
|
+
=====================================================================================
|
|
37
|
+
|
|
38
|
+
* **url**: explicit arg › `$DOS_WEBHOOK_URL` › the workspace `.env`
|
|
39
|
+
(`<root>/.env`'s `DOS_WEBHOOK_URL`). No URL anywhere → a non-delivered result.
|
|
40
|
+
* **token** (optional): explicit arg › `$DOS_WEBHOOK_TOKEN` › `<root>/.env`. When
|
|
41
|
+
set, sent as `Authorization: Bearer <token>`. Many incoming-webhook URLs carry the
|
|
42
|
+
secret in the PATH (Slack/Teams/Discord style) and need no token; a header-secret
|
|
43
|
+
transport (PagerDuty-style) uses the token. Both are supported.
|
|
44
|
+
|
|
45
|
+
The JSON body is built HERE, locally — a small DOS-shaped builder (`build_payload`,
|
|
46
|
+
the spine's analogue of `notify_slack.build_blocks`), so the kernel seam stays
|
|
47
|
+
wire-format-free. It is a FLAT, portable object (not a vendor wire format): a
|
|
48
|
+
structured consumer reads `severity`/`fields`/`source`; a dumb chat hook renders the
|
|
49
|
+
synthesized top-level `text`.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import json
|
|
55
|
+
import os
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
|
|
58
|
+
from dos.notify import Notification, NotifyResult
|
|
59
|
+
|
|
60
|
+
# A POST body caps at nothing in particular, but keep the summary bounded so a giant
|
|
61
|
+
# `dos top` screen does not produce a multi-megabyte request (the `notify_slack`
|
|
62
|
+
# _MAX_SUMMARY instinct; webhooks vary, so this is generous).
|
|
63
|
+
_MAX_SUMMARY = 8000
|
|
64
|
+
|
|
65
|
+
# Severity → a short tag for the synthesized `text` line (chat hooks render `text`).
|
|
66
|
+
_SEV_TAG = {"INFO": "·", "WARN": "▲", "URGENT": "■"}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_payload(note: Notification) -> dict:
|
|
70
|
+
"""A `Notification` → a portable JSON body (pure; no I/O).
|
|
71
|
+
|
|
72
|
+
`note.to_dict()` already carries `severity`/`title`/`summary`/`fields`/`key`/
|
|
73
|
+
`source`. We add a synthesized top-level **`text`** (`[SEV] title\\n summary`),
|
|
74
|
+
because most chat webhooks (Teams/Discord/Slack-incoming) render a `text` field
|
|
75
|
+
and ignore the rest — so ONE body serves both a structured consumer (reads
|
|
76
|
+
`fields`/`severity`) and a dumb chat hook (renders `text`). A consumer needing a
|
|
77
|
+
vendor-exact shape (PagerDuty's nested `payload`) is a later payload-shaping
|
|
78
|
+
subclass; this is the 90% generic adapter.
|
|
79
|
+
"""
|
|
80
|
+
body = note.to_dict()
|
|
81
|
+
if len(body.get("summary") or "") > _MAX_SUMMARY:
|
|
82
|
+
body["summary"] = body["summary"][:_MAX_SUMMARY]
|
|
83
|
+
tag = _SEV_TAG.get(note.severity.value, "·")
|
|
84
|
+
head = f"{tag} [{note.severity.value}] {note.title}".strip()
|
|
85
|
+
text = head if not body.get("summary") else f"{head}\n{body['summary']}"
|
|
86
|
+
body["text"] = text
|
|
87
|
+
return body
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# Credential / routing resolution — boundary I/O, kept off the pure builder
|
|
92
|
+
# (mirrors notify_slack._read_env_file / resolve_token, generalized to URL+token).
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _read_env_file(root: Path) -> dict[str, str]:
|
|
97
|
+
"""Best-effort parse of `<root>/.env` → {KEY: value}. Never raises."""
|
|
98
|
+
out: dict[str, str] = {}
|
|
99
|
+
try:
|
|
100
|
+
text = (root / ".env").read_text(encoding="utf-8")
|
|
101
|
+
except OSError:
|
|
102
|
+
return out
|
|
103
|
+
for line in text.splitlines():
|
|
104
|
+
line = line.strip()
|
|
105
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
106
|
+
continue
|
|
107
|
+
k, _, v = line.partition("=")
|
|
108
|
+
out[k.strip()] = v.strip().strip('"').strip("'")
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def resolve_url(explicit: str | None, *, root: Path | None) -> str:
|
|
113
|
+
"""Webhook URL: explicit arg › `$DOS_WEBHOOK_URL` › `<root>/.env`. "" if none."""
|
|
114
|
+
if explicit:
|
|
115
|
+
return explicit
|
|
116
|
+
env = os.environ.get("DOS_WEBHOOK_URL")
|
|
117
|
+
if env:
|
|
118
|
+
return env
|
|
119
|
+
if root is not None:
|
|
120
|
+
return _read_env_file(root).get("DOS_WEBHOOK_URL", "")
|
|
121
|
+
return ""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def resolve_token(explicit: str | None, *, root: Path | None) -> str:
|
|
125
|
+
"""Optional bearer token: explicit › `$DOS_WEBHOOK_TOKEN` › `<root>/.env`. "" if none."""
|
|
126
|
+
if explicit:
|
|
127
|
+
return explicit
|
|
128
|
+
env = os.environ.get("DOS_WEBHOOK_TOKEN")
|
|
129
|
+
if env:
|
|
130
|
+
return env
|
|
131
|
+
if root is not None:
|
|
132
|
+
return _read_env_file(root).get("DOS_WEBHOOK_TOKEN", "")
|
|
133
|
+
return ""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# A tiny default transport over urllib — injectable in tests, lazy at call.
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class _UrllibTransport:
|
|
142
|
+
"""The stdlib POST. Returns (status_code, reason); raises on network failure.
|
|
143
|
+
|
|
144
|
+
Kept behind a method so tests inject a fake with the same `post(...)` shape
|
|
145
|
+
instead of patching urllib (the `notify_slack` injected-client posture).
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def post(self, url: str, body: bytes, headers: dict, timeout: float) -> tuple[int, str]:
|
|
149
|
+
import urllib.error
|
|
150
|
+
import urllib.request
|
|
151
|
+
|
|
152
|
+
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
153
|
+
try:
|
|
154
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 - operator-supplied URL
|
|
155
|
+
return int(getattr(resp, "status", 0) or resp.getcode() or 0), "OK"
|
|
156
|
+
except urllib.error.HTTPError as e:
|
|
157
|
+
# A non-2xx with a response body — surface the code + reason, don't raise.
|
|
158
|
+
return int(e.code), str(getattr(e, "reason", "") or "HTTP error")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# The notifier.
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class WebhookNotifier:
|
|
167
|
+
"""Deliver a `Notification` by POSTing a portable JSON body to a configured URL.
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
url:
|
|
172
|
+
The webhook endpoint; defaults to `$DOS_WEBHOOK_URL` / the workspace `.env`
|
|
173
|
+
(`resolve_url`).
|
|
174
|
+
token:
|
|
175
|
+
Optional bearer secret; defaults to `$DOS_WEBHOOK_TOKEN` / `.env`. Sent as
|
|
176
|
+
`Authorization: Bearer <token>` when present (override via `headers`).
|
|
177
|
+
root:
|
|
178
|
+
Workspace root for `.env` resolution (the `SubstrateConfig.root`).
|
|
179
|
+
dry_run:
|
|
180
|
+
Render + report, POST NOTHING.
|
|
181
|
+
method:
|
|
182
|
+
HTTP method (default POST). Kept for the rare webhook that wants PUT.
|
|
183
|
+
headers:
|
|
184
|
+
Extra/override headers merged over the defaults (`Content-Type:
|
|
185
|
+
application/json` + the bearer header when a token is set).
|
|
186
|
+
timeout:
|
|
187
|
+
Request timeout in seconds (default 10).
|
|
188
|
+
transport:
|
|
189
|
+
Inject a fake with a `post(url, body, headers, timeout) -> (code, reason)`
|
|
190
|
+
method in tests; None uses the stdlib urllib transport.
|
|
191
|
+
|
|
192
|
+
`channel` is accepted-and-ignored (a webhook has no channel) so the generic
|
|
193
|
+
`dos notify` kwarg-forwarding can hand the same bag to any transport without the
|
|
194
|
+
caller branching per driver.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
name = "webhook"
|
|
198
|
+
|
|
199
|
+
def __init__(self, *, url: str = "", token: str | None = None,
|
|
200
|
+
root: "os.PathLike[str] | str | None" = None,
|
|
201
|
+
dry_run: bool = False, method: str = "POST",
|
|
202
|
+
headers: dict | None = None, timeout: float = 10.0,
|
|
203
|
+
transport=None, channel: str = ""): # noqa: ARG002 - channel ignored (parity)
|
|
204
|
+
self._url_arg = url
|
|
205
|
+
self._token = token
|
|
206
|
+
self._root = Path(root) if root is not None else None
|
|
207
|
+
self._dry_run = bool(dry_run)
|
|
208
|
+
self._method = (method or "POST").upper()
|
|
209
|
+
self._extra_headers = dict(headers or {})
|
|
210
|
+
self._timeout = max(0.1, float(timeout))
|
|
211
|
+
self._transport = transport
|
|
212
|
+
|
|
213
|
+
def _headers(self) -> dict:
|
|
214
|
+
h = {"Content-Type": "application/json", "User-Agent": "dos-notify/webhook"}
|
|
215
|
+
token = resolve_token(self._token, root=self._root)
|
|
216
|
+
if token:
|
|
217
|
+
h["Authorization"] = f"Bearer {token}"
|
|
218
|
+
h.update(self._extra_headers) # operator overrides win
|
|
219
|
+
return h
|
|
220
|
+
|
|
221
|
+
def send(self, note: Notification) -> NotifyResult:
|
|
222
|
+
"""Deliver `note`. Returns a `NotifyResult`; NEVER raises (fail-soft)."""
|
|
223
|
+
url = resolve_url(self._url_arg, root=self._root)
|
|
224
|
+
if not url:
|
|
225
|
+
return NotifyResult(
|
|
226
|
+
delivered=False,
|
|
227
|
+
detail="no webhook URL (pass --url, set $DOS_WEBHOOK_URL, "
|
|
228
|
+
"or add DOS_WEBHOOK_URL to the workspace .env)",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if self._dry_run:
|
|
232
|
+
return NotifyResult(
|
|
233
|
+
delivered=False,
|
|
234
|
+
detail=f"[dry-run] would POST to {url} "
|
|
235
|
+
f"({note.severity.value}: {note.title})",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
body = json.dumps(build_payload(note)).encode("utf-8")
|
|
240
|
+
except Exception as e: # noqa: BLE001 - a non-serializable field must not crash
|
|
241
|
+
return NotifyResult(delivered=False, detail=f"error: payload not serializable: {e}")
|
|
242
|
+
|
|
243
|
+
transport = self._transport if self._transport is not None else _UrllibTransport()
|
|
244
|
+
try:
|
|
245
|
+
code, reason = transport.post(url, body, self._headers(), self._timeout)
|
|
246
|
+
except Exception as e: # noqa: BLE001 - advisory; report, don't crash the producer
|
|
247
|
+
return NotifyResult(delivered=False, detail=f"error: {e}")
|
|
248
|
+
|
|
249
|
+
if 200 <= int(code) < 300:
|
|
250
|
+
return NotifyResult(delivered=True, detail=f"posted HTTP {code}", ref=str(code))
|
|
251
|
+
return NotifyResult(delivered=False, detail=f"HTTP {code}: {reason}")
|