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.
- sqlseed_web/AGENTS.md +106 -0
- sqlseed_web/__init__.py +24 -0
- sqlseed_web/__main__.py +8 -0
- sqlseed_web/_application.py +205 -0
- sqlseed_web/ai_settings.py +290 -0
- sqlseed_web/api.py +1102 -0
- sqlseed_web/app.py +26 -0
- sqlseed_web/managed_worker.py +184 -0
- sqlseed_web/operation_errors.py +42 -0
- sqlseed_web/plugin_environment.py +231 -0
- sqlseed_web/plugin_management.py +322 -0
- sqlseed_web/plugin_process.py +131 -0
- sqlseed_web/runtime_lifecycle.py +130 -0
- sqlseed_web/runtime_session.py +97 -0
- sqlseed_web/settings_environment.py +368 -0
- sqlseed_web/sqlite_target.py +86 -0
- sqlseed_web/state.py +348 -0
- sqlseed_web/static/AGENTS.md +180 -0
- sqlseed_web/static/ai.css +57 -0
- sqlseed_web/static/configs.css +50 -0
- sqlseed_web/static/date-picker.css +230 -0
- sqlseed_web/static/disclosure.css +149 -0
- sqlseed_web/static/graph-clarity.css +103 -0
- sqlseed_web/static/index.html +29 -0
- sqlseed_web/static/js/api.js +228 -0
- sqlseed_web/static/js/app.js +97 -0
- sqlseed_web/static/js/dropdown.js +432 -0
- sqlseed_web/static/js/filepicker.js +203 -0
- sqlseed_web/static/js/genform.js +1066 -0
- sqlseed_web/static/js/labels.js +195 -0
- sqlseed_web/static/js/pages/browse.js +209 -0
- sqlseed_web/static/js/pages/configs.js +424 -0
- sqlseed_web/static/js/pages/connect.js +332 -0
- sqlseed_web/static/js/pages/heal.js +395 -0
- sqlseed_web/static/js/pages/meta.js +110 -0
- sqlseed_web/static/js/pages/runs.js +293 -0
- sqlseed_web/static/js/pages/settings.js +942 -0
- sqlseed_web/static/js/pages/wizard.js +751 -0
- sqlseed_web/static/js/pages/workbench.js +3123 -0
- sqlseed_web/static/js/tree.js +126 -0
- sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
- sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
- sqlseed_web/static/js/workbench/ai-stream.js +116 -0
- sqlseed_web/static/js/workbench/ai.js +888 -0
- sqlseed_web/static/js/workbench/connection.js +508 -0
- sqlseed_web/static/js/workbench/date-picker.js +445 -0
- sqlseed_web/static/js/workbench/dependency-view.js +119 -0
- sqlseed_web/static/js/workbench/editor.js +1236 -0
- sqlseed_web/static/js/workbench/focus.js +11 -0
- sqlseed_web/static/js/workbench/graph-layout.js +332 -0
- sqlseed_web/static/js/workbench/graph.js +970 -0
- sqlseed_web/static/js/workbench/guidance.js +29 -0
- sqlseed_web/static/js/workbench/model.js +124 -0
- sqlseed_web/static/js/workbench/plugin-management.js +512 -0
- sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
- sqlseed_web/static/js/workbench/preview.js +572 -0
- sqlseed_web/static/js/workbench/provider-guide.js +33 -0
- sqlseed_web/static/js/workbench/recovery.js +28 -0
- sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
- sqlseed_web/static/js/workbench/session.js +174 -0
- sqlseed_web/static/js/workbench/table-data.js +186 -0
- sqlseed_web/static/js/workbench/ui.js +262 -0
- sqlseed_web/static/navigation.css +92 -0
- sqlseed_web/static/preview.css +29 -0
- sqlseed_web/static/runs.css +53 -0
- sqlseed_web/static/scrollbars.css +42 -0
- sqlseed_web/static/settings.css +108 -0
- sqlseed_web/static/style.css +3382 -0
- sqlseed_web/static/table-data.css +27 -0
- sqlseed_web/static/workbench.css +509 -0
- sqlseed_web/supervised_plugins.py +173 -0
- sqlseed_web/supervisor.py +238 -0
- sqlseed_web/workbench.py +381 -0
- sqlseed_web/workbench_ai.py +887 -0
- sqlseed_web/workbench_ai_relations.py +285 -0
- sqlseed_web/workbench_ai_stream.py +172 -0
- sqlseed_web/workbench_data.py +163 -0
- sqlseed_web/workbench_execution.py +199 -0
- sqlseed_web/workbench_runtime.py +1218 -0
- sqlseed_web/workbench_schema.py +277 -0
- sqlseed_web/workbench_store.py +458 -0
- sqlseed_web/worker_control.py +192 -0
- sqlseed_web-0.2.4.dist-info/METADATA +105 -0
- sqlseed_web-0.2.4.dist-info/RECORD +87 -0
- sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
- sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
- sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""Local SQLite storage for credential-free drafts and immutable run snapshots.
|
|
2
|
+
|
|
3
|
+
Connections belong to one short operation, so HTTP and background worker threads
|
|
4
|
+
never share a SQLite connection. WAL and immediate write transactions coordinate
|
|
5
|
+
independent store instances as well as threads. Constructing a store is the
|
|
6
|
+
server-start recovery boundary; application code should use ``get_store()``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sqlite3
|
|
15
|
+
import sys
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
from urllib.parse import parse_qsl, unquote, urlsplit
|
|
23
|
+
|
|
24
|
+
from sqlseed_web.workbench_execution import normalize_execution
|
|
25
|
+
|
|
26
|
+
_SELECT_DRAFT = "SELECT payload FROM workspace_drafts WHERE id = ?"
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Iterator
|
|
30
|
+
|
|
31
|
+
_SCHEMA_VERSION = 1
|
|
32
|
+
_MAX_RECORD_BYTES = 2 * 1024 * 1024
|
|
33
|
+
_MAX_TABLES = 1000
|
|
34
|
+
_RUN_STATUSES = frozenset({"queued", "running", "done", "success", "error", "cancelled", "interrupted", "skipped"})
|
|
35
|
+
_RUN_MUTABLE_FIELDS = frozenset(
|
|
36
|
+
{
|
|
37
|
+
"status",
|
|
38
|
+
"tables",
|
|
39
|
+
"progress",
|
|
40
|
+
"rows_inserted",
|
|
41
|
+
"error",
|
|
42
|
+
"started_at",
|
|
43
|
+
"finished_at",
|
|
44
|
+
"row_counts_exact",
|
|
45
|
+
"current_table",
|
|
46
|
+
"result",
|
|
47
|
+
"errors",
|
|
48
|
+
"elapsed",
|
|
49
|
+
"batch_count",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
_URL_PATTERN = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*://[^\s]+")
|
|
53
|
+
_PASSWORD_KEYS = frozenset({"password", "passwd", "pwd", "sslpassword"})
|
|
54
|
+
_STORES: dict[Path, WorkspaceStore] = {}
|
|
55
|
+
_STORE_LOCK = threading.Lock()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RevisionConflictError(ValueError):
|
|
59
|
+
"""A draft changed since the caller's expected revision was read."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
RevisionConflict = RevisionConflictError
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _json_text(value: dict[str, Any]) -> str:
|
|
66
|
+
"""Encode a bounded JSON snapshot, rejecting non-finite or non-JSON data."""
|
|
67
|
+
try:
|
|
68
|
+
encoded = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
|
|
69
|
+
except (TypeError, ValueError, OverflowError, RecursionError) as exc:
|
|
70
|
+
raise ValueError("Workspace payload must contain valid JSON values") from exc
|
|
71
|
+
if len(encoded.encode("utf-8")) > _MAX_RECORD_BYTES:
|
|
72
|
+
raise ValueError("Workspace payload exceeds the 2 MiB size limit")
|
|
73
|
+
pending: list[Any] = [value]
|
|
74
|
+
while pending:
|
|
75
|
+
item = pending.pop()
|
|
76
|
+
if isinstance(item, str):
|
|
77
|
+
_check_target(item)
|
|
78
|
+
elif isinstance(item, dict):
|
|
79
|
+
pending.extend(item.values())
|
|
80
|
+
elif isinstance(item, (list, tuple)):
|
|
81
|
+
pending.extend(item)
|
|
82
|
+
return encoded
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _decode(value: str) -> dict[str, Any]:
|
|
86
|
+
"""Read a JSON object from a workspace record."""
|
|
87
|
+
result: dict[str, Any] = json.loads(value)
|
|
88
|
+
if not isinstance(result, dict):
|
|
89
|
+
# Corrupt persisted records use RuntimeError under the runtime validation contract.
|
|
90
|
+
raise RuntimeError("Invalid workspace record: expected a JSON object") # noqa: TRY004
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _text(payload: dict[str, Any], key: str, limit: int = 512) -> str:
|
|
95
|
+
"""Require a nonempty bounded string field."""
|
|
96
|
+
value = payload.get(key)
|
|
97
|
+
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
|
98
|
+
raise ValueError(f"{key} must be a nonempty string of at most {limit} characters")
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _check_target(value: str) -> None:
|
|
103
|
+
"""Reject credentials in display labels and target identity strings."""
|
|
104
|
+
for match in _URL_PATTERN.finditer(value):
|
|
105
|
+
try:
|
|
106
|
+
parsed = urlsplit(match.group())
|
|
107
|
+
password = unquote(parsed.password or "")
|
|
108
|
+
query_passwords = [item for key, item in parse_qsl(parsed.query) if key.lower() in _PASSWORD_KEYS]
|
|
109
|
+
except ValueError as exc:
|
|
110
|
+
raise ValueError("Invalid target label; use a credential-free label") from exc
|
|
111
|
+
if any(secret and set(secret) != {"*"} for secret in (password, *query_passwords)):
|
|
112
|
+
raise ValueError("Connection passwords must be removed from workspace payloads")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _snapshot(payload: dict[str, Any]) -> dict[str, Any]:
|
|
116
|
+
"""Validate the common immutable, credential-free configuration snapshot."""
|
|
117
|
+
document = payload.get("document")
|
|
118
|
+
if not isinstance(document, dict):
|
|
119
|
+
# Payload validation uses ValueError, which the HTTP boundary handles consistently.
|
|
120
|
+
raise ValueError("document must be a configuration object") # noqa: TRY004
|
|
121
|
+
if {"db_path", "url"}.intersection(document):
|
|
122
|
+
raise ValueError("Remove connection fields db_path and url from the stored document")
|
|
123
|
+
target_key = _text(payload, "target_key", 4096)
|
|
124
|
+
target_label = _text(payload, "target_label", 4096)
|
|
125
|
+
_check_target(target_key)
|
|
126
|
+
_check_target(target_label)
|
|
127
|
+
return {
|
|
128
|
+
"target_key": target_key,
|
|
129
|
+
"target_label": target_label,
|
|
130
|
+
"document": document,
|
|
131
|
+
"schema_hash": _text(payload, "schema_hash"),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _validate_run_table(table: Any) -> str:
|
|
136
|
+
if not isinstance(table, dict):
|
|
137
|
+
# Payload validation uses ValueError, which the HTTP boundary handles consistently.
|
|
138
|
+
raise ValueError("Each run table must be an object") # noqa: TRY004
|
|
139
|
+
count = table.get("count", table.get("requested_count"))
|
|
140
|
+
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
|
|
141
|
+
raise ValueError("Run table count must be a nonnegative integer")
|
|
142
|
+
table_status = table.get("status", "queued")
|
|
143
|
+
if not isinstance(table_status, str) or table_status not in _RUN_STATUSES | {"not_run"}:
|
|
144
|
+
raise ValueError("Unknown run table status")
|
|
145
|
+
return _text(table, "name")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _validate_run(record: dict[str, Any]) -> None:
|
|
149
|
+
"""Validate bounded run status and table progress before persistence."""
|
|
150
|
+
status = record.get("status")
|
|
151
|
+
if not isinstance(status, str) or status not in _RUN_STATUSES:
|
|
152
|
+
raise ValueError("Unknown run status")
|
|
153
|
+
tables = record.get("tables")
|
|
154
|
+
if not isinstance(tables, list) or len(tables) > _MAX_TABLES:
|
|
155
|
+
raise ValueError(f"tables must be a list of at most {_MAX_TABLES} entries")
|
|
156
|
+
names: set[str] = set()
|
|
157
|
+
for table in tables:
|
|
158
|
+
if (name := _validate_run_table(table)) in names:
|
|
159
|
+
raise ValueError("Run table names must be unique")
|
|
160
|
+
names.add(name)
|
|
161
|
+
for progress in (record, *tables):
|
|
162
|
+
inserted = progress.get("rows_inserted", 0)
|
|
163
|
+
if inserted is not None and (isinstance(inserted, bool) or not isinstance(inserted, int) or inserted < 0):
|
|
164
|
+
raise ValueError("rows_inserted must be a nonnegative integer or null")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _set_run_identity(record: dict[str, Any], payload: dict[str, Any]) -> None:
|
|
168
|
+
"""Validate immutable run identifiers and timestamps independently of progress."""
|
|
169
|
+
run_id = payload.get("id", str(uuid.uuid4()))
|
|
170
|
+
_text({"id": run_id}, "id", 128)
|
|
171
|
+
revision = payload.get("revision")
|
|
172
|
+
if revision is not None and (isinstance(revision, bool) or not isinstance(revision, int) or revision < 1):
|
|
173
|
+
raise ValueError("revision must be a positive integer or null")
|
|
174
|
+
if (draft_id := payload.get("draft_id")) is not None:
|
|
175
|
+
_text({"draft_id": draft_id}, "draft_id", 128)
|
|
176
|
+
created_at = payload.get("created_at", time.time())
|
|
177
|
+
if isinstance(created_at, bool) or not isinstance(created_at, (int, float)) or created_at < 0:
|
|
178
|
+
raise ValueError("created_at must be a nonnegative timestamp")
|
|
179
|
+
record.update(id=run_id, draft_id=draft_id, revision=revision, created_at=created_at)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _run_snapshot(payload: dict[str, Any]) -> dict[str, Any]:
|
|
183
|
+
"""Validate execution and metadata before a run enters the store transaction."""
|
|
184
|
+
record = _snapshot(payload)
|
|
185
|
+
record["execution"] = normalize_execution(payload.get("execution"))
|
|
186
|
+
plan_hash = payload.get("plan_hash", "")
|
|
187
|
+
if not isinstance(plan_hash, str) or (plan_hash and not re.fullmatch(r"[a-f0-9]{64}", plan_hash)):
|
|
188
|
+
raise ValueError("plan_hash must be a SHA-256 digest or empty")
|
|
189
|
+
if record["execution"]["mode"] == "replace_selected" and not plan_hash:
|
|
190
|
+
raise ValueError("replacement execution requires an immutable plan_hash")
|
|
191
|
+
record["plan_hash"] = plan_hash
|
|
192
|
+
_set_run_identity(record, payload)
|
|
193
|
+
for key in ("name", "config_hash"):
|
|
194
|
+
if key in payload:
|
|
195
|
+
record[key] = _text(payload, key)
|
|
196
|
+
if "order" in payload:
|
|
197
|
+
order = payload["order"]
|
|
198
|
+
if not isinstance(order, list) or len(order) > _MAX_TABLES or any(not isinstance(name, str) for name in order):
|
|
199
|
+
raise ValueError("order must be a bounded list of table names")
|
|
200
|
+
record["order"] = order
|
|
201
|
+
record.update(status="queued", tables=[], rows_inserted=0, error=None, started_at=None, finished_at=None)
|
|
202
|
+
record.update({key: value for key, value in payload.items() if key in _RUN_MUTABLE_FIELDS})
|
|
203
|
+
_validate_run(record)
|
|
204
|
+
return record
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class WorkspaceStore:
|
|
208
|
+
"""Persist Web workspace metadata at ``path`` without opening user databases."""
|
|
209
|
+
|
|
210
|
+
def __init__(self, path: Path) -> None:
|
|
211
|
+
self.path = path.expanduser().resolve()
|
|
212
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
self._initialize()
|
|
214
|
+
|
|
215
|
+
@contextmanager
|
|
216
|
+
def _connection(self, *, write: bool = False) -> Iterator[sqlite3.Connection]:
|
|
217
|
+
"""Open one connection, optionally starting a serialized write transaction."""
|
|
218
|
+
db = sqlite3.connect(self.path, timeout=15.0, isolation_level=None)
|
|
219
|
+
try:
|
|
220
|
+
if write:
|
|
221
|
+
db.execute("BEGIN IMMEDIATE")
|
|
222
|
+
yield db
|
|
223
|
+
if write:
|
|
224
|
+
db.commit()
|
|
225
|
+
except BaseException:
|
|
226
|
+
if write:
|
|
227
|
+
db.rollback()
|
|
228
|
+
raise
|
|
229
|
+
finally:
|
|
230
|
+
db.close()
|
|
231
|
+
|
|
232
|
+
def _initialize(self) -> None:
|
|
233
|
+
"""Create versioned tables and recover records left by an earlier server."""
|
|
234
|
+
with self._connection() as db:
|
|
235
|
+
db.execute("PRAGMA journal_mode = WAL")
|
|
236
|
+
with self._connection(write=True) as db:
|
|
237
|
+
if (version := db.execute("PRAGMA user_version").fetchone()[0]) not in (0, _SCHEMA_VERSION):
|
|
238
|
+
raise RuntimeError(f"Unsupported workspace schema version {version}; expected {_SCHEMA_VERSION}")
|
|
239
|
+
db.execute(
|
|
240
|
+
"CREATE TABLE IF NOT EXISTS workspace_drafts ("
|
|
241
|
+
"id TEXT PRIMARY KEY, revision INTEGER NOT NULL, target_key TEXT NOT NULL, "
|
|
242
|
+
"updated_at REAL NOT NULL, payload TEXT NOT NULL)"
|
|
243
|
+
)
|
|
244
|
+
db.execute("CREATE INDEX IF NOT EXISTS workspace_drafts_target ON workspace_drafts(target_key, updated_at)")
|
|
245
|
+
db.execute(
|
|
246
|
+
"CREATE TABLE IF NOT EXISTS workspace_runs ("
|
|
247
|
+
"id TEXT PRIMARY KEY, status TEXT NOT NULL, created_at REAL NOT NULL, payload TEXT NOT NULL)"
|
|
248
|
+
)
|
|
249
|
+
db.execute("CREATE INDEX IF NOT EXISTS workspace_runs_created ON workspace_runs(created_at)")
|
|
250
|
+
db.execute("PRAGMA user_version = 1")
|
|
251
|
+
unfinished = db.execute("SELECT payload FROM workspace_runs WHERE status IN ('queued', 'running')")
|
|
252
|
+
for row in unfinished.fetchall():
|
|
253
|
+
record = _decode(row[0])
|
|
254
|
+
record.update(
|
|
255
|
+
status="interrupted",
|
|
256
|
+
finished_at=time.time(),
|
|
257
|
+
row_counts_exact=False,
|
|
258
|
+
error="Service restarted before completion; recorded inserted row counts may be incomplete.",
|
|
259
|
+
)
|
|
260
|
+
for table in record["tables"]:
|
|
261
|
+
if table.get("status", "queued") in {"queued", "running"}:
|
|
262
|
+
table["status"] = "interrupted"
|
|
263
|
+
db.execute(
|
|
264
|
+
"UPDATE workspace_runs SET status = ?, payload = ? WHERE id = ?",
|
|
265
|
+
("interrupted", _json_text(record), record["id"]),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def save_draft(
|
|
269
|
+
self,
|
|
270
|
+
payload: dict[str, Any],
|
|
271
|
+
*,
|
|
272
|
+
draft_id: str | None = None,
|
|
273
|
+
expected_revision: int | None = None,
|
|
274
|
+
) -> dict[str, Any]:
|
|
275
|
+
"""Create a draft or atomically replace the caller's expected revision."""
|
|
276
|
+
record = {**_snapshot(payload), "name": _text(payload, "name")}
|
|
277
|
+
view_state = payload.get("view_state", {})
|
|
278
|
+
if not isinstance(view_state, dict):
|
|
279
|
+
# Payload validation uses ValueError, which the HTTP boundary handles consistently.
|
|
280
|
+
raise ValueError("view_state must be an object") # noqa: TRY004
|
|
281
|
+
record["view_state"] = view_state
|
|
282
|
+
with self._connection(write=True) as db:
|
|
283
|
+
revision = 1
|
|
284
|
+
if draft_id is not None:
|
|
285
|
+
old = db.execute("SELECT revision FROM workspace_drafts WHERE id = ?", (draft_id,)).fetchone()
|
|
286
|
+
if old is None:
|
|
287
|
+
raise KeyError(draft_id)
|
|
288
|
+
if isinstance(expected_revision, bool) or expected_revision != old[0]:
|
|
289
|
+
raise RevisionConflict(f"Draft revision conflict: expected {expected_revision}, current {old[0]}")
|
|
290
|
+
revision = old[0] + 1
|
|
291
|
+
elif expected_revision is not None:
|
|
292
|
+
raise ValueError("expected_revision is only valid when updating an existing draft")
|
|
293
|
+
record.update(id=draft_id or str(uuid.uuid4()), revision=revision, updated_at=time.time())
|
|
294
|
+
encoded = _json_text(record)
|
|
295
|
+
if draft_id is None:
|
|
296
|
+
db.execute(
|
|
297
|
+
"INSERT INTO workspace_drafts (id, revision, target_key, updated_at, payload) "
|
|
298
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
299
|
+
(record["id"], revision, record["target_key"], record["updated_at"], encoded),
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
db.execute(
|
|
303
|
+
"UPDATE workspace_drafts SET revision = ?, target_key = ?, updated_at = ?, payload = ? "
|
|
304
|
+
"WHERE id = ?",
|
|
305
|
+
(revision, record["target_key"], record["updated_at"], encoded, draft_id),
|
|
306
|
+
)
|
|
307
|
+
return _decode(encoded)
|
|
308
|
+
|
|
309
|
+
def get_draft(self, draft_id: str) -> dict[str, Any]:
|
|
310
|
+
"""Return a detached draft snapshot, raising KeyError for an unknown id."""
|
|
311
|
+
with self._connection() as db:
|
|
312
|
+
row = db.execute(_SELECT_DRAFT, (draft_id,)).fetchone()
|
|
313
|
+
if row is None:
|
|
314
|
+
raise KeyError(draft_id)
|
|
315
|
+
return _decode(row[0])
|
|
316
|
+
|
|
317
|
+
def list_drafts(self, target_key: str | None = None) -> list[dict[str, Any]]:
|
|
318
|
+
"""List newest drafts first, optionally filtering by stable target identity."""
|
|
319
|
+
with self._connection() as db:
|
|
320
|
+
rows = db.execute(
|
|
321
|
+
"SELECT payload FROM workspace_drafts WHERE (? IS NULL OR target_key = ?) "
|
|
322
|
+
"ORDER BY updated_at DESC, rowid DESC",
|
|
323
|
+
(target_key, target_key),
|
|
324
|
+
).fetchall()
|
|
325
|
+
return [_decode(row[0]) for row in rows]
|
|
326
|
+
|
|
327
|
+
@staticmethod
|
|
328
|
+
def _draft_at_revision(db: sqlite3.Connection, draft_id: str, expected_revision: int) -> dict[str, Any]:
|
|
329
|
+
"""Read the displayed draft inside its caller's serialized write transaction."""
|
|
330
|
+
if (row := db.execute(_SELECT_DRAFT, (draft_id,)).fetchone()) is None:
|
|
331
|
+
raise KeyError(draft_id)
|
|
332
|
+
record = _decode(row[0])
|
|
333
|
+
if (
|
|
334
|
+
isinstance(expected_revision, bool)
|
|
335
|
+
or not isinstance(expected_revision, int)
|
|
336
|
+
or expected_revision != record["revision"]
|
|
337
|
+
):
|
|
338
|
+
raise RevisionConflict("配置已被更新,请刷新列表后重试")
|
|
339
|
+
return record
|
|
340
|
+
|
|
341
|
+
def rename_draft(self, draft_id: str, name: str, *, expected_revision: int) -> dict[str, Any]:
|
|
342
|
+
"""Rename metadata without rewriting the saved document or requiring its target."""
|
|
343
|
+
clean_name = _text({"name": name}, "name", 200).strip()
|
|
344
|
+
with self._connection(write=True) as db:
|
|
345
|
+
record = self._draft_at_revision(db, draft_id, expected_revision)
|
|
346
|
+
record.update(name=clean_name, revision=record["revision"] + 1, updated_at=time.time())
|
|
347
|
+
encoded = _json_text(record)
|
|
348
|
+
db.execute(
|
|
349
|
+
"UPDATE workspace_drafts SET revision = ?, updated_at = ?, payload = ? WHERE id = ?",
|
|
350
|
+
(record["revision"], record["updated_at"], encoded, draft_id),
|
|
351
|
+
)
|
|
352
|
+
return _decode(encoded)
|
|
353
|
+
|
|
354
|
+
def copy_draft(self, draft_id: str, name: str, *, expected_revision: int) -> dict[str, Any]:
|
|
355
|
+
"""Copy the exact reviewed revision into a new independent configuration."""
|
|
356
|
+
clean_name = _text({"name": name}, "name", 200).strip()
|
|
357
|
+
with self._connection(write=True) as db:
|
|
358
|
+
record = self._draft_at_revision(db, draft_id, expected_revision)
|
|
359
|
+
record.update(id=str(uuid.uuid4()), name=clean_name, revision=1, updated_at=time.time())
|
|
360
|
+
encoded = _json_text(record)
|
|
361
|
+
db.execute(
|
|
362
|
+
"INSERT INTO workspace_drafts (id, revision, target_key, updated_at, payload) VALUES (?, ?, ?, ?, ?)",
|
|
363
|
+
(record["id"], 1, record["target_key"], record["updated_at"], encoded),
|
|
364
|
+
)
|
|
365
|
+
return _decode(encoded)
|
|
366
|
+
|
|
367
|
+
def delete_draft(self, draft_id: str, *, expected_revision: int) -> dict[str, Any]:
|
|
368
|
+
"""Remove one current configuration; accepted runs retain their independent snapshots."""
|
|
369
|
+
with self._connection(write=True) as db:
|
|
370
|
+
record = self._draft_at_revision(db, draft_id, expected_revision)
|
|
371
|
+
db.execute("DELETE FROM workspace_drafts WHERE id = ?", (draft_id,))
|
|
372
|
+
return {"id": draft_id, "revision": record["revision"], "deleted": True}
|
|
373
|
+
|
|
374
|
+
def create_run(self, payload: dict[str, Any], *, require_current_draft: bool = False) -> dict[str, Any]:
|
|
375
|
+
"""Create a unique run containing its complete, immutable configuration."""
|
|
376
|
+
record = _run_snapshot(payload)
|
|
377
|
+
run_id, draft_id, created_at = record["id"], record["draft_id"], record["created_at"]
|
|
378
|
+
encoded = _json_text(record)
|
|
379
|
+
try:
|
|
380
|
+
with self._connection(write=True) as db:
|
|
381
|
+
if require_current_draft:
|
|
382
|
+
if (row := db.execute(_SELECT_DRAFT, (draft_id,)).fetchone()) is None:
|
|
383
|
+
raise KeyError(draft_id)
|
|
384
|
+
draft = _decode(row[0])
|
|
385
|
+
snapshot_fields = ("revision", "document", "schema_hash", "target_key")
|
|
386
|
+
if any(draft[key] != record[key] for key in snapshot_fields):
|
|
387
|
+
raise RevisionConflictError("Draft changed while validating the run; save and check it again")
|
|
388
|
+
db.execute(
|
|
389
|
+
"INSERT INTO workspace_runs (id, status, created_at, payload) VALUES (?, ?, ?, ?)",
|
|
390
|
+
(run_id, record["status"], created_at, encoded),
|
|
391
|
+
)
|
|
392
|
+
except sqlite3.IntegrityError as exc:
|
|
393
|
+
raise ValueError(f"Run already exists: {run_id}") from exc
|
|
394
|
+
return _decode(encoded)
|
|
395
|
+
|
|
396
|
+
def update_run(self, run_id: str, changes: dict[str, Any]) -> dict[str, Any]:
|
|
397
|
+
"""Atomically change run progress while preserving the original snapshot."""
|
|
398
|
+
if set(changes).difference(_RUN_MUTABLE_FIELDS):
|
|
399
|
+
raise ValueError("Cannot update immutable run snapshot fields")
|
|
400
|
+
with self._connection(write=True) as db:
|
|
401
|
+
if (row := db.execute("SELECT payload FROM workspace_runs WHERE id = ?", (run_id,)).fetchone()) is None:
|
|
402
|
+
raise KeyError(run_id)
|
|
403
|
+
record = _decode(row[0])
|
|
404
|
+
original_tables = [
|
|
405
|
+
(table["name"], table.get("count", table.get("requested_count"))) for table in record["tables"]
|
|
406
|
+
]
|
|
407
|
+
record.update(changes)
|
|
408
|
+
_validate_run(record)
|
|
409
|
+
updated_tables = [
|
|
410
|
+
(table["name"], table.get("count", table.get("requested_count"))) for table in record["tables"]
|
|
411
|
+
]
|
|
412
|
+
if updated_tables != original_tables:
|
|
413
|
+
raise ValueError("Cannot change immutable run table names or counts")
|
|
414
|
+
encoded = _json_text(record)
|
|
415
|
+
db.execute(
|
|
416
|
+
"UPDATE workspace_runs SET status = ?, payload = ? WHERE id = ?",
|
|
417
|
+
(record["status"], encoded, run_id),
|
|
418
|
+
)
|
|
419
|
+
return _decode(encoded)
|
|
420
|
+
|
|
421
|
+
def get_run(self, run_id: str) -> dict[str, Any]:
|
|
422
|
+
"""Return a detached run snapshot, raising KeyError for an unknown id."""
|
|
423
|
+
with self._connection() as db:
|
|
424
|
+
row = db.execute("SELECT payload FROM workspace_runs WHERE id = ?", (run_id,)).fetchone()
|
|
425
|
+
if row is None:
|
|
426
|
+
raise KeyError(run_id)
|
|
427
|
+
return _decode(row[0])
|
|
428
|
+
|
|
429
|
+
def list_runs(self, limit: int = 50) -> list[dict[str, Any]]:
|
|
430
|
+
"""List the latest runs with a bounded result count."""
|
|
431
|
+
if isinstance(limit, bool) or not isinstance(limit, int) or not 0 <= limit <= 500:
|
|
432
|
+
raise ValueError("limit must be an integer between 0 and 500")
|
|
433
|
+
with self._connection() as db:
|
|
434
|
+
rows = db.execute(
|
|
435
|
+
"SELECT payload FROM workspace_runs ORDER BY created_at DESC, rowid DESC LIMIT ?", (limit,)
|
|
436
|
+
).fetchall()
|
|
437
|
+
return [_decode(row[0]) for row in rows]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _default_path() -> Path:
|
|
441
|
+
"""Choose user application storage without relying on the working directory."""
|
|
442
|
+
if sys.platform == "darwin":
|
|
443
|
+
directory = Path.home() / "Library" / "Application Support"
|
|
444
|
+
elif sys.platform == "win32":
|
|
445
|
+
directory = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local")))
|
|
446
|
+
else:
|
|
447
|
+
directory = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local" / "share")))
|
|
448
|
+
return directory / "sqlseed" / "workspace.sqlite3"
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def get_store() -> WorkspaceStore:
|
|
452
|
+
"""Lazily initialize and reuse one store per configured workspace path."""
|
|
453
|
+
configured = os.environ.get("SQLSEED_WEB_WORKSPACE_PATH")
|
|
454
|
+
path = (Path(configured) if configured else _default_path()).expanduser().resolve()
|
|
455
|
+
with _STORE_LOCK:
|
|
456
|
+
if path not in _STORES:
|
|
457
|
+
_STORES[path] = WorkspaceStore(path)
|
|
458
|
+
return _STORES[path]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Bounded JSON RPC over inherited anonymous pipes, never an HTTP control port."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import socket
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from concurrent.futures import Future
|
|
12
|
+
from multiprocessing.connection import Connection
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from fastapi import HTTPException
|
|
16
|
+
from sqlseed._utils.daemon_task import DaemonTask
|
|
17
|
+
|
|
18
|
+
_CONTROL_CLOSED = "服务控制通道已关闭。"
|
|
19
|
+
|
|
20
|
+
MAX_MESSAGE_BYTES = 2_000_000
|
|
21
|
+
Handler = Callable[[str, dict[str, Any]], dict[str, Any]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ControlMessageTooLarge(RuntimeError):
|
|
25
|
+
"""A payload cannot fit in one bounded control message."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _encode_message(message: dict[str, Any]) -> bytes:
|
|
29
|
+
payload = json.dumps(message, ensure_ascii=False).encode("utf-8")
|
|
30
|
+
if len(payload) > MAX_MESSAGE_BYTES:
|
|
31
|
+
raise ControlMessageTooLarge("会话信息超过安全传输上限,未执行环境变更。")
|
|
32
|
+
return payload
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_control_result(result: dict[str, Any]) -> None:
|
|
36
|
+
"""Include the fixed request identifier and response envelope in the bound."""
|
|
37
|
+
_encode_message({"id": "0" * 24, "result": result})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ControlError(RuntimeError):
|
|
41
|
+
"""A public, credential-free control failure."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, detail: Any, status_code: int = 503) -> None:
|
|
44
|
+
super().__init__(detail.get("message", "控制请求未完成") if isinstance(detail, dict) else str(detail))
|
|
45
|
+
self.detail = detail
|
|
46
|
+
self.status_code = status_code
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ControlChannel:
|
|
50
|
+
"""A duplex RPC endpoint whose reader never blocks on request execution."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, connection: Connection) -> None:
|
|
53
|
+
self.connection = connection
|
|
54
|
+
self._send_lock = threading.Lock()
|
|
55
|
+
self._lock = threading.Lock()
|
|
56
|
+
self._closed = threading.Event()
|
|
57
|
+
self._pending: dict[str, tuple[threading.Event, dict[str, Any]]] = {}
|
|
58
|
+
self._answers: dict[str, Future[dict[str, Any]] | None] = {}
|
|
59
|
+
|
|
60
|
+
def start(self, handler: Handler) -> None:
|
|
61
|
+
self._handler = handler
|
|
62
|
+
threading.Thread(target=self._read, name="sqlseed-control-reader", daemon=True).start()
|
|
63
|
+
|
|
64
|
+
def _send(self, message: dict[str, Any]) -> None:
|
|
65
|
+
payload = _encode_message(message)
|
|
66
|
+
with self._send_lock:
|
|
67
|
+
if self._closed.is_set():
|
|
68
|
+
raise RuntimeError(_CONTROL_CLOSED)
|
|
69
|
+
try:
|
|
70
|
+
self.connection.send_bytes(payload)
|
|
71
|
+
except OSError as exc:
|
|
72
|
+
raise RuntimeError(_CONTROL_CLOSED) from exc
|
|
73
|
+
|
|
74
|
+
def call(self, method: str, params: dict[str, Any], *, timeout: float = 15) -> dict[str, Any]:
|
|
75
|
+
identifier = secrets.token_hex(12)
|
|
76
|
+
event = threading.Event()
|
|
77
|
+
result: dict[str, Any] = {}
|
|
78
|
+
with self._lock:
|
|
79
|
+
if len(self._pending) >= 32 or self._closed.is_set():
|
|
80
|
+
raise RuntimeError("服务控制通道暂不可用。")
|
|
81
|
+
self._pending[identifier] = event, result
|
|
82
|
+
try:
|
|
83
|
+
self._send({"id": identifier, "method": method, "params": params})
|
|
84
|
+
if not event.wait(timeout):
|
|
85
|
+
raise RuntimeError("控制请求未完成,请稍后检查服务状态。")
|
|
86
|
+
if "error" in result:
|
|
87
|
+
error = result["error"]
|
|
88
|
+
raise ControlError(error["detail"], error["status_code"])
|
|
89
|
+
value = result.get("result")
|
|
90
|
+
if not isinstance(value, dict):
|
|
91
|
+
# An invalid IPC reply follows the existing RuntimeError channel-failure contract.
|
|
92
|
+
raise RuntimeError(_CONTROL_CLOSED) # noqa: TRY004
|
|
93
|
+
return value
|
|
94
|
+
finally:
|
|
95
|
+
with self._lock:
|
|
96
|
+
self._pending.pop(identifier, None)
|
|
97
|
+
|
|
98
|
+
def _dispatch_message(self, message: dict[str, Any]) -> None:
|
|
99
|
+
if "method" in message:
|
|
100
|
+
if not isinstance(message["method"], str) or not isinstance(message.get("params"), dict):
|
|
101
|
+
raise ValueError("invalid control request")
|
|
102
|
+
if not self._start_answer(message):
|
|
103
|
+
self._send({"id": message["id"], "error": {"status_code": 503, "detail": "服务控制通道繁忙。"}})
|
|
104
|
+
else:
|
|
105
|
+
with self._lock:
|
|
106
|
+
if pending := self._pending.get(message["id"]):
|
|
107
|
+
pending[1].update(message)
|
|
108
|
+
pending[0].set()
|
|
109
|
+
|
|
110
|
+
def _read(self) -> None:
|
|
111
|
+
try:
|
|
112
|
+
while not self._closed.is_set():
|
|
113
|
+
message = json.loads(self.connection.recv_bytes(MAX_MESSAGE_BYTES))
|
|
114
|
+
if not isinstance(message, dict) or not isinstance(message.get("id"), str):
|
|
115
|
+
# Malformed wire values use ValueError, handled by the channel shutdown below.
|
|
116
|
+
raise ValueError("invalid control message") # noqa: TRY004
|
|
117
|
+
self._dispatch_message(message)
|
|
118
|
+
except (OSError, EOFError, ValueError, TypeError, RuntimeError):
|
|
119
|
+
self.close()
|
|
120
|
+
|
|
121
|
+
def _start_answer(self, message: dict[str, Any]) -> bool:
|
|
122
|
+
"""Own each bounded reply from reservation through response publication."""
|
|
123
|
+
identifier = message["id"]
|
|
124
|
+
with self._lock:
|
|
125
|
+
if len(self._answers) >= 8 or identifier in self._answers:
|
|
126
|
+
return False
|
|
127
|
+
self._answers[identifier] = None
|
|
128
|
+
try:
|
|
129
|
+
self._answers[identifier] = DaemonTask(
|
|
130
|
+
lambda: self._handler(message["method"], message["params"]),
|
|
131
|
+
name="sqlseed-control-answer",
|
|
132
|
+
on_done=lambda task: self._answer(identifier, task),
|
|
133
|
+
)
|
|
134
|
+
except BaseException:
|
|
135
|
+
self._answers.pop(identifier, None)
|
|
136
|
+
raise
|
|
137
|
+
return True
|
|
138
|
+
|
|
139
|
+
def _answer(self, identifier: str, task: Future[dict[str, Any]]) -> None:
|
|
140
|
+
response: dict[str, Any] = {"id": identifier}
|
|
141
|
+
if (error := task.exception()) is None:
|
|
142
|
+
response["result"] = task.result()
|
|
143
|
+
elif isinstance(error, (HTTPException, ControlError)):
|
|
144
|
+
response["error"] = {"status_code": error.status_code, "detail": error.detail}
|
|
145
|
+
else:
|
|
146
|
+
response["error"] = {"status_code": 503, "detail": "控制请求未完成,请检查服务状态。"}
|
|
147
|
+
try:
|
|
148
|
+
self._send(response)
|
|
149
|
+
except ControlMessageTooLarge:
|
|
150
|
+
try:
|
|
151
|
+
self._send(
|
|
152
|
+
{
|
|
153
|
+
"id": identifier,
|
|
154
|
+
"error": {
|
|
155
|
+
"status_code": 503,
|
|
156
|
+
"detail": {
|
|
157
|
+
"code": "control_response_too_large",
|
|
158
|
+
"message": "服务状态超过传输上限,请减少连接后重试。",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
except (OSError, RuntimeError):
|
|
164
|
+
pass
|
|
165
|
+
except (OSError, RuntimeError):
|
|
166
|
+
pass
|
|
167
|
+
finally:
|
|
168
|
+
with self._lock:
|
|
169
|
+
self._answers.pop(identifier, None)
|
|
170
|
+
|
|
171
|
+
def close(self) -> None:
|
|
172
|
+
with self._lock:
|
|
173
|
+
if self._closed.is_set():
|
|
174
|
+
return
|
|
175
|
+
self._closed.set()
|
|
176
|
+
for event, _ in self._pending.values():
|
|
177
|
+
event.set()
|
|
178
|
+
with self._send_lock:
|
|
179
|
+
if os.name != "nt":
|
|
180
|
+
# Wake a reader blocked in recv_bytes and notify the peer even
|
|
181
|
+
# when another thread currently holds a reference to this fd.
|
|
182
|
+
endpoint = socket.socket(fileno=self.connection.fileno())
|
|
183
|
+
try:
|
|
184
|
+
endpoint.shutdown(socket.SHUT_RDWR)
|
|
185
|
+
except OSError:
|
|
186
|
+
pass
|
|
187
|
+
finally:
|
|
188
|
+
endpoint.detach()
|
|
189
|
+
self.connection.close()
|
|
190
|
+
|
|
191
|
+
def wait_closed(self, timeout: float | None = None) -> bool:
|
|
192
|
+
return self._closed.wait(timeout)
|