subcortex 0.3.0__py3-none-any.whl
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.
- subcortex/__init__.py +3 -0
- subcortex/__main__.py +3 -0
- subcortex/adapters/__init__.py +48 -0
- subcortex/adapters/base.py +230 -0
- subcortex/adapters/claude_family.py +133 -0
- subcortex/adapters/codex.py +87 -0
- subcortex/adapters/copilot.py +60 -0
- subcortex/adapters/cursor.py +36 -0
- subcortex/adapters/docker_agent.py +115 -0
- subcortex/adapters/gemini_family.py +60 -0
- subcortex/adapters/grok.py +98 -0
- subcortex/adapters/kimi_code.py +138 -0
- subcortex/adapters/letta_vibe.py +96 -0
- subcortex/adapters/openhands.py +153 -0
- subcortex/auth.py +59 -0
- subcortex/backends/__init__.py +23 -0
- subcortex/backends/base.py +22 -0
- subcortex/backends/jev.py +460 -0
- subcortex/backends/laya.py +149 -0
- subcortex/cli.py +809 -0
- subcortex/client.py +77 -0
- subcortex/config.py +263 -0
- subcortex/daemon.py +502 -0
- subcortex/evalset.py +241 -0
- subcortex/hook.py +254 -0
- subcortex/installers/__init__.py +62 -0
- subcortex/installers/amp.py +39 -0
- subcortex/installers/base.py +874 -0
- subcortex/installers/claude_family.py +229 -0
- subcortex/installers/codex.py +110 -0
- subcortex/installers/copilot.py +65 -0
- subcortex/installers/crush.py +36 -0
- subcortex/installers/cursor.py +79 -0
- subcortex/installers/gemini_family.py +83 -0
- subcortex/installers/goose.py +186 -0
- subcortex/installers/kimi_code.py +71 -0
- subcortex/installers/mcp_only.py +111 -0
- subcortex/installers/more_hooks.py +184 -0
- subcortex/installers/opencode.py +66 -0
- subcortex/installers/openhands.py +84 -0
- subcortex/installers/pi_cline.py +53 -0
- subcortex/ledger.py +92 -0
- subcortex/localhttp.py +59 -0
- subcortex/mcp_server.py +187 -0
- subcortex/metrics.py +56 -0
- subcortex/plugins/amp/subcortex.ts +258 -0
- subcortex/plugins/cline/subcortex.ts +340 -0
- subcortex/plugins/opencode/subcortex.ts +265 -0
- subcortex/plugins/pi/subcortex.ts +292 -0
- subcortex/policy.py +341 -0
- subcortex/presets.py +163 -0
- subcortex/provision.py +188 -0
- subcortex/service.py +149 -0
- subcortex/state.py +137 -0
- subcortex/transcript.py +211 -0
- subcortex/tuis.py +51 -0
- subcortex/ui.py +319 -0
- subcortex/verdicts.py +233 -0
- subcortex/wizard.py +474 -0
- subcortex-0.3.0.dist-info/METADATA +287 -0
- subcortex-0.3.0.dist-info/RECORD +64 -0
- subcortex-0.3.0.dist-info/WHEEL +5 -0
- subcortex-0.3.0.dist-info/entry_points.txt +3 -0
- subcortex-0.3.0.dist-info/top_level.txt +1 -0
subcortex/daemon.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""subcortex daemon: stdlib ThreadingHTTPServer on 127.0.0.1:<port>.
|
|
2
|
+
|
|
3
|
+
Endpoints:
|
|
4
|
+
|
|
5
|
+
- ``POST /decide`` ``{state, questions, backend?}`` → ``{success, answers|error}``
|
|
6
|
+
- ``POST /verdict/prompt`` ``{prompt}`` → ``{success, verdict|error}``
|
|
7
|
+
- ``POST /verdict/output`` ``{output, context?, task?}`` → ``{success, verdict|error}``
|
|
8
|
+
- ``GET /health`` → ``{ok, version, backend, model}`` (no token needed)
|
|
9
|
+
- ``GET /stats`` → in-memory counters + uptime
|
|
10
|
+
|
|
11
|
+
Policy endpoints — the four behaviors from ``subcortex.policy``, for plugin-based
|
|
12
|
+
TUIs (OpenCode, Amp, ...) so no plugin re-implements thresholds or heuristics:
|
|
13
|
+
|
|
14
|
+
- ``POST /v1/prompt-hint`` ``{prompt, session_id?, tui?}`` → ``{success, hint|null}``
|
|
15
|
+
- ``POST /v1/tool-output`` ``{output, tool?, input?, failed?, session_id?, tui?, task?}``
|
|
16
|
+
→ ``{success, replacement|null}``
|
|
17
|
+
- ``POST /v1/snapshot`` ``{session_id, messages, trigger?, tui?}`` → ``{success, saved}``
|
|
18
|
+
- ``POST /v1/restore`` ``{session_id, tui?}`` → ``{success, context|null}``
|
|
19
|
+
|
|
20
|
+
Every request except ``GET /health`` must carry the per-user token
|
|
21
|
+
(``auth.HEADER``); requests with an ``Origin``, a foreign ``Host`` or a
|
|
22
|
+
non-JSON body are refused, so web pages can't reach it either. Model work is
|
|
23
|
+
bounded: at most ``MAX_CONCURRENT_DECISIONS`` run at once and a request that
|
|
24
|
+
can't start before its client gives up (``X-Subcortex-Timeout-Ms``) gets 503
|
|
25
|
+
and fails open. The config file is re-read when it changes, and the daemon
|
|
26
|
+
exits (to be restarted by the next hook or the login service) when a newer
|
|
27
|
+
subcortex is installed underneath it.
|
|
28
|
+
|
|
29
|
+
Single instance via an ``fcntl`` lock in the data dir; PID file and a rotated
|
|
30
|
+
log alongside it. The backend is constructed lazily on the first request and
|
|
31
|
+
its model loads lazily on the first verdict.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import fcntl
|
|
37
|
+
import hmac
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import signal
|
|
41
|
+
import sys
|
|
42
|
+
import threading
|
|
43
|
+
import time
|
|
44
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from types import SimpleNamespace
|
|
47
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
48
|
+
|
|
49
|
+
from . import __version__, auth, ledger, policy, verdicts
|
|
50
|
+
from .backends import get_backend
|
|
51
|
+
from .backends.base import DecisionBackend
|
|
52
|
+
from .config import config_path, data_dir, load_config, lock_path, log_path, pid_path
|
|
53
|
+
from .metrics import METRICS
|
|
54
|
+
|
|
55
|
+
MAX_CONCURRENT_DECISIONS = 4
|
|
56
|
+
DEFAULT_CLIENT_TIMEOUT_S = 3.0
|
|
57
|
+
LOG_MAX_BYTES = 5_000_000
|
|
58
|
+
VERSION_CHECK_S = 30.0
|
|
59
|
+
_LOCAL_HOSTS = ("127.0.0.1", "localhost", "[::1]")
|
|
60
|
+
|
|
61
|
+
BackendFactory = Callable[..., DecisionBackend]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _daemon_backend(state: Any, override: Optional[str] = None) -> DecisionBackend:
|
|
65
|
+
"""Cached per-name backend lookup; construction (not model load) happens here."""
|
|
66
|
+
_refresh_config(state)
|
|
67
|
+
name = str(override or state.config.get("backend") or "laya").strip().lower()
|
|
68
|
+
with state.lock:
|
|
69
|
+
if name not in state.backends:
|
|
70
|
+
state.backends[name] = state.factory(state.config, name)
|
|
71
|
+
return state.backends[name]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _host_name(value: str) -> str:
|
|
75
|
+
value = value.strip().lower()
|
|
76
|
+
if value.startswith("["):
|
|
77
|
+
return value[:value.find("]") + 1] if "]" in value else value
|
|
78
|
+
return value.split(":", 1)[0]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _config_stamp() -> Any:
|
|
82
|
+
try:
|
|
83
|
+
st = config_path().stat()
|
|
84
|
+
return (st.st_mtime_ns, st.st_size)
|
|
85
|
+
except OSError:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _refresh_config(state: Any) -> None:
|
|
90
|
+
"""Pick up config changes (an opt-out must apply without a restart)."""
|
|
91
|
+
if not state.watch_config:
|
|
92
|
+
return
|
|
93
|
+
now = time.monotonic()
|
|
94
|
+
if now - state.config_checked < 1.0:
|
|
95
|
+
return
|
|
96
|
+
state.config_checked = now
|
|
97
|
+
stamp = _config_stamp()
|
|
98
|
+
if stamp == state.config_stamp:
|
|
99
|
+
return
|
|
100
|
+
fresh = load_config()
|
|
101
|
+
with state.lock:
|
|
102
|
+
backend_keys = ("backend", "model", "jev", "daemon_python")
|
|
103
|
+
if any(fresh.get(k) != state.config.get(k) for k in backend_keys):
|
|
104
|
+
state.backends.clear()
|
|
105
|
+
state.config, state.config_stamp = fresh, stamp
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _installed_version() -> str:
|
|
109
|
+
"""The version string of the subcortex now on disk (may differ from ours)."""
|
|
110
|
+
try:
|
|
111
|
+
text = (Path(__file__).resolve().parent / "__init__.py").read_text(encoding="utf-8")
|
|
112
|
+
except OSError:
|
|
113
|
+
return __version__
|
|
114
|
+
for line in text.splitlines():
|
|
115
|
+
if line.startswith("__version__"):
|
|
116
|
+
return line.split("=", 1)[1].strip().strip("\"'")
|
|
117
|
+
return __version__
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def make_handler(state: Any):
|
|
121
|
+
class Handler(BaseHTTPRequestHandler):
|
|
122
|
+
# Socket reads/writes only (not model time): a client that connects and
|
|
123
|
+
# stalls must not pin a thread forever.
|
|
124
|
+
timeout = 15
|
|
125
|
+
|
|
126
|
+
def _log(self, msg: str) -> None:
|
|
127
|
+
_append_log(getattr(self.server, "log_path", None), f"{self.log_date_time_string()} {msg}")
|
|
128
|
+
|
|
129
|
+
def log_message(self, fmt: str, *args: Any) -> None:
|
|
130
|
+
if getattr(self.server, "log_requests", False):
|
|
131
|
+
self._log(f"{self.address_string()} {fmt % args}")
|
|
132
|
+
|
|
133
|
+
def _refuse(self, code: int, error: str) -> None:
|
|
134
|
+
METRICS.record(f"refused_{code}")
|
|
135
|
+
self._send_json(code, {"success": False, "error": error})
|
|
136
|
+
|
|
137
|
+
def _allowed(self, needs_token: bool) -> bool:
|
|
138
|
+
"""Only this user's own local processes: no browsers, no other users."""
|
|
139
|
+
host = _host_name(self.headers.get("Host") or "")
|
|
140
|
+
if host and host not in _LOCAL_HOSTS: # DNS rebinding
|
|
141
|
+
self._refuse(403, "foreign Host")
|
|
142
|
+
return False
|
|
143
|
+
if self.headers.get("Origin"):
|
|
144
|
+
self._refuse(403, "cross-origin requests are refused")
|
|
145
|
+
return False
|
|
146
|
+
if needs_token and state.token:
|
|
147
|
+
sent = self.headers.get(auth.HEADER) or ""
|
|
148
|
+
if not hmac.compare_digest(sent.encode(), state.token.encode()):
|
|
149
|
+
self._refuse(401, "missing or wrong token")
|
|
150
|
+
return False
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
def _deadline(self) -> float:
|
|
154
|
+
try:
|
|
155
|
+
budget = float(self.headers.get("X-Subcortex-Timeout-Ms") or 0) / 1000.0
|
|
156
|
+
except ValueError:
|
|
157
|
+
budget = 0.0
|
|
158
|
+
budget = budget if 0 < budget <= 3600 else DEFAULT_CLIENT_TIMEOUT_S
|
|
159
|
+
return self.received + budget - 0.1
|
|
160
|
+
|
|
161
|
+
def _decision_slot(self) -> bool:
|
|
162
|
+
"""Wait for a model slot only as long as the client still listens."""
|
|
163
|
+
wait = self._deadline() - time.monotonic()
|
|
164
|
+
if wait > 0 and state.slots.acquire(timeout=wait):
|
|
165
|
+
return True
|
|
166
|
+
self._refuse(503, "busy")
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
def _send_json(self, code: int, obj: Dict[str, Any]) -> None:
|
|
170
|
+
body = json.dumps(obj).encode("utf-8")
|
|
171
|
+
self.send_response(code)
|
|
172
|
+
self.send_header("Content-Type", "application/json")
|
|
173
|
+
self.send_header("Content-Length", str(len(body)))
|
|
174
|
+
self.end_headers()
|
|
175
|
+
self.wfile.write(body)
|
|
176
|
+
|
|
177
|
+
def _read_json(self) -> Optional[Dict[str, Any]]:
|
|
178
|
+
content_type = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower()
|
|
179
|
+
if content_type != "application/json":
|
|
180
|
+
return None
|
|
181
|
+
try:
|
|
182
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
183
|
+
except ValueError:
|
|
184
|
+
return None
|
|
185
|
+
if length <= 0 or length > 10_000_000:
|
|
186
|
+
return None
|
|
187
|
+
try:
|
|
188
|
+
data = json.loads(self.rfile.read(length))
|
|
189
|
+
except (ValueError, RecursionError, OSError): # garbage, pathological nesting, stalled client
|
|
190
|
+
return None
|
|
191
|
+
return data if isinstance(data, dict) else None
|
|
192
|
+
|
|
193
|
+
# -- GET -----------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def do_GET(self) -> None:
|
|
196
|
+
self.received = time.monotonic()
|
|
197
|
+
if not self._allowed(needs_token=self.path != "/health"):
|
|
198
|
+
return
|
|
199
|
+
if self.path == "/health":
|
|
200
|
+
_refresh_config(state)
|
|
201
|
+
cfg = state.config
|
|
202
|
+
self._send_json(200, {
|
|
203
|
+
"ok": True,
|
|
204
|
+
"version": __version__,
|
|
205
|
+
"pid": os.getpid(),
|
|
206
|
+
"backend": cfg.get("backend", "laya"),
|
|
207
|
+
"model": cfg.get("model") if cfg.get("backend", "laya") == "laya"
|
|
208
|
+
else (cfg.get("jev") or {}).get("model"),
|
|
209
|
+
})
|
|
210
|
+
elif self.path == "/stats":
|
|
211
|
+
self._send_json(200, METRICS.snapshot())
|
|
212
|
+
else:
|
|
213
|
+
self._send_json(404, {"success": False, "error": "not found"})
|
|
214
|
+
|
|
215
|
+
# -- POST -----------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def do_POST(self) -> None:
|
|
218
|
+
self.received = time.monotonic()
|
|
219
|
+
if not self._allowed(needs_token=True):
|
|
220
|
+
return
|
|
221
|
+
if self.path == "/decide":
|
|
222
|
+
self._handle_decide()
|
|
223
|
+
elif self.path == "/verdict/prompt":
|
|
224
|
+
self._handle_verdict_prompt()
|
|
225
|
+
elif self.path == "/verdict/output":
|
|
226
|
+
self._handle_verdict_output()
|
|
227
|
+
elif self.path in _POLICY_ROUTES:
|
|
228
|
+
self._handle_policy(_POLICY_ROUTES[self.path])
|
|
229
|
+
else:
|
|
230
|
+
self._send_json(404, {"success": False, "error": "not found"})
|
|
231
|
+
|
|
232
|
+
def _handle_decide(self) -> None:
|
|
233
|
+
payload = self._read_json()
|
|
234
|
+
if payload is None or "state" not in payload or "questions" not in payload:
|
|
235
|
+
self._send_json(400, {"success": False, "error": "need {state, questions}"})
|
|
236
|
+
return
|
|
237
|
+
if not self._decision_slot():
|
|
238
|
+
return
|
|
239
|
+
try:
|
|
240
|
+
backend = _daemon_backend(state, payload.get("backend"))
|
|
241
|
+
start = time.perf_counter()
|
|
242
|
+
result = backend.predict(payload["state"], payload["questions"])
|
|
243
|
+
METRICS.record("decide", round((time.perf_counter() - start) * 1000.0, 2))
|
|
244
|
+
reply = {"success": True, "answers": result.get("answers", result)}
|
|
245
|
+
for key in ("model", "usage"):
|
|
246
|
+
if key in result:
|
|
247
|
+
reply[key] = result[key]
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
self._log(f"decide failed: {exc}")
|
|
250
|
+
reply = {"success": False, "error": str(exc)}
|
|
251
|
+
finally:
|
|
252
|
+
state.slots.release()
|
|
253
|
+
self._send_json(200, reply)
|
|
254
|
+
|
|
255
|
+
def _handle_verdict_prompt(self) -> None:
|
|
256
|
+
payload = self._read_json()
|
|
257
|
+
if payload is None or "prompt" not in payload:
|
|
258
|
+
self._send_json(400, {"success": False, "error": "need {prompt}"})
|
|
259
|
+
return
|
|
260
|
+
if not self._decision_slot():
|
|
261
|
+
return
|
|
262
|
+
try:
|
|
263
|
+
backend = _daemon_backend(state)
|
|
264
|
+
verdict = verdicts.classify_prompt(str(payload["prompt"]), backend=backend, config=state.config)
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
self._log(f"verdict/prompt failed: {exc}")
|
|
267
|
+
verdict = None
|
|
268
|
+
finally:
|
|
269
|
+
state.slots.release()
|
|
270
|
+
if verdict is None:
|
|
271
|
+
self._send_json(200, {"success": False, "error": "verdict failed"})
|
|
272
|
+
else:
|
|
273
|
+
self._send_json(200, {"success": True, "verdict": verdict})
|
|
274
|
+
|
|
275
|
+
def _handle_verdict_output(self) -> None:
|
|
276
|
+
payload = self._read_json()
|
|
277
|
+
if payload is None or "output" not in payload:
|
|
278
|
+
self._send_json(400, {"success": False, "error": "need {output}"})
|
|
279
|
+
return
|
|
280
|
+
if not self._decision_slot():
|
|
281
|
+
return
|
|
282
|
+
try:
|
|
283
|
+
backend = _daemon_backend(state)
|
|
284
|
+
verdict = verdicts.judge_output(
|
|
285
|
+
str(payload["output"]),
|
|
286
|
+
context=str(payload.get("context", "")),
|
|
287
|
+
backend=backend,
|
|
288
|
+
config=state.config,
|
|
289
|
+
task=str(payload.get("task") or ""),
|
|
290
|
+
)
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
self._log(f"verdict/output failed: {exc}")
|
|
293
|
+
verdict = None
|
|
294
|
+
finally:
|
|
295
|
+
state.slots.release()
|
|
296
|
+
if verdict is None:
|
|
297
|
+
self._send_json(200, {"success": False, "error": "verdict failed"})
|
|
298
|
+
else:
|
|
299
|
+
self._send_json(200, {"success": True, "verdict": verdict})
|
|
300
|
+
|
|
301
|
+
def _handle_policy(self, route: str) -> None:
|
|
302
|
+
payload = self._read_json()
|
|
303
|
+
if payload is None:
|
|
304
|
+
self._send_json(400, {"success": False, "error": "need a JSON object"})
|
|
305
|
+
return
|
|
306
|
+
_refresh_config(state)
|
|
307
|
+
cfg = state.config
|
|
308
|
+
# Plugins name their TUI so sessions of different TUIs never share state.
|
|
309
|
+
tui = str(payload.get("tui") or "plugin")
|
|
310
|
+
sid = payload.get("session_id")
|
|
311
|
+
decides = route in ("prompt-hint", "tool-output")
|
|
312
|
+
if decides and not self._decision_slot():
|
|
313
|
+
return
|
|
314
|
+
commits: List[Callable[[], None]] = []
|
|
315
|
+
try:
|
|
316
|
+
if decides:
|
|
317
|
+
backend = _daemon_backend(state)
|
|
318
|
+
if route == "prompt-hint":
|
|
319
|
+
policy.remember_prompt(sid, payload.get("prompt"), tui=tui)
|
|
320
|
+
hint = policy.prompt_hint(
|
|
321
|
+
payload.get("prompt"), cfg,
|
|
322
|
+
lambda p: verdicts.classify_prompt(p, backend=backend, config=cfg))
|
|
323
|
+
METRICS.record("hint_given" if hint else "hint_skipped")
|
|
324
|
+
result: Dict[str, Any] = {"hint": hint}
|
|
325
|
+
if hint:
|
|
326
|
+
commits.append(lambda: ledger.record("hint", tui))
|
|
327
|
+
elif route == "tool-output":
|
|
328
|
+
replacement = policy.trim_output(
|
|
329
|
+
payload.get("output"), cfg,
|
|
330
|
+
lambda o, c, t: verdicts.judge_output(o, context=c, backend=backend,
|
|
331
|
+
config=cfg, task=t),
|
|
332
|
+
tool=str(payload.get("tool") or ""),
|
|
333
|
+
tool_input=payload.get("input"),
|
|
334
|
+
failed=bool(payload.get("failed")),
|
|
335
|
+
task=str(payload.get("task") or policy.last_prompt(sid, tui=tui)))
|
|
336
|
+
METRICS.record("output_trimmed" if replacement else "output_kept")
|
|
337
|
+
result = {"replacement": replacement}
|
|
338
|
+
if replacement:
|
|
339
|
+
before, after = len(str(payload.get("output") or "")), len(replacement)
|
|
340
|
+
commits.append(lambda: ledger.record("trim", tui, before=before, after=after))
|
|
341
|
+
elif route == "snapshot":
|
|
342
|
+
messages = payload.get("messages")
|
|
343
|
+
saved = policy.save_snapshot(
|
|
344
|
+
sid, messages if isinstance(messages, list) else [],
|
|
345
|
+
cfg, str(payload.get("trigger") or ""), tui=tui)
|
|
346
|
+
result = {"saved": saved}
|
|
347
|
+
else: # restore: deleted only once the reply went out (claim-then-commit)
|
|
348
|
+
result = {"context": policy.restore_snapshot(sid, cfg, tui=tui, commits=commits)}
|
|
349
|
+
if result["context"]:
|
|
350
|
+
commits.append(lambda: ledger.record("restore", tui))
|
|
351
|
+
except Exception as exc:
|
|
352
|
+
self._log(f"/v1/{route} failed: {exc}")
|
|
353
|
+
self._send_json(200, {"success": False, "error": "policy failed"})
|
|
354
|
+
return
|
|
355
|
+
finally:
|
|
356
|
+
if decides:
|
|
357
|
+
state.slots.release()
|
|
358
|
+
self._send_json(200, {"success": True, **result})
|
|
359
|
+
self.wfile.flush()
|
|
360
|
+
for commit in commits:
|
|
361
|
+
try:
|
|
362
|
+
commit()
|
|
363
|
+
except Exception:
|
|
364
|
+
pass
|
|
365
|
+
|
|
366
|
+
return Handler
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
_POLICY_ROUTES = {
|
|
370
|
+
"/v1/prompt-hint": "prompt-hint",
|
|
371
|
+
"/v1/tool-output": "tool-output",
|
|
372
|
+
"/v1/snapshot": "snapshot",
|
|
373
|
+
"/v1/restore": "restore",
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _append_log(path: Optional[str], line: str) -> None:
|
|
378
|
+
if not path:
|
|
379
|
+
return
|
|
380
|
+
try:
|
|
381
|
+
log = Path(path)
|
|
382
|
+
if log.is_file() and log.stat().st_size > LOG_MAX_BYTES:
|
|
383
|
+
log.replace(log.with_name(log.name + ".1"))
|
|
384
|
+
with open(log, "a", encoding="utf-8") as fh:
|
|
385
|
+
fh.write(line.rstrip() + "\n")
|
|
386
|
+
except OSError:
|
|
387
|
+
pass
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class _Server(ThreadingHTTPServer):
|
|
391
|
+
# The stdlib default backlog is 5: a burst of hooks from several sessions
|
|
392
|
+
# (plus plugins) would get connection resets. Every hook must get an answer.
|
|
393
|
+
request_queue_size = 256
|
|
394
|
+
daemon_threads = True
|
|
395
|
+
log_path: Optional[str] = None
|
|
396
|
+
log_requests = False
|
|
397
|
+
|
|
398
|
+
def server_bind(self) -> None:
|
|
399
|
+
# HTTPServer.server_bind resolves its own address (socket.getfqdn), a
|
|
400
|
+
# reverse-DNS lookup that can hang for many seconds on some machines.
|
|
401
|
+
# We only ever serve 127.0.0.1: skip it.
|
|
402
|
+
import socketserver
|
|
403
|
+
|
|
404
|
+
socketserver.TCPServer.server_bind(self)
|
|
405
|
+
self.server_name = "localhost"
|
|
406
|
+
self.server_port = self.server_address[1]
|
|
407
|
+
|
|
408
|
+
def handle_error(self, request: Any, client_address: Any) -> None:
|
|
409
|
+
# A client that gave up (hook budget spent) is normal, not an error.
|
|
410
|
+
exc = sys.exc_info()[1]
|
|
411
|
+
if isinstance(exc, (BrokenPipeError, ConnectionResetError, TimeoutError)):
|
|
412
|
+
return
|
|
413
|
+
import traceback
|
|
414
|
+
|
|
415
|
+
_append_log(self.log_path, f"request from {client_address} failed:\n{traceback.format_exc()}")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def create_server(
|
|
419
|
+
port: int,
|
|
420
|
+
config: Optional[Dict[str, Any]] = None,
|
|
421
|
+
backend_factory: Optional[BackendFactory] = None,
|
|
422
|
+
token: Optional[str] = None,
|
|
423
|
+
) -> ThreadingHTTPServer:
|
|
424
|
+
"""Build (but do not start) the daemon HTTP server. ``port=0`` picks an
|
|
425
|
+
ephemeral port — used by tests with a stubbed ``backend_factory``. The token
|
|
426
|
+
defaults to the per-user one in the data dir (created if missing); pass ""
|
|
427
|
+
to disable the check. A given ``config`` is used as is (never reloaded)."""
|
|
428
|
+
cfg = config or load_config()
|
|
429
|
+
state = SimpleNamespace(
|
|
430
|
+
config=cfg,
|
|
431
|
+
factory=backend_factory or get_backend,
|
|
432
|
+
backends={},
|
|
433
|
+
lock=threading.Lock(),
|
|
434
|
+
slots=threading.BoundedSemaphore(MAX_CONCURRENT_DECISIONS),
|
|
435
|
+
token=auth.ensure_token() if token is None else token,
|
|
436
|
+
watch_config=config is None,
|
|
437
|
+
config_checked=time.monotonic(),
|
|
438
|
+
config_stamp=_config_stamp(),
|
|
439
|
+
)
|
|
440
|
+
return _Server(("127.0.0.1", port), make_handler(state))
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _acquire_lock():
|
|
444
|
+
data_dir().mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
445
|
+
fd = open(lock_path(), "w")
|
|
446
|
+
try:
|
|
447
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
448
|
+
except OSError:
|
|
449
|
+
fd.close()
|
|
450
|
+
return None
|
|
451
|
+
return fd
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _watch_version(server: ThreadingHTTPServer) -> None:
|
|
455
|
+
"""Exit when a different subcortex version is installed underneath us, so
|
|
456
|
+
the next hook (or the login service) starts the new code."""
|
|
457
|
+
while True:
|
|
458
|
+
time.sleep(VERSION_CHECK_S)
|
|
459
|
+
installed = _installed_version()
|
|
460
|
+
if installed != __version__:
|
|
461
|
+
_append_log(getattr(server, "log_path", None),
|
|
462
|
+
f"subcortex {installed} is installed (running {__version__}); exiting to restart")
|
|
463
|
+
server.restart_requested = True
|
|
464
|
+
server.shutdown()
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def run(port: Optional[int] = None, config: Optional[Dict[str, Any]] = None) -> int:
|
|
469
|
+
"""Run the daemon in the foreground. Holds the single-instance lock for the
|
|
470
|
+
process lifetime; writes/removes the PID file; stops cleanly on SIGTERM."""
|
|
471
|
+
cfg = config or load_config()
|
|
472
|
+
_append_log(str(log_path()), f"{time.strftime('%Y-%m-%d %H:%M:%S')} subcortex {__version__} "
|
|
473
|
+
f"starting (pid {os.getpid()}, port {port or cfg['port']})")
|
|
474
|
+
lock_fd = _acquire_lock()
|
|
475
|
+
if lock_fd is None:
|
|
476
|
+
# Another instance is serving: not an error (a login service must not
|
|
477
|
+
# restart-loop because a hook already started the daemon).
|
|
478
|
+
print(f"subcortex daemon already running (lock: {lock_path()})", file=sys.stderr)
|
|
479
|
+
return 0
|
|
480
|
+
server = create_server(int(port or cfg["port"]), None if config is None else cfg)
|
|
481
|
+
server.log_path = str(log_path())
|
|
482
|
+
pid_path().write_text(str(os.getpid()))
|
|
483
|
+
# serve_forever must return (so the PID file is removed) on SIGTERM too.
|
|
484
|
+
signal.signal(signal.SIGTERM, lambda *_: threading.Thread(target=server.shutdown, daemon=True).start())
|
|
485
|
+
threading.Thread(target=_watch_version, args=(server,), daemon=True).start()
|
|
486
|
+
actual_port = server.server_address[1]
|
|
487
|
+
message = f"subcortex daemon {__version__} listening on 127.0.0.1:{actual_port} (pid {os.getpid()})"
|
|
488
|
+
_append_log(server.log_path, f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}")
|
|
489
|
+
print(message, flush=True)
|
|
490
|
+
try:
|
|
491
|
+
server.serve_forever()
|
|
492
|
+
except KeyboardInterrupt:
|
|
493
|
+
pass
|
|
494
|
+
finally:
|
|
495
|
+
server.server_close()
|
|
496
|
+
try:
|
|
497
|
+
pid_path().unlink()
|
|
498
|
+
except OSError:
|
|
499
|
+
pass
|
|
500
|
+
lock_fd.close()
|
|
501
|
+
# 75 (EX_TEMPFAIL) makes launchd/systemd start the new version; hooks would too.
|
|
502
|
+
return 75 if getattr(server, "restart_requested", False) else 0
|