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,173 @@
1
+ """Package operations with automatic, process-isolated business recovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ import threading
7
+ from typing import Any, Protocol
8
+
9
+ from fastapi import HTTPException
10
+
11
+ from sqlseed_web import plugin_environment
12
+ from sqlseed_web.plugin_environment import InstalledPackage
13
+ from sqlseed_web.plugin_management import ExecuteRequest, PlanRequest, PluginManager, _reject
14
+
15
+
16
+ class LifecycleController(Protocol):
17
+ def pause(self) -> None: ...
18
+ def resume(self) -> None: ...
19
+ def enter_maintenance(self) -> None: ...
20
+ def restore_business(self) -> dict[str, Any]: ...
21
+
22
+
23
+ class SupervisedPluginManager(PluginManager):
24
+ """Keep task ownership in the parent while serving workers are replaced."""
25
+
26
+ def __init__(self, controller: LifecycleController) -> None:
27
+ super().__init__(enabled=True)
28
+ self.controller = controller
29
+ self.instance_id = secrets.token_hex(16)
30
+ self.service_generation = 1
31
+ self.phase = "ready"
32
+ self.session_restore: dict[str, Any] = {}
33
+ self._package_status = "failed"
34
+ self._package_message = "组件操作未完成。"
35
+
36
+ def _reason(self) -> str | None:
37
+ if self.phase == "recovery_failed":
38
+ return "业务服务未恢复,请在页面重试恢复服务。"
39
+ if self.phase != "ready":
40
+ return "正在处理组件操作,业务服务将自动恢复。"
41
+ return super()._reason()
42
+
43
+ def status(self) -> dict[str, Any]:
44
+ with self._lock:
45
+ result = super().status()
46
+ result.update(
47
+ automatic_lifecycle=True,
48
+ instance_id=self.instance_id,
49
+ phase=self.phase,
50
+ service_generation=self.service_generation,
51
+ service_ready=self.phase == "ready",
52
+ session_restore=dict(self.session_restore),
53
+ restart_required=False,
54
+ maintenance_command=None,
55
+ )
56
+ return result
57
+
58
+ def plan(self, body: PlanRequest) -> dict[str, Any]:
59
+ with self._lock:
60
+ result = super().plan(body)
61
+ warnings = [
62
+ "安装固定当前环境与已有组件版本;没有兼容版本或 wheel 时会失败。"
63
+ if body.action == "install"
64
+ else "仅卸载选中的组件,不自动卸载其依赖。重新安装需要软件源提供兼容版本;开发版或本地安装的组件可能无法恢复。",
65
+ "操作期间会短暂暂停工作台;完成后自动恢复服务与可恢复的连接,无需手动重启。",
66
+ ]
67
+ result["warnings"] = warnings
68
+ if self._plan is not None:
69
+ self._plan["warnings"] = warnings
70
+ return result
71
+
72
+ def execute(self, body: ExecuteRequest) -> dict[str, Any]:
73
+ with self._lock:
74
+ if reason := self._reason():
75
+ raise _reject(reason)
76
+ self.controller.pause()
77
+ try:
78
+ result = super().execute(body)
79
+ except Exception:
80
+ self.controller.resume()
81
+ raise
82
+ if result["status"] == "failed":
83
+ self.controller.resume()
84
+ return result
85
+ self._stage("preparing", "正在暂存连接并准备组件操作。")
86
+ return self.task_snapshot()
87
+
88
+ def task_snapshot(self, task_id: str | None = None) -> dict[str, Any]:
89
+ with self._lock:
90
+ task = super().task_snapshot(task_id)
91
+ task.update(stage=self.phase, service_ready=self.phase == "ready", restart_required=False)
92
+ if self.phase not in {"ready", "recovery_failed"}:
93
+ task["status"] = "running"
94
+ return task
95
+
96
+ def _stage(self, stage: str, message: str) -> None:
97
+ with self._lock:
98
+ self.phase = stage
99
+ if self._task is not None:
100
+ self._task.update(
101
+ stage=stage, status="running", message=message, service_ready=False, restart_required=False
102
+ )
103
+
104
+ def _run(self, operation_plan: dict[str, Any], before: dict[str, InstalledPackage]) -> None:
105
+ self._package_status = "failed"
106
+ self._package_message = "组件操作未完成,业务服务已恢复;请检查服务日志后重试。"
107
+ try:
108
+ self.controller.enter_maintenance()
109
+ self._stage("installing", "正在安装组件。" if operation_plan["action"] == "install" else "正在卸载组件。")
110
+ if plugin_environment.installed_packages(self.environment.prefix) != before:
111
+ raise RuntimeError("environment changed before installation")
112
+ super()._run(operation_plan, before)
113
+ with self._lock:
114
+ if self._task is not None:
115
+ self._package_status = self._task["status"]
116
+ self._package_message = (
117
+ "组件操作完成,业务服务已自动恢复。"
118
+ if self._package_status == "succeeded"
119
+ else "组件操作失败,业务服务已恢复;请查看输出后重试。"
120
+ )
121
+ except (HTTPException, OSError, RuntimeError, ValueError):
122
+ self._package_status = "failed"
123
+ self._package_message = "组件操作未完成,业务服务已恢复;环境未通过操作前检查。"
124
+ self._output("组件操作未完成,正在自动恢复业务服务。")
125
+ finally:
126
+ self._restore()
127
+
128
+ def _recovery_failed(self) -> None:
129
+ with self._lock:
130
+ self.phase = "recovery_failed"
131
+ self.restart_required = False
132
+ if self._task is not None:
133
+ self._task.update(
134
+ status="failed",
135
+ message="业务服务未恢复,请点击重试恢复;不会重复安装或卸载。",
136
+ service_ready=False,
137
+ )
138
+
139
+ def _restore(self) -> None:
140
+ self._stage("restoring", "正在恢复业务服务与连接。")
141
+ restored = None
142
+ try:
143
+ restored = self.controller.restore_business()
144
+ except (HTTPException, OSError, RuntimeError, ValueError):
145
+ return
146
+ finally:
147
+ if restored is None:
148
+ self._recovery_failed()
149
+ with self._lock:
150
+ self.phase = "ready"
151
+ self.restart_required = False
152
+ self.service_generation += int(restored.get("service_restarted", True))
153
+ self.session_restore = restored
154
+ if self._task is not None:
155
+ self._task.update(
156
+ status=self._package_status,
157
+ message=self._package_message,
158
+ restart_required=False,
159
+ service_ready=True,
160
+ )
161
+
162
+ def recover(self) -> dict[str, Any]:
163
+ with self._lock:
164
+ if self.phase != "recovery_failed":
165
+ raise HTTPException(409, detail={"code": "recovery_not_needed", "message": "当前服务无需恢复。"})
166
+ self._stage("restoring", "正在重试恢复业务服务。")
167
+ self._worker = threading.Thread(target=self._restore, daemon=False, name="sqlseed-service-recover")
168
+ try:
169
+ self._worker.start()
170
+ except RuntimeError as exc:
171
+ self.phase = "recovery_failed"
172
+ raise _reject("暂时无法恢复服务,请稍后重试。") from exc
173
+ return self.status()
@@ -0,0 +1,238 @@
1
+ """Stable listener and private supervisor for replaceable Web worker processes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import multiprocessing
6
+ import signal
7
+ import socket
8
+ import threading
9
+ import time
10
+ from multiprocessing.context import SpawnProcess
11
+ from typing import Any
12
+
13
+ from fastapi import HTTPException
14
+
15
+ from sqlseed_web.managed_worker import run_worker
16
+ from sqlseed_web.plugin_management import ExecuteRequest, PlanRequest
17
+ from sqlseed_web.supervised_plugins import SupervisedPluginManager
18
+ from sqlseed_web.worker_control import ControlChannel, ControlError
19
+
20
+
21
+ class Supervisor:
22
+ """One supervised app owns its environment, children, and listening socket."""
23
+
24
+ def __init__(self, *, host: str = "127.0.0.1", port: int = 8630) -> None:
25
+ self.host, self.port = host, port
26
+ self.manager = SupervisedPluginManager(self)
27
+ self.listener: socket.socket | None = None
28
+ self.process: SpawnProcess | None = None
29
+ self.channel: ControlChannel | None = None
30
+ self.mode: str | None = None
31
+ self._session: dict[str, Any] = {}
32
+ self._ready = threading.Event()
33
+ self._restoration: dict[str, Any] = {}
34
+ self._lifecycle_lock = threading.RLock()
35
+ self._shutdown_requested = False
36
+ self._session_lost = False
37
+
38
+ def start(self) -> None:
39
+ if self.manager.environment.reason is None:
40
+ self.manager.start()
41
+ if self.manager.environment.reason is None and self.manager._environment_lock is None:
42
+ raise RuntimeError("此 Python 环境正被另一个 Web 服务使用,无法启动受管服务。")
43
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
44
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
45
+ try:
46
+ listener.bind((self.host, self.port))
47
+ listener.listen(2048)
48
+ self.port = listener.getsockname()[1]
49
+ self.listener = listener
50
+ self.manager.phase = "preparing"
51
+ self._spawn("business")
52
+ self.manager.phase = "ready"
53
+ except Exception:
54
+ self.stop()
55
+ listener.close()
56
+ raise
57
+
58
+ def _request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
59
+ if self.channel is None:
60
+ raise RuntimeError("业务进程暂不可用。")
61
+ try:
62
+ return self.channel.call(method, params or {})
63
+ except ControlError as exc:
64
+ raise HTTPException(exc.status_code, detail=exc.detail) from exc
65
+
66
+ def _handle(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
67
+ if method == "worker_ready":
68
+ self._restoration = dict(params.get("restoration", {}))
69
+ self._ready.set()
70
+ return {}
71
+ if method == "management":
72
+ return self.manager.status()
73
+ if method == "plan":
74
+ return self.manager.plan(PlanRequest.model_validate(params))
75
+ if method == "execute":
76
+ return self.manager.execute(ExecuteRequest.model_validate(params))
77
+ if method == "task":
78
+ return self.manager.task_snapshot(params.get("task_id"))
79
+ if method == "recover":
80
+ return self.manager.recover()
81
+ raise ValueError("unsupported supervisor method")
82
+
83
+ def _spawn(self, mode: str) -> dict[str, Any]:
84
+ if self.listener is None:
85
+ raise RuntimeError("服务监听端口不可用。")
86
+ self._ready.clear()
87
+ self._restoration = {}
88
+ context = multiprocessing.get_context("spawn")
89
+ parent, child = context.Pipe()
90
+ channel = ControlChannel(parent)
91
+ lock_descriptor = None
92
+ if self.manager._environment_lock is not None:
93
+ from multiprocessing.reduction import DupFd
94
+
95
+ lock_descriptor = DupFd(self.manager._environment_lock.fileno())
96
+ process = context.Process(
97
+ target=run_worker,
98
+ args=(
99
+ self.listener,
100
+ child,
101
+ mode,
102
+ self._session if mode == "business" else {},
103
+ self.manager.token,
104
+ lock_descriptor,
105
+ ),
106
+ name=f"sqlseed-{mode}-worker",
107
+ daemon=False,
108
+ )
109
+ self.channel, self.process, self.mode = channel, process, mode
110
+ self._shutdown_requested = False
111
+ process.start()
112
+ child.close()
113
+ channel.start(self._handle)
114
+ deadline = time.monotonic() + 20
115
+ while not self._ready.wait(0.05):
116
+ if not process.is_alive() or time.monotonic() >= deadline:
117
+ # No public API was admitted before readiness. A failed boot
118
+ # can be stopped without interrupting a generation transaction.
119
+ if process.is_alive():
120
+ process.kill()
121
+ process.join(timeout=3)
122
+ channel.close()
123
+ self.channel, self.process, self.mode = None, None, None
124
+ raise RuntimeError("业务服务启动未完成,请在页面重试恢复。")
125
+ if mode == "business":
126
+ self._request("resume")
127
+ return dict(self._restoration)
128
+
129
+ def _stop_worker(self) -> None:
130
+ if (process := self.process) is None:
131
+ return
132
+ if process.is_alive():
133
+ if not self._shutdown_requested:
134
+ try:
135
+ self._request("shutdown")
136
+ except RuntimeError:
137
+ # EOF makes the owned worker close admission and drain;
138
+ # losing control never permits proceeding to installation.
139
+ if self.channel is not None:
140
+ self.channel.close()
141
+ self._shutdown_requested = True
142
+ process.join(timeout=20)
143
+ if process.is_alive():
144
+ raise RuntimeError("业务进程尚未自然退出,未执行环境变更。")
145
+ if self.channel is not None:
146
+ self.channel.close()
147
+ self.channel, self.process, self.mode = None, None, None
148
+
149
+ def pause(self) -> None:
150
+ if self.mode != "business":
151
+ raise HTTPException(409, detail={"code": "service_not_ready", "message": "业务服务尚未就绪。"})
152
+ self._session = self._request("prepare")
153
+ self._session_lost = False
154
+
155
+ def resume(self) -> None:
156
+ if self.mode == "business":
157
+ self._request("resume")
158
+ self._session = {}
159
+
160
+ def enter_maintenance(self) -> None:
161
+ with self._lifecycle_lock:
162
+ self._stop_worker()
163
+ self._spawn("maintenance")
164
+
165
+ def restore_business(self) -> dict[str, Any]:
166
+ with self._lifecycle_lock:
167
+ if (
168
+ self.mode == "business"
169
+ and self.process is not None
170
+ and self.process.is_alive()
171
+ and not self._shutdown_requested
172
+ ):
173
+ self.resume()
174
+ return {"restored_connections": 0, "failed_connections": [], "service_restarted": False}
175
+ self._stop_worker()
176
+ try:
177
+ restored = self._spawn("business")
178
+ except Exception:
179
+ self._spawn("maintenance")
180
+ raise
181
+ self._session = {}
182
+ return {**restored, **self._lost_session_summary(), "service_restarted": True}
183
+
184
+ def _lost_session_summary(self) -> dict[str, Any]:
185
+ if not self._session_lost:
186
+ return {}
187
+ return {"session_lost": True, "message": "业务进程意外退出,原有连接与会话密钥需要重新配置。"}
188
+
189
+ def ensure_worker(self) -> None:
190
+ """Keep a recovery page available after an unexpected worker exit."""
191
+ with self._lifecycle_lock:
192
+ with self.manager._lock:
193
+ if self.manager.phase != "ready" or self.process is None or self.process.is_alive():
194
+ return
195
+ self.manager.phase = "recovery_failed"
196
+ self._session_lost = True
197
+ self._session = {}
198
+ self.manager.session_restore = self._lost_session_summary()
199
+ self.manager._task = None
200
+ self._stop_worker()
201
+ self._spawn("maintenance")
202
+
203
+ def stop(self) -> None:
204
+ if self.manager._worker is not None:
205
+ self.manager._worker.join()
206
+ with self._lifecycle_lock:
207
+ while self.process is not None:
208
+ try:
209
+ self._stop_worker()
210
+ except HTTPException as exc:
211
+ if exc.status_code != 409:
212
+ raise
213
+ # Explicit shutdown waits for existing work; it never
214
+ # kills running SQL or an SDK thread that has not exited.
215
+ time.sleep(0.1)
216
+ self.manager.stop()
217
+ if self.listener is not None:
218
+ self.listener.close()
219
+ self.listener = None
220
+
221
+
222
+ def run_supervised() -> None:
223
+ """Default console launch: plugin changes and recovery remain in the page."""
224
+ stopped = threading.Event()
225
+
226
+ def stop(signum: int, frame: Any) -> None:
227
+ stopped.set()
228
+
229
+ previous = {sig: signal.signal(sig, stop) for sig in (signal.SIGINT, signal.SIGTERM)}
230
+ supervisor = Supervisor()
231
+ try:
232
+ supervisor.start()
233
+ while not stopped.wait(0.25):
234
+ supervisor.ensure_worker()
235
+ finally:
236
+ supervisor.stop()
237
+ for sig, handler in previous.items():
238
+ signal.signal(sig, handler)