dev: a Go change under make dev rebuilds and restarts only the backend — the API and the gateway — behind windows and a tray that stay up, and the page refreshes what it shows in place, keeping its tab and anything open; a build that fails leaves the running backend be (build/dev.sh)

This commit is contained in:
yetone committed 2026-09-25 01:14:56 +08:00
1 parent 919585d170
commit 9a0078f08c
6 files changed
+304 -68

No files matched your search

+2
View File
@@ -4,3 +4,5 @@
/dist/
/magpie-dev
/rsrc_windows_*.syso
/magpie-dev-backend
/magpie-dev.new
+7 -11
View File
@@ -85,21 +85,17 @@ clean:
rm -rf magpie magpie.exe magpie.app dist rsrc_windows_*.syso
# Development: the UI is served from internal/gui/assets and the window
# reloads itself when a file there is saved; with fswatch installed, Go
# changes rebuild and relaunch the app. Uses its own gateway port so a
# running magpie keeps serving the agents. Ctrl-C ends the loop: fswatch
# swallows the interrupt, so without the trap the shell would just go round
# again.
# reloads itself when a file there is saved. With fswatch installed, the
# windows stay up and a Go change rebuilds and restarts only the backend
# behind them (build/dev.sh); without it, dev-once runs the app as one
# process. Uses its own ports so a running magpie keeps serving the agents.
DEV_ADDR ?= 127.0.0.1:3426
DEV_UI ?= 127.0.0.1:3427
DEV_BACKEND ?= 127.0.0.1:3428
DEV_CONTROL ?= 127.0.0.1:3429
dev:
@if command -v fswatch >/dev/null; then \
trap 'pkill -P $$pid 2>/dev/null; kill $$pid 2>/dev/null; wait $$pid 2>/dev/null; exit 0' INT TERM; \
while true; do \
$(MAKE) --no-print-directory dev-once & pid=$$!; \
fswatch -1 -r -e '.*' -i '\.go$$' -i '\.md$$' . >/dev/null; \
echo " go changed · rebuilding"; pkill -P $$pid 2>/dev/null; kill $$pid 2>/dev/null; wait $$pid 2>/dev/null; \
done; \
MAGPIE_ADDR=$(DEV_ADDR) MAGPIE_DEV_UI=$(DEV_UI) MAGPIE_DEV_BACKEND=$(DEV_BACKEND) MAGPIE_DEV_CONTROL=$(DEV_CONTROL) build/dev.sh; \
else $(MAKE) --no-print-directory dev-once; fi
dev-once:
Executable
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# `make dev`: the windows and the tray (the shell) stay up while a Go change
# rebuilds and restarts only the backend behind them — the API and the
# gateway. The page refreshes what it shows in place; UI files under
# internal/gui/assets reload the page as before. A build that fails leaves
# the running backend alone. A change to the shell's own code
# (internal/gui/app.go, dev_on.go) restarts the windows too. Quitting magpie
# from the tray, or Ctrl-C, ends it all.
#
# make passes MAGPIE_ADDR, MAGPIE_DEV_UI, MAGPIE_DEV_BACKEND and
# MAGPIE_DEV_CONTROL.
set -u
cd "$(dirname "$0")/.."
shell='' backend='' watch='' fsw=''
stop() { [ -n "$1" ] && kill "$1" 2>/dev/null && wait "$1" 2>/dev/null; }
start_backend() { MAGPIE_DEV_ROLE=backend ./magpie-dev-backend app & backend=$!; }
# the shell, and a watcher that ends everything once it is quit
start_shell() {
MAGPIE_DEV_ROLE=shell ./magpie-dev app & shell=$!
{ while kill -0 "$shell" 2>/dev/null; do sleep 1; done; kill -TERM $$; } & watch=$!
}
stop_shell() {
kill "$watch" 2>/dev/null; wait "$watch" 2>/dev/null
stop "$shell"
}
cleanup() {
trap - INT TERM
stop_shell; stop "$backend"; kill "$fsw" 2>/dev/null
exit 0
}
trap cleanup INT TERM
# what the build reads: Go, and the one Markdown file embedded
relevant() {
case $1 in
*/site/* | *_test.go) return 1 ;;
*.go | */internal/agent/codex_prompt.md) return 0 ;;
esac
return 1
}
# builds to a new file and moves it in, never over a running binary
build() { go build -tags dev -o magpie-dev.new . && cp magpie-dev.new magpie-dev-backend.new && mv magpie-dev-backend.new magpie-dev-backend; }
build || exit 1
mv magpie-dev.new magpie-dev
start_backend
start_shell
exec 3< <(fswatch -r -e '/\.git/' -e '/site/' -e '/magpie-dev' "$PWD")
fsw=$!
echo " magpie-dev · UI from internal/gui/assets, reload on save · Go changes restart the backend only · gateway $MAGPIE_ADDR"
while read -r -u 3 path; do
relevant "$path" || continue
changed=" $path"
# let a burst of saves settle into one build
while read -r -u 3 -t 0.3 more; do relevant "$more" && changed="$changed $more"; done
echo " go changed · rebuilding"
if ! build; then
echo " build failed · the backend before it keeps running"
continue
fi
stop "$backend"
start_backend
case $changed in
*/internal/gui/app.go* | */internal/gui/dev_on.go*)
echo " window code changed · reopening the windows"
stop_shell
mv magpie-dev.new magpie-dev
start_shell
;;
*) rm -f magpie-dev.new ;;
esac
done
+55 -41
View File
@@ -6,6 +6,7 @@ import (
_ "embed"
"encoding/hex"
"log"
"net/http"
"os"
"runtime"
"sync"
@@ -60,7 +61,7 @@ func (h *host) ShowMain(view string) {
// Import opens the window on an import link, for the user to confirm.
func (h *host) Import(link string) {
id := stashImport(link)
id := stash(link)
h.whenReady(func() {
h.panel.Hide()
h.main.SetURL("/?view=providers&import=" + id + h.query)
@@ -93,47 +94,15 @@ func (h *host) FitPanel(height int) {
// showMain opens the window immediately; otherwise only the tray icon appears.
// link is a magpie:// link the app was started with, to confirm and import.
func Run(version string, showMain bool, link string) error {
Version = version
// `make dev` runs the backend on its own, so a Go change restarts only
// that, behind windows that stay up.
if devRole() == "backend" {
return devBackend(func(w Windows) http.Handler { return Handler(w, startBackend()) })
}
// After an update off the Mac, the old process starts this one and then
// quits; let it go before looking for the gateway.
update.AwaitPredecessor()
// The gateway runs inside the app. If another magpie already has the
// port, that one serves and this one only shows its status.
var gw *gateway.Server
if !gateway.Running() {
gw = gateway.New()
go func() {
if err := gw.ListenAndServe(context.Background()); err != nil {
log.Println("gateway:", err)
}
}()
}
// Model lists are fetched, never compiled in: whatever the agents can see
// comes from the models.dev catalog plus each vendor's own /models answer.
// Keep both halves warm without making the user click anything.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if catalog.Stale() {
if err := catalog.Sync(ctx); err != nil {
log.Println("catalog:", err)
}
}
cancel()
// A signed-in agent's list exists only at the vendor; fill it in the
// first time so the picker never shows a stale snapshot.
for _, p := range provider.All() {
if p.Account == nil || !p.Ready() {
continue
}
if _, ok := p.Fetched(); ok {
continue
}
c, cancel := context.WithTimeout(context.Background(), 20*time.Second)
if _, err := p.Fetch(c); err != nil {
log.Println(p.ID + ": " + err.Error())
}
cancel()
}
}()
go func() {
if err := registerScheme(); err != nil {
log.Println("magpie:// links:", err)
@@ -144,8 +113,11 @@ func Run(version string, showMain bool, link string) error {
if t := os.Getenv("MAGPIE_THEME"); t != "" {
theme = "&theme=" + t
}
Version = version
h := &host{query: theme, ready: make(chan struct{})}
handler := devShell(h)
if handler == nil {
handler = Handler(h, startBackend())
}
h.app = application.New(application.Options{
// Windows and Linux start a new process for a magpie:// link (or a
// second launch); it hands its arguments to the running one and quits.
@@ -154,7 +126,7 @@ func Run(version string, showMain bool, link string) error {
Name: "magpie",
Description: "one place to pick every agent's model",
Icon: appIcon,
Assets: application.AssetOptions{Handler: Handler(h, gw)},
Assets: application.AssetOptions{Handler: handler},
Mac: application.MacOptions{ActivationPolicy: application.ActivationPolicyAccessory},
Windows: application.WindowsOptions{DisableQuitOnLastWindowClosed: true},
// A version downloaded but not restarted into is installed on the
@@ -273,6 +245,48 @@ func Run(version string, showMain bool, link string) error {
return h.app.Run()
}
// startBackend starts what serves the page and the agents: the gateway,
// unless another magpie has it (then that one serves and this one only
// shows its status, and gw is nil), and the model lists kept warm.
func startBackend() (gw *gateway.Server) {
if !gateway.Running() {
gw = gateway.New()
go func() {
if err := gw.ListenAndServe(context.Background()); err != nil {
log.Println("gateway:", err)
}
}()
}
// Model lists are fetched, never compiled in: whatever the agents can see
// comes from the models.dev catalog plus each vendor's own /models answer.
// Keep both halves warm without making the user click anything.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if catalog.Stale() {
if err := catalog.Sync(ctx); err != nil {
log.Println("catalog:", err)
}
}
cancel()
// A signed-in agent's list exists only at the vendor; fill it in the
// first time so the picker never shows a stale snapshot.
for _, p := range provider.All() {
if p.Account == nil || !p.Ready() {
continue
}
if _, ok := p.Fetched(); ok {
continue
}
c, cancel := context.WithTimeout(context.Background(), 20*time.Second)
if _, err := p.Fetch(c); err != nil {
log.Println(p.ID + ": " + err.Error())
}
cancel()
}
}()
return gw
}
// singleInstance makes a second launch hand over to this one, off the Mac.
// The id covers the executable and the config dir, so a build elsewhere or
// a sandboxed HOME runs on its own.
+9 -5
View File
@@ -7,8 +7,12 @@ import (
"net/http"
)
// The shipped app serves the UI from the binary.
func staticFS() fs.FS { f, _ := fs.Sub(assets, "assets"); return f }
func devRoutes(*http.ServeMux) {}
func devListen(http.Handler) {}
func devPage(h http.Handler) http.Handler { return h }
// The shipped app serves the UI from the binary, in one process.
func staticFS() fs.FS { f, _ := fs.Sub(assets, "assets"); return f }
func devRoutes(*http.ServeMux) {}
func devListen(http.Handler) {}
func devPage(h http.Handler) http.Handler { return h }
func devRole() string { return "" }
func devBackend(func(Windows) http.Handler) error { return nil }
func devShell(*host) http.Handler { return nil }
func stash(link string) string { return stashImport(link) }
+151 -11
View File
@@ -4,19 +4,31 @@ package gui
import (
"bytes"
"context"
"io"
"io/fs"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// Development build (`make dev`): the UI is read from internal/gui/assets on
// every request, and the page reloads itself when a file there changes. Go
// changes still need a rebuild; `make dev` restarts on those too.
// every request, and the page reloads itself when a file there changes.
//
// `make dev` also splits the app in two, so a Go change doesn't close the
// windows: a shell (MAGPIE_DEV_ROLE=shell) has the windows and the tray and
// passes every request on to a backend (MAGPIE_DEV_ROLE=backend) with the
// API and the gateway. A Go change rebuilds and restarts only the backend;
// the page, told so, refreshes what it shows in place. What the backend asks
// of the windows goes back to the shell's control address.
func assetsDir() string {
_, file, _, _ := runtime.Caller(0)
@@ -39,28 +51,40 @@ func stamp() string {
return strconv.FormatInt(newest.UnixNano(), 10)
}
// boot tells this process from the one before it.
var boot = strconv.FormatInt(time.Now().UnixNano(), 36)
// A new stamp reloads the page; a new boot (a restarted backend) only
// refreshes what it shows, as coming back to the window does, so the tab
// and anything open stay put.
const reloadJS = `(async () => {
let seen = "";
let seen = "", boot = "";
for (;;) {
try {
const r = await fetch("/api/dev/wait?since=" + seen, { cache: "no-store" });
const s = (await r.json()).stamp;
if (seen && s !== seen) { location.reload(); return; }
seen = s;
const r = await fetch("/api/dev/wait?since=" + seen + "&boot=" + boot, { cache: "no-store" });
const j = await r.json();
if (seen && j.stamp !== seen) { location.reload(); return; }
if (boot && j.boot !== boot && typeof load === "function") load();
seen = j.stamp; boot = j.boot;
} catch { await new Promise(r => setTimeout(r, 1000)); }
}
})();`
func devRoutes(mux *http.ServeMux) {
// long-poll: answers as soon as the assets change, or after 20s
// long-poll: answers as soon as the assets change, or after 20s, or at
// once from a backend the page hasn't seen
mux.HandleFunc("GET /api/dev/wait", func(rw http.ResponseWriter, r *http.Request) {
since := r.URL.Query().Get("since")
since, was := r.URL.Query().Get("since"), r.URL.Query().Get("boot")
s := stamp()
for i := 0; i < 50 && since != "" && s == since; i++ {
for i := 0; i < 50 && since != "" && s == since && (was == "" || was == boot); i++ {
time.Sleep(400 * time.Millisecond)
s = stamp()
}
writeJSON(rw, map[string]string{"stamp": s})
writeJSON(rw, map[string]string{"stamp": s, "boot": boot})
})
// the shell's import links, stashed where the page will ask for them
mux.HandleFunc("POST /api/dev/import", func(rw http.ResponseWriter, r *http.Request) {
io.WriteString(rw, stashImport(r.FormValue("link")))
})
mux.HandleFunc("GET /dev.js", func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "text/javascript")
@@ -102,3 +126,119 @@ func devListen(h http.Handler) {
}
}()
}
// devRole is this process's half of `make dev`: "backend", "shell", or ""
// for the whole app in one process.
func devRole() string { return os.Getenv("MAGPIE_DEV_ROLE") }
// devBackend serves the page and the API on MAGPIE_DEV_BACKEND for the shell.
func devBackend(handler func(Windows) http.Handler) error {
addr := os.Getenv("MAGPIE_DEV_BACKEND")
h := handler(remoteWindows(os.Getenv("MAGPIE_DEV_CONTROL")))
log.Println("dev backend:", "http://"+addr)
return http.ListenAndServe(addr, h)
}
// remoteWindows are the shell's windows, seen from the backend.
type remoteWindows string // the shell's control address
func (c remoteWindows) do(op, arg string) {
res, err := backendClient.PostForm("http://"+string(c)+"/"+op, url.Values{"arg": {arg}})
if err != nil {
log.Println("dev shell:", err)
return
}
res.Body.Close()
}
func (c remoteWindows) HidePanel() { c.do("hide", "") }
func (c remoteWindows) ShowMain(view string) { c.do("main", view) }
func (c remoteWindows) Quit() { c.do("quit", "") }
func (c remoteWindows) OpenURL(u string) { c.do("open", u) }
func (c remoteWindows) OpenFolder(path string) { c.do("reveal", path) }
func (c remoteWindows) FitPanel(height int) { c.do("fit", strconv.Itoa(height)) }
// backendClient waits out a backend's restart: a request made while the old
// one is gone connects to the new one once it listens.
var backendClient = &http.Client{Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
deadline := time.Now().Add(time.Minute)
for {
c, err := d.DialContext(ctx, network, addr)
if err == nil || time.Now().After(deadline) {
return c, err
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(200 * time.Millisecond):
}
}
},
}}
// devShell is the shell's handler, a proxy to the backend, and starts the
// control address the backend drives the windows through. nil outside the
// shell.
func devShell(h *host) http.Handler {
if devRole() != "shell" {
return nil
}
control := http.NewServeMux()
control.HandleFunc("POST /{op}", func(rw http.ResponseWriter, r *http.Request) {
arg := r.FormValue("arg")
switch r.PathValue("op") {
case "hide":
h.HidePanel()
case "main":
h.ShowMain(arg)
case "quit":
h.Quit()
case "open":
h.OpenURL(arg)
case "reveal":
h.OpenFolder(arg)
case "fit":
if n, err := strconv.Atoi(arg); err == nil {
h.FitPanel(n)
}
}
rw.WriteHeader(http.StatusNoContent)
})
go func() {
if err := http.ListenAndServe(os.Getenv("MAGPIE_DEV_CONTROL"), control); err != nil {
log.Println("dev control:", err)
}
}()
p := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: "http", Host: os.Getenv("MAGPIE_DEV_BACKEND")})
p.Transport = backendClient.Transport
// a restart cuts off whatever was in flight; the page asks again
p.ErrorHandler = func(rw http.ResponseWriter, r *http.Request, err error) {
rw.WriteHeader(http.StatusBadGateway)
}
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// The webview's requests often come without a Content-Length, which
// Wails reads as 0, and a proxy drops a body of length 0: say it's
// unknown so a POST's body gets through.
if r.ContentLength == 0 && r.Body != nil && r.Body != http.NoBody && r.Method != http.MethodGet && r.Method != http.MethodHead {
r.ContentLength = -1
}
p.ServeHTTP(rw, r)
})
}
// stash keeps an import link for the page, in the backend under `make dev`.
func stash(link string) string {
if devRole() != "shell" {
return stashImport(link)
}
res, err := backendClient.PostForm("http://"+os.Getenv("MAGPIE_DEV_BACKEND")+"/api/dev/import", url.Values{"link": {link}})
if err != nil {
log.Println("dev import:", err)
return ""
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
return strings.TrimSpace(string(b))
}