Add app runner for backend and discord stack
This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
YOUDIS_RUN_BACKEND=1
|
||||
YOUDIS_RUN_DISCORD=1
|
||||
YOUDIS_BACKEND_HOST=127.0.0.1
|
||||
YOUDIS_BACKEND_PORT=8000
|
||||
YOUDIS_BACKEND_HEALTH_TIMEOUT=20
|
||||
YOUDIS_BACKEND_HEALTH_INTERVAL=0.5
|
||||
YOUDIS_BACKEND_URL=http://127.0.0.1:8000
|
||||
YOUDIS_POLL_INTERVAL_SECONDS=2
|
||||
YOUDIS_YTDLP_EXECUTABLE=yt-dlp
|
||||
YOUDIS_CONFIG_DIR=/home/user/proj/youdis/test/
|
||||
YOUDIS_DOWNLOAD_DIR=/home/user/proj/youdis/downloads
|
||||
DISCORD_BOT_TOKEN=
|
||||
DISCORD_BOT_SCOPE=2147491904
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,6 +11,7 @@ env/
|
||||
# --- environment files ---
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.local
|
||||
|
||||
# --- emacs ---
|
||||
|
||||
11
README.md
11
README.md
@@ -1,5 +1,16 @@
|
||||
v2 architecture draft: see `docs/architecture-v2.org`
|
||||
|
||||
default v2 app run:
|
||||
```
|
||||
python3 ./youdis.py
|
||||
```
|
||||
|
||||
direct component runs still work for testing:
|
||||
```
|
||||
python3 -m uvicorn youdis.main:app --host 127.0.0.1 --port 8000
|
||||
python3 -m youdis.adapters.discord
|
||||
```
|
||||
|
||||
build and run the docker container
|
||||
```
|
||||
DISCORD_BOT_TOKEN = [discord bot token]
|
||||
|
||||
@@ -242,7 +242,56 @@ delete or retire legacy bot behaviors that no longer fit once the backend split
|
||||
- cleanup updated user-facing deployment artifacts to match the v2 naming and architecture, including `DISCORD_BOT_TOKEN` and removal of `users.json` references
|
||||
- archived planning docs were intentionally left untouched: `pm/tasks.org` is historical and `pm/notes.org` is personal working notes
|
||||
|
||||
* [ ] 2.0.4: fix automation and build pipeline (3)
|
||||
* [ ] 2.0.4: make youdis.py app runner for backend + adapters (3)
|
||||
Build a simple Python orchestration layer in youdis.py so the standard app stack can be launched from one entrypoint while backend and adapters remain independently runnable for testing.
|
||||
|
||||
** pm notes
|
||||
- keep components independently runnable
|
||||
- youdis.py as app-level orchestrator; let Docker just run python3 /app/youdis.py
|
||||
- no plugin discovery or hot-loading
|
||||
- Docker should package and launch the app, not decide internal process topology
|
||||
- preserve the ability to run backend or adapters directly for debugging
|
||||
- optimize for one obvious default run path
|
||||
|
||||
** Acceptance Criteria
|
||||
1. define the default app startup path
|
||||
- youdis.py launches the standard stack for v2
|
||||
- backend and Discord adapter startup order is explicit
|
||||
- shutdown behavior is coherent enough for local/dev use
|
||||
2. preserve modular run paths
|
||||
- backend can still be run directly
|
||||
- Discord adapter can still be run directly
|
||||
- orchestration layer does not bury component-level testing
|
||||
3. keep orchestration simple
|
||||
- no dynamic adapter discovery
|
||||
- no hot reloading framework
|
||||
- configured components are started explicitly
|
||||
4. document runtime ownership
|
||||
- clarify what Python orchestrates vs what Docker orchestrates
|
||||
- identify env vars or flags that control which components start
|
||||
- leave room for future Zulip/XMPP adapters without redesigning the runner
|
||||
|
||||
** evidence
|
||||
- commit:
|
||||
- tests:
|
||||
1. `python3 -m py_compile ./youdis.py ./youdis/adapters/discord.py`
|
||||
2. backend direct run still works: `python3 -m uvicorn youdis.main:app --host 127.0.0.1 --port 8000`
|
||||
3. discord direct run still works: `python3 -m youdis.adapters.discord`
|
||||
4. app runner backend-only smoke test: `YOUDIS_RUN_DISCORD=0 timeout 5s python3 ./youdis.py`
|
||||
5. app runner default path: `python3 ./youdis.py`
|
||||
- date:
|
||||
|
||||
** notes
|
||||
- `youdis.py` is now the default v2 app runner and starts the standard stack explicitly rather than dynamically discovering adapters
|
||||
- backend starts first and must pass a health check before the Discord adapter is launched
|
||||
- backend and Discord adapter remain directly runnable for debugging and tests
|
||||
- Docker is intended to invoke `python3 /app/youdis.py`; Python owns app orchestration while Docker owns packaging and runtime environment
|
||||
- runner flags currently include `YOUDIS_RUN_BACKEND`, `YOUDIS_RUN_DISCORD`, `YOUDIS_BACKEND_HOST`, and `YOUDIS_BACKEND_PORT`
|
||||
|
||||
|
||||
* ==== BACKLOG ====
|
||||
Tasks below this line are inactive and should not be touched.
|
||||
* [ ] 2.0.X: fix automation and build pipeline (3)
|
||||
repair and simplify the build/update/deploy path so it matches the new backend-plus-frontend structure
|
||||
** pm notes
|
||||
- this should come after architecture and discord integration stabilize. no point polishing the pipeline for the wrong shape.
|
||||
@@ -267,8 +316,6 @@ repair and simplify the build/update/deploy path so it matches the new backend-p
|
||||
|
||||
** notes
|
||||
|
||||
* ==== BACKLOG ====
|
||||
Tasks below this line are inactive and should not be touched.
|
||||
* [ ] X.x.x: clean up discord adapter UI
|
||||
|
||||
** acceptance criteria
|
||||
|
||||
144
youdis.py
144
youdis.py
@@ -1,7 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launcher shim for the Discord adapter."""
|
||||
"""App runner for the standard v2 stack."""
|
||||
|
||||
from youdis.adapters.discord import main
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.error import URLError
|
||||
from urllib.request import urlopen
|
||||
|
||||
from youdis.env import load_project_dotenv
|
||||
|
||||
|
||||
load_project_dotenv()
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent
|
||||
DEFAULT_BACKEND_HOST = os.getenv("YOUDIS_BACKEND_HOST", "127.0.0.1")
|
||||
DEFAULT_BACKEND_PORT = int(os.getenv("YOUDIS_BACKEND_PORT", "8000"))
|
||||
RUN_BACKEND = os.getenv("YOUDIS_RUN_BACKEND", "1") not in {"0", "false", "False"}
|
||||
RUN_DISCORD = os.getenv("YOUDIS_RUN_DISCORD", "1") not in {"0", "false", "False"}
|
||||
BACKEND_HEALTH_TIMEOUT = float(os.getenv("YOUDIS_BACKEND_HEALTH_TIMEOUT", "20"))
|
||||
BACKEND_HEALTH_INTERVAL = float(os.getenv("YOUDIS_BACKEND_HEALTH_INTERVAL", "0.5"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManagedProcess:
|
||||
name: str
|
||||
process: asyncio.subprocess.Process
|
||||
|
||||
|
||||
def backend_url() -> str:
|
||||
return f"http://{DEFAULT_BACKEND_HOST}:{DEFAULT_BACKEND_PORT}"
|
||||
|
||||
|
||||
def build_child_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env.setdefault("YOUDIS_BACKEND_URL", backend_url())
|
||||
return env
|
||||
|
||||
|
||||
async def start_backend(env: dict[str, str]) -> ManagedProcess:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"youdis.main:app",
|
||||
"--host",
|
||||
DEFAULT_BACKEND_HOST,
|
||||
"--port",
|
||||
str(DEFAULT_BACKEND_PORT),
|
||||
cwd=str(REPO_ROOT),
|
||||
env=env,
|
||||
)
|
||||
return ManagedProcess(name="backend", process=process)
|
||||
|
||||
|
||||
async def start_discord(env: dict[str, str]) -> ManagedProcess:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"youdis.adapters.discord",
|
||||
cwd=str(REPO_ROOT),
|
||||
env=env,
|
||||
)
|
||||
return ManagedProcess(name="discord", process=process)
|
||||
|
||||
|
||||
async def wait_for_backend_health(timeout_seconds: float) -> None:
|
||||
deadline = asyncio.get_running_loop().time() + timeout_seconds
|
||||
health_url = f"{backend_url()}/health"
|
||||
|
||||
while True:
|
||||
try:
|
||||
with urlopen(health_url, timeout=2) as response:
|
||||
if response.status == 200:
|
||||
return
|
||||
except URLError:
|
||||
pass
|
||||
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
raise TimeoutError(f"backend did not become healthy at {health_url}")
|
||||
|
||||
await asyncio.sleep(BACKEND_HEALTH_INTERVAL)
|
||||
|
||||
|
||||
async def stop_process(proc: ManagedProcess) -> None:
|
||||
if proc.process.returncode is not None:
|
||||
return
|
||||
|
||||
proc.process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(proc.process.wait(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
proc.process.kill()
|
||||
await proc.process.wait()
|
||||
|
||||
|
||||
async def run() -> int:
|
||||
managed: list[ManagedProcess] = []
|
||||
env = build_child_env()
|
||||
|
||||
if not RUN_BACKEND and not RUN_DISCORD:
|
||||
print("nothing to start: both YOUDIS_RUN_BACKEND and YOUDIS_RUN_DISCORD are disabled")
|
||||
return 1
|
||||
|
||||
try:
|
||||
if RUN_BACKEND:
|
||||
backend = await start_backend(env)
|
||||
managed.append(backend)
|
||||
print(f"started backend on {backend_url()}")
|
||||
await wait_for_backend_health(BACKEND_HEALTH_TIMEOUT)
|
||||
print("backend is healthy")
|
||||
|
||||
if RUN_DISCORD:
|
||||
discord = await start_discord(env)
|
||||
managed.append(discord)
|
||||
print("started discord adapter")
|
||||
|
||||
wait_tasks = [asyncio.create_task(proc.process.wait()) for proc in managed]
|
||||
done, pending = await asyncio.wait(wait_tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
|
||||
first_returncode = 0
|
||||
for task in done:
|
||||
first_returncode = task.result()
|
||||
break
|
||||
|
||||
return first_returncode
|
||||
finally:
|
||||
for proc in reversed(managed):
|
||||
await stop_process(proc)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
raise SystemExit(asyncio.run(run()))
|
||||
except KeyboardInterrupt:
|
||||
raise SystemExit(130)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -222,3 +222,7 @@ def main() -> None:
|
||||
bot.add_command(status)
|
||||
bot.add_command(interrupt)
|
||||
bot.start(api_token)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
except ModuleNotFoundError: # pragma: no cover - optional local convenience dependency
|
||||
load_dotenv = None
|
||||
|
||||
|
||||
def load_project_dotenv() -> None:
|
||||
if load_dotenv is None:
|
||||
return
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
load_dotenv(repo_root / ".env", override=False)
|
||||
|
||||
Reference in New Issue
Block a user