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
dos/notify.py
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""notify — push a DOS projection (decisions / fleet status) to a transport.
|
|
2
|
+
|
|
3
|
+
> **The verdict is the kernel; *where it lands* is a driver (docs/225).** DOS
|
|
4
|
+
> already has two read-only projections — `dos decisions` ("what needs a human")
|
|
5
|
+
> and `dos top` ("what is running now"). This seam turns either into ONE
|
|
6
|
+
> transport-agnostic `Notification` and hands it to a by-name `Notifier`. Slack is
|
|
7
|
+
> the first driver (`dos.drivers.notify_slack`); PagerDuty / email / a webhook are
|
|
8
|
+
> later drivers. **No transport name appears in this module.**
|
|
9
|
+
|
|
10
|
+
Why this module exists
|
|
11
|
+
======================
|
|
12
|
+
|
|
13
|
+
A fleet runs unattended; the operator is in Slack, not watching a terminal. The
|
|
14
|
+
two projections that answer "what needs me" / "what's running" render only to the
|
|
15
|
+
local screen today. The notification spine is the seam that pushes them out.
|
|
16
|
+
|
|
17
|
+
This is the kernel's pure-protocol + by-name-resolver pattern, for the FOURTH
|
|
18
|
+
time, now on the DELIVERY side — after `dos.judges` (the JUDGE rung),
|
|
19
|
+
`dos.overlap_policy` (the disjointness scorer), and `dos.hook_dialect` (the
|
|
20
|
+
host-hook renderer). The shape is identical: a pure Protocol + two frozen value
|
|
21
|
+
types + an unshadowable built-in + a by-name resolver; every real transport (which
|
|
22
|
+
names a vendor as code — a `SlackNotifier` is inherently Slack-specific) lives in a
|
|
23
|
+
driver and registers through the `dos.notifiers` entry-point group.
|
|
24
|
+
|
|
25
|
+
The neutral payload — `Notification`
|
|
26
|
+
=====================================
|
|
27
|
+
|
|
28
|
+
A `Notification` is the DOS-shaped fact, NOT a transport's wire format (it is not
|
|
29
|
+
Slack Block Kit — that is the Slack driver's shape). A renderer (this module) turns
|
|
30
|
+
the typed projection data into a `Notification`; a driver turns the `Notification`
|
|
31
|
+
into its transport's bytes. `Notification.key` is the load-bearing field for the
|
|
32
|
+
edit-in-place surface (`dos top` streamed into ONE re-edited message): a notifier
|
|
33
|
+
that can edit keys its single message on `key`; one that cannot ignores it and
|
|
34
|
+
posts.
|
|
35
|
+
|
|
36
|
+
The two adapters are PURE over the *already-built* projection data
|
|
37
|
+
==================================================================
|
|
38
|
+
|
|
39
|
+
The hard part is already done: `decisions.collect_decisions()` and
|
|
40
|
+
`dispatch_top.snapshot()` return typed, ranked, `to_dict()`-able data, and their
|
|
41
|
+
`render_*` functions produce the plain-text body. So the adapters here take that
|
|
42
|
+
data as ARGUMENTS (duck-typed — they read only the fields they need) and are pure:
|
|
43
|
+
data in, `Notification` out, no I/O. The CLI verb (layer 3) is what calls the
|
|
44
|
+
readers and hands the result in — that keeps `notify.py` a true layer-1 leaf that
|
|
45
|
+
imports no layer-3 module (the dependency-arrow rule; the same reason
|
|
46
|
+
`liveness.classify` takes evidence rather than reading git itself).
|
|
47
|
+
|
|
48
|
+
Failure direction = fail-SOFT
|
|
49
|
+
=============================
|
|
50
|
+
|
|
51
|
+
Unlike `hook_dialect` (fail-LOUD: a wrong dialect is a silent no-op against the
|
|
52
|
+
host, so it raises), a notification is advisory telemetry. A transport that raises
|
|
53
|
+
or is mis-wired must NEVER crash the fleet loop that emitted it. So `send_safely`
|
|
54
|
+
converts any `send` raise into a non-delivered `NotifyResult`. A *resolve* of an
|
|
55
|
+
unknown notifier name still raises (operator error, surfaced at config time, like
|
|
56
|
+
`resolve_judge`); a *send* never does. This is `LiveMessage._warn`'s philosophy —
|
|
57
|
+
"a streaming UI never crashes its producer" — lifted to the seam.
|
|
58
|
+
|
|
59
|
+
The advisory floor (docs/99)
|
|
60
|
+
============================
|
|
61
|
+
|
|
62
|
+
The notifier REPORTS; it never acts on the fleet. It cannot take a lease, stop a
|
|
63
|
+
run, or mutate state — it is a pure read-of-a-projection → push. A LIVENESS-halt
|
|
64
|
+
notification *describes* a proposed stop and carries the paste-to-stop command in a
|
|
65
|
+
field; enacting it stays the operator's call. `decisions.py`'s locked
|
|
66
|
+
read-only-router model, extended across the network boundary.
|
|
67
|
+
|
|
68
|
+
Pure-stdlib. The renderers + resolver are the unit-test surface; the only I/O in
|
|
69
|
+
the whole spine is inside a driver's `send`.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
from __future__ import annotations
|
|
73
|
+
|
|
74
|
+
import enum
|
|
75
|
+
import sys
|
|
76
|
+
from dataclasses import dataclass
|
|
77
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
78
|
+
|
|
79
|
+
if TYPE_CHECKING: # pragma: no cover - typing only; never imported at runtime
|
|
80
|
+
from dos.decisions import Decision
|
|
81
|
+
from dos.dispatch_top import Frame
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# The closed severity vocabulary. `str`-valued so it round-trips through `--json`
|
|
86
|
+
# without a lookup table (the `gate_classify.Verdict` / `DecisionKind` posture).
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Severity(str, enum.Enum):
|
|
91
|
+
"""How loud a notification is — the transport maps this to colour/emoji/route."""
|
|
92
|
+
|
|
93
|
+
INFO = "INFO" # a status digest, nothing wrong
|
|
94
|
+
WARN = "WARN" # a refusal / wedge / spinning lane is pending
|
|
95
|
+
URGENT = "URGENT" # a LIVENESS halt / stalled lane — a run is hung NOW
|
|
96
|
+
|
|
97
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
98
|
+
return self.value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# The neutral payload — the DOS-shaped fact a driver renders to its wire format.
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class Notification:
|
|
108
|
+
"""One transport-agnostic notification — pure data, no wire format.
|
|
109
|
+
|
|
110
|
+
`summary` is the plain-text body (reuses the projection's own renderer, so a
|
|
111
|
+
notifier with no rich surface still says everything). `fields` are the TOP rows
|
|
112
|
+
as `(label, value)` pairs — what a Block-Kit/section transport shows at a
|
|
113
|
+
glance. `key` is the stable identity for edit-in-place (a notifier that can
|
|
114
|
+
edit re-uses one message keyed on it); `source` names which projection this is.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
severity: Severity
|
|
118
|
+
title: str
|
|
119
|
+
summary: str
|
|
120
|
+
fields: tuple[tuple[str, str], ...] = ()
|
|
121
|
+
key: str = ""
|
|
122
|
+
source: str = ""
|
|
123
|
+
|
|
124
|
+
def to_dict(self) -> dict:
|
|
125
|
+
return {
|
|
126
|
+
"severity": self.severity.value,
|
|
127
|
+
"title": self.title,
|
|
128
|
+
"summary": self.summary,
|
|
129
|
+
"fields": [list(f) for f in self.fields],
|
|
130
|
+
"key": self.key,
|
|
131
|
+
"source": self.source,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# The result a driver returns — fail-soft: delivered=False is normal, never a raise.
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class NotifyResult:
|
|
142
|
+
"""The outcome of a `send` — always returned, never raised (fail-soft).
|
|
143
|
+
|
|
144
|
+
`delivered` is True iff the transport accepted it. `detail` is a one-line human
|
|
145
|
+
reason (`"posted ts=…"` / `"edited"` / `"dry-run"` / `"no token — skipped"` /
|
|
146
|
+
`"error: …"`). `ref` is the transport's message id (a Slack `ts`) when one
|
|
147
|
+
exists, so a later edit-in-place can target the same message.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
delivered: bool
|
|
151
|
+
detail: str = ""
|
|
152
|
+
ref: str = ""
|
|
153
|
+
|
|
154
|
+
def to_dict(self) -> dict:
|
|
155
|
+
return {"delivered": self.delivered, "detail": self.detail, "ref": self.ref}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
# The Notifier protocol + the unshadowable built-in null sink.
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@runtime_checkable
|
|
164
|
+
class Notifier(Protocol):
|
|
165
|
+
"""A transport that delivers a `Notification`. The driver-side seam.
|
|
166
|
+
|
|
167
|
+
`name` is the registered name (`"slack"`); `send` delivers and returns a
|
|
168
|
+
`NotifyResult`. An implementation MAY post a fresh message or edit one in place
|
|
169
|
+
(keyed on `note.key`) — that choice is the driver's, not the kernel's.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
name: str
|
|
173
|
+
|
|
174
|
+
def send(self, note: Notification) -> NotifyResult: ...
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class NullNotifier:
|
|
178
|
+
"""The honest zero — delivers nothing, the unshadowable built-in baseline.
|
|
179
|
+
|
|
180
|
+
The default notifier, so a bare `dos notify` is a safe no-op that renders the
|
|
181
|
+
payload and sends it nowhere (the `AbstainJudge` / `prefix`-floor analogue: the
|
|
182
|
+
built-in that can never loosen anything and is always resolvable). A host opts
|
|
183
|
+
IN to a real transport by naming one (`--notifier slack`).
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
name = "null"
|
|
187
|
+
|
|
188
|
+
def send(self, note: Notification) -> NotifyResult:
|
|
189
|
+
return NotifyResult(delivered=False, detail="null sink (no transport configured)")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
_BUILT_IN_NOTIFIERS: dict[str, type] = {"null": NullNotifier}
|
|
193
|
+
|
|
194
|
+
NOTIFIER_ENTRY_POINT_GROUP = "dos.notifiers"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# The pure adapters — typed projection data → a Notification. No I/O; duck-typed
|
|
199
|
+
# over the fields they read (so this module imports no layer-3 module at runtime).
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _decision_field(d: "Decision") -> tuple[str, str]:
|
|
204
|
+
"""One decision → a `(label, value)` field. `label` is kind@lane, `value` the why.
|
|
205
|
+
|
|
206
|
+
Pure. Reads only `.kind`/`.lane`/`.reason_token`/`.reason_text`/`.dup_count`
|
|
207
|
+
(duck-typed). A LIVENESS row's proposed stop command, when present, is appended
|
|
208
|
+
to the value so the page CARRIES the paste-to-stop (advisory — the operator
|
|
209
|
+
enacts it).
|
|
210
|
+
"""
|
|
211
|
+
kind = getattr(getattr(d, "kind", ""), "value", "") or str(getattr(d, "kind", ""))
|
|
212
|
+
lane = getattr(d, "lane", "") or "-"
|
|
213
|
+
label = f"{kind} @ {lane}"
|
|
214
|
+
value = getattr(d, "reason_token", "") or getattr(d, "reason_text", "") or "?"
|
|
215
|
+
dup = getattr(d, "dup_count", 1) or 1
|
|
216
|
+
if dup > 1:
|
|
217
|
+
value = f"{value} (×{dup})"
|
|
218
|
+
cmd = getattr(d, "proposed_command", "") or ""
|
|
219
|
+
if cmd:
|
|
220
|
+
value = f"{value} ⟶ stop: {cmd}"
|
|
221
|
+
return (label, value[:300])
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def notification_for_decisions(
|
|
225
|
+
rows: list["Decision"], *, summary: str = "", top: int = 5
|
|
226
|
+
) -> Notification:
|
|
227
|
+
"""Pure: the ranked decision rows → one `Notification` (the digest surface).
|
|
228
|
+
|
|
229
|
+
`rows` is the output of `decisions.collect_decisions(...)` — ALREADY ranked
|
|
230
|
+
(LIVENESS halt first, then refusals, then wedges, then soak gates), so the first
|
|
231
|
+
`top` rows ARE the TOP decisions to surface. `summary` is the prebuilt
|
|
232
|
+
plain-text body (`decisions.render_list_plain(rows)`); the caller passes it so
|
|
233
|
+
this module needs no `decisions` import. Severity rises with the worst row: a
|
|
234
|
+
LIVENESS halt → URGENT, any pending row → WARN, an empty queue → INFO.
|
|
235
|
+
|
|
236
|
+
`key` is workspace-stable so a notifier that edits in place keeps one digest
|
|
237
|
+
message current rather than spamming; the digest surface posts by default
|
|
238
|
+
(see the driver), but the key is available either way.
|
|
239
|
+
"""
|
|
240
|
+
n = len(rows)
|
|
241
|
+
has_liveness = any(
|
|
242
|
+
(getattr(getattr(r, "kind", None), "value", "") == "LIVENESS") for r in rows
|
|
243
|
+
)
|
|
244
|
+
if has_liveness:
|
|
245
|
+
severity = Severity.URGENT
|
|
246
|
+
elif n:
|
|
247
|
+
severity = Severity.WARN
|
|
248
|
+
else:
|
|
249
|
+
severity = Severity.INFO
|
|
250
|
+
|
|
251
|
+
if n == 0:
|
|
252
|
+
title = "fleet clear — no pending decisions"
|
|
253
|
+
else:
|
|
254
|
+
title = f"{n} decision{'s' if n != 1 else ''} need you"
|
|
255
|
+
fields = tuple(_decision_field(r) for r in rows[: max(0, top)])
|
|
256
|
+
return Notification(
|
|
257
|
+
severity=severity,
|
|
258
|
+
title=title,
|
|
259
|
+
summary=summary,
|
|
260
|
+
fields=fields,
|
|
261
|
+
key="dos-decisions",
|
|
262
|
+
source="decisions",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def notification_for_top(frame: "Frame", *, summary: str = "") -> Notification:
|
|
267
|
+
"""Pure: a `dos top` `Frame` → one `Notification` (the live-status surface).
|
|
268
|
+
|
|
269
|
+
`frame` is the output of `dispatch_top.snapshot(...)`; `summary` is the prebuilt
|
|
270
|
+
screen (`dispatch_top.render_frame_text(frame)`). Severity rises with the worst
|
|
271
|
+
lane: any STALLED lane → URGENT, any SPINNING → WARN, else INFO. `fields` are the
|
|
272
|
+
non-FREE lanes (`lane → chip + holder`) plus a recent-verdict tally — the
|
|
273
|
+
glance view. `key` is workspace-stable so the driver streams the status into ONE
|
|
274
|
+
edited message (the `LiveMessage` use-case) instead of a post per tick.
|
|
275
|
+
|
|
276
|
+
Duck-typed over `frame.lanes` (each with `.lane`/`.chip`/`.holder`) and
|
|
277
|
+
`frame.verdicts`/`frame.workspace` — no `dispatch_top` import at runtime.
|
|
278
|
+
"""
|
|
279
|
+
lanes = tuple(getattr(frame, "lanes", ()) or ())
|
|
280
|
+
# The chip strings carry their state word ("🟢 ADVANCING" / "🟡 SPINNING" /
|
|
281
|
+
# "🔴 STALLED" / "⚪ FREE"); we read the WORD, not the glyph, so a chip-format
|
|
282
|
+
# tweak in dispatch_top does not silently break the severity here.
|
|
283
|
+
def _word(chip: str) -> str:
|
|
284
|
+
parts = str(chip or "").split()
|
|
285
|
+
return parts[-1] if parts else ""
|
|
286
|
+
|
|
287
|
+
words = [_word(getattr(s, "chip", "")) for s in lanes]
|
|
288
|
+
advancing = sum(1 for w in words if w == "ADVANCING")
|
|
289
|
+
spinning = sum(1 for w in words if w == "SPINNING")
|
|
290
|
+
stalled = sum(1 for w in words if w == "STALLED")
|
|
291
|
+
free = sum(1 for w in words if w == "FREE")
|
|
292
|
+
|
|
293
|
+
if stalled:
|
|
294
|
+
severity = Severity.URGENT
|
|
295
|
+
elif spinning:
|
|
296
|
+
severity = Severity.WARN
|
|
297
|
+
else:
|
|
298
|
+
severity = Severity.INFO
|
|
299
|
+
|
|
300
|
+
title = (
|
|
301
|
+
f"fleet: {advancing} advancing · {spinning} spinning · "
|
|
302
|
+
f"{stalled} stalled · {free} free"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
fields: list[tuple[str, str]] = []
|
|
306
|
+
for s in lanes:
|
|
307
|
+
if _word(getattr(s, "chip", "")) == "FREE":
|
|
308
|
+
continue
|
|
309
|
+
holder = getattr(s, "holder", "") or ""
|
|
310
|
+
val = getattr(s, "chip", "")
|
|
311
|
+
if holder:
|
|
312
|
+
val = f"{val} {holder}"
|
|
313
|
+
fields.append((getattr(s, "lane", "") or "-", str(val)[:300]))
|
|
314
|
+
nverd = len(tuple(getattr(frame, "verdicts", ()) or ()))
|
|
315
|
+
if nverd:
|
|
316
|
+
fields.append(("recent verdicts", str(nverd)))
|
|
317
|
+
|
|
318
|
+
return Notification(
|
|
319
|
+
severity=severity,
|
|
320
|
+
title=title,
|
|
321
|
+
summary=summary,
|
|
322
|
+
fields=tuple(fields),
|
|
323
|
+
key=f"dos-top:{getattr(frame, 'workspace', '')}",
|
|
324
|
+
source="top",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# Resolver + discovery — built-ins first, then the `dos.notifiers` plugins. The
|
|
330
|
+
# `resolve_judge` / `resolve_dialect` shape; discovery I/O at the call boundary.
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _discover_entry_point_notifiers(*, _stderr=None) -> list[tuple[str, "Notifier"]]:
|
|
335
|
+
"""Every `dos.notifiers` plugin as `(name, notifier)`, sorted by name.
|
|
336
|
+
|
|
337
|
+
A plugin that fails to load is SKIPPED with a one-line stderr note rather than
|
|
338
|
+
crashing — the `_discover_entry_point_judges` posture (a broken third-party
|
|
339
|
+
plugin is the operator's to fix, not a kernel fault). Does entry-point I/O, so
|
|
340
|
+
it is a call-boundary helper, never called inside an adapter.
|
|
341
|
+
"""
|
|
342
|
+
stderr = _stderr if _stderr is not None else sys.stderr
|
|
343
|
+
out: list[tuple[str, Notifier]] = []
|
|
344
|
+
try:
|
|
345
|
+
from importlib.metadata import entry_points
|
|
346
|
+
except Exception: # pragma: no cover - importlib.metadata always present py3.11+
|
|
347
|
+
return out
|
|
348
|
+
try:
|
|
349
|
+
eps = entry_points(group=NOTIFIER_ENTRY_POINT_GROUP)
|
|
350
|
+
except TypeError: # pragma: no cover - py<3.10 selectable-API fallback
|
|
351
|
+
eps = entry_points().get(NOTIFIER_ENTRY_POINT_GROUP, []) # type: ignore[attr-defined]
|
|
352
|
+
except Exception: # pragma: no cover - defensive: never let discovery crash a call
|
|
353
|
+
return out
|
|
354
|
+
for ep in sorted(eps, key=lambda e: e.name):
|
|
355
|
+
try:
|
|
356
|
+
obj = ep.load()
|
|
357
|
+
notifier = obj() if isinstance(obj, type) else obj
|
|
358
|
+
except Exception as e: # pragma: no cover - depends on third-party plugin
|
|
359
|
+
print(
|
|
360
|
+
f"warning: notifier plugin {ep.name!r} failed to load ({e}); skipping",
|
|
361
|
+
file=stderr,
|
|
362
|
+
)
|
|
363
|
+
continue
|
|
364
|
+
out.append((ep.name, notifier))
|
|
365
|
+
return out
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _accepted_kwargs(ctor: type, kwargs: dict) -> dict:
|
|
369
|
+
"""Filter `kwargs` to the parameters `ctor.__init__` actually accepts.
|
|
370
|
+
|
|
371
|
+
The notification CLI builds ONE superset bag (channel/url/token/dry_run/root)
|
|
372
|
+
and hands it to whichever transport was named, so it need not branch per driver.
|
|
373
|
+
But a transport's `__init__` is keyword-only and would raise on an unexpected
|
|
374
|
+
kwarg (`slack` does not take `url`; `webhook` does not take `channel` unless it
|
|
375
|
+
opts in). So we pass only the parameters the constructor declares — unless it
|
|
376
|
+
declares `**kwargs` (a `VAR_KEYWORD` param), in which case it absorbs the rest and
|
|
377
|
+
we forward everything. Pure introspection; no I/O.
|
|
378
|
+
"""
|
|
379
|
+
try:
|
|
380
|
+
import inspect
|
|
381
|
+
|
|
382
|
+
params = inspect.signature(ctor).parameters
|
|
383
|
+
except (TypeError, ValueError): # pragma: no cover - builtins without a signature
|
|
384
|
+
return kwargs
|
|
385
|
+
if any(p.kind is p.VAR_KEYWORD for p in params.values()):
|
|
386
|
+
return kwargs
|
|
387
|
+
return {k: v for k, v in kwargs.items() if k in params}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def resolve_notifier(name: str, *, _stderr=None, **kwargs) -> "Notifier":
|
|
391
|
+
"""Resolve a notifier by name: built-ins first, then `dos.notifiers` plugins.
|
|
392
|
+
|
|
393
|
+
Built-ins (`null`) resolve FIRST and cannot be shadowed (the trusted-fallback
|
|
394
|
+
guarantee, identical to `resolve_judge`). An unknown name fails LOUD with the
|
|
395
|
+
known list — a typo'd `--notifier` is an operator error, never a silent degrade
|
|
396
|
+
to `null` (which would drop every notification quietly). `kwargs` (e.g.
|
|
397
|
+
`channel=…`, `url=…`, `token=…`, `dry_run=…`) are forwarded to a CONSTRUCTOR-style
|
|
398
|
+
occupant (a `type`), FILTERED to the parameters that constructor accepts (so the
|
|
399
|
+
CLI can hand the same superset to any transport); a pre-built instance ignores
|
|
400
|
+
them.
|
|
401
|
+
"""
|
|
402
|
+
if name in _BUILT_IN_NOTIFIERS:
|
|
403
|
+
cls = _BUILT_IN_NOTIFIERS[name]
|
|
404
|
+
accepted = _accepted_kwargs(cls, kwargs)
|
|
405
|
+
return cls(**accepted) if accepted else cls()
|
|
406
|
+
# For discovered plugins we resolve the ENTRY POINT and, if it is a class,
|
|
407
|
+
# construct it with kwargs (so the CLI can pass channel/token). A plugin that
|
|
408
|
+
# exposes a pre-built instance is used as-is.
|
|
409
|
+
stderr = _stderr if _stderr is not None else sys.stderr
|
|
410
|
+
try:
|
|
411
|
+
from importlib.metadata import entry_points
|
|
412
|
+
except Exception: # pragma: no cover
|
|
413
|
+
entry_points = None # type: ignore[assignment]
|
|
414
|
+
found: object | None = None
|
|
415
|
+
if entry_points is not None:
|
|
416
|
+
try:
|
|
417
|
+
eps = entry_points(group=NOTIFIER_ENTRY_POINT_GROUP)
|
|
418
|
+
except TypeError: # pragma: no cover - py<3.10
|
|
419
|
+
eps = entry_points().get(NOTIFIER_ENTRY_POINT_GROUP, []) # type: ignore[attr-defined]
|
|
420
|
+
except Exception: # pragma: no cover
|
|
421
|
+
eps = []
|
|
422
|
+
for ep in eps:
|
|
423
|
+
if ep.name == name:
|
|
424
|
+
try:
|
|
425
|
+
found = ep.load()
|
|
426
|
+
except Exception as e: # pragma: no cover - third-party
|
|
427
|
+
raise ValueError(
|
|
428
|
+
f"notifier {name!r} failed to load: {e}"
|
|
429
|
+
) from e
|
|
430
|
+
break
|
|
431
|
+
if found is not None:
|
|
432
|
+
if isinstance(found, type):
|
|
433
|
+
accepted = _accepted_kwargs(found, kwargs)
|
|
434
|
+
return found(**accepted) # type: ignore[return-value]
|
|
435
|
+
return found # a pre-built instance ignores kwargs
|
|
436
|
+
discovered = [n for n, _ in _discover_entry_point_notifiers(_stderr=stderr)]
|
|
437
|
+
known = sorted(set(_BUILT_IN_NOTIFIERS) | set(discovered))
|
|
438
|
+
raise ValueError(f"unknown notifier {name!r}; known: {', '.join(known)}")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def active_notifiers(*, _stderr=None) -> list[tuple[str, "Notifier"]]:
|
|
442
|
+
"""Every resolvable notifier as `(name, notifier)` — built-ins THEN discovered.
|
|
443
|
+
|
|
444
|
+
The order `dos doctor` would list. Does entry-point discovery (I/O), so it is a
|
|
445
|
+
call-boundary helper, never called inside an adapter (the `active_judges` rule).
|
|
446
|
+
"""
|
|
447
|
+
built: list[tuple[str, Notifier]] = [(n, cls()) for n, cls in _BUILT_IN_NOTIFIERS.items()]
|
|
448
|
+
return built + _discover_entry_point_notifiers(_stderr=_stderr)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def active_notifier_names(*, _stderr=None) -> list[str]:
|
|
452
|
+
"""The names of every active notifier (built-in + discovered)."""
|
|
453
|
+
return [name for name, _ in active_notifiers(_stderr=_stderr)]
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
# send_safely — the fail-soft wrapper. A send NEVER crashes the producer.
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def send_safely(notifier: "Notifier", note: Notification) -> NotifyResult:
|
|
462
|
+
"""Deliver `note` via `notifier`, converting ANY raise to a non-delivered result.
|
|
463
|
+
|
|
464
|
+
The fail-soft floor (`LiveMessage._warn`, lifted): a notification is advisory
|
|
465
|
+
telemetry, so a transport that raises (network down, bad token, a buggy plugin)
|
|
466
|
+
must never propagate into the fleet loop that emitted it. A clean `send` returns
|
|
467
|
+
its own `NotifyResult`; a raise becomes `NotifyResult(delivered=False,
|
|
468
|
+
detail="error: …")`. (Contrast `resolve_notifier`, which DOES raise on an
|
|
469
|
+
unknown name — that is a config-time operator error, surfaced before any send.)
|
|
470
|
+
"""
|
|
471
|
+
try:
|
|
472
|
+
result = notifier.send(note)
|
|
473
|
+
except Exception as e: # noqa: BLE001 - advisory telemetry must not crash the producer
|
|
474
|
+
return NotifyResult(delivered=False, detail=f"error: {e}")
|
|
475
|
+
if isinstance(result, NotifyResult):
|
|
476
|
+
return result
|
|
477
|
+
# A misbehaving occupant returned a non-NotifyResult; treat as a soft failure
|
|
478
|
+
# rather than trusting an unknown shape downstream.
|
|
479
|
+
return NotifyResult(delivered=False, detail="notifier returned a non-NotifyResult")
|
dos/observe.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""`dos observe` — the verdict-journal projection, read-only (docs/262).
|
|
2
|
+
|
|
3
|
+
The reader over the verdict journal (`verdict_journal`), the way `decisions` reads
|
|
4
|
+
the refusal sources and `trace` reads the spine/ledger/WAL/git. It answers the
|
|
5
|
+
questions the verdict journal was built to make cheap:
|
|
6
|
+
|
|
7
|
+
dos observe # fleet-wide rollup: counts per syscall × verdict
|
|
8
|
+
dos observe --run <run_id> # one run's verdict history, in order
|
|
9
|
+
dos observe --syscall NAME # filter the rollup/history to one dimension
|
|
10
|
+
dos observe --by verdict # fold on a different dimension (verdict/run_id/lane/source)
|
|
11
|
+
dos observe --tail N # the last N raw events
|
|
12
|
+
dos observe --json # machine-readable (for the trajectory-audit)
|
|
13
|
+
|
|
14
|
+
It is a **read-only projection**, never a store — the `decisions`/`trace`/`top`
|
|
15
|
+
contract: it reads the journal only, takes no lease, mints no belief, adjudicates
|
|
16
|
+
*nothing new*. The verdicts it shows were minted by the syscalls; this module only
|
|
17
|
+
folds and renders them. Delete it and you lose the reader, not the data.
|
|
18
|
+
|
|
19
|
+
Pure-where-it-can-be: the fold (`verdict_journal.rollup`/`for_run`) and the render
|
|
20
|
+
functions here are pure and are the unit-test surface; the only I/O is the single
|
|
21
|
+
`verdict_journal.read_all` at the boundary (mirrors `decisions.collect_decisions` /
|
|
22
|
+
`timeline.build_timeline`).
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import io
|
|
27
|
+
import sys
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
31
|
+
try:
|
|
32
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
33
|
+
except Exception: # pragma: no cover
|
|
34
|
+
pass
|
|
35
|
+
elif not isinstance(sys.stdout, io.TextIOWrapper): # pragma: no cover
|
|
36
|
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
|
|
37
|
+
|
|
38
|
+
from dos import verdict_journal as _vj
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# The assembled projection — one read, filtered + folded. The render surface.
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ObserveFrame:
|
|
48
|
+
"""What every observe renderer consumes — the filtered events + their rollup.
|
|
49
|
+
|
|
50
|
+
`events` is the filtered, append-ordered event list (after `--run`/`--syscall`);
|
|
51
|
+
`rollup` is the fold over them on the requested dimension; `corrupt` is the
|
|
52
|
+
integrity tally (count of `_CORRUPT` sentinel lines in the journal). `run` /
|
|
53
|
+
`syscall_filter` echo the active filters so a renderer can title itself.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
events: tuple[_vj.VerdictEvent, ...]
|
|
57
|
+
rollup: _vj.VerdictRollup
|
|
58
|
+
corrupt: int
|
|
59
|
+
run: str = ""
|
|
60
|
+
syscall_filter: str = ""
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict:
|
|
63
|
+
return {
|
|
64
|
+
"run": self.run,
|
|
65
|
+
"syscall_filter": self.syscall_filter,
|
|
66
|
+
"corrupt": self.corrupt,
|
|
67
|
+
"rollup": self.rollup.to_dict(),
|
|
68
|
+
"events": [e.to_dict() for e in self.events],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_frame(*, run: str = "", syscall: str = "", by: str = "syscall",
|
|
73
|
+
path=None) -> ObserveFrame:
|
|
74
|
+
"""Read the journal once, apply the filters, fold the rest. Read-only.
|
|
75
|
+
|
|
76
|
+
`run` filters to one run_id (the `trace` join key); `syscall` filters to one
|
|
77
|
+
dimension; `by` chooses the rollup dimension. A single `read_all` does the I/O;
|
|
78
|
+
everything after is pure (`for_run`/`rollup` + a list-comprehension filter), so
|
|
79
|
+
the assembly is unit-tested without a file the way `decisions.collect_decisions`
|
|
80
|
+
is.
|
|
81
|
+
"""
|
|
82
|
+
raw = _vj.read_all(path)
|
|
83
|
+
corrupt = _vj.count_corrupt(raw)
|
|
84
|
+
events = [
|
|
85
|
+
_vj.VerdictEvent.from_record(rec)
|
|
86
|
+
for rec in raw
|
|
87
|
+
if rec.get("op") != "_CORRUPT"
|
|
88
|
+
]
|
|
89
|
+
if run:
|
|
90
|
+
events = _vj.for_run(events, run)
|
|
91
|
+
if syscall:
|
|
92
|
+
events = [e for e in events if e.syscall == syscall]
|
|
93
|
+
roll = _vj.rollup(events, by=by, corrupt=corrupt)
|
|
94
|
+
return ObserveFrame(
|
|
95
|
+
events=tuple(events),
|
|
96
|
+
rollup=roll,
|
|
97
|
+
corrupt=corrupt,
|
|
98
|
+
run=run,
|
|
99
|
+
syscall_filter=syscall,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Rendering — the plain-text floor (rollup table + optional event list).
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def render_rollup_text(frame: ObserveFrame) -> str:
|
|
109
|
+
"""The fleet-wide rollup: a compact per-dimension verdict-count table.
|
|
110
|
+
|
|
111
|
+
One block per dimension value (a syscall, by default), each listing its verdict
|
|
112
|
+
tokens and counts. Mirrors the small-column idiom of `decisions.render_list_plain`.
|
|
113
|
+
"""
|
|
114
|
+
roll = frame.rollup
|
|
115
|
+
out: list[str] = []
|
|
116
|
+
title = "# observe"
|
|
117
|
+
if frame.run:
|
|
118
|
+
title += f" · run {frame.run}"
|
|
119
|
+
if frame.syscall_filter:
|
|
120
|
+
title += f" · syscall {frame.syscall_filter}"
|
|
121
|
+
out.append(title)
|
|
122
|
+
out.append(f" {roll.total} verdict event(s) recorded"
|
|
123
|
+
+ (f", folded by {roll.by}" if roll.by != "syscall" else ""))
|
|
124
|
+
if roll.corrupt:
|
|
125
|
+
out.append(f" ⚠ {roll.corrupt} corrupt/unreadable journal line(s) "
|
|
126
|
+
f"(integrity breach — not a torn tail)")
|
|
127
|
+
if not roll.total:
|
|
128
|
+
out.append(" (no verdicts recorded yet — wire a syscall to "
|
|
129
|
+
"verdict_journal.record, or run with DISPATCH_OBSERVE=1)")
|
|
130
|
+
return "\n".join(out)
|
|
131
|
+
out.append("")
|
|
132
|
+
for dim in roll.dimensions:
|
|
133
|
+
bucket = roll.counts.get(dim, {})
|
|
134
|
+
n = sum(bucket.values())
|
|
135
|
+
# "liveness 47 ADVANCING=40 SPINNING=5 STALLED=2"
|
|
136
|
+
pairs = " ".join(f"{tok}={cnt}" for tok, cnt in bucket.items())
|
|
137
|
+
out.append(f" {dim:<16} {n:>4} {pairs}")
|
|
138
|
+
return "\n".join(out)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
_TS_W = 20
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def render_history_text(frame: ObserveFrame, *, limit: int = 0) -> str:
|
|
145
|
+
"""The per-run (or filtered) verdict history, one event per line, in order.
|
|
146
|
+
|
|
147
|
+
`limit > 0` shows only the last `limit` events. Used by `--run` and `--tail`.
|
|
148
|
+
Provenance-first: ts, syscall, verdict, the run/subject/lane, and the evidence
|
|
149
|
+
detail that produced it — the byte-clean counts, never narration.
|
|
150
|
+
"""
|
|
151
|
+
out: list[str] = []
|
|
152
|
+
title = "# observe · history"
|
|
153
|
+
if frame.run:
|
|
154
|
+
title += f" · run {frame.run}"
|
|
155
|
+
if frame.syscall_filter:
|
|
156
|
+
title += f" · syscall {frame.syscall_filter}"
|
|
157
|
+
out.append(title)
|
|
158
|
+
events = list(frame.events)
|
|
159
|
+
if limit and limit > 0:
|
|
160
|
+
events = events[-limit:]
|
|
161
|
+
if not events:
|
|
162
|
+
out.append(" (no matching verdict events)")
|
|
163
|
+
return "\n".join(out)
|
|
164
|
+
header = f" {'ts':<{_TS_W}} {'syscall':<12} {'verdict':<14} subject / detail"
|
|
165
|
+
out.append(header)
|
|
166
|
+
out.append(" " + "-" * (len(header) - 2))
|
|
167
|
+
for e in events:
|
|
168
|
+
subj = e.subject or e.lane or ""
|
|
169
|
+
det = ""
|
|
170
|
+
if e.detail:
|
|
171
|
+
det = " {" + ", ".join(f"{k}={v}" for k, v in sorted(e.detail.items())) + "}"
|
|
172
|
+
run_tag = f" [{e.run_id}]" if (e.run_id and not frame.run) else ""
|
|
173
|
+
out.append(f" {(e.ts or '-'):<{_TS_W}} {(e.syscall or '-'):<12} "
|
|
174
|
+
f"{(e.verdict or '-'):<14} {subj}{det}{run_tag}")
|
|
175
|
+
return "\n".join(out)
|