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,98 @@
|
|
|
1
|
+
"""dos.drivers.decision_stop — a reference loop-STOP policy (a `dos.stop_policy` occupant).
|
|
2
|
+
|
|
3
|
+
The kernel scout's default is "an open operator decision is evidence-only, never a
|
|
4
|
+
STOP" (the 2026-06-03 directive). That is right for most decisions — a WEDGE or an
|
|
5
|
+
open soak gate blocks *future* work but wastes nothing while it waits, so halting
|
|
6
|
+
the whole loop on it is over-reach. But one decision class is different: a
|
|
7
|
+
`LIVENESS` row is an `OP_HALT` proposal — a run a watchdog judged SPINNING/hung
|
|
8
|
+
RIGHT NOW, actively burning budget. For a host that wants its loop to halt rather
|
|
9
|
+
than keep launching work alongside a doomed run, "stop on a LIVENESS decision" is a
|
|
10
|
+
legitimate policy.
|
|
11
|
+
|
|
12
|
+
This driver is the reference `dos.stop_policy.StopPolicy` occupant that encodes
|
|
13
|
+
exactly that, configurably:
|
|
14
|
+
|
|
15
|
+
* `DecisionClassStopPolicy(stop_classes=("LIVENESS",))` — reads the live
|
|
16
|
+
`dos.decisions` HUMAN queue and returns `StopVerdict.stop(...)` iff a pending
|
|
17
|
+
decision's `kind` is in `stop_classes`; otherwise `StopVerdict.defer()` (fall
|
|
18
|
+
through to the kernel's evidence-only default — WEDGE/soak/arbiter-refuse only
|
|
19
|
+
surface). The default `stop_classes` is `("LIVENESS",)` — the one urgent class.
|
|
20
|
+
|
|
21
|
+
It lives in a **driver**, not the kernel, because it does I/O (reads the decision
|
|
22
|
+
queue) and encodes host policy (which classes are halt-worthy) — the same reason a
|
|
23
|
+
ruling judge or a model-backed overlap scorer lives here. The kernel seam
|
|
24
|
+
(`dos.stop_policy`) holds only the Protocol + the fail-to-DEFER wrapper + the
|
|
25
|
+
resource-floor AND; this is one concrete policy under it. Registered under the
|
|
26
|
+
`dos.stop_policies` entry-point group so `resolve_stop_policy("decision-class")`
|
|
27
|
+
returns it; a host opts in by naming it in `dos.toml [scout] stop_policy`.
|
|
28
|
+
|
|
29
|
+
Safety: the policy reads the queue inside `decide`, and any fault there is caught
|
|
30
|
+
by the kernel's `run_stop_policy` (fail-to-DEFER) — but we ALSO guard the read here
|
|
31
|
+
so a torn/missing queue degrades to "no halt-worthy decision" (DEFER) with a clear
|
|
32
|
+
reason, rather than relying solely on the wrapper. And whatever this returns, the
|
|
33
|
+
kernel ANDs it under the `resource_blocked` floor, so this policy can only ever
|
|
34
|
+
*add* a halt, never suppress the measured resource STOP.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from dos.stop_policy import StopVerdict
|
|
40
|
+
|
|
41
|
+
# The decision classes this policy halts on, by default. LIVENESS = an OP_HALT
|
|
42
|
+
# proposal: a run hung/spinning NOW, burning budget — the one class where letting
|
|
43
|
+
# the loop keep launching work is worse than stopping. Everything else (WEDGE,
|
|
44
|
+
# ARBITER_REFUSE, PREFLIGHT_REFUSE, SOAK_GATE) blocks future work but wastes nothing
|
|
45
|
+
# waiting, so it stays evidence-only (DEFER → the kernel default surfaces it).
|
|
46
|
+
_DEFAULT_STOP_CLASSES = ("LIVENESS",)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DecisionClassStopPolicy:
|
|
50
|
+
"""STOP the loop iff a pending operator decision is of a configured urgent class.
|
|
51
|
+
|
|
52
|
+
The reference `dos.stop_policy.StopPolicy`. `stop_classes` is the set of
|
|
53
|
+
`dos.decisions.DecisionKind` values that warrant a halt (default
|
|
54
|
+
`("LIVENESS",)`). `decide` reads the live HUMAN decision queue for the active
|
|
55
|
+
workspace and returns STOP on the first match, else DEFER.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
name = "decision-class"
|
|
59
|
+
|
|
60
|
+
def __init__(self, stop_classes: tuple[str, ...] = _DEFAULT_STOP_CLASSES) -> None:
|
|
61
|
+
# Normalize to an upper-cased set for a case-insensitive `kind` match.
|
|
62
|
+
self.stop_classes = frozenset(c.strip().upper() for c in stop_classes if c)
|
|
63
|
+
|
|
64
|
+
def decide(self, state: object, config: object) -> StopVerdict:
|
|
65
|
+
"""Read the live HUMAN decision queue; STOP on an urgent-class row, else DEFER.
|
|
66
|
+
|
|
67
|
+
Guarded: a missing/torn queue (or an absent kernel decisions module) →
|
|
68
|
+
DEFER, never a spurious halt. The kernel's `run_stop_policy` would also
|
|
69
|
+
catch a raise, but degrading here gives a clearer reason and keeps the
|
|
70
|
+
policy honest on its own.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
from dos import config as _config
|
|
74
|
+
from dos import decisions as _decisions
|
|
75
|
+
cfg = config if config is not None else _config.active()
|
|
76
|
+
rows = _decisions.collect_decisions(cfg, resolver="HUMAN")
|
|
77
|
+
except Exception as e:
|
|
78
|
+
return StopVerdict.defer(
|
|
79
|
+
f"decision-class policy could not read the queue ({e!r}) — "
|
|
80
|
+
f"deferring (no halt)."
|
|
81
|
+
)
|
|
82
|
+
for d in rows:
|
|
83
|
+
kind = getattr(getattr(d, "kind", None), "value", "")
|
|
84
|
+
if str(kind).upper() in self.stop_classes:
|
|
85
|
+
lane = getattr(d, "lane", "") or "-"
|
|
86
|
+
text = getattr(d, "reason_text", "") or kind
|
|
87
|
+
return StopVerdict.stop(
|
|
88
|
+
f"a {kind} decision is pending (lane {lane!r}): {text[:120]}. "
|
|
89
|
+
f"Host policy halts the loop on this class — resolve it "
|
|
90
|
+
f"(`dos decisions` / F9) before relaunching.",
|
|
91
|
+
cause_key=f"stop_on_{str(kind).lower()}",
|
|
92
|
+
evidence=(f"{kind} lane={lane}",
|
|
93
|
+
f"stop_classes={sorted(self.stop_classes)}"),
|
|
94
|
+
)
|
|
95
|
+
return StopVerdict.defer(
|
|
96
|
+
f"no pending decision in the halt classes {sorted(self.stop_classes)} "
|
|
97
|
+
f"({len(rows)} HUMAN decision(s) pending, all evidence-only)."
|
|
98
|
+
)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""dos.drivers.export_file — the append-to-a-file occupant of `dos.exporter` (docs/266).
|
|
2
|
+
|
|
3
|
+
The first connector behind the verdict exporter, and the KEYSTONE one. Where the
|
|
4
|
+
kernel seam (`dos.exporter`) is transport-agnostic and names no transport, THIS is
|
|
5
|
+
where "append to a path" is allowed to be code — and it names no SPECIFIC vendor: it
|
|
6
|
+
re-emits each `VerdictEvent` as one JSONL line to an operator-chosen path, so the one
|
|
7
|
+
driver reaches *most* of the observability market for free. A JSONL stream at a path is
|
|
8
|
+
the universal adapter — Datadog's agent, Vector, Fluent Bit, Promtail, Splunk's
|
|
9
|
+
forwarder, and `logger(1)` all tail a file. It registers through the `dos.exporters`
|
|
10
|
+
entry-point group, so `resolve_exporter("file")` finds it by name and no kernel module
|
|
11
|
+
imports it.
|
|
12
|
+
|
|
13
|
+
Why it ships in the core (no extra)
|
|
14
|
+
===================================
|
|
15
|
+
|
|
16
|
+
A file append needs only `pathlib` + `json` from the standard library. So this driver
|
|
17
|
+
adds NO dependency and ships in the core install — a `pip install dos-kernel` can
|
|
18
|
+
already drain its verdict journal to any log shipper. (The StatsD driver is also
|
|
19
|
+
stdlib; only the OTLP driver pulls a real SDK, behind the `[export-otlp]` extra.)
|
|
20
|
+
|
|
21
|
+
The shape it writes
|
|
22
|
+
===================
|
|
23
|
+
|
|
24
|
+
ONE line per event — the event's own `to_record()` JSON (schema-tagged, byte-clean
|
|
25
|
+
`detail`), the SAME line shape the verdict journal itself writes. That is deliberate:
|
|
26
|
+
the export target is just a SECOND copy of the journal lines at a path a shipper
|
|
27
|
+
already watches, so a downstream parser that already understands a DOS verdict record
|
|
28
|
+
needs no new schema. The append is `O_APPEND` + line-buffered so concurrent appenders
|
|
29
|
+
(a `--follow` drain + a fresh run) never interleave a single line.
|
|
30
|
+
|
|
31
|
+
Disciplines (inherited from the seam — the `notify_webhook` posture, verbatim)
|
|
32
|
+
==============================================================================
|
|
33
|
+
|
|
34
|
+
* **Fail-soft.** `export` returns an `ExportResult`, never raises — no path, an
|
|
35
|
+
unwritable directory, a full disk, or a non-serializable field all degrade to
|
|
36
|
+
`exported=0` with a one-line reason. (The seam's `export_safely` is the outer net;
|
|
37
|
+
this is the inner one, so even a direct `FileExporter().export(...)` is crash-free.)
|
|
38
|
+
* **Advisory only.** It reads a batch → appends lines. It mutates no DOS state, takes
|
|
39
|
+
no lease, stops no run, adjudicates nothing. It does NOT rotate or cap the file —
|
|
40
|
+
that is the log shipper's job (and docs/262 Phase 4's `[retention]` for the journal
|
|
41
|
+
itself); a file exporter writes, the shipper consumes + truncates.
|
|
42
|
+
|
|
43
|
+
Routing (the `notify_webhook.resolve_url` ladder, generalized to a path)
|
|
44
|
+
========================================================================
|
|
45
|
+
|
|
46
|
+
* **path**: explicit arg › `$DOS_EXPORT_FILE` › the workspace `.env`
|
|
47
|
+
(`<root>/.env`'s `DOS_EXPORT_FILE`). No path anywhere → a non-exported result. A
|
|
48
|
+
relative path resolves against the workspace `root` (so `--path verdicts.jsonl`
|
|
49
|
+
lands under the workspace, not the cwd of whatever drove the drain).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import json
|
|
55
|
+
import os
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
|
|
58
|
+
from dos.exporter import ExportResult, _max_seq_cursor
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_env_file(root: Path) -> dict[str, str]:
|
|
62
|
+
"""Best-effort parse of `<root>/.env` → {KEY: value}. Never raises.
|
|
63
|
+
|
|
64
|
+
The `notify_webhook._read_env_file` twin (same parser, different key)."""
|
|
65
|
+
out: dict[str, str] = {}
|
|
66
|
+
try:
|
|
67
|
+
text = (root / ".env").read_text(encoding="utf-8")
|
|
68
|
+
except OSError:
|
|
69
|
+
return out
|
|
70
|
+
for line in text.splitlines():
|
|
71
|
+
line = line.strip()
|
|
72
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
73
|
+
continue
|
|
74
|
+
k, _, v = line.partition("=")
|
|
75
|
+
out[k.strip()] = v.strip().strip('"').strip("'")
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_path(explicit: str | None, *, root: Path | None) -> str:
|
|
80
|
+
"""Export path: explicit arg › `$DOS_EXPORT_FILE` › `<root>/.env`. "" if none.
|
|
81
|
+
|
|
82
|
+
A relative result is resolved against `root` so the export lands under the workspace
|
|
83
|
+
regardless of the drain's cwd (the `SubstrateConfig.root` anchor; the package never
|
|
84
|
+
assumes it lives in the repo it serves)."""
|
|
85
|
+
raw = explicit or os.environ.get("DOS_EXPORT_FILE") or ""
|
|
86
|
+
if not raw and root is not None:
|
|
87
|
+
raw = _read_env_file(root).get("DOS_EXPORT_FILE", "")
|
|
88
|
+
if not raw:
|
|
89
|
+
return ""
|
|
90
|
+
p = Path(raw)
|
|
91
|
+
if not p.is_absolute() and root is not None:
|
|
92
|
+
p = Path(root) / p
|
|
93
|
+
return str(p)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class FileExporter:
|
|
97
|
+
"""Drain a batch of `VerdictEvent`s by appending each as one JSONL line to a path.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
path:
|
|
102
|
+
The export file; defaults to `$DOS_EXPORT_FILE` / the workspace `.env`
|
|
103
|
+
(`resolve_path`). A relative path resolves against `root`.
|
|
104
|
+
root:
|
|
105
|
+
Workspace root for `.env` + relative-path resolution (the `SubstrateConfig.root`).
|
|
106
|
+
dry_run:
|
|
107
|
+
Resolve + count + report what WOULD be written, write NOTHING.
|
|
108
|
+
|
|
109
|
+
The constructor accepts-and-ignores the export CLI's other superset kwargs
|
|
110
|
+
(`host`/`port`/`endpoint`) only by NOT declaring them — `exporter._accepted_kwargs`
|
|
111
|
+
filters the bag to the params below, so a caller can hand the same kwargs to any
|
|
112
|
+
transport without branching per driver.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
name = "file"
|
|
116
|
+
|
|
117
|
+
def __init__(self, *, path: str = "",
|
|
118
|
+
root: "os.PathLike[str] | str | None" = None,
|
|
119
|
+
dry_run: bool = False):
|
|
120
|
+
self._path_arg = path
|
|
121
|
+
self._root = Path(root) if root is not None else None
|
|
122
|
+
self._dry_run = bool(dry_run)
|
|
123
|
+
|
|
124
|
+
def export(self, events) -> ExportResult:
|
|
125
|
+
"""Append each event as a JSONL line. Returns an `ExportResult`; NEVER raises."""
|
|
126
|
+
path = resolve_path(self._path_arg, root=self._root)
|
|
127
|
+
cursor = _max_seq_cursor(events)
|
|
128
|
+
n = len(events)
|
|
129
|
+
if not path:
|
|
130
|
+
return ExportResult(
|
|
131
|
+
exported=0,
|
|
132
|
+
detail="no export path (pass --path, set $DOS_EXPORT_FILE, "
|
|
133
|
+
"or add DOS_EXPORT_FILE to the workspace .env)",
|
|
134
|
+
cursor=cursor,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if self._dry_run:
|
|
138
|
+
return ExportResult(
|
|
139
|
+
exported=0,
|
|
140
|
+
detail=f"[dry-run] would append {n} event(s) to {path}",
|
|
141
|
+
cursor=cursor,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if n == 0:
|
|
145
|
+
# Nothing to ship — a perfectly normal drain when no new events landed.
|
|
146
|
+
return ExportResult(exported=0, detail=f"no new events for {path}", cursor=cursor)
|
|
147
|
+
|
|
148
|
+
# Build all the lines first; a single non-serializable field fails the whole
|
|
149
|
+
# batch cleanly (fail-soft) rather than writing a partial file then raising.
|
|
150
|
+
try:
|
|
151
|
+
lines = [
|
|
152
|
+
json.dumps(e.to_record(), ensure_ascii=False, sort_keys=True)
|
|
153
|
+
for e in events
|
|
154
|
+
]
|
|
155
|
+
except Exception as e: # noqa: BLE001 - a bad field must not crash the drain
|
|
156
|
+
return ExportResult(
|
|
157
|
+
exported=0, detail=f"error: event not serializable: {e}", cursor=cursor)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
p = Path(path)
|
|
161
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
# O_APPEND keeps concurrent appends from interleaving a single line (the
|
|
163
|
+
# verdict_journal.record discipline); we flush so a tailing shipper sees the
|
|
164
|
+
# lines promptly. No fsync here — the journal is the durable WAL; this is a
|
|
165
|
+
# best-effort outward copy, and an fsync per drain would throttle a follow loop.
|
|
166
|
+
with open(p, "a", encoding="utf-8") as fh:
|
|
167
|
+
fh.write("\n".join(lines) + "\n")
|
|
168
|
+
fh.flush()
|
|
169
|
+
except Exception as e: # noqa: BLE001 - advisory; report, don't crash the producer
|
|
170
|
+
return ExportResult(exported=0, detail=f"error: {e}", cursor=cursor)
|
|
171
|
+
|
|
172
|
+
return ExportResult(
|
|
173
|
+
exported=n, detail=f"appended {n} event(s) to {path}", cursor=cursor)
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""dos.drivers.export_otlp — the OpenTelemetry occupant of `dos.exporter` (docs/266).
|
|
2
|
+
|
|
3
|
+
The third connector behind the verdict exporter, and the native TRACES/LOGS path. Where
|
|
4
|
+
`export_file` writes JSONL for a shipper to parse and `export_statsd` emits counters,
|
|
5
|
+
THIS driver maps each `VerdictEvent` to an OTLP **log record** — `run_id` as a correlated
|
|
6
|
+
attribute, the byte-clean `detail` counts as attributes — and ships it to an
|
|
7
|
+
OpenTelemetry collector over OTLP/HTTP. So a shop already running an OTel collector
|
|
8
|
+
(Honeycomb, Grafana Tempo/Loki, Datadog's OTLP intake, Jaeger) ingests the verdict
|
|
9
|
+
stream natively, correlated by `run_id`, without a log-tailing or StatsD hop. It
|
|
10
|
+
registers through the `dos.exporters` entry-point group, so `resolve_exporter("otlp")`
|
|
11
|
+
finds it by name and no kernel module imports it.
|
|
12
|
+
|
|
13
|
+
Why this one needs an extra (`[export-otlp]`)
|
|
14
|
+
=============================================
|
|
15
|
+
|
|
16
|
+
Unlike `file` + `statsd` (stdlib-only, in the core), OTLP needs a real SDK — the
|
|
17
|
+
`opentelemetry-sdk` + the OTLP/HTTP log exporter. Those live behind the `[export-otlp]`
|
|
18
|
+
extra (the `[mcp]` / `[notify-slack]` precedent), so `pip install dos-kernel` stays
|
|
19
|
+
near-stdlib. The SDK is imported **lazily, inside the transport's `emit`** — never at
|
|
20
|
+
module load — so:
|
|
21
|
+
|
|
22
|
+
* entry-point discovery of this driver NEVER fails when the extra is absent (the
|
|
23
|
+
`notify_slack` lazy-import discipline — importing the driver must be free);
|
|
24
|
+
* an `export` with the extra absent returns a non-exported `ExportResult` carrying the
|
|
25
|
+
install hint (`pip install dos-kernel[export-otlp]`), NEVER an ImportError.
|
|
26
|
+
|
|
27
|
+
The pure part is import-free
|
|
28
|
+
============================
|
|
29
|
+
|
|
30
|
+
`build_records(events)` turns the batch into a list of NEUTRAL record dicts (severity +
|
|
31
|
+
body + attributes) with NO OpenTelemetry import — so the mapping is golden-shape testable
|
|
32
|
+
without the SDK, and the OTLP-specific construction (LoggerProvider, LogRecord, the HTTP
|
|
33
|
+
exporter) is confined to `_OtlpTransport.emit`, which a test replaces with a fake.
|
|
34
|
+
|
|
35
|
+
Disciplines (inherited from the seam — the `export_file`/`export_statsd` posture)
|
|
36
|
+
=================================================================================
|
|
37
|
+
|
|
38
|
+
* **Fail-soft.** `export` returns an `ExportResult`, never raises — an absent extra, a
|
|
39
|
+
down collector, a bad endpoint, an SDK error all degrade to `exported=0` with a
|
|
40
|
+
one-line reason. (The seam's `export_safely` is the outer net; this is the inner one.)
|
|
41
|
+
* **Advisory only.** It reads a batch → emits records. It mutates no DOS state, takes no
|
|
42
|
+
lease, stops no run, adjudicates nothing.
|
|
43
|
+
|
|
44
|
+
Routing
|
|
45
|
+
=======
|
|
46
|
+
|
|
47
|
+
* **endpoint**: explicit arg › `$DOS_OTLP_ENDPOINT` › `$OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
48
|
+
(the OTel standard env) › `<root>/.env` › `http://localhost:4318` (the OTLP/HTTP
|
|
49
|
+
default). The `/v1/logs` path is appended by the SDK exporter if not present.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import os
|
|
55
|
+
from pathlib import Path
|
|
56
|
+
|
|
57
|
+
from dos.exporter import ExportResult, _max_seq_cursor
|
|
58
|
+
|
|
59
|
+
_DEFAULT_ENDPOINT = "http://localhost:4318"
|
|
60
|
+
_INSTRUMENTATION_SCOPE = "dos.verdict"
|
|
61
|
+
|
|
62
|
+
# Map the kernel's verdict severity onto the OTLP severity scale. Most verdict tokens are
|
|
63
|
+
# neutral status (ADVANCING/SHIPPED → INFO); the "something is wrong" tokens map to WARN,
|
|
64
|
+
# and the hardest (a run hung / poison) to ERROR. A token not listed is INFO — a verdict
|
|
65
|
+
# is a status record, not inherently an error. (Used by build_records as a NUMBER so the
|
|
66
|
+
# pure part needs no OTel SeverityNumber import; the transport maps it to the enum.)
|
|
67
|
+
_SEVERITY_WARN = {"SPINNING", "DIMINISHING", "COSTLY", "OPEN", "WARN", "DEFER",
|
|
68
|
+
"RECENTLY_ATTEMPTED", "QUIET_INCOMPLETE", "HELD", "DIVERGED"}
|
|
69
|
+
_SEVERITY_ERROR = {"STALLED", "WASTEFUL", "BLOCK", "REJECT_POISON", "UNRESUMABLE",
|
|
70
|
+
"NOT_SHIPPED"}
|
|
71
|
+
# OTLP SeverityNumber values (stable ints from the spec): INFO=9, WARN=13, ERROR=17.
|
|
72
|
+
_SEV_INFO, _SEV_WARN, _SEV_ERROR = 9, 13, 17
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _read_env_file(root: Path) -> dict[str, str]:
|
|
76
|
+
"""Best-effort parse of `<root>/.env` → {KEY: value}. Never raises."""
|
|
77
|
+
out: dict[str, str] = {}
|
|
78
|
+
try:
|
|
79
|
+
text = (root / ".env").read_text(encoding="utf-8")
|
|
80
|
+
except OSError:
|
|
81
|
+
return out
|
|
82
|
+
for line in text.splitlines():
|
|
83
|
+
line = line.strip()
|
|
84
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
85
|
+
continue
|
|
86
|
+
k, _, v = line.partition("=")
|
|
87
|
+
out[k.strip()] = v.strip().strip('"').strip("'")
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def resolve_endpoint(explicit: str | None, *, root: Path | None) -> str:
|
|
92
|
+
"""OTLP endpoint: explicit › `$DOS_OTLP_ENDPOINT` › `$OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
93
|
+
› `<root>/.env` › http://localhost:4318."""
|
|
94
|
+
if explicit:
|
|
95
|
+
return explicit
|
|
96
|
+
for env_key in ("DOS_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_ENDPOINT"):
|
|
97
|
+
v = os.environ.get(env_key)
|
|
98
|
+
if v:
|
|
99
|
+
return v
|
|
100
|
+
if root is not None:
|
|
101
|
+
v = _read_env_file(root).get("DOS_OTLP_ENDPOINT")
|
|
102
|
+
if v:
|
|
103
|
+
return v
|
|
104
|
+
return _DEFAULT_ENDPOINT
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _severity_number(verdict: str) -> int:
|
|
108
|
+
"""The OTLP severity NUMBER for a verdict token (INFO/WARN/ERROR). Import-free."""
|
|
109
|
+
v = (verdict or "").upper()
|
|
110
|
+
if v in _SEVERITY_ERROR:
|
|
111
|
+
return _SEV_ERROR
|
|
112
|
+
if v in _SEVERITY_WARN:
|
|
113
|
+
return _SEV_WARN
|
|
114
|
+
return _SEV_INFO
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def build_records(events) -> list[dict]:
|
|
118
|
+
"""A batch of `VerdictEvent`s → neutral OTLP-shaped record dicts (pure; no OTel import).
|
|
119
|
+
|
|
120
|
+
Each record carries `body` (a human line `"<syscall> <verdict>"`), `severity_number`
|
|
121
|
+
(mapped from the verdict token), and `attributes` — the structured, queryable fields a
|
|
122
|
+
trace/log backend filters on: `dos.syscall`, `dos.verdict`, `dos.run_id` (the
|
|
123
|
+
correlation key), `dos.lane`/`dos.subject` when present, and each byte-clean
|
|
124
|
+
`detail.<k>` count flattened with a `dos.detail.` prefix (never the agent's narration —
|
|
125
|
+
the docs/138 invariant the journal already enforces, carried onto the wire). The
|
|
126
|
+
transport turns each dict into an OTLP LogRecord; this stays import-free + golden
|
|
127
|
+
testable, the spine's analogue of `export_statsd.build_lines`.
|
|
128
|
+
"""
|
|
129
|
+
out: list[dict] = []
|
|
130
|
+
for e in events:
|
|
131
|
+
syscall = getattr(e, "syscall", "") or ""
|
|
132
|
+
verdict = getattr(e, "verdict", "") or ""
|
|
133
|
+
attrs: dict[str, object] = {
|
|
134
|
+
"dos.syscall": syscall,
|
|
135
|
+
"dos.verdict": verdict,
|
|
136
|
+
}
|
|
137
|
+
run_id = getattr(e, "run_id", "") or ""
|
|
138
|
+
if run_id:
|
|
139
|
+
attrs["dos.run_id"] = run_id
|
|
140
|
+
lane = getattr(e, "lane", "") or ""
|
|
141
|
+
if lane:
|
|
142
|
+
attrs["dos.lane"] = lane
|
|
143
|
+
subject = getattr(e, "subject", "") or ""
|
|
144
|
+
if subject:
|
|
145
|
+
attrs["dos.subject"] = subject
|
|
146
|
+
source = getattr(e, "source", "") or ""
|
|
147
|
+
if source:
|
|
148
|
+
attrs["dos.source"] = source
|
|
149
|
+
detail = getattr(e, "detail", None) or {}
|
|
150
|
+
if isinstance(detail, dict):
|
|
151
|
+
for k, v in detail.items():
|
|
152
|
+
# OTLP attribute values must be a scalar (or a homogeneous list); coerce
|
|
153
|
+
# anything else to its string form so a nested/odd value still rides along.
|
|
154
|
+
attrs[f"dos.detail.{k}"] = v if isinstance(v, (str, int, float, bool)) else str(v)
|
|
155
|
+
out.append({
|
|
156
|
+
"body": f"{syscall} {verdict}".strip(),
|
|
157
|
+
"severity_number": _severity_number(verdict),
|
|
158
|
+
"attributes": attrs,
|
|
159
|
+
"ts": getattr(e, "ts", "") or "",
|
|
160
|
+
})
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class _OtlpTransport:
|
|
165
|
+
"""The lazy OTLP/HTTP log emitter. The ONLY place the OpenTelemetry SDK is imported.
|
|
166
|
+
|
|
167
|
+
`emit(records, endpoint)` returns the count emitted; raises `ImportError` when the
|
|
168
|
+
`[export-otlp]` extra is absent (the driver converts that to an install hint) and any
|
|
169
|
+
other exception on a transport failure (the driver converts that to a soft error).
|
|
170
|
+
Kept behind a method so tests inject a fake with the same `emit(records, endpoint)`
|
|
171
|
+
shape instead of standing up a real collector (the `export_statsd._UdpTransport` /
|
|
172
|
+
`notify_webhook._UrllibTransport` posture).
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def emit(self, records: list[dict], endpoint: str) -> int:
|
|
176
|
+
# Lazy import — absent extra raises ImportError HERE (caught by the driver), never
|
|
177
|
+
# at module load, so discovering this driver is always free.
|
|
178
|
+
from opentelemetry._logs import SeverityNumber
|
|
179
|
+
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
|
|
180
|
+
from opentelemetry.sdk._logs import LoggerProvider, LogRecord
|
|
181
|
+
from opentelemetry.sdk._logs.export import SimpleLogRecordProcessor
|
|
182
|
+
from opentelemetry.sdk.resources import Resource
|
|
183
|
+
|
|
184
|
+
resource = Resource.create({"service.name": _INSTRUMENTATION_SCOPE})
|
|
185
|
+
provider = LoggerProvider(resource=resource)
|
|
186
|
+
exporter = OTLPLogExporter(endpoint=endpoint)
|
|
187
|
+
processor = SimpleLogRecordProcessor(exporter)
|
|
188
|
+
provider.add_log_record_processor(processor)
|
|
189
|
+
logger = provider.get_logger(_INSTRUMENTATION_SCOPE)
|
|
190
|
+
|
|
191
|
+
sent = 0
|
|
192
|
+
for rec in records:
|
|
193
|
+
logger.emit(LogRecord(
|
|
194
|
+
body=rec.get("body", ""),
|
|
195
|
+
severity_number=SeverityNumber(int(rec.get("severity_number", _SEV_INFO))),
|
|
196
|
+
attributes=dict(rec.get("attributes", {})),
|
|
197
|
+
))
|
|
198
|
+
sent += 1
|
|
199
|
+
# Flush so the records leave before the provider is torn down (a short-lived drain,
|
|
200
|
+
# unlike a long-running app's batching processor).
|
|
201
|
+
try:
|
|
202
|
+
provider.force_flush()
|
|
203
|
+
provider.shutdown()
|
|
204
|
+
except Exception: # pragma: no cover - shutdown is best-effort
|
|
205
|
+
pass
|
|
206
|
+
return sent
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class OtlpExporter:
|
|
210
|
+
"""Drain a batch of `VerdictEvent`s as OTLP log records to an OpenTelemetry collector.
|
|
211
|
+
|
|
212
|
+
Parameters
|
|
213
|
+
----------
|
|
214
|
+
endpoint:
|
|
215
|
+
OTLP/HTTP endpoint; defaults to `$DOS_OTLP_ENDPOINT` /
|
|
216
|
+
`$OTEL_EXPORTER_OTLP_ENDPOINT` / `.env` / http://localhost:4318.
|
|
217
|
+
root:
|
|
218
|
+
Workspace root for `.env` resolution (the `SubstrateConfig.root`).
|
|
219
|
+
dry_run:
|
|
220
|
+
Build the records + report, emit NOTHING (and never imports the SDK).
|
|
221
|
+
transport:
|
|
222
|
+
Inject a fake with an `emit(records, endpoint) -> int` method in tests; None uses
|
|
223
|
+
the lazy OTLP transport (which needs the `[export-otlp]` extra).
|
|
224
|
+
|
|
225
|
+
The constructor accepts-and-ignores the export CLI's `path`/`host`/`port` superset
|
|
226
|
+
kwargs by NOT declaring them — `exporter._accepted_kwargs` filters the bag to the
|
|
227
|
+
params below, so a caller hands the same kwargs to any transport without branching.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
name = "otlp"
|
|
231
|
+
|
|
232
|
+
_INSTALL_HINT = ("OTLP exporter needs the [export-otlp] extra — "
|
|
233
|
+
"`pip install dos-kernel[export-otlp]`")
|
|
234
|
+
|
|
235
|
+
def __init__(self, *, endpoint: str = "",
|
|
236
|
+
root: "os.PathLike[str] | str | None" = None,
|
|
237
|
+
dry_run: bool = False, transport=None):
|
|
238
|
+
self._endpoint_arg = endpoint
|
|
239
|
+
self._root = Path(root) if root is not None else None
|
|
240
|
+
self._dry_run = bool(dry_run)
|
|
241
|
+
self._transport = transport
|
|
242
|
+
|
|
243
|
+
def export(self, events) -> ExportResult:
|
|
244
|
+
"""Emit one OTLP log record per event. Returns an `ExportResult`; NEVER raises."""
|
|
245
|
+
cursor = _max_seq_cursor(events)
|
|
246
|
+
n = len(events)
|
|
247
|
+
endpoint = resolve_endpoint(self._endpoint_arg, root=self._root)
|
|
248
|
+
|
|
249
|
+
if n == 0:
|
|
250
|
+
return ExportResult(
|
|
251
|
+
exported=0, detail=f"no new events for {endpoint}", cursor=cursor)
|
|
252
|
+
|
|
253
|
+
records = build_records(events)
|
|
254
|
+
|
|
255
|
+
if self._dry_run:
|
|
256
|
+
return ExportResult(
|
|
257
|
+
exported=0,
|
|
258
|
+
detail=f"[dry-run] would emit {n} OTLP log record(s) to {endpoint}",
|
|
259
|
+
cursor=cursor,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
transport = self._transport if self._transport is not None else _OtlpTransport()
|
|
263
|
+
try:
|
|
264
|
+
sent = transport.emit(records, endpoint)
|
|
265
|
+
except ImportError:
|
|
266
|
+
# The extra is absent — degrade to the install hint, never an ImportError.
|
|
267
|
+
return ExportResult(exported=0, detail=self._INSTALL_HINT, cursor=cursor)
|
|
268
|
+
except Exception as e: # noqa: BLE001 - advisory; report, don't crash the producer
|
|
269
|
+
return ExportResult(exported=0, detail=f"error: {e}", cursor=cursor)
|
|
270
|
+
|
|
271
|
+
return ExportResult(
|
|
272
|
+
exported=int(sent),
|
|
273
|
+
detail=f"emitted {sent} OTLP log record(s) to {endpoint}",
|
|
274
|
+
cursor=cursor,
|
|
275
|
+
)
|