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
@@ -6,6 +6,5 @@ AbstractGateway is a deployable Run Gateway host for AbstractRuntime:
6
6
  - security middleware for network-safe deployments
7
7
  """
8
8
 
9
- __version__ = "0.1.0"
10
-
9
+ __version__ = "0.1.1"
11
10
 
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
7
+
abstractgateway/app.py CHANGED
@@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
7
7
  from fastapi import FastAPI
8
8
  from fastapi.middleware.cors import CORSMiddleware
9
9
 
10
- from .routes import gateway_router
10
+ from .routes import gateway_router, triage_router
11
11
  from .security import GatewaySecurityMiddleware, load_gateway_auth_policy_from_env
12
12
 
13
13
 
@@ -26,7 +26,7 @@ async def _lifespan(_app: FastAPI):
26
26
  app = FastAPI(
27
27
  title="AbstractGateway",
28
28
  description="Durable Run Gateway for AbstractRuntime (commands + ledger replay/stream).",
29
- version="0.1.0",
29
+ version="0.1.1",
30
30
  lifespan=_lifespan,
31
31
  )
32
32
 
@@ -43,13 +43,13 @@ app.add_middleware(
43
43
  allow_credentials=True,
44
44
  allow_methods=["*"],
45
45
  allow_headers=["*"],
46
+ expose_headers=["*"],
46
47
  )
47
48
 
48
49
  app.include_router(gateway_router, prefix="/api")
50
+ app.include_router(triage_router, prefix="/api")
49
51
 
50
52
 
51
53
  @app.get("/api/health")
52
54
  async def health_check():
53
55
  return {"status": "healthy", "service": "abstractgateway"}
54
-
55
-
abstractgateway/cli.py CHANGED
@@ -1,30 +1,590 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import logging
5
+ import os
6
+ import signal
7
+ import threading
8
+ import json
9
+ import sys
10
+ import copy
11
+
12
+
13
+ def _stderr(line: str) -> None:
14
+ print(str(line), file=sys.stderr)
15
+
16
+
17
+ def _configure_console_logging(level: int = logging.INFO) -> None:
18
+ """Best-effort console logging config aligned with AbstractCore's default format."""
19
+ fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
20
+ datefmt = "%H:%M:%S"
21
+ try:
22
+ # Prefer AbstractCore's colored formatter when available to keep a consistent UX.
23
+ from abstractcore.utils.structured_logging import ColoredFormatter # type: ignore
24
+
25
+ formatter: logging.Formatter = ColoredFormatter(fmt, datefmt=datefmt)
26
+ except Exception:
27
+ formatter = logging.Formatter(fmt, datefmt=datefmt)
28
+ root = logging.getLogger()
29
+ if root.handlers:
30
+ for h in list(root.handlers):
31
+ try:
32
+ h.setFormatter(formatter)
33
+ except Exception:
34
+ continue
35
+ try:
36
+ root.setLevel(int(level))
37
+ except Exception:
38
+ pass
39
+ return
40
+ logging.basicConfig(level=int(level), format=fmt, datefmt=datefmt)
41
+ # `basicConfig` created handlers; set our preferred formatter.
42
+ for h in list(logging.getLogger().handlers):
43
+ try:
44
+ h.setFormatter(formatter)
45
+ except Exception:
46
+ continue
47
+
48
+
49
+ def _build_uvicorn_log_config(*, uvicorn, silence_gpu_metrics_access_log: bool) -> dict:
50
+ """Return a uvicorn log_config dict that matches AbstractCore-style formatting."""
51
+ try:
52
+ base = getattr(getattr(uvicorn, "config", None), "LOGGING_CONFIG", None)
53
+ if not isinstance(base, dict):
54
+ return {}
55
+ log_config = copy.deepcopy(base)
56
+ except Exception:
57
+ return {}
58
+
59
+ datefmt = "%H:%M:%S"
60
+ default_fmt = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
61
+ access_fmt = '%(asctime)s [%(levelname)s] %(name)s: %(client_addr)s - "%(request_line)s" %(status_code)s'
62
+
63
+ try:
64
+ fmts = log_config.setdefault("formatters", {})
65
+ # Replace uvicorn's default formatters with AbstractCore's formatter when available.
66
+ # NOTE: uvicorn's built-in DefaultFormatter uses `use_colors`; our formatter does not.
67
+ try:
68
+ from abstractcore.utils.structured_logging import ColoredFormatter # type: ignore
69
+
70
+ formatter_path = "abstractcore.utils.structured_logging.ColoredFormatter"
71
+ _ = ColoredFormatter # silence unused import for type checkers
72
+ except Exception:
73
+ formatter_path = "logging.Formatter"
74
+
75
+ fmts["default"] = {"()": formatter_path, "fmt": default_fmt, "datefmt": datefmt}
76
+ fmts["access"] = {"()": formatter_path, "fmt": access_fmt, "datefmt": datefmt}
77
+ except Exception:
78
+ # If uvicorn logging config structure changes, keep default logging.
79
+ return log_config
80
+
81
+ if silence_gpu_metrics_access_log:
82
+ log_config.setdefault("filters", {})["suppress_gpu_metrics"] = {
83
+ "()": "abstractgateway.cli._UvicornAccessLogFilter"
84
+ }
85
+ log_config.setdefault("handlers", {}).setdefault("access", {})["filters"] = ["suppress_gpu_metrics"]
86
+
87
+ return log_config
88
+
89
+
90
+ def _is_loopback_host(host: str) -> bool:
91
+ h = str(host or "").strip().lower()
92
+ return h in {"127.0.0.1", "::1", "localhost"}
93
+
94
+
95
+ def _is_public_bind_host(host: str) -> bool:
96
+ h = str(host or "").strip().lower()
97
+ return h in {"0.0.0.0", "::"}
98
+
99
+
100
+ def _is_weak_token(token: str) -> bool:
101
+ t = str(token or "").strip()
102
+ if not t:
103
+ return True
104
+ if t.lower() in {"dev-token", "devtoken", "token", "changeme", "password", "admin", "god", "zeus", "root", "superuser"}:
105
+ return True
106
+ # Heuristic: short shared secrets are easy to brute-force / leak.
107
+ return len(t) < 15
108
+
109
+
110
+ def _looks_like_public_origin_pattern(pattern: str) -> bool:
111
+ p = str(pattern or "").strip().lower()
112
+ if not p:
113
+ return False
114
+ if p == "*":
115
+ return True
116
+ if "ngrok" in p and "*" in p:
117
+ return True
118
+ if "localhost" in p or "127.0.0.1" in p or "::1" in p:
119
+ return False
120
+ # Any wildcard on a non-loopback origin is risky.
121
+ return "*" in p
122
+
123
+
124
+ class _UvicornAccessLogFilter(logging.Filter):
125
+ def filter(self, record: logging.LogRecord) -> bool: # pragma: no cover
126
+ # Silence the extremely high-frequency GPU metrics polling logs (200 OK only).
127
+ # Keep non-200 logs as a security/ops signal.
128
+ try:
129
+ args = record.args
130
+ # Uvicorn access logs pass args as:
131
+ # (client_addr, method, full_path, http_version, status_code)
132
+ if isinstance(args, tuple) and len(args) >= 5:
133
+ full_path = str(args[2] or "")
134
+ status_code = args[4]
135
+ if "/api/gateway/host/metrics/gpu" in full_path:
136
+ try:
137
+ if int(status_code) == 200:
138
+ return False
139
+ except Exception:
140
+ pass
141
+ except Exception:
142
+ pass
143
+
144
+ try:
145
+ msg = record.getMessage()
146
+ except Exception:
147
+ return True
148
+
149
+ if "/api/gateway/host/metrics/gpu" in msg and msg.rstrip().endswith(" 200"):
150
+ return False
151
+ return True
4
152
 
