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.
Files changed (40) hide show
  1. abstractgateway/__init__.py +1 -2
  2. abstractgateway/__main__.py +7 -0
  3. abstractgateway/app.py +4 -4
  4. abstractgateway/cli.py +568 -8
  5. abstractgateway/config.py +15 -5
  6. abstractgateway/embeddings_config.py +45 -0
  7. abstractgateway/host_metrics.py +274 -0
  8. abstractgateway/hosts/bundle_host.py +528 -55
  9. abstractgateway/hosts/visualflow_host.py +30 -3
  10. abstractgateway/integrations/__init__.py +2 -0
  11. abstractgateway/integrations/email_bridge.py +782 -0
  12. abstractgateway/integrations/telegram_bridge.py +534 -0
  13. abstractgateway/maintenance/__init__.py +5 -0
  14. abstractgateway/maintenance/action_tokens.py +100 -0
  15. abstractgateway/maintenance/backlog_exec_runner.py +1592 -0
  16. abstractgateway/maintenance/backlog_parser.py +184 -0
  17. abstractgateway/maintenance/draft_generator.py +451 -0
  18. abstractgateway/maintenance/llm_assist.py +212 -0
  19. abstractgateway/maintenance/notifier.py +109 -0
  20. abstractgateway/maintenance/process_manager.py +1064 -0
  21. abstractgateway/maintenance/report_models.py +81 -0
  22. abstractgateway/maintenance/report_parser.py +219 -0
  23. abstractgateway/maintenance/text_similarity.py +123 -0
  24. abstractgateway/maintenance/triage.py +507 -0
  25. abstractgateway/maintenance/triage_queue.py +142 -0
  26. abstractgateway/migrate.py +155 -0
  27. abstractgateway/routes/__init__.py +2 -2
  28. abstractgateway/routes/gateway.py +10817 -179
  29. abstractgateway/routes/triage.py +118 -0
  30. abstractgateway/runner.py +689 -14
  31. abstractgateway/security/gateway_security.py +425 -110
  32. abstractgateway/service.py +213 -6
  33. abstractgateway/stores.py +64 -4
  34. abstractgateway/workflow_deprecations.py +225 -0
  35. abstractgateway-0.1.1.dist-info/METADATA +135 -0
  36. abstractgateway-0.1.1.dist-info/RECORD +40 -0
  37. abstractgateway-0.1.0.dist-info/METADATA +0 -101
  38. abstractgateway-0.1.0.dist-info/RECORD +0 -18
  39. {abstractgateway-0.1.0.dist-info → abstractgateway-0.1.1.dist-info}/WHEEL +0 -0
  40. {abstractgateway-0.1.0.dist-info → abstractgateway-0.1.1.dist-info}/entry_points.txt +0 -0
@@ -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
- stores = build_file_stores(base_dir=cfg.data_dir)
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(base_dir=stores.base_dir, host=host, config=runner_cfg, enable=bool(cfg.runner_enabled))
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
- return GatewayService(config=cfg, stores=stores, host=host, runner=runner, auth_policy=policy)
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 FileArtifactStore, JsonFileRunStore, JsonlLedgerStore, ObservableLedgerStore
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
- return GatewayStores(base_dir=base, run_store=run_store, ledger_store=ledger_store, artifact_store=artifact_store)
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
+