From 4426ef187da01763bf1a4ebd8efccb65ebcbed93 Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 18 Jun 2026 19:52:47 +0800 Subject: [PATCH 1/4] feat(desktop): supervise UTA in the Electron shell + beta-aware L1 update check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packaged Electron shell only spawned Alice, which hard-requires OPENALICE_UTA_URL at boot and threw without it — so the desktop app never ran end to end. Port the prod.mjs supervision into apps/desktop: spawn UTA, poll /__uta/health, spawn Alice with OPENALICE_UTA_URL injected, watch restart-uta.flag to respawn UTA, and cascade-shutdown both children. - Cross-platform tree-kill (taskkill /T /F on win32) so the two children and their PTY/CLI grandchildren don't leak on quit / UTA restart. - Fix OPENALICE_APP_HOME: it must be app.getAppPath() (the dir that contains default/, ui/dist, src/workspaces, services/uta/dist), not dirname() — the old value pointed one level above the shipped files, breaking templates/UI. - L1 update check: poll the GitHub releases list (prereleases included, since we ship -beta.N with no formal release soon), surface a non-blocking download dialog when a newer version exists. No auto-download, no signing. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src/main.ts | 316 ++++++++++++++++++++------ apps/desktop/src/update-check.spec.ts | 100 ++++++++ apps/desktop/src/update-check.ts | 90 ++++++++ 3 files changed, 431 insertions(+), 75 deletions(-) create mode 100644 apps/desktop/src/update-check.spec.ts create mode 100644 apps/desktop/src/update-check.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 34147e48..4d7d5fc3 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,42 +1,66 @@ /** - * Electron main process — OpenAlice's guardian. + * Electron main process — OpenAlice's desktop guardian. * - * Responsibilities (MVP): - * 1. Probe free ports for backend web + MCP (starts at 47331 to dodge the - * crowded 3000s range; auto-fallback if taken — local user never sees - * "Alice can't start" for a port collision). - * 2. Spawn the backend (`dist/main.js`) as a child process with the - * chosen ports injected as env (`OPENALICE_WEB_PORT` / - * `OPENALICE_MCP_PORT` — picked up by `src/core/config.ts`'s - * env override). Single source of truth lives on the env channel for - * spawn-time-fixed values; runtime-mutable config still flows via - * file-reread. - * 3. Wait for backend HTTP readiness, then open a BrowserWindow pointed - * at the same port (same-origin, no CORS surface). - * 4. On quit: SIGTERM the backend, SIGKILL after 5s if it hangs. + * Supervises the same two-process topology as scripts/guardian/prod.mjs: + * 1. UTA service (services/uta/dist/uta.js, bind 127.0.0.1) + * 2. Alice main (dist/main.js) + * plus the desktop-only concerns: data relocation, BrowserWindow, quit UX. * - * Out of scope (future iterations): tray icon, auto-update, code signing, - * graceful-shutdown UX polish, multi-window, native menu integration. + * Lifecycle: + * relocate data → resolve ports → spawn UTA → poll /__uta/health + * → spawn Alice (OPENALICE_UTA_URL injected) → wait Alice ready + * → open window. Watch `data/control/restart-uta.flag` → respawn UTA. + * On quit or unexpected child exit: cascade tree-kill both children. + * + * The port + supervision logic is an inline mirror of + * scripts/guardian/{shared.ts,prod.mjs} — the desktop package is a separate + * release surface with no TS-dev-tooling dependency, the same reason + * probe-port.ts is duplicated rather than imported. + * + * Out of scope (future iterations): tray icon, full auto-update (L2 — needs + * code signing, blocked by Squirrel.Mac), multi-window, native menus. */ -import { app, BrowserWindow, dialog } from 'electron' -import { spawn, type ChildProcess } from 'node:child_process' -import { readFile } from 'node:fs/promises' +import { app, BrowserWindow, dialog, shell } from 'electron' +import { spawn, spawnSync, type ChildProcess } from 'node:child_process' +import { mkdir, readFile, watch } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { homedir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { probeFreePort } from './probe-port.js' import { relocateLegacyData } from './relocate-data.js' +import { checkForUpdate } from './update-check.js' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -let backend: ChildProcess | null = null +let uta: ChildProcess | null = null +let alice: ChildProcess | null = null let appQuitting = false +let restartingUTA = false const DEFAULT_WEB_PORT_START = 47331 const READY_TIMEOUT_MS = 30_000 +const UTA_READY_TIMEOUT_MS = 15_000 const SIGTERM_GRACE_MS = 5_000 +const UTA_RESTART_GRACE_MS = 8_000 + +// ── Cross-platform process-tree kill ───────────────────────── +// Inline mirror of scripts/guardian/shared.ts:killTree. UTA and Alice each +// spawn grandchildren (node-pty terminals, workspace CLIs). On Windows +// `child.kill()` reaps only the direct child and orphans those grandchildren +// — they keep holding their ports, breaking UTA restart and leaving zombies +// on quit. `taskkill /T` walks the whole tree; `/F` is the only reliable kill +// for a detached console child. POSIX has no wrapper, so a direct signal is +// correct and preserves graceful SIGTERM. +function killTree(child: ChildProcess, signal: NodeJS.Signals = 'SIGTERM'): void { + if (child.pid == null) return + if (process.platform === 'win32') { + try { spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F']) } catch { /* already gone */ } + } else { + try { child.kill(signal) } catch { /* already gone */ } + } +} // ── Port configuration ────────────────────────────────────── // Inline mirror of scripts/guardian/shared.ts (the desktop package is a @@ -101,7 +125,7 @@ async function claimPort( } } -async function waitForBackendReady(port: number, timeoutMs = READY_TIMEOUT_MS): Promise { +async function waitForAliceReady(port: number, timeoutMs = READY_TIMEOUT_MS): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { try { @@ -113,28 +137,46 @@ async function waitForBackendReady(port: number, timeoutMs = READY_TIMEOUT_MS): } await new Promise((r) => setTimeout(r, 200)) } - throw new Error(`backend did not become ready on port ${port} within ${timeoutMs}ms`) + throw new Error(`Alice did not become ready on port ${port} within ${timeoutMs}ms`) +} + +async function waitForUTA(utaUrl: string, timeoutMs = UTA_READY_TIMEOUT_MS): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + const res = await fetch(`${utaUrl}/__uta/health`) + if (res.ok) return true + } catch { + // not bound yet + } + await new Promise((r) => setTimeout(r, 200)) + } + return false } app.whenReady().then(async () => { - // Build output lives at /dist/electron/main.js and /dist/main.js - // (sibling directories at /dist/). The desktop package source is at - // apps/desktop/src/ but tsconfig.outDir is ../../dist/electron, so this - // sibling-resolve is unchanged from the pre-split layout. - const backendEntry = resolve(__dirname, '..', 'main.js') + // Build output lives at /dist/electron/main.js, /dist/main.js + // (Alice), and /services/uta/dist/uta.js (UTA). The desktop package + // source is at apps/desktop/src/ but tsconfig.outDir is ../../dist/electron, + // so these repo-relative resolves are unchanged from the pre-split layout. + const repoRoot = resolve(__dirname, '..', '..') + const aliceEntry = resolve(__dirname, '..', 'main.js') + const utaEntry = resolve(repoRoot, 'services', 'uta', 'dist', 'uta.js') // Two homes — user data vs app resources. See src/core/paths.ts for why // they're split. User data lives at ~/.openalice in BOTH branches — one // store shared with `pnpm dev` and bare `pnpm start`, so accounts are // configured once, not per topology. App resources stay lifecycle-owned: // .app/Contents/Resources when packaged, the repo in dev. - const repoRoot = resolve(__dirname, '..', '..') const userDataHome = join(homedir(), '.openalice') const homeEnv = app.isPackaged ? { OPENALICE_HOME: userDataHome, - // .app/Contents/Resources/ — sibling of app.asar - OPENALICE_APP_HOME: dirname(app.getAppPath()), + // The app dir itself (Contents/Resources/app with asar:false) — it's + // what *contains* default/, ui/dist, src/workspaces, services/uta/dist, + // matching how src/core/paths.ts resolves resources (APP_HOME/). + // NOT dirname() — that points one level above the shipped files. + OPENALICE_APP_HOME: app.getAppPath(), } : { OPENALICE_HOME: userDataHome, @@ -168,43 +210,80 @@ app.whenReady().then(async () => { const portsFile = await readPortsFile(homeEnv.OPENALICE_HOME) const webPort = await claimPort('web', 'OPENALICE_WEB_PORT', portsFile.web, DEFAULT_WEB_PORT_START) const mcpPort = await claimPort('mcp', 'OPENALICE_MCP_PORT', portsFile.mcp, webPort + 1) + const utaPort = await claimPort('uta', 'OPENALICE_UTA_PORT', portsFile.uta, mcpPort + 1) + const utaUrl = `http://127.0.0.1:${utaPort}` - backend = spawn(process.execPath, [backendEntry], { - env: { - ...process.env, - // CRITICAL: without this, the spawned process tries to start as - // another Electron "main process" (opens a new app instance) rather - // than executing the JS file as Node. `process.execPath` is the - // Electron binary in main-process context; only this env switches - // it to pure-Node runtime mode. - ELECTRON_RUN_AS_NODE: '1', - OPENALICE_WEB_PORT: String(webPort), - OPENALICE_MCP_PORT: String(mcpPort), - // The desktop shell doesn't spawn UTA yet (Alice expects a Guardian - // to provide OPENALICE_UTA_URL — known gap in the Electron topology). - // Forward a ports.json uta value anyway so the L1 file behaves - // uniformly once that wiring lands; explicit env still wins via the - // ...process.env spread above. - ...(portsFile.uta !== undefined && !process.env['OPENALICE_UTA_PORT'] - ? { OPENALICE_UTA_PORT: String(portsFile.uta) } - : {}), - // Hint for the backend (future use): we're under Electron, not a - // bare `node dist/main.js`. Today nothing reads this; future - // graceful-shutdown / update-flow code can branch on it. - OPENALICE_LAUNCHER: 'electron', - ...homeEnv, - }, - stdio: 'inherit', - }) + // ── Child spawns ──────────────────────────────────────────── + // Both children run as pure Node, not nested Electron main processes — + // ELECTRON_RUN_AS_NODE flips process.execPath (the Electron binary) into + // Node runtime mode. Without it each spawn would open a new app window. - backend.once('exit', (code, signal) => { - console.log(`[guardian] backend exited code=${code} signal=${signal}`) - if (!appQuitting) app.quit() - }) + const spawnUTA = (): ChildProcess => { + const child = spawn(process.execPath, [utaEntry], { + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + OPENALICE_UTA_PORT: String(utaPort), + OPENALICE_LAUNCHER: 'electron', + ...homeEnv, + }, + stdio: 'inherit', + }) + child.once('exit', (code, signal) => { + if (appQuitting || restartingUTA) return + console.error(`[guardian] UTA exited unexpectedly code=${code} signal=${signal}`) + shutdown() + }) + return child + } + + const spawnAlice = (): ChildProcess => { + const child = spawn(process.execPath, [aliceEntry], { + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + OPENALICE_WEB_PORT: String(webPort), + OPENALICE_MCP_PORT: String(mcpPort), + // The fix: Alice hard-requires OPENALICE_UTA_URL at boot + // (src/main.ts) and throws without it. The pre-UTA desktop shell + // never set it, so the packaged app crashed on launch. + OPENALICE_UTA_URL: utaUrl, + OPENALICE_LAUNCHER: 'electron', + ...homeEnv, + }, + stdio: 'inherit', + }) + child.once('exit', (code, signal) => { + if (appQuitting) return + console.error(`[guardian] Alice exited unexpectedly code=${code} signal=${signal}`) + shutdown() + }) + return child + } + + // ── Boot order: UTA first, then Alice pointed at it ───────── + console.log(`[guardian] UTA → ${utaUrl}`) + console.log(`[guardian] Alice → http://127.0.0.1:${webPort}`) + uta = spawnUTA() + const utaReady = await waitForUTA(utaUrl) + if (!utaReady) { + dialog.showErrorBox( + 'OpenAlice — trading service failed to start', + `The UTA trading service did not become ready within ${UTA_READY_TIMEOUT_MS / 1000}s.\n\n` + + `OpenAlice can't start without it. Check the logs at ${join(userDataHome, 'logs')} and relaunch.`, + ) + shutdown() + return + } + console.log(`[guardian] UTA ready pid=${uta.pid}`) - console.log(`[guardian] backend pid=${backend.pid} webPort=${webPort} mcpPort=${mcpPort}`) + alice = spawnAlice() + console.log(`[guardian] Alice pid=${alice.pid} webPort=${webPort} mcpPort=${mcpPort}`) + await waitForAliceReady(webPort) - await waitForBackendReady(webPort) + // ── Restart-flag watcher: broker config changes touch the flag; SIGTERM + // + respawn UTA without restarting Alice (mirrors prod.mjs). ──────────── + void startFlagWatcher(homeEnv.OPENALICE_HOME, utaUrl, spawnUTA) const win = new BrowserWindow({ width: 1280, @@ -217,25 +296,112 @@ app.whenReady().then(async () => { }, }) win.loadURL(`http://localhost:${webPort}/`) + + // L1 update check — non-blocking; surfaces a dialog only when a newer + // release exists. Never gates launch, never auto-downloads (no signing). + void checkForUpdate(app.getVersion()).then((update) => { + if (!update) return + void dialog + .showMessageBox(win, { + type: 'info', + title: 'OpenAlice — update available', + message: `A newer version of OpenAlice is available: ${update.version}`, + detail: `You're on ${app.getVersion()}. Download the new version from the release page.`, + buttons: ['Download', 'Later'], + defaultId: 0, + cancelId: 1, + }) + .then(({ response }) => { + if (response === 0) void shell.openExternal(update.url) + }) + }) }) -app.on('before-quit', (e) => { +async function restartUTA(utaUrl: string, spawnUTA: () => ChildProcess): Promise { + if (restartingUTA || appQuitting) return + restartingUTA = true + try { + console.log('[guardian] restart-uta.flag triggered — restarting UTA') + const old = uta + if (old && old.exitCode === null) { + const exited = new Promise((r) => old.once('exit', () => r())) + killTree(old, 'SIGTERM') + await Promise.race([exited, new Promise((r) => setTimeout(r, UTA_RESTART_GRACE_MS))]) + if (old.exitCode === null) { + killTree(old, 'SIGKILL') + await exited + } + } + uta = spawnUTA() + const ready = await waitForUTA(utaUrl) + console.log(ready ? '[guardian] UTA back online' : '[guardian] UTA did not come back up after restart') + } finally { + restartingUTA = false + } +} + +async function startFlagWatcher( + dataHome: string, + utaUrl: string, + spawnUTA: () => ChildProcess, +): Promise { + const flagPath = resolve(dataHome, 'data', 'control', 'restart-uta.flag') + const flagDir = dirname(flagPath) + const flagName = 'restart-uta.flag' + await mkdir(flagDir, { recursive: true }) + let pending: ReturnType | undefined + const fire = (): void => { + if (pending) clearTimeout(pending) + pending = setTimeout(() => { + pending = undefined + restartUTA(utaUrl, spawnUTA).catch((err) => console.error('[guardian] restartUTA threw:', err)) + }, 100) + } + try { + const watcher = watch(flagDir) + for await (const evt of watcher) { + if (evt.filename === flagName) fire() + } + } catch (err) { + console.error('[guardian] flag watcher errored:', err) + } +} + +/** Cascade tree-kill both children, then exit once they're gone. */ +function shutdown(): void { if (appQuitting) return - if (!backend || backend.killed || backend.exitCode !== null) return appQuitting = true - e.preventDefault() - console.log(`[guardian] SIGTERM → backend pid=${backend.pid}`) - backend.kill('SIGTERM') + const children = [uta, alice].filter((c): c is ChildProcess => c != null && c.exitCode === null && !c.killed) + if (children.length === 0) { + app.exit(0) + return + } + console.log(`[guardian] shutting down — SIGTERM → ${children.length} child(ren)`) + let remaining = children.length + const done = (): void => { + if (--remaining <= 0) { + clearTimeout(sigkill) + app.exit(0) + } + } + for (const c of children) { + c.once('exit', done) + killTree(c, 'SIGTERM') + } const sigkill = setTimeout(() => { - if (backend && !backend.killed) { - console.warn(`[guardian] backend did not exit after ${SIGTERM_GRACE_MS}ms → SIGKILL`) - backend.kill('SIGKILL') + for (const c of children) { + if (c.exitCode === null && !c.killed) { + console.warn(`[guardian] child pid=${c.pid} did not exit after ${SIGTERM_GRACE_MS}ms → SIGKILL`) + killTree(c, 'SIGKILL') + } } }, SIGTERM_GRACE_MS) - backend.once('exit', () => { - clearTimeout(sigkill) - app.exit(0) - }) +} + +app.on('before-quit', (e) => { + if (appQuitting) return + e.preventDefault() + shutdown() }) app.on('window-all-closed', () => { diff --git a/apps/desktop/src/update-check.spec.ts b/apps/desktop/src/update-check.spec.ts new file mode 100644 index 00000000..79d26a70 --- /dev/null +++ b/apps/desktop/src/update-check.spec.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { checkForUpdate, compareVersions } from './update-check.js' + +describe('compareVersions', () => { + it('orders by core version', () => { + expect(compareVersions('0.42.0', '0.41.9')).toBeGreaterThan(0) + expect(compareVersions('1.0.0', '0.99.99')).toBeGreaterThan(0) + expect(compareVersions('0.41.0', '0.41.1')).toBeLessThan(0) + expect(compareVersions('0.41.0', '0.41.0')).toBe(0) + }) + + it('treats a release as newer than a prerelease at the same core', () => { + expect(compareVersions('1.0.0', '1.0.0-rc.1')).toBeGreaterThan(0) + expect(compareVersions('1.0.0-rc.1', '1.0.0')).toBeLessThan(0) + }) + + it('orders prereleases lexicographically', () => { + expect(compareVersions('1.0.0-rc.2', '1.0.0-rc.1')).toBeGreaterThan(0) + expect(compareVersions('1.0.0-beta', '1.0.0-rc')).toBeLessThan(0) + }) + + it('tolerates malformed segments', () => { + expect(compareVersions('0.42', '0.41.0')).toBeGreaterThan(0) + expect(compareVersions('garbage', '0.0.0')).toBe(0) + }) +}) + +describe('checkForUpdate', () => { + afterEach(() => vi.unstubAllGlobals()) + + const stubFetch = (impl: () => Promise) => + vi.stubGlobal('fetch', vi.fn(impl as () => Promise)) + + it('returns update info when the latest release is newer', async () => { + stubFetch(async () => ({ + ok: true, + json: async () => [ + { tag_name: 'v0.42.0', html_url: 'https://example.test/releases/v0.42.0', draft: false }, + ], + })) + const update = await checkForUpdate('0.41.0') + expect(update).toEqual({ version: '0.42.0', url: 'https://example.test/releases/v0.42.0' }) + }) + + it('includes prereleases (beta) — the whole point of querying the list', async () => { + stubFetch(async () => ({ + ok: true, + json: async () => [ + { tag_name: 'v0.51.0-beta.1', html_url: 'https://example.test/beta', draft: false }, + ], + })) + const update = await checkForUpdate('0.50.0-beta.1') + expect(update).toEqual({ version: '0.51.0-beta.1', url: 'https://example.test/beta' }) + }) + + it('picks the highest version, not just the first in the list', async () => { + stubFetch(async () => ({ + ok: true, + json: async () => [ + { tag_name: 'v0.51.0-beta.1', html_url: 'https://example.test/b1', draft: false }, + { tag_name: 'v0.52.0-beta.1', html_url: 'https://example.test/b2', draft: false }, + { tag_name: 'v0.50.0-beta.1', html_url: 'https://example.test/b0', draft: false }, + ], + })) + const update = await checkForUpdate('0.50.0-beta.1') + expect(update?.version).toBe('0.52.0-beta.1') + }) + + it('skips draft releases', async () => { + stubFetch(async () => ({ + ok: true, + json: async () => [ + { tag_name: 'v0.99.0', html_url: 'https://example.test/draft', draft: true }, + { tag_name: 'v0.51.0-beta.1', html_url: 'https://example.test/real', draft: false }, + ], + })) + const update = await checkForUpdate('0.50.0-beta.1') + expect(update?.version).toBe('0.51.0-beta.1') + }) + + it('returns null when already on the latest', async () => { + stubFetch(async () => ({ + ok: true, + json: async () => [{ tag_name: 'v0.41.0', html_url: 'https://example.test/r', draft: false }], + })) + expect(await checkForUpdate('0.41.0')).toBeNull() + }) + + it('returns null on a non-ok response', async () => { + stubFetch(async () => ({ ok: false, json: async () => [] })) + expect(await checkForUpdate('0.41.0')).toBeNull() + }) + + it('swallows network errors', async () => { + stubFetch(async () => { + throw new Error('offline') + }) + expect(await checkForUpdate('0.41.0')).toBeNull() + }) +}) diff --git a/apps/desktop/src/update-check.ts b/apps/desktop/src/update-check.ts new file mode 100644 index 00000000..122aa23f --- /dev/null +++ b/apps/desktop/src/update-check.ts @@ -0,0 +1,90 @@ +/** + * L1 update check — compare the running app version against the latest + * GitHub Release and report when a newer one exists. + * + * Deliberately the *lightest* rung of the update ladder: no auto-download, + * no updater infrastructure, no code signing. (Full auto-update would need + * Squirrel.Mac, which hard-rejects unsigned updates — blocked until we have + * a Developer ID cert.) The caller shows a one-shot "Download" dialog that + * links to the release page; the user installs the new build manually. + * + * Best-effort by design: offline, rate-limited, or malformed responses + * resolve to null and never surface an error — a failed update check must + * never disrupt launch. + * + * Beta-aware: we query the releases *list*, not `/releases/latest` — that + * endpoint excludes prereleases, and OpenAlice ships `-beta.N` builds with + * no formal (non-prerelease) release on the near horizon. Skipping + * prereleases would make the whole check dead weight during the beta period. + * We take the highest-versioned non-draft release, prereleases included. + */ + +const RELEASES_API = 'https://api.github.com/repos/TraderAlice/OpenAlice/releases?per_page=30' +const CHECK_TIMEOUT_MS = 5_000 + +interface GithubRelease { + tag_name?: string + html_url?: string + draft?: boolean +} + +export interface UpdateInfo { + /** Latest release version, leading "v" stripped (e.g. "0.42.0"). */ + version: string + /** The release page to open in the user's browser. */ + url: string +} + +export async function checkForUpdate(currentVersion: string): Promise { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), CHECK_TIMEOUT_MS) + try { + const res = await fetch(RELEASES_API, { + headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'OpenAlice-desktop' }, + signal: ctrl.signal, + }) + if (!res.ok) return null + const body = (await res.json()) as unknown + if (!Array.isArray(body)) return null + // Highest-versioned non-draft release (prereleases included). + let best: UpdateInfo | null = null + for (const rel of body as GithubRelease[]) { + if (rel.draft || !rel.tag_name || !rel.html_url) continue + const version = rel.tag_name.replace(/^v/, '') + if (!best || compareVersions(version, best.version) > 0) { + best = { version, url: rel.html_url } + } + } + if (!best || compareVersions(best.version, currentVersion) <= 0) return null + return best + } catch { + // offline / rate-limited / aborted / malformed — update check is optional + return null + } finally { + clearTimeout(timer) + } +} + +/** + * Semver-ish compare. Returns >0 if `a` is newer than `b`, <0 if older, + * 0 if equal. A release outranks a prerelease at the same core version + * (1.0.0 > 1.0.0-rc.1); prereleases compare lexicographically. Dependency- + * free — the desktop package stays lean. + */ +export function compareVersions(a: string, b: string): number { + const pa = parseVersion(a) + const pb = parseVersion(b) + for (let i = 0; i < 3; i++) { + if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i] + } + if (!pa.pre && pb.pre) return 1 + if (pa.pre && !pb.pre) return -1 + if (pa.pre && pb.pre) return pa.pre < pb.pre ? -1 : pa.pre > pb.pre ? 1 : 0 + return 0 +} + +function parseVersion(v: string): { core: [number, number, number]; pre: string } { + const [core, ...preParts] = v.split('-') + const nums = core.split('.').map((n) => Number.parseInt(n, 10) || 0) + return { core: [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0], pre: preParts.join('-') } +} From 981416ec990784562defa86fd1c4e8734a3d3d6a Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 18 Jun 2026 19:53:18 +0800 Subject: [PATCH 2/4] chore(deps): drop dead @anthropic-ai/claude-agent-sdk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero source imports — the in-process AI loop that used it was deleted in the 0.40 World B collapse; the model loop runs in native workspace CLIs now. The package bundles a full copy of the Claude Code CLI (cli.js ~11M) + per-platform ripgrep binaries (vendor/ ~40M) = 54M of dead weight in the desktop package. @anthropic-ai/sdk (the key-probe), openai, and ai (the tool() primitive) stay — those are live. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 1 - pnpm-lock.yaml | 184 ------------------------------------------------- 2 files changed, 185 deletions(-) diff --git a/package.json b/package.json index 6d9c37de..575dac1a 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "packageManager": "pnpm@10.29.2", "dependencies": { "@alpacahq/alpaca-trade-api": "^3.1.3", - "@anthropic-ai/claude-agent-sdk": "^0.2.72", "@anthropic-ai/sdk": "^0.96.0", "@grammyjs/auto-retry": "^2.0.2", "@hono/node-server": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a550b7d..6efbacc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,9 +30,6 @@ importers: '@alpacahq/alpaca-trade-api': specifier: ^3.1.3 version: 3.1.3 - '@anthropic-ai/claude-agent-sdk': - specifier: ^0.2.72 - version: 0.2.72(zod@4.3.6) '@anthropic-ai/sdk': specifier: ^0.96.0 version: 0.96.0(zod@4.3.6) @@ -392,12 +389,6 @@ packages: resolution: {integrity: sha512-0b0mAvxaxh1JVoX70g0/Pw28QT+MZdDbvpu+xkf3ZZUT8iYpMVacrB0nWA1qKSM0inwzrcDlVn9uSunOL1wmNQ==} engines: {node: '>=16.9', npm: '>=6'} - '@anthropic-ai/claude-agent-sdk@0.2.72': - resolution: {integrity: sha512-GR3QaLRCoWO5DkRknaaCH6zzmUNZ3E6VckEKNE7EO5R7qDBexQe9tDKag257pji2NenTrnBDMxznoZrhNCRTzA==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^4.0.0 - '@anthropic-ai/sdk@0.96.0': resolution: {integrity: sha512-KlCsODtTyb17bLUVCSDC2HtSvAbJf60sEiPEax9dInF+aDF92vS4TZJ5XD7YCQXNb1/5icYaw8Y7wMjPlIV9Zg==} hasBin: true @@ -940,105 +931,6 @@ packages: peerDependencies: hono: ^4 - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - '@inquirer/ansi@2.0.6': resolution: {integrity: sha512-I/INw4sHGlVZ/afZOckpLiDP9SmbMl1g/GCqeHjLw1Afw/0PlRs2tRFgTGWmdI0hoNuWZn3y2iHNmG1vyECyQQ==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} @@ -4521,20 +4413,6 @@ snapshots: - debug - utf-8-validate - '@anthropic-ai/claude-agent-sdk@0.2.72(zod@4.3.6)': - dependencies: - zod: 4.3.6 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - '@anthropic-ai/sdk@0.96.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -4983,68 +4861,6 @@ snapshots: dependencies: hono: 4.12.25 - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - '@inquirer/ansi@2.0.6': {} '@inquirer/confirm@6.1.0(@types/node@22.19.15)': From 94a69c8c3b93fa13fa27c98e657588a7e0a7da79 Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 18 Jun 2026 19:53:34 +0800 Subject: [PATCH 3/4] ci(desktop): build + publish per-platform installers on release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit electron-builder config: include services/uta/dist + workspace templates, dist/*.js (tsup code-splits into hashed chunks — dist/main.js alone misses them), asar:false, mac dmg + win nsis (arch follows the runner). Unsigned — no Developer ID / Windows cert yet. release.yml: new build-desktop matrix (macos-14 arm64 / macos-13 x64 / windows-latest) gated on needs.release.outputs.created so a full Electron build (mac runners bill 10x) only fires when a new version is actually cut. Each runner builds its own platform/arch (pnpm installs only the os/cpu prebuilt — longbridge, node-pty), then softprops appends the artifact to the release the release job already created (release.yml stays the tag owner). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 55 +++++++++++++++++++++++++++++++++++ package.json | 21 +++++++++++-- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78a11b42..2eaa5f18 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,13 @@ jobs: permissions: contents: write packages: write + outputs: + # Whether this run actually cut a new release (version was bumped). The + # desktop build matrix gates on this — a full per-platform Electron build + # is expensive (mac runners bill 10×), so it must NOT run on every master + # push, only when a new tag/release was created. + created: ${{ steps.check.outputs.exists == 'false' }} + tag: ${{ steps.version.outputs.tag }} steps: - uses: actions/checkout@v4 @@ -127,3 +134,51 @@ jobs: npm publish --registry=https://registry.npmjs.org env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Per-platform desktop installers, attached to the release the `release` + # job already created (Option A — electron-builder only *builds*, softprops + # appends the artifact; release.yml stays the single owner of the release + # object + tag). Each runner builds only its own platform/arch, so each + # artifact carries just its matching native binary (longbridge, node-pty) — + # pnpm installs only the os/cpu-matching prebuilt. Unsigned for now: no + # Developer ID / Windows cert, so electron-builder emits unsigned packages + # (Gatekeeper right-click-open / SmartScreen on first run). + build-desktop: + needs: release + if: needs.release.outputs.created == 'true' + strategy: + fail-fast: false + matrix: + os: [macos-14, macos-13, windows-latest] # arm64 dmg / x64 dmg / x64 nsis + runs-on: ${{ matrix.os }} + permissions: + contents: write # upload release assets + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build Alice + UTA + desktop shell + run: pnpm electron:build + + - name: Package installer (unsigned) + # electron-builder picks the target + arch from the host runner (mac + # arm64 / mac x64 / win x64). --publish never: build only, softprops + # does the upload so the release object has one owner. + run: pnpm -F @traderalice/desktop exec electron-builder --projectDir ../.. --publish never + + - name: Attach installer to the release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ needs.release.outputs.tag }} + files: | + dist/electron-app/*.dmg + dist/electron-app/*.exe diff --git a/package.json b/package.json index 575dac1a..b677f499 100644 --- a/package.json +++ b/package.json @@ -66,16 +66,33 @@ "directories": { "output": "dist/electron-app" }, + "electronVersion": "39.8.10", + "asar": false, "files": [ - "dist/main.js", + "dist/*.js", "dist/electron/**", "ui/dist/**", "default/**", "src/workspaces/cli/**", + "src/workspaces/templates/**", + "services/uta/dist/**", + "services/uta/package.json", "package.json" ], "mac": { - "target": "dir" + "target": [ + "dmg" + ], + "category": "public.app-category.finance" + }, + "win": { + "target": [ + "nsis" + ] + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true } }, "pnpm": { From 5c2739b3aed57a0e4c843ff609ce04463c42600c Mon Sep 17 00:00:00 2001 From: Ame Date: Thu, 18 Jun 2026 19:57:58 +0800 Subject: [PATCH 4/4] chore(release): 0.51.0-beta.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First version to exercise the desktop build/publish pipeline — cutting this release triggers build-desktop (mac arm64 + mac x64 + windows) to attach installers to the GitHub Release. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ccad1e7b..98bbb7ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-alice", - "version": "0.51.0-beta.1", + "version": "0.51.0-beta.2", "description": "File-based trading agent engine", "type": "module", "main": "dist/electron/main.js",