app
Google Antigravity
Public Made by Adomby adom
Google Antigravity in Adom Hydrogen: the editor extension, the agy CLI and a dock dashboard that installs both into your container.
← Commit history
Prepare Antigravity 0.1.6 with safe dashboard upgrades and fast readiness
7 files changed
+151−29
README.md+8@@ -24,6 +24,14 @@ adom-antigravity open [--new] # focus the Antigravity panel (or start a n adom-antigravity serve --print-url # the dashboard (what a dock click runs) ``` +## Dashboard updates++The dock checks for package updates on launch. Server readiness uses `/api/health`,+which remains fast while `/api/status` discovers the editor and installed tools.+When a release changes, `serve` replaces only the verified Antigravity process+owning its port; repeated launches reuse the current server. A newer running+version and unrelated port owners are left in place.+ ## Where Antigravity shows up The extension starts Antigravity's own web UI (a local "hub") inside the container and frames it in the editor's side bar. That frame needs the editor to hand out forwarded ports as `http://<port>.localhost:<proxy port>/` (Hydrogen 1.0.224 and later do: Hydrogen's own reverse proxy serves that form with the app's paths untouched and a loopback `Host` upstream, because the hub's page uses absolute asset paths and refuses non-localhost hosts; nothing in code-server or the container image is patched). On an older Hydrogen or on Web Hydrogen, which path-proxy ports as `/proxy/<port>/`, the panel is blank; there the dashboard's **Open Antigravity** turns the dashboard tab into that UI instead. The CLI `agy` works in a terminal tab everywhere.
adom_antigravity.py+81−21@@ -16,12 +16,12 @@ container (the WSL2 Adom-Workspace on Windows, the Lima container on a Mac): adom-antigravity open # focus the Antigravity panel in the editor adom-antigravity serve --print-url # the dock dashboard (what a dock click runs) """-import argparse, glob, json, os, platform, shutil, subprocess, sys, time, urllib.request, urllib.error, urllib.parse+import argparse, fcntl, glob, json, os, platform, shutil, signal, subprocess, sys, time, urllib.request, urllib.error, urllib.parse from pathlib import Path from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer ROOT = Path(__file__).resolve().parent-VERSION = '0.1.5'+VERSION = '0.1.6' EXT_ID = 'Google.google-antigravity' CLI_INSTALL = 'https://antigravity.google/cli/install.sh' EXT_DIRS = [Path.home() / '.local/share/code-server/extensions', Path.home() / '.vscode-server/extensions']@@ -285,6 +285,81 @@ def server_identity(url): return json.load(urllib.request.urlopen(url + 'api/status', timeout=20)) +def retire_server(port):+ """Retire only our same-user Python server owning this loopback listener."""+ inodes = set()+ for line in Path('/proc/net/tcp').read_text().splitlines()[1:]:+ fields = line.split()+ if fields[1] == f'0100007F:{port:04X}' and fields[3] == '0A':+ inodes.add(fields[9])+ owners = []+ for proc in Path('/proc').iterdir():+ if not proc.name.isdigit():+ continue+ try:+ if proc.stat().st_uid != os.getuid():+ continue+ links = {os.readlink(fd) for fd in (proc / 'fd').iterdir()}+ if not any(f'socket:[{inode}]' in links for inode in inodes):+ continue+ argv = (proc / 'cmdline').read_bytes().rstrip(b'\0').split(b'\0')+ if (len(argv) != 5 or Path(os.fsdecode(argv[1])).name != Path(__file__).name+ or argv[2:] != [b'_serve', b'--port', str(port).encode()]):+ raise RuntimeError('Refusing to stop an unrecognized port owner')+ owners.append(int(proc.name))+ except (FileNotFoundError, PermissionError, ProcessLookupError):+ continue+ if len(owners) != 1:+ raise RuntimeError('Cannot identify exactly one Antigravity server owning the port')+ pid = owners[0]+ # A pidfd binds the signal to this process, even if Linux later reuses its PID.+ with os.fdopen(os.pidfd_open(pid), 'rb') as process_handle:+ argv = Path(f'/proc/{pid}/cmdline').read_bytes().rstrip(b'\0').split(b'\0')+ if (len(argv) != 5 or Path(os.fsdecode(argv[1])).name != Path(__file__).name+ or argv[2:] != [b'_serve', b'--port', str(port).encode()]):+ raise RuntimeError('Port owner changed before retirement')+ signal.pidfd_send_signal(process_handle.fileno(), signal.SIGTERM)+ for _ in range(50):+ try:+ alive = bool(Path(f'/proc/{pid}/cmdline').read_bytes())+ except FileNotFoundError:+ alive = False+ if not alive:+ return+ time.sleep(.1)+ raise RuntimeError('Previous dashboard did not stop; refusing to force-kill it')+++def ensure_server(port, log):+ url = f'http://127.0.0.1:{port}/'+ try:+ running = server_identity(url)+ except (OSError, ValueError):+ running = None+ if running:+ if running.get('app') != 'adom/antigravity':+ raise RuntimeError('Port is occupied by a different service')+ if running.get('version') == VERSION:+ return url+ old_version = tuple(int(v) for v in running['version'].split('.'))+ if old_version > tuple(int(v) for v in VERSION.split('.')):+ raise RuntimeError('Refusing to replace a newer Antigravity dashboard')+ retire_server(port)+ with (log / 'dashboard.log').open('ab') as out:+ process = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '_serve', '--port', str(port)], stdin=subprocess.DEVNULL, stdout=out, stderr=out, start_new_session=True)+ for _ in range(50):+ if process.poll() is not None:+ raise RuntimeError('Dashboard process exited; inspect ' + str(log / 'dashboard.log'))+ try:+ ready = server_identity(url)+ if ready.get('app') == 'adom/antigravity' and ready.get('version') == VERSION:+ return url+ except (OSError, ValueError):+ pass+ time.sleep(.1)+ raise RuntimeError('Dashboard failed to start; inspect ' + str(log / 'dashboard.log'))++ def main(): p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument('command', choices=['status', 'context', 'install-extension', 'install-cli', 'open', 'serve', '_serve'])@@ -300,25 +375,10 @@ def main(): r = open_panel(a.new); print(json.dumps(r, indent=2)); sys.exit(0 if r.get('ok') else 1) if a.command == '_serve': ThreadingHTTPServer(('127.0.0.1', a.port), Handler).serve_forever(); return- url = f'http://127.0.0.1:{a.port}/'- try:- r = server_identity(url); assert r.get('app') == 'adom/antigravity' and r['version'] == VERSION- except Exception:- log = Path.home() / '.local/state/adom/antigravity'; log.mkdir(parents=True, exist_ok=True)- with (log / 'dashboard.log').open('ab') as out:- process = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '_serve', '--port', str(a.port)], stdin=subprocess.DEVNULL, stdout=out, stderr=out, start_new_session=True)- for _ in range(50):- if process.poll() is not None:- raise RuntimeError('Dashboard process exited; inspect ' + str(log / 'dashboard.log'))- try:- ready = server_identity(url)- if ready.get('app') != 'adom/antigravity' or ready.get('version') != VERSION:- raise RuntimeError('Port is occupied by a different service')- break- except Exception:- time.sleep(.1)- else:- raise RuntimeError('Dashboard failed to start; inspect ' + str(log / 'dashboard.log'))+ log = Path.home() / '.local/state/adom/antigravity'; log.mkdir(parents=True, exist_ok=True)+ with (log / f'launch-{a.port}.lock').open('a') as lock:+ fcntl.flock(lock, fcntl.LOCK_EX)+ url = ensure_server(a.port, log) print(url, flush=True)
dockbar.json+1−2@@ -8,8 +8,7 @@ "steps": [ { "label": "Installing the Antigravity dashboard",- "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg install adom/antigravity'",- "skip_if": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; command -v adom-antigravity'",+ "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; adom-wiki pkg update antigravity || adom-wiki pkg install adom/antigravity'", "timeout_seconds": 180 }, {
docs/DASHBOARD.md+2−2@@ -1,9 +1,9 @@ # Antigravity dock dashboard -`dockbar.json` makes this wiki app launchable from Hydrogen's Dock Bar. Its install step resolves the app package (`adom-wiki pkg install adom/antigravity`); its launch step runs `adom-antigravity serve --print-url`, which starts (or reuses) the dashboard on 127.0.0.1:47867 inside the container and prints the URL the dock navigates to.+`dockbar.json` makes this wiki app launchable from Hydrogen's Dock Bar. Its install step checks for package updates with an install fallback; its launch step runs `adom-antigravity serve --print-url`, which starts (or reuses) the dashboard on 127.0.0.1:47867 inside the container and prints the URL the dock navigates to. Launches are serialized per port. An older dashboard is retired only after checking its application identity, loopback socket ownership, user, and process arguments; a newer version is never downgraded. The dashboard follows the dock-dashboard contract: house header, the shared `AdomNewbiePanel` say strip, an LED pill row (Extension, CLI, Editor, Runtime), then three action cards: install the Google Antigravity VS Code extension into code-server, install the `agy` CLI into ~/.local/bin, open the Antigravity panel or a terminal running `agy`. -Server routes: `GET /api/status` (`/state`), `GET /events`, `POST /api/install-extension`, `POST /api/install-cli`, `POST /api/open {new?}`, `POST /api/reload-editor`, `POST /api/tui`, plus the `/ai-threads/run-prompt` proxy the say strip uses. The browser exposes `adomAntigravity.state / refresh / inspect / selfCheck`.+Server routes: `GET /api/health` (fast server readiness with version and PID), `GET /api/status` (`/state`, full installation diagnostics), `GET /events`, `POST /api/install-extension`, `POST /api/install-cli`, `POST /api/open {new?}`, `POST /api/reload-editor`, `POST /api/tui`, plus the `/ai-threads/run-prompt` proxy the say strip uses. The browser exposes `adomAntigravity.state / refresh / inspect / selfCheck`. Everything runs inside the container (WSL2 on Windows, Lima on macOS). Nothing is installed on the host OS.
package.json+1−1@@ -4,7 +4,7 @@ "type": "app", "title": "Google Antigravity", "brief": "Google Antigravity in Adom Hydrogen: the editor extension, the agy CLI and a dock dashboard that installs both into your container.",- "version": "0.1.5",+ "version": "0.1.6", "description": "Try Google Antigravity inside your Adom workspace. The dock dashboard installs the Google Antigravity VS Code extension into Hydrogen's editor and the agy command line into your WSL2 or Lima container, opens the panel, and reports what is installed and signed in.", "org": "adom", "needs_sudo": false,
page.json+2−2@@ -4,7 +4,7 @@ "type": "app", "title": "Google Antigravity", "brief": "Google Antigravity in Adom Hydrogen: the editor extension, the agy CLI and a dock dashboard that installs both into your container.",- "version": "0.1.5",+ "version": "0.1.6", "hero": { "type": "image", "path": "screenshots/hero.png"@@ -83,4 +83,4 @@ "README.md", "dockbar.json" ]-}+}
test_startup.py+56−1@@ -33,6 +33,12 @@ class LegacyHandler(BaseHTTPRequestHandler): class StartupTests(unittest.TestCase):+ @staticmethod+ def free_port():+ with socket.socket() as sock:+ sock.bind(('127.0.0.1', 0))+ return sock.getsockname()[1]+ def serve_fixture(self, handler): server = ThreadingHTTPServer(('127.0.0.1', 0), handler) thread = threading.Thread(target=server.serve_forever, daemon=True)@@ -52,7 +58,7 @@ class StartupTests(unittest.TestCase): result = self.launch(port, home) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout.strip(), f'http://127.0.0.1:{port}/')- self.assertFalse((Path(home) / '.local/state/adom/antigravity').exists())+ self.assertFalse((Path(home) / '.local/state/adom/antigravity/dashboard.log').exists()) def test_health_does_not_run_external_diagnostics(self): port = self.serve_fixture(app.Handler)@@ -90,6 +96,55 @@ class StartupTests(unittest.TestCase): self.assertNotEqual(result.returncode, 0) self.assertEqual(app.server_identity(f'http://127.0.0.1:{port}/')['app'], 'another-app') + @unittest.skipUnless(os.environ.get('ANTIGRAVITY_PREDECESSOR'), 'requires a registry predecessor')+ def test_registry_predecessor_upgrade_is_scoped_to_port(self):+ self.check_upgrade(os.environ['ANTIGRAVITY_PREDECESSOR'])++ def test_upgrade_of_server_with_current_lifecycle(self):+ with tempfile.TemporaryDirectory() as directory:+ predecessor = Path(directory) / Path(app.__file__).name+ predecessor.write_text(Path(app.__file__).read_text().replace(+ "VERSION = '0.1.6'", "VERSION = '0.1.5'"))+ self.check_upgrade(str(predecessor))++ def check_upgrade(self, predecessor):+ with tempfile.TemporaryDirectory() as home:+ ports = [self.free_port(), self.free_port()]+ old = [subprocess.Popen(+ [sys.executable, predecessor, '_serve', '--port', str(port)],+ env={**os.environ, 'HOME': home}, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)+ for port in ports]+ current_pid = None+ try:+ for index, port in enumerate(ports):+ for _ in range(50):+ self.assertIsNone(old[index].poll(), 'Predecessor process exited')+ try:+ state = app.server_identity(f'http://127.0.0.1:{port}/')+ self.assertEqual(state['version'], '0.1.5')+ break+ except OSError:+ time.sleep(.1)+ else:+ self.fail('Predecessor never started')+ result = self.launch(ports[0], home)+ self.assertEqual(result.returncode, 0, result.stderr)+ state = app.server_identity(result.stdout.strip())+ current_pid = state['pid']+ self.assertEqual(state['version'], app.VERSION)+ old[0].wait(timeout=5)+ self.assertIsNone(old[1].poll(), 'Another port was affected')+ again = self.launch(ports[0], home)+ self.assertEqual(again.returncode, 0, again.stderr)+ self.assertEqual(app.server_identity(again.stdout.strip())['pid'], current_pid)+ finally:+ if current_pid:+ os.kill(current_pid, signal.SIGTERM)+ for process in old:+ if process.poll() is None:+ process.terminate()+ process.wait(timeout=5)+ if __name__ == '__main__': unittest.main()