sqlseed-web 0.2.4__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.
Files changed (87) hide show
  1. sqlseed_web/AGENTS.md +106 -0
  2. sqlseed_web/__init__.py +24 -0
  3. sqlseed_web/__main__.py +8 -0
  4. sqlseed_web/_application.py +205 -0
  5. sqlseed_web/ai_settings.py +290 -0
  6. sqlseed_web/api.py +1102 -0
  7. sqlseed_web/app.py +26 -0
  8. sqlseed_web/managed_worker.py +184 -0
  9. sqlseed_web/operation_errors.py +42 -0
  10. sqlseed_web/plugin_environment.py +231 -0
  11. sqlseed_web/plugin_management.py +322 -0
  12. sqlseed_web/plugin_process.py +131 -0
  13. sqlseed_web/runtime_lifecycle.py +130 -0
  14. sqlseed_web/runtime_session.py +97 -0
  15. sqlseed_web/settings_environment.py +368 -0
  16. sqlseed_web/sqlite_target.py +86 -0
  17. sqlseed_web/state.py +348 -0
  18. sqlseed_web/static/AGENTS.md +180 -0
  19. sqlseed_web/static/ai.css +57 -0
  20. sqlseed_web/static/configs.css +50 -0
  21. sqlseed_web/static/date-picker.css +230 -0
  22. sqlseed_web/static/disclosure.css +149 -0
  23. sqlseed_web/static/graph-clarity.css +103 -0
  24. sqlseed_web/static/index.html +29 -0
  25. sqlseed_web/static/js/api.js +228 -0
  26. sqlseed_web/static/js/app.js +97 -0
  27. sqlseed_web/static/js/dropdown.js +432 -0
  28. sqlseed_web/static/js/filepicker.js +203 -0
  29. sqlseed_web/static/js/genform.js +1066 -0
  30. sqlseed_web/static/js/labels.js +195 -0
  31. sqlseed_web/static/js/pages/browse.js +209 -0
  32. sqlseed_web/static/js/pages/configs.js +424 -0
  33. sqlseed_web/static/js/pages/connect.js +332 -0
  34. sqlseed_web/static/js/pages/heal.js +395 -0
  35. sqlseed_web/static/js/pages/meta.js +110 -0
  36. sqlseed_web/static/js/pages/runs.js +293 -0
  37. sqlseed_web/static/js/pages/settings.js +942 -0
  38. sqlseed_web/static/js/pages/wizard.js +751 -0
  39. sqlseed_web/static/js/pages/workbench.js +3123 -0
  40. sqlseed_web/static/js/tree.js +126 -0
  41. sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
  42. sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
  43. sqlseed_web/static/js/workbench/ai-stream.js +116 -0
  44. sqlseed_web/static/js/workbench/ai.js +888 -0
  45. sqlseed_web/static/js/workbench/connection.js +508 -0
  46. sqlseed_web/static/js/workbench/date-picker.js +445 -0
  47. sqlseed_web/static/js/workbench/dependency-view.js +119 -0
  48. sqlseed_web/static/js/workbench/editor.js +1236 -0
  49. sqlseed_web/static/js/workbench/focus.js +11 -0
  50. sqlseed_web/static/js/workbench/graph-layout.js +332 -0
  51. sqlseed_web/static/js/workbench/graph.js +970 -0
  52. sqlseed_web/static/js/workbench/guidance.js +29 -0
  53. sqlseed_web/static/js/workbench/model.js +124 -0
  54. sqlseed_web/static/js/workbench/plugin-management.js +512 -0
  55. sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
  56. sqlseed_web/static/js/workbench/preview.js +572 -0
  57. sqlseed_web/static/js/workbench/provider-guide.js +33 -0
  58. sqlseed_web/static/js/workbench/recovery.js +28 -0
  59. sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
  60. sqlseed_web/static/js/workbench/session.js +174 -0
  61. sqlseed_web/static/js/workbench/table-data.js +186 -0
  62. sqlseed_web/static/js/workbench/ui.js +262 -0
  63. sqlseed_web/static/navigation.css +92 -0
  64. sqlseed_web/static/preview.css +29 -0
  65. sqlseed_web/static/runs.css +53 -0
  66. sqlseed_web/static/scrollbars.css +42 -0
  67. sqlseed_web/static/settings.css +108 -0
  68. sqlseed_web/static/style.css +3382 -0
  69. sqlseed_web/static/table-data.css +27 -0
  70. sqlseed_web/static/workbench.css +509 -0
  71. sqlseed_web/supervised_plugins.py +173 -0
  72. sqlseed_web/supervisor.py +238 -0
  73. sqlseed_web/workbench.py +381 -0
  74. sqlseed_web/workbench_ai.py +887 -0
  75. sqlseed_web/workbench_ai_relations.py +285 -0
  76. sqlseed_web/workbench_ai_stream.py +172 -0
  77. sqlseed_web/workbench_data.py +163 -0
  78. sqlseed_web/workbench_execution.py +199 -0
  79. sqlseed_web/workbench_runtime.py +1218 -0
  80. sqlseed_web/workbench_schema.py +277 -0
  81. sqlseed_web/workbench_store.py +458 -0
  82. sqlseed_web/worker_control.py +192 -0
  83. sqlseed_web-0.2.4.dist-info/METADATA +105 -0
  84. sqlseed_web-0.2.4.dist-info/RECORD +87 -0
  85. sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
  86. sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
  87. sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
