From c64987f2eb9c91bdaafa65633c1125f28221905e Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sun, 18 Oct 2026 23:07:11 -0700 Subject: [PATCH] auto restart server --- AGENTS.md | 6 +++--- cmd/byre/serve.go | 33 +++++++++++++++++++++++++++++++++ docs/macos.md | 4 ++-- docs/operations.md | 4 ++-- mac/Sources/Byre/Server.swift | 9 --------- mac/Sources/Byre/Updater.swift | 27 --------------------------- mac/deploy.sh | 1 - 7 files changed, 40 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 12ec3a2..48fa16e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,15 +12,15 @@ These are requirements, not preferences. Changing one needs the owner's agreemen A deploy is a release that isn't uploaded: the same notarized disk image, installed on the owner's Macs instead of published. When asked to deploy, do exactly this, from the worktree root: -1. Run `mac/deploy.sh`, always as the last action of the turn. It runs `mac/package.sh` (the build, both notarizations and the disk image, as for a release, into `mac/.build/release//`), installs the image's app as `/Applications/Byre.app` with `mac/install.sh`, and restarts the service with `launchctl kickstart -k`. The service runs `/Applications/Byre.app/Contents/Helpers/byre`, so the kickstart starts the new server and `byre-hands`. Everything is signed with Developer ID, so the macOS privacy grants (Screen Recording, Accessibility, Automation) survive. +1. Run `mac/deploy.sh`, always as the last action of the turn. It runs `mac/package.sh` (the build, both notarizations and the disk image, as for a release, into `mac/.build/release//`), and installs the image's app as `/Applications/Byre.app` with `mac/install.sh`. The service runs `/Applications/Byre.app/Contents/Helpers/byre`; within 5 seconds it sees that file replaced and exits, and launchd starts the new server and `byre-hands`. Everything is signed with Developer ID, so the macOS privacy grants (Screen Recording, Accessibility, Automation) survive. 2. When you resume, confirm it came up: read the tail of `~/Library/Logs/byre.log`, and `GET /api/admin/status` through the `tailscale serve` URL (`tailscale serve status` shows it). Report what you deployed and what you checked. 3. Once the service is confirmed healthy, update each client Mac whose app is running, through the client tools and without asking first: upload the disk image (`mac/.build/release//byre-.dmg`) and `mac/install.sh`, quit the app, run `install.sh `, and `open /Applications/Byre.app`. The app reads its server address from its iCloud-synced SwiftData store, so it reconnects to prod with no setup. Report each Mac's app commit (`install.sh` prints it). What to expect: - The notarizations take a minute or two each; `notarytool` prints "In Progress" until "Accepted". A rejection stops the script before anything is installed; `xcrun notarytool log --keychain-profile byre-notary` says why. -- You run inside byre, so the kickstart kills your own turn partway through the script. That is expected, not a reason to skip it. You resume with a "byre restarted while you were mid-turn" message; do the checks then, and confirm the service started after your kickstart. -- The restart can cut off the record of the last tool call, so you may resume with no memory of having deployed. Before concluding that another agent restarted byre, compare the service's start time (`started_at` in `/api/admin/status`, or the last `listening` line in the log) with when you ran `mac/deploy.sh`: a restart a few minutes after it, once the notarizations are done, is most likely your kickstart. If byre restarts without your kickstart, another agent deployed its own branch, and the running build is theirs. +- You run inside byre, so the restart kills your own turn just after the install. That is expected, not a reason to skip it. You resume with a "byre restarted while you were mid-turn" message; do the checks then, and confirm the service started after your install (the log shows `executable replaced, exiting` just before it). +- The restart can cut off the record of the last tool call, so you may resume with no memory of having deployed. Before concluding that another agent restarted byre, compare the service's start time (`started_at` in `/api/admin/status`, or the last `listening` line in the log) with when you ran `mac/deploy.sh`: a restart a few minutes after it, once the notarizations are done, is most likely your install. If byre restarts without your install, another agent deployed its own branch, and the running build is theirs. ## Releasing diff --git a/cmd/byre/serve.go b/cmd/byre/serve.go index 1ad51fe..0c7653f 100644 --- a/cmd/byre/serve.go +++ b/cmd/byre/serve.go @@ -39,6 +39,9 @@ func serveCmd(args []string) { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + ctx, replaced := context.WithCancel(ctx) + defer replaced() + go watchExecutable(ctx, replaced) hs := &http.Server{ Addr: *listenAddr, @@ -59,3 +62,33 @@ func serveCmd(args []string) { fatal("serve", "err", err) } } + +func watchExecutable(ctx context.Context, replaced context.CancelFunc) { + exe, err := os.Executable() + if err != nil { + fatal("executable path", "err", err) + } + started, err := os.Stat(exe) + if err != nil { + fatal("stat executable", "path", exe, "err", err) + } + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + now, err := os.Stat(exe) + if err != nil { + continue + } + if os.SameFile(started, now) { + continue + } + slog.Info("executable replaced, exiting", "path", exe) + replaced() + return + } +} diff --git a/docs/macos.md b/docs/macos.md index 443f94e..8cfe870 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -74,7 +74,7 @@ API calls go through the generated `Client` over `URLSessionTransport`. A middle `EventStream` is a hand-written SSE client over `URLSession.bytes(for:)`. It splits lines itself, because `AsyncBytes.lines` drops the blank lines that end SSE events, and parses off the main actor, handing each frame to it. It handles `id`, `event: ping`, `event: ready` and `data`: it tracks the last seq, reconnects from it after a stale threshold (40 s, checked every 5 s) and a retry delay (2 s), when the network comes back (`NWPathMonitor`), and when the app becomes active with a stale stream. An event whose data doesn't decode is logged and skipped. There are two streams, global (after the bootstrap `last_event_seq`) and lead (`?lead=true`, after the last event of the lead's first page), and only the global one drives the connection dot. -Startup: `GET /api/admin/status`, then me, projects, inbox, lead sessions and summary reads in parallel, then drafts, focus, the lead stream, the window, the global stream. A failure before the window shows replaces it with `byre failed to start: ` and the whole startup is tried again every 2 s until it succeeds, so a server restarting (the app's own kickstart after an update, for one) heals by itself; Forget Server leaves that screen. `handleEvent` calls `notifyInbox` on `inbox.created` and `notifyTurn` on `turn.ended`. +Startup: `GET /api/admin/status`, then me, projects, inbox, lead sessions and summary reads in parallel, then drafts, focus, the lead stream, the window, the global stream. A failure before the window shows replaces it with `byre failed to start: ` and the whole startup is tried again every 2 s until it succeeds, so a server restarting (after an update replaces its binary, for one) heals by itself; Forget Server leaves that screen. `handleEvent` calls `notifyInbox` on `inbox.created` and `notifyTurn` on `turn.ended`. The app logs through `os.Logger` with subsystem `run.fc.byre.mac`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it. @@ -157,7 +157,7 @@ On first launch, and in a window from New Server Window, the window shows one fi ## Updates -`Updater.swift` runs Sparkle's standard updater (`SPUStandardUpdaterController`), started at launch. It reads `SUFeedURL` from `mac/Info.plist`, the appcast of the newest release (see "Releases" in `docs/operations.md`), checks it daily (`SUEnableAutomaticChecks`, so Sparkle doesn't first ask whether to), compares the appcast's `sparkle:version` with `CFBundleVersion`, and verifies the download's EdDSA signature against `SUPublicEDKey` and its Developer ID signature against the running app's. Check for Updates… in the app menu checks at once. Sparkle's own windows show the offer and the progress; the appcast carries no release notes, so its release-notes view, a web view, is never shown. At launch, the app compares its `CFBundleVersion` with `lastLaunchedVersion` in `UserDefaults` and stores the new one; when a stored version differs, however the bundle was replaced (Sparkle relaunching, installing on quit, or a new copy dragged in), it reads `~/Library/LaunchAgents/run.fc.byre.plist`, and when the service's program is this bundle's `Contents/Helpers/byre`, runs `launchctl kickstart -k gui//run.fc.byre`, so the server updates with the app. A server installed from a checkout is left alone. +`Updater.swift` runs Sparkle's standard updater (`SPUStandardUpdaterController`), started at launch. It reads `SUFeedURL` from `mac/Info.plist`, the appcast of the newest release (see "Releases" in `docs/operations.md`), checks it daily (`SUEnableAutomaticChecks`, so Sparkle doesn't first ask whether to), compares the appcast's `sparkle:version` with `CFBundleVersion`, and verifies the download's EdDSA signature against `SUPublicEDKey` and its Developer ID signature against the running app's. Check for Updates… in the app menu checks at once. Sparkle's own windows show the offer and the progress; the appcast carries no release notes, so its release-notes view, a web view, is never shown. When the bundle is replaced, a server running from its `Contents/Helpers/byre` restarts itself onto the new binary (see "Running as a service" in `docs/operations.md`). ## Persistence diff --git a/docs/operations.md b/docs/operations.md index ed55937..c9babf6 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -119,7 +119,7 @@ The listen address is `byre serve --listen`, whose default is `defaultListen` in byre is served at the root of the machine's `ts.net` name on 443 (`https://..ts.net`), not under a path: the API uses absolute paths, and the macOS app takes an origin with no path. `tailscale serve status` shows the URL and what else the machine serves. `launchctl print gui/$(id -u)/run.fc.byre` shows the service and the program it runs. -A new build reaches the service by replacing `/Applications/Byre.app` and restarting the service: `mac/deploy.sh` (see "Releases"), or the app's updater, which restarts the service when it launches as a new version. Logs go to `~/Library/Logs/byre.log`. +A new build reaches the service by replacing `/Applications/Byre.app`: `mac/deploy.sh` (see "Releases"), or the app's updater. `byre serve` stats its own executable (`os.Executable`) every 5 seconds (`watchExecutable` in `cmd/byre/serve.go`); once that path holds a different file, it logs `executable replaced, exiting` and shuts down as on SIGTERM, and launchd (`KeepAlive`) starts the new binary. A missing path counts as not yet replaced, so the moment in `install.sh` between removing the old bundle and moving the new one in doesn't trigger it. Logs go to `~/Library/Logs/byre.log`. ## Releases @@ -127,7 +127,7 @@ A release is one notarized disk image, `byre-.dmg`, holding `Byre.app`: `mac/package.sh` builds one for whatever the checkout holds, into `mac/.build/release//`, `` being the bundle's `CFBundleShortVersionString`, and prints the image's path. It builds with `mac/bundle.sh` (signed with Developer ID, see `docs/macos.md`), zips the app and notarizes it with `xcrun notarytool submit --keychain-profile byre-notary --wait`, staples the ticket, builds the disk image with `hdiutil` (the app and an `/Applications` link), signs, notarizes and staples that too, and writes its SHA-256 beside it. It then signs the image for Sparkle with `sign_update --account byre` and writes `appcast.xml`, a feed with that one release. -A deploy is that image installed rather than published: `mac/deploy.sh` runs `package.sh`, installs the image's app with `mac/install.sh ` (mounts the image, copies `Byre.app` to `/Applications/Byre.app.new`, removes the old bundle and moves the new one into place, so nothing running from the old bundle has its files written over; it prints the installed `ByreCommit`), and restarts the service with `launchctl kickstart -k`. Client Macs get the same image through the client tools and `install.sh` (`AGENTS.md`, "Deploying"). A deployed build's `CFBundleVersion` is at least the last release's, so the updater never offers to go back to it. +A deploy is that image installed rather than published: `mac/deploy.sh` runs `package.sh`, installs the image's app with `mac/install.sh ` (mounts the image, copies `Byre.app` to `/Applications/Byre.app.new`, removes the old bundle and moves the new one into place, so nothing running from the old bundle has its files written over; it prints the installed `ByreCommit`); the service sees its executable replaced and restarts itself (see "Running as a service"). Client Macs get the same image through the client tools and `install.sh` (`AGENTS.md`, "Deploying"). A deployed build's `CFBundleVersion` is at least the last release's, so the updater never offers to go back to it. `mac/release.sh ` publishes one, run on byre's host from a checkout whose `HEAD` carries a `v` tag already pushed to `origin`, with a clean tree; it fetches the tag from `origin` and stops unless it names `HEAD`, then runs `package.sh`. Last, it creates the release as a draft through the Gitea API (`POST /api/v1/repos/ai/byre/releases`, its notes the Markdown file ``, which the releasing agent writes from the diffs since the previous tag, as `AGENTS.md`, "Releasing", describes), uploads the image, its `.sha256` and `appcast.xml` as assets, and only then publishes it (`PATCH` with `draft: false`), so the `latest` release always has its appcast. A run that fails part way leaves the draft on the releases page; delete it there before running again. Everything lands in `mac/.build/release//`, and the script prints the release page. diff --git a/mac/Sources/Byre/Server.swift b/mac/Sources/Byre/Server.swift index 3867700..9a35b69 100644 --- a/mac/Sources/Byre/Server.swift +++ b/mac/Sources/Byre/Server.swift @@ -4,15 +4,6 @@ import SwiftUI typealias Permission = Components.Schemas.Permission -struct LaunchAgent: Decodable { - let programArguments: [String] - - enum CodingKeys: String, CodingKey { - case programArguments = "ProgramArguments" - } -} - -let agentPlist = FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/LaunchAgents/run.fc.byre.plist") let serverHelper = Bundle.main.bundleURL.appending(path: "Contents/Helpers/byre").path private let service = "gui/\(getuid())/run.fc.byre" diff --git a/mac/Sources/Byre/Updater.swift b/mac/Sources/Byre/Updater.swift index 78322c6..b37c729 100644 --- a/mac/Sources/Byre/Updater.swift +++ b/mac/Sources/Byre/Updater.swift @@ -1,43 +1,16 @@ import Foundation import Sparkle -private let lastVersionKey = "lastLaunchedVersion" - @MainActor final class Updater { static let shared = Updater() private lazy var controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) func start() { - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String - let last = UserDefaults.standard.string(forKey: lastVersionKey) - UserDefaults.standard.set(version, forKey: lastVersionKey) - if last != nil && last != version { - restartServer() - } _ = controller } func check() { controller.checkForUpdates(nil) } - - private func restartServer() { - guard let data = try? Data(contentsOf: agentPlist), - let plist = try? PropertyListDecoder().decode(LaunchAgent.self, from: data), - plist.programArguments.first == serverHelper - else { - return - } - let p = Process() - p.executableURL = URL(filePath: "/bin/launchctl") - p.arguments = ["kickstart", "-k", "gui/\(getuid())/run.fc.byre"] - do { - try p.run() - p.waitUntilExit() - log.info("restarted byre server: status \(p.terminationStatus)") - } catch { - log.error("restart byre server: \(errorText(error), privacy: .public)") - } - } } diff --git a/mac/deploy.sh b/mac/deploy.sh index a69d396..c6c998e 100755 --- a/mac/deploy.sh +++ b/mac/deploy.sh @@ -6,4 +6,3 @@ mac=$(cd "$(dirname "$0")" && pwd) version=$(plutil -extract CFBundleShortVersionString raw "$mac/.build/Byre.app/Contents/Info.plist") dmg=$mac/.build/release/$version/byre-$version.dmg "$mac/install.sh" "$dmg" -launchctl kickstart -k "gui/$(id -u)/run.fc.byre"