graphharbor 0.13.0.post2__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.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.5
2
+ Name: graphharbor
3
+ Version: 0.13.0.post2
4
+ Summary: GraphHarbor CLI for a self-hosted LangGraph Agent Server on PostgreSQL and Redis
5
+ Project-URL: Homepage, https://github.com/ljxpython/graphharbor
6
+ Project-URL: Repository, https://github.com/ljxpython/graphharbor
7
+ Project-URL: Issues, https://github.com/ljxpython/graphharbor/issues
8
+ Project-URL: Changelog, https://github.com/ljxpython/graphharbor/releases
9
+ Author-email: Mohankumar Ramachandran <mail@mohanram.dev>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agent-protocol,agent-server,agents,ai-agents,cli,langchain,langgraph,langgraph-api,langgraph-sdk,langsmith,llm,open-source,postgres,redis,self-hosted,uvicorn
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.11
28
+ Requires-Dist: click>=8.1.7
29
+ Requires-Dist: graphharbor-runtime==0.13.0.post2
30
+ Requires-Dist: langgraph-cli<0.5,>=0.4.0
31
+ Requires-Dist: pyfiglet>=1.0.0
32
+ Requires-Dist: python-dotenv>=0.8.0
33
+ Requires-Dist: uvicorn[standard]>=0.51.0
34
+ Description-Content-Type: text/markdown
35
+
36
+ # graphharbor
37
+
38
+ Minimal CLI for running the self-hosted GraphHarbor Agent Server on the open
39
+ [`graphharbor-runtime`](https://pypi.org/project/graphharbor-runtime/) backend
40
+ (Postgres + Redis).
41
+
42
+ ## Command
43
+
44
+ ```bash
45
+ graphharbor serve [OPTIONS]
46
+ ```
47
+
48
+ Requires a `langgraph.json` in the working directory (same format as
49
+ [`langgraph-cli`](https://github.com/langchain-ai/langgraph/tree/main/libs/cli))
50
+ and `DATABASE_URI` / `REDIS_URI` (see repo `.env.example`). Bring your own
51
+ PostgreSQL and Redis reachable from the host.
52
+
53
+ ```bash
54
+ cp .env.example .env
55
+ # Start PostgreSQL and Redis with your host's service manager.
56
+
57
+ # Dev (hot reload)
58
+ graphharbor serve --reload -c langgraph.json
59
+
60
+ # Prod (multi-process)
61
+ graphharbor serve --host 0.0.0.0 --workers 4 -c langgraph.json
62
+ ```
63
+
64
+ Run `graphharbor serve --help` for the full option list.
@@ -0,0 +1,12 @@
1
+ langhost/__init__.py,sha256=A3vk6inbe5N-v0ti0JoQoSAPMyiMy4YuCDYNOhiEz54,275
2
+ langhost/__main__.py,sha256=Z_8g0qzCSaEiYYX5KPZ_0Q3YHN83PaSNYPW1op38meM,67
3
+ langhost/cli.py,sha256=Bc2oL8aaCBdZSwN4zu-xKf9aPGzYAWflRlRMR_qWlkY,16664
4
+ langhost/core_api.py,sha256=Uf73zDohE6V6YHcznVbxUEF2FAis78l8FPUneuf42EE,52111
5
+ langhost/protocol_api.py,sha256=IXpKNMqhpPozSCN9BIGOzVz57gMoocKSwMZDqjhkTxM,13381
6
+ langhost/server.py,sha256=Kts1cyNpZh9v_DjrOgANZp37cCqyFDFZlHEkLL6b5tY,35730
7
+ langhost/streaming.py,sha256=a6g31tZ-MQ8m01Cl8s7pj6EyoypL1Gf9XAn_pfO6tKQ,11898
8
+ graphharbor-0.13.0.post2.dist-info/METADATA,sha256=lOZJgRe0mGr1SuCCi1xkbXvtDkeRQRY1SvY-Qou86Ks,2503
9
+ graphharbor-0.13.0.post2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ graphharbor-0.13.0.post2.dist-info/entry_points.txt,sha256=hM5uFwJylE-2OVnqAus0QZSFkYsvtVCoUkE9Mb3us-c,49
11
+ graphharbor-0.13.0.post2.dist-info/licenses/LICENSE,sha256=VOzjTuqsyEVtwd7ylP1eoLjPAAvdB6iC9a7JBGFJUgE,1080
12
+ graphharbor-0.13.0.post2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ graphharbor = langhost.cli:cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mohankumar Ramachandran
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
langhost/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """GraphHarbor CLI for a self-hosted LangGraph Agent Server."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("graphharbor")
7
+ except PackageNotFoundError: # pragma: no cover
8
+ __version__ = "0.0.0"
9
+
10
+ __all__ = ["__version__"]
langhost/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from langhost.cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
langhost/cli.py ADDED
@@ -0,0 +1,512 @@
1
+ """GraphHarbor CLI — config loading plus the self-owned Agent Server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import pathlib
8
+ import socket
9
+ import sys
10
+ from collections.abc import Sequence
11
+ from typing import Any, cast
12
+
13
+ import click
14
+ from dotenv import load_dotenv
15
+ from langgraph_cli.config import validate_config_file
16
+ from langgraph_cli.constants import DEFAULT_CONFIG
17
+ from pyfiglet import figlet_format
18
+
19
+ from langgraph_runtime_pg.graph_registry import resolve_config_base_dir
20
+ from langhost import __version__
21
+ from langhost.server import run_server
22
+
23
+ # Always the open Postgres+Redis runtime.
24
+ RUNTIME_EDITION = "pg"
25
+ DEFAULT_PORT = 31296
26
+ LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]
27
+ DEFAULT_STUDIO_ORIGIN = "https://smith.langchain.com"
28
+
29
+ # Upstream run_server always logs an inmem-oriented welcome; replace it for GraphHarbor.
30
+ _UPSTREAM_WELCOME_MARKER = "This in-memory server is designed for development and testing"
31
+
32
+
33
+ def _resolve_port(host: str, port: int | None) -> int:
34
+ """Return the requested port or the first free port, without private API imports."""
35
+ requested = port if port is not None else DEFAULT_PORT
36
+ with socket.socket(
37
+ socket.AF_INET6 if ":" in host else socket.AF_INET, socket.SOCK_STREAM
38
+ ) as sock:
39
+ try:
40
+ sock.bind((host, requested))
41
+ except OSError:
42
+ sock.bind((host, 0))
43
+ return int(sock.getsockname()[1])
44
+ return requested
45
+
46
+
47
+ def _display_host(host: str) -> str:
48
+ if host in {"0.0.0.0", "::"}:
49
+ return "127.0.0.1"
50
+ if ":" in host and not host.startswith("["):
51
+ return f"[{host}]"
52
+ return host
53
+
54
+
55
+ def _server_base_url(
56
+ host: str,
57
+ port: int,
58
+ *,
59
+ ssl: bool,
60
+ mount_prefix: str | None,
61
+ ) -> str:
62
+ scheme = "https" if ssl else "http"
63
+ url = f"{scheme}://{_display_host(host)}:{port}"
64
+ if mount_prefix:
65
+ url += mount_prefix
66
+ return url
67
+
68
+
69
+ def _langhost_welcome(
70
+ *,
71
+ host: str,
72
+ port: int,
73
+ ssl: bool,
74
+ studio_origin: str | None,
75
+ mount_prefix: str | None,
76
+ ) -> str:
77
+ api_url = _server_base_url(host, port, ssl=ssl, mount_prefix=mount_prefix)
78
+ origin = (studio_origin or DEFAULT_STUDIO_ORIGIN).rstrip("/")
79
+ studio_url = f"{origin}/studio/?baseUrl={api_url}"
80
+ agent_chat_url = f"https://agentchat.vercel.app/?apiUrl={api_url}&assistantId=agent"
81
+ title = figlet_format("GraphHarbor", font="standard").rstrip()
82
+ repo_url = "https://github.com/ljxpython/graphharbor"
83
+ return f"""
84
+
85
+ {title}
86
+
87
+ - 🚀 API: \033[36m{api_url}\033[0m
88
+ - 🎨 Studio UI: \033[36m{studio_url}\033[0m
89
+ - 📚 API Docs: \033[36m{api_url}/docs\033[0m
90
+ - 💬 Agent Chat UI: \033[36m{agent_chat_url}\033[0m
91
+
92
+ \033[1;33m★\033[0m If GraphHarbor helps you, a \033[1;33mGitHub star\033[0m keeps the project alive:
93
+ \033[36m{repo_url}\033[0m
94
+
95
+ Self-hosted LangGraph Agent Server with PostgreSQL + Redis.
96
+ GraphHarbor {__version__}
97
+
98
+ """
99
+
100
+
101
+ class _ReplaceWelcomeBanner(logging.Filter):
102
+ """Swap the upstream inmem welcome for a prebuilt GraphHarbor banner."""
103
+
104
+ def __init__(self, welcome: str) -> None:
105
+ super().__init__()
106
+ self._welcome = welcome
107
+
108
+ def filter(self, record: logging.LogRecord) -> bool:
109
+ if _UPSTREAM_WELCOME_MARKER in record.getMessage():
110
+ record.msg = self._welcome
111
+ record.args = ()
112
+ return (
113
+ True # NOSONAR — logging.Filter keeps all records; we only rewrite the welcome banner
114
+ )
115
+
116
+
117
+ def _validate_serve_options(
118
+ reload: bool,
119
+ workers: int,
120
+ ssl_certfile: pathlib.Path | None,
121
+ ssl_keyfile: pathlib.Path | None,
122
+ tunnel: bool,
123
+ wait_for_client: bool,
124
+ debug_port: int | None,
125
+ ) -> None:
126
+ """Validate mutually exclusive CLI options, raising UsageError on conflicts."""
127
+ if reload and workers > 1:
128
+ raise click.UsageError("Cannot combine --reload with --workers > 1.")
129
+ if (ssl_certfile is None) != (ssl_keyfile is None):
130
+ raise click.UsageError("Both --ssl-certfile and --ssl-keyfile are required for HTTPS.")
131
+ if ssl_certfile and ssl_keyfile and tunnel:
132
+ raise click.UsageError("Cannot combine --tunnel with SSL options.")
133
+ if wait_for_client and debug_port is None:
134
+ raise click.UsageError("--wait-for-client requires --debug-port.")
135
+
136
+
137
+ def _prepare_serve_env(
138
+ env_file: pathlib.Path | None,
139
+ database_uri: str | None,
140
+ redis_uri: str | None,
141
+ n_jobs_per_worker: int | None,
142
+ ) -> tuple[str, str, int | None]:
143
+ """Load dotenv and resolve database/redis URIs. Returns (db_uri, redis_uri, n_jobs)."""
144
+ load_dotenv(env_file or ".env", override=False)
145
+ os.environ["LANGGRAPH_RUNTIME_EDITION"] = RUNTIME_EDITION
146
+
147
+ database_uri = database_uri or os.environ.get("DATABASE_URI")
148
+ redis_uri = redis_uri or os.environ.get("REDIS_URI")
149
+ if not database_uri:
150
+ raise click.UsageError(
151
+ "DATABASE_URI is required. Please set it in the environment or pass it to the command via --database-uri."
152
+ )
153
+ if not redis_uri:
154
+ raise click.UsageError(
155
+ "REDIS_URI is required. Please set it in the environment or pass it to the command via --redis-uri."
156
+ )
157
+ if n_jobs_per_worker is None:
158
+ raw = os.environ.get("N_JOBS_PER_WORKER")
159
+ if raw:
160
+ n_jobs_per_worker = int(raw)
161
+ return database_uri, redis_uri, n_jobs_per_worker
162
+
163
+
164
+ def _build_uvicorn_kwargs(workers: int) -> dict[str, Any]:
165
+ """Build extra kwargs passed to uvicorn."""
166
+ uvicorn_kwargs: dict[str, Any] = {}
167
+ if workers > 1:
168
+ uvicorn_kwargs["workers"] = workers
169
+ return uvicorn_kwargs
170
+
171
+
172
+ def _resolve_mount_prefix(config_json: dict[str, Any]) -> tuple[dict | None, str | None]:
173
+ """Extract http config and resolve mount prefix from config/env."""
174
+ http_cfg = config_json.get("http")
175
+ mount_prefix = None
176
+ if isinstance(http_cfg, dict):
177
+ mount_prefix = http_cfg.get("mount_prefix")
178
+ mount_prefix = (
179
+ os.environ.get("MOUNT_PREFIX") or os.environ.get("LANGGRAPH_MOUNT_PREFIX") or mount_prefix
180
+ )
181
+ return http_cfg, mount_prefix
182
+
183
+
184
+ @click.group()
185
+ @click.version_option(version=__version__, prog_name="graphharbor")
186
+ def cli() -> None:
187
+ """Self-hosted LangGraph Agent Server on PostgreSQL + Redis."""
188
+
189
+
190
+ @cli.command(
191
+ "serve",
192
+ help=(
193
+ "Run the Agent Server (LANGGRAPH_RUNTIME_EDITION=pg). "
194
+ "Use --reload for local development; --workers for production."
195
+ ),
196
+ )
197
+ @click.option(
198
+ "--host",
199
+ default="127.0.0.1",
200
+ show_default=True,
201
+ help=(
202
+ "Bind address. Prefer 127.0.0.1 for local work; use 0.0.0.0 only on "
203
+ "trusted networks / behind a reverse proxy."
204
+ ),
205
+ )
206
+ @click.option(
207
+ "--port",
208
+ "-p",
209
+ default=DEFAULT_PORT,
210
+ show_default=True,
211
+ type=int,
212
+ help="Port to bind the Agent Server to.",
213
+ )
214
+ @click.option(
215
+ "--config",
216
+ "-c",
217
+ default=DEFAULT_CONFIG,
218
+ show_default=True,
219
+ type=click.Path(exists=True, dir_okay=False, path_type=pathlib.Path),
220
+ help="Path to langgraph.json (graphs, deps, env, auth, http, …).",
221
+ )
222
+ @click.option(
223
+ "--env-file",
224
+ type=click.Path(exists=True, dir_okay=False, path_type=pathlib.Path),
225
+ default=None,
226
+ help="Optional dotenv file to load before starting (default: .env if present).",
227
+ )
228
+ @click.option(
229
+ "--database-uri",
230
+ default=None,
231
+ help="Postgres URI (overrides DATABASE_URI). Required unless set in the environment.",
232
+ )
233
+ @click.option(
234
+ "--redis-uri",
235
+ default=None,
236
+ help="Redis URI (overrides REDIS_URI). Required unless set in the environment.",
237
+ )
238
+ @click.option(
239
+ "--reload",
240
+ is_flag=True,
241
+ help="Auto-reload on code changes (dev). Incompatible with --workers > 1.",
242
+ )
243
+ @click.option(
244
+ "--reload-include",
245
+ "reload_includes",
246
+ multiple=True,
247
+ help="Glob(s) to watch when --reload is set (repeatable).",
248
+ )
249
+ @click.option(
250
+ "--reload-exclude",
251
+ "reload_excludes",
252
+ multiple=True,
253
+ help="Glob(s) to ignore when --reload is set (repeatable).",
254
+ )
255
+ @click.option(
256
+ "--workers",
257
+ default=1,
258
+ show_default=True,
259
+ type=click.IntRange(min=1),
260
+ help="Uvicorn worker processes (prod). Incompatible with --reload.",
261
+ )
262
+ @click.option(
263
+ "--n-jobs-per-worker",
264
+ default=None,
265
+ type=int,
266
+ help=(
267
+ "Max concurrent background jobs per server process "
268
+ "(sets N_JOBS_PER_WORKER; default inside run_server is 1)."
269
+ ),
270
+ )
271
+ @click.option(
272
+ "--browser/--no-browser",
273
+ default=False,
274
+ help="Open LangSmith Studio in the browser when the server is ready.",
275
+ )
276
+ @click.option(
277
+ "--studio-url",
278
+ default=None,
279
+ help="LangSmith Studio base URL (default: https://smith.langchain.com).",
280
+ )
281
+ @click.option(
282
+ "--tunnel",
283
+ is_flag=True,
284
+ help="Expose the server via a Cloudflare tunnel (remote Studio access).",
285
+ )
286
+ @click.option(
287
+ "--debug-port",
288
+ default=None,
289
+ type=int,
290
+ help="Listen for a remote debugger (debugpy) on this port.",
291
+ )
292
+ @click.option(
293
+ "--wait-for-client",
294
+ is_flag=True,
295
+ help="With --debug-port, block until a debugger attaches.",
296
+ )
297
+ @click.option(
298
+ "--allow-blocking",
299
+ is_flag=True,
300
+ help="Do not raise on synchronous/blocking I/O in graph code.",
301
+ )
302
+ @click.option(
303
+ "--server-log-level",
304
+ default="INFO",
305
+ show_default=True,
306
+ type=click.Choice(LOG_LEVELS, case_sensitive=False),
307
+ help="Log level for uvicorn / GraphHarbor Agent Server.",
308
+ )
309
+ @click.option(
310
+ "--ssl-certfile",
311
+ type=click.Path(exists=True, dir_okay=False, path_type=pathlib.Path),
312
+ default=None,
313
+ help="TLS certificate file (serve over HTTPS).",
314
+ )
315
+ @click.option(
316
+ "--ssl-keyfile",
317
+ type=click.Path(exists=True, dir_okay=False, path_type=pathlib.Path),
318
+ default=None,
319
+ help="TLS private key file (serve over HTTPS).",
320
+ )
321
+ def serve(
322
+ host: str, # NOSONAR - Click command signature intentionally wide
323
+ port: int, # NOSONAR - Click command signature intentionally wide
324
+ config: pathlib.Path,
325
+ env_file: pathlib.Path | None,
326
+ database_uri: str | None,
327
+ redis_uri: str | None,
328
+ reload: bool,
329
+ reload_includes: tuple[str, ...],
330
+ reload_excludes: tuple[str, ...],
331
+ workers: int,
332
+ n_jobs_per_worker: int | None,
333
+ browser: bool,
334
+ studio_url: str | None,
335
+ tunnel: bool,
336
+ debug_port: int | None,
337
+ wait_for_client: bool,
338
+ allow_blocking: bool,
339
+ server_log_level: str,
340
+ ssl_certfile: pathlib.Path | None,
341
+ ssl_keyfile: pathlib.Path | None,
342
+ ) -> None:
343
+ _validate_serve_options(
344
+ reload, workers, ssl_certfile, ssl_keyfile, tunnel, wait_for_client, debug_port
345
+ )
346
+
347
+ config_json = validate_config_file(config)
348
+ if config_json.get("node_version"):
349
+ raise click.UsageError(
350
+ "JS graphs are not supported by langhost. Remove the 'node_version' field from the config."
351
+ )
352
+
353
+ cwd = pathlib.Path.cwd()
354
+ config_base_dir = resolve_config_base_dir(config, cast(dict[str, Any], config_json))
355
+ config_env = config_json.get("env")
356
+ if env_file is None and isinstance(config_env, str):
357
+ load_dotenv(config_base_dir / config_env, override=False)
358
+ database_uri, redis_uri, n_jobs_per_worker = _prepare_serve_env(
359
+ env_file, database_uri, redis_uri, n_jobs_per_worker
360
+ )
361
+ for import_root in (cwd, config_base_dir):
362
+ if str(import_root) not in sys.path:
363
+ sys.path.append(str(import_root))
364
+ for dep in config_json.get("dependencies", []):
365
+ dep_path = cwd / dep
366
+ if dep_path.is_dir() and dep_path.exists():
367
+ sys.path.append(str(dep_path))
368
+
369
+ includes: Sequence[str] | None = list(reload_includes) or None
370
+ excludes: Sequence[str] | None = list(reload_excludes) or None
371
+ uvicorn_kwargs = _build_uvicorn_kwargs(workers)
372
+ http_cfg, mount_prefix = _resolve_mount_prefix(cast(dict[str, Any], config_json))
373
+
374
+ port = _resolve_port(host, port)
375
+ welcome = _langhost_welcome(
376
+ host=host,
377
+ port=port,
378
+ ssl=ssl_certfile is not None and ssl_keyfile is not None,
379
+ studio_origin=studio_url,
380
+ mount_prefix=mount_prefix,
381
+ )
382
+ api_cli_logger = logging.getLogger("graphharbor.cli")
383
+ banner_filter = _ReplaceWelcomeBanner(welcome)
384
+ api_cli_logger.addFilter(banner_filter)
385
+ try:
386
+ run_server(
387
+ host,
388
+ port,
389
+ reload,
390
+ config_json.get("graphs", {}),
391
+ n_jobs_per_worker=n_jobs_per_worker,
392
+ open_browser=browser,
393
+ tunnel=tunnel,
394
+ debug_port=debug_port,
395
+ wait_for_client=wait_for_client,
396
+ env=config_json.get("env"),
397
+ reload_includes=includes,
398
+ reload_excludes=excludes,
399
+ store=config_json.get("store"),
400
+ auth=config_json.get("auth"),
401
+ http=http_cfg,
402
+ ui=config_json.get("ui"),
403
+ ui_config=config_json.get("ui_config"),
404
+ webhooks=config_json.get("webhooks"),
405
+ checkpointer=config_json.get("checkpointer"),
406
+ studio_url=studio_url,
407
+ disable_persistence=config_json.get("disable_persistence", False),
408
+ allow_blocking=allow_blocking,
409
+ server_level=server_log_level,
410
+ ssl_certfile=ssl_certfile,
411
+ ssl_keyfile=ssl_keyfile,
412
+ runtime_edition=RUNTIME_EDITION,
413
+ __database_uri__=database_uri,
414
+ __redis_uri__=redis_uri,
415
+ __migrations_path__=None,
416
+ config=config_json,
417
+ base_dir=config_base_dir,
418
+ **uvicorn_kwargs,
419
+ )
420
+ finally:
421
+ api_cli_logger.removeFilter(banner_filter)
422
+
423
+
424
+ @cli.command("migrate", help="Apply the explicit PostgreSQL schema migration job.")
425
+ @click.argument(
426
+ "migration_action",
427
+ type=click.Choice(["upgrade", "current", "history", "stamp", "downgrade"]),
428
+ default="upgrade",
429
+ )
430
+ @click.argument("revision", required=False)
431
+ def migrate_command(migration_action: str, revision: str | None) -> None:
432
+ """Run migrations separately from API startup."""
433
+ from langgraph_runtime_pg import migrate
434
+
435
+ if migration_action == "upgrade":
436
+ click.echo(f"upgraded to {migrate.upgrade_head()}")
437
+ elif migration_action == "stamp":
438
+ migrate.stamp_head()
439
+ click.echo("stamped head")
440
+ elif migration_action == "current":
441
+ migrate.current()
442
+ elif migration_action == "history":
443
+ migrate.history()
444
+ else:
445
+ migrate.downgrade(revision or "-1")
446
+ click.echo(f"downgraded to {revision or '-1'}")
447
+
448
+
449
+ @cli.command("worker", help="Run the GraphHarbor worker against host PostgreSQL and Redis.")
450
+ @click.option(
451
+ "--config",
452
+ "config_path",
453
+ default=DEFAULT_CONFIG,
454
+ show_default=True,
455
+ type=click.Path(exists=True, dir_okay=False, path_type=pathlib.Path),
456
+ help="Path to langgraph.json used for graph discovery.",
457
+ )
458
+ @click.option("--env-file", type=click.Path(exists=True, dir_okay=False, path_type=pathlib.Path))
459
+ @click.option("--database-uri", default=None)
460
+ @click.option("--redis-uri", default=None)
461
+ @click.option("--n-jobs-per-worker", default=1, type=click.IntRange(min=1), show_default=True)
462
+ @click.option(
463
+ "--compatibility-spike",
464
+ is_flag=True,
465
+ help="Use the legacy private worker only for comparison tests; never use in production.",
466
+ )
467
+ def worker_command(
468
+ config_path: pathlib.Path,
469
+ env_file: pathlib.Path | None,
470
+ database_uri: str | None,
471
+ redis_uri: str | None,
472
+ n_jobs_per_worker: int,
473
+ compatibility_spike: bool,
474
+ ) -> None:
475
+ """Start only the queue worker; migrations remain an explicit command."""
476
+ worker_config = validate_config_file(config_path)
477
+ worker_base_dir = resolve_config_base_dir(config_path, cast(dict[str, Any], worker_config))
478
+ worker_env = worker_config.get("env")
479
+ if env_file is None and isinstance(worker_env, str):
480
+ load_dotenv(worker_base_dir / worker_env, override=False)
481
+ database_uri, redis_uri, _ = _prepare_serve_env(
482
+ env_file, database_uri, redis_uri, n_jobs_per_worker
483
+ )
484
+ os.environ["DATABASE_URI"] = database_uri
485
+ os.environ["REDIS_URI"] = redis_uri
486
+ os.environ["GRAPHHARBOR_COMPATIBILITY_SPIKE"] = "1" if compatibility_spike else "0"
487
+
488
+ if compatibility_spike:
489
+ from langgraph_runtime_pg.production import lifespan
490
+ from langgraph_runtime_pg.queue import queue
491
+
492
+ async def _run_compatibility() -> None:
493
+ async with lifespan():
494
+ await queue()
495
+
496
+ import asyncio
497
+
498
+ asyncio.run(_run_compatibility())
499
+ return
500
+
501
+ async def _run() -> None:
502
+ from langgraph_runtime_pg.production_worker import run_worker
503
+
504
+ await run_worker(config_path)
505
+
506
+ import asyncio
507
+
508
+ asyncio.run(_run())
509
+
510
+
511
+ if __name__ == "__main__":
512
+ cli()