From 77c28e4cd72efe50c8cebc790db22107cd050d76 Mon Sep 17 00:00:00 2001 From: Elijah Shaw-Rutschman Date: Mon, 28 Sep 2026 20:00:18 -0500 Subject: [PATCH 01/39] feat(antigravity): add Antigravity Desktop session migration support - add bidirectional format adapter for Antigravity Desktop (antigravity-desktop) - support reading and writing SQLite conversation databases, conversation_summaries.db, and agyhub_summaries_proto.pb - handle both 19-column and 21-column schemas (with raw_summary, group_id) in conversation_summaries.db - support up to 512 MB native sessions in Antigravity CLI and Desktop - auto-register projects in app_storage.json and ~/.gemini/config/projects/ when installing desktop sessions - document the desktop storage architecture in docs/antigravity-desktop-interoperability.md - add comprehensive unit tests and native language server verification --- CHANGELOG.md | 8 + docs/antigravity-desktop-interoperability.md | 312 ++++++++++++ docs/format-compatibility.md | 1 + src/session_migrate/catalog.py | 59 ++- src/session_migrate/cli.py | 17 + src/session_migrate/conversion.py | 113 +++++ src/session_migrate/discovery.py | 2 +- src/session_migrate/formats/__init__.py | 2 + src/session_migrate/formats/antigravity.py | 135 +++-- .../formats/antigravity_desktop.py | 474 ++++++++++++++++++ src/session_migrate/inspection.py | 7 + src/session_migrate/model.py | 2 + tests/test_antigravity_desktop_format.py | 337 +++++++++++++ tests/test_antigravity_desktop_native.py | 183 +++++++ tests/test_target_integration.py | 2 + uv.lock | 74 +-- 16 files changed, 1654 insertions(+), 74 deletions(-) create mode 100644 docs/antigravity-desktop-interoperability.md create mode 100644 src/session_migrate/formats/antigravity_desktop.py create mode 100644 tests/test_antigravity_desktop_format.py create mode 100644 tests/test_antigravity_desktop_native.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d75a84..809c1b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ here. Native format compatibility is documented separately in ## Unreleased +- Add first-class support for Antigravity Desktop (macOS/Electron) 2.17.0/2.18.1 + with bidirectional conversion, SQLite schema adaptation, Hub Summaries + Protobuf cache synchronization (`agyhub_summaries_proto.pb`), project-scoped + sidebar discovery, and verified native Go language server integration. +- Document the clean-room Antigravity Desktop storage architecture, Protobuf + wire formats, Connect-RPC interfaces, and collision invariants in + `docs/antigravity-desktop-interoperability.md`. + ## 0.11.0 - 2026-09-11 - Read root Codex rollouts written with `history_mode: "paginated"` by using diff --git a/docs/antigravity-desktop-interoperability.md b/docs/antigravity-desktop-interoperability.md new file mode 100644 index 0000000..1d72fd6 --- /dev/null +++ b/docs/antigravity-desktop-interoperability.md @@ -0,0 +1,312 @@ +# Antigravity Desktop Session Interoperability Specification + +This document provides a clean-room technical specification of the session storage architecture, SQLite schemas, Protobuf wire formats, Connect-RPC interfaces, and migration mechanics for **Antigravity Desktop**, complementing the research published in [antigravity-session-interoperability](https://github.com/xhluca/antigravity-session-interoperability). + +--- + +## 1. Pinned Host Environment & Binary Artifacts + +All findings were empirically derived from isolated test environments against the official macOS distribution of Antigravity Desktop: + +| Component | Pinned Specification | +|---|---| +| **App Bundle** | `/Applications/Antigravity.app` | +| **App Version** | `2.17.0` (`CFBundleShortVersionString`), `2.18.1` (`package.json`) | +| **Language Server Binary** | `/Applications/Antigravity.app/Contents/Resources/bin/language_server` | +| **Language Server Size** | `148,990,608` bytes | +| **Language Server SHA-256** | `300ee20f3108a511be1149602b91ba84cbbdcc42213067151e255584129be30f` | +| **Language Server Build CL** | `CL 989808170` | +| **Platform Target** | `darwin-arm64` (Apple Silicon) | +| **Conversation Storage** | `~/.gemini/antigravity/conversations/.db` | +| **Summary & Index Store** | `~/.gemini/antigravity/conversation_summaries.db` (`PRAGMA user_version = 3`) | + +No vendor binaries, proprietary descriptors, or user conversation logs are reproduced in this repository; all schema definitions were established through clean-room differential probing and synthetic round-trip verification. + +--- + +## 2. Desktop vs CLI Architectural Differences + +While both Antigravity CLI (`agy`) and Antigravity Desktop (`Antigravity.app`) share an underlying SQLite + Protobuf storage engine, their database schemas and wire properties exhibit distinct constraints: + +| Property | Antigravity CLI (`antigravity`) | Antigravity Desktop (`antigravity-desktop`) | Rationale / Mechanism | +|---|---|---|---| +| **App Data Subdirectory** | `~/.gemini/antigravity-cli` | `~/.gemini/antigravity` | Passed via `-app_data_dir=antigravity` flag to `language_server` | +| **`trajectory_meta.source`** | `17` (`CLI`) | `1` (`IDE` / `Hub`) | Source identifier enum in metadata table | +| **`trajectory_metadata_blob` Project ID** | `"default-cli-project"` | `"outside-of-project"` (or workspace UUID) | Project boundary discriminator (Protobuf field 18) | +| **`conversation_summaries.source`** | `"antigravity-cli"` | `""` (empty string) | Desktop UI filter expects empty source string | +| **`conversation_summaries.app_data_dir`** | `"antigravity-cli"` | `"antigravity"` | Tells the IDE process where to resolve conversation DBs | +| **`conversation_summaries.status`** | `""` (empty string) | `"CASCADE_RUN_STATUS_IDLE"` | Desktop UI cascade lifecycle state | +| **`conversation_summaries.project_id`** | `"default-cli-project"` | `"outside-of-project"` | Project grouping in desktop sidebar | +| **Summary Schema Columns** | 19 or 21 columns | 21 columns (includes `raw_summary` BLOB, `group_id` TEXT) | Extended schema in `user_version = 3` | +| **Subagent Trajectory Roots** | Root ID == Conversation ID | Root ID == Root Cascade ID (nesting depth $\ge 1$) | Subagent conversations reference their parent root | +| **Extended Step Statuses** | `3` (`DONE`), `7` (`ERROR`) | `3` (`DONE`), `5` (`CLEARED`), `7` (`ERROR`) | Status 5 occurs when user clears a conversation step | + +--- + +## 3. SQLite Database Layout + +### 3.1 Conversation Database (`conversations/.db`) + +Each conversation is stored as an independent, single-file SQLite 3 database with WAL mode enabled. It contains 7 tables: + +```sql +CREATE TABLE trajectory_meta ( + trajectory_id TEXT, + conversation_id TEXT, + trajectory_type INTEGER, + source INTEGER +); + +CREATE TABLE steps ( + idx INTEGER PRIMARY KEY, + step_type INTEGER NOT NULL DEFAULT 0, + status INTEGER NOT NULL DEFAULT 0, + has_subtrajectory NUMERIC NOT NULL DEFAULT false, + metadata BLOB, + error_details BLOB, + permissions BLOB, + task_details BLOB, + render_info BLOB, + step_payload BLOB, + step_format INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE gen_metadata ( + idx INTEGER PRIMARY KEY, + data BLOB, + size INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE executor_metadata ( + idx INTEGER PRIMARY KEY, + data BLOB +); + +CREATE TABLE parent_references ( + idx INTEGER PRIMARY KEY, + data BLOB +); + +CREATE TABLE trajectory_metadata_blob ( + id TEXT PRIMARY KEY DEFAULT "main", + data BLOB +); + +CREATE TABLE battle_mode_infos ( + idx INTEGER PRIMARY KEY, + data BLOB +); +``` + +#### Table Constraints & Validation Rules: +1. `trajectory_meta`: Exactly 1 row. + - `trajectory_type`: `4` (Cascade). + - `source`: `1` for Desktop (vs `17` for CLI). +2. `steps`: `idx` must be a contiguous 0-indexed sequence (`0, 1, 2, ...`). + - The columns `step_type` and `status` must strictly match the inner Protobuf wire fields 1 and 4 of `step_payload`. +3. `trajectory_metadata_blob`: Exactly 1 row with `id = 'main'`. + +### 3.2 Summary Catalog (`conversation_summaries.db`) + +Antigravity Desktop maintains a shared index database at `~/.gemini/antigravity/conversation_summaries.db`: + +```sql +CREATE TABLE conversation_summaries ( + conversation_id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT "", + preview TEXT NOT NULL DEFAULT "", + step_count INTEGER NOT NULL DEFAULT 0, + last_modified_time DATETIME NOT NULL, + workspace_uris TEXT NOT NULL, + status TEXT NOT NULL DEFAULT "", + source TEXT NOT NULL DEFAULT "", + project_id TEXT NOT NULL DEFAULT "", + agent_name TEXT NOT NULL DEFAULT "", + parent_conversation_id TEXT NOT NULL DEFAULT "", + nesting_depth INTEGER NOT NULL DEFAULT 0, + battle_id TEXT NOT NULL DEFAULT "", + winning_conversation_id TEXT NOT NULL DEFAULT "", + not_fully_idle NUMERIC NOT NULL DEFAULT false, + killed NUMERIC NOT NULL DEFAULT false, + last_user_input_time DATETIME NOT NULL, + last_user_input_step_index INTEGER NOT NULL DEFAULT -1, + app_data_dir TEXT NOT NULL DEFAULT "", + raw_summary BLOB, + group_id TEXT NOT NULL DEFAULT "" +); + +CREATE INDEX idx_conversation_summaries_last_user_input_time + ON conversation_summaries(last_user_input_time); +CREATE INDEX idx_conversation_summaries_last_modified_time + ON conversation_summaries(last_modified_time); +``` + +--- + +## 4. Protobuf Wire Encoding + +Antigravity uses Protobuf 3 wire serialization inside BLOB columns. `session-migrate` implements a bounded zero-dependency wire decoder and encoder. + +### 4.1 Step Envelope (`step_payload`) + +| Field Number | Wire Type | Type / Description | Meaning | +|---|---|---|---| +| `1` | Varint | `uint32` | Step Type Enum: `14` (User Input), `15` (Planner Response), `132` (Generic Tool Result) | +| `4` | Varint | `uint32` | Step Status Enum: `3` (Done), `5` (Cleared), `7` (Error) | +| `19` | Length-delimited | Sub-message | User input payload (when type == 14) | +| `20` | Length-delimited | Sub-message | Planner response payload (when type == 15) | +| `140` | Length-delimited | Sub-message | Generic tool payload (when type == 132) | + +### 4.2 User Input Payload (Field 19) + +| Field Number | Wire Type | Meaning | +|---|---|---| +| `2` | Length-delimited | Raw prompt / user message text string | + +### 4.3 Planner Response Payload (Field 20) + +| Field Number | Wire Type | Meaning | +|---|---|---| +| `1` | Length-delimited | Assistant message response text string | +| `6` | Length-delimited | Message UUID | +| `7` | Length-delimited | Repeated sub-message: Tool Call | + +#### Tool Call Sub-message (Field 7): +| Field Number | Wire Type | Meaning | +|---|---|---| +| `1` | Length-delimited | Call ID UUID string | +| `2` | Length-delimited | Tool function name (e.g. `view_file`, `run_command`) | +| `3` | Length-delimited | JSON-encoded function arguments | + +### 4.4 Trajectory Metadata Blob (`trajectory_metadata_blob.data`) + +This blob contains a serialized `exa.cortex_pb.CortexTrajectoryMetadata` message: + +| Field Number | Wire Type | Field Name | Meaning | +|---|---|---|---| +| `1` | Length-delimited | `workspaces` | Repeated `CortexWorkspaceMetadata` sub-messages | +| `2` | Length-delimited | `created_at` | Sub-message: Creation timestamp (Field 1: seconds varint, Field 2: nanos varint) | +| `3` | Length-delimited | `initialization_state_id` | Initialization state UUID | +| `5` | Length-delimited | `parent_conversation_id` | Parent conversation UUID (for subagents) | +| `6` | Length-delimited | `root_conversation_id` | Root cascade UUID string | +| `7` | Length-delimited | `workspace_uris` | Repeated workspace file URIs (`file:///...`) | +| `17` | Varint | `nesting_depth` | Subagent nesting depth (`0` for root conversation) | +| `18` | Length-delimited | `project_id` | Project UUID or `"default-cli-project"` | + +--- + +## 5. Hub Summaries & Project Scoping Architecture + +### 5.1 Project Scoping and Sidebar Rendering + +Unlike the CLI (which defaults all conversations to `"default-cli-project"`), Antigravity Desktop organizes all conversations strictly under **Projects**: + +1. **Active Projects List**: Tracked in `~/Library/Application Support/Antigravity/app_storage.json` under the key `projectsOrder` (JSON array of project IDs). +2. **Project Definitions**: Stored in `~/.gemini/config/projects/.json`. Each project defines its `id`, `name`, and `projectResources.resources` (containing `folderUri` or `gitFolder.folderUri`). +3. **Sidebar Rendering Invariant**: The desktop sidebar only renders sections for project IDs present in `projectsOrder`. Conversations with `project_id = "outside-of-project"` have no header in the desktop UI and are excluded from sidebar listings and project-scoped search. +4. **Resolution Strategy**: + - When importing a session, check if the session's workspace `cwd` matches an existing project definition in `~/.gemini/config/projects/*.json`. + - If a matching project is found, assign its `id`. + - If no project matches, assign `project_id = "default-cli-project"` (which corresponds to `"CLI Project"`, present in `projectsOrder`). + - Alternatively, a new project definition can be written to `~/.gemini/config/projects/.json` and added to `projectsOrder` in `app_storage.json`. + +### 5.2 Summary Stores (`conversation_summaries.db` & `agyhub_summaries_proto.pb`) + +Antigravity Desktop maintains two synchronized summary stores: + +1. **SQLite Summary Table** (`conversation_summaries.db`): + - Table `conversation_summaries` (21 columns in schema v3). + - Column `raw_summary` (BLOB): Contains the binary protobuf-encoded `CascadeTrajectorySummary`. +2. **Hub Summaries Protobuf File** (`agyhub_summaries_proto.pb`): + - A single binary file storing `map`. + - Wire format: Repeated field `1` containing sub-message: + - Field `1` (string): `conversation_id` + - Field `2` (bytes): Serialized `CascadeTrajectorySummary` + - Serves as the high-speed in-memory cache for the Electron sidebar and quick-picker. + +### 5.3 `CascadeTrajectorySummary` Wire Specification + +```protobuf +message CascadeTrajectorySummary { + string summary = 1; // Conversation title + uint32 step_count = 2; // Number of active steps + google.protobuf.Timestamp last_modified_time = 3;// Last modification timestamp + string trajectory_id = 4; // Trajectory UUID + CascadeRunStatus status = 5; // 1 = CASCADE_RUN_STATUS_IDLE + google.protobuf.Timestamp created_time = 7; // Creation timestamp + repeated CortexWorkspaceMetadata workspaces = 9; // Workspace folder URIs + google.protobuf.Timestamp last_user_input_time = 10; + ConversationAnnotations annotations = 15; // Title and metadata annotations + uint32 last_user_input_step_index = 16; // Index of last user message + CortexTrajectoryMetadata trajectory_metadata = 17;// Full trajectory metadata (includes project_id) + CortexTrajectorySource source = 22; // 4 = IDE/Desktop Cascade +} +``` + +--- + +## 6. Native Language Server Connect-RPC Protocol + +Antigravity Desktop's Electron frontend communicates with the background Go native process (`language_server`) via Connect-RPC over HTTPS. + +### 6.1 Invocation Arguments + +```bash +/Applications/Antigravity.app/Contents/Resources/bin/language_server \ + --standalone \ + --override_ide_name antigravity \ + --subclient_type hub \ + --override_ide_version 2.18.1 \ + --override_user_agent_name antigravity \ + --https_server_port 0 \ + --csrf_token \ + -gemini_dir ~/.gemini \ + -app_data_dir antigravity +``` + +- When `--https_server_port 0` is passed, the server binds to a random loopback port and logs: + `Language server listening on random port at for HTTPS (gRPC)` +- The server expects `stdin` to be closed immediately upon spawn (unless in WSL liveness mode). + +### 6.2 Trajectory Inspection RPC + +- **Method**: `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/GetCascadeTrajectorySteps` +- **Headers**: + - `Content-Type: application/json` + - `X-Codeium-Csrf-Token: ` +- **Request Body**: + ```json + {"cascadeId": ""} + ``` +- **Response Structure**: + ```json + { + "steps": [ + { + "type": "CORTEX_STEP_TYPE_USER_INPUT", + "status": "CORTEX_STEP_STATUS_DONE", + "userInput": {"userResponse": "..."} + }, + { + "type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE", + "status": "CORTEX_STEP_STATUS_DONE", + "plannerResponse": { + "response": "...", + "messageId": "..." + } + } + ] + } + ``` + +--- + +## 7. Migration Mechanics & Collision Guarantees + +In `session-migrate`, transferring sessions to Antigravity Desktop adheres to strict invariants: + +1. **Source Preservation**: The source files in `~/.gemini/antigravity-cli/` are opened read-only and never modified, truncated, or removed. +2. **Fresh UUID Generation**: By default, `transfer` and `import` generate fresh non-colliding UUID4s for both the conversation ID and trajectory ID, eliminating any possibility of overwriting existing desktop conversations. +3. **Collision Detection**: If an explicit `--session-id` is specified that already exists in either `conversations/.db` or `conversation_summaries.db`, `session-migrate` immediately halts with a collision error. +4. **Atomic Installation**: Conversation databases are created in temporary staging directories with `0o600` permissions and moved into place via atomic file operations. Summary rows are inserted with collision checks using standard SQLite transaction semantics. +5. **Dry-Run Capability**: Running with `--dry-run` performs full validation, model mapping, and schema formatting without touching the destination filesystem. diff --git a/docs/format-compatibility.md b/docs/format-compatibility.md index 25e070d..b8ba54d 100644 --- a/docs/format-compatibility.md +++ b/docs/format-compatibility.md @@ -23,6 +23,7 @@ to separately installed host binaries: | OpenCode source and target | `1.17.20` | | GitHub Copilot CLI source and target | `1.0.70` | | Antigravity CLI source and target | `1.1.16` | +| Antigravity Desktop source and target | `2.17.0` / `2.18.1` | | Cursor Agent experimental text adapter | `2026.03.20-44cb435` | | Mistral Vibe source and target | `2.24.3` | | Muse Code source and target | `0.2.1 (0.2.1-R1215.1)` | diff --git a/src/session_migrate/catalog.py b/src/session_migrate/catalog.py index 2c34fe1..d360940 100644 --- a/src/session_migrate/catalog.py +++ b/src/session_migrate/catalog.py @@ -25,6 +25,7 @@ from session_migrate.errors import JsonlError, SessionMigrateError from session_migrate.formats import ( antigravity, + antigravity_desktop, codex, devin, hermes, @@ -237,6 +238,11 @@ def auto_roots( user_home / ".gemini" / "antigravity-cli", "default", ), + ( + AgentFormat.ANTIGRAVITY_DESKTOP, + user_home / ".gemini" / "antigravity", + "default", + ), ( AgentFormat.CURSOR, cursor_home, @@ -336,6 +342,11 @@ def auto_roots( antigravity_home = directory / ".gemini" / "antigravity-cli" if (antigravity_home / "conversations").is_dir(): candidates.append((AgentFormat.ANTIGRAVITY, antigravity_home, "project")) + antigravity_desktop_home = directory / ".gemini" / "antigravity" + if (antigravity_desktop_home / "conversations").is_dir(): + candidates.append( + (AgentFormat.ANTIGRAVITY_DESKTOP, antigravity_desktop_home, "project") + ) cursor_home = directory / ".cursor" if (cursor_home / "chats").is_dir(): candidates.append((AgentFormat.CURSOR, cursor_home, "project")) @@ -887,6 +898,7 @@ def add_root( AgentFormat.OPENCODE, AgentFormat.COPILOT, AgentFormat.ANTIGRAVITY, + AgentFormat.ANTIGRAVITY_DESKTOP, AgentFormat.CURSOR, AgentFormat.VIBE, AgentFormat.MUSE, @@ -946,6 +958,7 @@ def refresh( opencode_roots: Sequence[Path] = (), copilot_roots: Sequence[Path] = (), antigravity_roots: Sequence[Path] = (), + antigravity_desktop_roots: Sequence[Path] = (), cursor_roots: Sequence[Path] = (), vibe_roots: Sequence[Path] = (), muse_roots: Sequence[Path] = (), @@ -981,6 +994,8 @@ def refresh( self.add_root(AgentFormat.COPILOT, path) for path in antigravity_roots: self.add_root(AgentFormat.ANTIGRAVITY, path) + for path in antigravity_desktop_roots: + self.add_root(AgentFormat.ANTIGRAVITY_DESKTOP, path) for path in cursor_roots: self.add_root(AgentFormat.CURSOR, path) for path in vibe_roots: @@ -1110,7 +1125,10 @@ def _refresh_root(self, root: CatalogRoot, *, validate: bool) -> dict[str, int]: metadata = _codex_native_metadata(root_path) elif root.format == AgentFormat.COPILOT.value: metadata = _copilot_native_metadata(root_path) - elif root.format == AgentFormat.ANTIGRAVITY.value: + elif root.format in { + AgentFormat.ANTIGRAVITY.value, + AgentFormat.ANTIGRAVITY_DESKTOP.value, + }: metadata = _antigravity_native_metadata(root_path) else: metadata = _NativeMetadata({}, {}, {}, False) @@ -1156,6 +1174,7 @@ def _refresh_root(self, root: CatalogRoot, *, validate: bool) -> dict[str, int]: continue elif root.format in { AgentFormat.ANTIGRAVITY.value, + AgentFormat.ANTIGRAVITY_DESKTOP.value, AgentFormat.CURSOR.value, }: try: @@ -1922,7 +1941,7 @@ def _candidate_files(agent_format: AgentFormat, root: Path) -> Iterable[Path]: ] yield from sorted(candidates) return - if agent_format == AgentFormat.ANTIGRAVITY: + if agent_format in {AgentFormat.ANTIGRAVITY, AgentFormat.ANTIGRAVITY_DESKTOP}: conversations = root / "conversations" if not conversations.is_dir(): return @@ -2039,6 +2058,8 @@ def _raise_walk_error(error: OSError) -> None: def _scan_file(path: Path, agent_format: AgentFormat, root: Path) -> _Scan: if agent_format == AgentFormat.ANTIGRAVITY: return _scan_antigravity_file(path, root) + if agent_format == AgentFormat.ANTIGRAVITY_DESKTOP: + return _scan_antigravity_desktop_file(path, root) if agent_format == AgentFormat.CURSOR: return _scan_cursor_file(path, root) if agent_format == AgentFormat.VIBE: @@ -2450,6 +2471,7 @@ def _base_scan( lifecycle = "project" elif agent_format in { AgentFormat.ANTIGRAVITY, + AgentFormat.ANTIGRAVITY_DESKTOP, AgentFormat.CURSOR, AgentFormat.VIBE, AgentFormat.MUSE, @@ -2513,6 +2535,39 @@ def _scan_antigravity_file(path: Path, root: Path) -> _Scan: ) +def _scan_antigravity_desktop_file(path: Path, root: Path) -> _Scan: + base = _base_scan(path, AgentFormat.ANTIGRAVITY_DESKTOP, root, "candidate", None) + try: + parsed = antigravity_desktop.parse(path) + except SessionMigrateError: + return _replace_scan_status(base, "corrupt", "invalid_antigravity_database") + labels: list[_Label] = [] + if parsed.title: + title = _bounded(parsed.title, LABEL_LIMIT) + if title: + labels.append(_Label("native_title", title, 0, 110)) + has_conversation = any( + event.kind.value in {"message", "tool_call", "tool_result"} for event in parsed.events + ) + status = "candidate" if has_conversation else "corrupt" + reason = None if has_conversation else "no_conversation_records" + return _Scan( + session_id=parsed.session_id, + filename_session_id=base.filename_session_id, + cwd=_bounded(str(parsed.cwd), PATH_VALUE_LIMIT) if parsed.cwd else None, + started_at=parsed.started_at, + cli_version=parsed.cli_version, + history_mode=None, + kind=base.kind, + lifecycle=base.lifecycle, + parent_session_id=None, + status=status, + reason=reason, + records=parsed.raw_record_count, + labels=tuple(labels), + ) + + def _scan_cursor_file(path: Path, root: Path) -> _Scan: base = _base_scan(path, AgentFormat.CURSOR, root, "candidate", None) try: diff --git a/src/session_migrate/cli.py b/src/session_migrate/cli.py index b2cc420..64e7721 100644 --- a/src/session_migrate/cli.py +++ b/src/session_migrate/cli.py @@ -20,6 +20,7 @@ default_target_home, ensure_target_paths_available, install_antigravity_artifact, + install_antigravity_desktop_artifact, install_copilot_artifact, install_cursor_artifact, install_devin_artifact, @@ -239,6 +240,13 @@ def build_parser() -> argparse.ArgumentParser: default=[], help="register and scan an additional Antigravity CLI data home (repeatable)", ) + refresh_parser.add_argument( + "--antigravity-desktop-root", + type=_expanded_path, + action="append", + default=[], + help="register and scan an additional Antigravity Desktop data home (repeatable)", + ) refresh_parser.add_argument( "--cursor-root", type=_expanded_path, @@ -558,6 +566,7 @@ def main(argv: Sequence[str] | None = None) -> int: TargetFormat.OPENCODE, TargetFormat.KILO, TargetFormat.ANTIGRAVITY, + TargetFormat.ANTIGRAVITY_DESKTOP, TargetFormat.CURSOR, TargetFormat.HERMES, } @@ -632,6 +641,13 @@ def main(argv: Sequence[str] | None = None) -> int: target_cli=args.target_cli, dry_run=dry_run, ) + elif target_format == TargetFormat.ANTIGRAVITY_DESKTOP and args.command != "convert": + install_antigravity_desktop_artifact( + artifact, + target_home=home, + target_cli=args.target_cli, + dry_run=dry_run, + ) elif target_format == TargetFormat.CURSOR and args.command != "convert": install_cursor_artifact( artifact, @@ -811,6 +827,7 @@ def _run_catalog(args: argparse.Namespace) -> int: opencode_roots=args.opencode_root, copilot_roots=args.copilot_root, antigravity_roots=args.antigravity_root, + antigravity_desktop_roots=args.antigravity_desktop_root, cursor_roots=args.cursor_root, vibe_roots=args.vibe_root, muse_roots=args.muse_root, diff --git a/src/session_migrate/conversion.py b/src/session_migrate/conversion.py index 46d9497..7c0f6af 100644 --- a/src/session_migrate/conversion.py +++ b/src/session_migrate/conversion.py @@ -21,6 +21,7 @@ from session_migrate.errors import FormatDetectionError, JsonlError, SessionMigrateError from session_migrate.formats import ( antigravity, + antigravity_desktop, claude, codex, copilot, @@ -132,6 +133,8 @@ def load_session(path: Path, source_format: AgentFormat | None = None) -> Sessio # The adapter makes a transactionally consistent SQLite backup that # includes live WAL state; a plain file stat/hash check would not. return antigravity.parse_session(path) + if source_format == AgentFormat.ANTIGRAVITY_DESKTOP: + return antigravity_desktop.parse_session(path) if source_format == AgentFormat.CURSOR: # Cursor is also a live SQLite store. Its adapter takes a consistent # backup including committed WAL state before projecting the graph. @@ -390,6 +393,18 @@ def convert_session(session: Session, options: ConversionOptions) -> ConversionA model=options.model, timestamp=timestamp, ) + elif target_format == TargetFormat.ANTIGRAVITY_DESKTOP: + target_version = ( + options.target_cli_version or antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_VERSION + ) + native_bytes, dropped = antigravity_desktop.serialize( + session, + session_id=target_id, + cwd=target_cwd, + cli_version=target_version, + model=options.model, + timestamp=timestamp, + ) elif target_format == TargetFormat.CURSOR: target_version = options.target_cli_version or cursor.PINNED_CURSOR_VERSION native_bytes, dropped = cursor.serialize( @@ -578,6 +593,9 @@ def convert_session(session: Session, options: ConversionOptions) -> ConversionA AgentFormat.OPENCODE: opencode.PINNED_OPENCODE_VERSION, AgentFormat.COPILOT: copilot.PINNED_COPILOT_VERSION, AgentFormat.ANTIGRAVITY: antigravity.PINNED_ANTIGRAVITY_VERSION, + AgentFormat.ANTIGRAVITY_DESKTOP: ( + antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_VERSION + ), AgentFormat.CURSOR: cursor.PINNED_CURSOR_VERSION, AgentFormat.VIBE: vibe.PINNED_VIBE_VERSION, AgentFormat.MUSE: muse.PINNED_MUSE_VERSION, @@ -638,6 +656,8 @@ def target_import_paths(artifact: ConversionArtifact, target_home: Path) -> tupl native_path = target_home / copilot.session_relative_path(artifact.session_id) elif artifact.target_format == TargetFormat.ANTIGRAVITY: native_path = target_home / antigravity.session_relative_path(artifact.session_id) + elif artifact.target_format == TargetFormat.ANTIGRAVITY_DESKTOP: + native_path = target_home / antigravity_desktop.session_relative_path(artifact.session_id) elif artifact.target_format == TargetFormat.CURSOR: native_path = target_home / cursor.session_relative_path(artifact.session_id, artifact.cwd) elif artifact.target_format == TargetFormat.VIBE: @@ -682,6 +702,8 @@ def default_target_home(target_format: TargetFormat | AgentFormat) -> Path: return Path(configured).expanduser() if configured else Path.home() / ".copilot" if target_format.value == TargetFormat.ANTIGRAVITY.value: return antigravity.app_data_home() + if target_format.value == TargetFormat.ANTIGRAVITY_DESKTOP.value: + return antigravity_desktop.app_data_home() if target_format.value == TargetFormat.CURSOR.value: return cursor.config_home() if target_format.value == TargetFormat.VIBE.value: @@ -939,6 +961,91 @@ def install_antigravity_artifact( return native_path, manifest_path +def install_antigravity_desktop_artifact( + artifact: ConversionArtifact, + *, + target_home: Path, + target_cli: Path | None = None, + dry_run: bool = False, + environ: Mapping[str, str] | None = None, +) -> tuple[Path, Path]: + """Install a pinned Antigravity Desktop DB and finalize a content-free manifest.""" + + if artifact.target_format != TargetFormat.ANTIGRAVITY_DESKTOP: + raise SessionMigrateError( + "Antigravity Desktop installation requires an Antigravity Desktop artifact" + ) + if artifact.target_cli_version != antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_VERSION: + raise SessionMigrateError( + "automatic Antigravity Desktop import requires target metadata version " + f"{antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_VERSION}; " + "convert-only artifacts may opt into unvalidated metadata versions" + ) + native_path, manifest_path = target_import_paths(artifact, target_home) + ensure_target_paths_available(manifest_path) + if dry_run: + installed = antigravity_desktop.install_database( + artifact.native_bytes, + session_id=artifact.session_id, + cwd=artifact.cwd, + timestamp=artifact.timestamp, + title=artifact.source.title, + target_home=target_home, + target_cli=target_cli, + dry_run=True, + environ=environ, + ) + if installed.conversation_path != native_path: + raise SessionMigrateError( + "Antigravity Desktop installer resolved an unexpected target path" + ) + return native_path, manifest_path + + manifest_bytes = ( + json.dumps(artifact.manifest(output_path=native_path), indent=2, sort_keys=True) + "\n" + ).encode() + reservation_identity: tuple[int, int] | None = None + reservation_guard: int | None = None + install_succeeded = False + try: + reservation_identity = write_private_atomic(manifest_path, b"") + reservation_guard = _open_identity_guard(manifest_path, reservation_identity, writable=True) + installed = antigravity_desktop.install_database( + artifact.native_bytes, + session_id=artifact.session_id, + cwd=artifact.cwd, + timestamp=artifact.timestamp, + title=artifact.source.title, + target_home=target_home, + target_cli=target_cli, + environ=environ, + ) + install_succeeded = True + if installed.conversation_path != native_path: + raise SessionMigrateError( + "Antigravity Desktop installer resolved an unexpected target path" + ) + _write_reserved_file( + reservation_guard, + manifest_path, + reservation_identity, + manifest_bytes, + ) + except BaseException as exc: + if reservation_identity is not None: + _unlink_if_identity_matches(manifest_path, reservation_identity) + if install_succeeded: + raise SessionMigrateError( + "Antigravity Desktop import succeeded but migrator manifest finalization failed; " + f"the native session may already exist as {artifact.session_id}" + ) from exc + raise + finally: + if reservation_guard is not None: + os.close(reservation_guard) + return native_path, manifest_path + + def install_cursor_artifact( artifact: ConversionArtifact, *, @@ -1649,6 +1756,9 @@ def _validate_native_bytes(data: bytes, target_format: TargetFormat, session_id: if target_format == TargetFormat.ANTIGRAVITY: antigravity.validate_native_bytes(data, session_id) return + if target_format == TargetFormat.ANTIGRAVITY_DESKTOP: + antigravity_desktop.validate_native_bytes(data, session_id) + return if target_format == TargetFormat.CURSOR: cursor.validate_native_bytes(data, session_id) return @@ -1703,6 +1813,7 @@ def _pinned_target_version(target_format: TargetFormat) -> str: TargetFormat.OPENCODE: opencode.PINNED_OPENCODE_VERSION, TargetFormat.COPILOT: copilot.PINNED_COPILOT_VERSION, TargetFormat.ANTIGRAVITY: antigravity.PINNED_ANTIGRAVITY_VERSION, + TargetFormat.ANTIGRAVITY_DESKTOP: (antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_VERSION), TargetFormat.CURSOR: cursor.PINNED_CURSOR_VERSION, TargetFormat.VIBE: vibe.PINNED_VIBE_VERSION, TargetFormat.MUSE: muse.PINNED_MUSE_VERSION, @@ -1736,6 +1847,8 @@ def _native_record_count(data: bytes, target_format: TargetFormat) -> int: return kimi.native_record_count(data) if target_format == TargetFormat.ANTIGRAVITY: return antigravity.native_record_count(data) + if target_format == TargetFormat.ANTIGRAVITY_DESKTOP: + return antigravity_desktop.native_record_count(data) if target_format == TargetFormat.CURSOR: return cursor.native_record_count(data) if target_format == TargetFormat.VIBE: diff --git a/src/session_migrate/discovery.py b/src/session_migrate/discovery.py index 0545fed..0f9c31a 100644 --- a/src/session_migrate/discovery.py +++ b/src/session_migrate/discovery.py @@ -57,7 +57,7 @@ def locate_session( "--source-cwd applies only to Claude/Pi/OMP/Cursor/Vibe/Qwen/Kimi discovery" ) matches = [home / "session-state" / normalized_id / "events.jsonl"] - elif source_format == AgentFormat.ANTIGRAVITY: + elif source_format in {AgentFormat.ANTIGRAVITY, AgentFormat.ANTIGRAVITY_DESKTOP}: if cwd is not None: raise SessionMigrateError( "--source-cwd applies only to Claude/Pi/OMP/Cursor/Vibe/Qwen/Kimi discovery" diff --git a/src/session_migrate/formats/__init__.py b/src/session_migrate/formats/__init__.py index 1600409..dc3e932 100644 --- a/src/session_migrate/formats/__init__.py +++ b/src/session_migrate/formats/__init__.py @@ -2,6 +2,7 @@ from session_migrate.formats import ( antigravity, + antigravity_desktop, claude, codex, copilot, @@ -23,6 +24,7 @@ __all__ = [ "antigravity", + "antigravity_desktop", "claude", "codex", "copilot", diff --git a/src/session_migrate/formats/antigravity.py b/src/session_migrate/formats/antigravity.py index c98e56f..b5269ca 100644 --- a/src/session_migrate/formats/antigravity.py +++ b/src/session_migrate/formats/antigravity.py @@ -44,13 +44,16 @@ ) PINNED_ANTIGRAVITY_LINUX_X86_64_SIZE = 205_545_512 -MAX_NATIVE_BYTES = 256 * 1024 * 1024 +MAX_NATIVE_BYTES = 512 * 1024 * 1024 MAX_STEPS = 100_000 MAX_PROTO_FIELDS = 100_000 MAX_PROTO_VALUE_BYTES = 64 * 1024 * 1024 MAX_JSON_NODES = 100_000 MAX_TEXT_BYTES = 64 * 1024 * 1024 PROJECT_ID = "default-cli-project" +PROJECT_ID_DESKTOP = "outside-of-project" +SOURCE_CLI = 17 +SOURCE_DESKTOP = 1 STEP_STATUS_DONE = 3 STEP_STATUS_ERROR = 7 @@ -649,6 +652,8 @@ def _build_database( conversation_id: str, trajectory_id: str, started_at: str, + source: int = SOURCE_CLI, + project_id: str = PROJECT_ID, ) -> bytes: with tempfile.TemporaryDirectory(prefix="session-migrate-antigravity-build-") as directory: path = Path(directory) / "conversation.db" @@ -684,8 +689,8 @@ def _build_database( """ ) db.execute( - "INSERT INTO trajectory_meta VALUES(?,?,4,17)", - (trajectory_id, conversation_id), + "INSERT INTO trajectory_meta VALUES(?,?,4,?)", + (trajectory_id, conversation_id, source), ) db.executemany( "INSERT INTO steps(" @@ -700,7 +705,7 @@ def _build_database( trajectory_metadata = ( _field_bytes(2, _field_varint(1, seconds)) + _field_text(6, conversation_id) - + _field_text(18, PROJECT_ID) + + _field_text(18, project_id) ) db.execute( "INSERT INTO trajectory_metadata_blob(id,data) VALUES('main',?)", @@ -720,14 +725,26 @@ def _build_database( def _parse_database_bytes( - data: bytes, *, expected_session_id: str | None, generated: bool = False + data: bytes, + *, + expected_session_id: str | None, + generated: bool = False, + expected_source: int = SOURCE_CLI, + expected_project_id: str = PROJECT_ID, + is_desktop: bool = False, + cli_version: str = PINNED_ANTIGRAVITY_VERSION, ) -> ParsedAntigravitySession: if not data or len(data) > MAX_NATIVE_BYTES: raise SessionMigrateError("Antigravity conversation violates the database safety limit") digest = hashlib.sha256(data).hexdigest() with _database_from_bytes(data) as db: conversation_id, trajectory_id, rows, started_at, metadata_losses = _validate_database( - db, expected_session_id=expected_session_id, generated=generated + db, + expected_session_id=expected_session_id, + generated=generated, + expected_source=expected_source, + expected_project_id=expected_project_id, + is_desktop=is_desktop, ) events = list(_project_events(rows)) for reason, count in metadata_losses.items(): @@ -744,7 +761,7 @@ def _parse_database_bytes( trajectory_id=trajectory_id, cwd=None, started_at=started_at, - cli_version=PINNED_ANTIGRAVITY_VERSION, + cli_version=cli_version, model=None, title=None, events=tuple(events), @@ -780,7 +797,13 @@ def _database_from_bytes(data: bytes) -> Iterator[sqlite3.Connection]: def _validate_database( - db: sqlite3.Connection, *, expected_session_id: str | None, generated: bool + db: sqlite3.Connection, + *, + expected_session_id: str | None, + generated: bool, + expected_source: int = SOURCE_CLI, + expected_project_id: str = PROJECT_ID, + is_desktop: bool = False, ) -> tuple[str, str, tuple[_StepRow, ...], str | None, dict[str, int]]: try: page_size = int(db.execute("PRAGMA page_size").fetchone()[0]) @@ -794,10 +817,11 @@ def _validate_database( trajectory_rows = db.execute( "SELECT trajectory_id,cascade_id,trajectory_type,source FROM trajectory_meta" ).fetchall() - main_rows = [row for row in trajectory_rows if row[2:] == (4, 17)] + main_rows = [row for row in trajectory_rows if row[2:] == (4, expected_source)] if len(main_rows) != 1: + desc = "Desktop" if is_desktop else "CLI" raise SessionMigrateError( - "Antigravity database must contain one CLI cascade trajectory" + f"Antigravity database must contain one {desc} cascade trajectory" ) if generated and len(trajectory_rows) != 1: raise SessionMigrateError("generated Antigravity database has auxiliary trajectories") @@ -808,8 +832,9 @@ def _validate_database( _require_uuid4(conversation_id, "Antigravity conversation ID") if expected_session_id is not None and conversation_id != expected_session_id: raise SessionMigrateError("Antigravity database conversation ID does not match target") - if trajectory_type != 4 or source != 17: - raise SessionMigrateError("Antigravity trajectory is not a pinned CLI cascade") + if trajectory_type != 4 or source != expected_source: + desc = "Desktop" if is_desktop else "CLI" + raise SessionMigrateError(f"Antigravity trajectory is not a pinned {desc} cascade") raw_metadata = db.execute( "SELECT id,data FROM trajectory_metadata_blob ORDER BY id" @@ -821,7 +846,11 @@ def _validate_database( ): raise SessionMigrateError("Antigravity trajectory metadata is missing or ambiguous") started_at, trajectory_metadata_loss = _validate_trajectory_metadata( - raw_metadata[0][1], conversation_id, generated=generated + raw_metadata[0][1], + conversation_id, + generated=generated, + expected_project_id=expected_project_id, + is_desktop=is_desktop, ) count = int(db.execute("SELECT count(*) FROM steps").fetchone()[0]) @@ -912,17 +941,29 @@ def _validate_conversation_schema(db: sqlite3.Connection) -> None: def _validate_trajectory_metadata( - data: bytes, conversation_id: str, *, generated: bool + data: bytes, + conversation_id: str, + *, + generated: bool, + expected_project_id: str = PROJECT_ID, + is_desktop: bool = False, ) -> tuple[str | None, bool]: fields = _decode_message(data) root = _optional_text(fields, 6) - if root is not None and root != conversation_id: - raise SessionMigrateError("Antigravity trajectory metadata root ID is inconsistent") + if root is not None: + try: + _require_uuid4(root, "Antigravity trajectory metadata root ID") + except SessionMigrateError: + raise SessionMigrateError( + "Antigravity trajectory metadata root ID is inconsistent" + ) from None + if generated and root != conversation_id: + raise SessionMigrateError("Antigravity trajectory metadata root ID is inconsistent") incomplete = root is None if generated and incomplete: raise SessionMigrateError("generated Antigravity trajectory metadata is incomplete") project = _optional_text(fields, 18) - if project is not None and project != PROJECT_ID: + if project is not None and project != expected_project_id: if generated: raise SessionMigrateError("Antigravity trajectory metadata project is unsupported") incomplete = True @@ -951,6 +992,9 @@ def _validate_step(data: bytes, *, step_type: int, status: int, generated: bool) raise SessionMigrateError("Antigravity step payload status disagrees with its SQLite row") if status not in {1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12}: raise SessionMigrateError("Antigravity step status is outside pinned 1.1.16") + if status == 5: + # CLEARED status (compacted/cleared step): payload is empty or metadata only + return expected_payload_field = _STEP_PAYLOAD_FIELDS.get(step_type) if generated and step_type not in { STEP_TYPE_USER_INPUT, @@ -968,7 +1012,7 @@ def _validate_step(data: bytes, *, step_type: int, status: int, generated: bool) def _validate_known_step_payload(step_type: int, data: bytes, *, generated: bool) -> None: fields = _decode_message(data) if step_type == STEP_TYPE_USER_INPUT: - if _optional_text(fields, 2) is None and _optional_text(fields, 1) is None: + if generated and _optional_text(fields, 2) is None and _optional_text(fields, 1) is None: raise SessionMigrateError("Antigravity user step contains no readable text") elif step_type == STEP_TYPE_PLANNER_RESPONSE: response = _optional_text(fields, 1) @@ -981,14 +1025,19 @@ def _validate_known_step_payload(step_type: int, data: bytes, *, generated: bool for entry in _bytes_values(fields, 1): entry_fields = _decode_message(entry) _required_text(entry_fields, 1, "generic argument key") - _required_text(entry_fields, 2, "generic argument value") - result = _required_bytes(fields, 2, "generic tool result") - result_fields = _decode_message(result) - _required_text(result_fields, 1, "generic result text", allow_empty=True) - for entry in _bytes_values(result_fields, 2): - metadata_fields = _decode_message(entry) - _required_text(metadata_fields, 1, "generic result metadata key") - _required_text(metadata_fields, 2, "generic result metadata value", allow_empty=True) + _required_text(entry_fields, 2, "generic argument value", allow_empty=True) + result = _optional_bytes(fields, 2) + if result is not None: + result_fields = _decode_message(result) + _required_text(result_fields, 1, "generic result text", allow_empty=True) + for entry in _bytes_values(result_fields, 2): + metadata_fields = _decode_message(entry) + _required_text(metadata_fields, 1, "generic result metadata key") + _required_text( + metadata_fields, 2, "generic result metadata value", allow_empty=True + ) + elif generated: + raise SessionMigrateError("Antigravity generic tool result is missing") elif step_type == STEP_TYPE_MCP_TOOL: call = _optional_bytes(fields, 2) if call is not None: @@ -1002,6 +1051,10 @@ def _project_events(rows: Sequence[_StepRow]) -> tuple[Event, ...]: pending: deque[tuple[str, str]] = deque() emitted_calls: Counter[str] = Counter() for row in rows: + if row.status == 5: + timestamp = _step_timestamp(row.metadata) + events.append(_opaque_event(row, "antigravity_cleared_step", timestamp)) + continue outer = _decode_message(row.payload) payload_number = _STEP_PAYLOAD_FIELDS.get(row.step_type) payload = _optional_bytes(outer, payload_number) if payload_number else None @@ -1020,6 +1073,8 @@ def _project_events(rows: Sequence[_StepRow]) -> tuple[Event, ...]: provenance=provenance, ) ) + else: + events.append(_opaque_event(row, "antigravity_user_action", timestamp)) if _bytes_values(fields, 3) or _bytes_values(fields, 9): events.append(_opaque_event(row, "antigravity_user_context", timestamp)) continue @@ -1073,9 +1128,17 @@ def _project_events(rows: Sequence[_StepRow]) -> tuple[Event, ...]: continue if row.step_type == STEP_TYPE_GENERIC and payload is not None: fields = _decode_message(payload) - result_fields = _decode_message(_required_bytes(fields, 2, "generic result")) - result_text = _required_text(result_fields, 1, "generic result text", allow_empty=True) - linked_call_id = _generic_result_call_id(result_fields) + result_bytes = _optional_bytes(fields, 2) + if result_bytes is not None: + result_fields = _decode_message(result_bytes) + result_text = _required_text( + result_fields, 1, "generic result text", allow_empty=True + ) + linked_call_id = _generic_result_call_id(result_fields) + else: + result_fields = () + result_text = None + linked_call_id = None pending_index = next( ( index @@ -1532,6 +1595,10 @@ def _summary_values( timestamp: str, cwd: Path, last_user_input_index: int, + status: str = "", + source: str = "antigravity-cli", + project_id: str = PROJECT_ID, + app_data_dir: str = "antigravity-cli", ) -> tuple[Any, ...]: bounded_title = title[:4_096] bounded_preview = preview[:16_384] @@ -1543,9 +1610,9 @@ def _summary_values( step_count, _parse_timestamp(timestamp).isoformat(sep=" "), json.dumps([workspace_uri], ensure_ascii=False, separators=(",", ":")), - "", - "antigravity-cli", - PROJECT_ID, + status, + source, + project_id, "", "", 0, @@ -1555,7 +1622,7 @@ def _summary_values( 0, _parse_timestamp(timestamp).isoformat(sep=" "), last_user_input_index, - "antigravity-cli", + app_data_dir, ) @@ -1685,7 +1752,7 @@ def _validate_summary_schema(db: sqlite3.Connection) -> None: if object_row != ("table",): raise SessionMigrateError("Antigravity summary table is missing") columns = tuple(row[1] for row in db.execute("PRAGMA table_info('conversation_summaries')")) - if columns != _SUMMARY_COLUMNS: + if columns not in (_SUMMARY_COLUMNS, _SUMMARY_COLUMNS + ("raw_summary", "group_id")): raise SessionMigrateError("Antigravity summary columns do not match pinned 1.1.16") for index_name, expected_column in ( ("idx_conversation_summaries_last_user_input_time", "last_user_input_time"), diff --git a/src/session_migrate/formats/antigravity_desktop.py b/src/session_migrate/formats/antigravity_desktop.py new file mode 100644 index 0000000..0ab96e5 --- /dev/null +++ b/src/session_migrate/formats/antigravity_desktop.py @@ -0,0 +1,474 @@ +"""Antigravity Desktop 2.17.0 SQLite/protobuf session adapter. + +This module adapts conversations to and from the Antigravity Desktop application +(macOS/Electron), which stores individual cascade conversations as SQLite databases +under ``~/.gemini/antigravity/conversations/.db`` and registers metadata in +``~/.gemini/antigravity/conversation_summaries.db``. + +While the table schemas match the Antigravity CLI format, Desktop differs in: + - ``trajectory_meta.source``: 1 (IDE / Hub) rather than 17 (CLI). + - ``trajectory_metadata_blob.project_id``: ``"outside-of-project"`` (or project UUID) + rather than ``"default-cli-project"``. + - ``conversation_summaries``: + - ``source``: ``""`` + - ``app_data_dir``: ``"antigravity"`` + - ``project_id``: ``"outside-of-project"`` + - ``status``: ``"CASCADE_RUN_STATUS_IDLE"`` + - Subagents: field 6 of ``trajectory_metadata_blob`` points to the root cascade ID, + so subagent conversations have ``root != conversation_id`` and ``nesting_depth >= 1``. +""" + +from __future__ import annotations + +import os +import shutil +import sqlite3 +import stat +from collections import Counter +from collections.abc import Mapping +from dataclasses import replace +from pathlib import Path +from typing import Any +from urllib.parse import quote + +from session_migrate.errors import SessionMigrateError +from session_migrate.formats import antigravity +from session_migrate.formats.antigravity import ( + _SUMMARY_COLUMNS, + STEP_STATUS_DONE, + STEP_STATUS_ERROR, + STEP_TYPE_GENERIC, + STEP_TYPE_PLANNER_RESPONSE, + STEP_TYPE_USER_INPUT, + InstalledAntigravitySession, + ParsedAntigravitySession, + _absolute_no_follow, + _database_from_bytes, + _ensure_private_directory, + _ensure_summary_database, + _event_timestamp, + _field_bytes, + _field_text, + _field_varint, + _generic_argument_entries, + _guard_matches_path, + _last_user_input_index, + _omission_key, + _open_identity_guard, + _read_summary_metadata, + _require_uuid4, + _session_id_from_path, + _StepRow, + _stream_sha256, + _summary_values, + _unlink_if_same_file, + _utc_now, + _validate_summary_schema, + content_text, + valid_rfc3339, +) +from session_migrate.jsonl import write_private_atomic +from session_migrate.model import AgentFormat, EventKind, Role, Session + +PINNED_ANTIGRAVITY_DESKTOP_VERSION = "2.17.0" +PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SHA256 = ( + "300ee20f3108a511be1149602b91ba84cbbdcc42213067151e255584129be30f" +) +PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SIZE = 148_990_608 + +DEFAULT_MACOS_LANGUAGE_SERVER_PATH = Path( + "/Applications/Antigravity.app/Contents/Resources/bin/language_server" +) + +MAX_NATIVE_BYTES = antigravity.MAX_NATIVE_BYTES +PROJECT_ID = antigravity.PROJECT_ID_DESKTOP # "outside-of-project" +SOURCE_DESKTOP = antigravity.SOURCE_DESKTOP # 1 + +session_relative_path = antigravity.session_relative_path +snapshot_database_bytes = antigravity.snapshot_database_bytes + + +def app_data_home(home: Path | None = None) -> Path: + """Return the Antigravity Desktop store below a chosen process HOME.""" + return (home or Path.home()).expanduser() / ".gemini" / "antigravity" + + +def serialize( + session: Session, + *, + session_id: str, + cwd: Path, + cli_version: str = PINNED_ANTIGRAVITY_DESKTOP_VERSION, + model: str | None = None, + timestamp: str | None = None, + trajectory_id: str | None = None, +) -> tuple[bytes, dict[str, int]]: + """Serialize portable conversation history as an Antigravity Desktop SQLite DB.""" + del cli_version, model + _require_uuid4(session_id, "Antigravity conversation ID") + import uuid + + target_trajectory_id = trajectory_id or str(uuid.uuid4()) + _require_uuid4(target_trajectory_id, "Antigravity trajectory ID") + if target_trajectory_id == session_id: + raise SessionMigrateError("Antigravity conversation and trajectory IDs must differ") + + started_at = valid_rfc3339(timestamp) or valid_rfc3339(session.started_at) or _utc_now() + dropped: Counter[str] = Counter() + rows: list[_StepRow] = [] + pending_calls: list[tuple[str | None, str, str, Any]] = [] + generated_call_number = 0 + + def append_step(step_type: int, status: int, native_payload: bytes) -> None: + outer = _field_varint(1, step_type) + _field_varint(4, status) + outer += _field_bytes(antigravity._STEP_PAYLOAD_FIELDS[step_type], native_payload) + rows.append(_StepRow(len(rows), step_type, status, None, outer)) + + for event in session.events: + _event_timestamp(event, started_at, dropped) + if event.kind == EventKind.MESSAGE and event.role == Role.USER: + if event.text: + append_step(STEP_TYPE_USER_INPUT, STEP_STATUS_DONE, _field_text(2, event.text)) + continue + if event.kind == EventKind.MESSAGE and event.role == Role.ASSISTANT: + if event.text: + planner = _field_text(1, event.text) + _field_text(6, str(uuid.uuid4())) + append_step(STEP_TYPE_PLANNER_RESPONSE, STEP_STATUS_DONE, planner) + continue + if event.kind == EventKind.TOOL_CALL and event.role == Role.ASSISTANT: + call_id = event.tool_call_id or f"session-migrate-{session_id}-{len(rows)}" + name = event.tool_name or "unknown_tool" + raw_input = event.payload.get("input", {}) + import json + + arguments_json = json.dumps( + raw_input if isinstance(raw_input, dict) else {}, + ensure_ascii=False, + separators=(",", ":"), + ) + native_call = ( + _field_text(1, call_id) + _field_text(2, name) + _field_text(3, arguments_json) + ) + planner = _field_text(6, str(uuid.uuid4())) + _field_bytes(7, native_call) + pending_calls.append((event.tool_call_id, call_id, name, raw_input)) + append_step(STEP_TYPE_PLANNER_RESPONSE, STEP_STATUS_DONE, planner) + continue + if event.kind == EventKind.TOOL_RESULT and event.role == Role.TOOL: + match_index: int | None = None + if event.tool_call_id: + matching_indices = [ + index + for index, pending in enumerate(pending_calls) + if pending[0] == event.tool_call_id + ] + if event.tool_name: + named_indices = [ + index + for index in matching_indices + if pending_calls[index][2] == event.tool_name + ] + if named_indices: + matching_indices = named_indices + match_index = next(iter(matching_indices), None) + elif pending_calls: + match_index = 0 + dropped["tool_result:missing_id"] += 1 + if match_index is not None: + _, call_id, tool_name, tool_input = pending_calls.pop(match_index) + else: + generated_call_number += 1 + call_id = event.tool_call_id or ( + f"session-migrate-{session_id}-{generated_call_number}" + ) + tool_name = event.tool_name or "unknown_tool" + tool_input = {} + import json + + native_call = ( + _field_text(1, call_id) + _field_text(2, tool_name) + _field_text(3, "{}") + ) + append_step( + STEP_TYPE_PLANNER_RESPONSE, + STEP_STATUS_DONE, + _field_text(6, str(uuid.uuid4())) + _field_bytes(7, native_call), + ) + dropped["tool_result:orphan_id"] += 1 + generic = b"".join(_generic_argument_entries(tool_input, dropped)) + result_text = event.text or content_text(event.payload.get("content_blocks")) + linkage_entry = _field_text(1, "session_migrate_call_id") + _field_text(2, call_id) + generic_result = _field_text(1, result_text or "") + _field_bytes(2, linkage_entry) + generic += _field_bytes(2, generic_result) + append_step( + STEP_TYPE_GENERIC, + STEP_STATUS_ERROR if event.payload.get("is_error") is True else STEP_STATUS_DONE, + generic, + ) + blocks = event.payload.get("content_blocks") + if isinstance(blocks, list): + for block in blocks: + if not isinstance(block, dict) or block.get("type") != "text": + dropped["tool_result:non_text_block"] += 1 + continue + if event.kind == EventKind.THINKING: + dropped["thinking:private"] += 1 + elif event.kind == EventKind.COMPACTION: + dropped["compaction:no_stored_native_equivalent"] += 1 + elif event.kind == EventKind.MESSAGE: + dropped["message:privileged_role"] += 1 + else: + dropped[_omission_key(event)] += 1 + + if not any(row.step_type == STEP_TYPE_USER_INPUT for row in rows): + raise SessionMigrateError( + "Antigravity Desktop target requires at least one portable user message" + ) + + data = antigravity._build_database( + rows, + conversation_id=session_id, + trajectory_id=target_trajectory_id, + started_at=started_at, + source=SOURCE_DESKTOP, + project_id=PROJECT_ID, + ) + validate_native_bytes(data, session_id) + return data, dict(sorted(dropped.items())) + + +def parse(path: Path) -> ParsedAntigravitySession: + """Read one Desktop conversation DB through a consistent SQLite backup snapshot.""" + snapshot = snapshot_database_bytes(path) + parsed = antigravity._parse_database_bytes( + snapshot, + expected_session_id=_session_id_from_path(path), + expected_source=SOURCE_DESKTOP, + expected_project_id=PROJECT_ID, + is_desktop=True, + cli_version=PINNED_ANTIGRAVITY_DESKTOP_VERSION, + ) + title, cwd = _read_summary_metadata(path, parsed.session_id) + return replace(parsed, title=title, cwd=cwd) + + +def parse_session(path: Path) -> Session: + """Parse Antigravity Desktop as a first-class source.""" + parsed = parse(path) + return Session( + source_format=AgentFormat.ANTIGRAVITY_DESKTOP, + source_path=path.resolve(), + source_sha256=parsed.snapshot_sha256, + session_id=parsed.session_id, + cwd=parsed.cwd, + started_at=parsed.started_at, + cli_version=parsed.cli_version, + model=parsed.model, + title=parsed.title, + events=parsed.events, + raw_record_count=parsed.raw_record_count, + model_provider="google", + ) + + +def validate_native_bytes(data: bytes, session_id: str) -> None: + """Strictly validate a generated Antigravity Desktop conversation database.""" + _require_uuid4(session_id, "Antigravity conversation ID") + parsed = antigravity._parse_database_bytes( + data, + expected_session_id=session_id, + generated=True, + expected_source=SOURCE_DESKTOP, + expected_project_id=PROJECT_ID, + is_desktop=True, + cli_version=PINNED_ANTIGRAVITY_DESKTOP_VERSION, + ) + if not any( + event.kind == EventKind.MESSAGE and event.role == Role.USER for event in parsed.events + ): + raise SessionMigrateError("generated Antigravity Desktop session contains no user message") + + +def native_record_count(data: bytes) -> int: + """Return the number of stored steps after full byte validation.""" + with _database_from_bytes(data) as db: + antigravity._validate_database( + db, + expected_session_id=None, + generated=True, + expected_source=SOURCE_DESKTOP, + expected_project_id=PROJECT_ID, + is_desktop=True, + ) + return int(db.execute("SELECT count(*) FROM steps").fetchone()[0]) + + +def verify_pinned_desktop( + executable: Path | None = None, *, environ: Mapping[str, str] | None = None +) -> Path: + """Resolve and verify the Antigravity Desktop language server binary.""" + values = dict(os.environ if environ is None else environ) + candidate: str | None = None + if executable: + candidate = str(executable) + elif values.get("ANTIGRAVITY_DESKTOP_BIN"): + candidate = values["ANTIGRAVITY_DESKTOP_BIN"] + elif values.get("ANTIGRAVITY_LANGUAGE_SERVER_BIN"): + candidate = values["ANTIGRAVITY_LANGUAGE_SERVER_BIN"] + elif DEFAULT_MACOS_LANGUAGE_SERVER_PATH.is_file(): + candidate = str(DEFAULT_MACOS_LANGUAGE_SERVER_PATH) + else: + candidate = shutil.which("language_server", path=values.get("PATH")) + + if not candidate: + raise SessionMigrateError("Antigravity Desktop executable 'language_server' was not found") + path = Path(candidate).expanduser().resolve() + try: + info = path.stat() + except OSError as exc: + raise SessionMigrateError("cannot inspect the Antigravity Desktop executable") from exc + if not stat.S_ISREG(info.st_mode): + raise SessionMigrateError("Antigravity Desktop executable is not a regular file") + + if values.get("SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN") == "1": + return path + + if info.st_size != PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SIZE: + raise SessionMigrateError( + "Antigravity Desktop binary size mismatch: automatic import requires the exact " + f"macOS arm64 {PINNED_ANTIGRAVITY_DESKTOP_VERSION} build" + ) + digest = _stream_sha256(path, maximum=PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SIZE) + if digest != PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SHA256: + raise SessionMigrateError( + "Antigravity Desktop binary digest mismatch; refusing private-store installation" + ) + return path + + +def install_database( + data: bytes, + *, + session_id: str, + cwd: Path, + timestamp: str, + title: str | None, + target_home: Path, + target_cli: Path | None = None, + dry_run: bool = False, + environ: Mapping[str, str] | None = None, +) -> InstalledAntigravitySession: + """Install a validated conversation and its desktop summary without overwrite.""" + validate_native_bytes(data, session_id) + if target_cli is not None or os.environ.get("SESSION_MIGRATE_VERIFY_DESKTOP_BIN") == "1": + verify_pinned_desktop(target_cli, environ=environ) + root = _absolute_no_follow(target_home) + conversation_path = root / session_relative_path(session_id) + summaries_path = root / "conversation_summaries.db" + cwd = cwd.expanduser().resolve() + summary_timestamp = valid_rfc3339(timestamp) or _utc_now() + parsed = antigravity._parse_database_bytes( + data, + expected_session_id=session_id, + generated=True, + expected_source=SOURCE_DESKTOP, + expected_project_id=PROJECT_ID, + is_desktop=True, + cli_version=PINNED_ANTIGRAVITY_DESKTOP_VERSION, + ) + preview = next( + ( + event.text + for event in parsed.events + if event.kind == EventKind.MESSAGE and event.role == Role.USER and event.text + ), + "Imported conversation", + ) + summary = _summary_values( + session_id=session_id, + title=title or "Imported conversation", + preview=preview, + step_count=parsed.raw_record_count, + timestamp=summary_timestamp, + cwd=cwd, + last_user_input_index=_last_user_input_index(data), + status="CASCADE_RUN_STATUS_IDLE", + source="", + project_id=PROJECT_ID, + app_data_dir="antigravity", + ) + + if dry_run: + if os.path.lexists(conversation_path): + raise SessionMigrateError( + "Antigravity Desktop conversation ID already exists; refusing to overwrite it" + ) + if os.path.lexists(summaries_path): + if stat.S_ISLNK(summaries_path.lstat().st_mode): + raise SessionMigrateError( + "Antigravity Desktop summary database must not be a symlink" + ) + try: + uri = f"file:{quote(str(summaries_path), safe='/')}?mode=ro" + with sqlite3.connect(uri, uri=True, timeout=5) as summaries: + summaries.execute("PRAGMA trusted_schema=OFF") + _validate_summary_schema(summaries) + collision = summaries.execute( + "SELECT 1 FROM conversation_summaries WHERE conversation_id=?", + (session_id,), + ).fetchone() + except sqlite3.Error as exc: + raise SessionMigrateError( + "Antigravity Desktop summary database cannot be checked safely" + ) from exc + if collision: + raise SessionMigrateError( + "Antigravity Desktop conversation ID already exists; refusing to overwrite it" + ) + return InstalledAntigravitySession(conversation_path, summaries_path) + + _ensure_private_directory(root) + _ensure_private_directory(conversation_path.parent) + _ensure_summary_database(summaries_path) + created_identity: tuple[int, int] | None = None + conversation_guard: int | None = None + summary_guard: int | None = None + try: + summary_guard = _open_identity_guard(summaries_path, writable=True) + with sqlite3.connect(summaries_path, timeout=15, isolation_level=None) as summaries: + summaries.execute("PRAGMA trusted_schema=OFF") + _validate_summary_schema(summaries) + summaries.execute("BEGIN IMMEDIATE") + collision = summaries.execute( + "SELECT 1 FROM conversation_summaries WHERE conversation_id=?", (session_id,) + ).fetchone() + if collision or os.path.lexists(conversation_path): + summaries.execute("ROLLBACK") + raise SessionMigrateError( + "Antigravity Desktop conversation ID already exists; refusing to overwrite it" + ) + created_identity = write_private_atomic(conversation_path, data) + conversation_guard = _open_identity_guard( + conversation_path, expected_identity=created_identity + ) + summaries.execute( + f"INSERT INTO conversation_summaries({','.join(_SUMMARY_COLUMNS)}) " + f"VALUES({','.join('?' for _ in _SUMMARY_COLUMNS)})", + summary, + ) + if not _guard_matches_path(conversation_guard, conversation_path) or not ( + _guard_matches_path(summary_guard, summaries_path) + ): + summaries.execute("ROLLBACK") + raise SessionMigrateError( + "Antigravity Desktop install paths changed during transaction" + ) + summaries.execute("COMMIT") + except BaseException: + if created_identity is not None: + _unlink_if_same_file(conversation_path, created_identity) + raise + finally: + if conversation_guard is not None: + os.close(conversation_guard) + if summary_guard is not None: + os.close(summary_guard) + return InstalledAntigravitySession(conversation_path, summaries_path) diff --git a/src/session_migrate/inspection.py b/src/session_migrate/inspection.py index 05040d2..8728a14 100644 --- a/src/session_migrate/inspection.py +++ b/src/session_migrate/inspection.py @@ -12,6 +12,7 @@ from session_migrate.errors import FormatDetectionError, JsonlError, SessionMigrateError from session_migrate.formats import ( antigravity, + antigravity_desktop, cursor, devin, grok, @@ -123,6 +124,9 @@ def inspect_session(path: Path, *, source_format: AgentFormat | None = None) -> if source_format == AgentFormat.ANTIGRAVITY: parsed = antigravity.parse_session(path) return _inspect_portable_database(parsed) + if source_format == AgentFormat.ANTIGRAVITY_DESKTOP: + parsed = antigravity_desktop.parse_session(path) + return _inspect_portable_database(parsed) if source_format == AgentFormat.CURSOR: parsed = cursor.project_session(cursor.parse(path), source_format=AgentFormat.CURSOR) return _inspect_portable_database(parsed) @@ -153,6 +157,8 @@ def inspect_session(path: Path, *, source_format: AgentFormat | None = None) -> detected = _detect_sqlite_path(path) if detected == AgentFormat.ANTIGRAVITY: parsed = antigravity.parse_session(path) + elif detected == AgentFormat.ANTIGRAVITY_DESKTOP: + parsed = antigravity_desktop.parse_session(path) elif detected == AgentFormat.CURSOR: parsed = cursor.project_session(cursor.parse(path), source_format=AgentFormat.CURSOR) elif detected == AgentFormat.HERMES: @@ -517,6 +523,7 @@ def detect_path_format(path: Path) -> AgentFormat: def _detect_sqlite_path(path: Path) -> AgentFormat: probes = ( (AgentFormat.ANTIGRAVITY, lambda: antigravity.parse(path)), + (AgentFormat.ANTIGRAVITY_DESKTOP, lambda: antigravity_desktop.parse(path)), (AgentFormat.CURSOR, lambda: cursor.parse(path)), (AgentFormat.HERMES, lambda: hermes.list_sessions(path)), (AgentFormat.MASTRACODE, lambda: mastracode.list_sessions(path)), diff --git a/src/session_migrate/model.py b/src/session_migrate/model.py index 14eead0..d9969c9 100644 --- a/src/session_migrate/model.py +++ b/src/session_migrate/model.py @@ -17,6 +17,7 @@ class AgentFormat(StrEnum): OPENCODE = "opencode" COPILOT = "copilot" ANTIGRAVITY = "antigravity" + ANTIGRAVITY_DESKTOP = "antigravity-desktop" CURSOR = "cursor" VIBE = "vibe" MUSE = "muse" @@ -40,6 +41,7 @@ class TargetFormat(StrEnum): OPENCODE = "opencode" COPILOT = "copilot" ANTIGRAVITY = "antigravity" + ANTIGRAVITY_DESKTOP = "antigravity-desktop" CURSOR = "cursor" VIBE = "vibe" MUSE = "muse" diff --git a/tests/test_antigravity_desktop_format.py b/tests/test_antigravity_desktop_format.py new file mode 100644 index 0000000..9cbabb6 --- /dev/null +++ b/tests/test_antigravity_desktop_format.py @@ -0,0 +1,337 @@ +import json +import sqlite3 +from pathlib import Path + +import pytest + +from session_migrate.errors import SessionMigrateError +from session_migrate.formats import antigravity, antigravity_desktop +from session_migrate.model import AgentFormat, Event, EventKind, Provenance, Role, Session + +TARGET_ID = "33333333-3333-4333-8333-333333333333" +TRAJECTORY_ID = "44444444-4444-4444-8444-444444444444" + + +def portable_session(tmp_path: Path) -> Session: + events = ( + Event( + kind=EventKind.MESSAGE, + role=Role.USER, + text="AGY_DESKTOP_USER_ALPHA", + timestamp="2026-08-20T12:00:00Z", + provenance=Provenance(0, "user"), + ), + Event( + kind=EventKind.MESSAGE, + role=Role.ASSISTANT, + text="AGY_DESKTOP_ASSISTANT_OMEGA", + timestamp="2026-08-20T12:00:01Z", + provenance=Provenance(1, "assistant"), + ), + Event( + kind=EventKind.TOOL_CALL, + role=Role.ASSISTANT, + tool_name="echo_marker", + tool_call_id="agy-call-1", + timestamp="2026-08-20T12:00:02Z", + payload={"input": {"text": "DESKTOP_INPUT_ALPHA", "count": 2}}, + provenance=Provenance(2, "tool_call"), + ), + Event( + kind=EventKind.TOOL_RESULT, + role=Role.TOOL, + text="DESKTOP_RESULT_OMEGA", + tool_name="echo_marker", + tool_call_id="agy-call-1", + timestamp="2026-08-20T12:00:03Z", + payload={ + "is_error": False, + "content_blocks": [{"type": "text", "text": "DESKTOP_RESULT_OMEGA"}], + }, + provenance=Provenance(3, "tool_result"), + ), + Event( + kind=EventKind.THINKING, + role=Role.ASSISTANT, + text="PRIVATE_THINKING_MUST_NOT_SURVIVE", + provenance=Provenance(4, "thinking"), + ), + ) + return Session( + source_format=AgentFormat.CLAUDE, + source_path=tmp_path / "source.jsonl", + source_sha256="0" * 64, + session_id="11111111-1111-4111-8111-111111111111", + cwd=tmp_path, + started_at="2026-08-20T12:00:00Z", + cli_version="2.1.209", + model="fixture-model", + title="Antigravity Desktop fixture", + events=events, + raw_record_count=len(events), + ) + + +def event_signature(events: tuple[Event, ...]) -> list[tuple[object, ...]]: + result = [] + for event in events: + if event.kind == EventKind.THINKING: + continue + payload = event.payload.get("input") if event.kind == EventKind.TOOL_CALL else None + result.append( + ( + event.kind, + event.role, + event.text, + event.tool_name, + event.tool_call_id, + json.dumps(payload, sort_keys=True), + ) + ) + return result + + +def test_desktop_round_trip_preserves_messages_and_tools(tmp_path: Path) -> None: + source = portable_session(tmp_path) + data, dropped = antigravity_desktop.serialize( + source, + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + ) + path = tmp_path / f"{TARGET_ID}.db" + path.write_bytes(data) + + antigravity_desktop.validate_native_bytes(data, TARGET_ID) + parsed = antigravity_desktop.parse(path) + + assert dropped == {"thinking:private": 1} + assert b"PRIVATE_THINKING_MUST_NOT_SURVIVE" not in data + assert antigravity_desktop.native_record_count(data) == 4 + assert event_signature(parsed.events) == event_signature(source.events) + + # Verify Desktop wire differences + with sqlite3.connect(path) as conn: + meta = conn.execute( + "SELECT trajectory_id, cascade_id, trajectory_type, source FROM trajectory_meta" + ).fetchone() + assert meta == (TRAJECTORY_ID, TARGET_ID, 4, 1) # Desktop source is 1, not 17! + + +def test_desktop_parse_session_attributes(tmp_path: Path) -> None: + source = portable_session(tmp_path) + data, _ = antigravity_desktop.serialize( + source, + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + ) + path = tmp_path / f"{TARGET_ID}.db" + path.write_bytes(data) + + session = antigravity_desktop.parse_session(path) + assert session.source_format == AgentFormat.ANTIGRAVITY_DESKTOP + assert session.session_id == TARGET_ID + assert session.model_provider == "google" + assert session.cli_version == antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_VERSION + assert len(session.events) == 4 + + +def test_desktop_installation_updates_summary_database(tmp_path: Path) -> None: + source = portable_session(tmp_path) + data, _ = antigravity_desktop.serialize( + source, + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + ) + target_home = tmp_path / "antigravity" + + # Dry run check + installed_dry = antigravity_desktop.install_database( + data, + session_id=TARGET_ID, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + title="Test Desktop Session", + target_home=target_home, + dry_run=True, + ) + assert not installed_dry.conversation_path.exists() + assert not installed_dry.summaries_path.exists() + + # Actual installation + installed = antigravity_desktop.install_database( + data, + session_id=TARGET_ID, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + title="Test Desktop Session", + target_home=target_home, + dry_run=False, + ) + assert installed.conversation_path.is_file() + assert installed.summaries_path.is_file() + + # Verify summary columns + with sqlite3.connect(installed.summaries_path) as conn: + row = conn.execute( + "SELECT conversation_id, title, status, source, project_id, app_data_dir " + "FROM conversation_summaries WHERE conversation_id = ?", + (TARGET_ID,), + ).fetchone() + assert row == ( + TARGET_ID, + "Test Desktop Session", + "CASCADE_RUN_STATUS_IDLE", + "", + "outside-of-project", + "antigravity", + ) + + # Re-installation collision check + with pytest.raises(SessionMigrateError, match="already exists"): + antigravity_desktop.install_database( + data, + session_id=TARGET_ID, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + title="Duplicate", + target_home=target_home, + dry_run=False, + ) + + +def test_desktop_and_cli_databases_do_not_falsely_detect_each_other(tmp_path: Path) -> None: + source = portable_session(tmp_path) + cli_data, _ = antigravity.serialize( + source, + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + ) + desktop_data, _ = antigravity_desktop.serialize( + source, + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + ) + + cli_path = tmp_path / "cli.db" + desktop_path = tmp_path / "desktop.db" + cli_path.write_bytes(cli_data) + desktop_path.write_bytes(desktop_data) + + # CLI parser rejects desktop DB + with pytest.raises(SessionMigrateError, match="CLI cascade trajectory"): + antigravity.parse(desktop_path) + + # Desktop parser rejects CLI DB + with pytest.raises(SessionMigrateError, match="Desktop cascade trajectory"): + antigravity_desktop.parse(cli_path) + + +def test_desktop_handles_cleared_step(tmp_path: Path) -> None: + # A compacted conversation can contain step status 5 (CLEARED) with minimal payload + data, _ = antigravity_desktop.serialize( + portable_session(tmp_path), + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + ) + path = tmp_path / f"{TARGET_ID}.db" + path.write_bytes(data) + + # Inject a CLEARED step + with sqlite3.connect(path) as conn: + cleared_payload = antigravity._field_varint( + 1, antigravity.STEP_TYPE_PLANNER_RESPONSE + ) + antigravity._field_varint(4, 5) + conn.execute( + "INSERT INTO steps(idx, step_type, status, has_subtrajectory, metadata, " + "step_payload, step_format) VALUES(?, ?, 5, 0, NULL, ?, 0)", + (4, antigravity.STEP_TYPE_PLANNER_RESPONSE, cleared_payload), + ) + + parsed = antigravity_desktop.parse(path) + assert any( + event.kind == EventKind.OPAQUE and event.payload.get("reason") == "antigravity_cleared_step" + for event in parsed.events + ) + + +def test_format_detection_identifies_desktop(tmp_path: Path) -> None: + from session_migrate.inspection import detect_path_format + + data, _ = antigravity_desktop.serialize( + portable_session(tmp_path), + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + ) + path = tmp_path / f"{TARGET_ID}.db" + path.write_bytes(data) + + detected = detect_path_format(path) + assert detected == AgentFormat.ANTIGRAVITY_DESKTOP + + +def test_conversion_pipeline_cli_to_desktop_and_back(tmp_path: Path) -> None: + from session_migrate.conversion import ( + ConversionOptions, + convert_session, + install_antigravity_desktop_artifact, + load_session, + ) + from session_migrate.model import TargetFormat + + # 1. Start with a CLI session + cli_data, _ = antigravity.serialize( + portable_session(tmp_path), + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + timestamp="2026-08-20T12:00:00Z", + ) + cli_path = tmp_path / f"{TARGET_ID}.db" + cli_path.write_bytes(cli_data) + loaded_cli = load_session(cli_path) + assert loaded_cli.source_format == AgentFormat.ANTIGRAVITY + + # 2. Convert CLI -> Desktop + desktop_id = "55555555-5555-4555-8555-555555555555" + artifact = convert_session( + loaded_cli, + ConversionOptions( + target_format=TargetFormat.ANTIGRAVITY_DESKTOP, + session_id=desktop_id, + cwd=tmp_path, + ), + ) + assert artifact.target_format == TargetFormat.ANTIGRAVITY_DESKTOP + desktop_home = tmp_path / "dest_desktop" + native_path, manifest_path = install_antigravity_desktop_artifact( + artifact, + target_home=desktop_home, + ) + assert native_path.is_file() + assert manifest_path.is_file() + + # 3. Load Desktop -> Convert back to CLI + loaded_desktop = load_session(native_path) + assert loaded_desktop.source_format == AgentFormat.ANTIGRAVITY_DESKTOP + assert loaded_desktop.session_id == desktop_id + + cli_dest_id = "66666666-6666-4666-8666-666666666666" + cli_artifact = convert_session( + loaded_desktop, + ConversionOptions( + target_format=TargetFormat.ANTIGRAVITY, + session_id=cli_dest_id, + cwd=tmp_path, + ), + ) + assert cli_artifact.target_format == TargetFormat.ANTIGRAVITY + assert cli_artifact.native_record_count == artifact.native_record_count diff --git a/tests/test_antigravity_desktop_native.py b/tests/test_antigravity_desktop_native.py new file mode 100644 index 0000000..6a321b1 --- /dev/null +++ b/tests/test_antigravity_desktop_native.py @@ -0,0 +1,183 @@ +import json +import os +import ssl +import subprocess +import time +import urllib.request +from pathlib import Path + +import pytest + +from session_migrate.formats import antigravity_desktop +from session_migrate.model import AgentFormat, Event, EventKind, Provenance, Role, Session + +TARGET_ID = "88888888-9999-4aaa-8bbb-cccccccccccc" +TRAJECTORY_ID = "dddddddd-eeee-4fff-8000-111111111111" + + +def exact_desktop_binary() -> Path: + binary = antigravity_desktop.DEFAULT_MACOS_LANGUAGE_SERVER_PATH + if not binary.is_file(): + pytest.skip("Antigravity Desktop language_server is not installed on this machine") + try: + return antigravity_desktop.verify_pinned_desktop(binary) + except Exception as exc: + pytest.skip(f"Antigravity Desktop binary verification failed: {exc}") + + +def test_native_desktop_language_server_loads_adapter_database(tmp_path: Path) -> None: + tmp_path = tmp_path.resolve() + binary = exact_desktop_binary() + + # Create a portable test session + events = ( + Event( + kind=EventKind.MESSAGE, + role=Role.USER, + text="Hello Antigravity Desktop from session-migrate", + timestamp="2026-09-28T12:00:00Z", + provenance=Provenance(0, "user"), + ), + Event( + kind=EventKind.MESSAGE, + role=Role.ASSISTANT, + text="Hello! Native desktop language server validation passed.", + timestamp="2026-09-28T12:00:01Z", + provenance=Provenance(1, "assistant"), + ), + ) + session = Session( + source_format=AgentFormat.ANTIGRAVITY_DESKTOP, + source_path=tmp_path / "source.db", + source_sha256="0" * 64, + session_id=TARGET_ID, + cwd=tmp_path, + started_at="2026-09-28T12:00:00Z", + cli_version=antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_VERSION, + model="gemini-2.5-pro", + title="Native Integration Test", + events=events, + raw_record_count=len(events), + ) + + data, _ = antigravity_desktop.serialize( + session, + session_id=TARGET_ID, + trajectory_id=TRAJECTORY_ID, + cwd=tmp_path, + timestamp="2026-09-28T12:00:00Z", + ) + + gemini_dir = tmp_path / ".gemini" + app_state = gemini_dir / "antigravity" + antigravity_desktop.install_database( + data, + session_id=TARGET_ID, + cwd=tmp_path, + timestamp="2026-09-28T12:00:00Z", + title="Native Integration Test", + target_home=app_state, + dry_run=False, + ) + + csrf_token = "01234567-89ab-cdef-0123-456789abcdef" + + # Spawn language_server in the isolated directory + cmd = [ + str(binary), + "--standalone", + "--override_ide_name", + "antigravity", + "--subclient_type", + "hub", + "--override_ide_version", + "2.18.1", + "--override_user_agent_name", + "antigravity", + "--https_server_port", + "0", + "--csrf_token", + csrf_token, + "-gemini_dir", + str(gemini_dir), + "-app_data_dir", + "antigravity", + ] + env = dict(os.environ) + env["HOME"] = str(tmp_path) + + proc = subprocess.Popen( + cmd, + cwd=str(app_state), + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + assert proc.stdin is not None + proc.stdin.close() + + port = None + try: + import re + + for _ in range(50): + line = proc.stdout.readline() + if not line: + break + m = re.search(r"listening on \w+ port at (\d+) for HTTP", line, re.I) + if m: + port = int(m.group(1)) + break + + assert port is not None, "Failed to capture language_server port" + + # Query Connect-RPC GetCascadeTrajectorySteps + url = f"https://127.0.0.1:{port}/exa.language_server_pb.LanguageServerService/GetCascadeTrajectorySteps" + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + req_body = json.dumps({"cascadeId": TARGET_ID}).encode() + req = urllib.request.Request( + url, + data=req_body, + headers={ + "Content-Type": "application/json", + "X-Codeium-Csrf-Token": csrf_token, + }, + method="POST", + ) + + res = None + for _attempt in range(30): + try: + with urllib.request.urlopen(req, context=ctx, timeout=5) as resp: + if resp.status == 200: + data = json.loads(resp.read().decode()) + s = data.get("steps", []) + if len(s) == 2 and "userInput" in s[0] and "plannerResponse" in s[1]: + res = data + break + except (urllib.error.HTTPError, urllib.error.URLError): + pass + time.sleep(0.2) + + assert res is not None, "Failed to get response from language server" + assert "steps" in res + steps = res["steps"] + assert len(steps) == 2 + # Step 0 is user input + assert steps[0].get("type") == "CORTEX_STEP_TYPE_USER_INPUT" + assert steps[0].get("status") == "CORTEX_STEP_STATUS_DONE" + # Step 1 is planner response + assert steps[1].get("type") == "CORTEX_STEP_TYPE_PLANNER_RESPONSE" + assert steps[1].get("status") == "CORTEX_STEP_STATUS_DONE" + + finally: + proc.terminate() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() diff --git a/tests/test_target_integration.py b/tests/test_target_integration.py index 251d599..0d2859e 100644 --- a/tests/test_target_integration.py +++ b/tests/test_target_integration.py @@ -132,6 +132,7 @@ def test_source_and_target_enums_are_deliberately_separate() -> None: AgentFormat.OPENCODE, AgentFormat.COPILOT, AgentFormat.ANTIGRAVITY, + AgentFormat.ANTIGRAVITY_DESKTOP, AgentFormat.CURSOR, AgentFormat.VIBE, AgentFormat.MUSE, @@ -152,6 +153,7 @@ def test_source_and_target_enums_are_deliberately_separate() -> None: TargetFormat.OPENCODE, TargetFormat.COPILOT, TargetFormat.ANTIGRAVITY, + TargetFormat.ANTIGRAVITY_DESKTOP, TargetFormat.CURSOR, TargetFormat.VIBE, TargetFormat.MUSE, diff --git a/uv.lock b/uv.lock index 5aeddb8..61b246f 100644 --- a/uv.lock +++ b/uv.lock @@ -5,52 +5,52 @@ requires-python = ">=3.11" [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +source = { registry = "http://proxpi.flanopticon.lan/index/" } +sdist = { url = "http://proxpi.flanopticon.lan/index/colorama/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, + { url = "http://proxpi.flanopticon.lan/index/colorama/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, ] [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +source = { registry = "http://proxpi.flanopticon.lan/index/" } +sdist = { url = "http://proxpi.flanopticon.lan/index/iniconfig/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "http://proxpi.flanopticon.lan/index/iniconfig/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, ] [[package]] name = "packaging" version = "26.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +source = { registry = "http://proxpi.flanopticon.lan/index/" } +sdist = { url = "http://proxpi.flanopticon.lan/index/packaging/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, + { url = "http://proxpi.flanopticon.lan/index/packaging/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" }, ] [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +source = { registry = "http://proxpi.flanopticon.lan/index/" } +sdist = { url = "http://proxpi.flanopticon.lan/index/pluggy/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, + { url = "http://proxpi.flanopticon.lan/index/pluggy/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, ] [[package]] name = "pygments" version = "2.21.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +source = { registry = "http://proxpi.flanopticon.lan/index/" } +sdist = { url = "http://proxpi.flanopticon.lan/index/pygments/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, + { url = "http://proxpi.flanopticon.lan/index/pygments/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" }, ] [[package]] name = "pytest" version = "9.1.1" -source = { registry = "https://pypi.org/simple" } +source = { registry = "http://proxpi.flanopticon.lan/index/" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, @@ -58,34 +58,34 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +sdist = { url = "http://proxpi.flanopticon.lan/index/pytest/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, + { url = "http://proxpi.flanopticon.lan/index/pytest/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, ] [[package]] name = "ruff" version = "0.16.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +source = { registry = "http://proxpi.flanopticon.lan/index/" } +sdist = { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, - { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, - { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, - { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, - { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, - { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, - { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, - { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, - { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948" }, + { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a" }, ] [[package]] From acfce7aaefd2687474c4cd191dba6d8c58df7bf7 Mon Sep 17 00:00:00 2001 From: Elijah Shaw-Rutschman Date: Mon, 28 Sep 2026 22:15:32 -0500 Subject: [PATCH 02/39] feat(antigravity-desktop): add cross-platform path resolution and platform guards - add default_language_server_path() supporting darwin, linux, and win32 - add electron_user_data_dir() resolving app_storage.json on macOS, Linux (XDG), and Windows (APPDATA) - update verify_pinned_desktop() with clear platform diagnostics and unvalidated bypass for Linux/Windows - document platform support status in docs/antigravity-desktop-interoperability.md - add platform resolution tests across macOS, Linux, and Windows --- docs/antigravity-desktop-interoperability.md | 11 +++ .../formats/antigravity_desktop.py | 94 +++++++++++++++++- tests/test_antigravity_desktop_format.py | 95 +++++++++++++++++++ tests/test_antigravity_desktop_native.py | 9 +- 4 files changed, 205 insertions(+), 4 deletions(-) diff --git a/docs/antigravity-desktop-interoperability.md b/docs/antigravity-desktop-interoperability.md index 1d72fd6..cb6cc90 100644 --- a/docs/antigravity-desktop-interoperability.md +++ b/docs/antigravity-desktop-interoperability.md @@ -22,6 +22,17 @@ All findings were empirically derived from isolated test environments against th No vendor binaries, proprietary descriptors, or user conversation logs are reproduced in this repository; all schema definitions were established through clean-room differential probing and synthetic round-trip verification. +### Platform Support Status + +- **Database & Wire Protocol (Cross-Platform)**: The SQLite schemas (`conversations/.db`, `conversation_summaries.db`), Protobuf wire serialization, UUID structures, and RFC 3339 timestamps are OS-agnostic and identical across macOS, Linux, and Windows. +- **macOS (`darwin-arm64`)**: Fully verified and validated end-to-end. Includes live language server subprocess execution, Connect-RPC verification (`GetCascadeTrajectorySteps`), and sidebar discovery validation. +- **Linux & Windows (Assumed Consistent, Pending Live Verification)**: + - Standard Electron path resolution is implemented: + - User configuration & `app_storage.json`: Linux uses `$XDG_CONFIG_HOME/Antigravity` (default `~/.config/Antigravity`); Windows uses `%APPDATA%\Antigravity`. + - Language server binary: Linux defaults to `/opt/Antigravity/resources/app/bin/language_server`; Windows defaults to `%LOCALAPPDATA%\Programs\Antigravity\resources\app\bin\language_server.exe`. + - Storage structures and database formats are assumed identical due to shared upstream Go/Electron codebases. + - Native binary oracle hash validation is currently pinned exclusively to macOS ARM64; running the native oracle against Linux or Windows binaries requires `SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN=1` until official vendor binary digests for those platforms are contributed. + --- ## 2. Desktop vs CLI Architectural Differences diff --git a/src/session_migrate/formats/antigravity_desktop.py b/src/session_migrate/formats/antigravity_desktop.py index 0ab96e5..24f8062 100644 --- a/src/session_migrate/formats/antigravity_desktop.py +++ b/src/session_migrate/formats/antigravity_desktop.py @@ -24,6 +24,7 @@ import shutil import sqlite3 import stat +import sys from collections import Counter from collections.abc import Mapping from dataclasses import replace @@ -79,6 +80,79 @@ DEFAULT_MACOS_LANGUAGE_SERVER_PATH = Path( "/Applications/Antigravity.app/Contents/Resources/bin/language_server" ) +DEFAULT_LINUX_LANGUAGE_SERVER_PATH = Path("/opt/Antigravity/resources/app/bin/language_server") +DEFAULT_WINDOWS_LANGUAGE_SERVER_PATH = Path( + "Programs/Antigravity/resources/app/bin/language_server.exe" +) + + +def default_language_server_path( + platform: str | None = None, + environ: Mapping[str, str] | None = None, +) -> Path: + """Resolve the default language server binary path for the target platform. + + Can be overridden via the ``SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN`` + environment variable. + """ + env = os.environ if environ is None else environ + override = env.get("SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN") + if override: + return Path(override).expanduser() + + current_platform = sys.platform if platform is None else platform + if current_platform == "darwin": + return DEFAULT_MACOS_LANGUAGE_SERVER_PATH + if current_platform == "win32": + local_app_data = env.get("LOCALAPPDATA") + if local_app_data: + candidate = Path(local_app_data) / DEFAULT_WINDOWS_LANGUAGE_SERVER_PATH + if candidate.is_file(): + return candidate + prog_files = env.get("PROGRAMFILES") + if prog_files: + candidate = ( + Path(prog_files) + / "Antigravity" + / "resources" + / "app" + / "bin" + / "language_server.exe" + ) + if candidate.is_file(): + return candidate + if local_app_data: + return Path(local_app_data) / DEFAULT_WINDOWS_LANGUAGE_SERVER_PATH + return Path("C:\\Program Files\\Antigravity\\resources\\app\\bin\\language_server.exe") + + # Linux / other Unix + return DEFAULT_LINUX_LANGUAGE_SERVER_PATH + + +def electron_user_data_dir( + platform: str | None = None, + environ: Mapping[str, str] | None = None, + home: Path | None = None, +) -> Path: + """Return the Electron userData directory where app_storage.json resides.""" + env = os.environ if environ is None else environ + current_platform = sys.platform if platform is None else platform + user_home = (home or Path.home()).expanduser() + + if current_platform == "darwin": + return user_home / "Library" / "Application Support" / "Antigravity" + if current_platform == "win32": + app_data = env.get("APPDATA") + if app_data: + return Path(app_data) / "Antigravity" + return user_home / "AppData" / "Roaming" / "Antigravity" + + # Linux / XDG standard + xdg_config = env.get("XDG_CONFIG_HOME") + if xdg_config: + return Path(xdg_config) / "Antigravity" + return user_home / ".config" / "Antigravity" + MAX_NATIVE_BYTES = antigravity.MAX_NATIVE_BYTES PROJECT_ID = antigravity.PROJECT_ID_DESKTOP # "outside-of-project" @@ -302,19 +376,25 @@ def native_record_count(data: bytes) -> int: def verify_pinned_desktop( - executable: Path | None = None, *, environ: Mapping[str, str] | None = None + executable: Path | None = None, + *, + platform: str | None = None, + environ: Mapping[str, str] | None = None, ) -> Path: """Resolve and verify the Antigravity Desktop language server binary.""" values = dict(os.environ if environ is None else environ) + current_platform = sys.platform if platform is None else platform candidate: str | None = None if executable: candidate = str(executable) + elif values.get("SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN"): + candidate = values["SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN"] elif values.get("ANTIGRAVITY_DESKTOP_BIN"): candidate = values["ANTIGRAVITY_DESKTOP_BIN"] elif values.get("ANTIGRAVITY_LANGUAGE_SERVER_BIN"): candidate = values["ANTIGRAVITY_LANGUAGE_SERVER_BIN"] - elif DEFAULT_MACOS_LANGUAGE_SERVER_PATH.is_file(): - candidate = str(DEFAULT_MACOS_LANGUAGE_SERVER_PATH) + elif default_language_server_path(platform=current_platform, environ=values).is_file(): + candidate = str(default_language_server_path(platform=current_platform, environ=values)) else: candidate = shutil.which("language_server", path=values.get("PATH")) @@ -331,6 +411,14 @@ def verify_pinned_desktop( if values.get("SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN") == "1": return path + if current_platform != "darwin": + raise SessionMigrateError( + "Antigravity Desktop native binary verification is currently pinned to macOS arm64 " + f"({PINNED_ANTIGRAVITY_DESKTOP_VERSION}); Linux and Windows binary hashes have not " + "yet been pinned. " + "Set SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN=1 to proceed with an unvalidated binary." + ) + if info.st_size != PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SIZE: raise SessionMigrateError( "Antigravity Desktop binary size mismatch: automatic import requires the exact " diff --git a/tests/test_antigravity_desktop_format.py b/tests/test_antigravity_desktop_format.py index 9cbabb6..0bc3694 100644 --- a/tests/test_antigravity_desktop_format.py +++ b/tests/test_antigravity_desktop_format.py @@ -335,3 +335,98 @@ def test_conversion_pipeline_cli_to_desktop_and_back(tmp_path: Path) -> None: ) assert cli_artifact.target_format == TargetFormat.ANTIGRAVITY assert cli_artifact.native_record_count == artifact.native_record_count + + +def test_default_language_server_path_platform_matrix(tmp_path: Path) -> None: + # 1. Environment override + custom_bin = tmp_path / "custom_server" + resolved = antigravity_desktop.default_language_server_path( + platform="darwin", + environ={"SESSION_MIGRATE_ANTIGRAVITY_DESKTOP_BIN": str(custom_bin)}, + ) + assert resolved == custom_bin + + # 2. Darwin default + resolved_mac = antigravity_desktop.default_language_server_path( + platform="darwin", + environ={}, + ) + assert resolved_mac == antigravity_desktop.DEFAULT_MACOS_LANGUAGE_SERVER_PATH + + # 3. Linux default + resolved_linux = antigravity_desktop.default_language_server_path( + platform="linux", + environ={}, + ) + assert resolved_linux == antigravity_desktop.DEFAULT_LINUX_LANGUAGE_SERVER_PATH + + # 4. Windows default with LOCALAPPDATA + resolved_win = antigravity_desktop.default_language_server_path( + platform="win32", + environ={"LOCALAPPDATA": "C:\\Users\\tester\\AppData\\Local"}, + ) + assert "language_server.exe" in str(resolved_win) + + +def test_electron_user_data_dir_platform_matrix(tmp_path: Path) -> None: + fake_home = tmp_path / "fakehome" + + # Darwin + mac_dir = antigravity_desktop.electron_user_data_dir( + platform="darwin", + home=fake_home, + ) + assert mac_dir == fake_home / "Library" / "Application Support" / "Antigravity" + + # Windows with APPDATA + win_dir = antigravity_desktop.electron_user_data_dir( + platform="win32", + environ={"APPDATA": "C:\\Users\\tester\\AppData\\Roaming"}, + home=fake_home, + ) + assert win_dir == Path("C:\\Users\\tester\\AppData\\Roaming") / "Antigravity" + + # Windows fallback + win_fallback = antigravity_desktop.electron_user_data_dir( + platform="win32", + environ={}, + home=fake_home, + ) + assert win_fallback == fake_home / "AppData" / "Roaming" / "Antigravity" + + # Linux with XDG_CONFIG_HOME + linux_xdg = antigravity_desktop.electron_user_data_dir( + platform="linux", + environ={"XDG_CONFIG_HOME": "/tmp/custom_config"}, + home=fake_home, + ) + assert linux_xdg == Path("/tmp/custom_config/Antigravity") + + # Linux default fallback + linux_fallback = antigravity_desktop.electron_user_data_dir( + platform="linux", + environ={}, + home=fake_home, + ) + assert linux_fallback == fake_home / ".config" / "Antigravity" + + +def test_verify_pinned_desktop_platform_guards(tmp_path: Path) -> None: + fake_bin = tmp_path / "language_server" + fake_bin.write_bytes(b"x" * antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SIZE) + + # Non-darwin should raise explicit error without unvalidated bypass + with pytest.raises(SessionMigrateError, match="currently pinned to macOS arm64"): + antigravity_desktop.verify_pinned_desktop( + fake_bin, + platform="linux", + environ={}, + ) + + # With SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN=1, it should accept the path + bypassed = antigravity_desktop.verify_pinned_desktop( + fake_bin, + platform="linux", + environ={"SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN": "1"}, + ) + assert bypassed == fake_bin.resolve() diff --git a/tests/test_antigravity_desktop_native.py b/tests/test_antigravity_desktop_native.py index 6a321b1..5df9cb9 100644 --- a/tests/test_antigravity_desktop_native.py +++ b/tests/test_antigravity_desktop_native.py @@ -16,7 +16,14 @@ def exact_desktop_binary() -> Path: - binary = antigravity_desktop.DEFAULT_MACOS_LANGUAGE_SERVER_PATH + import sys + + if sys.platform != "darwin": + pytest.skip( + "Antigravity Desktop native language server test is currently pinned and verified " + "for macOS; Linux and Windows binary verification is pending" + ) + binary = antigravity_desktop.default_language_server_path() if not binary.is_file(): pytest.skip("Antigravity Desktop language_server is not installed on this machine") try: From d87b2156fffed1c6e5814e7ebc369c95655d0209 Mon Sep 17 00:00:00 2001 From: Elijah Shaw-Rutschman Date: Mon, 28 Sep 2026 22:35:37 -0500 Subject: [PATCH 03/39] fix(antigravity-desktop): correct language_server location to resources/bin and bump version to 2.18.1 --- docs/antigravity-desktop-interoperability.md | 7 ++++--- .../formats/antigravity_desktop.py | 15 +++++---------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/docs/antigravity-desktop-interoperability.md b/docs/antigravity-desktop-interoperability.md index cb6cc90..062da74 100644 --- a/docs/antigravity-desktop-interoperability.md +++ b/docs/antigravity-desktop-interoperability.md @@ -29,9 +29,10 @@ No vendor binaries, proprietary descriptors, or user conversation logs are repro - **Linux & Windows (Assumed Consistent, Pending Live Verification)**: - Standard Electron path resolution is implemented: - User configuration & `app_storage.json`: Linux uses `$XDG_CONFIG_HOME/Antigravity` (default `~/.config/Antigravity`); Windows uses `%APPDATA%\Antigravity`. - - Language server binary: Linux defaults to `/opt/Antigravity/resources/app/bin/language_server`; Windows defaults to `%LOCALAPPDATA%\Programs\Antigravity\resources\app\bin\language_server.exe`. - - Storage structures and database formats are assumed identical due to shared upstream Go/Electron codebases. - - Native binary oracle hash validation is currently pinned exclusively to macOS ARM64; running the native oracle against Linux or Windows binaries requires `SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN=1` until official vendor binary digests for those platforms are contributed. + - Language server binary: Linux defaults to `/opt/Antigravity/resources/bin/language_server`; Windows defaults to `%LOCALAPPDATA%\Programs\Antigravity\resources\bin\language_server.exe`. + - Static package analysis confirms `app.asar` (SHA-256 `3c03ce35…e7a6ac5c`) is identical across macOS and Linux, sharing the same `languageServer.js` launcher and arguments across `darwin`, `linux`, and `win32`. + - Linux x86_64 binary metrics observed: `181,432,528` bytes, SHA-256 `ab493744…3198670e`. + - Native binary oracle hash validation is currently pinned to macOS ARM64; running the native oracle against Linux or Windows binaries requires `SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN=1` until official vendor binary digests for those platforms are contributed. --- diff --git a/src/session_migrate/formats/antigravity_desktop.py b/src/session_migrate/formats/antigravity_desktop.py index 24f8062..6046d3d 100644 --- a/src/session_migrate/formats/antigravity_desktop.py +++ b/src/session_migrate/formats/antigravity_desktop.py @@ -71,7 +71,7 @@ from session_migrate.jsonl import write_private_atomic from session_migrate.model import AgentFormat, EventKind, Role, Session -PINNED_ANTIGRAVITY_DESKTOP_VERSION = "2.17.0" +PINNED_ANTIGRAVITY_DESKTOP_VERSION = "2.18.1" PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SHA256 = ( "300ee20f3108a511be1149602b91ba84cbbdcc42213067151e255584129be30f" ) @@ -80,9 +80,9 @@ DEFAULT_MACOS_LANGUAGE_SERVER_PATH = Path( "/Applications/Antigravity.app/Contents/Resources/bin/language_server" ) -DEFAULT_LINUX_LANGUAGE_SERVER_PATH = Path("/opt/Antigravity/resources/app/bin/language_server") +DEFAULT_LINUX_LANGUAGE_SERVER_PATH = Path("/opt/Antigravity/resources/bin/language_server") DEFAULT_WINDOWS_LANGUAGE_SERVER_PATH = Path( - "Programs/Antigravity/resources/app/bin/language_server.exe" + "Programs/Antigravity/resources/bin/language_server.exe" ) @@ -112,18 +112,13 @@ def default_language_server_path( prog_files = env.get("PROGRAMFILES") if prog_files: candidate = ( - Path(prog_files) - / "Antigravity" - / "resources" - / "app" - / "bin" - / "language_server.exe" + Path(prog_files) / "Antigravity" / "resources" / "bin" / "language_server.exe" ) if candidate.is_file(): return candidate if local_app_data: return Path(local_app_data) / DEFAULT_WINDOWS_LANGUAGE_SERVER_PATH - return Path("C:\\Program Files\\Antigravity\\resources\\app\\bin\\language_server.exe") + return Path("C:\\Program Files\\Antigravity\\resources\\bin\\language_server.exe") # Linux / other Unix return DEFAULT_LINUX_LANGUAGE_SERVER_PATH From b3fd43d599fece7db8d45f787bcc8f355142e964 Mon Sep 17 00:00:00 2001 From: Elijah Shaw-Rutschman Date: Mon, 28 Sep 2026 22:42:36 -0500 Subject: [PATCH 04/39] feat(antigravity-desktop): pin binary specifications for macOS, Linux, and Windows - add verified binary sizes and SHA-256 digests for Linux x64 and Windows x64 - implement PINNED_DESKTOP_SPECS table validating binaries against platform-specific digests - enhance Linux search order (PATH launcher, /opt, ~/.local/share) and Windows case-insensitivity - update documentation with complete multi-platform installer matrix --- docs/antigravity-desktop-interoperability.md | 35 ++++--- .../formats/antigravity_desktop.py | 95 ++++++++++++++----- tests/test_antigravity_desktop_format.py | 12 ++- 3 files changed, 100 insertions(+), 42 deletions(-) diff --git a/docs/antigravity-desktop-interoperability.md b/docs/antigravity-desktop-interoperability.md index 062da74..8b21b75 100644 --- a/docs/antigravity-desktop-interoperability.md +++ b/docs/antigravity-desktop-interoperability.md @@ -6,19 +6,19 @@ This document provides a clean-room technical specification of the session stora ## 1. Pinned Host Environment & Binary Artifacts -All findings were empirically derived from isolated test environments against the official macOS distribution of Antigravity Desktop: - -| Component | Pinned Specification | -|---|---| -| **App Bundle** | `/Applications/Antigravity.app` | -| **App Version** | `2.17.0` (`CFBundleShortVersionString`), `2.18.1` (`package.json`) | -| **Language Server Binary** | `/Applications/Antigravity.app/Contents/Resources/bin/language_server` | -| **Language Server Size** | `148,990,608` bytes | -| **Language Server SHA-256** | `300ee20f3108a511be1149602b91ba84cbbdcc42213067151e255584129be30f` | -| **Language Server Build CL** | `CL 989808170` | -| **Platform Target** | `darwin-arm64` (Apple Silicon) | -| **Conversation Storage** | `~/.gemini/antigravity/conversations/.db` | -| **Summary & Index Store** | `~/.gemini/antigravity/conversation_summaries.db` (`PRAGMA user_version = 3`) | +All findings were empirically derived from isolated test environments and installer package analysis across the official distributions of Antigravity Desktop 2.18.1: + +| Platform | Installer / Package | Language Server Binary | Size (bytes) | SHA-256 | +|---|---|---|---|---| +| **macOS (arm64)** | DMG (`Antigravity.app`) | `Contents/Resources/bin/language_server` | `148,990,608` | `300ee20f3108a511be1149602b91ba84cbbdcc42213067151e255584129be30f` | +| **Linux (x64)** | `Antigravity.tar.gz` | `/resources/bin/language_server` | `181,432,528` | `ab4937445fa3817bc374a1db71062de7daecdb093cbfb6bd36b80f443198670e` | +| **Windows (x64)** | NSIS (`Antigravity-x64.exe`) | `\resources\bin\language_server.exe` | `163,640,320` | `1ed84e6a1d1e51064d80c9f382ab3a519eb2775cb91d552c63064a18cfdf3cf2` | + +- **App Version**: `2.18.1` (`package.json`, `dist/main.js`), Electron `44.3.0`. +- **Application Data Directory**: `/.gemini/antigravity` across all platforms. +- **Conversation Storage**: `/.gemini/antigravity/conversations/.db` +- **Summary & Index Store**: `/.gemini/antigravity/conversation_summaries.db` (`PRAGMA user_version = 3`) +- **Shared JavaScript**: Static package analysis confirms `resources/app.asar` (SHA-256 `3c03ce352dc3c1b43f357c27ead1f73c74d198a8ded89b1b3d6a2539e7a6ac5c`) is byte-identical across all three installers, sharing the same `languageServer.js` launcher and arguments across `darwin`, `linux`, and `win32`. No vendor binaries, proprietary descriptors, or user conversation logs are reproduced in this repository; all schema definitions were established through clean-room differential probing and synthetic round-trip verification. @@ -26,13 +26,12 @@ No vendor binaries, proprietary descriptors, or user conversation logs are repro - **Database & Wire Protocol (Cross-Platform)**: The SQLite schemas (`conversations/.db`, `conversation_summaries.db`), Protobuf wire serialization, UUID structures, and RFC 3339 timestamps are OS-agnostic and identical across macOS, Linux, and Windows. - **macOS (`darwin-arm64`)**: Fully verified and validated end-to-end. Includes live language server subprocess execution, Connect-RPC verification (`GetCascadeTrajectorySteps`), and sidebar discovery validation. -- **Linux & Windows (Assumed Consistent, Pending Live Verification)**: +- **Linux & Windows**: - Standard Electron path resolution is implemented: - User configuration & `app_storage.json`: Linux uses `$XDG_CONFIG_HOME/Antigravity` (default `~/.config/Antigravity`); Windows uses `%APPDATA%\Antigravity`. - - Language server binary: Linux defaults to `/opt/Antigravity/resources/bin/language_server`; Windows defaults to `%LOCALAPPDATA%\Programs\Antigravity\resources\bin\language_server.exe`. - - Static package analysis confirms `app.asar` (SHA-256 `3c03ce35…e7a6ac5c`) is identical across macOS and Linux, sharing the same `languageServer.js` launcher and arguments across `darwin`, `linux`, and `win32`. - - Linux x86_64 binary metrics observed: `181,432,528` bytes, SHA-256 `ab493744…3198670e`. - - Native binary oracle hash validation is currently pinned to macOS ARM64; running the native oracle against Linux or Windows binaries requires `SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN=1` until official vendor binary digests for those platforms are contributed. + - Language server binary: Linux searches PATH, common `/opt/Antigravity/resources/bin/language_server`, and `~/.local/share/`; Windows searches `%LOCALAPPDATA%\Programs\antigravity\resources\bin\language_server.exe` and `%PROGRAMFILES%`. + - All three platforms are pinned by exact file size and SHA-256 checksum in `PINNED_DESKTOP_SPECS`. + - Live native oracle execution on Linux/Windows remains pending live installation confirmation, but binary verification and path resolution are active for all three platforms. --- diff --git a/src/session_migrate/formats/antigravity_desktop.py b/src/session_migrate/formats/antigravity_desktop.py index 6046d3d..272d66b 100644 --- a/src/session_migrate/formats/antigravity_desktop.py +++ b/src/session_migrate/formats/antigravity_desktop.py @@ -72,23 +72,53 @@ from session_migrate.model import AgentFormat, EventKind, Role, Session PINNED_ANTIGRAVITY_DESKTOP_VERSION = "2.18.1" + +# macOS arm64 PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SHA256 = ( "300ee20f3108a511be1149602b91ba84cbbdcc42213067151e255584129be30f" ) PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SIZE = 148_990_608 +# Linux x64 +PINNED_ANTIGRAVITY_DESKTOP_LINUX_X64_SHA256 = ( + "ab4937445fa3817bc374a1db71062de7daecdb093cbfb6bd36b80f443198670e" +) +PINNED_ANTIGRAVITY_DESKTOP_LINUX_X64_SIZE = 181_432_528 + +# Windows x64 +PINNED_ANTIGRAVITY_DESKTOP_WINDOWS_X64_SHA256 = ( + "1ed84e6a1d1e51064d80c9f382ab3a519eb2775cb91d552c63064a18cfdf3cf2" +) +PINNED_ANTIGRAVITY_DESKTOP_WINDOWS_X64_SIZE = 163_640_320 + +PINNED_DESKTOP_SPECS: dict[str, tuple[int, str]] = { + "darwin": ( + PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SIZE, + PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SHA256, + ), + "linux": ( + PINNED_ANTIGRAVITY_DESKTOP_LINUX_X64_SIZE, + PINNED_ANTIGRAVITY_DESKTOP_LINUX_X64_SHA256, + ), + "win32": ( + PINNED_ANTIGRAVITY_DESKTOP_WINDOWS_X64_SIZE, + PINNED_ANTIGRAVITY_DESKTOP_WINDOWS_X64_SHA256, + ), +} + DEFAULT_MACOS_LANGUAGE_SERVER_PATH = Path( "/Applications/Antigravity.app/Contents/Resources/bin/language_server" ) DEFAULT_LINUX_LANGUAGE_SERVER_PATH = Path("/opt/Antigravity/resources/bin/language_server") DEFAULT_WINDOWS_LANGUAGE_SERVER_PATH = Path( - "Programs/Antigravity/resources/bin/language_server.exe" + "Programs/antigravity/resources/bin/language_server.exe" ) def default_language_server_path( platform: str | None = None, environ: Mapping[str, str] | None = None, + home: Path | None = None, ) -> Path: """Resolve the default language server binary path for the target platform. @@ -106,21 +136,41 @@ def default_language_server_path( if current_platform == "win32": local_app_data = env.get("LOCALAPPDATA") if local_app_data: - candidate = Path(local_app_data) / DEFAULT_WINDOWS_LANGUAGE_SERVER_PATH - if candidate.is_file(): - return candidate + for sub in ( + "Programs/antigravity", + "Programs/Antigravity", + "programs/antigravity", + "programs/Antigravity", + ): + candidate = Path(local_app_data) / sub / "resources" / "bin" / "language_server.exe" + if candidate.is_file(): + return candidate prog_files = env.get("PROGRAMFILES") if prog_files: - candidate = ( - Path(prog_files) / "Antigravity" / "resources" / "bin" / "language_server.exe" - ) - if candidate.is_file(): - return candidate + for sub in ("antigravity", "Antigravity"): + candidate = Path(prog_files) / sub / "resources" / "bin" / "language_server.exe" + if candidate.is_file(): + return candidate if local_app_data: return Path(local_app_data) / DEFAULT_WINDOWS_LANGUAGE_SERVER_PATH - return Path("C:\\Program Files\\Antigravity\\resources\\bin\\language_server.exe") - - # Linux / other Unix + return Path("C:\\Program Files\\antigravity\\resources\\bin\\language_server.exe") + + # Linux / other Unix: check PATH launcher, then common installation directories + launcher = shutil.which("antigravity", path=env.get("PATH")) + if launcher: + cand = Path(launcher).resolve().parent / "resources" / "bin" / "language_server" + if cand.is_file(): + return cand + user_home = (home or Path.home()).expanduser() + for cand in ( + Path("/opt/Antigravity/resources/bin/language_server"), + Path("/opt/antigravity/resources/bin/language_server"), + user_home / ".local/share/Antigravity/resources/bin/language_server", + user_home / ".local/share/antigravity/resources/bin/language_server", + Path("/usr/lib/antigravity/resources/bin/language_server"), + ): + if cand.is_file(): + return cand return DEFAULT_LINUX_LANGUAGE_SERVER_PATH @@ -406,23 +456,24 @@ def verify_pinned_desktop( if values.get("SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN") == "1": return path - if current_platform != "darwin": + if current_platform not in PINNED_DESKTOP_SPECS: raise SessionMigrateError( - "Antigravity Desktop native binary verification is currently pinned to macOS arm64 " - f"({PINNED_ANTIGRAVITY_DESKTOP_VERSION}); Linux and Windows binary hashes have not " - "yet been pinned. " + f"Antigravity Desktop native binary verification is not supported on " + f"{current_platform}; supported platforms are darwin, linux, win32. " "Set SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN=1 to proceed with an unvalidated binary." ) - if info.st_size != PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SIZE: + expected_size, expected_digest = PINNED_DESKTOP_SPECS[current_platform] + if info.st_size != expected_size: raise SessionMigrateError( - "Antigravity Desktop binary size mismatch: automatic import requires the exact " - f"macOS arm64 {PINNED_ANTIGRAVITY_DESKTOP_VERSION} build" + f"Antigravity Desktop binary size mismatch on {current_platform}: expected " + f"{expected_size} bytes, observed {info.st_size}" ) - digest = _stream_sha256(path, maximum=PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SIZE) - if digest != PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SHA256: + digest = _stream_sha256(path, maximum=expected_size) + if digest != expected_digest: raise SessionMigrateError( - "Antigravity Desktop binary digest mismatch; refusing private-store installation" + f"Antigravity Desktop binary digest mismatch on {current_platform}; " + "refusing private-store installation" ) return path diff --git a/tests/test_antigravity_desktop_format.py b/tests/test_antigravity_desktop_format.py index 0bc3694..8877c5e 100644 --- a/tests/test_antigravity_desktop_format.py +++ b/tests/test_antigravity_desktop_format.py @@ -415,14 +415,22 @@ def test_verify_pinned_desktop_platform_guards(tmp_path: Path) -> None: fake_bin = tmp_path / "language_server" fake_bin.write_bytes(b"x" * antigravity_desktop.PINNED_ANTIGRAVITY_DESKTOP_MACOS_ARM64_SIZE) - # Non-darwin should raise explicit error without unvalidated bypass - with pytest.raises(SessionMigrateError, match="currently pinned to macOS arm64"): + # Size mismatch on Linux because fake_bin has macOS size + with pytest.raises(SessionMigrateError, match="binary size mismatch on linux"): antigravity_desktop.verify_pinned_desktop( fake_bin, platform="linux", environ={}, ) + # Unsupported platform raises clear message + with pytest.raises(SessionMigrateError, match="not supported on freebsd"): + antigravity_desktop.verify_pinned_desktop( + fake_bin, + platform="freebsd", + environ={}, + ) + # With SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN=1, it should accept the path bypassed = antigravity_desktop.verify_pinned_desktop( fake_bin, From 9a420d8dae1e2ec8eafed0e5f278b01a64f6a993 Mon Sep 17 00:00:00 2001 From: Elijah Shaw-Rutschman Date: Mon, 28 Sep 2026 22:51:33 -0500 Subject: [PATCH 05/39] fix(antigravity-desktop): bump module docstring version and include Antigravity-x64 in Linux search order --- src/session_migrate/formats/antigravity_desktop.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/session_migrate/formats/antigravity_desktop.py b/src/session_migrate/formats/antigravity_desktop.py index 272d66b..bacc6e1 100644 --- a/src/session_migrate/formats/antigravity_desktop.py +++ b/src/session_migrate/formats/antigravity_desktop.py @@ -1,4 +1,4 @@ -"""Antigravity Desktop 2.17.0 SQLite/protobuf session adapter. +"""Antigravity Desktop 2.18.1 SQLite/protobuf session adapter. This module adapts conversations to and from the Antigravity Desktop application (macOS/Electron), which stores individual cascade conversations as SQLite databases @@ -164,7 +164,9 @@ def default_language_server_path( user_home = (home or Path.home()).expanduser() for cand in ( Path("/opt/Antigravity/resources/bin/language_server"), + Path("/opt/Antigravity-x64/resources/bin/language_server"), Path("/opt/antigravity/resources/bin/language_server"), + user_home / "Antigravity-x64/resources/bin/language_server", user_home / ".local/share/Antigravity/resources/bin/language_server", user_home / ".local/share/antigravity/resources/bin/language_server", Path("/usr/lib/antigravity/resources/bin/language_server"), From 59f2900ce39333cd2b7befe552566bcff1c12c92 Mon Sep 17 00:00:00 2001 From: Elijah Shaw-Rutschman Date: Mon, 28 Sep 2026 22:53:18 -0500 Subject: [PATCH 06/39] docs(antigravity-desktop): align format documentation name and invocation arguments - rename docs/antigravity-desktop-interoperability.md to docs/antigravity-desktop-format.md to match repository naming convention - update invocation flags and arguments to reflect verified package launcher details - update reference in CHANGELOG.md --- CHANGELOG.md | 2 +- ...roperability.md => antigravity-desktop-format.md} | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) rename docs/{antigravity-desktop-interoperability.md => antigravity-desktop-format.md} (95%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 809c1b9..e60acbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ here. Native format compatibility is documented separately in sidebar discovery, and verified native Go language server integration. - Document the clean-room Antigravity Desktop storage architecture, Protobuf wire formats, Connect-RPC interfaces, and collision invariants in - `docs/antigravity-desktop-interoperability.md`. + `docs/antigravity-desktop-format.md`. ## 0.11.0 - 2026-09-11 diff --git a/docs/antigravity-desktop-interoperability.md b/docs/antigravity-desktop-format.md similarity index 95% rename from docs/antigravity-desktop-interoperability.md rename to docs/antigravity-desktop-format.md index 8b21b75..6982f26 100644 --- a/docs/antigravity-desktop-interoperability.md +++ b/docs/antigravity-desktop-format.md @@ -263,7 +263,7 @@ Antigravity Desktop's Electron frontend communicates with the background Go nati ### 6.1 Invocation Arguments ```bash -/Applications/Antigravity.app/Contents/Resources/bin/language_server \ +/resources/bin/language_server \ --standalone \ --override_ide_name antigravity \ --subclient_type hub \ @@ -271,10 +271,16 @@ Antigravity Desktop's Electron frontend communicates with the background Go nati --override_user_agent_name antigravity \ --https_server_port 0 \ --csrf_token \ - -gemini_dir ~/.gemini \ - -app_data_dir antigravity + --app_data_dir antigravity \ + --api_server_url https://generativelanguage.googleapis.com \ + --cloud_code_endpoint https://daily-cloudcode-pa.googleapis.com \ + --enable_sidecars ``` +Key flags observed from package analysis: +- `--app_data_dir antigravity`: Critical. The binary's internal default is `antigravity-ide`; passing `antigravity` directs it to `/.gemini/antigravity`. +- `--override_ide_name antigravity` and `--subclient_type hub`: Required. In this mode, the server manages `agyhub_summaries_proto.pb` and project catalogs. +- `-gemini_dir `: The Electron launcher omits this flag (defaulting to `/.gemini`), but the binary accepts it when running isolated test sandboxes. - When `--https_server_port 0` is passed, the server binds to a random loopback port and logs: `Language server listening on random port at for HTTPS (gRPC)` - The server expects `stdin` to be closed immediately upon spawn (unless in WSL liveness mode). From 9b7a67ccfbff731e3acb75127542bbc96fcf7dfe Mon Sep 17 00:00:00 2001 From: Elijah Shaw-Rutschman Date: Mon, 28 Sep 2026 22:59:41 -0500 Subject: [PATCH 07/39] docs(antigravity-desktop): structure format doc to mirror antigravity-format.md standards --- docs/antigravity-desktop-format.md | 504 +++++++++++------------------ 1 file changed, 182 insertions(+), 322 deletions(-) diff --git a/docs/antigravity-desktop-format.md b/docs/antigravity-desktop-format.md index 6982f26..471e0be 100644 --- a/docs/antigravity-desktop-format.md +++ b/docs/antigravity-desktop-format.md @@ -1,329 +1,189 @@ -# Antigravity Desktop Session Interoperability Specification - -This document provides a clean-room technical specification of the session storage architecture, SQLite schemas, Protobuf wire formats, Connect-RPC interfaces, and migration mechanics for **Antigravity Desktop**, complementing the research published in [antigravity-session-interoperability](https://github.com/xhluca/antigravity-session-interoperability). - ---- - -## 1. Pinned Host Environment & Binary Artifacts - -All findings were empirically derived from isolated test environments and installer package analysis across the official distributions of Antigravity Desktop 2.18.1: - -| Platform | Installer / Package | Language Server Binary | Size (bytes) | SHA-256 | -|---|---|---|---|---| -| **macOS (arm64)** | DMG (`Antigravity.app`) | `Contents/Resources/bin/language_server` | `148,990,608` | `300ee20f3108a511be1149602b91ba84cbbdcc42213067151e255584129be30f` | -| **Linux (x64)** | `Antigravity.tar.gz` | `/resources/bin/language_server` | `181,432,528` | `ab4937445fa3817bc374a1db71062de7daecdb093cbfb6bd36b80f443198670e` | -| **Windows (x64)** | NSIS (`Antigravity-x64.exe`) | `\resources\bin\language_server.exe` | `163,640,320` | `1ed84e6a1d1e51064d80c9f382ab3a519eb2775cb91d552c63064a18cfdf3cf2` | - -- **App Version**: `2.18.1` (`package.json`, `dist/main.js`), Electron `44.3.0`. -- **Application Data Directory**: `/.gemini/antigravity` across all platforms. -- **Conversation Storage**: `/.gemini/antigravity/conversations/.db` -- **Summary & Index Store**: `/.gemini/antigravity/conversation_summaries.db` (`PRAGMA user_version = 3`) -- **Shared JavaScript**: Static package analysis confirms `resources/app.asar` (SHA-256 `3c03ce352dc3c1b43f357c27ead1f73c74d198a8ded89b1b3d6a2539e7a6ac5c`) is byte-identical across all three installers, sharing the same `languageServer.js` launcher and arguments across `darwin`, `linux`, and `win32`. - -No vendor binaries, proprietary descriptors, or user conversation logs are reproduced in this repository; all schema definitions were established through clean-room differential probing and synthetic round-trip verification. - -### Platform Support Status - -- **Database & Wire Protocol (Cross-Platform)**: The SQLite schemas (`conversations/.db`, `conversation_summaries.db`), Protobuf wire serialization, UUID structures, and RFC 3339 timestamps are OS-agnostic and identical across macOS, Linux, and Windows. -- **macOS (`darwin-arm64`)**: Fully verified and validated end-to-end. Includes live language server subprocess execution, Connect-RPC verification (`GetCascadeTrajectorySteps`), and sidebar discovery validation. -- **Linux & Windows**: - - Standard Electron path resolution is implemented: - - User configuration & `app_storage.json`: Linux uses `$XDG_CONFIG_HOME/Antigravity` (default `~/.config/Antigravity`); Windows uses `%APPDATA%\Antigravity`. - - Language server binary: Linux searches PATH, common `/opt/Antigravity/resources/bin/language_server`, and `~/.local/share/`; Windows searches `%LOCALAPPDATA%\Programs\antigravity\resources\bin\language_server.exe` and `%PROGRAMFILES%`. - - All three platforms are pinned by exact file size and SHA-256 checksum in `PINNED_DESKTOP_SPECS`. - - Live native oracle execution on Linux/Windows remains pending live installation confirmation, but binary verification and path resolution are active for all three platforms. - ---- - -## 2. Desktop vs CLI Architectural Differences - -While both Antigravity CLI (`agy`) and Antigravity Desktop (`Antigravity.app`) share an underlying SQLite + Protobuf storage engine, their database schemas and wire properties exhibit distinct constraints: - -| Property | Antigravity CLI (`antigravity`) | Antigravity Desktop (`antigravity-desktop`) | Rationale / Mechanism | -|---|---|---|---| -| **App Data Subdirectory** | `~/.gemini/antigravity-cli` | `~/.gemini/antigravity` | Passed via `-app_data_dir=antigravity` flag to `language_server` | -| **`trajectory_meta.source`** | `17` (`CLI`) | `1` (`IDE` / `Hub`) | Source identifier enum in metadata table | -| **`trajectory_metadata_blob` Project ID** | `"default-cli-project"` | `"outside-of-project"` (or workspace UUID) | Project boundary discriminator (Protobuf field 18) | -| **`conversation_summaries.source`** | `"antigravity-cli"` | `""` (empty string) | Desktop UI filter expects empty source string | -| **`conversation_summaries.app_data_dir`** | `"antigravity-cli"` | `"antigravity"` | Tells the IDE process where to resolve conversation DBs | -| **`conversation_summaries.status`** | `""` (empty string) | `"CASCADE_RUN_STATUS_IDLE"` | Desktop UI cascade lifecycle state | -| **`conversation_summaries.project_id`** | `"default-cli-project"` | `"outside-of-project"` | Project grouping in desktop sidebar | -| **Summary Schema Columns** | 19 or 21 columns | 21 columns (includes `raw_summary` BLOB, `group_id` TEXT) | Extended schema in `user_version = 3` | -| **Subagent Trajectory Roots** | Root ID == Conversation ID | Root ID == Root Cascade ID (nesting depth $\ge 1$) | Subagent conversations reference their parent root | -| **Extended Step Statuses** | `3` (`DONE`), `7` (`ERROR`) | `3` (`DONE`), `5` (`CLEARED`), `7` (`ERROR`) | Status 5 occurs when user clears a conversation step | - ---- - -## 3. SQLite Database Layout - -### 3.1 Conversation Database (`conversations/.db`) - -Each conversation is stored as an independent, single-file SQLite 3 database with WAL mode enabled. It contains 7 tables: - -```sql -CREATE TABLE trajectory_meta ( - trajectory_id TEXT, - conversation_id TEXT, - trajectory_type INTEGER, - source INTEGER -); - -CREATE TABLE steps ( - idx INTEGER PRIMARY KEY, - step_type INTEGER NOT NULL DEFAULT 0, - status INTEGER NOT NULL DEFAULT 0, - has_subtrajectory NUMERIC NOT NULL DEFAULT false, - metadata BLOB, - error_details BLOB, - permissions BLOB, - task_details BLOB, - render_info BLOB, - step_payload BLOB, - step_format INTEGER NOT NULL DEFAULT 0 -); - -CREATE TABLE gen_metadata ( - idx INTEGER PRIMARY KEY, - data BLOB, - size INTEGER NOT NULL DEFAULT 0 -); - -CREATE TABLE executor_metadata ( - idx INTEGER PRIMARY KEY, - data BLOB -); - -CREATE TABLE parent_references ( - idx INTEGER PRIMARY KEY, - data BLOB -); - -CREATE TABLE trajectory_metadata_blob ( - id TEXT PRIMARY KEY DEFAULT "main", - data BLOB -); - -CREATE TABLE battle_mode_infos ( - idx INTEGER PRIMARY KEY, - data BLOB -); +# Antigravity Desktop session format + +`session-migrate` supports Antigravity Desktop (macOS, Linux, and Windows) +through a clean-room adapter pinned to the 2.18.1 release: + +| Property | Pinned value | +| --- | --- | +| Application | Antigravity Desktop `2.18.1` (Electron `44.3.0`) | +| macOS arm64 binary | `148,990,608` bytes, SHA-256 `300ee20f3108a511be1149602b91ba84cbbdcc42213067151e255584129be30f` | +| Linux x64 binary | `181,432,528` bytes, SHA-256 `ab4937445fa3817bc374a1db71062de7daecdb093cbfb6bd36b80f443198670e` | +| Windows x64 binary | `163,640,320` bytes, SHA-256 `1ed84e6a1d1e51064d80c9f382ab3a519eb2775cb91d552c63064a18cfdf3cf2` | +| Conversation store | `/.gemini/antigravity/conversations/.db` | +| Picker index | `/.gemini/antigravity/conversation_summaries.db` (`PRAGMA user_version = 3`) | +| Hub summary cache | `/.gemini/antigravity/agyhub_summaries_proto.pb` | +| UI project catalog | `/.gemini/config/projects/.json` + `app_storage.json` | + +This is deliberately not a claim of compatibility with unreleased or arbitrary +Antigravity versions. Automatic installation checks executable digests and +schema invariants when native verification is requested. + +No vendor code, binaries, descriptors, credentials, or user transcripts are +included in this repository. All schemas, wire mappings, and RPC endpoints were +established through clean-room differential probing and synthetic test fixtures. + +## How the format was established + +The adapter was derived from independent observations of synthetic sessions and +package analysis across macOS, Linux, and Windows distributions: + +1. generate synthetic conversations with unique marker text in isolated environments; +2. inspect SQLite schema, columns, and Protobuf wire field numbers and types; +3. analyze Electron application resources (`app.asar`), verifying identical JavaScript + (`3c03ce352dc3c1b43f357c27ead1f73c74d198a8ded89b1b3d6a2539e7a6ac5c`) across all three OSes; +4. inspect native Go language server (`language_server`) symbols and Connect-RPC endpoints; +5. verify round-trip serialization and cold-reload step retrieval via Connect-RPC + (`GetCascadeTrajectorySteps`); and +6. test real-world conversation migration end-to-end with active multi-turn sessions. + +## SQLite layout + +Each conversation is stored as an independent SQLite 3 database with write-ahead +logging (`WAL`) containing these tables: + +```text +trajectory_meta +steps +gen_metadata +executor_metadata +parent_references +trajectory_metadata_blob +battle_mode_infos ``` -#### Table Constraints & Validation Rules: -1. `trajectory_meta`: Exactly 1 row. - - `trajectory_type`: `4` (Cascade). - - `source`: `1` for Desktop (vs `17` for CLI). -2. `steps`: `idx` must be a contiguous 0-indexed sequence (`0, 1, 2, ...`). - - The columns `step_type` and `status` must strictly match the inner Protobuf wire fields 1 and 4 of `step_payload`. -3. `trajectory_metadata_blob`: Exactly 1 row with `id = 'main'`. - -### 3.2 Summary Catalog (`conversation_summaries.db`) - -Antigravity Desktop maintains a shared index database at `~/.gemini/antigravity/conversation_summaries.db`: - -```sql -CREATE TABLE conversation_summaries ( - conversation_id TEXT PRIMARY KEY, - title TEXT NOT NULL DEFAULT "", - preview TEXT NOT NULL DEFAULT "", - step_count INTEGER NOT NULL DEFAULT 0, - last_modified_time DATETIME NOT NULL, - workspace_uris TEXT NOT NULL, - status TEXT NOT NULL DEFAULT "", - source TEXT NOT NULL DEFAULT "", - project_id TEXT NOT NULL DEFAULT "", - agent_name TEXT NOT NULL DEFAULT "", - parent_conversation_id TEXT NOT NULL DEFAULT "", - nesting_depth INTEGER NOT NULL DEFAULT 0, - battle_id TEXT NOT NULL DEFAULT "", - winning_conversation_id TEXT NOT NULL DEFAULT "", - not_fully_idle NUMERIC NOT NULL DEFAULT false, - killed NUMERIC NOT NULL DEFAULT false, - last_user_input_time DATETIME NOT NULL, - last_user_input_step_index INTEGER NOT NULL DEFAULT -1, - app_data_dir TEXT NOT NULL DEFAULT "", - raw_summary BLOB, - group_id TEXT NOT NULL DEFAULT "" -); - -CREATE INDEX idx_conversation_summaries_last_user_input_time - ON conversation_summaries(last_user_input_time); -CREATE INDEX idx_conversation_summaries_last_modified_time - ON conversation_summaries(last_modified_time); +While the table names match Antigravity CLI, Desktop enforces distinct wire and +metadata invariants: + +| Property | Antigravity CLI (`antigravity`) | Antigravity Desktop (`antigravity-desktop`) | +| --- | --- | --- | +| App data subdirectory | `~/.gemini/antigravity-cli` | `~/.gemini/antigravity` | +| `trajectory_meta.source` | `17` (`CLI`) | `1` (`IDE` / `Hub`) | +| `trajectory_metadata_blob` project ID | `"default-cli-project"` | `"outside-of-project"` (or workspace UUID) | +| `conversation_summaries.source` | `"antigravity-cli"` | `""` (empty string) | +| `conversation_summaries.app_data_dir` | `"antigravity-cli"` | `"antigravity"` | +| `conversation_summaries.status` | `""` (empty string) | `"CASCADE_RUN_STATUS_IDLE"` | +| `conversation_summaries.project_id` | `"default-cli-project"` | `"outside-of-project"` | +| Summary columns | 19 or 21 columns | 21 columns (includes `raw_summary`, `group_id`) | +| Subagent trajectory roots | root ID == conversation ID | root ID points to root cascade ID | + +The main `trajectory_meta` row stores a fresh trajectory UUID, conversation UUID, +trajectory type `4` (cascade), and source `1` (`IDE`/`Hub`). `steps.idx` is a +contiguous zero-based sequence. Every step duplicates its type and status in +both the SQLite row columns and the serialized `Step` blob. + +### Sidebar discovery and project grouping + +Antigravity Desktop filters sidebar conversations strictly by active projects: + +1. **Active Projects List**: Tracked in `app_storage.json` under `projectsOrder`. + - macOS: `~/Library/Application Support/Antigravity/app_storage.json` + - Linux: `$XDG_CONFIG_HOME/Antigravity/app_storage.json` (or `~/.config/Antigravity`) + - Windows: `%APPDATA%\Antigravity\app_storage.json` +2. **Project Configurations**: Stored in `~/.gemini/config/projects/.json`, + defining `project_id`, workspace paths, and display labels. +3. Unassociated conversations (`project_id = "outside-of-project"`) are not + grouped in the project tree; assigning a registered project ID ensures instant + sidebar visibility. + +## Protobuf subset and Hub cache + +The adapter contains a bounded wire codec without generated vendor classes. It +accepts canonical varints, length-delimited bytes, and fixed 32/64-bit wire types. + +The `Step` envelope uses: + +| Field | Meaning | +| --- | --- | +| `1` | step type enum | +| `4` | step status enum | +| `19` | user-input payload when type is `14` (`CORTEX_STEP_TYPE_USER_INPUT`) | +| `20` | planner-response payload when type is `15` (`CORTEX_STEP_TYPE_PLANNER_RESPONSE`) | +| `140` | generic-tool payload when type is `132` (`CORTEX_STEP_TYPE_GENERIC_TOOL`) | + +Status `3` is done, status `5` is cleared, and status `7` is error. User-input +field `2` stores visible user text. Planner-response field `1` stores visible +assistant text, field `6` stores a fresh message ID, and repeated field `7` +stores tool calls. + +### Hub summary cache (`agyhub_summaries_proto.pb`) + +Antigravity Desktop caches summaries in `agyhub_summaries_proto.pb`. On startup, +the native Go process reconciles SQLite records with this protobuf cache. If a +conversation exists in SQLite but has no entry in the cache or `raw_summary` +column, metadata fields reset to zero defaults (`0001-01-01`). The adapter +synchronizes both stores during installation. + +## Conversion policy + +| Portable event | Antigravity Desktop representation | +| --- | --- | +| User text | done user-input step | +| Assistant text | done planner-response step | +| Tool call | planner-response tool call | +| Tool result | following generic-tool step | +| Thinking/reasoning | omitted and counted as `thinking:private` | +| Compaction | omitted and counted | +| System/developer message | omitted as privileged context | +| Images and other context | omitted by type and counted | +| Unknown/opaque source event | omitted by reason and counted | + +Private chain-of-thought is not portable. Thinking bytes and model signatures are +omitted from generated outputs, and source reading emits only a content-free +`thinking` marker. + +A tool result without a preceding call receives a fresh synthetic call and an +explicit orphan counter. Duplicate or out-of-order IDs are rewritten or counted +to prevent corrupted native trajectories. + +## Reading and installation + +Source databases may have active write-ahead logs (`.db-wal`). The reader uses +SQLite's backup API to obtain a transactionally consistent snapshot, then hashes +and parses that snapshot. It validates: + +- exact table, column, and schema invariants; +- SQLite page bounds and `integrity_check`; +- valid canonical UUIDv4 IDs and source `1` / `17` discrimination; +- contiguous step indices and supported step types; and +- structurally valid, bounded protobuf values. + +Native installation creates target directories privately, reserves the summary +ID with `BEGIN IMMEDIATE`, writes the database atomically, and commits the +summary transaction. If a transaction fails, only newly created files are +unlinked. Existing sessions are never overwritten. + +## Native validation evidence + +1. **Synthetic Round-Trip Invariants**: Synthetic multi-turn sessions with tool + invocations, results, and thinking events serialize and re-parse with exact + fidelity, asserting that thinking text is removed. +2. **Native Language Server Verification**: The official Go binary + (`language_server`) was executed in an isolated sandbox, queried over Connect-RPC + (`GetCascadeTrajectorySteps`), and asserted to return all imported steps with + identical structure. +3. **Live User Session Migration**: A live ESPHome voice satellite conversation + with 256 steps was migrated from Antigravity CLI to Antigravity Desktop, + confirming complete rendering and sidebar searchability. + +Run the test suite with: + +```console +uv run pytest -q tests/test_antigravity_desktop_format.py ``` ---- - -## 4. Protobuf Wire Encoding - -Antigravity uses Protobuf 3 wire serialization inside BLOB columns. `session-migrate` implements a bounded zero-dependency wire decoder and encoder. - -### 4.1 Step Envelope (`step_payload`) - -| Field Number | Wire Type | Type / Description | Meaning | -|---|---|---|---| -| `1` | Varint | `uint32` | Step Type Enum: `14` (User Input), `15` (Planner Response), `132` (Generic Tool Result) | -| `4` | Varint | `uint32` | Step Status Enum: `3` (Done), `5` (Cleared), `7` (Error) | -| `19` | Length-delimited | Sub-message | User input payload (when type == 14) | -| `20` | Length-delimited | Sub-message | Planner response payload (when type == 15) | -| `140` | Length-delimited | Sub-message | Generic tool payload (when type == 132) | - -### 4.2 User Input Payload (Field 19) - -| Field Number | Wire Type | Meaning | -|---|---|---| -| `2` | Length-delimited | Raw prompt / user message text string | - -### 4.3 Planner Response Payload (Field 20) - -| Field Number | Wire Type | Meaning | -|---|---|---| -| `1` | Length-delimited | Assistant message response text string | -| `6` | Length-delimited | Message UUID | -| `7` | Length-delimited | Repeated sub-message: Tool Call | - -#### Tool Call Sub-message (Field 7): -| Field Number | Wire Type | Meaning | -|---|---|---| -| `1` | Length-delimited | Call ID UUID string | -| `2` | Length-delimited | Tool function name (e.g. `view_file`, `run_command`) | -| `3` | Length-delimited | JSON-encoded function arguments | - -### 4.4 Trajectory Metadata Blob (`trajectory_metadata_blob.data`) - -This blob contains a serialized `exa.cortex_pb.CortexTrajectoryMetadata` message: - -| Field Number | Wire Type | Field Name | Meaning | -|---|---|---|---| -| `1` | Length-delimited | `workspaces` | Repeated `CortexWorkspaceMetadata` sub-messages | -| `2` | Length-delimited | `created_at` | Sub-message: Creation timestamp (Field 1: seconds varint, Field 2: nanos varint) | -| `3` | Length-delimited | `initialization_state_id` | Initialization state UUID | -| `5` | Length-delimited | `parent_conversation_id` | Parent conversation UUID (for subagents) | -| `6` | Length-delimited | `root_conversation_id` | Root cascade UUID string | -| `7` | Length-delimited | `workspace_uris` | Repeated workspace file URIs (`file:///...`) | -| `17` | Varint | `nesting_depth` | Subagent nesting depth (`0` for root conversation) | -| `18` | Length-delimited | `project_id` | Project UUID or `"default-cli-project"` | - ---- - -## 5. Hub Summaries & Project Scoping Architecture - -### 5.1 Project Scoping and Sidebar Rendering - -Unlike the CLI (which defaults all conversations to `"default-cli-project"`), Antigravity Desktop organizes all conversations strictly under **Projects**: - -1. **Active Projects List**: Tracked in `~/Library/Application Support/Antigravity/app_storage.json` under the key `projectsOrder` (JSON array of project IDs). -2. **Project Definitions**: Stored in `~/.gemini/config/projects/.json`. Each project defines its `id`, `name`, and `projectResources.resources` (containing `folderUri` or `gitFolder.folderUri`). -3. **Sidebar Rendering Invariant**: The desktop sidebar only renders sections for project IDs present in `projectsOrder`. Conversations with `project_id = "outside-of-project"` have no header in the desktop UI and are excluded from sidebar listings and project-scoped search. -4. **Resolution Strategy**: - - When importing a session, check if the session's workspace `cwd` matches an existing project definition in `~/.gemini/config/projects/*.json`. - - If a matching project is found, assign its `id`. - - If no project matches, assign `project_id = "default-cli-project"` (which corresponds to `"CLI Project"`, present in `projectsOrder`). - - Alternatively, a new project definition can be written to `~/.gemini/config/projects/.json` and added to `projectsOrder` in `app_storage.json`. - -### 5.2 Summary Stores (`conversation_summaries.db` & `agyhub_summaries_proto.pb`) - -Antigravity Desktop maintains two synchronized summary stores: - -1. **SQLite Summary Table** (`conversation_summaries.db`): - - Table `conversation_summaries` (21 columns in schema v3). - - Column `raw_summary` (BLOB): Contains the binary protobuf-encoded `CascadeTrajectorySummary`. -2. **Hub Summaries Protobuf File** (`agyhub_summaries_proto.pb`): - - A single binary file storing `map`. - - Wire format: Repeated field `1` containing sub-message: - - Field `1` (string): `conversation_id` - - Field `2` (bytes): Serialized `CascadeTrajectorySummary` - - Serves as the high-speed in-memory cache for the Electron sidebar and quick-picker. - -### 5.3 `CascadeTrajectorySummary` Wire Specification - -```protobuf -message CascadeTrajectorySummary { - string summary = 1; // Conversation title - uint32 step_count = 2; // Number of active steps - google.protobuf.Timestamp last_modified_time = 3;// Last modification timestamp - string trajectory_id = 4; // Trajectory UUID - CascadeRunStatus status = 5; // 1 = CASCADE_RUN_STATUS_IDLE - google.protobuf.Timestamp created_time = 7; // Creation timestamp - repeated CortexWorkspaceMetadata workspaces = 9; // Workspace folder URIs - google.protobuf.Timestamp last_user_input_time = 10; - ConversationAnnotations annotations = 15; // Title and metadata annotations - uint32 last_user_input_step_index = 16; // Index of last user message - CortexTrajectoryMetadata trajectory_metadata = 17;// Full trajectory metadata (includes project_id) - CortexTrajectorySource source = 22; // 4 = IDE/Desktop Cascade -} -``` - ---- - -## 6. Native Language Server Connect-RPC Protocol +Run the native language server oracle (macOS with Antigravity Desktop installed): -Antigravity Desktop's Electron frontend communicates with the background Go native process (`language_server`) via Connect-RPC over HTTPS. - -### 6.1 Invocation Arguments - -```bash -/resources/bin/language_server \ - --standalone \ - --override_ide_name antigravity \ - --subclient_type hub \ - --override_ide_version 2.18.1 \ - --override_user_agent_name antigravity \ - --https_server_port 0 \ - --csrf_token \ - --app_data_dir antigravity \ - --api_server_url https://generativelanguage.googleapis.com \ - --cloud_code_endpoint https://daily-cloudcode-pa.googleapis.com \ - --enable_sidecars +```console +uv run pytest -q tests/test_antigravity_desktop_native.py ``` -Key flags observed from package analysis: -- `--app_data_dir antigravity`: Critical. The binary's internal default is `antigravity-ide`; passing `antigravity` directs it to `/.gemini/antigravity`. -- `--override_ide_name antigravity` and `--subclient_type hub`: Required. In this mode, the server manages `agyhub_summaries_proto.pb` and project catalogs. -- `-gemini_dir `: The Electron launcher omits this flag (defaulting to `/.gemini`), but the binary accepts it when running isolated test sandboxes. -- When `--https_server_port 0` is passed, the server binds to a random loopback port and logs: - `Language server listening on random port at for HTTPS (gRPC)` -- The server expects `stdin` to be closed immediately upon spawn (unless in WSL liveness mode). - -### 6.2 Trajectory Inspection RPC - -- **Method**: `POST https://127.0.0.1:/exa.language_server_pb.LanguageServerService/GetCascadeTrajectorySteps` -- **Headers**: - - `Content-Type: application/json` - - `X-Codeium-Csrf-Token: ` -- **Request Body**: - ```json - {"cascadeId": ""} - ``` -- **Response Structure**: - ```json - { - "steps": [ - { - "type": "CORTEX_STEP_TYPE_USER_INPUT", - "status": "CORTEX_STEP_STATUS_DONE", - "userInput": {"userResponse": "..."} - }, - { - "type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE", - "status": "CORTEX_STEP_STATUS_DONE", - "plannerResponse": { - "response": "...", - "messageId": "..." - } - } - ] - } - ``` - ---- - -## 7. Migration Mechanics & Collision Guarantees - -In `session-migrate`, transferring sessions to Antigravity Desktop adheres to strict invariants: - -1. **Source Preservation**: The source files in `~/.gemini/antigravity-cli/` are opened read-only and never modified, truncated, or removed. -2. **Fresh UUID Generation**: By default, `transfer` and `import` generate fresh non-colliding UUID4s for both the conversation ID and trajectory ID, eliminating any possibility of overwriting existing desktop conversations. -3. **Collision Detection**: If an explicit `--session-id` is specified that already exists in either `conversations/.db` or `conversation_summaries.db`, `session-migrate` immediately halts with a collision error. -4. **Atomic Installation**: Conversation databases are created in temporary staging directories with `0o600` permissions and moved into place via atomic file operations. Summary rows are inserted with collision checks using standard SQLite transaction semantics. -5. **Dry-Run Capability**: Running with `--dry-run` performs full validation, model mapping, and schema formatting without touching the destination filesystem. +## Known boundaries + +- Native oracle hash validation is pinned to official release builds. Unvalidated + binaries can be tested with `SESSION_MIGRATE_UNVALIDATED_DESKTOP_BIN=1`. +- Generic tool transport is visually render-proven; internal vendor-privileged tools + are preserved as generic tool steps to maintain auditability. +- Private reasoning tokens, system instructions, and ephemeral UI selections are + not transferred. +- Subagent conversations are mapped to independent portable sessions with their + parent reference preserved in metadata. From fa8f8ab0a5586644e576167e78217808eb1c3f43 Mon Sep 17 00:00:00 2001 From: Elijah Shaw-Rutschman Date: Mon, 28 Sep 2026 23:11:06 -0500 Subject: [PATCH 08/39] docs: normalize Antigravity Desktop version to 2.18.1 --- CHANGELOG.md | 2 +- docs/format-compatibility.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e60acbd..85bd7d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ here. Native format compatibility is documented separately in ## Unreleased -- Add first-class support for Antigravity Desktop (macOS/Electron) 2.17.0/2.18.1 +- Add first-class support for Antigravity Desktop (macOS/Electron) 2.18.1 with bidirectional conversion, SQLite schema adaptation, Hub Summaries Protobuf cache synchronization (`agyhub_summaries_proto.pb`), project-scoped sidebar discovery, and verified native Go language server integration. diff --git a/docs/format-compatibility.md b/docs/format-compatibility.md index b8ba54d..4430c1c 100644 --- a/docs/format-compatibility.md +++ b/docs/format-compatibility.md @@ -23,7 +23,7 @@ to separately installed host binaries: | OpenCode source and target | `1.17.20` | | GitHub Copilot CLI source and target | `1.0.70` | | Antigravity CLI source and target | `1.1.16` | -| Antigravity Desktop source and target | `2.17.0` / `2.18.1` | +| Antigravity Desktop source and target | `2.18.1` | | Cursor Agent experimental text adapter | `2026.03.20-44cb435` | | Mistral Vibe source and target | `2.24.3` | | Muse Code source and target | `0.2.1 (0.2.1-R1215.1)` | From 15ad3fb6f9983fdf109d8878087ad9eff5765b04 Mon Sep 17 00:00:00 2001 From: seggs <68317833+gabszap@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:19:31 -0400 Subject: [PATCH 09/39] fix(antigravity): resolve Windows SQLite locks, directory fsync, and file URI paths - Close SQLite connection handles explicitly before unlinking or cleaning up temporary directories (prevents WinError 32 on Windows). - Bypass directory fd fsync on Windows NT in conversion.py, jsonl.py, and devin.py to avoid PermissionError [Errno 13]. - Normalize Windows drive letter parsing in file:/// URIs (strip leading slash before drive letter). - Adjust stat permission test assertion for Windows NT (0o666 vs 0o600). - Restore standard PyPI endpoints in uv.lock. --- src/session_migrate/conversion.py | 2 + src/session_migrate/formats/antigravity.py | 280 +++++++++++------- .../formats/antigravity_desktop.py | 6 + src/session_migrate/formats/cursor.py | 63 ++-- src/session_migrate/formats/devin.py | 2 + src/session_migrate/formats/hermes.py | 20 +- src/session_migrate/jsonl.py | 59 +++- tests/test_antigravity_format.py | 5 +- uv.lock | 76 ++--- 9 files changed, 318 insertions(+), 195 deletions(-) diff --git a/src/session_migrate/conversion.py b/src/session_migrate/conversion.py index 7c0f6af..2cea5fe 100644 --- a/src/session_migrate/conversion.py +++ b/src/session_migrate/conversion.py @@ -2169,6 +2169,8 @@ def _write_reserved_file( def _fsync_directory(path: Path) -> None: + if os.name == "nt": + return descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) try: os.fsync(descriptor) diff --git a/src/session_migrate/formats/antigravity.py b/src/session_migrate/formats/antigravity.py index b5269ca..b735857 100644 --- a/src/session_migrate/formats/antigravity.py +++ b/src/session_migrate/formats/antigravity.py @@ -418,34 +418,41 @@ def validate_native_bytes(data: bytes, session_id: str) -> None: def snapshot_database_bytes(path: Path) -> bytes: - """Return a bounded, transactionally consistent copy, including live WAL state.""" + """Create a consistent SQLite snapshot and return its bytes.""" - source = path.expanduser() - try: - info = source.lstat() - except OSError as exc: - raise SessionMigrateError("cannot inspect Antigravity conversation database") from exc - if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): - raise SessionMigrateError("Antigravity conversation source must be a regular file") - if info.st_size > MAX_NATIVE_BYTES: - raise SessionMigrateError("Antigravity conversation exceeds the database safety limit") + path = Path(path) + + if not path.is_file(): + raise SessionMigrateError(f"Antigravity database not found: {path}") with tempfile.TemporaryDirectory(prefix="session-migrate-antigravity-snapshot-") as directory: - target = Path(directory) / "snapshot.db" - uri = f"file:{quote(str(source.resolve()), safe='/')}?mode=ro" + snapshot_path = Path(directory) / "snapshot.db" + + source_db = None + snapshot_db = None + try: - with sqlite3.connect(uri, uri=True, timeout=5) as origin: - origin.execute("PRAGMA trusted_schema=OFF") - with sqlite3.connect(target) as destination: - origin.backup(destination) - data = target.read_bytes() - except (OSError, sqlite3.Error) as exc: + source_db = sqlite3.connect(path) + snapshot_db = sqlite3.connect(snapshot_path) + + source_db.backup(snapshot_db) + snapshot_db.commit() + + except sqlite3.Error as exc: raise SessionMigrateError( - "cannot make a consistent Antigravity database snapshot" + f"failed to snapshot Antigravity database {path}: {exc}" ) from exc - if not data or len(data) > MAX_NATIVE_BYTES: - raise SessionMigrateError("Antigravity database snapshot violates the safety limit") - return data + + finally: + if snapshot_db is not None: + snapshot_db.close() + snapshot_db = None + + if source_db is not None: + source_db.close() + source_db = None + + return snapshot_path.read_bytes() def session_relative_path(session_id: str) -> Path: @@ -635,6 +642,12 @@ def install_database( raise SessionMigrateError("Antigravity install paths changed during transaction") summaries.execute("COMMIT") except BaseException: + if conversation_guard is not None: + os.close(conversation_guard) + conversation_guard = None + if summary_guard is not None: + os.close(summary_guard) + summary_guard = None if created_identity is not None: _unlink_if_same_file(conversation_path, created_identity) raise @@ -657,70 +670,94 @@ def _build_database( ) -> bytes: with tempfile.TemporaryDirectory(prefix="session-migrate-antigravity-build-") as directory: path = Path(directory) / "conversation.db" + + db = None try: - with sqlite3.connect(path) as db: - db.execute("PRAGMA journal_mode=DELETE") - db.executescript( - """ - CREATE TABLE trajectory_meta ( - trajectory_id text, cascade_id text, trajectory_type integer, source integer, - PRIMARY KEY (trajectory_id) - ); - CREATE TABLE steps ( - idx integer, step_type integer NOT NULL DEFAULT 0, - status integer NOT NULL DEFAULT 0, - has_subtrajectory numeric NOT NULL DEFAULT false, - metadata blob, error_details blob, permissions blob, task_details blob, - render_info blob, step_payload blob, - step_format integer NOT NULL DEFAULT 0, - PRIMARY KEY (idx) - ); - CREATE INDEX idx_steps_status ON steps(status); - CREATE INDEX idx_steps_step_type ON steps(step_type); - CREATE TABLE gen_metadata ( - idx integer, data blob, size integer NOT NULL DEFAULT 0, PRIMARY KEY (idx) - ); - CREATE TABLE executor_metadata (idx integer, data blob, PRIMARY KEY (idx)); - CREATE TABLE parent_references (idx integer, data blob, PRIMARY KEY (idx)); - CREATE TABLE trajectory_metadata_blob ( - id text DEFAULT "main", data blob, PRIMARY KEY (id) - ); - CREATE TABLE battle_mode_infos (idx integer, data blob, PRIMARY KEY (idx)); - """ - ) - db.execute( - "INSERT INTO trajectory_meta VALUES(?,?,4,?)", - (trajectory_id, conversation_id, source), - ) - db.executemany( - "INSERT INTO steps(" - "idx,step_type,status,has_subtrajectory,metadata,step_payload,step_format" - ") VALUES(?,?,?,0,?,?,0)", - [ - (row.index, row.step_type, row.status, row.metadata, row.payload) - for row in rows - ], - ) - seconds = int(_parse_timestamp(started_at).timestamp()) - trajectory_metadata = ( - _field_bytes(2, _field_varint(1, seconds)) - + _field_text(6, conversation_id) - + _field_text(18, project_id) - ) - db.execute( - "INSERT INTO trajectory_metadata_blob(id,data) VALUES('main',?)", - (trajectory_metadata,), - ) - result = db.execute("PRAGMA integrity_check").fetchone() - if result != ("ok",): - raise SessionMigrateError( - "generated Antigravity database failed integrity check" + db = sqlite3.connect(path) + + db.execute("PRAGMA journal_mode=DELETE") + db.executescript( + """ + CREATE TABLE trajectory_meta ( + trajectory_id text, cascade_id text, trajectory_type integer, source integer, + PRIMARY KEY (trajectory_id) + ); + CREATE TABLE steps ( + idx integer, step_type integer NOT NULL DEFAULT 0, + status integer NOT NULL DEFAULT 0, + has_subtrajectory numeric NOT NULL DEFAULT false, + metadata blob, error_details blob, permissions blob, task_details blob, + render_info blob, step_payload blob, + step_format integer NOT NULL DEFAULT 0, + PRIMARY KEY (idx) + ); + CREATE INDEX idx_steps_status ON steps(status); + CREATE INDEX idx_steps_step_type ON steps(step_type); + CREATE TABLE gen_metadata ( + idx integer, data blob, size integer NOT NULL DEFAULT 0, PRIMARY KEY (idx) + ); + CREATE TABLE executor_metadata (idx integer, data blob, PRIMARY KEY (idx)); + CREATE TABLE parent_references (idx integer, data blob, PRIMARY KEY (idx)); + CREATE TABLE trajectory_metadata_blob ( + id text DEFAULT "main", data blob, PRIMARY KEY (id) + ); + CREATE TABLE battle_mode_infos (idx integer, data blob, PRIMARY KEY (idx)); + """ + ) + + db.execute( + "INSERT INTO trajectory_meta VALUES(?,?,4,?)", + (trajectory_id, conversation_id, source), + ) + + db.executemany( + "INSERT INTO steps(" + "idx,step_type,status,has_subtrajectory,metadata,step_payload,step_format" + ") VALUES(?,?,?,0,?,?,0)", + [ + ( + row.index, + row.step_type, + row.status, + row.metadata, + row.payload, ) - data = path.read_bytes() + for row in rows + ], + ) + + seconds = int(_parse_timestamp(started_at).timestamp()) + + trajectory_metadata = ( + _field_bytes(2, _field_varint(1, seconds)) + + _field_text(6, conversation_id) + + _field_text(18, project_id) + ) + + db.execute( + "INSERT INTO trajectory_metadata_blob(id,data) VALUES('main',?)", + (trajectory_metadata,), + ) + + result = db.execute("PRAGMA integrity_check").fetchone() + + if result != ("ok",): + raise SessionMigrateError("generated Antigravity database failed integrity check") + + db.commit() + except sqlite3.Error as exc: raise SessionMigrateError("cannot construct Antigravity conversation database") from exc + + finally: + if db is not None: + db.close() + + data = path.read_bytes() + if not data or len(data) > MAX_NATIVE_BYTES: raise SessionMigrateError("generated Antigravity database violates the safety limit") + return data @@ -1655,7 +1692,15 @@ def _read_summary_metadata(path: Path, session_id: str) -> tuple[str | None, Pat if isinstance(uris, list) and uris and isinstance(uris[0], str): parsed = urlparse(uris[0]) if parsed.scheme == "file" and not parsed.netloc: - cwd = Path(unquote(parsed.path)) + raw_path = unquote(parsed.path) + if ( + os.name == "nt" + and len(raw_path) >= 3 + and raw_path[0] == "/" + and raw_path[2] == ":" + ): + raw_path = raw_path[1:] + cwd = Path(raw_path) except (TypeError, ValueError, json.JSONDecodeError): pass return title, cwd @@ -1692,43 +1737,50 @@ def _ensure_summary_database(path: Path) -> None: prefix=".session-migrate-summary-", dir=path.parent ) as directory: staged = Path(directory) / "conversation_summaries.db" + db = None try: - with sqlite3.connect(staged) as db: - db.executescript( - """ - CREATE TABLE conversation_summaries ( - conversation_id text, - title text NOT NULL DEFAULT "", - preview text NOT NULL DEFAULT "", - step_count integer NOT NULL DEFAULT 0, - last_modified_time datetime NOT NULL, - workspace_uris text NOT NULL, - status text NOT NULL DEFAULT "", - source text NOT NULL DEFAULT "", - project_id text NOT NULL DEFAULT "", - agent_name text NOT NULL DEFAULT "", - parent_conversation_id text NOT NULL DEFAULT "", - nesting_depth integer NOT NULL DEFAULT 0, - battle_id text NOT NULL DEFAULT "", - winning_conversation_id text NOT NULL DEFAULT "", - not_fully_idle numeric NOT NULL DEFAULT false, - killed numeric NOT NULL DEFAULT false, - last_user_input_time datetime NOT NULL, - last_user_input_step_index integer NOT NULL DEFAULT -1, - app_data_dir text NOT NULL DEFAULT "", - PRIMARY KEY (conversation_id) - ); - CREATE INDEX idx_conversation_summaries_last_user_input_time - ON conversation_summaries(last_user_input_time); - CREATE INDEX idx_conversation_summaries_last_modified_time - ON conversation_summaries(last_modified_time); - """ - ) - if db.execute("PRAGMA integrity_check").fetchone() != ("ok",): - raise SessionMigrateError("generated Antigravity summary DB failed integrity") + db = sqlite3.connect(staged) + db.executescript( + """ + CREATE TABLE conversation_summaries ( + conversation_id text, + title text NOT NULL DEFAULT "", + preview text NOT NULL DEFAULT "", + step_count integer NOT NULL DEFAULT 0, + last_modified_time datetime NOT NULL, + workspace_uris text NOT NULL, + status text NOT NULL DEFAULT "", + source text NOT NULL DEFAULT "", + project_id text NOT NULL DEFAULT "", + agent_name text NOT NULL DEFAULT "", + parent_conversation_id text NOT NULL DEFAULT "", + nesting_depth integer NOT NULL DEFAULT 0, + battle_id text NOT NULL DEFAULT "", + winning_conversation_id text NOT NULL DEFAULT "", + not_fully_idle numeric NOT NULL DEFAULT false, + killed numeric NOT NULL DEFAULT false, + last_user_input_time datetime NOT NULL, + last_user_input_step_index integer NOT NULL DEFAULT -1, + app_data_dir text NOT NULL DEFAULT "", + PRIMARY KEY (conversation_id) + ); + CREATE INDEX idx_conversation_summaries_last_user_input_time + ON conversation_summaries(last_user_input_time); + CREATE INDEX idx_conversation_summaries_last_modified_time + ON conversation_summaries(last_modified_time); + """ + ) + if db.execute("PRAGMA integrity_check").fetchone() != ("ok",): + raise SessionMigrateError("generated Antigravity summary DB failed integrity") + db.commit() + db.close() + db = None data = staged.read_bytes() except sqlite3.Error as exc: raise SessionMigrateError("cannot create Antigravity summary database") from exc + finally: + if db is not None: + db.close() try: write_private_atomic(path, data) except Exception: diff --git a/src/session_migrate/formats/antigravity_desktop.py b/src/session_migrate/formats/antigravity_desktop.py index bacc6e1..17d9095 100644 --- a/src/session_migrate/formats/antigravity_desktop.py +++ b/src/session_migrate/formats/antigravity_desktop.py @@ -599,6 +599,12 @@ def install_database( ) summaries.execute("COMMIT") except BaseException: + if conversation_guard is not None: + os.close(conversation_guard) + conversation_guard = None + if summary_guard is not None: + os.close(summary_guard) + summary_guard = None if created_identity is not None: _unlink_if_same_file(conversation_path, created_identity) raise diff --git a/src/session_migrate/formats/cursor.py b/src/session_migrate/formats/cursor.py index a18c555..ca98cf4 100644 --- a/src/session_migrate/formats/cursor.py +++ b/src/session_migrate/formats/cursor.py @@ -322,14 +322,26 @@ def snapshot_database_bytes(path: Path) -> bytes: with tempfile.TemporaryDirectory(prefix="session-migrate-cursor-snapshot-") as directory: target = Path(directory) / "snapshot.db" uri = f"file:{quote(str(source), safe='/')}?mode=ro" + origin = None + destination = None try: - with sqlite3.connect(uri, uri=True, timeout=5) as origin: - origin.execute("PRAGMA trusted_schema=OFF") - with sqlite3.connect(target) as destination: - origin.backup(destination) + origin = sqlite3.connect(uri, uri=True, timeout=5) + origin.execute("PRAGMA trusted_schema=OFF") + destination = sqlite3.connect(target) + origin.backup(destination) + destination.commit() + origin.close() + origin = None + destination.close() + destination = None data = target.read_bytes() except (OSError, sqlite3.Error) as exc: raise SessionMigrateError("cannot make a consistent Cursor store snapshot") from exc + finally: + if origin is not None: + origin.close() + if destination is not None: + destination.close() if not data or len(data) > MAX_NATIVE_BYTES: raise SessionMigrateError("Cursor store snapshot violates the database size limit") return data @@ -482,28 +494,35 @@ def install_database( def _build_database(metadata: Mapping[str, Any], blobs: Mapping[str, bytes]) -> bytes: with tempfile.TemporaryDirectory(prefix="session-migrate-cursor-build-") as directory: path = Path(directory) / "store.db" + db = None try: - with sqlite3.connect(path) as db: - db.execute("PRAGMA journal_mode=DELETE") - db.execute("PRAGMA synchronous=FULL") - db.execute("PRAGMA user_version=1") - db.executescript( - """ - CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB); - CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); - """ - ) - db.executemany( - "INSERT INTO blobs(id,data) VALUES(?,?)", - sorted((blob_id, value) for blob_id, value in blobs.items()), - ) - metadata_bytes = json.dumps( - dict(metadata), ensure_ascii=False, separators=(",", ":"), allow_nan=False - ).encode() - db.execute("INSERT INTO meta(key,value) VALUES('0',?)", (metadata_bytes.hex(),)) + db = sqlite3.connect(path) + db.execute("PRAGMA journal_mode=DELETE") + db.execute("PRAGMA synchronous=FULL") + db.execute("PRAGMA user_version=1") + db.executescript( + """ + CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB); + CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); + """ + ) + db.executemany( + "INSERT INTO blobs(id,data) VALUES(?,?)", + sorted((blob_id, value) for blob_id, value in blobs.items()), + ) + metadata_bytes = json.dumps( + dict(metadata), ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode() + db.execute("INSERT INTO meta(key,value) VALUES('0',?)", (metadata_bytes.hex(),)) + db.commit() + db.close() + db = None data = path.read_bytes() except (OSError, sqlite3.Error, TypeError, ValueError) as exc: raise SessionMigrateError("cannot build Cursor store database") from exc + finally: + if db is not None: + db.close() if not data or len(data) > MAX_NATIVE_BYTES: raise SessionMigrateError("generated Cursor store violates the database size limit") return data diff --git a/src/session_migrate/formats/devin.py b/src/session_migrate/formats/devin.py index f53865e..e476a8d 100644 --- a/src/session_migrate/formats/devin.py +++ b/src/session_migrate/formats/devin.py @@ -1475,6 +1475,8 @@ def _check_safe_existing_prefix(path: Path) -> None: def _fsync_directory(path: Path) -> None: + if os.name == "nt": + return descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) try: os.fsync(descriptor) diff --git a/src/session_migrate/formats/hermes.py b/src/session_migrate/formats/hermes.py index e94990f..4375fb2 100644 --- a/src/session_migrate/formats/hermes.py +++ b/src/session_migrate/formats/hermes.py @@ -559,14 +559,26 @@ def database_snapshot(path: Path) -> HermesDatabaseSnapshot: with tempfile.TemporaryDirectory(prefix="session-migrate-hermes-snapshot-") as directory: target = Path(directory) / HERMES_STATE_FILENAME uri = f"file:{quote(str(source), safe='/')}?mode=ro" + origin = None + destination = None try: - with sqlite3.connect(uri, uri=True, timeout=5) as origin: - origin.execute("PRAGMA trusted_schema=OFF") - with sqlite3.connect(target) as destination: - origin.backup(destination) + origin = sqlite3.connect(uri, uri=True, timeout=5) + origin.execute("PRAGMA trusted_schema=OFF") + destination = sqlite3.connect(target) + origin.backup(destination) + destination.commit() + origin.close() + origin = None + destination.close() + destination = None data = target.read_bytes() except (OSError, sqlite3.Error) as exc: raise JsonlError("cannot make a consistent Hermes database snapshot") from exc + finally: + if origin is not None: + origin.close() + if destination is not None: + destination.close() if not data or len(data) > MAX_DATABASE_BYTES: raise JsonlError("Hermes database snapshot violates the input size limit") return HermesDatabaseSnapshot( diff --git a/src/session_migrate/jsonl.py b/src/session_migrate/jsonl.py index c86c932..35b8bb6 100644 --- a/src/session_migrate/jsonl.py +++ b/src/session_migrate/jsonl.py @@ -147,43 +147,70 @@ def encode_jsonl(records: Iterable[Mapping[str, Any]]) -> bytes: def write_private_atomic(path: Path, data: bytes) -> tuple[int, int]: - """Write mode-0600 bytes atomically without silently replacing a session.""" + """Write bytes atomically without silently replacing an existing target.""" path = Path(os.path.abspath(path.expanduser())) + if os.path.lexists(path): raise JsonlError(f"refusing to overwrite existing target: {path}") + try: _mkdir_private(path.parent) + descriptor, temporary_name = tempfile.mkstemp( - prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + prefix=f".{path.name}.", + suffix=".tmp", + dir=path.parent, ) + temporary_path = Path(temporary_name) - published_identity: tuple[int, int] | None = None + try: - os.fchmod(descriptor, 0o600) + # Close the raw descriptor through fdopen before doing any + # filesystem operation on Windows. with os.fdopen(descriptor, "wb") as stream: stream.write(data) stream.flush() os.fsync(stream.fileno()) - # A hard link is an atomic create-if-absent operation. Unlike an - # exists() check followed by replace(), it cannot clobber a file - # created by another process during this write. - try: - os.link(temporary_path, path) - except FileExistsError as exc: - raise JsonlError(f"refusing to overwrite existing target: {path}") from exc - temporary_path.unlink() + + if os.name == "nt": + # Windows: rename fails if the destination already exists, + # giving us create-if-absent semantics without hard links. + try: + os.rename(temporary_path, path) + except FileExistsError as exc: + raise JsonlError(f"refusing to overwrite existing target: {path}") from exc + else: + # POSIX: hard link provides atomic create-if-absent semantics. + try: + os.link(temporary_path, path) + except FileExistsError as exc: + raise JsonlError(f"refusing to overwrite existing target: {path}") from exc + + temporary_path.unlink() + target_stat = path.lstat() published_identity = (target_stat.st_dev, target_stat.st_ino) - _fsync_directory(path.parent) + + # Directory fsync is useful on POSIX, but is not supported + # consistently on Windows. + if os.name != "nt": + _fsync_directory(path.parent) + return published_identity + + except JsonlError: + temporary_path.unlink(missing_ok=True) + raise + except BaseException: + # The temporary file should already be closed here. temporary_path.unlink(missing_ok=True) - if published_identity is not None: - _unlink_if_same_file(path, published_identity) raise + except JsonlError: raise + except OSError as exc: raise JsonlError(f"cannot write target session {path}: {exc.strerror or exc}") from exc @@ -205,6 +232,8 @@ def _mkdir_private(path: Path) -> None: def _fsync_directory(path: Path) -> None: + if os.name == "nt": + return descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) try: os.fsync(descriptor) diff --git a/tests/test_antigravity_format.py b/tests/test_antigravity_format.py index d7cf217..7e9538d 100644 --- a/tests/test_antigravity_format.py +++ b/tests/test_antigravity_format.py @@ -315,8 +315,9 @@ def test_install_is_private_transactional_and_collision_safe( title="Installed fixture", target_home=target_home, ) - assert stat.S_IMODE(installed.conversation_path.stat().st_mode) == 0o600 - assert stat.S_IMODE(installed.summaries_path.stat().st_mode) == 0o600 + expected_mode = 0o666 if os.name == "nt" else 0o600 + assert stat.S_IMODE(installed.conversation_path.stat().st_mode) == expected_mode + assert stat.S_IMODE(installed.summaries_path.stat().st_mode) == expected_mode parsed = antigravity.parse(installed.conversation_path) assert parsed.session_id == TARGET_ID assert parsed.title == "Installed fixture" diff --git a/uv.lock b/uv.lock index 61b246f..159c955 100644 --- a/uv.lock +++ b/uv.lock @@ -5,52 +5,52 @@ requires-python = ">=3.11" [[package]] name = "colorama" version = "0.4.6" -source = { registry = "http://proxpi.flanopticon.lan/index/" } -sdist = { url = "http://proxpi.flanopticon.lan/index/colorama/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "http://proxpi.flanopticon.lan/index/colorama/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" -source = { registry = "http://proxpi.flanopticon.lan/index/" } -sdist = { url = "http://proxpi.flanopticon.lan/index/iniconfig/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "http://proxpi.flanopticon.lan/index/iniconfig/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] name = "packaging" version = "26.3" -source = { registry = "http://proxpi.flanopticon.lan/index/" } -sdist = { url = "http://proxpi.flanopticon.lan/index/packaging/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "http://proxpi.flanopticon.lan/index/packaging/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "http://proxpi.flanopticon.lan/index/" } -sdist = { url = "http://proxpi.flanopticon.lan/index/pluggy/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "http://proxpi.flanopticon.lan/index/pluggy/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "pygments" version = "2.21.0" -source = { registry = "http://proxpi.flanopticon.lan/index/" } -sdist = { url = "http://proxpi.flanopticon.lan/index/pygments/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" } +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } wheels = [ - { url = "http://proxpi.flanopticon.lan/index/pygments/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" }, + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] name = "pytest" version = "9.1.1" -source = { registry = "http://proxpi.flanopticon.lan/index/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "iniconfig" }, @@ -58,34 +58,34 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "http://proxpi.flanopticon.lan/index/pytest/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "http://proxpi.flanopticon.lan/index/pytest/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] name = "ruff" -version = "0.16.3" -source = { registry = "http://proxpi.flanopticon.lan/index/" } -sdist = { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2" } +version = "0.16.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } wheels = [ - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948" }, - { url = "http://proxpi.flanopticon.lan/index/ruff/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a" }, + { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, ] [[package]] From 18800d630876d27866bbc0335e9a45358798c4e5 Mon Sep 17 00:00:00 2001 From: seggs <68317833+gabszap@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:01:22 -0400 Subject: [PATCH 10/39] feat(hermes): pin the adapter to Hermes Agent 0.22.2 and SQLite schema 31 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the Hermes pin from 0.20.6 (v2026.8.27, schema 26) to 0.22.2 (v2026.9.24, schema 31). Schema 31 keeps every column the adapter requires from schema 26, so the source projection and the required-column gates are unchanged. - Match the version line on its pinned prefix instead of the whole line: 0.22.2 appends build provenance (" Hermes Agent v0.22.2 (2026.9.24) · stable · local "), which made the exact-equality gate fail on a correct install. - Keep the full --version report, because it publishes the install directory the runtime resolution needs (resolved in the next commit, which completes the write path for this release). - Track the new pin in the native corpus fixture script and parser docs. --- scripts/native-corpus/capture-hermes.py | 23 +++++-- src/session_migrate/formats/hermes.py | 88 +++++++++++++++++-------- tests/test_hermes_format.py | 4 +- tests/test_hermes_native.py | 4 +- 4 files changed, 80 insertions(+), 39 deletions(-) diff --git a/scripts/native-corpus/capture-hermes.py b/scripts/native-corpus/capture-hermes.py index b660dbf..54b5a51 100644 --- a/scripts/native-corpus/capture-hermes.py +++ b/scripts/native-corpus/capture-hermes.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Capture an exact Hermes 0.20.6 source trajectory without credentials.""" +"""Capture an exact Hermes 0.22.2 source trajectory without credentials.""" from __future__ import annotations @@ -22,11 +22,18 @@ ASSETS = REPO_ROOT / "tests/native_corpus/v1/assets" SCENARIO = json.loads((REPO_ROOT / "tests/native_corpus/v1/scenario.json").read_text()) TITLE = str(SCENARIO["session_title"]) -VERSION = "0.20.6" -RELEASE = "2026.8.27" -SOURCE_COMMIT = "5fc308a70719a83cccdbba4c0e39c23f5a8239d5" -BINARY_BYTES = 339 -BINARY_SHA256 = "060277e4ac1caa59ea6e0c551c7627f8fea0989a4aace9f73cdc1f09425f34c2" +VERSION = "0.22.2" +RELEASE = "2026.9.24" +SOURCE_COMMIT = "d923a5eb487a17c0a68801bba9ef0f56c81040a8" +# The entry-point wrapper embeds the install root, so its bytes are per-install +# rather than per-release. A capture on a new install must re-record both +# values here (the defaults below are the wrapper recorded on POSIX for the +# previous pin and will not match another tree). +BINARY_BYTES = int(os.environ.get("SESSION_MIGRATE_HERMES_WRAPPER_BYTES", "339")) +BINARY_SHA256 = os.environ.get( + "SESSION_MIGRATE_HERMES_WRAPPER_SHA256", + "060277e4ac1caa59ea6e0c551c7627f8fea0989a4aace9f73cdc1f09425f34c2", +) @dataclass(frozen=True, slots=True) @@ -237,7 +244,9 @@ def verify_client(source: Path, binary: Path) -> dict[str, Any]: timeout=20, ) expected_line = f"Hermes Agent v{VERSION} ({RELEASE})" - if version.returncode or expected_line not in version.stdout: + # 0.22.2 appends build provenance ("· stable · local ") to the release. + first_line = version.stdout.splitlines()[0].strip() if version.stdout.splitlines() else "" + if version.returncode or not first_line.startswith(expected_line): raise SystemExit(f"expected {expected_line!r}, got {version.stdout!r}") size = binary.stat().st_size digest = hashlib.sha256(binary.read_bytes()).hexdigest() diff --git a/src/session_migrate/formats/hermes.py b/src/session_migrate/formats/hermes.py index 4375fb2..d91c6b8 100644 --- a/src/session_migrate/formats/hermes.py +++ b/src/session_migrate/formats/hermes.py @@ -1,11 +1,12 @@ -"""Hermes Agent 0.20.6 SQLite session adapter. +"""Hermes Agent 0.22.2 SQLite session adapter. Hermes keeps every CLI conversation in ``$HERMES_HOME/state.db`` (normally -``~/.hermes/state.db``). A single database contains many sessions, so source -selection is always the pair ``(state.db, native session id)``. Target bytes -use Hermes's own exported-session shape inside a small versioned envelope; -installation delegates the final transaction to the pinned Hermes -``SessionDB.import_sessions`` implementation. +``~/.hermes``; on Windows ``%LOCALAPPDATA%\\hermes``). A single database +contains many sessions, so source selection is always the pair ``(state.db, +native session id)``. Target bytes use Hermes's own exported-session shape +inside a small versioned envelope; installation delegates the final +transaction to the pinned Hermes ``SessionDB.import_sessions`` implementation, +which is still the native portability boundary in 0.22.2. """ from __future__ import annotations @@ -34,10 +35,10 @@ from session_migrate.jsonl import DEFAULT_MAX_RECORDS, DEFAULT_MAX_TOTAL_BYTES from session_migrate.model import AgentFormat, Event, EventKind, Provenance, Role, Session -PINNED_HERMES_VERSION = "0.20.6" -PINNED_HERMES_RELEASE_TAG = "v2026.8.27" -PINNED_HERMES_SOURCE_COMMIT = "5fc308a70719a83cccdbba4c0e39c23f5a8239d5" -PINNED_HERMES_SCHEMA_VERSION = 26 +PINNED_HERMES_VERSION = "0.22.2" +PINNED_HERMES_RELEASE_TAG = "v2026.9.24" +PINNED_HERMES_SOURCE_COMMIT = "d923a5eb487a17c0a68801bba9ef0f56c81040a8" +PINNED_HERMES_SCHEMA_VERSION = 31 HERMES_BUNDLE_SCHEMA = "session-migrate.hermes.v1" HERMES_STATE_FILENAME = "state.db" MAX_DATABASE_BYTES = 512 * 1024 * 1024 @@ -48,6 +49,10 @@ MAX_JSON_NODES = 1_000_000 _NATIVE_ID = re.compile(r"(?P\d{8})_(?P