loki-mode 9.8.0 → 9.12.0
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.
- package/README.md +19 -14
- package/SKILL.md +3 -2
- package/VERSION +1 -1
- package/autonomy/loki +122 -1
- package/autonomy/run.sh +49 -2
- package/dashboard/__init__.py +1 -1
- package/dashboard/api_evidence.py +411 -0
- package/dashboard/api_operator.py +283 -0
- package/dashboard/api_phases.py +262 -0
- package/dashboard/api_releases.py +242 -0
- package/dashboard/api_runs.py +477 -0
- package/dashboard/api_tests.py +444 -0
- package/dashboard/api_v2.py +47 -1
- package/dashboard/server.py +54 -0
- package/dashboard/static/index.html +246 -135
- package/docs/ARCHITECTURE-OVERVIEW.md +5 -3
- package/docs/CAPABILITY-BACKLOG.md +53 -0
- package/docs/COMPARISON.md +2 -2
- package/docs/COMPETITIVE-ANALYSIS.md +1 -1
- package/docs/COMPETITIVE-SCORECARD.md +422 -0
- package/docs/DASHBOARD-9.12-EVIDENCE.md +97 -0
- package/docs/DASHBOARD-ARCHITECTURE.md +423 -0
- package/docs/DEMOS.md +21 -23
- package/docs/HANDOFF-2026-08-03.md +439 -0
- package/docs/INSTALLATION.md +17 -10
- package/docs/OUTCOME-FRONTIER.md +536 -0
- package/docs/PROMPT-ABLATION-RESULT.md +97 -0
- package/docs/TOOLS.md +800 -0
- package/docs/alternative-installations.md +2 -3
- package/docs/audit-logging.md +44 -35
- package/docs/authentication.md +13 -2
- package/docs/authorization.md +87 -81
- package/docs/git-workflow.md +6 -3
- package/docs/metrics.md +15 -16
- package/docs/network-security.md +16 -13
- package/docs/openclaw-integration.md +36 -556
- package/docs/show-hn-post.md +2 -2
- package/docs/siem-integration.md +39 -36
- package/loki-ts/dist/loki.js +18 -18
- package/mcp/__init__.py +1 -1
- package/package.json +2 -2
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/references/confidence-routing.md +18 -1
- package/references/invariant-checks.md +13 -8
- package/references/magic-rarv-integration.md +0 -1
- package/references/multi-provider.md +27 -5
- package/skills/healing.md +4 -2
- package/tools/audit-docs.py +488 -0
- package/tools/baseline-pin.py +19 -1
- package/tools/calibration-audit.py +523 -0
- package/tools/ci-gate.py +19 -1
- package/tools/cost-forecast.py +344 -0
- package/tools/cost-guard.py +19 -1
- package/tools/cost-history.py +19 -1
- package/tools/cost-per-outcome.py +394 -0
- package/tools/estimate-run.py +19 -1
- package/tools/evidence-freshness.py +307 -0
- package/tools/gate-init.py +19 -1
- package/tools/gate-report.py +19 -1
- package/tools/gate-simulate.py +570 -0
- package/tools/gate-trend.py +354 -0
- package/tools/model-advisor.py +52 -1
- package/tools/policy-load.py +19 -1
- package/tools/prompt-cost.py +363 -0
- package/tools/prompt-diff.py +448 -0
- package/tools/prompt-lint.py +448 -0
- package/tools/receipt-bundle.py +72 -2
- package/tools/receipt-diff.py +19 -1
- package/tools/receipt-find.py +19 -1
- package/tools/receipt-stats.py +380 -0
- package/tools/receipt-timeline.py +478 -0
- package/tools/receipt-verify-batch.py +291 -0
- package/tools/run-replay.py +19 -1
- package/tools/signing-status.py +19 -1
- package/tools/token-guard.py +19 -1
- package/tools/token-tax.py +375 -0
- package/tools/tool-index.py +19 -1
- package/tools/verification-tax.py +277 -0
- package/tools/verify-chain.py +361 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only evidence API: receipts (and the artifact files beside them).
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS. The dashboard has 138 routes and ZERO of them touch receipts,
|
|
5
|
+
which are the product's differentiator: there is currently no way to show the
|
|
6
|
+
evidence chain at all. This module is the data layer for that, and nothing
|
|
7
|
+
more.
|
|
8
|
+
|
|
9
|
+
WHAT IS DELIBERATELY NOT HERE. No hashing, no receipt parsing, no verdict
|
|
10
|
+
precedence. Every one of those already has exactly one definition upstream and
|
|
11
|
+
a second copy is how the two drift:
|
|
12
|
+
|
|
13
|
+
verify(), verify_integrity() autonomy/lib/proof-verify.py
|
|
14
|
+
receipt_state() tools/receipt-bundle.py (3-state classifier)
|
|
15
|
+
find_receipts() tools/receipt-bundle.py (the walker)
|
|
16
|
+
measured_cost() tools/receipt-diff.py, via receipt-bundle
|
|
17
|
+
|
|
18
|
+
This file maps their output onto a UI shape and adds ONE fact they do not
|
|
19
|
+
carry: freshness. That is the entire diff.
|
|
20
|
+
|
|
21
|
+
THE STATES ARE NOT COLLAPSED. A receipt that cannot be verified reads
|
|
22
|
+
UNVERIFIABLE with the reason that made it so. It never reads "ok", and it is
|
|
23
|
+
never dropped from the list -- an absent row is indistinguishable from a clean
|
|
24
|
+
one, which is the failure mode receipts exist to prevent.
|
|
25
|
+
|
|
26
|
+
VERIFIED every axis was checked and passed
|
|
27
|
+
FAILED an axis was checked and said no
|
|
28
|
+
UNVERIFIABLE an axis could NOT be checked here, with its reason
|
|
29
|
+
EMPTY the walk found nothing (a verdict, never a pass)
|
|
30
|
+
|
|
31
|
+
FRESHNESS IS MEASURED OR UNKNOWN, NEVER ZERO. `freshness_s` is None when
|
|
32
|
+
generated_at is absent or unparseable, and `freshness_source` says which. Zero
|
|
33
|
+
would read as "generated this instant", which is a fabricated observation.
|
|
34
|
+
Note generated_at ends in "Z", which datetime.fromisoformat rejects before
|
|
35
|
+
Python 3.11 -- handled below, since this must behave the same on both.
|
|
36
|
+
|
|
37
|
+
EVERY RESULT CARRIES ITS OWN SOURCE AND ERROR STATE. `source` names the file or
|
|
38
|
+
walk the numbers came from, `checked_at` when, and `error` is a string whenever
|
|
39
|
+
the answer could not be produced -- never an empty list standing in for a
|
|
40
|
+
failed read.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import datetime
|
|
44
|
+
import importlib.util
|
|
45
|
+
import os
|
|
46
|
+
import pathlib
|
|
47
|
+
import sys
|
|
48
|
+
|
|
49
|
+
# A stale .pyc for a hyphenated module loaded by path makes mutation probes
|
|
50
|
+
# report FALSE failures (the probe edits the source, the loader serves the old
|
|
51
|
+
# bytecode). Must be set before any loader below runs.
|
|
52
|
+
sys.dont_write_bytecode = True
|
|
53
|
+
|
|
54
|
+
_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load(name, path):
|
|
58
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
59
|
+
mod = importlib.util.module_from_spec(spec)
|
|
60
|
+
spec.loader.exec_module(mod)
|
|
61
|
+
return mod
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_pv = _load("proof_verify", _ROOT / "autonomy" / "lib" / "proof-verify.py")
|
|
65
|
+
_rb = _load("receipt_bundle", _ROOT / "tools" / "receipt-bundle.py")
|
|
66
|
+
|
|
67
|
+
verify = _pv.verify
|
|
68
|
+
receipt_state = _rb.receipt_state
|
|
69
|
+
find_receipts = _rb.find_receipts
|
|
70
|
+
find_receipts_bounded = _rb.find_receipts_bounded
|
|
71
|
+
measured_cost = _rb.measured_cost
|
|
72
|
+
|
|
73
|
+
VERIFIED = _rb.VERIFIED
|
|
74
|
+
FAILED = _rb.FAILED
|
|
75
|
+
UNVERIFIABLE = _rb.UNVERIFIABLE
|
|
76
|
+
EMPTY = _rb.EMPTY
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _now():
|
|
80
|
+
return datetime.datetime.now(datetime.timezone.utc)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _iso(dt):
|
|
84
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_generated_at(value):
|
|
88
|
+
"""(datetime | None, reason). Reason is non-empty exactly when dt is None.
|
|
89
|
+
|
|
90
|
+
fromisoformat rejects a trailing "Z" before Python 3.11, so it is swapped
|
|
91
|
+
for the explicit +00:00 offset rather than sliced away -- dropping it would
|
|
92
|
+
silently reinterpret a UTC stamp as local time and skew every freshness
|
|
93
|
+
number by the host's offset.
|
|
94
|
+
"""
|
|
95
|
+
if not isinstance(value, str) or not value:
|
|
96
|
+
return None, "receipt records no generated_at timestamp"
|
|
97
|
+
try:
|
|
98
|
+
dt = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
99
|
+
except ValueError:
|
|
100
|
+
return None, "generated_at is not an ISO-8601 timestamp: %r" % value
|
|
101
|
+
if dt.tzinfo is None:
|
|
102
|
+
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
|
103
|
+
return dt, ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _freshness(proof, now=None):
|
|
107
|
+
"""How old the receipt says it is, in seconds, or UNKNOWN with a reason.
|
|
108
|
+
|
|
109
|
+
A negative age (receipt stamped in the future) is reported as-is rather
|
|
110
|
+
than clamped to 0: clock skew is a real condition an operator needs to see,
|
|
111
|
+
and clamping would disguise it as a fresh receipt.
|
|
112
|
+
"""
|
|
113
|
+
now = now or _now()
|
|
114
|
+
generated_at = proof.get("generated_at") if isinstance(proof, dict) else None
|
|
115
|
+
dt, reason = _parse_generated_at(generated_at)
|
|
116
|
+
if dt is None:
|
|
117
|
+
return {
|
|
118
|
+
"generated_at": generated_at if isinstance(generated_at, str) else None,
|
|
119
|
+
"freshness_s": None,
|
|
120
|
+
"freshness_source": "UNKNOWN: " + reason,
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
"generated_at": generated_at,
|
|
124
|
+
"freshness_s": (now - dt).total_seconds(),
|
|
125
|
+
"freshness_source": "receipt generated_at vs wall clock at read time",
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _cost_usd(proof):
|
|
130
|
+
"""Measured cost, or None. None means UNMEASURED, not free.
|
|
131
|
+
|
|
132
|
+
`is not None`, never truthiness: a genuinely measured $0.00 is a real
|
|
133
|
+
observation and must survive as 0.0.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
cost = measured_cost(proof)
|
|
137
|
+
except Exception:
|
|
138
|
+
return None
|
|
139
|
+
if cost is None:
|
|
140
|
+
return None
|
|
141
|
+
return cost.get("cost_usd")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def list_receipts(workspace, repo_dir=".", max_entries=None,
|
|
145
|
+
max_seconds=None):
|
|
146
|
+
"""Every receipt under `workspace`, classified. Pure: no writes, no network.
|
|
147
|
+
|
|
148
|
+
Returns a list of rows, one per receipt found, each carrying:
|
|
149
|
+
path, verdict, hash_ok, cost_usd|None, measured, generated_at,
|
|
150
|
+
freshness_s|None, freshness_source, reason
|
|
151
|
+
|
|
152
|
+
`measured` is about COST specifically -- whether cost_usd is a real
|
|
153
|
+
observation -- and is independent of the verdict. A FAILED receipt can
|
|
154
|
+
still have measured its own spend, and hiding that would understate what a
|
|
155
|
+
bad run cost.
|
|
156
|
+
|
|
157
|
+
A row whose receipt will not load is kept with verdict UNVERIFIABLE and the
|
|
158
|
+
loader's own reason. It is never dropped: a missing row and a clean row
|
|
159
|
+
look identical to a reader.
|
|
160
|
+
|
|
161
|
+
ponytail: O(N) receipts x several git subprocesses each, over an unbounded
|
|
162
|
+
rglob, and integrity is computed twice per receipt (verify() does it
|
|
163
|
+
internally, then verify_integrity() again for hash_ok). Both are deliberate:
|
|
164
|
+
the alternative is re-deriving the verdict precedence ladder here, which is
|
|
165
|
+
exactly the drift this module exists to avoid. Paginate, or cache by
|
|
166
|
+
(path, mtime), if a workspace outgrows an interactive request.
|
|
167
|
+
"""
|
|
168
|
+
root = pathlib.Path(workspace)
|
|
169
|
+
if not root.is_dir():
|
|
170
|
+
return []
|
|
171
|
+
paths, _truncated = find_receipts_bounded(root, max_entries=max_entries,
|
|
172
|
+
max_seconds=max_seconds)
|
|
173
|
+
return _rows_for_paths(paths, repo_dir)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _rows_for_paths(paths, repo_dir="."):
|
|
177
|
+
"""Classify an ALREADY-WALKED list of receipt paths.
|
|
178
|
+
|
|
179
|
+
Split out from list_receipts so receipts_report can do the walk itself and
|
|
180
|
+
keep the truncation reason as a local. Sharing this loop means the two
|
|
181
|
+
entry points cannot drift into classifying the same receipt differently.
|
|
182
|
+
"""
|
|
183
|
+
rows = []
|
|
184
|
+
now = _now()
|
|
185
|
+
for path in paths:
|
|
186
|
+
state, reason = receipt_state(path, repo_dir)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
proof = _pv._load_proof(str(path))
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
# Classified but unreadable: keep the row, say why, and leave every
|
|
192
|
+
# derived field UNKNOWN rather than defaulting it to a number.
|
|
193
|
+
rows.append({
|
|
194
|
+
"path": str(path),
|
|
195
|
+
"verdict": state,
|
|
196
|
+
"hash_ok": None,
|
|
197
|
+
"cost_usd": None,
|
|
198
|
+
"measured": False,
|
|
199
|
+
"generated_at": None,
|
|
200
|
+
"freshness_s": None,
|
|
201
|
+
"freshness_source": "UNKNOWN: receipt could not be read: %s" % exc,
|
|
202
|
+
"reason": reason or str(exc),
|
|
203
|
+
})
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
# hash_ok is the integrity axis alone and is git-independent, so it
|
|
207
|
+
# stays meaningful even where the repo checks cannot run.
|
|
208
|
+
try:
|
|
209
|
+
hash_ok = bool(_pv.verify_integrity(proof).get("hash_ok"))
|
|
210
|
+
except Exception:
|
|
211
|
+
hash_ok = None
|
|
212
|
+
|
|
213
|
+
cost = _cost_usd(proof)
|
|
214
|
+
row = {
|
|
215
|
+
"path": str(path),
|
|
216
|
+
"verdict": state,
|
|
217
|
+
"hash_ok": hash_ok,
|
|
218
|
+
"cost_usd": cost,
|
|
219
|
+
"measured": cost is not None,
|
|
220
|
+
"reason": reason,
|
|
221
|
+
}
|
|
222
|
+
row.update(_freshness(proof, now))
|
|
223
|
+
rows.append(row)
|
|
224
|
+
|
|
225
|
+
return rows
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def receipts_report(workspace, repo_dir=".", max_entries=None,
|
|
229
|
+
max_seconds=None):
|
|
230
|
+
"""list_receipts plus the batch verdict, source, and error state.
|
|
231
|
+
|
|
232
|
+
The verdict is the WEAKEST state present (imported, never restated), and an
|
|
233
|
+
empty walk is EMPTY -- its own verdict, not a vacuous pass. "We found
|
|
234
|
+
nothing wrong" is not a claim anyone is entitled to make about a tree they
|
|
235
|
+
never opened.
|
|
236
|
+
"""
|
|
237
|
+
root = pathlib.Path(workspace)
|
|
238
|
+
checked_at = _iso(_now())
|
|
239
|
+
base = {
|
|
240
|
+
"report": "loki-dashboard-receipts/v1",
|
|
241
|
+
"source": "walk of proof.json under %s, re-verified from %s" % (
|
|
242
|
+
os.path.abspath(str(workspace)), os.path.abspath(repo_dir)),
|
|
243
|
+
"checked_at": checked_at,
|
|
244
|
+
"freshness_source": "computed at read time; no cache",
|
|
245
|
+
"receipts": [],
|
|
246
|
+
"count": 0,
|
|
247
|
+
# Seeded here, not only on the success path. A consumer must be able to
|
|
248
|
+
# read the same keys on BOTH paths; a counts block that exists only when
|
|
249
|
+
# the walk succeeded makes report["counts"][FAILED] raise KeyError
|
|
250
|
+
# exactly when something went wrong, which is when it is needed most.
|
|
251
|
+
"counts": {FAILED: 0, UNVERIFIABLE: 0, VERIFIED: 0},
|
|
252
|
+
"verdict": EMPTY,
|
|
253
|
+
# Seeded here, not only on the success path, for the same reason
|
|
254
|
+
# `counts` is: a consumer reading report["truncated"] must not hit a
|
|
255
|
+
# KeyError on the branch where the walk could not run at all.
|
|
256
|
+
"truncated": None,
|
|
257
|
+
"error": None,
|
|
258
|
+
}
|
|
259
|
+
if not root.is_dir():
|
|
260
|
+
base["error"] = "workspace not found or not a directory: %s" % workspace
|
|
261
|
+
return base
|
|
262
|
+
|
|
263
|
+
# The walk is done HERE, not inside list_receipts, so the truncation
|
|
264
|
+
# reason is a plain local rather than shared mutable state. Stashing it on
|
|
265
|
+
# the function object would race between concurrent requests and could
|
|
266
|
+
# report one caller's complete walk as another's partial one.
|
|
267
|
+
_paths, truncated = find_receipts_bounded(root, max_entries=max_entries,
|
|
268
|
+
max_seconds=max_seconds)
|
|
269
|
+
rows = _rows_for_paths(_paths, repo_dir)
|
|
270
|
+
base["truncated"] = truncated
|
|
271
|
+
base["receipts"] = rows
|
|
272
|
+
base["count"] = len(rows)
|
|
273
|
+
base["verdict"] = _rb.rollup([r["verdict"] for r in rows])
|
|
274
|
+
base["counts"] = {s: sum(1 for r in rows if r["verdict"] == s)
|
|
275
|
+
for s in (FAILED, UNVERIFIABLE, VERIFIED)}
|
|
276
|
+
if truncated:
|
|
277
|
+
# A partial walk cannot certify a sequence. The verdict is held down
|
|
278
|
+
# to UNVERIFIABLE rather than reported as VERIFIED over the subset
|
|
279
|
+
# that happened to be reached before the limit.
|
|
280
|
+
if base["verdict"] == VERIFIED:
|
|
281
|
+
base["verdict"] = UNVERIFIABLE
|
|
282
|
+
base["error"] = ("the receipt walk was truncated (%s), so this audit "
|
|
283
|
+
"is PARTIAL and cannot certify the workspace"
|
|
284
|
+
% truncated)
|
|
285
|
+
elif not rows:
|
|
286
|
+
base["error"] = ("no receipts found under this workspace, so nothing "
|
|
287
|
+
"was audited. Zero receipts is not a passing audit.")
|
|
288
|
+
return base
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def receipt_detail(path, repo_dir="."):
|
|
292
|
+
"""The verifier's OWN output, verbatim, plus freshness and source.
|
|
293
|
+
|
|
294
|
+
verify()'s dict is passed through unmodified under `verification` -- not
|
|
295
|
+
reshaped, not filtered, not summarised. Any reshaping here would be a
|
|
296
|
+
second opinion about what a receipt says, and the entire point is that
|
|
297
|
+
there is one.
|
|
298
|
+
"""
|
|
299
|
+
checked_at = _iso(_now())
|
|
300
|
+
out = {
|
|
301
|
+
"report": "loki-dashboard-receipt-detail/v1",
|
|
302
|
+
"path": str(path),
|
|
303
|
+
"source": "autonomy/lib/proof-verify.py verify(), re-run at read time",
|
|
304
|
+
"checked_at": checked_at,
|
|
305
|
+
"verification": None,
|
|
306
|
+
"verdict": UNVERIFIABLE,
|
|
307
|
+
"reason": "",
|
|
308
|
+
"generated_at": None,
|
|
309
|
+
"freshness_s": None,
|
|
310
|
+
"freshness_source": "UNKNOWN: receipt not read",
|
|
311
|
+
# Same rule as counts above: every early return below must still carry
|
|
312
|
+
# these keys, and UNMEASURED is None/False -- never 0.0/True.
|
|
313
|
+
"cost_usd": None,
|
|
314
|
+
"measured": False,
|
|
315
|
+
"error": None,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
p = pathlib.Path(path)
|
|
319
|
+
if not p.is_file():
|
|
320
|
+
out["error"] = ("no such receipt: %s -- not present on disk" % path)
|
|
321
|
+
out["reason"] = out["error"]
|
|
322
|
+
out["freshness_source"] = "UNKNOWN: " + out["error"]
|
|
323
|
+
return out
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
out["verification"] = verify(str(p), repo_dir)
|
|
327
|
+
except Exception as exc:
|
|
328
|
+
out["error"] = "the receipt could not be verified: %s" % exc
|
|
329
|
+
out["reason"] = out["error"]
|
|
330
|
+
out["freshness_source"] = "UNKNOWN: " + out["error"]
|
|
331
|
+
return out
|
|
332
|
+
|
|
333
|
+
# The verdict comes from the shared classifier, NOT from verify()'s `ok`.
|
|
334
|
+
# `ok` is False both for a receipt that failed a check and for one whose
|
|
335
|
+
# checks could not run, and collapsing those two is the distinction this
|
|
336
|
+
# whole surface exists to preserve.
|
|
337
|
+
state, reason = receipt_state(str(p), repo_dir)
|
|
338
|
+
out["verdict"] = state
|
|
339
|
+
out["reason"] = reason
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
proof = _pv._load_proof(str(p))
|
|
343
|
+
except Exception as exc:
|
|
344
|
+
out["freshness_source"] = "UNKNOWN: receipt could not be read: %s" % exc
|
|
345
|
+
return out
|
|
346
|
+
|
|
347
|
+
out.update(_freshness(proof))
|
|
348
|
+
out["cost_usd"] = _cost_usd(proof)
|
|
349
|
+
out["measured"] = out["cost_usd"] is not None
|
|
350
|
+
return out
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def list_artifacts(workspace):
|
|
354
|
+
"""Real files under `workspace`/artifacts/, with mtime-derived freshness.
|
|
355
|
+
|
|
356
|
+
ponytail: an enumeration of what is actually on disk, nothing more. No
|
|
357
|
+
artifact taxonomy, no type inference, no parsing of contents -- there is no
|
|
358
|
+
artifact schema in this repo to be faithful to, and inventing one would be
|
|
359
|
+
fabricating structure the files do not have.
|
|
360
|
+
|
|
361
|
+
ponytail: unbounded rglob, stat per file, no pagination. Fine for the ~75
|
|
362
|
+
files this repo has; add a limit + offset if a workspace outgrows it.
|
|
363
|
+
"""
|
|
364
|
+
root = pathlib.Path(workspace) / "artifacts"
|
|
365
|
+
now = _now()
|
|
366
|
+
out = {
|
|
367
|
+
"report": "loki-dashboard-artifacts/v1",
|
|
368
|
+
"source": "directory listing of %s" % os.path.abspath(str(root)),
|
|
369
|
+
"checked_at": _iso(now),
|
|
370
|
+
"freshness_source": "filesystem mtime vs wall clock at read time",
|
|
371
|
+
"artifacts": [],
|
|
372
|
+
"count": 0,
|
|
373
|
+
"error": None,
|
|
374
|
+
}
|
|
375
|
+
if not root.is_dir():
|
|
376
|
+
out["error"] = "no artifacts directory under this workspace: %s" % root
|
|
377
|
+
return out
|
|
378
|
+
|
|
379
|
+
rows = []
|
|
380
|
+
for p in sorted(root.rglob("*")):
|
|
381
|
+
if not p.is_file():
|
|
382
|
+
continue
|
|
383
|
+
try:
|
|
384
|
+
st = p.stat()
|
|
385
|
+
except OSError as exc:
|
|
386
|
+
# Named and counted, never skipped -- an unreadable artifact is a
|
|
387
|
+
# fact about the tree, not an absence.
|
|
388
|
+
rows.append({
|
|
389
|
+
"path": str(p),
|
|
390
|
+
"size_bytes": None,
|
|
391
|
+
"modified_at": None,
|
|
392
|
+
"freshness_s": None,
|
|
393
|
+
"freshness_source": "UNKNOWN: could not stat: %s" % exc,
|
|
394
|
+
})
|
|
395
|
+
continue
|
|
396
|
+
mtime = datetime.datetime.fromtimestamp(
|
|
397
|
+
st.st_mtime, datetime.timezone.utc)
|
|
398
|
+
rows.append({
|
|
399
|
+
"path": str(p),
|
|
400
|
+
"size_bytes": st.st_size,
|
|
401
|
+
"modified_at": _iso(mtime),
|
|
402
|
+
"freshness_s": (now - mtime).total_seconds(),
|
|
403
|
+
"freshness_source": "filesystem mtime",
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
out["artifacts"] = rows
|
|
407
|
+
out["count"] = len(rows)
|
|
408
|
+
if not rows:
|
|
409
|
+
out["error"] = ("artifacts directory exists but contains no files, so "
|
|
410
|
+
"nothing was listed.")
|
|
411
|
+
return out
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""The operator surface: the four evidence readers, reachable over HTTP.
|
|
2
|
+
|
|
3
|
+
WHAT THIS FIXES. dashboard/api_runs.py, api_evidence.py, api_tests.py and
|
|
4
|
+
api_releases.py read real Loki lifecycle state from the filesystem, and every
|
|
5
|
+
one of them was written with a strict envelope contract: an empty result
|
|
6
|
+
carries a REASON, an unmeasured number reads None rather than 0, and the
|
|
7
|
+
sources consulted are named so any row can be audited.
|
|
8
|
+
|
|
9
|
+
None of them had an APIRouter. They were libraries that only the test suite
|
|
10
|
+
imported -- four readers, zero of them reachable by a user. Mounting them is
|
|
11
|
+
the whole point of this module.
|
|
12
|
+
|
|
13
|
+
WHY ONE ROUTER AND NOT FOUR. Four separately-mounted routers would each need
|
|
14
|
+
their own prefix decision, their own error convention and their own auth
|
|
15
|
+
story, and they would drift. One router with one convention is the thing a
|
|
16
|
+
later reader can keep correct.
|
|
17
|
+
|
|
18
|
+
WHAT THIS DELIBERATELY DOES NOT EXPOSE. There is no `/runs` LIST route here,
|
|
19
|
+
even though api_runs.list_runs is the most obviously useful function in the
|
|
20
|
+
set. api_v2 already serves GET /api/v2/runs and already falls back to this
|
|
21
|
+
exact adapter when its SQL store is empty. Adding a second list surface would
|
|
22
|
+
recreate precisely the divergence that fallback was written to avoid: two
|
|
23
|
+
endpoints answering "what runs exist" from different stores, disagreeing, with
|
|
24
|
+
no way for a caller to know which one lied. The per-run detail route below has
|
|
25
|
+
no v2 equivalent, so it adds a surface rather than forking one.
|
|
26
|
+
|
|
27
|
+
THE ENVELOPE IS PASSED THROUGH UNCHANGED. Every route returns exactly what the
|
|
28
|
+
reader returned. It is tempting to unwrap `{"runs": [...]}` into a bare list
|
|
29
|
+
for convenience, and that would silently discard `reason`, `source` and
|
|
30
|
+
`freshness_s` -- the three fields that separate "there are no runs" from "I
|
|
31
|
+
could not read the runs". A caller that cannot tell those apart will render
|
|
32
|
+
the second as the first, which is the exact failure this codebase treats as
|
|
33
|
+
worse than an error.
|
|
34
|
+
|
|
35
|
+
READ-ONLY BY CONSTRUCTION. Every route is a GET and every underlying reader
|
|
36
|
+
only opens files. Nothing here mutates a run, and nothing here shells out.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
from typing import Optional
|
|
42
|
+
|
|
43
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
router = APIRouter(prefix="/api/operator", tags=["operator"])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Auth is imported at module scope, which is correct: dashboard/auth.py
|
|
51
|
+
# imports PyJWT LAZILY (inside functions, auth.py:64 and :622), so importing
|
|
52
|
+
# auth does NOT require PyJWT to be installed. An earlier version of this file
|
|
53
|
+
# deferred the import on the theory that it did, and in doing so called the
|
|
54
|
+
# async dependency by hand and threw away the un-awaited coroutine -- the scope
|
|
55
|
+
# check silently stopped running. require_scope returns an ASYNC callable that
|
|
56
|
+
# FastAPI must inject (it takes Security(get_current_token)), so it belongs in
|
|
57
|
+
# Depends() and nowhere else.
|
|
58
|
+
from . import auth
|
|
59
|
+
|
|
60
|
+
# Every route below carries the "read" scope, matching api_v2. These endpoints
|
|
61
|
+
# expose run detail, gate results, receipt verdicts and release state -- all of
|
|
62
|
+
# it operational evidence about a real workspace, and none of it public.
|
|
63
|
+
#
|
|
64
|
+
# This is not optional politeness: tests/dashboard/test_all_data_gets_scoped.py
|
|
65
|
+
# is a regression guard for the v7.x finding that 68 of 110 GET routes carried
|
|
66
|
+
# no dependency, so an unauthenticated caller could read runs, tasks, memory and
|
|
67
|
+
# findings whenever LOKI_ENTERPRISE_AUTH was on. It caught these four routes
|
|
68
|
+
# unguarded on their first run, which is precisely what it exists to do.
|
|
69
|
+
_READ = [Depends(auth.require_scope("read"))]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _loki_dir() -> str:
|
|
73
|
+
"""The workspace this dashboard is reporting on.
|
|
74
|
+
|
|
75
|
+
Resolved per-request rather than at import time: the dashboard is started
|
|
76
|
+
from the workspace directory in some deployments and given LOKI_DIR in
|
|
77
|
+
others, and an import-time snapshot would pin whichever was true when the
|
|
78
|
+
module first loaded.
|
|
79
|
+
"""
|
|
80
|
+
return os.environ.get("LOKI_DIR") or os.path.join(os.getcwd(), ".loki")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _repo_dir() -> str:
|
|
84
|
+
return os.environ.get("LOKI_REPO_DIR") or os.getcwd()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Ceilings for the receipt walk on the HTTP path. Generous enough that a real
|
|
88
|
+
# workspace (this repo's own archive is on the order of 75 receipts) never hits
|
|
89
|
+
# them, low enough that a pathological tree cannot hold a worker open. Both are
|
|
90
|
+
# overridable so an operator with a genuinely large archive can raise them
|
|
91
|
+
# rather than silently receiving PARTIAL results forever.
|
|
92
|
+
def _int_env(name: str, default: int) -> int:
|
|
93
|
+
try:
|
|
94
|
+
v = int(os.environ.get(name, "") or default)
|
|
95
|
+
return v if v > 0 else default
|
|
96
|
+
except ValueError:
|
|
97
|
+
return default
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_RECEIPT_SCAN_MAX_ENTRIES = _int_env("LOKI_RECEIPT_SCAN_MAX_ENTRIES", 50000)
|
|
101
|
+
_RECEIPT_SCAN_MAX_SECONDS = _int_env("LOKI_RECEIPT_SCAN_MAX_SECONDS", 10)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _allowed_roots() -> list:
|
|
105
|
+
"""The only trees a caller may ask this API to walk.
|
|
106
|
+
|
|
107
|
+
LOKI_OPERATOR_ROOTS (os.pathsep-separated) when set, else the workspace and
|
|
108
|
+
the repo. Resolved to real paths so the comparison in _resolve_workspace
|
|
109
|
+
happens after symlinks are followed, not before.
|
|
110
|
+
"""
|
|
111
|
+
raw = os.environ.get("LOKI_OPERATOR_ROOTS")
|
|
112
|
+
candidates = raw.split(os.pathsep) if raw else [_loki_dir(), _repo_dir()]
|
|
113
|
+
roots = []
|
|
114
|
+
for c in candidates:
|
|
115
|
+
c = c.strip()
|
|
116
|
+
if not c:
|
|
117
|
+
continue
|
|
118
|
+
try:
|
|
119
|
+
roots.append(os.path.realpath(c))
|
|
120
|
+
except OSError:
|
|
121
|
+
continue
|
|
122
|
+
return roots
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _resolve_workspace(workspace: Optional[str]) -> str:
|
|
126
|
+
"""Confine `workspace` to an allowed root, or refuse.
|
|
127
|
+
|
|
128
|
+
WHY THIS EXISTS. receipts_report walks its argument with an UNBOUNDED
|
|
129
|
+
rglob (api_evidence.py:346) and stats every entry. Passed a caller-supplied
|
|
130
|
+
path this is a filesystem-traversal primitive: `?workspace=/` walks the
|
|
131
|
+
whole disk, `../../..` climbs out of the workspace, and a symlink inside an
|
|
132
|
+
allowed root points anywhere at all. The auth dependency is not a
|
|
133
|
+
sufficient answer, because LOKI_ENTERPRISE_AUTH is off by default, so on a
|
|
134
|
+
default deployment this route is reachable unauthenticated.
|
|
135
|
+
|
|
136
|
+
The check is done on the REALPATH of both sides. Comparing the raw string
|
|
137
|
+
would be defeated by `..` segments and by a symlink whose name sits happily
|
|
138
|
+
inside an allowed root while its target does not.
|
|
139
|
+
|
|
140
|
+
Containment is tested by prefix on a path with a trailing separator:
|
|
141
|
+
plain startswith would let "/workspaces-evil" pass as inside
|
|
142
|
+
"/workspaces".
|
|
143
|
+
"""
|
|
144
|
+
if workspace is None:
|
|
145
|
+
return _loki_dir()
|
|
146
|
+
try:
|
|
147
|
+
target = os.path.realpath(workspace)
|
|
148
|
+
except OSError as exc:
|
|
149
|
+
raise HTTPException(status_code=400,
|
|
150
|
+
detail="workspace is not a usable path: %s" % exc)
|
|
151
|
+
for root in _allowed_roots():
|
|
152
|
+
if target == root or target.startswith(root.rstrip(os.sep) + os.sep):
|
|
153
|
+
return target
|
|
154
|
+
# The refusal names the parameter but NOT the allowed roots: echoing them
|
|
155
|
+
# back turns a rejection into a filesystem-layout oracle.
|
|
156
|
+
raise HTTPException(
|
|
157
|
+
status_code=403,
|
|
158
|
+
detail="workspace is outside the configured operator roots")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _fail(what: str, exc: Exception):
|
|
162
|
+
"""A reader that raised is a 503, never an empty 200.
|
|
163
|
+
|
|
164
|
+
This is the honesty rule at the transport layer. Returning `{"runs": []}`
|
|
165
|
+
when the reader threw would be indistinguishable from a healthy empty
|
|
166
|
+
workspace, and the dashboard would render "no runs" over a broken disk.
|
|
167
|
+
"""
|
|
168
|
+
logger.warning("operator reader %s failed: %s", what, exc)
|
|
169
|
+
raise HTTPException(
|
|
170
|
+
status_code=503,
|
|
171
|
+
detail="%s is unavailable: %s" % (what, exc),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@router.get("/runs/{run_id}", dependencies=_READ)
|
|
176
|
+
def operator_run_detail(run_id: str):
|
|
177
|
+
"""One run plus its per-iteration records.
|
|
178
|
+
|
|
179
|
+
Per-iteration detail exists only for the CURRENT run -- the efficiency
|
|
180
|
+
records are wiped at run start (autonomy/run.sh). For any older run the
|
|
181
|
+
reader returns an empty `iterations` with a reason saying so, which is
|
|
182
|
+
correct and must not be mistaken for a run that did no work.
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
from . import api_runs
|
|
186
|
+
return api_runs.get_run(_loki_dir(), run_id)
|
|
187
|
+
except Exception as exc:
|
|
188
|
+
_fail("run detail", exc)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@router.get("/tests", dependencies=_READ)
|
|
192
|
+
def operator_tests():
|
|
193
|
+
"""Latest gate evidence: tests, static analysis, build, coverage.
|
|
194
|
+
|
|
195
|
+
Rows carry an explicit status rather than a bare boolean, because
|
|
196
|
+
"not run" and "ran and failed" are different operator situations and a
|
|
197
|
+
boolean collapses them.
|
|
198
|
+
"""
|
|
199
|
+
try:
|
|
200
|
+
from . import api_tests
|
|
201
|
+
return api_tests.list_test_results(_loki_dir())
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
_fail("test results", exc)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@router.get("/receipts", dependencies=_READ)
|
|
207
|
+
def operator_receipts(workspace: Optional[str] = Query(default=None)):
|
|
208
|
+
"""Evidence Receipts discovered for this workspace, with a batch verdict.
|
|
209
|
+
|
|
210
|
+
Deliberately `receipts_report` and NOT `list_receipts`. The latter returns
|
|
211
|
+
a bare list, and a bare list cannot distinguish "this workspace has no
|
|
212
|
+
receipts" from "the walk failed" -- both arrive as []. The report carries
|
|
213
|
+
an explicit `verdict` (EMPTY for a tree with nothing in it, which is its
|
|
214
|
+
own state and not a pass), a `source` naming what was walked and what it
|
|
215
|
+
was re-verified against, and an `error` field.
|
|
216
|
+
|
|
217
|
+
The verdict is the WEAKEST state present, imported from the verifier
|
|
218
|
+
rather than restated here, so this route cannot drift into disagreeing
|
|
219
|
+
with `loki proof verify` about the same receipts.
|
|
220
|
+
|
|
221
|
+
The `workspace` parameter is confined to the configured operator roots by
|
|
222
|
+
_resolve_workspace before it reaches the walker. See that function for why
|
|
223
|
+
an auth dependency alone does not cover this.
|
|
224
|
+
"""
|
|
225
|
+
root = _resolve_workspace(workspace)
|
|
226
|
+
try:
|
|
227
|
+
from . import api_evidence
|
|
228
|
+
# Bounded because this walk is reached from an HTTP request. Confining
|
|
229
|
+
# the ROOT (above) limits where it walks; it does not limit how much,
|
|
230
|
+
# and a deep allowed tree is still an unbounded rglob plus a stat per
|
|
231
|
+
# entry. A truncated walk is reported honestly: receipts_report holds
|
|
232
|
+
# the verdict down to UNVERIFIABLE and states that the audit is
|
|
233
|
+
# PARTIAL, rather than certifying the subset it happened to reach.
|
|
234
|
+
return api_evidence.receipts_report(
|
|
235
|
+
root, repo_dir=_repo_dir(),
|
|
236
|
+
max_entries=_RECEIPT_SCAN_MAX_ENTRIES,
|
|
237
|
+
max_seconds=_RECEIPT_SCAN_MAX_SECONDS)
|
|
238
|
+
except HTTPException:
|
|
239
|
+
# A 403/400 from the confinement check is the ANSWER, not a failure to
|
|
240
|
+
# read. Letting it fall into _fail would relabel a refused traversal as
|
|
241
|
+
# a 503 "receipts unavailable", which reads as a broken disk and hides
|
|
242
|
+
# that someone asked for a path they may not have.
|
|
243
|
+
raise
|
|
244
|
+
except Exception as exc:
|
|
245
|
+
_fail("receipts", exc)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@router.get("/releases", dependencies=_READ)
|
|
249
|
+
def operator_releases(limit: int = Query(default=20, ge=1, le=200)):
|
|
250
|
+
"""Release history read from git tags, plus whether VERSION is ahead.
|
|
251
|
+
|
|
252
|
+
On a checkout with no tags -- which is what GitHub Actions produces --
|
|
253
|
+
the reader returns no rows, `newest_tag: None` and `version_is_ahead:
|
|
254
|
+
None`. That last field is deliberately None and not False: "I cannot
|
|
255
|
+
compare" is not "it is not ahead".
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
from . import api_releases
|
|
259
|
+
return api_releases.list_releases(_repo_dir(), limit=limit)
|
|
260
|
+
except Exception as exc:
|
|
261
|
+
_fail("releases", exc)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@router.get("/phases", dependencies=_READ)
|
|
265
|
+
def operator_phases():
|
|
266
|
+
"""Measured phase history for the current run, from real phase_change events.
|
|
267
|
+
|
|
268
|
+
Exists because the session-timeline component had no measured source and
|
|
269
|
+
SYNTHESIZED one: a fixed phase rotation with `Math.random()` durations,
|
|
270
|
+
rendered to an operator as history. The runtime does record the truth
|
|
271
|
+
(autonomy/run.sh:6562 emits phase_change to .loki/events.jsonl, NOT to
|
|
272
|
+
metrics/trust-events.jsonl); nothing exposed it.
|
|
273
|
+
|
|
274
|
+
Segment endpoints are event timestamps and nothing else. The opening
|
|
275
|
+
phase's start and the final phase's end were never emitted, so they read
|
|
276
|
+
None and are reported as such -- an unmeasured boundary must not be
|
|
277
|
+
back-computed from process uptime, which measures a different thing.
|
|
278
|
+
"""
|
|
279
|
+
try:
|
|
280
|
+
from . import api_phases
|
|
281
|
+
return api_phases.phase_history(_loki_dir())
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
_fail("phase history", exc)
|