loki-mode 11.72.5 → 11.74.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/company-seats.json +82 -0
- package/autonomy/loki +14 -4
- package/autonomy/trigger-schedule.py +414 -6
- package/completions/loki.bash +5 -0
- package/dashboard/__init__.py +1 -1
- package/docs/CLI-REFERENCE.md +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/v10/OPERATING-MODEL.md +20 -8
- package/docs/v10/RETRO-2026.md +650 -0
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +2 -2
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/tools/cockpit/SPEC.md +275 -0
- package/tools/cockpit/index.html +146 -0
- package/tools/cockpit/roi-basis.json +33 -0
- package/tools/cockpit/server.py +946 -0
- package/web-app/dist/assets/{AdminPage-Dm_5zuet.js → AdminPage-tOzodPzi.js} +1 -1
- package/web-app/dist/assets/{Avatar-BvqpB-F4.js → Avatar-gnHOTpn7.js} +1 -1
- package/web-app/dist/assets/{Badge-cA69Pepr.js → Badge-D-zPIsIE.js} +1 -1
- package/web-app/dist/assets/{Button-CDmFgrwq.js → Button-Ba3teZuX.js} +1 -1
- package/web-app/dist/assets/{CockpitPage-Iqjcnh-c.js → CockpitPage-DnB6jCFt.js} +1 -1
- package/web-app/dist/assets/{ComparePage-D8RAxAOR.js → ComparePage-Dv5VR5dI.js} +1 -1
- package/web-app/dist/assets/{ErrorBoundary-GijzDi6f.js → ErrorBoundary-D3PxG1EY.js} +1 -1
- package/web-app/dist/assets/{EvidenceReceiptPanel-Bqw8-22j.js → EvidenceReceiptPanel-CrpPxnCl.js} +1 -1
- package/web-app/dist/assets/{GitHubIssuesPanel-Bzf04hzj.js → GitHubIssuesPanel-F7xTGZKO.js} +1 -1
- package/web-app/dist/assets/{GitHubPRsPanel-DGgDjMUo.js → GitHubPRsPanel-BYXG9iC7.js} +1 -1
- package/web-app/dist/assets/{HomePage-DkYbylcL.js → HomePage-CO9c8oIn.js} +2 -2
- package/web-app/dist/assets/{LoginPage-DcKcxe5E.js → LoginPage-CmN5NyRe.js} +1 -1
- package/web-app/dist/assets/{MagicPage-DhJvyXuv.js → MagicPage-BPUMtPt8.js} +1 -1
- package/web-app/dist/assets/{MetricsPage-XuL_5vIR.js → MetricsPage-Dm3GHAsm.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-DJ_xp0Pk.js → NotFoundPage-Boe4TCcQ.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-tQud0wez.js → ProjectPage-BFc_Uj5w.js} +3 -3
- package/web-app/dist/assets/{ProjectsPage-DH0qu3Pr.js → ProjectsPage-DMQ5nIgH.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-C6awIK-D.js → SettingsPage-DDSWgjSO.js} +1 -1
- package/web-app/dist/assets/{ShowcasePage-BZmouaYY.js → ShowcasePage-C1zCzPa1.js} +1 -1
- package/web-app/dist/assets/{SystemSettingsPage-DlzkIHxb.js → SystemSettingsPage-D0CAotwF.js} +1 -1
- package/web-app/dist/assets/{TeamsPage-BE9A2S7z.js → TeamsPage-CPYm4yhm.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-C6xj7zEx.js → TemplatesPage-BdoBMm29.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-s7aXRB6h.js → TerminalOutput-CuKnS9oh.js} +1 -1
- package/web-app/dist/assets/{activity-DFWHHxU2.js → activity-Cs5LdWlh.js} +1 -1
- package/web-app/dist/assets/{bell-Bvmj6_8N.js → bell-IueQl5bE.js} +1 -1
- package/web-app/dist/assets/{bot-CccLnU-x.js → bot-Cpzd92QS.js} +1 -1
- package/web-app/dist/assets/{check-CZhlpbNc.js → check-xIB1DeBr.js} +1 -1
- package/web-app/dist/assets/{chevron-left-0jhI7cBO.js → chevron-left-B5b17KZc.js} +1 -1
- package/web-app/dist/assets/{circle-alert-D1L0kBVz.js → circle-alert-BfWbFPEh.js} +1 -1
- package/web-app/dist/assets/{clock-DGWLNKjf.js → clock-By9_QIi9.js} +1 -1
- package/web-app/dist/assets/{cloud-a1MQAGdL.js → cloud-DIoOyUKF.js} +1 -1
- package/web-app/dist/assets/{code-xml-DxVV6byE.js → code-xml-DpKRt91V.js} +1 -1
- package/web-app/dist/assets/{database-D3924vBP.js → database-pPFHh_ki.js} +1 -1
- package/web-app/dist/assets/{dollar-sign-C8x-FGop.js → dollar-sign-Cpg8N8aR.js} +1 -1
- package/web-app/dist/assets/{file-code-corner-CnlvfUOb.js → file-code-corner-B6KJxWC_.js} +1 -1
- package/web-app/dist/assets/{file-plus--tA113s3.js → file-plus-C2KqIxgl.js} +1 -1
- package/web-app/dist/assets/{globe-0CjDC5UM.js → globe-Dfp84gVc.js} +1 -1
- package/web-app/dist/assets/{hammer-CbqEYcGU.js → hammer-8Y1MYrke.js} +1 -1
- package/web-app/dist/assets/{index-CLoaV7sW.js → index-Diz9R0Ad.js} +3 -3
- package/web-app/dist/assets/{layers-9yjLnKlC.js → layers-Bl1-P7Mb.js} +1 -1
- package/web-app/dist/assets/{loader-circle-Ca13l3wn.js → loader-circle-BBLKWE-I.js} +1 -1
- package/web-app/dist/assets/{lock-B4iSBE00.js → lock-CjVUTsEb.js} +1 -1
- package/web-app/dist/assets/{package-C0yE2f0g.js → package-D80urViP.js} +1 -1
- package/web-app/dist/assets/{plus-CdRL6Oo5.js → plus-EBsGo-WE.js} +1 -1
- package/web-app/dist/assets/{refresh-cw-BlIOCuip.js → refresh-cw-Cj4D7iA7.js} +1 -1
- package/web-app/dist/assets/{rotate-ccw-CGjJ0OjA.js → rotate-ccw-D4fUvKp7.js} +1 -1
- package/web-app/dist/assets/{scroll-text-BWKkwBJS.js → scroll-text-KPjNlar2.js} +1 -1
- package/web-app/dist/assets/{server-D6NDFit1.js → server-BxSS3qLC.js} +1 -1
- package/web-app/dist/assets/{shield-alert-CA9E0ro3.js → shield-alert-kmsOLKh_.js} +1 -1
- package/web-app/dist/assets/{thumbs-up-pGSCT8yq.js → thumbs-up-BFA68HSV.js} +1 -1
- package/web-app/dist/assets/{trash-2--Vr-M4Ty.js → trash-2-DPSwmDYc.js} +1 -1
- package/web-app/dist/assets/{trending-down-Ci4vIc_h.js → trending-down-C8cVBNwP.js} +1 -1
- package/web-app/dist/assets/{trending-up-BGiZ0e9c.js → trending-up-BazAYgbt.js} +1 -1
- package/web-app/dist/assets/{upload-BAVIKX0b.js → upload-B14dm_MM.js} +1 -1
- package/web-app/dist/assets/{usePolling-COCviyJj.js → usePolling-ELsOlZfz.js} +1 -1
- package/web-app/dist/assets/{user-BASUHaOe.js → user-4bTC0Fne.js} +1 -1
- package/web-app/dist/index.html +1 -1
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CEO cockpit: a local, read-mostly view of the company (see SPEC.md).
|
|
3
|
+
|
|
4
|
+
Python 3 standard library only. Binds loopback only. The single write path is
|
|
5
|
+
POST /api/queue, which appends one row to docs/v10/FOUNDER-QUEUE.md.
|
|
6
|
+
|
|
7
|
+
Every data source degrades to an explicit UNKNOWN (or UNMEASURED) with the
|
|
8
|
+
reason in `error`; a missing value is None, never 0.
|
|
9
|
+
"""
|
|
10
|
+
import argparse
|
|
11
|
+
import fcntl
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
import shlex
|
|
17
|
+
import signal
|
|
18
|
+
import socket
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from datetime import datetime, timedelta, timezone
|
|
24
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
25
|
+
|
|
26
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
27
|
+
PANELS = ["pulse", "releases", "board", "agents", "okrs", "roi", "queue"]
|
|
28
|
+
BOARD_TOKENS = ["ready", "building", "review", "review-blocked", "approved",
|
|
29
|
+
"merged", "released", "blocked", "rejected", "parked"]
|
|
30
|
+
ACTIVE_TOKENS = ["building", "review", "review-blocked", "approved", "blocked"]
|
|
31
|
+
TIER_BUDGET_MIN = {"LOW": 15, "MEDIUM": 30, "HIGH": 60}
|
|
32
|
+
DEFAULT_TIMEOUT = 20
|
|
33
|
+
PULSE_TIMEOUT = 60
|
|
34
|
+
QUEUE_CAP = 2000
|
|
35
|
+
BODY_CAP = 8192
|
|
36
|
+
STALE_SEAT_MIN = 60
|
|
37
|
+
BASIS_STALE_DAYS = 90
|
|
38
|
+
ENV_ALLOW = ("PATH", "HOME", "USER", "LOGNAME", "LANG", "TMPDIR",
|
|
39
|
+
"NPM_CONFIG_USERCONFIG", "NPM_CONFIG_GLOBALCONFIG",
|
|
40
|
+
"GH_CONFIG_DIR", "XDG_CONFIG_HOME")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Ctx:
|
|
44
|
+
"""Resolved paths and settings; every path comes from flags or env."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, root):
|
|
47
|
+
e = os.environ.get
|
|
48
|
+
self.root = root
|
|
49
|
+
self.runtime = e("COCKPIT_RUNTIME_DIR") or os.path.join(root, ".loki", "cockpit")
|
|
50
|
+
self.board = e("BOARD_MD") or os.path.join(root, "docs/v10/BOARD.md")
|
|
51
|
+
self.queue = e("FOUNDER_QUEUE_MD") or os.path.join(root, "docs/v10/FOUNDER-QUEUE.md")
|
|
52
|
+
self.retro = e("RETRO_MD") or os.path.join(root, "docs/v10/RETRO-2026.md")
|
|
53
|
+
self.readings = e("USAGE_READINGS_TSV") or os.path.join(root, "docs/v10/usage-readings.tsv")
|
|
54
|
+
self.cost_file = e("COST_HISTORY_FILE") or os.path.join(root, ".loki/cost-history.jsonl")
|
|
55
|
+
self.status_dir = e("COCKPIT_STATUS_DIR") or os.path.join(root, ".loki/company/status")
|
|
56
|
+
self.company_dir = e("COCKPIT_COMPANY_DIR") or os.path.join(root, ".loki/company")
|
|
57
|
+
self.schedules = e("COCKPIT_SCHEDULES_JSON") or os.path.join(root, ".loki/triggers/schedules.json")
|
|
58
|
+
self.okrs = e("COCKPIT_OKRS_JSON") or os.path.join(root, ".loki/company/okrs.json")
|
|
59
|
+
self.scoreboard = e("COCKPIT_SCOREBOARD_DIR") or os.path.join(root, ".loki/company/scoreboard")
|
|
60
|
+
self.agents_dir = e("COCKPIT_AGENTS_DIR") or os.path.join(root, ".claude/agents")
|
|
61
|
+
self.basis = e("COCKPIT_ROI_BASIS") or os.path.join(HERE, "roi-basis.json")
|
|
62
|
+
self.timeout = float(e("COCKPIT_CMD_TIMEOUT") or DEFAULT_TIMEOUT)
|
|
63
|
+
self.pulse_timeout = float(e("COCKPIT_PULSE_TIMEOUT") or PULSE_TIMEOUT)
|
|
64
|
+
self.first_wait = float(e("COCKPIT_FIRST_WAIT") or 6)
|
|
65
|
+
self.npm_cmd = shlex.split(e("COCKPIT_NPM_CMD") or "npm view loki-mode time dist-tags --json")
|
|
66
|
+
self.gh_cmd = shlex.split(e("COCKPIT_GH_CMD") or (
|
|
67
|
+
"gh run list --workflow release.yml --limit 20 --json "
|
|
68
|
+
"databaseId,status,conclusion,headSha,createdAt,displayTitle,url"))
|
|
69
|
+
self.pulse_cmd = shlex.split(e("COCKPIT_PULSE_CMD") or "bash scripts/v10-pulse.sh")
|
|
70
|
+
self.usage_cmd = shlex.split(e("COCKPIT_USAGE_CMD") or
|
|
71
|
+
"%s scripts/usage-governor.py --json" % sys.executable)
|
|
72
|
+
self.cost_cmd = [sys.executable, "tools/cost-history.py", "report", "--json",
|
|
73
|
+
"--file", self.cost_file]
|
|
74
|
+
|
|
75
|
+
def now(self):
|
|
76
|
+
v = os.environ.get("COCKPIT_NOW")
|
|
77
|
+
if v:
|
|
78
|
+
return parse_iso(v) or datetime.now(timezone.utc)
|
|
79
|
+
return datetime.now(timezone.utc)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_iso(s):
|
|
83
|
+
if not isinstance(s, str):
|
|
84
|
+
return None
|
|
85
|
+
s = s.strip().replace("Z", "+00:00")
|
|
86
|
+
try:
|
|
87
|
+
d = datetime.fromisoformat(s)
|
|
88
|
+
except ValueError:
|
|
89
|
+
return None
|
|
90
|
+
if d.tzinfo is None:
|
|
91
|
+
d = d.replace(tzinfo=timezone.utc)
|
|
92
|
+
return d.astimezone(timezone.utc)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def iso(d):
|
|
96
|
+
return d.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def age_min(ctx, ts):
|
|
100
|
+
d = parse_iso(ts)
|
|
101
|
+
if d is None:
|
|
102
|
+
return None
|
|
103
|
+
return round((ctx.now() - d).total_seconds() / 60.0, 1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def child_env(ctx, extra=None):
|
|
107
|
+
env = {k: os.environ[k] for k in ENV_ALLOW if k in os.environ}
|
|
108
|
+
env["PULSE_REPO_ROOT"] = ctx.root
|
|
109
|
+
if extra:
|
|
110
|
+
env.update(extra)
|
|
111
|
+
return env
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_cmd(ctx, argv, timeout, extra_env=None):
|
|
115
|
+
"""Run argv (never a shell) with a hard timeout. Returns (rc, out, err).
|
|
116
|
+
|
|
117
|
+
rc is None when the command could not run or timed out; err says why.
|
|
118
|
+
The cockpit kills only the process group it started itself.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
p = subprocess.Popen(argv, cwd=ctx.root, env=child_env(ctx, extra_env),
|
|
122
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
|
123
|
+
stderr=subprocess.PIPE, start_new_session=True)
|
|
124
|
+
except (OSError, ValueError) as ex:
|
|
125
|
+
return None, "", "cannot run %s: %s" % (argv[0] if argv else "?", ex)
|
|
126
|
+
try:
|
|
127
|
+
out, err = p.communicate(timeout=timeout)
|
|
128
|
+
except subprocess.TimeoutExpired:
|
|
129
|
+
try:
|
|
130
|
+
os.killpg(p.pid, signal.SIGKILL)
|
|
131
|
+
except (OSError, ProcessLookupError):
|
|
132
|
+
pass
|
|
133
|
+
try:
|
|
134
|
+
p.communicate(timeout=5)
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
return None, "", "timed out after %ss: %s" % (timeout, " ".join(argv[:3]))
|
|
138
|
+
return (p.returncode, out.decode("utf-8", "replace"),
|
|
139
|
+
err.decode("utf-8", "replace").strip()[-300:])
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def panel(ctx, status, source_cmd, data=None, error=None):
|
|
143
|
+
return {"status": status, "as_of": iso(ctx.now()), "source_cmd": source_cmd,
|
|
144
|
+
"data": data if data is not None else {}, "error": error}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def unknown(ctx, source_cmd, error):
|
|
148
|
+
return panel(ctx, "UNKNOWN", source_cmd, None, error)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def read_text(path):
|
|
152
|
+
try:
|
|
153
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
154
|
+
return f.read(), None
|
|
155
|
+
except OSError as ex:
|
|
156
|
+
return None, "cannot read %s: %s" % (path, ex.strerror or ex)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def read_json(path):
|
|
160
|
+
txt, err = read_text(path)
|
|
161
|
+
if err:
|
|
162
|
+
return None, err
|
|
163
|
+
try:
|
|
164
|
+
return json.loads(txt), None
|
|
165
|
+
except ValueError as ex:
|
|
166
|
+
return None, "invalid JSON in %s: %s" % (path, ex)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def val(x):
|
|
170
|
+
return x.get("value") if isinstance(x, dict) else x
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------- pulse
|
|
174
|
+
def collect_pulse(ctx):
|
|
175
|
+
src = " ".join(ctx.pulse_cmd)
|
|
176
|
+
rc, out, err = run_cmd(ctx, ctx.pulse_cmd, ctx.pulse_timeout)
|
|
177
|
+
if rc is None:
|
|
178
|
+
return unknown(ctx, src, err)
|
|
179
|
+
metrics, violations, unknown_list, stale = {}, [], [], []
|
|
180
|
+
in_viol = False
|
|
181
|
+
for line in out.splitlines():
|
|
182
|
+
if line.startswith("=== VIOLATIONS"):
|
|
183
|
+
in_viol = True
|
|
184
|
+
continue
|
|
185
|
+
if line.startswith("==="):
|
|
186
|
+
continue
|
|
187
|
+
if in_viol and line.startswith("VIOLATION:"):
|
|
188
|
+
violations.append(line[len("VIOLATION:"):].strip())
|
|
189
|
+
continue
|
|
190
|
+
if line.startswith("UNKNOWN metrics:"):
|
|
191
|
+
unknown_list = [x.strip() for x in line.split(":", 1)[1].split(",") if x.strip()]
|
|
192
|
+
continue
|
|
193
|
+
if line.startswith("STALE"):
|
|
194
|
+
stale.append(line.strip())
|
|
195
|
+
continue
|
|
196
|
+
m = re.match(r"^([A-Za-z][^:]{0,100}): (.*)$", line)
|
|
197
|
+
if m and not in_viol and m.group(1) not in ("NEXT ACTION",):
|
|
198
|
+
metrics[m.group(1)] = m.group(2)
|
|
199
|
+
status = {0: "OK", 1: "VIOLATION", 2: "UNKNOWN"}.get(rc)
|
|
200
|
+
if status is None:
|
|
201
|
+
return unknown(ctx, src, "pulse exited %s: %s" % (rc, err))
|
|
202
|
+
data = {"exit_code": rc, "metrics": metrics, "violations": violations,
|
|
203
|
+
"unknown_metrics": unknown_list, "stale": stale}
|
|
204
|
+
error = None
|
|
205
|
+
if status == "UNKNOWN":
|
|
206
|
+
error = "pulse reported unknown metrics: " + (", ".join(unknown_list) or "unspecified")
|
|
207
|
+
return panel(ctx, status, src, data, error)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ------------------------------------------------------------- releases
|
|
211
|
+
def collect_releases(ctx):
|
|
212
|
+
src = "%s ; %s ; VERSION ; git describe" % (" ".join(ctx.npm_cmd), " ".join(ctx.gh_cmd))
|
|
213
|
+
errors, data = [], {"npm_label": "registry view (not proof a tarball is fetchable)"}
|
|
214
|
+
now = ctx.now()
|
|
215
|
+
|
|
216
|
+
rc, out, err = run_cmd(ctx, ctx.npm_cmd, ctx.timeout)
|
|
217
|
+
npm = None
|
|
218
|
+
if rc != 0:
|
|
219
|
+
errors.append("npm: " + (err or "exit %s" % rc))
|
|
220
|
+
else:
|
|
221
|
+
try:
|
|
222
|
+
npm = json.loads(out)
|
|
223
|
+
except ValueError as ex:
|
|
224
|
+
errors.append("npm: invalid JSON: %s" % ex)
|
|
225
|
+
if isinstance(npm, dict):
|
|
226
|
+
tags = npm.get("dist-tags") or {}
|
|
227
|
+
times = npm.get("time") or {}
|
|
228
|
+
vers = []
|
|
229
|
+
for v, t in times.items():
|
|
230
|
+
d = parse_iso(t) if v not in ("created", "modified") else None
|
|
231
|
+
if d:
|
|
232
|
+
vers.append((d, v))
|
|
233
|
+
vers.sort()
|
|
234
|
+
last = vers[-20:]
|
|
235
|
+
data["latest"], data["next"] = tags.get("latest"), tags.get("next")
|
|
236
|
+
data["versions"] = [{"version": v, "published": iso(d)} for d, v in reversed(last)]
|
|
237
|
+
if vers:
|
|
238
|
+
data["last_publish_minutes_ago"] = round((now - vers[-1][0]).total_seconds() / 60.0, 1)
|
|
239
|
+
data["releases_last_hour"] = sum(1 for d, _ in vers if now - d <= timedelta(hours=1))
|
|
240
|
+
data["releases_last_24h"] = sum(1 for d, _ in vers if now - d <= timedelta(hours=24))
|
|
241
|
+
elif npm is not None:
|
|
242
|
+
errors.append("npm: unexpected JSON shape")
|
|
243
|
+
|
|
244
|
+
rc, out, err = run_cmd(ctx, ctx.gh_cmd, ctx.timeout)
|
|
245
|
+
runs = None
|
|
246
|
+
if rc != 0:
|
|
247
|
+
errors.append("gh: " + (err or "exit %s" % rc))
|
|
248
|
+
else:
|
|
249
|
+
try:
|
|
250
|
+
runs = json.loads(out)
|
|
251
|
+
except ValueError as ex:
|
|
252
|
+
errors.append("gh: invalid JSON: %s" % ex)
|
|
253
|
+
if isinstance(runs, list):
|
|
254
|
+
data["runs"] = [{k: r.get(k) for k in ("databaseId", "status", "conclusion", "headSha",
|
|
255
|
+
"createdAt", "displayTitle", "url")}
|
|
256
|
+
for r in runs if isinstance(r, dict)]
|
|
257
|
+
elif runs is not None:
|
|
258
|
+
errors.append("gh: unexpected JSON shape")
|
|
259
|
+
|
|
260
|
+
vtxt, verr = read_text(os.path.join(ctx.root, "VERSION"))
|
|
261
|
+
data["version_file"] = vtxt.strip() if vtxt is not None else None
|
|
262
|
+
if verr:
|
|
263
|
+
errors.append("VERSION: " + verr)
|
|
264
|
+
rc, out, _ = run_cmd(ctx, ["git", "-C", ctx.root, "describe", "--tags", "--abbrev=0"], ctx.timeout)
|
|
265
|
+
data["last_tag"] = out.strip() if rc == 0 and out.strip() else None
|
|
266
|
+
rc, out, _ = run_cmd(ctx, ["git", "-C", ctx.root, "log", "-1", "--format=%H", "--", "VERSION"], ctx.timeout)
|
|
267
|
+
vsha = out.strip() if rc == 0 else ""
|
|
268
|
+
data["version_sha"] = vsha or None
|
|
269
|
+
|
|
270
|
+
flags, violation = [], False
|
|
271
|
+
if data.get("version_file") and data.get("next") and data["version_file"] != data["next"]:
|
|
272
|
+
flags.append("VERSION %s differs from npm next %s" % (data["version_file"], data["next"]))
|
|
273
|
+
if data.get("next") and data.get("latest") and data["next"] != data["latest"]:
|
|
274
|
+
flags.append("npm next %s differs from latest %s" % (data["next"], data["latest"]))
|
|
275
|
+
if vsha and isinstance(data.get("runs"), list):
|
|
276
|
+
for r in data["runs"]:
|
|
277
|
+
if (r.get("headSha") or "").startswith(vsha[:12]) and r.get("conclusion") == "failure":
|
|
278
|
+
violation = True
|
|
279
|
+
flags.append("release run %s failed on the newest VERSION SHA %s" % (r.get("databaseId"), vsha[:8]))
|
|
280
|
+
break
|
|
281
|
+
data["flags"] = flags
|
|
282
|
+
if violation:
|
|
283
|
+
return panel(ctx, "VIOLATION", src, data, "; ".join(errors) or None)
|
|
284
|
+
if errors:
|
|
285
|
+
return panel(ctx, "UNKNOWN", src, data, "; ".join(errors))
|
|
286
|
+
return panel(ctx, "OK", src, data, None)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------- board
|
|
290
|
+
def split_row(line):
|
|
291
|
+
return [c.strip() for c in line.strip().strip("|").split("|")]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def parse_board(text):
|
|
295
|
+
rows, unparsed = [], []
|
|
296
|
+
for line in text.splitlines():
|
|
297
|
+
if not line.startswith("|"):
|
|
298
|
+
continue
|
|
299
|
+
cells = split_row(line)
|
|
300
|
+
if not cells or cells[0] in ("ID", "") or set(cells[0]) <= set("-: "):
|
|
301
|
+
continue
|
|
302
|
+
status_cell = cells[5] if len(cells) >= 7 else ""
|
|
303
|
+
m = re.match(r"^([a-z-]+)@(\d{4}-\d\d-\d\dT\d\d:\d\dZ)$", status_cell)
|
|
304
|
+
if len(cells) >= 7 and m and m.group(1) in BOARD_TOKENS:
|
|
305
|
+
rows.append({"id": cells[0], "owner": cells[1], "tier": cells[4].upper(),
|
|
306
|
+
"token": m.group(1), "since": m.group(2),
|
|
307
|
+
"notes": "|".join(cells[6:])[:300]})
|
|
308
|
+
else:
|
|
309
|
+
unparsed.append(cells[0])
|
|
310
|
+
return rows, unparsed
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def collect_board(ctx):
|
|
314
|
+
src = "read " + ctx.board
|
|
315
|
+
text, err = read_text(ctx.board)
|
|
316
|
+
if err:
|
|
317
|
+
return unknown(ctx, src, err)
|
|
318
|
+
rows, unparsed = parse_board(text)
|
|
319
|
+
counts = {t: 0 for t in BOARD_TOKENS}
|
|
320
|
+
for r in rows:
|
|
321
|
+
counts[r["token"]] += 1
|
|
322
|
+
active = []
|
|
323
|
+
for r in rows:
|
|
324
|
+
if r["token"] in ACTIVE_TOKENS:
|
|
325
|
+
a = age_min(ctx, r["since"])
|
|
326
|
+
budget = TIER_BUDGET_MIN.get(r["tier"])
|
|
327
|
+
active.append({**r, "age_min": a, "budget_min": budget,
|
|
328
|
+
"over_budget": (a is not None and budget is not None and a > budget)})
|
|
329
|
+
data = {"counts": counts, "active": active, "unparsed": unparsed, "rows_parsed": len(rows),
|
|
330
|
+
"ready_below_30": counts["ready"] < 30, "ready_target": 30}
|
|
331
|
+
if not rows:
|
|
332
|
+
return unknown(ctx, src, "no BOARD rows parsed from " + ctx.board)
|
|
333
|
+
return panel(ctx, "OK", src, data, None)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# --------------------------------------------------------------- agents
|
|
337
|
+
def agent_slugs(ctx):
|
|
338
|
+
try:
|
|
339
|
+
return sorted(f[:-3] for f in os.listdir(ctx.agents_dir) if f.endswith(".md")), None
|
|
340
|
+
except OSError as ex:
|
|
341
|
+
return [], "cannot list %s: %s" % (ctx.agents_dir, ex.strerror or ex)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def root_causes(path):
|
|
345
|
+
txt, _ = read_text(path)
|
|
346
|
+
if not txt:
|
|
347
|
+
return []
|
|
348
|
+
m = re.search(r"^## Root causes owned[^\n]*\n(.*?)(?=^## |\Z)", txt, re.S | re.M)
|
|
349
|
+
if not m:
|
|
350
|
+
return []
|
|
351
|
+
out, cur = [], None
|
|
352
|
+
for line in m.group(1).splitlines():
|
|
353
|
+
if line.startswith("- "):
|
|
354
|
+
cur = line[2:].strip()
|
|
355
|
+
out.append(cur)
|
|
356
|
+
elif cur is not None and line.startswith(" ") and line.strip():
|
|
357
|
+
out[-1] += " " + line.strip()
|
|
358
|
+
return out
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def seat_cadence(ctx):
|
|
362
|
+
"""{seat: cron_expr} for `seat <name>` entries, plus the cron helpers (shared with the daemon)."""
|
|
363
|
+
import importlib.util
|
|
364
|
+
mod_path = os.path.join(HERE, "..", "..", "autonomy", "trigger-schedule.py")
|
|
365
|
+
spec = importlib.util.spec_from_file_location("trigger_schedule_cockpit", mod_path)
|
|
366
|
+
mod = importlib.util.module_from_spec(spec)
|
|
367
|
+
try:
|
|
368
|
+
spec.loader.exec_module(mod)
|
|
369
|
+
except Exception:
|
|
370
|
+
return {}, None
|
|
371
|
+
data, _ = read_json(ctx.schedules)
|
|
372
|
+
rows = data if isinstance(data, list) else (data or {}).get("schedules", []) if isinstance(data, dict) else []
|
|
373
|
+
out = {}
|
|
374
|
+
for r in rows:
|
|
375
|
+
parts = str(r.get("action", "")).split()
|
|
376
|
+
if len(parts) == 2 and parts[0] == "seat" and r.get("enabled", True):
|
|
377
|
+
out[parts[1]] = r.get("cron_expr")
|
|
378
|
+
return out, mod
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def seat_tick(ctx, slug, cadence, mod):
|
|
382
|
+
"""Last tick, outcome, next due and staleness for one seat (2x interval = STALE)."""
|
|
383
|
+
d, err = read_json(os.path.join(ctx.company_dir, slug, "last.json"))
|
|
384
|
+
t = {"last_tick": None, "outcome": None, "interval_min": None, "next_due": None,
|
|
385
|
+
"state": "NO-TICK", "age_min": None}
|
|
386
|
+
if isinstance(d, dict):
|
|
387
|
+
t["last_tick"], t["outcome"] = d.get("ts"), d.get("outcome")
|
|
388
|
+
t["age_min"] = age_min(ctx, d.get("ts"))
|
|
389
|
+
cron = cadence.get(slug)
|
|
390
|
+
if cron and mod:
|
|
391
|
+
try:
|
|
392
|
+
t["interval_min"] = mod.interval_minutes(cron)
|
|
393
|
+
local = ctx.now().astimezone()
|
|
394
|
+
nxt = mod.next_fire(cron, local.replace(tzinfo=None))
|
|
395
|
+
if nxt:
|
|
396
|
+
t["next_due"] = nxt.astimezone().astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
397
|
+
except ValueError:
|
|
398
|
+
pass
|
|
399
|
+
if t["last_tick"] is not None:
|
|
400
|
+
t["state"] = "OK"
|
|
401
|
+
if t["interval_min"] and (t["age_min"] is None or t["age_min"] > 2 * t["interval_min"]):
|
|
402
|
+
t["state"] = "STALE"
|
|
403
|
+
return t
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def collect_agents(ctx):
|
|
407
|
+
src = "read %s/<slug>.json for slugs in %s" % (ctx.status_dir, ctx.agents_dir)
|
|
408
|
+
slugs, err = agent_slugs(ctx)
|
|
409
|
+
if err or not slugs:
|
|
410
|
+
return unknown(ctx, src, err or "no agent files in " + ctx.agents_dir)
|
|
411
|
+
seats, have = [], 0
|
|
412
|
+
cadence, mod = seat_cadence(ctx)
|
|
413
|
+
for slug in slugs:
|
|
414
|
+
seat = {"slug": slug, "state": "UNKNOWN", "root_causes":
|
|
415
|
+
root_causes(os.path.join(ctx.agents_dir, slug + ".md"))}
|
|
416
|
+
seat["tick"] = tick = seat_tick(ctx, slug, cadence, mod)
|
|
417
|
+
d, e = read_json(os.path.join(ctx.status_dir, slug + ".json"))
|
|
418
|
+
if e or not isinstance(d, dict):
|
|
419
|
+
seat["error"] = e or "status file is not an object"
|
|
420
|
+
seat["state"] = "UNKNOWN"
|
|
421
|
+
else:
|
|
422
|
+
have += 1
|
|
423
|
+
for k in ("seat", "model", "doing", "slice", "since", "next", "blocked_on", "updated"):
|
|
424
|
+
seat[k] = d.get(k)
|
|
425
|
+
a = age_min(ctx, d.get("updated"))
|
|
426
|
+
seat["updated_min_ago"] = a
|
|
427
|
+
seat["state"] = "STALE" if (a is None or a > STALE_SEAT_MIN) else "OK"
|
|
428
|
+
seat["blocked"] = bool(d.get("blocked_on"))
|
|
429
|
+
if tick["state"] == "STALE":
|
|
430
|
+
seat["state"] = "STALE"
|
|
431
|
+
elif seat["state"] == "UNKNOWN" and tick["state"] == "OK":
|
|
432
|
+
seat["state"] = "OK"
|
|
433
|
+
seat.pop("error", None)
|
|
434
|
+
have += 1
|
|
435
|
+
seats.append(seat)
|
|
436
|
+
data = {"seats": seats, "reporting": have, "total": len(slugs)}
|
|
437
|
+
if have == 0:
|
|
438
|
+
return panel(ctx, "UNKNOWN", src, data, "no seat has written a status file in " + ctx.status_dir)
|
|
439
|
+
return panel(ctx, "OK", src, data, None)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ----------------------------------------------------------------- okrs
|
|
443
|
+
def parse_retro_items(text):
|
|
444
|
+
m = re.search(r"^## 6\.[^\n]*\n(.*?)(?=^## 7\.|^---\s*$|\Z)", text, re.S | re.M)
|
|
445
|
+
if not m:
|
|
446
|
+
return []
|
|
447
|
+
items = []
|
|
448
|
+
for line in m.group(1).splitlines():
|
|
449
|
+
im = re.match(r"^(\d+)\. (.*)$", line)
|
|
450
|
+
if not im:
|
|
451
|
+
continue
|
|
452
|
+
body = im.group(2)
|
|
453
|
+
title = re.split(r"(?<=[.])\s", body, maxsplit=1)[0].strip()
|
|
454
|
+
om = re.search(r"Owner:\s*(.+?)\.\s+Metric:", body) or re.search(r"Owner:\s*([^.]+)", body)
|
|
455
|
+
mm = re.search(r"Metric:\s*(.*)$", body)
|
|
456
|
+
items.append({"n": int(im.group(1)), "title": title,
|
|
457
|
+
"owner": om.group(1).strip() if om else None,
|
|
458
|
+
"metric": mm.group(1).strip() if mm else None})
|
|
459
|
+
return items
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def collect_okrs(ctx):
|
|
463
|
+
src = "read %s section 6 ; read %s" % (ctx.retro, ctx.okrs)
|
|
464
|
+
text, err = read_text(ctx.retro)
|
|
465
|
+
if err:
|
|
466
|
+
return unknown(ctx, src, err)
|
|
467
|
+
items = parse_retro_items(text)
|
|
468
|
+
if not items:
|
|
469
|
+
return unknown(ctx, src, "no numbered items parsed from section 6 of " + ctx.retro)
|
|
470
|
+
cur, cerr = read_json(ctx.okrs)
|
|
471
|
+
if not isinstance(cur, dict):
|
|
472
|
+
cur = {}
|
|
473
|
+
measured = 0
|
|
474
|
+
for it in items:
|
|
475
|
+
c = cur.get(str(it["n"]))
|
|
476
|
+
if not isinstance(c, dict) or c.get("value") is None:
|
|
477
|
+
it["state"], it["current"] = "UNMEASURED", None
|
|
478
|
+
elif not c.get("source_cmd"):
|
|
479
|
+
it["state"], it["current"] = "UNCITED", c
|
|
480
|
+
else:
|
|
481
|
+
it["state"], it["current"] = "MEASURED", c
|
|
482
|
+
measured += 1
|
|
483
|
+
data = {"items": items, "measured": measured, "total": len(items),
|
|
484
|
+
"values_note": (cerr if cerr else None)}
|
|
485
|
+
return panel(ctx, "OK" if measured else "UNMEASURED", src, data,
|
|
486
|
+
None if measured else "no OKR has a measured current value" + (": " + cerr if cerr else ""))
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ------------------------------------------------------------------ roi
|
|
490
|
+
def last_readings(path):
|
|
491
|
+
txt, err = read_text(path)
|
|
492
|
+
if err:
|
|
493
|
+
return None, err
|
|
494
|
+
rows = [l.split("\t") for l in txt.splitlines() if l.strip() and not l.startswith("utc_time")]
|
|
495
|
+
rows = [r for r in rows if len(r) >= 3]
|
|
496
|
+
if not rows:
|
|
497
|
+
return None, "no readings in " + path
|
|
498
|
+
r = rows[-1]
|
|
499
|
+
try:
|
|
500
|
+
return {"latest_utc": r[0], "window_percent": float(r[1]), "weekly_percent": float(r[2]),
|
|
501
|
+
"n": len(rows), "unit": "plan-usage percent (not tokens, not dollars)"}, None
|
|
502
|
+
except ValueError:
|
|
503
|
+
return None, "unparsable last reading in " + path
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def collect_roi(ctx):
|
|
507
|
+
src = "read %s ; %s ; %s ; read %s" % (ctx.basis, " ".join(ctx.cost_cmd),
|
|
508
|
+
" ".join(ctx.usage_cmd), ctx.readings)
|
|
509
|
+
basis, berr = read_json(ctx.basis)
|
|
510
|
+
if not isinstance(basis, dict):
|
|
511
|
+
return unknown(ctx, src, berr or "ROI basis is not an object")
|
|
512
|
+
now = ctx.now()
|
|
513
|
+
sd = parse_iso(basis.get("snapshot_date"))
|
|
514
|
+
basis_stale = sd is None or (now - sd).days > BASIS_STALE_DAYS
|
|
515
|
+
_rc, sha, _e = run_cmd(ctx, ["git", "-C", ctx.root, "log", "-1", "--format=%h", "--", ctx.basis], ctx.timeout)
|
|
516
|
+
notes = []
|
|
517
|
+
|
|
518
|
+
# cost side
|
|
519
|
+
plan = val(basis.get("plan_usd_per_month"))
|
|
520
|
+
per_day = round(plan * 12 / 365.0, 2) if isinstance(plan, (int, float)) else None
|
|
521
|
+
usage, uerr = last_readings(ctx.readings)
|
|
522
|
+
if uerr:
|
|
523
|
+
notes.append("usage readings: " + uerr)
|
|
524
|
+
gov = None
|
|
525
|
+
rc, out, err = run_cmd(ctx, ctx.usage_cmd, ctx.timeout)
|
|
526
|
+
if rc == 0:
|
|
527
|
+
try:
|
|
528
|
+
g = json.loads(out)
|
|
529
|
+
gov = {k: g.get(k) for k in ("weekly", "window") if isinstance(g, dict)}
|
|
530
|
+
except ValueError:
|
|
531
|
+
notes.append("usage governor: invalid JSON")
|
|
532
|
+
else:
|
|
533
|
+
notes.append("usage governor: " + (err or "exit %s" % rc))
|
|
534
|
+
rc, out, err = run_cmd(ctx, ctx.cost_cmd, ctx.timeout)
|
|
535
|
+
metered = {"state": "UNMEASURED", "reason": err or "no output"}
|
|
536
|
+
if out.strip():
|
|
537
|
+
try:
|
|
538
|
+
r = json.loads(out)
|
|
539
|
+
if r.get("status") == "no_history" or not r.get("measured"):
|
|
540
|
+
metered = {"state": "UNMEASURED", "reason": r.get("why") or "no measured runs"}
|
|
541
|
+
else:
|
|
542
|
+
costs = [c for c in r.get("costs", []) if isinstance(c, (int, float))]
|
|
543
|
+
metered = {"state": "MEASURED", "runs": r.get("runs"), "measured_runs": r.get("measured"),
|
|
544
|
+
"unmeasured_runs": (r.get("runs") or 0) - (r.get("measured") or 0),
|
|
545
|
+
"total_usd": round(sum(costs), 4), "median_usd": r.get("median_usd")}
|
|
546
|
+
except ValueError:
|
|
547
|
+
metered = {"state": "UNMEASURED", "reason": "cost-history output is not JSON"}
|
|
548
|
+
cost = {"plan_usd_per_month": plan, "plan_usd_per_day": per_day,
|
|
549
|
+
"daily_budget_usd": val(basis.get("daily_budget_usd")),
|
|
550
|
+
"label": "MEASURED" if metered["state"] == "MEASURED" else "ESTIMATE",
|
|
551
|
+
"label_note": "ESTIMATE = plan proration (plan x 12 / 365 per day); MEASURED only from cost-history rows",
|
|
552
|
+
"usage": usage, "governor": gov, "metered": metered}
|
|
553
|
+
|
|
554
|
+
# value side
|
|
555
|
+
comp = basis.get("annual_total_comp_usd") or {}
|
|
556
|
+
overhead = val(basis.get("overhead_multiplier"))
|
|
557
|
+
hours = val(basis.get("work_hours_per_year"))
|
|
558
|
+
tiers = basis.get("seat_tier") or {}
|
|
559
|
+
default_tier = basis.get("default_tier", "senior")
|
|
560
|
+
ok_rate = isinstance(overhead, (int, float)) and isinstance(hours, (int, float)) and hours > 0
|
|
561
|
+
|
|
562
|
+
def rate(tier):
|
|
563
|
+
c = comp.get(tier)
|
|
564
|
+
return round(c * overhead / hours, 2) if ok_rate and isinstance(c, (int, float)) else None
|
|
565
|
+
|
|
566
|
+
slugs, _ = agent_slugs(ctx)
|
|
567
|
+
payroll = None
|
|
568
|
+
if slugs and isinstance(overhead, (int, float)):
|
|
569
|
+
parts = [comp.get(tiers.get(s, default_tier)) for s in slugs]
|
|
570
|
+
if all(isinstance(p, (int, float)) for p in parts):
|
|
571
|
+
payroll = {"seats": len(slugs), "annual_usd": round(sum(parts) * overhead),
|
|
572
|
+
"per_day_usd": round(sum(parts) * overhead / 365.0, 2),
|
|
573
|
+
"label": "ASSUMPTION (tier map and overhead multiplier)"}
|
|
574
|
+
hpo = val((basis.get("hours_per_outcome") or {}).get("verified_slice"))
|
|
575
|
+
value = {"formula": "hourly rate = annual total comp x overhead multiplier / work hours per year; "
|
|
576
|
+
"value = sum over released slices in window of hours per outcome x hourly rate of the owning seat tier",
|
|
577
|
+
"hourly_rates": {t: rate(t) for t in comp}, "standing_payroll_equivalent": payroll,
|
|
578
|
+
"hours_per_outcome_verified_slice": hpo, "window_days": 7}
|
|
579
|
+
multiple, weaker = None, None
|
|
580
|
+
if not isinstance(hpo, (int, float)):
|
|
581
|
+
value["state"] = "UNMEASURED"
|
|
582
|
+
value["reason"] = "hours_per_outcome.verified_slice is unset in roi-basis.json; no ROI multiple is printed"
|
|
583
|
+
value["human_equivalent_usd"] = None
|
|
584
|
+
else:
|
|
585
|
+
btext, _ = read_text(ctx.board)
|
|
586
|
+
rows, _ = parse_board(btext or "")
|
|
587
|
+
win = [r for r in rows if r["token"] == "released" and (age_min(ctx, r["since"]) or 1e9) <= 7 * 24 * 60]
|
|
588
|
+
total = 0.0
|
|
589
|
+
for r in win:
|
|
590
|
+
t = next((tiers[s] for s in tiers if s in r["owner"]), default_tier)
|
|
591
|
+
rt = rate(t)
|
|
592
|
+
total += (hpo * rt) if rt is not None else 0.0
|
|
593
|
+
value.update({"state": "ESTIMATE", "released_slices_in_window": len(win),
|
|
594
|
+
"human_equivalent_usd": round(total, 2)})
|
|
595
|
+
if per_day is not None and per_day > 0 and total > 0:
|
|
596
|
+
multiple, weaker = round(total / (per_day * 7), 2), "ASSUMPTION"
|
|
597
|
+
roi = {"multiple": multiple, "weaker_side_label": weaker,
|
|
598
|
+
"note": None if multiple is not None else "no ROI multiple: a side is unmeasured"}
|
|
599
|
+
|
|
600
|
+
score = {"state": "UNMEASURED", "reason": "no scoreboard files in " + ctx.scoreboard,
|
|
601
|
+
"cost_per_verified_outcome_usd": None}
|
|
602
|
+
try:
|
|
603
|
+
files = sorted(f for f in os.listdir(ctx.scoreboard) if f.endswith(".json"))
|
|
604
|
+
except OSError:
|
|
605
|
+
files = []
|
|
606
|
+
if files:
|
|
607
|
+
d, _ = read_json(os.path.join(ctx.scoreboard, files[-1]))
|
|
608
|
+
c = d.get("cost_per_verified_outcome_usd") if isinstance(d, dict) else None
|
|
609
|
+
score = {"state": "MEASURED" if c is not None else "UNMEASURED", "file": files[-1],
|
|
610
|
+
"cost_per_verified_outcome_usd": c,
|
|
611
|
+
"reason": None if c is not None else "latest scoreboard file has no cost_per_verified_outcome_usd"}
|
|
612
|
+
|
|
613
|
+
data = {"cost": cost, "value": value, "roi": roi, "scoreboard": score,
|
|
614
|
+
"basis": {"path": ctx.basis, "git_sha": sha.strip() or None, "source_url": basis.get("source_url"),
|
|
615
|
+
"snapshot_date": basis.get("snapshot_date"), "stale": basis_stale,
|
|
616
|
+
"stale_note": "snapshot older than %d days" % BASIS_STALE_DAYS if basis_stale else None},
|
|
617
|
+
"notes": notes}
|
|
618
|
+
measured = metered["state"] == "MEASURED"
|
|
619
|
+
return panel(ctx, "OK" if (measured and multiple is not None) else "UNMEASURED", src, data,
|
|
620
|
+
None if multiple is not None else "ROI multiple withheld: " + (
|
|
621
|
+
"value side unmeasured" if value["state"] == "UNMEASURED" else "cost side unmeasured"))
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# ---------------------------------------------------------------- queue
|
|
625
|
+
def queue_rows(text):
|
|
626
|
+
rows = []
|
|
627
|
+
for line in text.splitlines():
|
|
628
|
+
m = re.match(r"^\|\s*(\d+)\s*\|\s*(\d{4}-\d\d-\d\d)\s*\|\s*(.*?)\s*\|", line)
|
|
629
|
+
if m:
|
|
630
|
+
rows.append({"n": int(m.group(1)), "date": m.group(2), "item": m.group(3)[:240]})
|
|
631
|
+
return rows
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def collect_queue(ctx):
|
|
635
|
+
src = "read " + ctx.queue
|
|
636
|
+
text, err = read_text(ctx.queue)
|
|
637
|
+
if err:
|
|
638
|
+
return unknown(ctx, src, err)
|
|
639
|
+
rows = queue_rows(text)
|
|
640
|
+
data = {"rows_total": len(rows), "max_number": max([r["n"] for r in rows], default=None),
|
|
641
|
+
"cockpit_rows": sum(1 for r in rows if r["item"].startswith("[cockpit]")),
|
|
642
|
+
"last": rows[-5:], "max_item_chars": QUEUE_CAP}
|
|
643
|
+
return panel(ctx, "OK", src, data, None)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
COLLECTORS = {"pulse": (collect_pulse, 90), "releases": (collect_releases, 60),
|
|
647
|
+
"board": (collect_board, 5), "agents": (collect_agents, 5),
|
|
648
|
+
"okrs": (collect_okrs, 15), "roi": (collect_roi, 60), "queue": (collect_queue, 3)}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
# ---------------------------------------------------------------- cache
|
|
652
|
+
class Store:
|
|
653
|
+
"""Per-panel cache refreshed in a background thread so a request never
|
|
654
|
+
waits on a slow shell-out beyond the first-collection wait."""
|
|
655
|
+
|
|
656
|
+
def __init__(self, ctx):
|
|
657
|
+
self.ctx, self.lock = ctx, threading.Lock()
|
|
658
|
+
self.entries, self.inflight = {}, {}
|
|
659
|
+
|
|
660
|
+
def _run(self, name):
|
|
661
|
+
fn = COLLECTORS[name][0]
|
|
662
|
+
try:
|
|
663
|
+
p = fn(self.ctx)
|
|
664
|
+
except Exception as ex: # a collector bug must surface as UNKNOWN, not a 500
|
|
665
|
+
p = unknown(self.ctx, name, "collector crashed: %r" % (ex,))
|
|
666
|
+
with self.lock:
|
|
667
|
+
self.entries[name] = (time.time(), p)
|
|
668
|
+
self.inflight.pop(name, None)
|
|
669
|
+
try:
|
|
670
|
+
d = os.path.join(self.ctx.runtime, "cache")
|
|
671
|
+
os.makedirs(d, exist_ok=True)
|
|
672
|
+
with open(os.path.join(d, name + ".json"), "w") as f:
|
|
673
|
+
json.dump(p, f)
|
|
674
|
+
except OSError:
|
|
675
|
+
pass
|
|
676
|
+
|
|
677
|
+
def kick(self, name):
|
|
678
|
+
with self.lock:
|
|
679
|
+
e = self.entries.get(name)
|
|
680
|
+
if (e and time.time() - e[0] < COLLECTORS[name][1]) or name in self.inflight:
|
|
681
|
+
return self.inflight.get(name)
|
|
682
|
+
t = threading.Thread(target=self._run, args=(name,), daemon=True)
|
|
683
|
+
self.inflight[name] = t
|
|
684
|
+
t.start()
|
|
685
|
+
return t
|
|
686
|
+
|
|
687
|
+
def get(self, name, wait):
|
|
688
|
+
t = self.kick(name)
|
|
689
|
+
with self.lock:
|
|
690
|
+
e = self.entries.get(name)
|
|
691
|
+
if e is None:
|
|
692
|
+
if t is not None:
|
|
693
|
+
t.join(wait)
|
|
694
|
+
with self.lock:
|
|
695
|
+
e = self.entries.get(name)
|
|
696
|
+
if e is None:
|
|
697
|
+
return unknown(self.ctx, name, "first collection still in progress; retry shortly")
|
|
698
|
+
p = dict(e[1])
|
|
699
|
+
p["cache_age_s"] = round(time.time() - e[0], 1)
|
|
700
|
+
return p
|
|
701
|
+
|
|
702
|
+
def invalidate(self, name):
|
|
703
|
+
with self.lock:
|
|
704
|
+
self.entries.pop(name, None)
|
|
705
|
+
|
|
706
|
+
def snapshot(self, names, wait):
|
|
707
|
+
for n in names:
|
|
708
|
+
self.kick(n)
|
|
709
|
+
deadline = time.time() + wait
|
|
710
|
+
out = {}
|
|
711
|
+
for n in names:
|
|
712
|
+
out[n] = self.get(n, max(0.0, deadline - time.time()))
|
|
713
|
+
return {"generated_at": iso(self.ctx.now()), "repo_root": self.ctx.root, "panels": out}
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
# --------------------------------------------------------- queue append
|
|
717
|
+
def sanitize(s):
|
|
718
|
+
s = re.sub(r"[\r\n\t]+", " ", s)
|
|
719
|
+
s = re.sub(r"[\x00-\x1f\x7f]", "", s)
|
|
720
|
+
return s.replace("|", "/").strip()
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
class QueueError(Exception):
|
|
724
|
+
def __init__(self, code, msg):
|
|
725
|
+
super().__init__(msg)
|
|
726
|
+
self.code, self.msg = code, msg
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def append_queue(ctx, item, why):
|
|
730
|
+
if not isinstance(item, str) or not isinstance(why, str):
|
|
731
|
+
raise QueueError(400, "item and why must be strings")
|
|
732
|
+
if len(item) > QUEUE_CAP or len(why) > QUEUE_CAP:
|
|
733
|
+
raise QueueError(413, "item or why exceeds %d characters" % QUEUE_CAP)
|
|
734
|
+
item, why = sanitize(item), sanitize(why) or "founder command"
|
|
735
|
+
if not item:
|
|
736
|
+
raise QueueError(400, "item is empty")
|
|
737
|
+
os.makedirs(ctx.runtime, exist_ok=True)
|
|
738
|
+
lock_path = os.path.join(ctx.runtime, "FOUNDER-QUEUE.md.lock")
|
|
739
|
+
with open(lock_path, "a") as lf:
|
|
740
|
+
deadline = time.time() + 5
|
|
741
|
+
while True:
|
|
742
|
+
try:
|
|
743
|
+
fcntl.flock(lf, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
744
|
+
break
|
|
745
|
+
except OSError:
|
|
746
|
+
if time.time() > deadline:
|
|
747
|
+
raise QueueError(503, "queue lock busy")
|
|
748
|
+
time.sleep(0.05)
|
|
749
|
+
try:
|
|
750
|
+
try:
|
|
751
|
+
with open(ctx.queue, "rb") as f:
|
|
752
|
+
before = f.read()
|
|
753
|
+
except OSError as ex:
|
|
754
|
+
raise QueueError(500, "cannot read queue: %s" % (ex.strerror or ex))
|
|
755
|
+
nums = [int(m) for m in re.findall(rb"(?m)^\|\s*(\d+)\s*\|", before)]
|
|
756
|
+
n = (max(nums) if nums else 0) + 1
|
|
757
|
+
line = "| %d | %s | [cockpit] %s | %s | chief-of-staff to triage | open |" % (
|
|
758
|
+
n, ctx.now().strftime("%Y-%m-%d"), item, why)
|
|
759
|
+
payload = (b"" if before.endswith(b"\n") or not before else b"\n") + line.encode("utf-8") + b"\n"
|
|
760
|
+
size0 = len(before)
|
|
761
|
+
try:
|
|
762
|
+
with open(ctx.queue, "ab") as f:
|
|
763
|
+
f.write(payload)
|
|
764
|
+
f.flush()
|
|
765
|
+
os.fsync(f.fileno())
|
|
766
|
+
with open(ctx.queue, "rb") as f:
|
|
767
|
+
after = f.read()
|
|
768
|
+
lines = after.rstrip(b"\n").split(b"\n")
|
|
769
|
+
if len(after) < size0 or lines[-1] != line.encode("utf-8"):
|
|
770
|
+
raise OSError("verification failed")
|
|
771
|
+
except OSError as ex:
|
|
772
|
+
try:
|
|
773
|
+
os.truncate(ctx.queue, size0)
|
|
774
|
+
except OSError:
|
|
775
|
+
pass
|
|
776
|
+
raise QueueError(500, "append failed, file restored: %s" % ex)
|
|
777
|
+
return {"row": n, "line": line}
|
|
778
|
+
finally:
|
|
779
|
+
fcntl.flock(lf, fcntl.LOCK_UN)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
# ----------------------------------------------------------------- http
|
|
783
|
+
def make_handler(ctx, store, token, port, index_html):
|
|
784
|
+
hosts = {"127.0.0.1:%d" % port, "localhost:%d" % port, "[::1]:%d" % port}
|
|
785
|
+
origins = {"http://" + h for h in hosts}
|
|
786
|
+
csp = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
|
|
787
|
+
|
|
788
|
+
class H(BaseHTTPRequestHandler):
|
|
789
|
+
server_version = "cockpit"
|
|
790
|
+
protocol_version = "HTTP/1.1"
|
|
791
|
+
|
|
792
|
+
def log_message(self, *a):
|
|
793
|
+
pass
|
|
794
|
+
|
|
795
|
+
def send(self, code, body, ctype="application/json"):
|
|
796
|
+
if not isinstance(body, bytes):
|
|
797
|
+
body = json.dumps(body).encode("utf-8")
|
|
798
|
+
self.send_response(code)
|
|
799
|
+
self.send_header("Content-Type", ctype + "; charset=utf-8")
|
|
800
|
+
self.send_header("Content-Length", str(len(body)))
|
|
801
|
+
self.send_header("Cache-Control", "no-store")
|
|
802
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
803
|
+
self.send_header("Content-Security-Policy", csp)
|
|
804
|
+
self.end_headers()
|
|
805
|
+
self.wfile.write(body)
|
|
806
|
+
|
|
807
|
+
def host_ok(self):
|
|
808
|
+
if self.headers.get("Host") in hosts:
|
|
809
|
+
return True
|
|
810
|
+
self.send(403, {"error": "bad Host header"})
|
|
811
|
+
return False
|
|
812
|
+
|
|
813
|
+
def do_GET(self):
|
|
814
|
+
if not self.host_ok():
|
|
815
|
+
return
|
|
816
|
+
path = self.path.split("?", 1)[0]
|
|
817
|
+
if path == "/":
|
|
818
|
+
page = index_html.replace("__COCKPIT_TOKEN__", token)
|
|
819
|
+
return self.send(200, page.encode("utf-8"), "text/html")
|
|
820
|
+
if path == "/healthz":
|
|
821
|
+
return self.send(200, {"ok": True, "pid": os.getpid()})
|
|
822
|
+
if path == "/api/snapshot":
|
|
823
|
+
return self.send(200, store.snapshot(PANELS, ctx.first_wait))
|
|
824
|
+
if path.startswith("/api/panel/"):
|
|
825
|
+
name = path[len("/api/panel/"):]
|
|
826
|
+
if name not in COLLECTORS:
|
|
827
|
+
return self.send(404, {"error": "unknown panel"})
|
|
828
|
+
return self.send(200, store.get(name, ctx.first_wait))
|
|
829
|
+
if path == "/api/queue":
|
|
830
|
+
return self.send(405, {"error": "method not allowed"})
|
|
831
|
+
self.send(404, {"error": "not found"})
|
|
832
|
+
|
|
833
|
+
def do_POST(self):
|
|
834
|
+
if not self.host_ok():
|
|
835
|
+
return
|
|
836
|
+
path = self.path.split("?", 1)[0]
|
|
837
|
+
if path != "/api/queue":
|
|
838
|
+
known = path in ("/", "/healthz", "/api/snapshot") or path.startswith("/api/panel/")
|
|
839
|
+
return self.send(405 if known else 404, {"error": "method not allowed" if known else "not found"})
|
|
840
|
+
tok = self.headers.get("X-Cockpit-Token") or ""
|
|
841
|
+
if not secrets.compare_digest(tok, token) or self.headers.get("Origin") not in origins:
|
|
842
|
+
return self.send(403, {"error": "missing or wrong token or origin"})
|
|
843
|
+
try:
|
|
844
|
+
n = int(self.headers.get("Content-Length") or "-1")
|
|
845
|
+
except ValueError:
|
|
846
|
+
n = -1
|
|
847
|
+
if n < 0:
|
|
848
|
+
return self.send(400, {"error": "Content-Length required"})
|
|
849
|
+
if n > BODY_CAP:
|
|
850
|
+
self.close_connection = True
|
|
851
|
+
return self.send(413, {"error": "body exceeds %d bytes" % BODY_CAP})
|
|
852
|
+
raw = self.rfile.read(n)
|
|
853
|
+
try:
|
|
854
|
+
body = json.loads(raw.decode("utf-8"))
|
|
855
|
+
assert isinstance(body, dict)
|
|
856
|
+
except (ValueError, AssertionError):
|
|
857
|
+
return self.send(400, {"error": "body must be a JSON object"})
|
|
858
|
+
try:
|
|
859
|
+
res = append_queue(ctx, body.get("item", ""), body.get("why", "") or "")
|
|
860
|
+
except QueueError as ex:
|
|
861
|
+
return self.send(ex.code, {"error": ex.msg})
|
|
862
|
+
store.invalidate("queue")
|
|
863
|
+
self.send(200, res)
|
|
864
|
+
|
|
865
|
+
def _no(self):
|
|
866
|
+
if self.host_ok():
|
|
867
|
+
self.send(405, {"error": "method not allowed"})
|
|
868
|
+
|
|
869
|
+
do_PUT = do_DELETE = do_PATCH = do_OPTIONS = _no
|
|
870
|
+
|
|
871
|
+
def do_HEAD(self):
|
|
872
|
+
self._no()
|
|
873
|
+
|
|
874
|
+
return H
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
class V6Server(ThreadingHTTPServer):
|
|
878
|
+
address_family = socket.AF_INET6
|
|
879
|
+
daemon_threads = True
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def serve(ctx, host, port):
|
|
883
|
+
token = secrets.token_urlsafe(32)
|
|
884
|
+
with open(os.path.join(HERE, "index.html"), encoding="utf-8") as f:
|
|
885
|
+
index_html = f.read()
|
|
886
|
+
store = Store(ctx)
|
|
887
|
+
srv_cls = V6Server if host == "::1" else ThreadingHTTPServer
|
|
888
|
+
# Bind first: the handler's Host allow-list needs the real port.
|
|
889
|
+
httpd = srv_cls((host, port), BaseHTTPRequestHandler, bind_and_activate=False)
|
|
890
|
+
httpd.daemon_threads = True
|
|
891
|
+
httpd.server_bind()
|
|
892
|
+
real_port = httpd.server_address[1]
|
|
893
|
+
httpd.RequestHandlerClass = make_handler(ctx, store, token, real_port, index_html)
|
|
894
|
+
httpd.server_activate()
|
|
895
|
+
os.makedirs(ctx.runtime, exist_ok=True)
|
|
896
|
+
tok_path = os.path.join(ctx.runtime, "token")
|
|
897
|
+
fd = os.open(tok_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
898
|
+
with os.fdopen(fd, "w") as f:
|
|
899
|
+
f.write(token)
|
|
900
|
+
os.chmod(tok_path, 0o600)
|
|
901
|
+
with open(os.path.join(ctx.runtime, "server.pid"), "w") as f:
|
|
902
|
+
f.write(str(os.getpid()))
|
|
903
|
+
shown = "[::1]" if host == "::1" else host
|
|
904
|
+
print("cockpit: http://%s:%d/" % (shown, real_port), flush=True)
|
|
905
|
+
print("cockpit pid: %d" % os.getpid(), flush=True)
|
|
906
|
+
signal.signal(signal.SIGTERM, lambda *_: threading.Thread(target=httpd.shutdown, daemon=True).start())
|
|
907
|
+
try:
|
|
908
|
+
httpd.serve_forever()
|
|
909
|
+
except KeyboardInterrupt:
|
|
910
|
+
pass
|
|
911
|
+
finally:
|
|
912
|
+
httpd.server_close()
|
|
913
|
+
return 0
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def main(argv=None):
|
|
917
|
+
ap = argparse.ArgumentParser(description="CEO cockpit (loopback only)")
|
|
918
|
+
ap.add_argument("--port", type=int, default=8787)
|
|
919
|
+
ap.add_argument("--host", default="127.0.0.1")
|
|
920
|
+
ap.add_argument("--repo-root", default=os.path.abspath(os.path.join(HERE, "..", "..")))
|
|
921
|
+
ap.add_argument("--once", action="store_true")
|
|
922
|
+
ap.add_argument("--json", action="store_true")
|
|
923
|
+
ap.add_argument("--panel", choices=PANELS)
|
|
924
|
+
a = ap.parse_args(argv)
|
|
925
|
+
if a.host not in ("127.0.0.1", "::1"):
|
|
926
|
+
print("cockpit: refusing --host %s; only 127.0.0.1 or ::1 are allowed" % a.host, file=sys.stderr)
|
|
927
|
+
return 64
|
|
928
|
+
ctx = Ctx(os.path.abspath(a.repo_root))
|
|
929
|
+
if a.once:
|
|
930
|
+
store = Store(ctx)
|
|
931
|
+
names = [a.panel] if a.panel else PANELS
|
|
932
|
+
snap = store.snapshot(names, ctx.pulse_timeout + ctx.timeout * 4 + 30)
|
|
933
|
+
if a.json:
|
|
934
|
+
print(json.dumps(snap, indent=2, sort_keys=True))
|
|
935
|
+
else:
|
|
936
|
+
for n, p in snap["panels"].items():
|
|
937
|
+
print("%-9s %-10s %s" % (n, p["status"], p.get("error") or ""))
|
|
938
|
+
sts = [p["status"] for p in snap["panels"].values()]
|
|
939
|
+
if "VIOLATION" in sts:
|
|
940
|
+
return 1
|
|
941
|
+
return 2 if "UNKNOWN" in sts else 0
|
|
942
|
+
return serve(ctx, a.host, a.port)
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
if __name__ == "__main__":
|
|
946
|
+
sys.exit(main())
|