loki-mode 11.74.2 → 11.75.1

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 (166) 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/browser-open.sh +1 -1
  8. package/autonomy/lib/chat.sh +1 -1
  9. package/autonomy/lib/cockpit-render.sh +1 -1
  10. package/autonomy/lib/done-recognition.sh +4 -4
  11. package/autonomy/lib/efficiency_cost.py +1 -1
  12. package/autonomy/lib/engine10-push.sh +2 -2
  13. package/autonomy/lib/prd-enrich.sh +1 -1
  14. package/autonomy/lib/proof-check.sh +1 -1
  15. package/autonomy/lib/proof-generator.py +3 -3
  16. package/autonomy/lib/proof-pr.sh +1 -1
  17. package/autonomy/lib/skill-link-heal.sh +1 -1
  18. package/autonomy/lib/voter-agents.sh +1 -1
  19. package/autonomy/loki +26 -37
  20. package/autonomy/prd-checklist.sh +1 -1
  21. package/autonomy/receipt_jwt.py +1 -1
  22. package/autonomy/run.sh +28 -28
  23. package/autonomy/trigger-schedule.py +6 -414
  24. package/autonomy/tui.sh +1 -1
  25. package/autonomy/verify.sh +4 -4
  26. package/completions/loki.bash +0 -5
  27. package/dashboard/__init__.py +1 -1
  28. package/docs/ARCHITECTURE.md +1 -2
  29. package/docs/CLI-REFERENCE.md +1 -1
  30. package/docs/COMPONENTS.md +1 -1
  31. package/docs/INSTALLATION.md +1 -1
  32. package/docs/ROUTING-EVIDENCE.md +1 -2
  33. package/docs/V10-RESEARCH.md +1 -1
  34. package/docs/dev/architecture-reference.md +3 -7
  35. package/docs/dev/project-structure.md +1 -4
  36. package/docs/dev/release-checklist.md +13 -31
  37. package/docs/research-2026-07/OBSERVABILITY-TOOL-SPEC.md +1 -1
  38. package/docs/v11/MARKET-SLICES.md +1 -1
  39. package/docs/v11/REGISTRIES.md +1 -1
  40. package/docs/v11/ROUTER-1.md +2 -2
  41. package/loki-ts/dist/loki.js +2 -2
  42. package/mcp/__init__.py +1 -1
  43. package/package.json +2 -2
  44. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  45. package/skills/extending.md +2 -3
  46. package/skills/factory-operations.md +11 -18
  47. package/skills/release-cadence.md +2 -2
  48. package/web-app/dist/assets/{AdminPage-jkjilfbl.js → AdminPage-xKgtIhk2.js} +1 -1
  49. package/web-app/dist/assets/{Avatar-bd5JWkSJ.js → Avatar-Q14AHox9.js} +1 -1
  50. package/web-app/dist/assets/{Badge-DVRit2Xw.js → Badge-yk5M-lip.js} +1 -1
  51. package/web-app/dist/assets/{Button-BNAB-pVZ.js → Button-ynznk0At.js} +1 -1
  52. package/web-app/dist/assets/{CockpitPage-DpG29h_w.js → CockpitPage-C8B765za.js} +1 -1
  53. package/web-app/dist/assets/{ComparePage-Dasg5V41.js → ComparePage-DqzmWKnf.js} +1 -1
  54. package/web-app/dist/assets/{ErrorBoundary-DJojse6b.js → ErrorBoundary-DdBPd_Lq.js} +1 -1
  55. package/web-app/dist/assets/{EvidenceReceiptPanel-Blxw7_u1.js → EvidenceReceiptPanel-jb4Tp38b.js} +1 -1
  56. package/web-app/dist/assets/{GitHubIssuesPanel-BBvSFj80.js → GitHubIssuesPanel-xJQiLcxs.js} +1 -1
  57. package/web-app/dist/assets/{GitHubPRsPanel-BCIKeSBg.js → GitHubPRsPanel-84W3Dhk_.js} +1 -1
  58. package/web-app/dist/assets/{HomePage-C7d__oNG.js → HomePage-St1Xl2tu.js} +2 -2
  59. package/web-app/dist/assets/{LoginPage-BXljL3yY.js → LoginPage-jRt0NUHb.js} +1 -1
  60. package/web-app/dist/assets/{MagicPage-Xjkts_yE.js → MagicPage-CQLFe7LR.js} +1 -1
  61. package/web-app/dist/assets/{MetricsPage-kQvVp-j2.js → MetricsPage-zzX9eTC5.js} +1 -1
  62. package/web-app/dist/assets/{NotFoundPage-D-4ZlEaO.js → NotFoundPage-DDu023SY.js} +1 -1
  63. package/web-app/dist/assets/{ProjectPage-DfzR5RXP.js → ProjectPage-D60UURaH.js} +3 -3
  64. package/web-app/dist/assets/{ProjectsPage-DFDsWzkV.js → ProjectsPage-DqVsKxlt.js} +1 -1
  65. package/web-app/dist/assets/{SettingsPage-CLPIoYT3.js → SettingsPage-G7pPKvo0.js} +1 -1
  66. package/web-app/dist/assets/{ShowcasePage-PZKkyGeY.js → ShowcasePage-CrU_aKgS.js} +1 -1
  67. package/web-app/dist/assets/{SystemSettingsPage-BsBlcnzo.js → SystemSettingsPage-ClKGQFsB.js} +1 -1
  68. package/web-app/dist/assets/{TeamsPage-BSjgNhoU.js → TeamsPage-B530vSNe.js} +1 -1
  69. package/web-app/dist/assets/{TemplatesPage-W2KeAzbg.js → TemplatesPage-COmv_Cqd.js} +1 -1
  70. package/web-app/dist/assets/{TerminalOutput-BjroTiCC.js → TerminalOutput-CufG9YzF.js} +1 -1
  71. package/web-app/dist/assets/{activity-BXZ5YYes.js → activity-Ct1Updr6.js} +1 -1
  72. package/web-app/dist/assets/{bell-D1urp5pa.js → bell-rcvhQuGr.js} +1 -1
  73. package/web-app/dist/assets/{bot-Chedf2bF.js → bot-DzeqEI-b.js} +1 -1
  74. package/web-app/dist/assets/{check-DhaCuOCV.js → check-zacnZHyC.js} +1 -1
  75. package/web-app/dist/assets/{chevron-left-D7BpQyA6.js → chevron-left-Lxnkl8Qg.js} +1 -1
  76. package/web-app/dist/assets/{circle-alert-DnGvQ_AQ.js → circle-alert-DoqSdQdB.js} +1 -1
  77. package/web-app/dist/assets/{clock-CqfxWhHM.js → clock-B7bJcVIC.js} +1 -1
  78. package/web-app/dist/assets/{cloud-BKxp5AWB.js → cloud-DdA9Bu6q.js} +1 -1
  79. package/web-app/dist/assets/{code-xml-BDQ6GBtt.js → code-xml-CAZEZ9T8.js} +1 -1
  80. package/web-app/dist/assets/{database-BmpEx1y_.js → database-Da2GkEIN.js} +1 -1
  81. package/web-app/dist/assets/{dollar-sign-DYNY9R_C.js → dollar-sign-DjDiG-37.js} +1 -1
  82. package/web-app/dist/assets/{file-code-corner-BJi0IBFd.js → file-code-corner-CSL-Jij-.js} +1 -1
  83. package/web-app/dist/assets/{file-plus-Dqyb2bcQ.js → file-plus-02DRA8pV.js} +1 -1
  84. package/web-app/dist/assets/{globe-DPh4RWfM.js → globe-CGyB90kK.js} +1 -1
  85. package/web-app/dist/assets/{hammer-B3xnuoA1.js → hammer-BaQB96TG.js} +1 -1
  86. package/web-app/dist/assets/{index-BpX8DcTr.js → index-lcoI1_Kk.js} +3 -3
  87. package/web-app/dist/assets/{layers-BRCmyCW8.js → layers-BWJBvoeg.js} +1 -1
  88. package/web-app/dist/assets/{loader-circle-n07Pky0M.js → loader-circle-CdcI0FeS.js} +1 -1
  89. package/web-app/dist/assets/{lock-BWqOuQmS.js → lock-BBkw5NJx.js} +1 -1
  90. package/web-app/dist/assets/{package-BJlB_Z-Z.js → package-BpPRO3LM.js} +1 -1
  91. package/web-app/dist/assets/{plus-oH17BZu2.js → plus-D7XbyK29.js} +1 -1
  92. package/web-app/dist/assets/{refresh-cw-CPQ476LL.js → refresh-cw-DLG3hyAi.js} +1 -1
  93. package/web-app/dist/assets/{rotate-ccw-7hLvOcnS.js → rotate-ccw-Bqau4R_S.js} +1 -1
  94. package/web-app/dist/assets/{scroll-text-BCmLauQn.js → scroll-text-BtIL3cNk.js} +1 -1
  95. package/web-app/dist/assets/{server-DfTfvW84.js → server-tZCmZ4yH.js} +1 -1
  96. package/web-app/dist/assets/{shield-alert-CaeGRq19.js → shield-alert-K5JXzFKg.js} +1 -1
  97. package/web-app/dist/assets/{thumbs-up-Dz-l2GPC.js → thumbs-up-CHCVyJdw.js} +1 -1
  98. package/web-app/dist/assets/{trash-2-B-OsDmSo.js → trash-2-DncNxVq0.js} +1 -1
  99. package/web-app/dist/assets/{trending-down-CXTFOxzq.js → trending-down-D8Bp0azX.js} +1 -1
  100. package/web-app/dist/assets/{trending-up-FQEdqr3v.js → trending-up-AixETdKR.js} +1 -1
  101. package/web-app/dist/assets/{upload-CacWSNTg.js → upload-VCbjFn2H.js} +1 -1
  102. package/web-app/dist/assets/{usePolling-cIjZ9qd2.js → usePolling-CQvKD2Jj.js} +1 -1
  103. package/web-app/dist/assets/{user-DAouWFHi.js → user-SK-YguQ6.js} +1 -1
  104. package/web-app/dist/index.html +1 -1
  105. package/autonomy/company-seats.json +0 -82
  106. package/docs/COMPETITIVE-INTEL-2026-09.md +0 -444
  107. package/docs/FOUNDER-DECISIONS-TRUST-SEMANTICS-2026-08-01.md +0 -145
  108. package/docs/FOUNDER-STATUS-AND-DECISIONS-2026-07-01.md +0 -132
  109. package/docs/LOKI-10-BUILD-PROMPT.md +0 -350
  110. package/docs/STRATEGY-2026-2028.md +0 -124
  111. package/docs/V10-VISION.md +0 -161
  112. package/docs/v10/ADOPTION-PLAN.md +0 -375
  113. package/docs/v10/ARCHITECT-CUTS.md +0 -744
  114. package/docs/v10/BACKLOG.md +0 -231
  115. package/docs/v10/BOARD.md +0 -1403
  116. package/docs/v10/CONTROL-PLANE.md +0 -120
  117. package/docs/v10/CONTROL.md +0 -36
  118. package/docs/v10/CP-ASK-PLAN.md +0 -85
  119. package/docs/v10/CP-ENTERPRISE-UI.md +0 -537
  120. package/docs/v10/D51-PHASE-B.md +0 -120
  121. package/docs/v10/D61-SPEED.md +0 -105
  122. package/docs/v10/D63-CARDS.md +0 -354
  123. package/docs/v10/DECISIONS.md +0 -915
  124. package/docs/v10/E2E-D65.md +0 -123
  125. package/docs/v10/ENGINE-LAWS.md +0 -163
  126. package/docs/v10/ENGINE-MEASURE.md +0 -69
  127. package/docs/v10/ENGINE.md +0 -743
  128. package/docs/v10/FAILURE-CLASSES.md +0 -2026
  129. package/docs/v10/FC21B-FC22-PLAN.md +0 -107
  130. package/docs/v10/FIRST-RUN-GATE.md +0 -39
  131. package/docs/v10/FOUNDER-QUEUE.md +0 -29
  132. package/docs/v10/GAP-RESEARCH-1003.md +0 -32
  133. package/docs/v10/GUARDS.md +0 -883
  134. package/docs/v10/L0-WAVE1.md +0 -81
  135. package/docs/v10/LEGACY-REMOVAL.md +0 -408
  136. package/docs/v10/MEDIUM-ANALYSIS.md +0 -346
  137. package/docs/v10/METRICS.md +0 -645
  138. package/docs/v10/MODERNIZE.md +0 -437
  139. package/docs/v10/OPENCODE-PARITY-PLAN.md +0 -84
  140. package/docs/v10/OPERATING-MODEL.md +0 -60
  141. package/docs/v10/PLAN-V12.md +0 -896
  142. package/docs/v10/PROGRESS.md +0 -2735
  143. package/docs/v10/RELEASE-11.md +0 -77
  144. package/docs/v10/RETRO-2026.md +0 -650
  145. package/docs/v10/ROADMAP-D63.md +0 -35
  146. package/docs/v10/SCALE.md +0 -140
  147. package/docs/v10/SCORECARD-PLAN.md +0 -250
  148. package/docs/v10/SWARM-PROMPT.md +0 -98
  149. package/docs/v10/SWARM.md +0 -293
  150. package/docs/v10/cp-redesign/PARITY.md +0 -188
  151. package/docs/v10/cp-redesign/legacy/INDEX.md +0 -1167
  152. package/docs/v10/roadmap-10/01-measurement.md +0 -113
  153. package/docs/v10/roadmap-10/02-cost.md +0 -75
  154. package/docs/v10/roadmap-10/03-speed.md +0 -65
  155. package/docs/v10/roadmap-10/04-first-run.md +0 -61
  156. package/docs/v10/roadmap-10/05-deploy.md +0 -61
  157. package/docs/v10/roadmap-10/06-stability.md +0 -64
  158. package/docs/v10/roadmap-10/07-quality.md +0 -57
  159. package/docs/v10/roadmap-10/08-proof.md +0 -48
  160. package/docs/v10/roadmap-10/09-safety.md +0 -74
  161. package/docs/v10/roadmap-10/README.md +0 -1464
  162. package/skills/sdlc-fleet.md +0 -170
  163. package/tools/cockpit/SPEC.md +0 -275
  164. package/tools/cockpit/index.html +0 -146
  165. package/tools/cockpit/roi-basis.json +0 -33
  166. 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())