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:
6 files changed
+304
-68
No files matched your search
@@ -4,3 +4,5 @@
|
||||
/dist/
|
||||
/magpie-dev
|
||||
/rsrc_windows_*.syso
|
||||
/magpie-dev-backend
|
||||
/magpie-dev.new
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user