loki-mode 11.74.2 → 11.75.2

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 (167) hide show
  1. package/README.md +1 -1
  2. package/SKILL.md +2 -3
  3. package/VERSION +1 -1
  4. package/autonomy/completion-council.sh +10 -10
  5. package/autonomy/council-v2.sh +4 -4
  6. package/autonomy/grill.sh +1 -1
  7. package/autonomy/lib/ablation.py +22 -2
  8. package/autonomy/lib/browser-open.sh +1 -1
  9. package/autonomy/lib/chat.sh +1 -1
  10. package/autonomy/lib/cockpit-render.sh +1 -1
  11. package/autonomy/lib/done-recognition.sh +4 -4
  12. package/autonomy/lib/efficiency_cost.py +1 -1
  13. package/autonomy/lib/engine10-push.sh +2 -2
  14. package/autonomy/lib/prd-enrich.sh +1 -1
  15. package/autonomy/lib/proof-check.sh +1 -1
  16. package/autonomy/lib/proof-generator.py +3 -3
  17. package/autonomy/lib/proof-pr.sh +1 -1
  18. package/autonomy/lib/skill-link-heal.sh +1 -1
  19. package/autonomy/lib/voter-agents.sh +1 -1
  20. package/autonomy/loki +26 -37
  21. package/autonomy/prd-checklist.sh +1 -1
  22. package/autonomy/receipt_jwt.py +1 -1
  23. package/autonomy/run.sh +28 -28
  24. package/autonomy/trigger-schedule.py +6 -414
  25. package/autonomy/tui.sh +1 -1
  26. package/autonomy/verify.sh +4 -4
  27. package/completions/loki.bash +0 -5
  28. package/dashboard/__init__.py +1 -1
  29. package/docs/ARCHITECTURE.md +1 -2
  30. package/docs/CLI-REFERENCE.md +1 -1
  31. package/docs/COMPONENTS.md +1 -1
  32. package/docs/INSTALLATION.md +1 -1
  33. package/docs/ROUTING-EVIDENCE.md +1 -2
  34. package/docs/V10-RESEARCH.md +1 -1
  35. package/docs/dev/architecture-reference.md +3 -7
  36. package/docs/dev/project-structure.md +1 -4
  37. package/docs/dev/release-checklist.md +13 -31
  38. package/docs/research-2026-07/OBSERVABILITY-TOOL-SPEC.md +1 -1
  39. package/docs/v11/MARKET-SLICES.md +1 -1
  40. package/docs/v11/REGISTRIES.md +1 -1
  41. package/docs/v11/ROUTER-1.md +2 -2
  42. package/loki-ts/dist/loki.js +2 -2
  43. package/mcp/__init__.py +1 -1
  44. package/package.json +2 -2
  45. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  46. package/skills/extending.md +2 -3
  47. package/skills/factory-operations.md +11 -18
  48. package/skills/release-cadence.md +2 -2
  49. package/web-app/dist/assets/{AdminPage-jkjilfbl.js → AdminPage-CTwi8SUX.js} +1 -1
  50. package/web-app/dist/assets/{Avatar-bd5JWkSJ.js → Avatar-DVtGtLkJ.js} +1 -1
  51. package/web-app/dist/assets/{Badge-DVRit2Xw.js → Badge-rwKzz0Pe.js} +1 -1
  52. package/web-app/dist/assets/{Button-BNAB-pVZ.js → Button-nIsPDfsQ.js} +1 -1
  53. package/web-app/dist/assets/{CockpitPage-DpG29h_w.js → CockpitPage-BCeDpoWa.js} +1 -1
  54. package/web-app/dist/assets/{ComparePage-Dasg5V41.js → ComparePage-CYIYanVq.js} +1 -1
  55. package/web-app/dist/assets/{ErrorBoundary-DJojse6b.js → ErrorBoundary-Bkz6HqWp.js} +1 -1
  56. package/web-app/dist/assets/{EvidenceReceiptPanel-Blxw7_u1.js → EvidenceReceiptPanel-DZT2Np4F.js} +1 -1
  57. package/web-app/dist/assets/{GitHubIssuesPanel-BBvSFj80.js → GitHubIssuesPanel-Bzla91Lu.js} +1 -1
  58. package/web-app/dist/assets/{GitHubPRsPanel-BCIKeSBg.js → GitHubPRsPanel-FjT-Xn5t.js} +1 -1
  59. package/web-app/dist/assets/{HomePage-C7d__oNG.js → HomePage-pXgmRGLt.js} +2 -2
  60. package/web-app/dist/assets/{LoginPage-BXljL3yY.js → LoginPage-CnmpE28B.js} +1 -1
  61. package/web-app/dist/assets/{MagicPage-Xjkts_yE.js → MagicPage-CuSeGsLq.js} +1 -1
  62. package/web-app/dist/assets/{MetricsPage-kQvVp-j2.js → MetricsPage-Bg3PkBOU.js} +1 -1
  63. package/web-app/dist/assets/{NotFoundPage-D-4ZlEaO.js → NotFoundPage-uzBtZNV-.js} +1 -1
  64. package/web-app/dist/assets/{ProjectPage-DfzR5RXP.js → ProjectPage-Dacki8Xr.js} +3 -3
  65. package/web-app/dist/assets/{ProjectsPage-DFDsWzkV.js → ProjectsPage-BRGjksXG.js} +1 -1
  66. package/web-app/dist/assets/{SettingsPage-CLPIoYT3.js → SettingsPage-BbNf3fEJ.js} +1 -1
  67. package/web-app/dist/assets/{ShowcasePage-PZKkyGeY.js → ShowcasePage-XX4830sX.js} +1 -1
  68. package/web-app/dist/assets/{SystemSettingsPage-BsBlcnzo.js → SystemSettingsPage-DOhQeXa6.js} +1 -1
  69. package/web-app/dist/assets/{TeamsPage-BSjgNhoU.js → TeamsPage-2reIRUp_.js} +1 -1
  70. package/web-app/dist/assets/{TemplatesPage-W2KeAzbg.js → TemplatesPage-BUsLEWHW.js} +1 -1
  71. package/web-app/dist/assets/{TerminalOutput-BjroTiCC.js → TerminalOutput-BrBDcgzC.js} +1 -1
  72. package/web-app/dist/assets/{activity-BXZ5YYes.js → activity-B6AZpgmb.js} +1 -1
  73. package/web-app/dist/assets/{bell-D1urp5pa.js → bell-CB792A7S.js} +1 -1
  74. package/web-app/dist/assets/{bot-Chedf2bF.js → bot-uQZwOFAn.js} +1 -1
  75. package/web-app/dist/assets/{check-DhaCuOCV.js → check-DrtG0lIZ.js} +1 -1
  76. package/web-app/dist/assets/{chevron-left-D7BpQyA6.js → chevron-left-CrsJSAqQ.js} +1 -1
  77. package/web-app/dist/assets/{circle-alert-DnGvQ_AQ.js → circle-alert-BUCly4p3.js} +1 -1
  78. package/web-app/dist/assets/{clock-CqfxWhHM.js → clock-B2IT9SnE.js} +1 -1
  79. package/web-app/dist/assets/{cloud-BKxp5AWB.js → cloud-CN6Ux7Q7.js} +1 -1
  80. package/web-app/dist/assets/{code-xml-BDQ6GBtt.js → code-xml-BEm8PWnK.js} +1 -1
  81. package/web-app/dist/assets/{database-BmpEx1y_.js → database-cHe5Fgu8.js} +1 -1
  82. package/web-app/dist/assets/{dollar-sign-DYNY9R_C.js → dollar-sign-BVmP4YrE.js} +1 -1
  83. package/web-app/dist/assets/{file-code-corner-BJi0IBFd.js → file-code-corner-D-m-NrwO.js} +1 -1
  84. package/web-app/dist/assets/{file-plus-Dqyb2bcQ.js → file-plus-6mCjIzAB.js} +1 -1
  85. package/web-app/dist/assets/{globe-DPh4RWfM.js → globe-vcWOujwU.js} +1 -1
  86. package/web-app/dist/assets/{hammer-B3xnuoA1.js → hammer-D4nZFpO2.js} +1 -1
  87. package/web-app/dist/assets/{index-BpX8DcTr.js → index-u8toJ43r.js} +3 -3
  88. package/web-app/dist/assets/{layers-BRCmyCW8.js → layers-CNhYLop8.js} +1 -1
  89. package/web-app/dist/assets/{loader-circle-n07Pky0M.js → loader-circle-Vl7XIMV6.js} +1 -1
  90. package/web-app/dist/assets/{lock-BWqOuQmS.js → lock-SW_KwAvf.js} +1 -1
  91. package/web-app/dist/assets/{package-BJlB_Z-Z.js → package-QPK-7Ej1.js} +1 -1
  92. package/web-app/dist/assets/{plus-oH17BZu2.js → plus-DUgAlgCB.js} +1 -1
  93. package/web-app/dist/assets/{refresh-cw-CPQ476LL.js → refresh-cw-LfUksPHq.js} +1 -1
  94. package/web-app/dist/assets/{rotate-ccw-7hLvOcnS.js → rotate-ccw-CMbuouVq.js} +1 -1
  95. package/web-app/dist/assets/{scroll-text-BCmLauQn.js → scroll-text-C_FxBmNQ.js} +1 -1
  96. package/web-app/dist/assets/{server-DfTfvW84.js → server-7UJe9Jqt.js} +1 -1
  97. package/web-app/dist/assets/{shield-alert-CaeGRq19.js → shield-alert-BMuu6fYB.js} +1 -1
  98. package/web-app/dist/assets/{thumbs-up-Dz-l2GPC.js → thumbs-up-Bhs0d2Gp.js} +1 -1
  99. package/web-app/dist/assets/{trash-2-B-OsDmSo.js → trash-2-BZKxv49w.js} +1 -1
  100. package/web-app/dist/assets/{trending-down-CXTFOxzq.js → trending-down-DKeCMZek.js} +1 -1
  101. package/web-app/dist/assets/{trending-up-FQEdqr3v.js → trending-up-43_9fShg.js} +1 -1
  102. package/web-app/dist/assets/{upload-CacWSNTg.js → upload-pV3irPEW.js} +1 -1
  103. package/web-app/dist/assets/{usePolling-cIjZ9qd2.js → usePolling-CoZQM-WL.js} +1 -1
  104. package/web-app/dist/assets/{user-DAouWFHi.js → user-CMl7dXvC.js} +1 -1
  105. package/web-app/dist/index.html +1 -1
  106. package/autonomy/company-seats.json +0 -82
  107. package/docs/COMPETITIVE-INTEL-2026-09.md +0 -444
  108. package/docs/FOUNDER-DECISIONS-TRUST-SEMANTICS-2026-08-01.md +0 -145
  109. package/docs/FOUNDER-STATUS-AND-DECISIONS-2026-07-01.md +0 -132
  110. package/docs/LOKI-10-BUILD-PROMPT.md +0 -350
  111. package/docs/STRATEGY-2026-2028.md +0 -124
  112. package/docs/V10-VISION.md +0 -161
  113. package/docs/v10/ADOPTION-PLAN.md +0 -375
  114. package/docs/v10/ARCHITECT-CUTS.md +0 -744
  115. package/docs/v10/BACKLOG.md +0 -231
  116. package/docs/v10/BOARD.md +0 -1403
  117. package/docs/v10/CONTROL-PLANE.md +0 -120
  118. package/docs/v10/CONTROL.md +0 -36
  119. package/docs/v10/CP-ASK-PLAN.md +0 -85
  120. package/docs/v10/CP-ENTERPRISE-UI.md +0 -537
  121. package/docs/v10/D51-PHASE-B.md +0 -120
  122. package/docs/v10/D61-SPEED.md +0 -105
  123. package/docs/v10/D63-CARDS.md +0 -354
  124. package/docs/v10/DECISIONS.md +0 -915
  125. package/docs/v10/E2E-D65.md +0 -123
  126. package/docs/v10/ENGINE-LAWS.md +0 -163
  127. package/docs/v10/ENGINE-MEASURE.md +0 -69
  128. package/docs/v10/ENGINE.md +0 -743
  129. package/docs/v10/FAILURE-CLASSES.md +0 -2026
  130. package/docs/v10/FC21B-FC22-PLAN.md +0 -107
  131. package/docs/v10/FIRST-RUN-GATE.md +0 -39
  132. package/docs/v10/FOUNDER-QUEUE.md +0 -29
  133. package/docs/v10/GAP-RESEARCH-1003.md +0 -32
  134. package/docs/v10/GUARDS.md +0 -883
  135. package/docs/v10/L0-WAVE1.md +0 -81
  136. package/docs/v10/LEGACY-REMOVAL.md +0 -408
  137. package/docs/v10/MEDIUM-ANALYSIS.md +0 -346
  138. package/docs/v10/METRICS.md +0 -645
  139. package/docs/v10/MODERNIZE.md +0 -437
  140. package/docs/v10/OPENCODE-PARITY-PLAN.md +0 -84
  141. package/docs/v10/OPERATING-MODEL.md +0 -60
  142. package/docs/v10/PLAN-V12.md +0 -896
  143. package/docs/v10/PROGRESS.md +0 -2735
  144. package/docs/v10/RELEASE-11.md +0 -77
  145. package/docs/v10/RETRO-2026.md +0 -650
  146. package/docs/v10/ROADMAP-D63.md +0 -35
  147. package/docs/v10/SCALE.md +0 -140
  148. package/docs/v10/SCORECARD-PLAN.md +0 -250
  149. package/docs/v10/SWARM-PROMPT.md +0 -98
  150. package/docs/v10/SWARM.md +0 -293
  151. package/docs/v10/cp-redesign/PARITY.md +0 -188
  152. package/docs/v10/cp-redesign/legacy/INDEX.md +0 -1167
  153. package/docs/v10/roadmap-10/01-measurement.md +0 -113
  154. package/docs/v10/roadmap-10/02-cost.md +0 -75
  155. package/docs/v10/roadmap-10/03-speed.md +0 -65
  156. package/docs/v10/roadmap-10/04-first-run.md +0 -61
  157. package/docs/v10/roadmap-10/05-deploy.md +0 -61
  158. package/docs/v10/roadmap-10/06-stability.md +0 -64
  159. package/docs/v10/roadmap-10/07-quality.md +0 -57
  160. package/docs/v10/roadmap-10/08-proof.md +0 -48
  161. package/docs/v10/roadmap-10/09-safety.md +0 -74
  162. package/docs/v10/roadmap-10/README.md +0 -1464
  163. package/skills/sdlc-fleet.md +0 -170
  164. package/tools/cockpit/SPEC.md +0 -275
  165. package/tools/cockpit/index.html +0 -146
  166. package/tools/cockpit/roi-basis.json +0 -33
  167. package/tools/cockpit/server.py +0 -946
@@ -1,946 +0,0 @@
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())