Files

175 lines
5.9 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""dev_watch.py — Watch creator sources and hot-sync into a running QwenPaw.
Frontend: expects `vite build --watch` (started by dev.sh) to keep
rebuilding ui/dist incrementally; any dist change is rsynced into the
installed plugin copy. Plugin static files are read from disk on every
request, so a browser hard-refresh is all that's needed — no reinstall.
Backend: any backend/*.py change is rsynced into the installed copy and
then hot-reloaded via `POST /api/plugins/install` with source pointing at
the installed dir itself. The loader skips copytree when source == target,
so this is a pure module reload (~1s), using the localhost auth bypass.
"""
import hashlib
import json
import os
import subprocess
import sys
import time
import urllib.request
PLUGIN_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WORKING_DIR = os.path.expanduser(
os.environ.get("QWENPAW_WORKING_DIR", "~/.qwenpaw"),
)
PORT = os.environ.get("QWENPAW_PORT", "8088")
INSTALLED_DIR = os.path.join(WORKING_DIR, "plugins", "qwenpaw-creator")
POLL_SECONDS = 1.0
# Watch only vite's outDir (dist/app). dist/index.js is NOT watched or
# written here: vite watch ignores only its outDir, so writing anything
# else under ui/ (e.g. via package-plugin.mjs) retriggers a rebuild and
# creates an endless build loop. The entry is generated in-memory and
# written straight into the installed copy instead.
FRONTEND_SRC = os.path.join(PLUGIN_DIR, "ui", "dist", "app")
BACKEND_SRC = os.path.join(PLUGIN_DIR, "backend")
def snapshot(root: str, exts: tuple = ()) -> dict:
result = {}
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
for name in filenames:
if exts and not name.endswith(exts):
continue
path = os.path.join(dirpath, name)
try:
result[path] = os.stat(path).st_mtime
except OSError:
pass
return result
def rsync(src: str, dst: str) -> None:
subprocess.run(
[
"rsync",
"-a",
"--delete",
"--exclude",
"__pycache__",
src.rstrip("/") + "/",
dst.rstrip("/") + "/",
],
check=True,
)
def sync_frontend() -> None:
"""Mirror dist/app into the installed copy and regenerate index.js.
Replicates ui/scripts/package-plugin.mjs (build-id substitution into
plugin-entry.js) without touching the workspace, to avoid retriggering
vite watch.
"""
index_html = os.path.join(FRONTEND_SRC, "index.html")
with open(index_html, "rb") as f:
build_id = hashlib.sha256(f.read()).hexdigest()[:12]
with open(
os.path.join(PLUGIN_DIR, "ui", "plugin-entry.js"),
encoding="utf-8",
) as f:
entry = f.read()
if "__CREATOR_BUILD_ID__" not in entry:
raise RuntimeError(
"plugin-entry.js missing __CREATOR_BUILD_ID__ placeholder",
)
dst_dist = os.path.join(INSTALLED_DIR, "ui", "dist")
rsync(FRONTEND_SRC, os.path.join(dst_dist, "app"))
with open(os.path.join(dst_dist, "index.js"), "w", encoding="utf-8") as f:
f.write(entry.replace("__CREATOR_BUILD_ID__", build_id))
def reload_backend() -> None:
payload = json.dumps({"source": INSTALLED_DIR, "force": True}).encode()
req = urllib.request.Request(
f"http://127.0.0.1:{PORT}/api/plugins/install",
data=payload,
method="POST",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
json.loads(resp.read())
def main() -> None:
if not os.path.isdir(INSTALLED_DIR):
sys.exit(
f"Installed copy not found: {INSTALLED_DIR}\n"
"Run scripts/dev.sh install once first.",
)
print(f"[dev-watch] installed copy : {INSTALLED_DIR}")
print(
"[dev-watch] backend reload : "
f"POST 127.0.0.1:{PORT}/api/plugins/install (force)",
)
print("[dev-watch] watching ui/dist/app and backend/ ... (Ctrl+C to stop)")
fe_state = snapshot(FRONTEND_SRC)
be_state = snapshot(BACKEND_SRC, (".py", ".json", ".txt"))
while True:
time.sleep(POLL_SECONDS)
fe_now = snapshot(FRONTEND_SRC)
if fe_now != fe_state:
# Debounce: wait until the vite rebuild fully settles (snapshot
# stable across two polls) before syncing.
while True:
time.sleep(0.7)
settled = snapshot(FRONTEND_SRC)
if settled == fe_now:
break
fe_now = settled
try:
sync_frontend()
fe_state = fe_now
print(
f"[dev-watch] {time.strftime('%H:%M:%S')} "
"frontend synced — hard-refresh the browser",
)
except Exception as exc:
# Keep fe_state unchanged so the next poll retries (e.g. a
# sync attempted mid-build before index.html existed).
print(
f"[dev-watch] frontend sync failed, retrying: {exc}",
file=sys.stderr,
)
time.sleep(2)
be_now = snapshot(BACKEND_SRC, (".py", ".json", ".txt"))
if be_now != be_state:
rsync(BACKEND_SRC, os.path.join(INSTALLED_DIR, "backend"))
be_state = be_now
try:
reload_backend()
print(
f"[dev-watch] {time.strftime('%H:%M:%S')} "
"backend synced + hot-reloaded",
)
except Exception as exc:
print(
f"[dev-watch] backend reload FAILED: {exc}",
file=sys.stderr,
)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\n[dev-watch] stopped")