@@ -0,0 +1,322 @@
1
+ """Opt-in maintenance API for a fixed set of optional components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hmac
6
+ import ipaddress
7
+ import secrets
8
+ import shlex
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import threading
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Any, Literal, Protocol
16
+ from urllib.parse import urlsplit
17
+
18
+ from fastapi import APIRouter, HTTPException, Request
19
+ from pydantic import BaseModel, ConfigDict
20
+
21
+ from sqlseed_web import plugin_environment
22
+ from sqlseed_web.plugin_environment import COMPONENT_DISTRIBUTIONS, EnvironmentLock, InstalledPackage
23
+ from sqlseed_web.plugin_process import run_installer
24
+
25
+ router = APIRouter(prefix="/api/settings/plugins", tags=["plugin-maintenance"])
26
+
27
+
28
+ class PlanRequest(BaseModel):
29
+ model_config = ConfigDict(extra="forbid")
30
+ component_id: Literal["ai", "cli", "mcp", "mimesis"]
31
+ action: Literal["install", "uninstall"]
32
+
33
+
34
+ class ExecuteRequest(BaseModel):
35
+ model_config = ConfigDict(extra="forbid")
36
+ plan_id: str
37
+
38
+
39
+ class ManagementService(Protocol):
40
+ enabled: bool
41
+ token: str
42
+
43
+ def status(self) -> dict[str, Any]: ...
44
+ def plan(self, body: PlanRequest) -> dict[str, Any]: ...
45
+ def execute(self, body: ExecuteRequest) -> dict[str, Any]: ...
46
+ def task_snapshot(self, task_id: str | None = None) -> dict[str, Any]: ...
47
+ def recover(self) -> dict[str, Any]: ...
48
+
49
+
50
+ def _reject(message: str, code: str = "plugin_management_unavailable", status: int = 409) -> HTTPException:
51
+ return HTTPException(status_code=status, detail={"code": code, "message": message})
52
+
53
+
54
+ def _loopback(host: str) -> bool:
55
+ if host == "localhost":
56
+ return True
57
+ try:
58
+ return ipaddress.ip_address(host).is_loopback
59
+ except ValueError:
60
+ return False
61
+
62
+
63
+ def guard_request(request: Request, manager: ManagementService) -> None:
64
+ """Check transport-derived client, literal Host, same Origin, and a per-process nonce."""
65
+ hosts = request.headers.getlist("host")
66
+ try:
67
+ parsed = urlsplit(f"//{hosts[0]}") if len(hosts) == 1 else None
68
+ valid_host = parsed is not None and parsed.hostname is not None and _loopback(parsed.hostname)
69
+ valid_host = valid_host and parsed is not None and not parsed.username and not parsed.password
70
+ valid_host = valid_host and parsed is not None and not parsed.path and not parsed.query and not parsed.fragment
71
+ if parsed is not None:
72
+ _ = parsed.port
73
+ except ValueError:
74
+ valid_host = False
75
+ if not valid_host or request.client is None or not _loopback(request.client.host):
76
+ raise _reject("插件管理仅允许本机访问。", "plugin_management_forbidden", 403)
77
+ origin = request.headers.get("origin")
78
+ expected = f"{request.url.scheme}://{hosts[0]}"
79
+ if origin is not None and origin != expected:
80
+ raise _reject("插件管理请求必须来自当前页面。", "plugin_management_forbidden", 403)
81
+ if request.headers.get("sec-fetch-site") == "cross-site":
82
+ raise _reject("插件管理请求必须来自当前页面。", "plugin_management_forbidden", 403)
83
+ if request.method not in {"GET", "HEAD"}:
84
+ token = request.headers.get("x-sqlseed-management-token", "")
85
+ if origin != expected or not hmac.compare_digest(token, manager.token):
86
+ raise _reject("插件管理请求缺少有效的页面凭据。", "plugin_management_forbidden", 403)
87
+
88
+
89
+ class PluginManager:
90
+ """One reviewable plan and one task, protected for the server lifetime."""
91
+
92
+ def __init__(self, *, enabled: bool) -> None:
93
+ self.enabled = enabled
94
+ self.token = secrets.token_urlsafe(32)
95
+ self.environment = plugin_environment._environment()
96
+ self.restart_required = False
97
+ self._lock = threading.RLock()
98
+ self._environment_lock: EnvironmentLock | None = None
99
+ self._startup_reason: str | None = None
100
+ self._plan: dict[str, Any] | None = None
101
+ self._snapshot: dict[str, InstalledPackage] = {}
102
+ self._task: dict[str, Any] | None = None
103
+ self._worker: threading.Thread | None = None
104
+
105
+ def start(self) -> None:
106
+ prefix = self.environment.prefix
107
+ if not (prefix / "pyvenv.cfg").is_file() or (not self.enabled and self.environment.reason):
108
+ return
109
+ environment_lock = EnvironmentLock(prefix, exclusive=self.enabled)
110
+ try:
111
+ environment_lock.acquire()
112
+ except (OSError, RuntimeError) as exc:
113
+ if not self.enabled:
114
+ raise RuntimeError("无法领取 Web 环境使用锁;请检查权限或停止正在运行的维护服务。") from exc
115
+ self._startup_reason = "无法领取维护锁;请检查权限并停止使用此环境的其他 Web 进程。"
116
+ return
117
+ self._environment_lock = environment_lock
118
+
119
+ def stop(self) -> None:
120
+ if self._worker is not None:
121
+ self._worker.join()
122
+ if self._environment_lock is not None:
123
+ self._environment_lock.release()
124
+ self._environment_lock = None
125
+
126
+ def _reason(self) -> str | None:
127
+ if not self.enabled:
128
+ return "此部署由外部服务托管,暂不支持网页安装或卸载;请联系部署管理员。"
129
+ if self.restart_required:
130
+ return "环境已执行变更,请先停止维护服务并正常重启 Web 验证。"
131
+ if self._task and self._task["status"] == "running":
132
+ return "已有组件操作正在执行。"
133
+ if self.environment.reason:
134
+ return self.environment.reason
135
+ if self._startup_reason:
136
+ return self._startup_reason
137
+ if self._environment_lock is None:
138
+ return "维护服务尚未领取环境锁。"
139
+ return None
140
+
141
+ def status(self) -> dict[str, Any]:
142
+ with self._lock:
143
+ reason = self._reason()
144
+ try:
145
+ components = plugin_environment.package_status(
146
+ plugin_environment.installed_packages(self.environment.prefix), reason
147
+ )
148
+ except RuntimeError:
149
+ reason = "已安装组件的元数据无法安全解析;请先修复当前 Python 环境。"
150
+ components = []
151
+ args = [self.environment.executable, "-m", "sqlseed_web", "--manage-plugins"]
152
+ command = shlex.join(args)
153
+ if sys.platform == "win32":
154
+ command = "& " + " ".join("'" + arg.replace("'", "''") + "'" for arg in args)
155
+ return {
156
+ "enabled": self.enabled,
157
+ "automatic_lifecycle": False,
158
+ "available": reason is None,
159
+ "reason": reason,
160
+ "maintenance_command": command,
161
+ "python_executable": self.environment.executable,
162
+ "token": self.token if self.enabled else None,
163
+ "restart_required": self.restart_required,
164
+ "active_task": self.task_snapshot() if self._task else None,
165
+ "components": components,
166
+ }
167
+
168
+ def plan(self, body: PlanRequest) -> dict[str, Any]:
169
+ with self._lock:
170
+ if reason := self._reason():
171
+ raise _reject(reason)
172
+ try:
173
+ packages = plugin_environment.installed_packages(self.environment.prefix)
174
+ except RuntimeError as exc:
175
+ raise _reject(str(exc)) from exc
176
+ distribution = COMPONENT_DISTRIBUTIONS[body.component_id]
177
+ package = packages.get(distribution)
178
+ if body.action == "install" and package is not None:
179
+ raise _reject("此组件已经安装;加载异常请使用修复指引。")
180
+ if body.action == "uninstall":
181
+ if package is None:
182
+ raise _reject("此组件尚未安装。")
183
+ if users := plugin_environment.required_by(distribution, packages):
184
+ raise _reject(f"由 {', '.join(users)} 使用,请先卸载这些可选组件。")
185
+ self._snapshot = packages
186
+ self._plan = {
187
+ "plan_id": secrets.token_urlsafe(24),
188
+ "component_id": body.component_id,
189
+ "action": body.action,
190
+ "distribution": distribution,
191
+ "version": package.version if package else None,
192
+ "summary": f"{'安装' if body.action == 'install' else '卸载'} {distribution},目标是当前 Web 的 Python 环境。",
193
+ "warnings": [
194
+ "安装会访问软件包源,并冻结所有已安装组件的版本;不兼容时失败,不自动升级。"
195
+ if body.action == "install"
196
+ else "仅卸载选中的组件,不自动卸载其依赖。重新安装需要软件源提供兼容版本;开发版或本地安装的组件可能无法恢复。",
197
+ "完成或失败后均需停止维护服务,正常重启 Web 并检查组件状态。",
198
+ ],
199
+ "expires_in": 300,
200
+ "expires_at": time.monotonic() + 300,
201
+ }
202
+ return {key: value for key, value in self._plan.items() if key != "expires_at"}
203
+
204
+ def execute(self, body: ExecuteRequest) -> dict[str, Any]:
205
+ with self._lock:
206
+ if reason := self._reason():
207
+ raise _reject(reason)
208
+ operation_plan = self._plan
209
+ if (
210
+ operation_plan is None
211
+ or not hmac.compare_digest(operation_plan["plan_id"], body.plan_id)
212
+ or time.monotonic() > operation_plan["expires_at"]
213
+ ):
214
+ raise _reject("操作计划已失效,请重新查看并确认。")
215
+ try:
216
+ current = plugin_environment.installed_packages(self.environment.prefix)
217
+ except RuntimeError as exc:
218
+ raise _reject(str(exc)) from exc
219
+ if current != self._snapshot or plugin_environment._environment() != self.environment:
220
+ self._plan = None
221
+ raise _reject("Python 环境已发生变化,请重新查看并确认操作计划。")
222
+ self._task = {
223
+ "task_id": secrets.token_urlsafe(24),
224
+ "component_id": operation_plan["component_id"],
225
+ "action": operation_plan["action"],
226
+ "status": "running",
227
+ "output": [],
228
+ "message": "正在准备环境操作。",
229
+ "restart_required": False,
230
+ "returncode": None,
231
+ }
232
+ self._plan = None
233
+ self._worker = threading.Thread(
234
+ target=self._run, args=(operation_plan, current), daemon=False, name="sqlseed-plugin-install"
235
+ )
236
+ try:
237
+ self._worker.start()
238
+ except RuntimeError:
239
+ self._worker = None
240
+ self._task.update(status="failed", message="无法启动组件操作。")
241
+ return self.task_snapshot()
242
+
243
+ def task_snapshot(self, task_id: str | None = None) -> dict[str, Any]:
244
+ with self._lock:
245
+ if self._task is None or (task_id is not None and self._task["task_id"] != task_id):
246
+ raise _reject("找不到此组件操作;服务重启后任务记录不再保留。", "plugin_task_not_found", 404)
247
+ return {**self._task, "output": list(self._task["output"])}
248
+
249
+ def _output(self, text: str) -> None:
250
+ with self._lock:
251
+ if self._task is not None and len(self._task["output"]) < 200:
252
+ self._task["output"].append(text[:2000])
253
+
254
+ def _run(self, operation_plan: dict[str, Any], before: dict[str, InstalledPackage]) -> None:
255
+ result = None
256
+ succeeded = False
257
+ message = "组件操作失败;请检查输出并使用原环境管理工具修复。"
258
+ try:
259
+ with tempfile.TemporaryDirectory(prefix="sqlseed-plugin-plan-") as directory:
260
+ constraints = Path(directory) / "constraints.txt"
261
+ constraints.write_text(
262
+ "".join(f"{name}=={package.version}\n" for name, package in sorted(before.items())),
263
+ encoding="utf-8",
264
+ )
265
+ arguments = plugin_environment.installer_arguments(
266
+ self.environment, operation_plan["action"], operation_plan["distribution"], constraints
267
+ )
268
+ with self._lock:
269
+ self.restart_required = True
270
+ if self._task is not None:
271
+ self._task.update(restart_required=True, message="安装工具正在运行,请等待完成。")
272
+ if self._environment_lock is None:
273
+ raise RuntimeError("环境锁已失效,未执行安装工具。")
274
+ result = run_installer(arguments, self._output, lock_descriptor=self._environment_lock.fileno())
275
+ after = plugin_environment.installed_packages(self.environment.prefix)
276
+ target = operation_plan["distribution"]
277
+ expected_target = target in after if operation_plan["action"] == "install" else target not in after
278
+ preserved = all(after.get(name) == package for name, package in before.items() if name != target)
279
+ if succeeded := result == 0 and expected_target and preserved:
280
+ message = "组件操作完成;请停止维护服务并正常重启 Web 验证。"
281
+ elif result == 0:
282
+ message = "安装工具已退出,但组件元数据核验未通过;请检查环境并重启 Web。"
283
+ except (OSError, RuntimeError, ValueError, subprocess.SubprocessError):
284
+ self._output("无法完成环境操作;请使用原环境管理工具检查。")
285
+ finally:
286
+ with self._lock:
287
+ if self._task is not None:
288
+ self._task.update(status="succeeded" if succeeded else "failed", message=message, returncode=result)
289
+
290
+ def recover(self) -> dict[str, Any]:
291
+ raise _reject("此部署不支持自动恢复服务。")
292
+
293
+
294
+ def _manager(request: Request) -> ManagementService:
295
+ manager: ManagementService = request.app.state.plugin_manager
296
+ guard_request(request, manager)
297
+ return manager
298
+
299
+
300
+ @router.get("/management")
301
+ def management(request: Request) -> dict[str, Any]:
302
+ return _manager(request).status()
303
+
304
+
305
+ @router.post("/plan")
306
+ def plan(body: PlanRequest, request: Request) -> dict[str, Any]:
307
+ return _manager(request).plan(body)
308
+
309
+
310
+ @router.post("/execute", status_code=202)
311
+ def execute(body: ExecuteRequest, request: Request) -> dict[str, Any]:
312
+ return _manager(request).execute(body)
313
+
314
+
315
+ @router.get("/tasks/{task_id}")
316
+ def task(task_id: str, request: Request) -> dict[str, Any]:
317
+ return _manager(request).task_snapshot(task_id)
318
+
319
+
320
+ @router.post("/recover", status_code=202)
321
+ def recover(request: Request) -> dict[str, Any]:
322
+ return _manager(request).recover()
@@ -0,0 +1,131 @@
1
+ """Bounded installer subprocess execution; never expose raw output or secrets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import signal
8
+ import subprocess
9
+ import tempfile
10
+ import time
11
+ from collections.abc import Callable, Iterator
12
+ from typing import IO
13
+
14
+ from sqlseed._utils.daemon_task import DaemonTask
15
+
16
+ OUTPUT_LIMIT = 24_000
17
+
18
+
19
+ def _sanitized(text: str) -> str:
20
+ text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", text)
21
+ text = re.sub(r"(?i)\b(?:https?|ftp|file)://\S+", "[地址已隐藏]", text)
22
+ text = re.sub(
23
+ r"(?i)\b(?:authorization|password|passwd|token|api[_-]?key|secret)\b\s*[:=]\s*\S+", "[敏感信息已隐藏]", text
24
+ )
25
+ for name, value in os.environ.items():
26
+ if len(value) >= 6 and re.search(r"(?i)(key|token|password|secret|credential)", name):
27
+ text = text.replace(value, "[敏感信息已隐藏]")
28
+ return "".join(character for character in text if character in "\n\t" or ord(character) >= 32)
29
+
30
+
31
+ def _installer_lines(stream: IO[bytes]) -> Iterator[tuple[bytes, int]]:
32
+ """Drain complete lines, dropping an oversized line through its newline."""
33
+ pending = b""
34
+ dropping_line = False
35
+ while chunk := os.read(stream.fileno(), 1024):
36
+ if dropping_line:
37
+ if b"\n" not in chunk:
38
+ continue
39
+ _, chunk = chunk.split(b"\n", 1)
40
+ dropping_line = False
41
+ pending += chunk
42
+ while b"\n" in pending or len(pending) > 4096:
43
+ if b"\n" in pending:
44
+ line, pending = pending.split(b"\n", 1)
45
+ else:
46
+ # Discard overlong lines as a whole: do not reveal split credentials/URLs.
47
+ pending = b""
48
+ dropping_line = True
49
+ line = b"[overlong installer output omitted]"
50
+ yield line, 2000
51
+ if pending:
52
+ # A final partial line retains the existing overall-budget truncation.
53
+ yield pending, OUTPUT_LIMIT
54
+
55
+
56
+ def _read_installer_output(stream: IO[bytes], output: Callable[[str], None]) -> None:
57
+ remaining = OUTPUT_LIMIT
58
+ for line, line_limit in _installer_lines(stream):
59
+ if remaining <= 0:
60
+ continue
61
+ if clean := _sanitized(line.decode("utf-8", errors="replace"))[: min(remaining, line_limit)]:
62
+ output(clean)
63
+ remaining -= len(clean)
64
+ if remaining <= 0:
65
+ output("输出已达到长度上限,后续输出已省略。")
66
+
67
+
68
+ def run_installer(
69
+ arguments: list[str], output: Callable[[str], None], *, timeout: float = 300, lock_descriptor: int | None = None
70
+ ) -> int:
71
+ """Stream bounded text, kill a timed-out process group, then reap the child."""
72
+ environment = {
73
+ name: value
74
+ for name, value in os.environ.items()
75
+ if not name.startswith(("PIP_", "UV_", "PYTHON")) and name != "VIRTUAL_ENV"
76
+ }
77
+ environment.update({"PIP_CONFIG_FILE": os.devnull, "PYTHONNOUSERSITE": "1", "NO_COLOR": "1"})
78
+ with (
79
+ tempfile.TemporaryDirectory(prefix="sqlseed-plugin-process-") as directory,
80
+ subprocess.Popen(
81
+ arguments,
82
+ stdin=subprocess.DEVNULL,
83
+ stdout=subprocess.PIPE,
84
+ stderr=subprocess.STDOUT,
85
+ cwd=directory,
86
+ env=environment,
87
+ start_new_session=os.name != "nt",
88
+ # Children retain the environment lock until they have exited.
89
+ pass_fds=(lock_descriptor,) if os.name != "nt" and lock_descriptor is not None else (),
90
+ ) as process,
91
+ ):
92
+ reader: DaemonTask[None] | None = None
93
+ stopped = False
94
+ try:
95
+ if (stream := process.stdout) is None:
96
+ raise RuntimeError("无法读取安装工具输出。")
97
+ reader = DaemonTask(lambda: _read_installer_output(stream, output), name="sqlseed-plugin-output")
98
+ deadline = time.monotonic() + timeout
99
+ try:
100
+ result = process.wait(timeout=timeout)
101
+ if not reader.wait(max(0, deadline - time.monotonic())):
102
+ raise subprocess.TimeoutExpired(arguments, timeout)
103
+ except subprocess.TimeoutExpired:
104
+ _stop_installer(process)
105
+ stopped = True
106
+ output("安装工具运行超时,子进程已停止;请检查环境并重启 Web。")
107
+ return -1
108
+ if (error := reader.exception()) is not None:
109
+ raise RuntimeError("无法读取安装工具输出。") from error
110
+ return result
111
+ finally:
112
+ # Startup and output failures own the same cleanup as timeouts.
113
+ # Terminate before Popen.__exit__ waits, including inherited pipes.
114
+ # The process group can outlive its leader while holding pipes
115
+ # and the environment lock, including after a successful exit.
116
+ if not stopped:
117
+ _stop_installer(process)
118
+ if reader is not None:
119
+ reader.wait(2)
120
+
121
+
122
+ def _stop_installer(process: subprocess.Popen[bytes]) -> None:
123
+ if os.name == "nt":
124
+ if process.poll() is None:
125
+ process.kill()
126
+ else:
127
+ try:
128
+ os.killpg(process.pid, signal.SIGKILL)
129
+ except ProcessLookupError:
130
+ pass
131
+ process.wait()
@@ -0,0 +1,130 @@
1
+ """Atomic runtime admission and leases held until real request/worker exit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections.abc import Callable, Iterator
7
+ from contextlib import contextmanager
8
+ from typing import Any, Literal
9
+
10
+ from fastapi import HTTPException
11
+ from starlette.responses import JSONResponse
12
+ from starlette.types import ASGIApp, Receive, Scope, Send
13
+
14
+
15
+ class RuntimeGate:
16
+ """Reject a runtime pause while any admitted work still owns a lease."""
17
+
18
+ def __init__(self) -> None:
19
+ self._lock = threading.Lock()
20
+ self._paused = False
21
+ self._counts = {"requests": 0, "background_jobs": 0, "ai_analyses": 0}
22
+
23
+ def activity(self) -> dict[str, int]:
24
+ with self._lock:
25
+ return dict(self._counts)
26
+
27
+ def close_admission(self) -> dict[str, int]:
28
+ """Reject new work immediately while existing leases drain normally."""
29
+ with self._lock:
30
+ self._paused = True
31
+ return dict(self._counts)
32
+
33
+ def pause_if_idle(self) -> dict[str, int]:
34
+ with self._lock:
35
+ activity = dict(self._counts)
36
+ if any(activity.values()):
37
+ raise HTTPException(
38
+ 409,
39
+ detail={
40
+ "code": "plugin_management_busy",
41
+ "message": "当前仍有请求或后台任务运行,请等待完成后再管理插件。",
42
+ "activity": activity,
43
+ },
44
+ )
45
+ self._paused = True
46
+ return activity
47
+
48
+ def resume(self) -> None:
49
+ with self._lock:
50
+ self._paused = False
51
+
52
+ def acquire(self, category: Literal["requests", "background_jobs", "ai_analyses"]) -> None:
53
+ with self._lock:
54
+ if self._paused:
55
+ raise HTTPException(
56
+ 503,
57
+ detail={
58
+ "code": "plugin_runtime_paused",
59
+ "message": "正在更新插件,服务恢复后即可继续使用。",
60
+ },
61
+ )
62
+ self._counts[category] += 1
63
+
64
+ def release(self, category: Literal["requests", "background_jobs", "ai_analyses"]) -> None:
65
+ with self._lock:
66
+ if self._counts[category] <= 0:
67
+ raise RuntimeError("A runtime lease cannot be released twice")
68
+ self._counts[category] -= 1
69
+
70
+ @contextmanager
71
+ def request(self) -> Iterator[None]:
72
+ self.acquire("requests")
73
+ try:
74
+ yield
75
+ finally:
76
+ self.release("requests")
77
+
78
+
79
+ runtime_gate = RuntimeGate()
80
+
81
+
82
+ class RuntimeAdmissionMiddleware:
83
+ """Count complete ASGI calls, including streams and response background work."""
84
+
85
+ def __init__(self, app: ASGIApp) -> None:
86
+ self.app = app
87
+
88
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
89
+ if scope["type"] != "http" or scope.get("path", "").startswith("/api/settings/plugins/"):
90
+ await self.app(scope, receive, send)
91
+ return
92
+ gate = runtime_gate
93
+ try:
94
+ gate.acquire("requests")
95
+ except HTTPException as exc:
96
+ await JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})(scope, receive, send)
97
+ return
98
+ try:
99
+ await self.app(scope, receive, send)
100
+ finally:
101
+ gate.release("requests")
102
+
103
+
104
+ def start_background(
105
+ *,
106
+ target: Callable[..., Any],
107
+ args: tuple[Any, ...] = (),
108
+ kwargs: dict[str, Any] | None = None,
109
+ category: Literal["job", "ai"],
110
+ daemon: bool = True,
111
+ name: str | None = None,
112
+ ) -> threading.Thread:
113
+ """Reserve before startup; release only when the target has actually exited."""
114
+ gate = runtime_gate
115
+ lease: Literal["background_jobs", "ai_analyses"] = "ai_analyses" if category == "ai" else "background_jobs"
116
+ gate.acquire(lease)
117
+
118
+ def run() -> None:
119
+ try:
120
+ target(*args, **(kwargs or {}))
121
+ finally:
122
+ gate.release(lease)
123
+
124
+ try:
125
+ thread = threading.Thread(target=run, daemon=daemon, name=name)
126
+ thread.start()
127
+ except BaseException:
128
+ gate.release(lease)
129
+ raise
130
+ return thread
@@ -0,0 +1,97 @@
1
+ """Transfer live connection settings across managed workers through memory only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from fastapi import HTTPException
9
+ from sqlseed._utils.logger import get_logger
10
+
11
+ from sqlseed_web.operation_errors import generation_errors
12
+ from sqlseed_web.sqlite_target import sqlite_target
13
+ from sqlseed_web.state import Connection, state
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ def export_session() -> dict[str, Any]:
19
+ """Snapshot an idle worker; memory databases cannot survive its replacement."""
20
+ connections = []
21
+ for item in state.list_connections():
22
+ target = sqlite_target(item["target"], item["conn_id"])
23
+ if target is not None and target.kind != "sqlite":
24
+ raise HTTPException(
25
+ 409,
26
+ detail={
27
+ "code": "plugin_session_not_restorable",
28
+ "message": "当前连接包含 SQLite 内存数据库,更新插件会丢失内存数据;请先保存数据并断开该连接。",
29
+ },
30
+ )
31
+ connections.append({key: item[key] for key in ("conn_id", "target", "provider", "locale")})
32
+ return {"connections": connections, "ai_override": state.get_ai_override()}
33
+
34
+
35
+ def _restore_connection(item: Any) -> None:
36
+ """Validate and open one saved connection, unregistering unsuccessful opens."""
37
+ conn: Connection | None = None
38
+ opened = False
39
+ try:
40
+ if not isinstance(item, dict) or not all(
41
+ isinstance(item.get(key), str) and item[key] for key in ("target", "conn_id", "provider", "locale")
42
+ ):
43
+ raise ValueError("Invalid saved connection settings")
44
+ target = sqlite_target(item["target"], item["conn_id"])
45
+ if target is not None and (target.kind != "sqlite" or not Path(target.value).is_file()):
46
+ raise ValueError("The original file-backed database is unavailable")
47
+ conn = state.add_connection(
48
+ item["target"], provider=item["provider"], locale=item["locale"], connection_id=item["conn_id"]
49
+ )
50
+ # Registry construction is lazy: exercise the actual opening path.
51
+ conn.orchestrator.get_table_names()
52
+ opened = True
53
+ except generation_errors(conn.orchestrator if conn is not None else None, additional=(KeyError,)) as exc:
54
+ raise ValueError("Saved connection could not be restored") from exc
55
+ finally:
56
+ if conn is not None and not opened:
57
+ try:
58
+ state.close_connection(conn.conn_id)
59
+ except generation_errors(conn.orchestrator, additional=(KeyError,)):
60
+ # The registry removes ownership before disposing the adapter.
61
+ logger.warning("Restored connection adapter cleanup failed", conn_id=conn.conn_id)
62
+
63
+
64
+ def restore_session(snapshot: dict[str, Any]) -> dict[str, Any]:
65
+ """Reopen each target with the original ID; report sanitized partial failures."""
66
+ restored = 0
67
+ failures: list[dict[str, str]] = []
68
+ items = snapshot.get("connections", [])
69
+ if not isinstance(items, list):
70
+ raise TypeError("Session connections must be a list")
71
+ for item in items:
72
+ try:
73
+ _restore_connection(item)
74
+ except ValueError:
75
+ failures.append(
76
+ {
77
+ "conn_id": str(item.get("conn_id", "")) if isinstance(item, dict) else "",
78
+ "message": "无法恢复此连接,请检查数据库可访问性并重新连接。",
79
+ }
80
+ )
81
+ else:
82
+ restored += 1
83
+ override = snapshot.get("ai_override", {})
84
+ valid_override = isinstance(override, dict) and all(
85
+ isinstance(k, str) and isinstance(v, str) for k, v in override.items()
86
+ )
87
+ if valid_override:
88
+ # Includes credential-service binding and explicit environment-key
89
+ # suppression markers. Never write these values to settings files.
90
+ state.set_ai_override(override)
91
+ return {"restored_connections": restored, "failed_connections": failures, "ai_session_restored": valid_override}
92
+
93
+
94
+ def close_session() -> None:
95
+ """Close registered idle connections after runtime admission has paused."""
96
+ for item in state.list_connections():
97
+ state.close_connection(item["conn_id"])