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/__init__.py
CHANGED
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.
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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()
|