5
153
 
6
154
  def main(argv: list[str] | None = None) -> None:
155
+ _configure_console_logging()
7
156
  parser = argparse.ArgumentParser(prog="abstractgateway", description="AbstractGateway (Run Gateway host)")
8
157
  sub = parser.add_subparsers(dest="cmd", required=True)
9
158
 
10
159
  serve = sub.add_parser("serve", help="Run the AbstractGateway HTTP/SSE server")
11
- serve.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
160
+ serve.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
12
161
  serve.add_argument("--port", type=int, default=8080, help="Bind port (default: 8080)")
13
162
  serve.add_argument("--reload", action="store_true", help="Enable auto-reload (dev only)")
163
+ serve.add_argument(
164
+ "--no-runner",
165
+ action="store_true",
166
+ help="Serve the HTTP API without starting the runner (use `abstractgateway runner` in another process).",
167
+ )
168
+
169
+ runner = sub.add_parser("runner", help="Run the AbstractGateway runner worker (no HTTP)")
170
+
171
+ tg = sub.add_parser("telegram-auth", help="One-time TDLib authentication bootstrap for Telegram Secret Chats (E2EE)")
172
+ tg.add_argument("--timeout-s", type=float, default=120.0, help="Max seconds to wait for TDLib authorization (default: 120)")
173
+
174
+ mig = sub.add_parser("migrate", help="Migrate durable stores between backends (best-effort)")
175
+ mig.add_argument("--from", dest="src", default="file", choices=["file"], help="Source backend (default: file)")
176
+ mig.add_argument("--to", dest="dst", default="sqlite", choices=["sqlite"], help="Destination backend (default: sqlite)")
177
+ mig.add_argument(
178
+ "--data-dir",
179
+ default=None,
180
+ help="Source data dir (defaults to ABSTRACTGATEWAY_DATA_DIR or ./runtime)",
181
+ )
182
+ mig.add_argument(
183
+ "--db-path",
184
+ default=None,
185
+ help="Destination sqlite file path (defaults to <data-dir>/gateway.sqlite3)",
186
+ )
187
+ mig.add_argument("--overwrite", action="store_true", help="Overwrite destination DB if it exists")
188
+
189
+ triage = sub.add_parser("triage-reports", help="Triage /bug and /feature reports (decision queue + optional backlog drafts)")
190
+ triage.add_argument(
191
+ "--data-dir",
192
+ default=None,
193
+ help="Gateway data dir (defaults to ABSTRACTGATEWAY_DATA_DIR or ./runtime/gateway)",
194
+ )
195
+ triage.add_argument(
196
+ "--repo-root",
197
+ default=None,
198
+ help="Repo root containing docs/backlog (auto-detected from CWD if omitted)",
199
+ )
200
+ triage.add_argument("--write-drafts", action="store_true", help="Write backlog drafts into docs/backlog/proposed/")
201
+ triage.add_argument("--llm", action="store_true", help="Enable optional LLM assist (env/config required)")
202
+ triage.add_argument("--action-base-url", default=None, help="Base URL for triage action links (e.g., https://<host>)")
203
+ triage.add_argument("--print-actions", action="store_true", help="Print approve/defer/reject action links for pending decisions")
204
+ triage.add_argument("--notify", action="store_true", help="Send a notification (Telegram/email) when pending decisions exist")
205
+ triage.add_argument("--json", action="store_true", help="Emit machine-readable JSON output")
206
+
207
+ triage_apply = sub.add_parser("triage-apply", help="Apply a triage decision action (approve/reject/defer)")
208
+ triage_apply.add_argument("decision_id", help="Decision id (stable hash) under <data_dir>/triage_queue/")
209
+ triage_apply.add_argument("action", choices=["approve", "reject", "defer"], help="Action to apply")
210
+ triage_apply.add_argument(
211
+ "--data-dir",
212
+ default=None,
213
+ help="Gateway data dir (defaults to ABSTRACTGATEWAY_DATA_DIR or ./runtime/gateway)",
214
+ )
215
+ triage_apply.add_argument(
216
+ "--repo-root",
217
+ default=None,
218
+ help="Repo root containing docs/backlog (auto-detected from CWD if omitted)",
219
+ )
220
+
221
+ be = sub.add_parser("backlog-exec-runner", help="Run backlog execution runner (consumes backlog_exec_queue)")
222
+ be.add_argument(
223
+ "--data-dir",
224
+ default=None,
225
+ help="Gateway data dir (defaults to ABSTRACTGATEWAY_DATA_DIR or ./runtime/gateway)",
226
+ )
227
+ be.add_argument(
228
+ "--repo-root",
229
+ default=None,
230
+ help="Repo root containing docs/backlog (defaults to ABSTRACTGATEWAY_TRIAGE_REPO_ROOT or CWD)",
231
+ )
14
232
 
