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,262 @@
|
|
|
1
|
+
"""Read-only phase history for a run, derived from real `phase_change` events.
|
|
2
|
+
|
|
3
|
+
WHY THIS EXISTS. dashboard-ui/components/loki-session-timeline.js SYNTHESIZED
|
|
4
|
+
its timeline. It took a scalar status (uptime, current phase, iteration count),
|
|
5
|
+
rotated a fixed list `['planning','building','testing','reviewing']`, and gave
|
|
6
|
+
each invented segment a randomized duration:
|
|
7
|
+
|
|
8
|
+
const duration = segmentDuration * (0.8 + Math.random() * 0.4);
|
|
9
|
+
|
|
10
|
+
An operator read plausible phase boundaries for work that never happened. The
|
|
11
|
+
runtime records the real thing -- autonomy/run.sh:6562 emits
|
|
12
|
+
|
|
13
|
+
emit_event_json "phase_change" "from=$LAST_KNOWN_PHASE" \\
|
|
14
|
+
"to=$current_phase" "iteration=$ITERATION_COUNT"
|
|
15
|
+
|
|
16
|
+
and emit_event_json (autonomy/run.sh:2435) appends to .loki/events.jsonl with a
|
|
17
|
+
UTC timestamp. So the fix is to READ the record, not to render UNKNOWN.
|
|
18
|
+
|
|
19
|
+
THE FILE IS events.jsonl, NOT metrics/trust-events.jsonl. Those are two
|
|
20
|
+
different append-only logs. api_runs.py documents the trust log and finds no
|
|
21
|
+
phase_change in it; looking there and concluding the data is absent is the
|
|
22
|
+
available wrong turn.
|
|
23
|
+
|
|
24
|
+
WHAT A phase_change EVENT CAN AND CANNOT TELL US
|
|
25
|
+
------------------------------------------------
|
|
26
|
+
Each record is {"timestamp": <utc>, "type": "phase_change",
|
|
27
|
+
"data": {"from":..., "to":..., "iteration":...}}. It marks an INSTANT, not an
|
|
28
|
+
interval -- there is no duration field anywhere. So a segment's end is the NEXT
|
|
29
|
+
event's timestamp, and the final `to` phase is still running.
|
|
30
|
+
|
|
31
|
+
Two boundaries are therefore genuinely unmeasured, and this module refuses to
|
|
32
|
+
invent either:
|
|
33
|
+
|
|
34
|
+
THE FIRST SEGMENT'S START. The emitter only fires when LAST_KNOWN_PHASE is
|
|
35
|
+
non-empty AND changed, so the run's opening phase has no recorded start.
|
|
36
|
+
status.uptime_seconds is PROCESS uptime, not phase-0 start; subtracting it
|
|
37
|
+
would be fabrication with extra steps. The first event's `from` phase is
|
|
38
|
+
reported separately as `leading_phase` with start=None, never as a segment
|
|
39
|
+
with a made-up start.
|
|
40
|
+
|
|
41
|
+
THE LAST SEGMENT'S END. The trailing phase is ongoing. Its `end` is None and
|
|
42
|
+
`ongoing` is True; the caller anchors it to `checked_at` (supplied here, as
|
|
43
|
+
api_evidence.receipts_report already does) rather than to a client clock.
|
|
44
|
+
|
|
45
|
+
SAMPLED, NOT EXHAUSTIVE. The emitter lives in a polled monitor loop, so a phase
|
|
46
|
+
shorter than one poll interval never produced an event and is absent from this
|
|
47
|
+
history. `sampled: True` says so in the envelope. Absence of a segment is not
|
|
48
|
+
evidence the phase did not occur.
|
|
49
|
+
|
|
50
|
+
PHASE NAMES ARE PASSED THROUGH VERBATIM. The runtime writes UPPERCASE values
|
|
51
|
+
(BOOTSTRAP, BUILDING, COMPLETED, FAILED -- autonomy/run.sh:6229, :25537,
|
|
52
|
+
:25645, :25653) via _advance_current_phase, and the set is open: any string a
|
|
53
|
+
caller passes to that function becomes a phase. Mapping an unrecognized name
|
|
54
|
+
onto a known label is how "BOOTSTRAP" renders as "Idle", which is the same
|
|
55
|
+
class of lie as the simulation. Normalization is the renderer's problem and it
|
|
56
|
+
must fall back to the ACTUAL string.
|
|
57
|
+
|
|
58
|
+
THE HONESTY RULE, as elsewhere in this package: an empty result carries a
|
|
59
|
+
`reason`, an unmeasured value is None and never 0, and `source` names the real
|
|
60
|
+
path read so any row can be audited. A missing events.jsonl and an unreadable
|
|
61
|
+
one produce DIFFERENT reasons.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
import json
|
|
67
|
+
import os
|
|
68
|
+
import time
|
|
69
|
+
from typing import Any, Optional
|
|
70
|
+
|
|
71
|
+
# Imported, not restated: a second copy of the freshness/UNKNOWN convention is
|
|
72
|
+
# how the honesty rule drifts (see api_runs.py's module docstring).
|
|
73
|
+
from .api_runs import UNKNOWN, _freshness, _mtime, _p
|
|
74
|
+
|
|
75
|
+
__all__ = ["phase_history", "UNKNOWN"]
|
|
76
|
+
|
|
77
|
+
_EVENTS = "events.jsonl"
|
|
78
|
+
_SOURCE_PATHS = (".loki/events.jsonl",)
|
|
79
|
+
|
|
80
|
+
# events.jsonl is append-only and unbounded. Read at most the trailing slice,
|
|
81
|
+
# matching the 10MB cap dashboard/server.py:_read_events already applies to the
|
|
82
|
+
# same file, so a long-running workspace cannot pin the dashboard's memory.
|
|
83
|
+
_MAX_BYTES = 10 * 1024 * 1024
|
|
84
|
+
|
|
85
|
+
# ponytail: a phase_change event is ~120 bytes, so the byte cap alone bounds
|
|
86
|
+
# this well below any render budget. No event-count cap on top of it.
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _iter_lines(path: str):
|
|
90
|
+
"""Trailing slice of an append-only log, newest-truncation-safe.
|
|
91
|
+
|
|
92
|
+
Seeking mid-file lands inside a record, so the first partial line after a
|
|
93
|
+
seek is discarded. Raises on an unreadable file: the caller distinguishes
|
|
94
|
+
"absent" from "could not read", and swallowing the error here would erase
|
|
95
|
+
that difference.
|
|
96
|
+
"""
|
|
97
|
+
size = os.path.getsize(path)
|
|
98
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
99
|
+
if size > _MAX_BYTES:
|
|
100
|
+
fh.seek(size - _MAX_BYTES)
|
|
101
|
+
fh.readline() # discard the partial record
|
|
102
|
+
for line in fh:
|
|
103
|
+
yield line
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_ts(raw: Any) -> Optional[float]:
|
|
107
|
+
"""UTC ISO-8601 (`date -u +%Y-%m-%dT%H:%M:%SZ`) to epoch seconds.
|
|
108
|
+
|
|
109
|
+
None on anything unparseable -- an event whose time cannot be read cannot
|
|
110
|
+
anchor a segment, and guessing a time would place a measured phase at a
|
|
111
|
+
fabricated moment.
|
|
112
|
+
"""
|
|
113
|
+
if not isinstance(raw, str) or not raw:
|
|
114
|
+
return None
|
|
115
|
+
txt = raw.strip()
|
|
116
|
+
if txt.endswith("Z"):
|
|
117
|
+
txt = txt[:-1] + "+00:00"
|
|
118
|
+
try:
|
|
119
|
+
from datetime import datetime
|
|
120
|
+
return datetime.fromisoformat(txt).timestamp()
|
|
121
|
+
except (ValueError, TypeError):
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _phase_change_events(path: str) -> list:
|
|
126
|
+
"""Every well-formed phase_change record, oldest first.
|
|
127
|
+
|
|
128
|
+
A malformed line is skipped rather than fatal: events.jsonl is appended to
|
|
129
|
+
by concurrent shell writers, so a torn final line is normal operation and
|
|
130
|
+
must not blank out the history behind it.
|
|
131
|
+
"""
|
|
132
|
+
out = []
|
|
133
|
+
for line in _iter_lines(path):
|
|
134
|
+
line = line.strip()
|
|
135
|
+
if not line or "phase_change" not in line:
|
|
136
|
+
continue # cheap prefilter; the log is mostly other event types
|
|
137
|
+
try:
|
|
138
|
+
rec = json.loads(line)
|
|
139
|
+
except (json.JSONDecodeError, ValueError):
|
|
140
|
+
continue
|
|
141
|
+
if not isinstance(rec, dict) or rec.get("type") != "phase_change":
|
|
142
|
+
continue
|
|
143
|
+
ts = _parse_ts(rec.get("timestamp"))
|
|
144
|
+
if ts is None:
|
|
145
|
+
continue
|
|
146
|
+
data = rec.get("data")
|
|
147
|
+
if not isinstance(data, dict):
|
|
148
|
+
continue
|
|
149
|
+
out.append((ts, data))
|
|
150
|
+
out.sort(key=lambda pair: pair[0])
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _iteration(data: dict) -> Optional[int]:
|
|
155
|
+
"""The iteration number, or None. Never 0 as a stand-in for absent."""
|
|
156
|
+
raw = data.get("iteration")
|
|
157
|
+
if isinstance(raw, bool):
|
|
158
|
+
return UNKNOWN
|
|
159
|
+
if isinstance(raw, int):
|
|
160
|
+
return raw
|
|
161
|
+
if isinstance(raw, str) and raw.strip().lstrip("-").isdigit():
|
|
162
|
+
return int(raw.strip())
|
|
163
|
+
return UNKNOWN
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _envelope(segments, leading, reason, freshness, checked_at, sampled=True):
|
|
167
|
+
return {
|
|
168
|
+
"segments": segments,
|
|
169
|
+
"leading_phase": leading,
|
|
170
|
+
"source": list(_SOURCE_PATHS),
|
|
171
|
+
"freshness_s": freshness,
|
|
172
|
+
"reason": reason,
|
|
173
|
+
"checked_at": checked_at,
|
|
174
|
+
"sampled": sampled,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def phase_history(loki_dir: str, now: Optional[float] = None) -> dict:
|
|
179
|
+
"""Measured phase segments for the current run.
|
|
180
|
+
|
|
181
|
+
Returns an envelope::
|
|
182
|
+
|
|
183
|
+
{"segments": [{"phase", "start", "end", "ongoing", "iteration"}, ...],
|
|
184
|
+
"leading_phase": {"phase", "start": None, "iteration"} | None,
|
|
185
|
+
"source": [...], "freshness_s": int|None, "reason": str|None,
|
|
186
|
+
"checked_at": float, "sampled": True}
|
|
187
|
+
|
|
188
|
+
`segments` is empty with a populated `reason` in every no-data case, and
|
|
189
|
+
the reasons are distinct so the UI can say WHY rather than showing one
|
|
190
|
+
blank state for four different situations.
|
|
191
|
+
"""
|
|
192
|
+
now = time.time() if now is None else now
|
|
193
|
+
path = _p(loki_dir, _EVENTS)
|
|
194
|
+
fresh = _freshness([_mtime(path)], now=now)
|
|
195
|
+
|
|
196
|
+
if not os.path.exists(path):
|
|
197
|
+
return _envelope(
|
|
198
|
+
[], None,
|
|
199
|
+
"no phase history: %s does not exist (the run has not started, or "
|
|
200
|
+
"this workspace predates event logging)" % _SOURCE_PATHS[0],
|
|
201
|
+
fresh, now)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
events = _phase_change_events(path)
|
|
205
|
+
except OSError as exc:
|
|
206
|
+
# NOT the same as "no events". A dashboard that renders an unreadable
|
|
207
|
+
# log identically to an empty one reports healthy on a broken disk.
|
|
208
|
+
return _envelope(
|
|
209
|
+
[], None,
|
|
210
|
+
"could not read phase history: %s is unreadable (%s)"
|
|
211
|
+
% (_SOURCE_PATHS[0], exc),
|
|
212
|
+
fresh, now)
|
|
213
|
+
|
|
214
|
+
if not events:
|
|
215
|
+
return _envelope(
|
|
216
|
+
[], None,
|
|
217
|
+
"phase history not recorded: %s exists but contains no "
|
|
218
|
+
"phase_change events (the run never changed phase, or it predates "
|
|
219
|
+
"phase_change emission)" % _SOURCE_PATHS[0],
|
|
220
|
+
fresh, now)
|
|
221
|
+
|
|
222
|
+
# The first event's `from` is a phase we know RAN but whose start was never
|
|
223
|
+
# emitted. Reported with start=None so a renderer can name it without
|
|
224
|
+
# drawing it on a time axis it has no coordinate for.
|
|
225
|
+
first_ts, first_data = events[0]
|
|
226
|
+
leading = None
|
|
227
|
+
from_phase = first_data.get("from")
|
|
228
|
+
if isinstance(from_phase, str) and from_phase:
|
|
229
|
+
leading = {
|
|
230
|
+
"phase": from_phase,
|
|
231
|
+
"start": UNKNOWN,
|
|
232
|
+
"end": first_ts,
|
|
233
|
+
"iteration": _iteration(first_data),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
# One segment per event: it starts where the event fired and ends where the
|
|
237
|
+
# NEXT one did. Both endpoints are measured timestamps; nothing is derived
|
|
238
|
+
# from a duration model.
|
|
239
|
+
segments = []
|
|
240
|
+
for idx, (ts, data) in enumerate(events):
|
|
241
|
+
to_phase = data.get("to")
|
|
242
|
+
if not isinstance(to_phase, str) or not to_phase:
|
|
243
|
+
continue
|
|
244
|
+
nxt = events[idx + 1][0] if idx + 1 < len(events) else None
|
|
245
|
+
segments.append({
|
|
246
|
+
"phase": to_phase,
|
|
247
|
+
"start": ts,
|
|
248
|
+
# None, not now: the run's final phase has no recorded end. The
|
|
249
|
+
# caller anchors it to checked_at and knows it did so.
|
|
250
|
+
"end": nxt,
|
|
251
|
+
"ongoing": nxt is None,
|
|
252
|
+
"iteration": _iteration(data),
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
if not segments:
|
|
256
|
+
return _envelope(
|
|
257
|
+
[], leading,
|
|
258
|
+
"phase history not recorded: %d phase_change event(s) carried no "
|
|
259
|
+
"usable 'to' phase" % len(events),
|
|
260
|
+
fresh, now)
|
|
261
|
+
|
|
262
|
+
return _envelope(segments, leading, None, fresh, now)
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Read-only "releases" view over git tags and the VERSION file.
|
|
2
|
+
|
|
3
|
+
WHY THIS EXISTS. Releases had zero API surface. Unlike the other read-only
|
|
4
|
+
domains this one has sources that DO exist in a plain checkout -- git tags
|
|
5
|
+
(780 in this repo) and VERSION -- so it needs no run to have happened first.
|
|
6
|
+
|
|
7
|
+
THE SOURCES, and what each can and cannot tell us:
|
|
8
|
+
|
|
9
|
+
VERSION the version the working tree currently claims. It is the
|
|
10
|
+
value the next release WILL carry, so it is routinely AHEAD of the
|
|
11
|
+
newest tag while unreleased work sits on the branch.
|
|
12
|
+
git tags the versions that actually shipped. Read with
|
|
13
|
+
`git for-each-ref --sort=-v:refname refs/tags`, which orders by version
|
|
14
|
+
NUMERICALLY. Not --sort=-creatordate: a hotfix cut from an old branch is
|
|
15
|
+
newest by date while being an older version, and "newest tag" must mean
|
|
16
|
+
the same thing as the side VERSION is compared against.
|
|
17
|
+
|
|
18
|
+
THE AHEAD CASE is the reason this domain is worth exposing. When VERSION is
|
|
19
|
+
ahead of every tag, NO tag is current, and every row reads is_current False.
|
|
20
|
+
The envelope therefore states `version`, `newest_tag` and `version_is_ahead`
|
|
21
|
+
directly, so a caller can tell "VERSION is ahead of the newest tag" apart from
|
|
22
|
+
"VERSION was unreadable" -- two states that produce identical rows.
|
|
23
|
+
|
|
24
|
+
Version comparison is NUMERIC, on an int tuple. A string compare puts "9.11.0"
|
|
25
|
+
BELOW "9.8.1" and would report this very repo as behind its own newest tag,
|
|
26
|
+
which is precisely backwards. Anything that does not parse to ints reads
|
|
27
|
+
version_is_ahead=None: unknown, never False.
|
|
28
|
+
|
|
29
|
+
THE HONESTY RULE, applied to what this domain actually measures. A tag with no
|
|
30
|
+
readable creation date reads date=None, never today's date. An unparseable
|
|
31
|
+
version reads version_is_ahead=None, never False. There is no cost or token
|
|
32
|
+
field here, so record_is_measured() from autonomy/lib/efficiency_cost.py --
|
|
33
|
+
the canonical predicate used by dashboard/api_runs.py -- has nothing to judge
|
|
34
|
+
and is deliberately not imported; importing it unused would only imply a
|
|
35
|
+
measurement this reader does not make.
|
|
36
|
+
|
|
37
|
+
CHANGELOG.md is deliberately NOT read. It parses cleanly (`## v9.11.0`
|
|
38
|
+
headings), but it carries no field of the row shape that git and VERSION do
|
|
39
|
+
not already supply, and `source` must list paths actually consulted rather
|
|
40
|
+
than paths considered.
|
|
41
|
+
|
|
42
|
+
Every returned envelope states `source` (the real paths read), `freshness_s`
|
|
43
|
+
(age in seconds of the newest file that actually contributed, None when
|
|
44
|
+
nothing did) and an explicit `reason` when empty -- the shape established by
|
|
45
|
+
dashboard/api_runs.py.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import os
|
|
51
|
+
import subprocess
|
|
52
|
+
import time
|
|
53
|
+
from typing import Any, Optional
|
|
54
|
+
|
|
55
|
+
__all__ = ["list_releases", "UNKNOWN"]
|
|
56
|
+
|
|
57
|
+
# What an unmeasured value reads as. Same name and meaning as api_runs.UNKNOWN.
|
|
58
|
+
UNKNOWN = None
|
|
59
|
+
|
|
60
|
+
_VERSION_FILE = "VERSION"
|
|
61
|
+
|
|
62
|
+
# Real paths read, in the style of dashboard/api_runs.py._SOURCE_PATHS.
|
|
63
|
+
_SOURCE_PATHS = ("VERSION", "git tags (refs/tags)")
|
|
64
|
+
|
|
65
|
+
# One record per tag: "<tag>\x1f<iso date>". \x1f (unit separator) cannot occur
|
|
66
|
+
# in a git refname, so it cannot be produced by a tag name containing a pipe.
|
|
67
|
+
_TAG_FORMAT = "%(refname:short)\x1f%(creatordate:iso-strict)"
|
|
68
|
+
|
|
69
|
+
_GIT_TIMEOUT_S = 20
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _mtime(path: str) -> Optional[float]:
|
|
73
|
+
try:
|
|
74
|
+
return os.path.getmtime(path)
|
|
75
|
+
except OSError:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _freshness(mtimes: list, now: Optional[float] = None) -> Optional[int]:
|
|
80
|
+
"""Age in seconds of the NEWEST file that contributed. None if none did.
|
|
81
|
+
|
|
82
|
+
Copied in behaviour from api_runs._freshness, including the clamp at 0 so
|
|
83
|
+
a file written during this call cannot report a negative age. Derived from
|
|
84
|
+
file mtimes ONLY: a tag's creation date is the age of the RELEASE, not the
|
|
85
|
+
age of this read, and using it would misreport freshness by years.
|
|
86
|
+
"""
|
|
87
|
+
real = [m for m in mtimes if m is not None]
|
|
88
|
+
if not real:
|
|
89
|
+
return None
|
|
90
|
+
return max(0, int((now if now is not None else time.time()) - max(real)))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _read_version(repo_dir: str) -> Optional[str]:
|
|
94
|
+
"""The VERSION file's value, or None when absent/empty/unreadable."""
|
|
95
|
+
try:
|
|
96
|
+
with open(os.path.join(repo_dir, _VERSION_FILE), "r", encoding="utf-8") as fh:
|
|
97
|
+
return fh.read().strip() or None
|
|
98
|
+
except OSError:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _version_tuple(value: Optional[str]) -> Optional[tuple]:
|
|
103
|
+
""""v9.11.0" -> (9, 11, 0). None when it is not a numeric dotted version.
|
|
104
|
+
|
|
105
|
+
None is the whole point: an unparseable version must make every comparison
|
|
106
|
+
that depends on it read unknown rather than silently False. Pre-release
|
|
107
|
+
suffixes ("9.1.0-rc1") do not parse to ints and so read unknown rather
|
|
108
|
+
than being ordered by a rule this module never verified.
|
|
109
|
+
"""
|
|
110
|
+
if not value:
|
|
111
|
+
return None
|
|
112
|
+
raw = value.strip().lstrip("vV")
|
|
113
|
+
parts = raw.split(".")
|
|
114
|
+
try:
|
|
115
|
+
return tuple(int(p) for p in parts)
|
|
116
|
+
except ValueError:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _git_tags(repo_dir: str, limit: int) -> tuple:
|
|
121
|
+
"""([(tag, date|None)], reason|None), newest version first.
|
|
122
|
+
|
|
123
|
+
--count applies AFTER the sort, so this stays cheap against a repo with
|
|
124
|
+
hundreds of tags. A missing git binary and a non-repo directory are
|
|
125
|
+
reported as distinct reasons, and both yield an empty list -- never a
|
|
126
|
+
fabricated one.
|
|
127
|
+
"""
|
|
128
|
+
cmd = [
|
|
129
|
+
"git", "for-each-ref",
|
|
130
|
+
"--sort=-v:refname",
|
|
131
|
+
"--count=%d" % limit,
|
|
132
|
+
"--format=" + _TAG_FORMAT,
|
|
133
|
+
"refs/tags",
|
|
134
|
+
]
|
|
135
|
+
try:
|
|
136
|
+
proc = subprocess.run(
|
|
137
|
+
cmd, cwd=repo_dir, capture_output=True, text=True,
|
|
138
|
+
timeout=_GIT_TIMEOUT_S,
|
|
139
|
+
)
|
|
140
|
+
except FileNotFoundError:
|
|
141
|
+
return [], "git executable not found on PATH"
|
|
142
|
+
except subprocess.TimeoutExpired:
|
|
143
|
+
return [], "git for-each-ref timed out after %ds" % _GIT_TIMEOUT_S
|
|
144
|
+
except OSError as exc:
|
|
145
|
+
return [], "git for-each-ref failed: %s" % (exc,)
|
|
146
|
+
|
|
147
|
+
if proc.returncode != 0:
|
|
148
|
+
detail = (proc.stderr or "").strip().splitlines()
|
|
149
|
+
return [], "git for-each-ref failed in %s: %s" % (
|
|
150
|
+
repo_dir, detail[0] if detail else "exit %d" % proc.returncode)
|
|
151
|
+
|
|
152
|
+
out = []
|
|
153
|
+
for line in proc.stdout.splitlines():
|
|
154
|
+
if not line.strip():
|
|
155
|
+
continue
|
|
156
|
+
tag, _sep, date = line.partition("\x1f")
|
|
157
|
+
if not tag:
|
|
158
|
+
continue
|
|
159
|
+
# An empty date field reads None. creatordate is the tag's own date for
|
|
160
|
+
# an annotated tag and the tagged commit's date for a lightweight one;
|
|
161
|
+
# both are real measurements. Absent is absent -- never today.
|
|
162
|
+
out.append((tag, date.strip() or UNKNOWN))
|
|
163
|
+
return out, None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def list_releases(repo_dir: str, limit: int = 20, now: Optional[float] = None) -> dict:
|
|
167
|
+
"""Releases from git tags, newest version first.
|
|
168
|
+
|
|
169
|
+
Returns an ENVELOPE, not a bare list, because the contract requires an
|
|
170
|
+
explicit reason when the result is empty and a list cannot carry one:
|
|
171
|
+
|
|
172
|
+
{"releases": [{"tag", "version", "date"|None, "is_current", "source"}],
|
|
173
|
+
"version": str|None, "newest_tag": str|None,
|
|
174
|
+
"version_is_ahead": bool|None,
|
|
175
|
+
"source": [...], "freshness_s": int|None, "reason": None|str}
|
|
176
|
+
|
|
177
|
+
version_is_ahead is True when the VERSION file names a version greater than
|
|
178
|
+
every tag -- unreleased work on the branch, which is the normal state
|
|
179
|
+
between releases. It is None (not False) whenever either side is missing or
|
|
180
|
+
unparseable, because "we could not compare" is not "it is not ahead".
|
|
181
|
+
|
|
182
|
+
is_current is a NUMERIC equality against VERSION, so exactly one tag can be
|
|
183
|
+
current, and none is current while VERSION is ahead.
|
|
184
|
+
"""
|
|
185
|
+
envelope = {
|
|
186
|
+
"releases": [],
|
|
187
|
+
"version": UNKNOWN,
|
|
188
|
+
"newest_tag": UNKNOWN,
|
|
189
|
+
"version_is_ahead": UNKNOWN,
|
|
190
|
+
"source": list(_SOURCE_PATHS),
|
|
191
|
+
"freshness_s": None,
|
|
192
|
+
"reason": None,
|
|
193
|
+
}
|
|
194
|
+
if not repo_dir or not os.path.isdir(repo_dir):
|
|
195
|
+
envelope["reason"] = "no repository directory at %s" % (repo_dir,)
|
|
196
|
+
return envelope
|
|
197
|
+
|
|
198
|
+
version = _read_version(repo_dir)
|
|
199
|
+
envelope["version"] = version if version is not None else UNKNOWN
|
|
200
|
+
envelope["freshness_s"] = _freshness(
|
|
201
|
+
[_mtime(os.path.join(repo_dir, _VERSION_FILE))], now=now)
|
|
202
|
+
|
|
203
|
+
if limit is not None and limit <= 0:
|
|
204
|
+
envelope["reason"] = "limit must be positive, got %r" % (limit,)
|
|
205
|
+
return envelope
|
|
206
|
+
|
|
207
|
+
tags, reason = _git_tags(repo_dir, limit)
|
|
208
|
+
if reason is not None:
|
|
209
|
+
envelope["reason"] = reason
|
|
210
|
+
return envelope
|
|
211
|
+
if not tags:
|
|
212
|
+
envelope["reason"] = "no tags under refs/tags in %s" % (repo_dir,)
|
|
213
|
+
return envelope
|
|
214
|
+
|
|
215
|
+
version_t = _version_tuple(version)
|
|
216
|
+
envelope["newest_tag"] = tags[0][0]
|
|
217
|
+
|
|
218
|
+
newest_t = _version_tuple(tags[0][0])
|
|
219
|
+
if version_t is not None and newest_t is not None:
|
|
220
|
+
envelope["version_is_ahead"] = version_t > newest_t
|
|
221
|
+
else:
|
|
222
|
+
# Left as None with a stated reason: one side did not parse, so the
|
|
223
|
+
# comparison was not made. Reporting False here would assert a fact.
|
|
224
|
+
envelope["reason"] = (
|
|
225
|
+
"version_is_ahead unknown: %s did not parse as a numeric version"
|
|
226
|
+
% ("VERSION (%r)" % (version,) if version_t is None
|
|
227
|
+
else "newest tag (%r)" % (tags[0][0],))
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
for tag, date in tags:
|
|
231
|
+
tag_t = _version_tuple(tag)
|
|
232
|
+
envelope["releases"].append({
|
|
233
|
+
"tag": tag,
|
|
234
|
+
"version": tag.lstrip("vV") or tag,
|
|
235
|
+
"date": date,
|
|
236
|
+
# Numeric equality. A string compare would also make "v9.10.0" and
|
|
237
|
+
# "9.1.0" behave unpredictably here, not just in the ahead check.
|
|
238
|
+
"is_current": bool(tag_t is not None and version_t is not None
|
|
239
|
+
and tag_t == version_t),
|
|
240
|
+
"source": "git tag %s" % tag,
|
|
241
|
+
})
|
|
242
|
+
return envelope
|