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