15
233
  args = parser.parse_args(argv)
16
234
 
17
235
  if args.cmd == "serve":
18
- import uvicorn
236
+ # ------------------------------------------------------------------
237
+ # Startup security self-checks (fail-fast on missing auth token).
238
+ # ------------------------------------------------------------------
239
+ try:
240
+ from .security import load_gateway_auth_policy_from_env
241
+ except Exception:
242
+ load_gateway_auth_policy_from_env = None # type: ignore[assignment]
243
+
244
+ if load_gateway_auth_policy_from_env is not None:
245
+ policy = load_gateway_auth_policy_from_env()
246
+
247
+ if bool(policy.enabled) and bool(policy.protect_write_endpoints) and not tuple(policy.tokens or ()):
248
+ raise SystemExit(
249
+ "Missing gateway auth token.\n\n"
250
+ "Set a strong shared secret before starting the gateway:\n"
251
+ ' export ABSTRACTGATEWAY_AUTH_TOKEN="$(python -c \'import secrets; print(secrets.token_urlsafe(32))\')"\n'
252
+ "\n"
253
+ "This is required for security, especially if you bind to 0.0.0.0 or expose the gateway via ngrok."
254
+ )
255
+
256
+ host = str(getattr(args, "host", "") or "")
257
+ if _is_public_bind_host(host):
258
+ _stderr(
259
+ "[WARN] Gateway is binding to 0.0.0.0/:: (non-loopback). "
260
+ "If you expose this service (ngrok/LAN), ensure you use a strong auth token and restrict origins."
261
+ )
262
+ _stderr(" Example hardening:")
263
+ _stderr(
264
+ ' export ABSTRACTGATEWAY_AUTH_TOKEN="$(python -c \'import secrets; print(secrets.token_urlsafe(32))\')"'
265
+ )
266
+ _stderr(" export ABSTRACTGATEWAY_ALLOWED_ORIGINS=https://<your-subdomain>.ngrok-free.app")
267
+ _stderr(" export ABSTRACTGATEWAY_BACKLOG_EXEC_RUNNER=0 # unless explicitly needed")
268
+ if any(_is_weak_token(t) for t in tuple(policy.tokens or ())):
269
+ raise SystemExit(
270
+ "Refusing to start: weak auth token detected while binding to a non-loopback host.\n"
271
+ "Set a stronger token (>=15 chars, random) and try again."
272
+ )
273
+
274
+ origins = tuple(getattr(policy, "allowed_origins", ()) or ())
275
+ public_wildcard_origins = any(_looks_like_public_origin_pattern(o) for o in origins)
276
+ if public_wildcard_origins:
277
+ _stderr(
278
+ "[WARN] ABSTRACTGATEWAY_ALLOWED_ORIGINS contains public wildcard origin patterns. "
279
+ "This weakens browser-origin protections."
280
+ )
281
+ if not _is_loopback_host(host) and any(_is_weak_token(t) for t in tuple(policy.tokens or ())):
282
+ raise SystemExit(
283
+ "Refusing to start: weak auth token detected while using public wildcard origins.\n"
284
+ "Set a stronger token (>=15 chars, random) and try again."
285
+ )
286
+
287
+ # Backlog exec runner can run code/tools; warn loudly when enabled.
288
+ runner_enabled = str(os.getenv("ABSTRACTGATEWAY_BACKLOG_EXEC_RUNNER") or os.getenv("ABSTRACT_BACKLOG_EXEC_RUNNER") or "").strip()
289
+ if runner_enabled.lower() in {"1", "true", "yes", "on"}:
290
+ _stderr(
291
+ "[WARN] Backlog exec runner is enabled. It can execute queued backlog tasks on this machine. "
292
+ "Only enable this in trusted environments."
293
+ )
294
+
295
+ if str(os.getenv("ABSTRACTGATEWAY_SILENCE_GPU_METRICS_ACCESS_LOG", "1")).strip().lower() in {"1", "true", "yes", "on"}:
296
+ silence_gpu_metrics_access_log = True
297
+ else:
298
+ silence_gpu_metrics_access_log = False
299
+
300
+ prev_runner_env = os.environ.get("ABSTRACTGATEWAY_RUNNER")
301
+ if bool(getattr(args, "no_runner", False)):
302
+ # Override env for this process: do not start the background runner loop in the HTTP API process.
303
+ os.environ["ABSTRACTGATEWAY_RUNNER"] = "0"
304
+
305
+ try:
306
+ import uvicorn
307
+ except Exception as e:
308
+ raise SystemExit(
309
+ "AbstractGateway HTTP server dependencies are missing.\n"
310
+ "Install with: `pip install \"abstractgateway[http]\"`\n"
311
+ f"(import failed: {e})"
312
+ )
313
+
314
+ try:
315
+ run_kwargs: dict[str, object] = {
316
+ "host": str(args.host),
317
+ "port": int(args.port),
318
+ "reload": bool(args.reload),
319
+ }
320
+
321
+ log_config = _build_uvicorn_log_config(
322
+ uvicorn=uvicorn,
323
+ silence_gpu_metrics_access_log=bool(silence_gpu_metrics_access_log),
324
+ )
325
+ if log_config:
326
+ run_kwargs["log_config"] = log_config
327
+
328
+ uvicorn.run("abstractgateway.app:app", **run_kwargs)
329
+ finally:
330
+ if prev_runner_env is None:
331
+ os.environ.pop("ABSTRACTGATEWAY_RUNNER", None)
332
+ else:
333
+ os.environ["ABSTRACTGATEWAY_RUNNER"] = prev_runner_env
334
+ return
335
+
336
+ if args.cmd == "runner":
337
+ # Force runner enabled for this process.
338
+ prev_runner_env = os.environ.get("ABSTRACTGATEWAY_RUNNER")
339
+ os.environ["ABSTRACTGATEWAY_RUNNER"] = "1"
19
340
 
