diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d75a84..85bd7d9 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.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-format.md`. + ## 0.11.0 - 2026-09-11 - Read root Codex rollouts written with `history_mode: "paginated"` by using diff --git a/docs/antigravity-desktop-format.md b/docs/antigravity-desktop-format.md new file mode 100644 index 0000000..471e0be --- /dev/null +++ b/docs/antigravity-desktop-format.md @@ -0,0 +1,189 @@ +# 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 +``` + +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 +``` + +Run the native language server oracle (macOS with Antigravity Desktop installed): + +```console +uv run pytest -q tests/test_antigravity_desktop_native.py +``` + +## 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. diff --git a/docs/format-compatibility.md b/docs/format-compatibility.md index 25e070d..4430c1c 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.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..bacc6e1 --- /dev/null +++ b/src/session_migrate/formats/antigravity_desktop.py @@ -0,0 +1,610 @@ +"""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 +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 +import sys +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.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" +) + + +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. + + 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: + 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: + 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: 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-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"), + ): + if cand.is_file(): + return cand + 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" +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, + *, + 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_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")) + + 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 current_platform not in PINNED_DESKTOP_SPECS: + raise SessionMigrateError( + 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." + ) + + expected_size, expected_digest = PINNED_DESKTOP_SPECS[current_platform] + if info.st_size != expected_size: + raise SessionMigrateError( + f"Antigravity Desktop binary size mismatch on {current_platform}: expected " + f"{expected_size} bytes, observed {info.st_size}" + ) + digest = _stream_sha256(path, maximum=expected_size) + if digest != expected_digest: + raise SessionMigrateError( + f"Antigravity Desktop binary digest mismatch on {current_platform}; " + "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..8877c5e --- /dev/null +++ b/tests/test_antigravity_desktop_format.py @@ -0,0 +1,440 @@ +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 + + +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) + + # 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, + 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 new file mode 100644 index 0000000..5df9cb9 --- /dev/null +++ b/tests/test_antigravity_desktop_native.py @@ -0,0 +1,190 @@ +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: + 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: + 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]]