abstractgateway 0.1.0__py3-none-any.whl → 0.1.1__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.
- abstractgateway/__init__.py +1 -2
- abstractgateway/__main__.py +7 -0
- abstractgateway/app.py +4 -4
- abstractgateway/cli.py +568 -8
- abstractgateway/config.py +15 -5
- abstractgateway/embeddings_config.py +45 -0
- abstractgateway/host_metrics.py +274 -0
- abstractgateway/hosts/bundle_host.py +528 -55
- abstractgateway/hosts/visualflow_host.py +30 -3
- abstractgateway/integrations/__init__.py +2 -0
- abstractgateway/integrations/email_bridge.py +782 -0
- abstractgateway/integrations/telegram_bridge.py +534 -0
- abstractgateway/maintenance/__init__.py +5 -0
- abstractgateway/maintenance/action_tokens.py +100 -0
- abstractgateway/maintenance/backlog_exec_runner.py +1592 -0
- abstractgateway/maintenance/backlog_parser.py +184 -0
- abstractgateway/maintenance/draft_generator.py +451 -0
- abstractgateway/maintenance/llm_assist.py +212 -0
- abstractgateway/maintenance/notifier.py +109 -0
- abstractgateway/maintenance/process_manager.py +1064 -0
- abstractgateway/maintenance/report_models.py +81 -0
- abstractgateway/maintenance/report_parser.py +219 -0
- abstractgateway/maintenance/text_similarity.py +123 -0
- abstractgateway/maintenance/triage.py +507 -0
- abstractgateway/maintenance/triage_queue.py +142 -0
- abstractgateway/migrate.py +155 -0
- abstractgateway/routes/__init__.py +2 -2
- abstractgateway/routes/gateway.py +10817 -179
- abstractgateway/routes/triage.py +118 -0
- abstractgateway/runner.py +689 -14
- abstractgateway/security/gateway_security.py +425 -110
- abstractgateway/service.py +213 -6
- abstractgateway/stores.py +64 -4
- abstractgateway/workflow_deprecations.py +225 -0
- abstractgateway-0.1.1.dist-info/METADATA +135 -0
- abstractgateway-0.1.1.dist-info/RECORD +40 -0
- abstractgateway-0.1.0.dist-info/METADATA +0 -101
- abstractgateway-0.1.0.dist-info/RECORD +0 -18
- {abstractgateway-0.1.0.dist-info → abstractgateway-0.1.1.dist-info}/WHEEL +0 -0
- {abstractgateway-0.1.0.dist-info → abstractgateway-0.1.1.dist-info}/entry_points.txt +0 -0
abstractgateway/service.py
CHANGED
|
@@ -2,13 +2,15 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
5
6
|
from typing import Any, Dict, Optional
|
|
6
7
|
|
|
7
8
|
from .config import GatewayHostConfig
|
|
9
|
+
from .embeddings_config import resolve_embedding_config
|
|
8
10
|
from .hosts.visualflow_host import VisualFlowGatewayHost, VisualFlowRegistry
|
|
9
11
|
from .runner import GatewayRunner, GatewayRunnerConfig
|
|
10
12
|
from .security import GatewayAuthPolicy, load_gateway_auth_policy_from_env
|
|
11
|
-
from .stores import GatewayStores, build_file_stores
|
|
13
|
+
from .stores import GatewayStores, build_file_stores, build_sqlite_stores
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
@dataclass(frozen=True)
|
|
@@ -20,9 +22,41 @@ class GatewayService:
|
|
|
20
22
|
host: Any
|
|
21
23
|
runner: GatewayRunner
|
|
22
24
|
auth_policy: GatewayAuthPolicy
|
|
25
|
+
embedding_provider: Optional[str] = None
|
|
26
|
+
embedding_model: Optional[str] = None
|
|
27
|
+
embeddings_client: Optional[Any] = None
|
|
28
|
+
telegram_bridge: Optional[Any] = None
|
|
29
|
+
email_bridge: Optional[Any] = None
|
|
23
30
|
|
|
24
31
|
|
|
25
32
|
_service: Optional[GatewayService] = None
|
|
33
|
+
_backlog_exec_runner: Optional[Any] = None
|
|
34
|
+
_backlog_exec_runner_error: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def backlog_exec_runner_status() -> Dict[str, Any]:
|
|
38
|
+
runner = _backlog_exec_runner
|
|
39
|
+
alive = False
|
|
40
|
+
last_error: Optional[str] = None
|
|
41
|
+
try:
|
|
42
|
+
if runner is not None and hasattr(runner, "is_running"):
|
|
43
|
+
alive = bool(runner.is_running())
|
|
44
|
+
elif runner is not None and hasattr(runner, "_thread"):
|
|
45
|
+
t = getattr(runner, "_thread", None)
|
|
46
|
+
alive = bool(t is not None and getattr(t, "is_alive", lambda: False)())
|
|
47
|
+
except Exception:
|
|
48
|
+
alive = False
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
if runner is not None and hasattr(runner, "last_error"):
|
|
52
|
+
last_error = runner.last_error()
|
|
53
|
+
except Exception:
|
|
54
|
+
last_error = None
|
|
55
|
+
|
|
56
|
+
if _backlog_exec_runner_error:
|
|
57
|
+
last_error = _backlog_exec_runner_error
|
|
58
|
+
|
|
59
|
+
return {"alive": bool(alive), "error": (str(last_error) if last_error else "") or None}
|
|
26
60
|
|
|
27
61
|
|
|
28
62
|
def get_gateway_service() -> GatewayService:
|
|
@@ -34,7 +68,13 @@ def get_gateway_service() -> GatewayService:
|
|
|
34
68
|
|
|
35
69
|
def create_default_gateway_service() -> GatewayService:
|
|
36
70
|
cfg = GatewayHostConfig.from_env()
|
|
37
|
-
|
|
71
|
+
backend = str(getattr(cfg, "store_backend", "file") or "file").strip().lower() or "file"
|
|
72
|
+
if backend == "file":
|
|
73
|
+
stores = build_file_stores(base_dir=cfg.data_dir)
|
|
74
|
+
elif backend == "sqlite":
|
|
75
|
+
stores = build_sqlite_stores(base_dir=cfg.data_dir, db_path=getattr(cfg, "db_path", None))
|
|
76
|
+
else:
|
|
77
|
+
raise RuntimeError(f"Unsupported store backend: {backend}. Supported: file|sqlite")
|
|
38
78
|
|
|
39
79
|
# Workflow source:
|
|
40
80
|
# - bundle (default): `.flow` bundles with VisualFlow JSON (compiled via AbstractRuntime; no AbstractFlow import)
|
|
@@ -45,6 +85,7 @@ def create_default_gateway_service() -> GatewayService:
|
|
|
45
85
|
|
|
46
86
|
host = WorkflowBundleGatewayHost.load_from_dir(
|
|
47
87
|
bundles_dir=cfg.flows_dir,
|
|
88
|
+
data_dir=cfg.data_dir,
|
|
48
89
|
run_store=stores.run_store,
|
|
49
90
|
ledger_store=stores.ledger_store,
|
|
50
91
|
artifact_store=stores.artifact_store,
|
|
@@ -68,22 +109,128 @@ def create_default_gateway_service() -> GatewayService:
|
|
|
68
109
|
tick_workers=int(cfg.tick_workers),
|
|
69
110
|
run_scan_limit=int(cfg.run_scan_limit),
|
|
70
111
|
)
|
|
71
|
-
runner = GatewayRunner(
|
|
112
|
+
runner = GatewayRunner(
|
|
113
|
+
base_dir=stores.base_dir,
|
|
114
|
+
host=host,
|
|
115
|
+
config=runner_cfg,
|
|
116
|
+
enable=bool(cfg.runner_enabled),
|
|
117
|
+
command_store=stores.command_store,
|
|
118
|
+
cursor_store=stores.command_cursor_store,
|
|
119
|
+
)
|
|
72
120
|
|
|
73
121
|
policy = load_gateway_auth_policy_from_env()
|
|
74
|
-
|
|
122
|
+
|
|
123
|
+
embedding_provider: Optional[str] = None
|
|
124
|
+
embedding_model: Optional[str] = None
|
|
125
|
+
embeddings_client: Optional[Any] = None
|
|
126
|
+
try:
|
|
127
|
+
embedding_provider, embedding_model = resolve_embedding_config(base_dir=stores.base_dir)
|
|
128
|
+
from abstractruntime.integrations.abstractcore.embeddings_client import AbstractCoreEmbeddingsClient
|
|
129
|
+
|
|
130
|
+
embeddings_client = AbstractCoreEmbeddingsClient(
|
|
131
|
+
provider=embedding_provider,
|
|
132
|
+
model=embedding_model,
|
|
133
|
+
manager_kwargs={
|
|
134
|
+
"cache_dir": Path(stores.base_dir) / "abstractcore" / "embeddings",
|
|
135
|
+
# Embeddings must be trustworthy for semantic retrieval; do not return zero vectors on failure.
|
|
136
|
+
"strict": True,
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
except Exception:
|
|
140
|
+
# Embeddings are optional: the gateway may run without AbstractCore embedding deps.
|
|
141
|
+
embeddings_client = None
|
|
142
|
+
|
|
143
|
+
telegram_bridge = None
|
|
144
|
+
enabled_raw = os.getenv("ABSTRACT_TELEGRAM_BRIDGE")
|
|
145
|
+
if enabled_raw is not None and str(enabled_raw).strip().lower() in {"1", "true", "yes", "on"}:
|
|
146
|
+
try:
|
|
147
|
+
from .integrations.telegram_bridge import TelegramBridge, TelegramBridgeConfig
|
|
148
|
+
except Exception as e:
|
|
149
|
+
raise RuntimeError(
|
|
150
|
+
"Telegram bridge is enabled (ABSTRACT_TELEGRAM_BRIDGE=1) but the optional Telegram dependencies are not installed. "
|
|
151
|
+
"Install with: `pip install \"abstractgateway[telegram]\"`"
|
|
152
|
+
) from e
|
|
153
|
+
|
|
154
|
+
tcfg = TelegramBridgeConfig.from_env(base_dir=cfg.data_dir)
|
|
155
|
+
if not tcfg.flow_id:
|
|
156
|
+
raise RuntimeError("ABSTRACT_TELEGRAM_FLOW_ID is required when ABSTRACT_TELEGRAM_BRIDGE=1")
|
|
157
|
+
telegram_bridge = TelegramBridge(config=tcfg, host=host, runner=runner, artifact_store=stores.artifact_store)
|
|
158
|
+
|
|
159
|
+
email_bridge = None
|
|
160
|
+
email_enabled_raw = os.getenv("ABSTRACT_EMAIL_BRIDGE")
|
|
161
|
+
if email_enabled_raw is not None and str(email_enabled_raw).strip().lower() in {"1", "true", "yes", "on"}:
|
|
162
|
+
from .integrations.email_bridge import EmailBridge, EmailBridgeConfig
|
|
163
|
+
|
|
164
|
+
ecfg = EmailBridgeConfig.from_env(base_dir=cfg.data_dir)
|
|
165
|
+
email_bridge = EmailBridge(config=ecfg, host=host, runner=runner, artifact_store=stores.artifact_store)
|
|
166
|
+
|
|
167
|
+
return GatewayService(
|
|
168
|
+
config=cfg,
|
|
169
|
+
stores=stores,
|
|
170
|
+
host=host,
|
|
171
|
+
runner=runner,
|
|
172
|
+
auth_policy=policy,
|
|
173
|
+
embedding_provider=embedding_provider,
|
|
174
|
+
embedding_model=embedding_model,
|
|
175
|
+
embeddings_client=embeddings_client,
|
|
176
|
+
telegram_bridge=telegram_bridge,
|
|
177
|
+
email_bridge=email_bridge,
|
|
178
|
+
)
|
|
75
179
|
|
|
76
180
|
|
|
77
181
|
def start_gateway_runner() -> None:
|
|
78
182
|
svc = get_gateway_service()
|
|
79
183
|
svc.runner.start()
|
|
184
|
+
# Optional: backlog execution runner (consumes backlog_exec_queue and executes requests).
|
|
185
|
+
global _backlog_exec_runner, _backlog_exec_runner_error
|
|
186
|
+
try:
|
|
187
|
+
if _backlog_exec_runner is None:
|
|
188
|
+
from .maintenance.backlog_exec_runner import BacklogExecRunner, BacklogExecRunnerConfig
|
|
189
|
+
|
|
190
|
+
cfg = BacklogExecRunnerConfig.from_env()
|
|
191
|
+
if cfg.enabled:
|
|
192
|
+
_backlog_exec_runner = BacklogExecRunner(gateway_data_dir=svc.stores.base_dir, cfg=cfg)
|
|
193
|
+
_backlog_exec_runner.start()
|
|
194
|
+
_backlog_exec_runner_error = None
|
|
195
|
+
except Exception as e:
|
|
196
|
+
# Best-effort: never break the gateway runner start if maintenance runner fails.
|
|
197
|
+
_backlog_exec_runner = None
|
|
198
|
+
try:
|
|
199
|
+
_backlog_exec_runner_error = str(e)
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
bridge = getattr(svc, "telegram_bridge", None)
|
|
203
|
+
if bridge is not None:
|
|
204
|
+
bridge.start()
|
|
205
|
+
email_bridge = getattr(svc, "email_bridge", None)
|
|
206
|
+
if email_bridge is not None:
|
|
207
|
+
email_bridge.start()
|
|
80
208
|
|
|
81
209
|
|
|
82
210
|
def stop_gateway_runner() -> None:
|
|
83
|
-
global _service
|
|
211
|
+
global _service, _backlog_exec_runner, _backlog_exec_runner_error
|
|
84
212
|
if _service is None:
|
|
85
213
|
return
|
|
86
214
|
try:
|
|
215
|
+
try:
|
|
216
|
+
bridge = getattr(_service, "telegram_bridge", None)
|
|
217
|
+
if bridge is not None:
|
|
218
|
+
bridge.stop()
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
try:
|
|
222
|
+
bridge2 = getattr(_service, "email_bridge", None)
|
|
223
|
+
if bridge2 is not None:
|
|
224
|
+
bridge2.stop()
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
try:
|
|
228
|
+
if _backlog_exec_runner is not None:
|
|
229
|
+
_backlog_exec_runner.stop()
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
_backlog_exec_runner = None
|
|
233
|
+
_backlog_exec_runner_error = None
|
|
87
234
|
_service.runner.stop()
|
|
88
235
|
finally:
|
|
89
236
|
_service = None
|
|
@@ -101,6 +248,8 @@ def run_summary(run: Any) -> Dict[str, Any]:
|
|
|
101
248
|
"current_node": getattr(run, "current_node", None),
|
|
102
249
|
"created_at": getattr(run, "created_at", None),
|
|
103
250
|
"updated_at": getattr(run, "updated_at", None),
|
|
251
|
+
"actor_id": getattr(run, "actor_id", None),
|
|
252
|
+
"session_id": getattr(run, "session_id", None),
|
|
104
253
|
"parent_run_id": getattr(run, "parent_run_id", None),
|
|
105
254
|
"error": getattr(run, "error", None),
|
|
106
255
|
# Best-effort pause metadata. We intentionally do not return full run.vars over HTTP.
|
|
@@ -109,6 +258,11 @@ def run_summary(run: Any) -> Dict[str, Any]:
|
|
|
109
258
|
"paused_at": None,
|
|
110
259
|
"resumed_at": None,
|
|
111
260
|
"waiting": None,
|
|
261
|
+
# Best-effort schedule metadata (only for scheduled parent runs).
|
|
262
|
+
"is_scheduled": False,
|
|
263
|
+
"schedule": None,
|
|
264
|
+
# Best-effort limits metadata (for UX, not for enforcing).
|
|
265
|
+
"limits": None,
|
|
112
266
|
}
|
|
113
267
|
try:
|
|
114
268
|
vars_obj = getattr(run, "vars", None)
|
|
@@ -121,14 +275,67 @@ def run_summary(run: Any) -> Dict[str, Any]:
|
|
|
121
275
|
out["resumed_at"] = control.get("resumed_at")
|
|
122
276
|
except Exception:
|
|
123
277
|
pass
|
|
278
|
+
|
|
279
|
+
# Schedule + limits are safe, small subsets for UI. Never return full run.vars.
|
|
280
|
+
try:
|
|
281
|
+
vars_obj = getattr(run, "vars", None)
|
|
282
|
+
if isinstance(vars_obj, dict):
|
|
283
|
+
meta = vars_obj.get("_meta")
|
|
284
|
+
schedule = meta.get("schedule") if isinstance(meta, dict) else None
|
|
285
|
+
if isinstance(schedule, dict) and schedule.get("kind") == "scheduled_run":
|
|
286
|
+
out["is_scheduled"] = True
|
|
287
|
+
out["schedule"] = {
|
|
288
|
+
"kind": "scheduled_run",
|
|
289
|
+
"interval": schedule.get("interval"),
|
|
290
|
+
"repeat_count": schedule.get("repeat_count"),
|
|
291
|
+
"repeat_until": schedule.get("repeat_until"),
|
|
292
|
+
"start_at": schedule.get("start_at"),
|
|
293
|
+
"share_context": schedule.get("share_context"),
|
|
294
|
+
"target_workflow_id": schedule.get("target_workflow_id"),
|
|
295
|
+
"target_bundle_ref": schedule.get("target_bundle_ref"),
|
|
296
|
+
"target_flow_id": schedule.get("target_flow_id"),
|
|
297
|
+
"created_at": schedule.get("created_at"),
|
|
298
|
+
"updated_at": schedule.get("updated_at"),
|
|
299
|
+
}
|
|
300
|
+
else:
|
|
301
|
+
wid = getattr(run, "workflow_id", None)
|
|
302
|
+
if isinstance(wid, str) and wid.startswith("scheduled:"):
|
|
303
|
+
out["is_scheduled"] = True
|
|
304
|
+
|
|
305
|
+
limits = vars_obj.get("_limits")
|
|
306
|
+
if isinstance(limits, dict):
|
|
307
|
+
used = limits.get("estimated_tokens_used")
|
|
308
|
+
max_tokens = limits.get("max_tokens")
|
|
309
|
+
max_input = limits.get("max_input_tokens")
|
|
310
|
+
warn_pct = limits.get("warn_tokens_pct")
|
|
311
|
+
budget = max_input if max_input is not None else max_tokens
|
|
312
|
+
pct = None
|
|
313
|
+
try:
|
|
314
|
+
used_i = int(used) if used is not None and not isinstance(used, bool) else None
|
|
315
|
+
budget_i = int(budget) if budget is not None and not isinstance(budget, bool) else None
|
|
316
|
+
if used_i is not None and budget_i is not None and budget_i > 0:
|
|
317
|
+
pct = float(used_i) / float(budget_i)
|
|
318
|
+
except Exception:
|
|
319
|
+
pct = None
|
|
320
|
+
out["limits"] = {
|
|
321
|
+
"tokens": {
|
|
322
|
+
"estimated_used": used,
|
|
323
|
+
"max_tokens": max_tokens,
|
|
324
|
+
"max_input_tokens": max_input,
|
|
325
|
+
"pct": pct,
|
|
326
|
+
"warn_tokens_pct": warn_pct,
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
except Exception:
|
|
330
|
+
pass
|
|
124
331
|
if waiting is not None:
|
|
125
332
|
out["waiting"] = {
|
|
126
333
|
"reason": getattr(getattr(waiting, "reason", None), "value", None) or str(getattr(waiting, "reason", "")),
|
|
127
334
|
"wait_key": getattr(waiting, "wait_key", None),
|
|
335
|
+
"until": getattr(waiting, "until", None),
|
|
128
336
|
"prompt": getattr(waiting, "prompt", None),
|
|
129
337
|
"choices": getattr(waiting, "choices", None),
|
|
130
338
|
"allow_free_text": getattr(waiting, "allow_free_text", None),
|
|
131
339
|
"details": getattr(waiting, "details", None),
|
|
132
340
|
}
|
|
133
341
|
return out
|
|
134
|
-
|
abstractgateway/stores.py
CHANGED
|
@@ -13,6 +13,8 @@ class GatewayStores:
|
|
|
13
13
|
run_store: Any
|
|
14
14
|
ledger_store: Any
|
|
15
15
|
artifact_store: Any
|
|
16
|
+
command_store: Any
|
|
17
|
+
command_cursor_store: Any
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
def build_file_stores(*, base_dir: Path) -> GatewayStores:
|
|
@@ -21,14 +23,72 @@ def build_file_stores(*, base_dir: Path) -> GatewayStores:
|
|
|
21
23
|
Contract: base_dir is owned by the gateway host process (durable control plane).
|
|
22
24
|
"""
|
|
23
25
|
|
|
24
|
-
from abstractruntime import
|
|
26
|
+
from abstractruntime import (
|
|
27
|
+
FileArtifactStore,
|
|
28
|
+
JsonFileCommandCursorStore,
|
|
29
|
+
JsonFileRunStore,
|
|
30
|
+
JsonlCommandStore,
|
|
31
|
+
JsonlLedgerStore,
|
|
32
|
+
OffloadingLedgerStore,
|
|
33
|
+
OffloadingRunStore,
|
|
34
|
+
ObservableLedgerStore,
|
|
35
|
+
)
|
|
25
36
|
|
|
26
37
|
base = Path(base_dir).expanduser().resolve()
|
|
27
38
|
base.mkdir(parents=True, exist_ok=True)
|
|
28
39
|
|
|
29
|
-
run_store = JsonFileRunStore(base)
|
|
30
|
-
ledger_store = ObservableLedgerStore(JsonlLedgerStore(base))
|
|
31
40
|
artifact_store = FileArtifactStore(base)
|
|
32
|
-
|
|
41
|
+
run_store = OffloadingRunStore(JsonFileRunStore(base), artifact_store=artifact_store)
|
|
42
|
+
ledger_store = OffloadingLedgerStore(ObservableLedgerStore(JsonlLedgerStore(base)), artifact_store=artifact_store)
|
|
43
|
+
command_store = JsonlCommandStore(base)
|
|
44
|
+
command_cursor_store = JsonFileCommandCursorStore(base / "commands_cursor.json")
|
|
45
|
+
return GatewayStores(
|
|
46
|
+
base_dir=base,
|
|
47
|
+
run_store=run_store,
|
|
48
|
+
ledger_store=ledger_store,
|
|
49
|
+
artifact_store=artifact_store,
|
|
50
|
+
command_store=command_store,
|
|
51
|
+
command_cursor_store=command_cursor_store,
|
|
52
|
+
)
|
|
33
53
|
|
|
34
54
|
|
|
55
|
+
def build_sqlite_stores(*, base_dir: Path, db_path: Path | None = None) -> GatewayStores:
|
|
56
|
+
"""Create SQLite-backed stores under base_dir.
|
|
57
|
+
|
|
58
|
+
Note:
|
|
59
|
+
- Artifacts remain file-backed (blobs on disk); the DB stores structured metadata/state.
|
|
60
|
+
- The DB file defaults to `<base_dir>/gateway.sqlite3` when db_path is not provided.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
from abstractruntime import (
|
|
64
|
+
FileArtifactStore,
|
|
65
|
+
ObservableLedgerStore,
|
|
66
|
+
OffloadingLedgerStore,
|
|
67
|
+
OffloadingRunStore,
|
|
68
|
+
SqliteCommandCursorStore,
|
|
69
|
+
SqliteCommandStore,
|
|
70
|
+
SqliteDatabase,
|
|
71
|
+
SqliteLedgerStore,
|
|
72
|
+
SqliteRunStore,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
base = Path(base_dir).expanduser().resolve()
|
|
76
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
|
|
78
|
+
db_file = Path(db_path).expanduser().resolve() if db_path is not None else (base / "gateway.sqlite3")
|
|
79
|
+
db = SqliteDatabase(db_file)
|
|
80
|
+
|
|
81
|
+
artifact_store = FileArtifactStore(base)
|
|
82
|
+
run_store = OffloadingRunStore(SqliteRunStore(db), artifact_store=artifact_store)
|
|
83
|
+
ledger_store = OffloadingLedgerStore(ObservableLedgerStore(SqliteLedgerStore(db)), artifact_store=artifact_store)
|
|
84
|
+
command_store = SqliteCommandStore(db)
|
|
85
|
+
command_cursor_store = SqliteCommandCursorStore(db, consumer_id="gateway_runner")
|
|
86
|
+
|
|
87
|
+
return GatewayStores(
|
|
88
|
+
base_dir=base,
|
|
89
|
+
run_store=run_store,
|
|
90
|
+
ledger_store=ledger_store,
|
|
91
|
+
artifact_store=artifact_store,
|
|
92
|
+
command_store=command_store,
|
|
93
|
+
command_cursor_store=command_cursor_store,
|
|
94
|
+
)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
import threading
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _utc_now_iso() -> str:
|
|
16
|
+
return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _empty_state() -> Dict[str, Any]:
|
|
20
|
+
return {"version": 1, "bundles": {}}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _normalize_state(raw: Any) -> Dict[str, Any]:
|
|
24
|
+
if not isinstance(raw, dict):
|
|
25
|
+
return _empty_state()
|
|
26
|
+
bundles0 = raw.get("bundles")
|
|
27
|
+
if not isinstance(bundles0, dict):
|
|
28
|
+
return _empty_state()
|
|
29
|
+
bundles: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
|
30
|
+
for bid, flows in bundles0.items():
|
|
31
|
+
b = str(bid or "").strip()
|
|
32
|
+
if not b:
|
|
33
|
+
continue
|
|
34
|
+
if not isinstance(flows, dict):
|
|
35
|
+
continue
|
|
36
|
+
out_flows: Dict[str, Dict[str, Any]] = {}
|
|
37
|
+
for fid, rec in flows.items():
|
|
38
|
+
f = str(fid or "").strip() or "*"
|
|
39
|
+
if not isinstance(rec, dict):
|
|
40
|
+
continue
|
|
41
|
+
deprecated_at = str(rec.get("deprecated_at") or "").strip() or ""
|
|
42
|
+
reason = str(rec.get("reason") or "").strip() or ""
|
|
43
|
+
deprecated_by = str(rec.get("deprecated_by") or "").strip() or ""
|
|
44
|
+
out_flows[f] = {
|
|
45
|
+
"deprecated_at": deprecated_at,
|
|
46
|
+
**({"reason": reason} if reason else {}),
|
|
47
|
+
**({"deprecated_by": deprecated_by} if deprecated_by else {}),
|
|
48
|
+
}
|
|
49
|
+
if out_flows:
|
|
50
|
+
bundles[b] = out_flows
|
|
51
|
+
return {"version": 1, "bundles": bundles}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _atomic_write_json(path: Path, payload: Dict[str, Any]) -> None:
|
|
55
|
+
path = Path(path)
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
data = json.dumps(payload, ensure_ascii=False, indent=2)
|
|
58
|
+
tmp_fd: Optional[int] = None
|
|
59
|
+
tmp_path: Optional[str] = None
|
|
60
|
+
try:
|
|
61
|
+
tmp_fd, tmp_path = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
|
|
62
|
+
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
|
|
63
|
+
tmp_fd = None
|
|
64
|
+
f.write(data)
|
|
65
|
+
f.flush()
|
|
66
|
+
try:
|
|
67
|
+
os.fsync(f.fileno())
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
os.replace(str(tmp_path), str(path))
|
|
71
|
+
tmp_path = None
|
|
72
|
+
finally:
|
|
73
|
+
if tmp_fd is not None:
|
|
74
|
+
try:
|
|
75
|
+
os.close(tmp_fd)
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
if tmp_path is not None:
|
|
79
|
+
try:
|
|
80
|
+
os.unlink(tmp_path)
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class WorkflowDeprecatedError(RuntimeError):
|
|
86
|
+
def __init__(self, *, bundle_id: str, flow_id: str, record: Dict[str, Any]):
|
|
87
|
+
self.bundle_id = str(bundle_id or "").strip()
|
|
88
|
+
self.flow_id = str(flow_id or "").strip() or "*"
|
|
89
|
+
self.record = dict(record or {})
|
|
90
|
+
reason = str(self.record.get("reason") or "").strip()
|
|
91
|
+
msg = f"Workflow '{self.bundle_id}:{self.flow_id}' is deprecated"
|
|
92
|
+
if reason:
|
|
93
|
+
msg = f"{msg}: {reason}"
|
|
94
|
+
super().__init__(msg)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class WorkflowDeprecationStore:
|
|
98
|
+
"""File-backed store for workflow deprecation status (gateway-owned).
|
|
99
|
+
|
|
100
|
+
Format (v1):
|
|
101
|
+
{
|
|
102
|
+
"version": 1,
|
|
103
|
+
"bundles": {
|
|
104
|
+
"bundle_id": {
|
|
105
|
+
"*": { "deprecated_at": "...", "reason": "..." },
|
|
106
|
+
"flow_id": { "deprecated_at": "...", "reason": "..." }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(self, *, path: str | Path) -> None:
|
|
113
|
+
self._path = Path(path).expanduser().resolve()
|
|
114
|
+
self._lock = threading.RLock()
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def path(self) -> Path:
|
|
118
|
+
return self._path
|
|
119
|
+
|
|
120
|
+
def _load_unlocked(self) -> Tuple[Dict[str, Any], Optional[str]]:
|
|
121
|
+
if not self._path.exists():
|
|
122
|
+
return _empty_state(), None
|
|
123
|
+
try:
|
|
124
|
+
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
125
|
+
return _normalize_state(raw), None
|
|
126
|
+
except Exception as e:
|
|
127
|
+
# Best-effort recovery: keep a copy for inspection and start fresh.
|
|
128
|
+
try:
|
|
129
|
+
ts = _utc_now_iso().replace(":", "").replace("-", "")
|
|
130
|
+
backup = self._path.with_suffix(self._path.suffix + f".corrupt.{ts}")
|
|
131
|
+
os.replace(str(self._path), str(backup))
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
return _empty_state(), str(e)
|
|
135
|
+
|
|
136
|
+
def snapshot(self) -> Tuple[Dict[str, Any], Optional[str]]:
|
|
137
|
+
with self._lock:
|
|
138
|
+
return self._load_unlocked()
|
|
139
|
+
|
|
140
|
+
def get_record(self, *, bundle_id: str, flow_id: str) -> Optional[Dict[str, Any]]:
|
|
141
|
+
bid = str(bundle_id or "").strip()
|
|
142
|
+
fid = str(flow_id or "").strip() or "*"
|
|
143
|
+
if not bid:
|
|
144
|
+
return None
|
|
145
|
+
with self._lock:
|
|
146
|
+
state, _err = self._load_unlocked()
|
|
147
|
+
bundles = state.get("bundles")
|
|
148
|
+
if not isinstance(bundles, dict):
|
|
149
|
+
return None
|
|
150
|
+
flows = bundles.get(bid)
|
|
151
|
+
if not isinstance(flows, dict):
|
|
152
|
+
return None
|
|
153
|
+
specific = flows.get(fid)
|
|
154
|
+
if isinstance(specific, dict) and specific.get("deprecated_at"):
|
|
155
|
+
return dict(specific)
|
|
156
|
+
wildcard = flows.get("*")
|
|
157
|
+
if isinstance(wildcard, dict) and wildcard.get("deprecated_at"):
|
|
158
|
+
return dict(wildcard)
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
def is_deprecated(self, *, bundle_id: str, flow_id: str) -> bool:
|
|
162
|
+
return self.get_record(bundle_id=bundle_id, flow_id=flow_id) is not None
|
|
163
|
+
|
|
164
|
+
def set_deprecated(
|
|
165
|
+
self,
|
|
166
|
+
*,
|
|
167
|
+
bundle_id: str,
|
|
168
|
+
flow_id: Optional[str] = None,
|
|
169
|
+
reason: Optional[str] = None,
|
|
170
|
+
deprecated_by: Optional[str] = None,
|
|
171
|
+
deprecated_at: Optional[str] = None,
|
|
172
|
+
) -> Dict[str, Any]:
|
|
173
|
+
bid = str(bundle_id or "").strip()
|
|
174
|
+
if not bid:
|
|
175
|
+
raise ValueError("bundle_id is required")
|
|
176
|
+
fid = str(flow_id or "").strip() or "*"
|
|
177
|
+
rec: Dict[str, Any] = {"deprecated_at": str(deprecated_at or "").strip() or _utc_now_iso()}
|
|
178
|
+
r = str(reason or "").strip()
|
|
179
|
+
if r:
|
|
180
|
+
rec["reason"] = r
|
|
181
|
+
by = str(deprecated_by or "").strip()
|
|
182
|
+
if by:
|
|
183
|
+
rec["deprecated_by"] = by
|
|
184
|
+
with self._lock:
|
|
185
|
+
state, err = self._load_unlocked()
|
|
186
|
+
if err:
|
|
187
|
+
logger.warning("Deprecation store reset due to parse error", extra={"path": str(self._path), "error": err})
|
|
188
|
+
bundles = state.setdefault("bundles", {})
|
|
189
|
+
if not isinstance(bundles, dict):
|
|
190
|
+
bundles = {}
|
|
191
|
+
state["bundles"] = bundles
|
|
192
|
+
flows = bundles.get(bid)
|
|
193
|
+
if not isinstance(flows, dict):
|
|
194
|
+
flows = {}
|
|
195
|
+
bundles[bid] = flows
|
|
196
|
+
flows[fid] = rec
|
|
197
|
+
_atomic_write_json(self._path, _normalize_state(state))
|
|
198
|
+
return {"bundle_id": bid, "flow_id": fid, **rec}
|
|
199
|
+
|
|
200
|
+
def clear_deprecated(self, *, bundle_id: str, flow_id: Optional[str] = None) -> bool:
|
|
201
|
+
bid = str(bundle_id or "").strip()
|
|
202
|
+
if not bid:
|
|
203
|
+
raise ValueError("bundle_id is required")
|
|
204
|
+
fid = str(flow_id or "").strip() or "*"
|
|
205
|
+
with self._lock:
|
|
206
|
+
state, err = self._load_unlocked()
|
|
207
|
+
if err:
|
|
208
|
+
logger.warning("Deprecation store reset due to parse error", extra={"path": str(self._path), "error": err})
|
|
209
|
+
bundles = state.get("bundles")
|
|
210
|
+
if not isinstance(bundles, dict):
|
|
211
|
+
return False
|
|
212
|
+
flows = bundles.get(bid)
|
|
213
|
+
if not isinstance(flows, dict):
|
|
214
|
+
return False
|
|
215
|
+
if fid not in flows:
|
|
216
|
+
return False
|
|
217
|
+
del flows[fid]
|
|
218
|
+
if not flows:
|
|
219
|
+
try:
|
|
220
|
+
del bundles[bid]
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
_atomic_write_json(self._path, _normalize_state(state))
|
|
224
|
+
return True
|
|
225
|
+
|