20
- uvicorn.run(
21
- "abstractgateway.app:app",
22
- host=str(args.host),
23
- port=int(args.port),
24
- reload=bool(args.reload),
341
+ from .service import start_gateway_runner, stop_gateway_runner
342
+
343
+ stop = threading.Event()
344
+
345
+ def _handle(_signum, _frame) -> None: # pragma: no cover
346
+ stop.set()
347
+
348
+ try:
349
+ signal.signal(signal.SIGINT, _handle)
350
+ signal.signal(signal.SIGTERM, _handle)
351
+ except Exception:
352
+ # Some platforms (or embedded interpreters) may not support signals.
353
+ pass
354
+
355
+ start_gateway_runner()
356
+ try:
357
+ while not stop.is_set():
358
+ stop.wait(0.5)
359
+ finally:
360
+ stop_gateway_runner()
361
+ if prev_runner_env is None:
362
+ os.environ.pop("ABSTRACTGATEWAY_RUNNER", None)
363
+ else:
364
+ os.environ["ABSTRACTGATEWAY_RUNNER"] = prev_runner_env
365
+ return
366
+
367
+ if args.cmd == "telegram-auth":
368
+ # This command is intentionally interactive. It is meant to be run once to
369
+ # create the TDLib session under ABSTRACT_TELEGRAM_DB_DIR.
370
+ import getpass
371
+
372
+ try:
373
+ from abstractcore.tools.telegram_tdlib import TdlibClient, TdlibConfig
374
+ except Exception as e:
375
+ raise SystemExit(
376
+ "TDLib bootstrap requires the optional Telegram dependencies. "
377
+ "Install with: `pip install \"abstractgateway[telegram]\"` "
378
+ f"(import failed: {e})"
379
+ )
380
+
381
+ try:
382
+ base_cfg = TdlibConfig.from_env()
383
+ except Exception as e:
384
+ raise SystemExit(f"Missing/invalid Telegram env config: {e}")
385
+
386
+ code = input("Telegram login code (leave blank if not needed): ").strip() or None
387
+ pw = getpass.getpass("Telegram 2FA password (leave blank if none): ").strip() or None
388
+
389
+ cfg = TdlibConfig(
390
+ api_id=base_cfg.api_id,
391
+ api_hash=base_cfg.api_hash,
392
+ phone=base_cfg.phone,
393
+ database_directory=base_cfg.database_directory,
394
+ files_directory=base_cfg.files_directory,
395
+ database_encryption_key=base_cfg.database_encryption_key,
396
+ use_secret_chats=base_cfg.use_secret_chats,
397
+ login_code=code or base_cfg.login_code,
398
+ two_factor_password=pw or base_cfg.two_factor_password,
25
399
  )
400
+
401
+ client = TdlibClient(config=cfg)
402
+ client.start()
403
+ try:
404
+ ok = client.wait_until_ready(timeout_s=float(args.timeout_s))
405
+ if not ok:
406
+ err = client.last_error or "Timed out waiting for TDLib authorization"
407
+ raise SystemExit(err)
408
+ print("TDLib authorization: OK (session stored in TDLib database directory).")
409
+ finally:
410
+ try:
411
+ client.stop()
412
+ except Exception:
413
+ pass
26
414
  return
27
415
 
28
- raise SystemExit(2)
416
+ if args.cmd == "migrate":
417
+ from pathlib import Path
418
+
419
+ from .migrate import migrate_file_to_sqlite
420
+
421
+ data_dir = Path(str(args.data_dir or "")).expanduser().resolve() if args.data_dir else None
422
+ if data_dir is None:
423
+ data_dir = Path(os.getenv("ABSTRACTGATEWAY_DATA_DIR", "./runtime")).expanduser().resolve()
424
+ db_path = Path(str(args.db_path or "")).expanduser().resolve() if args.db_path else (data_dir / "gateway.sqlite3")
425
+
426
+ if str(args.src).strip().lower() != "file" or str(args.dst).strip().lower() != "sqlite":
427
+ raise SystemExit("Only --from=file --to=sqlite is supported in v0")
428
+
429
+ migrate_file_to_sqlite(base_dir=data_dir, db_path=db_path, overwrite=bool(args.overwrite))
430
+ print(f"Migrated file stores from {data_dir} to sqlite DB {db_path}")
431
+ return
432
+
433
+ if args.cmd == "triage-reports":
434
+ from pathlib import Path
435
+
436
+ from .maintenance.action_tokens import build_action_links
437
+ from .maintenance.notifier import send_email_notification, send_telegram_notification
438
+ from .maintenance.triage import triage_reports
439
+ from .maintenance.triage_queue import decisions_dir, iter_decisions
440
+
441
+ data_dir = Path(str(args.data_dir or "")).expanduser().resolve() if args.data_dir else None
442
+ if data_dir is None:
443
+ data_dir = Path(os.getenv("ABSTRACTGATEWAY_DATA_DIR", "./runtime/gateway")).expanduser().resolve()
444
+
445
+ repo_root = Path(str(args.repo_root)).expanduser().resolve() if args.repo_root else None
446
+
447
+ out = triage_reports(
448
+ gateway_data_dir=data_dir,
449
+ repo_root=repo_root,
450
+ write_drafts=bool(args.write_drafts),
451
+ enable_llm=bool(args.llm),
452
+ )
453
+ pending = []
454
+ qdir = decisions_dir(gateway_data_dir=data_dir)
455
+ pending = [d for d in iter_decisions(qdir) if d.status == "pending"]
456
+
457
+ if (bool(args.print_actions) or bool(args.notify)) and args.action_base_url:
458
+ secret = os.getenv("ABSTRACTGATEWAY_TRIAGE_ACTION_SECRET") or os.getenv("ABSTRACT_TRIAGE_ACTION_SECRET") or ""
459
+ secret = str(secret).strip()
460
+ if secret:
461
+ out["pending_decisions"] = len(pending)
462
+ out["action_links"] = {}
463
+ for d in pending[:25]:
464
+ out["action_links"][d.decision_id] = build_action_links(
465
+ decision_id=d.decision_id,
466
+ base_url=str(args.action_base_url),
467
+ secret=secret,
468
+ )
469
+ else:
470
+ out["action_links_error"] = "Missing TRIAGE_ACTION_SECRET (links disabled)"
471
+
472
+ if bool(args.notify) and pending:
473
+ # Compose a compact, actionable digest.
474
+ lines = [f"Triage: {len(pending)} pending report decisions"]
475
+ for d in pending[:10]:
476
+ lines.append(f"- {d.decision_id}: {d.report_relpath}")
477
+ if d.missing_fields:
478
+ lines.append(f" missing: {', '.join(d.missing_fields[:3])}")
479
+ links = (out.get("action_links") or {}).get(d.decision_id) if isinstance(out.get("action_links"), dict) else None
480
+ if isinstance(links, dict) and links:
481
+ lines.append(f" approve: {links.get('approve')}")
482
+ lines.append(f" defer 1d: {links.get('defer_1d')}")
483
+ lines.append(f" defer 7d: {links.get('defer_7d')}")
484
+ lines.append(f" reject: {links.get('reject')}")
485
+ else:
486
+ lines.append(f" approve: abstractgateway triage-apply {d.decision_id} approve")
487
+ lines.append(f" reject: abstractgateway triage-apply {d.decision_id} reject")
488
+ lines.append(f" defer: ABSTRACT_TRIAGE_DEFER_DAYS=7 abstractgateway triage-apply {d.decision_id} defer")
489
+ body = "\n".join(lines).strip() + "\n"
490
+ # Telegram first (short), then email (full).
491
+ ok_tg, err_tg = send_telegram_notification(text=body[:3500])
492
+ ok_em, err_em = send_email_notification(subject=f"[AbstractFramework] Triage pending ({len(pending)})", body_text=body)
493
+ out["notify"] = {
494
+ "telegram": {"ok": ok_tg, "error": err_tg},
495
+ "email": {"ok": ok_em, "error": err_em},
496
+ }
497
+ if bool(args.json):
498
+ print(json.dumps(out, ensure_ascii=False, indent=2, sort_keys=True))
499
+ else:
500
+ print(f"Reports scanned: {out.get('reports')}")
501
+ print(f"Decision queue: {out.get('decisions_dir')}")
502
+ if out.get("drafts_written"):
503
+ print("Drafts written:")
504
+ for p in out["drafts_written"]:
505
+ print(f" - {p}")
506
+ if out.get("action_links"):
507
+ print("Action links (pending):")
508
+ for did, links in list(out["action_links"].items())[:10]:
509
+ print(f" - {did}:")
510
+ for k, v in links.items():
511
+ print(f" {k}: {v}")
512
+ return
513
+
514
+ if args.cmd == "backlog-exec-runner":
515
+ from pathlib import Path
516
+
517
+ from .maintenance.backlog_exec_runner import BacklogExecRunner, BacklogExecRunnerConfig
518
+
519
+ data_dir = Path(str(args.data_dir or "")).expanduser().resolve() if args.data_dir else None
520
+ if data_dir is None:
521
+ data_dir = Path(os.getenv("ABSTRACTGATEWAY_DATA_DIR", "./runtime/gateway")).expanduser().resolve()
522
+
523
+ repo_root = Path(str(args.repo_root)).expanduser().resolve() if args.repo_root else None
524
+ if repo_root is None:
525
+ rr = os.getenv("ABSTRACTGATEWAY_TRIAGE_REPO_ROOT") or os.getenv("ABSTRACT_TRIAGE_REPO_ROOT") or ""
526
+ repo_root = Path(rr).expanduser().resolve() if rr.strip() else Path.cwd().expanduser().resolve()
527
+ os.environ["ABSTRACTGATEWAY_TRIAGE_REPO_ROOT"] = str(repo_root)
528
+
529
+ cfg = BacklogExecRunnerConfig.from_env()
530
+ cfg = BacklogExecRunnerConfig(
531
+ enabled=True,
532
+ poll_interval_s=cfg.poll_interval_s,
533
+ workers=getattr(cfg, "workers", 1),
534
+ executor=cfg.executor,
535
+ notify=cfg.notify,
536
+ codex_bin=cfg.codex_bin,
537
+ codex_model=cfg.codex_model,
538
+ codex_reasoning_effort=getattr(cfg, "codex_reasoning_effort", ""),
539
+ codex_sandbox=cfg.codex_sandbox,
540
+ codex_approvals=cfg.codex_approvals,
541
+ exec_mode_default=getattr(cfg, "exec_mode_default", "uat"),
542
+ )
543
+
544
+ stop = threading.Event()
29
545
 
546
+ def _handle(_signum, _frame) -> None: # pragma: no cover
547
+ stop.set()
548
+
549
+ try:
550
+ signal.signal(signal.SIGINT, _handle)
551
+ signal.signal(signal.SIGTERM, _handle)
552
+ except Exception:
553
+ pass
554
+
555
+ runner = BacklogExecRunner(gateway_data_dir=data_dir, cfg=cfg)
556
+ runner.start()
557
+ try:
558
+ while not stop.is_set():
559
+ stop.wait(0.5)
560
+ finally:
561
+ runner.stop()
562
+ return
563
+
564
+ if args.cmd == "triage-apply":
565
+ from pathlib import Path
566
+
567
+ from .maintenance.triage import apply_decision_action
568
+
569
+ data_dir = Path(str(args.data_dir or "")).expanduser().resolve() if args.data_dir else None
570
+ if data_dir is None:
571
+ data_dir = Path(os.getenv("ABSTRACTGATEWAY_DATA_DIR", "./runtime/gateway")).expanduser().resolve()
572
+ repo_root = Path(str(args.repo_root)).expanduser().resolve() if args.repo_root else None
573
+
574
+ decision, err = apply_decision_action(
575
+ gateway_data_dir=data_dir,
576
+ decision_id=str(args.decision_id),
577
+ action=str(args.action),
578
+ repo_root=repo_root,
579
+ )
580
+ if err:
581
+ raise SystemExit(err)
582
+ if decision is None:
583
+ raise SystemExit("No decision updated")
584
+ print(f"Updated decision {decision.decision_id}: status={decision.status} draft={decision.draft_relpath or '(none)'}")
585
+ return
586
+
587
+ raise SystemExit(2)
30
588
 
589
+ if __name__ == "__main__":
590
+ main()