1// @effect-diagnostics nodeBuiltinImport:off - Effect has no incremental digest or free-space query.
2import * as EffectNodeStream from "@effect/platform-node/NodeStream";
3import { ProviderDriverKind, type ProviderInstallState } from "@t3tools/contracts";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
6import * as Clock from "effect/Clock";
7import * as Cause from "effect/Cause";
8import * as Context from "effect/Context";
9import * as Crypto from "effect/Crypto";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import * as Fiber from "effect/Fiber";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Path from "effect/Path";
17import * as Schema from "effect/Schema";
18import * as Scope from "effect/Scope";
19import * as Semaphore from "effect/Semaphore";
20import * as Stream from "effect/Stream";
21import * as SubscriptionRef from "effect/SubscriptionRef";
22import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
23import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
24import * as NodeCrypto from "node:crypto";
25import * as NodeFSP from "node:fs/promises";
26
27import * as ServerConfig from "../config.ts";
28import { openZipArchive } from "../zipArchive.ts";
29import { makeAntigravityAcpRuntime } from "./acp/AntigravityAcpSupport.ts";
30import {
31 buildAntigravityAcpSpawnInput,
32 prepareAntigravityProfile,
33} from "./antigravityAuthSupport.ts";
34import {
35 resolveAntigravityReleaseAsset,
36 type AntigravityReleaseAsset,
37} from "./antigravityRelease.ts";
38
39const DRIVER = ProviderDriverKind.make("antigravity");
40const DOWNLOAD_TIMEOUT = "45 minutes";
41const VALIDATION_TIMEOUT = "90 seconds";
42const FREE_SPACE_MARGIN = 256 * 1024 * 1024;
43const RECORD_MAX_BYTES = 8 * 1024;
44const RELEASE_RECORD = ".install-complete.json";
45
46const ReleaseId = Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/u));
47const ActiveRelease = Schema.Struct({ releaseId: ReleaseId });
48const InstalledRelease = Schema.Struct({
49 releaseId: ReleaseId,
50 version: Schema.String,
51 executable: Schema.Struct({ name: Schema.String, bytes: Schema.Number }),
52 harness: Schema.Struct({ name: Schema.String, bytes: Schema.Number }),
53});
54type InstalledRelease = typeof InstalledRelease.Type;
55const encodeActiveRelease = Schema.encodeEffect(Schema.fromJsonString(ActiveRelease));
56const encodeInstalledRelease = Schema.encodeEffect(Schema.fromJsonString(InstalledRelease));
57
58export class AntigravityInstallationError extends Schema.TaggedError<AntigravityInstallationError>()(
59 "AntigravityInstallationError",
60 {
61 operation: Schema.String,
62 detail: Schema.String,
63 cause: Schema.optional(Schema.Defect()),
64 },
65) {
66 override get message() {
67 return this.detail;
68 }
69}
70const isInstallationError = Schema.is(AntigravityInstallationError);
71
72export interface AntigravityExecutable {
73 readonly executablePath: string;
74 readonly harnessPath: string;
75 readonly source: "override" | "managed" | "path";
76 readonly version: string | null;
77 readonly managedVersionDirectory: string | null;
78}
79
80interface AntigravityInstallationService {
81 readonly managedDirectory: string;
82 readonly resolve: (
83 binaryPath?: string,
84 environment?: NodeJS.ProcessEnv,
85 ) => Effect.Effect<AntigravityExecutable, AntigravityInstallationError>;
86 /** Hold the lease until the spawned process has exited. */
87 readonly acquire: (
88 binaryPath?: string,
89 environment?: NodeJS.ProcessEnv,
90 ) => Effect.Effect<AntigravityExecutable, AntigravityInstallationError, Scope.Scope>;
91 readonly start: Effect.Effect<ProviderInstallState, AntigravityInstallationError>;
92 readonly cancel: (
93 operationId: string,
94 ) => Effect.Effect<ProviderInstallState, AntigravityInstallationError>;
95 readonly state: Effect.Effect<ProviderInstallState>;
96 readonly changes: Stream.Stream<ProviderInstallState>;
97 readonly remove: (
98 protectedBinaryPaths?: ReadonlyArray<string>,
99 ) => Effect.Effect<void, AntigravityInstallationError>;
100}
101
102export class AntigravityInstallation extends Context.Service<
103 AntigravityInstallation,
104 AntigravityInstallationService
105>()("t3/provider/AntigravityInstallation") {
106 static readonly layer = Layer.effect(
107 AntigravityInstallation,
108 Effect.gen(function* () {
109 const config = yield* ServerConfig.ServerConfig;
110 return yield* makeAntigravityInstallation({ baseDir: config.baseDir });
111 }),
112 );
113}
114
115export interface AntigravityInstallationOptions {
116 readonly baseDir: string;
117 readonly releaseAsset?: AntigravityReleaseAsset | null;
118 readonly validate?: (
119 executable: AntigravityExecutable,
120 expectedVersion: string,
121 ) => Effect.Effect<void, AntigravityInstallationError, Scope.Scope>;
122}
123
124const installationError = (operation: string, detail: string, cause?: unknown) =>
125 new AntigravityInstallationError({
126 operation,
127 detail,
128 ...(cause === undefined ? {} : { cause }),
129 });
130
131const wrapFailure = (operation: string, detail: string) => (cause: unknown) =>
132 isInstallationError(cause) ? cause : installationError(operation, detail, cause);
133
134function executableNames(platform: NodeJS.Platform) {
135 return platform === "win32"
136 ? { executable: "agy_acp_server.exe", harness: "localharness_external.exe" }
137 : { executable: "agy_acp_server.par", harness: "localharness_external" };
138}
139
140function isRunning(state: ProviderInstallState) {
141 return (
142 state.phase === "downloading" || state.phase === "extracting" || state.phase === "verifying"
143 );
144}
145
146export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.make")(function* (
147 options: AntigravityInstallationOptions,
148) {
149 const fs = yield* FileSystem.FileSystem;
150 const path = yield* Path.Path;
151 const crypto = yield* Crypto.Crypto;
152 const http = yield* HttpClient.HttpClient;
153 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
154 const serviceScope = yield* Effect.scope;
155 const platform = yield* HostProcess.Platform;
156 const arch = yield* HostProcess.Architecture;
157 const environment = yield* HostProcess.Environment;
158 const releaseAsset =
159 options.releaseAsset === undefined
160 ? resolveAntigravityReleaseAsset(platform, arch)
161 : options.releaseAsset;
162 const names = executableNames(platform);
163 const managedDirectory = path.join(
164 options.baseDir,
165 "tools",
166 "antigravity-acp",
167 `${platform}-${arch}`,
168 );
169 const versionsDirectory = path.join(managedDirectory, "versions");
170 const activePath = path.join(managedDirectory, "active.json");
171 const gate = yield* Semaphore.make(1);
172 const leases = new Map<string, number>();
173 let running: { readonly operationId: string; readonly fiber: Fiber.Fiber<void> } | undefined;
174 const state = yield* SubscriptionRef.make<ProviderInstallState>({
175 driver: DRIVER,
176 operationId: null,
177 phase: "idle",
178 downloadedBytes: 0,
179 totalBytes: releaseAsset?.archiveBytes ?? null,
180 version: releaseAsset?.version ?? null,
181 installedVersion: null,
182 canRemove: false,
183 message: null,
184 });
185
186 const readRecord = Effect.fn("AntigravityInstallation.readRecord")(function* <A>(
187 filePath: string,
188 schema: Schema.Codec<A>,
189 ) {
190 const info = yield* fs.stat(filePath);
191 if (info.type !== "File" || Number(info.size) > RECORD_MAX_BYTES) {
192 return yield* installationError(
193 "resolve",
194 "The managed runtime record is invalid. Reinstall Antigravity.",
195 );
196 }
197 const contents = yield* fs.readFileString(filePath);
198 return yield* Schema.decodeEffect(Schema.fromJsonString(schema))(contents);
199 });
200
201 const executableFile = Effect.fn("AntigravityInstallation.executableFile")(function* (
202 filePath: string,
203 bytes?: number,
204 ) {
205 const info = yield* fs.stat(filePath).pipe(Effect.option);
206 return (
207 Option.isSome(info) &&
208 info.value.type === "File" &&
209 (bytes === undefined || Number(info.value.size) === bytes) &&
210 (platform === "win32" || (info.value.mode & 0o111) !== 0)
211 );
212 });
213
214 const completedRelease = Effect.fn("AntigravityInstallation.completedRelease")(function* (
215 releaseId: string,
216 ) {
217 const directory = path.join(versionsDirectory, releaseId);
218 const record = yield* readRecord(path.join(directory, RELEASE_RECORD), InstalledRelease);
219 if (
220 record.releaseId !== releaseId ||
221 record.executable.name !== names.executable ||
222 record.harness.name !== names.harness ||
223 !Number.isSafeInteger(record.executable.bytes) ||
224 record.executable.bytes <= 0 ||
225 !Number.isSafeInteger(record.harness.bytes) ||
226 record.harness.bytes <= 0 ||
227 !record.version.trim() ||
228 !(yield* executableFile(path.join(directory, names.executable), record.executable.bytes)) ||
229 !(yield* executableFile(path.join(directory, names.harness), record.harness.bytes))
230 ) {
231 return yield* installationError(
232 "resolve",
233 "The managed Antigravity runtime is incomplete. Reinstall it.",
234 );
235 }
236 return {
237 executablePath: path.join(directory, names.executable),
238 harnessPath: path.join(directory, names.harness),
239 source: "managed",
240 version: record.version,
241 managedVersionDirectory: directory,
242 } satisfies AntigravityExecutable;
243 });
244
245 const fromExternal = Effect.fn("AntigravityInstallation.fromExternal")(function* (
246 candidate: string,
247 source: "override" | "path",
248 ) {
249 if (!(yield* executableFile(candidate))) return null;
250 const executablePath = yield* fs.realPath(candidate);
251 const directory = path.dirname(executablePath);
252 const harnessPath = path.join(directory, names.harness);
253 if (!(yield* executableFile(harnessPath))) return null;
254 const realVersions = yield* fs.realPath(versionsDirectory).pipe(Effect.option);
255 if (
256 Option.isSome(realVersions) &&
257 path.dirname(directory) === realVersions.value &&
258 /^[a-f0-9]{64}$/u.test(path.basename(directory))
259 ) {
260 const installed = yield* completedRelease(path.basename(directory));
261 return { ...installed, executablePath, harnessPath, source } satisfies AntigravityExecutable;
262 }
263 return {
264 executablePath,
265 harnessPath,
266 source,
267 version: null,
268 managedVersionDirectory: null,
269 } satisfies AntigravityExecutable;
270 });
271
272 const pathCandidates = (binary: string, processEnvironment = environment) => {
273 const pathValue =
274 platform === "win32"
275 ? Object.entries(processEnvironment).findLast(([key]) => key.toUpperCase() === "PATH")?.[1]
276 : processEnvironment.PATH;
277 return (pathValue ?? "")
278 .split(platform === "win32" ? ";" : ":")
279 .map((directory) => directory.trim().replace(/^"|"$/gu, ""))
280 .filter((directory) => directory.length > 0)
281 .map((directory) => path.resolve(directory, binary));
282 };
283
284 const resolve: AntigravityInstallationService["resolve"] = Effect.fn(
285 "AntigravityInstallation.resolve",
286 )(
287 function* (binaryPath?: string, processEnvironment?: NodeJS.ProcessEnv) {
288 const override = binaryPath?.trim();
289 if (override) {
290 const candidates =
291 path.isAbsolute(override) || override.includes("/") || override.includes("\\")
292 ? [path.resolve(override)]
293 : pathCandidates(override, processEnvironment);
294 for (const candidate of candidates) {
295 const selected = yield* fromExternal(candidate, "override");
296 if (selected) return selected;
297 }
298 return yield* installationError(
299 "resolve",
300 "The custom Antigravity executable or its localharness_external sibling is missing or not executable.",
301 );
302 }
303 if (yield* fs.exists(activePath)) {
304 const active = yield* readRecord(activePath, ActiveRelease);
305 return yield* completedRelease(active.releaseId);
306 }
307 for (const candidate of pathCandidates(names.executable, processEnvironment)) {
308 const selected = yield* fromExternal(candidate, "path");
309 if (selected) return selected;
310 }
311 return yield* installationError(
312 "resolve",
313 releaseAsset
314 ? "Antigravity is not installed. Install it in this environment or set a custom executable path."
315 : `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported environment or a custom executable.`,
316 );
317 },
318 Effect.mapError(
319 wrapFailure(
320 "resolve",
321 "Could not read the Antigravity installation. Reinstall it or set a custom executable path.",
322 ),
323 ),
324 );
325
326 const acquire = (binaryPath?: string, processEnvironment?: NodeJS.ProcessEnv) =>
327 Effect.acquireRelease(
328 gate.withPermit(
329 Effect.gen(function* () {
330 const executable = yield* resolve(binaryPath, processEnvironment);
331 const directory = executable.managedVersionDirectory;
332 if (directory) leases.set(directory, (leases.get(directory) ?? 0) + 1);
333 return executable;
334 }),
335 ),
336 (executable) =>
337 gate.withPermit(
338 Effect.sync(() => {
339 const directory = executable.managedVersionDirectory;
340 if (!directory) return;
341 const remaining = (leases.get(directory) ?? 1) - 1;
342 if (remaining > 0) leases.set(directory, remaining);
343 else leases.delete(directory);
344 }),
345 ),
346 );
347
348 const validate =
349 options.validate ??
350 Effect.fn("AntigravityInstallation.validate")(
351 function* (executable: AntigravityExecutable, expectedVersion: string) {
352 const profileDirectory = yield* fs.makeTempDirectoryScoped({
353 prefix: "t3-antigravity-validate-",
354 });
355 const profile = yield* prepareAntigravityProfile({
356 profileDirectory,
357 platform,
358 baseEnv: environment,
359 // The profile is scoped, so it cleans up the unpack; a shallow
360 // root keeps it under Windows' path limit.
361 tempDirectory: profileDirectory,
362 });
363 const runtime = yield* makeAntigravityAcpRuntime({
364 spawn: buildAntigravityAcpSpawnInput({
365 installation: executable,
366 profile,
367 cwd: profileDirectory,
368 baseEnv: environment,
369 }),
370 cwd: profileDirectory,
371 childProcessSpawner: spawner,
372 clientInfo: { name: "t3-code", version: "0.0.0" },
373 });
374 const initialized = yield* runtime.initialize();
375 if (
376 initialized.agentInfo?.name !== "antigravity-acp" ||
377 initialized.agentInfo.version !== expectedVersion ||
378 // Antigravity 1.1.1 can report 2 with the legacy ACP response shape.
379 // The ACP client chooses the session wire format from that shape.
380 (initialized.protocolVersion !== 1 && initialized.protocolVersion !== 2) ||
381 initialized.agentCapabilities?.loadSession !== true ||
382 !initialized.agentCapabilities.sessionCapabilities?.resume ||
383 !initialized.agentCapabilities.auth?.logout ||
384 !initialized.authMethods?.some((method) => method.id === "oauth-personal")
385 ) {
386 return yield* installationError(
387 "verify",
388 "The downloaded runtime did not identify as the expected Google Antigravity release.",
389 );
390 }
391 },
392 Effect.provideService(FileSystem.FileSystem, fs),
393 Effect.provideService(Path.Path, path),
394 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
395 Effect.provideService(Crypto.Crypto, crypto),
396 Effect.mapError(
397 wrapFailure(
398 "verify",
399 "The downloaded Antigravity runtime could not start in this environment.",
400 ),
401 ),
402 );
403
404 const install = Effect.fn("AntigravityInstallation.install")(
405 function* (asset: AntigravityReleaseAsset) {
406 yield* resolveNodeExecutable("Antigravity", environment).pipe(
407 Effect.provideService(FileSystem.FileSystem, fs),
408 Effect.provideService(Path.Path, path),
409 Effect.provideService(HostProcess.Platform, platform),
410 Effect.mapError((cause) =>
411 installationError("verify", nodeRuntimeUnavailableMessage("Antigravity"), cause),
412 ),
413 );
414 const report = (phase: ProviderInstallState["phase"], message: string | null) =>
415 SubscriptionRef.update(state, (current) => ({ ...current, phase, message }));
416 yield* fs.makeDirectory(versionsDirectory, { recursive: true });
417 yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove: true }));
418 const destination = path.join(versionsDirectory, asset.sha256);
419 const activate = Effect.fn("AntigravityInstallation.activate")(
420 function* () {
421 const pointerDirectory = yield* fs.makeTempDirectoryScoped({
422 directory: managedDirectory,
423 prefix: "active.json.",
424 });
425 const pointerPath = path.join(pointerDirectory, "contents.tmp");
426 yield* fs.writeFileString(
427 pointerPath,
428 yield* encodeActiveRelease({ releaseId: asset.sha256 }),
429 { flag: "wx", mode: 0o600 },
430 );
431 yield* fs.rename(pointerPath, activePath);
432 // The pointer commits the install. Later temp cleanup cannot undo it.
433 yield* SubscriptionRef.update(
434 state,
435 (current) =>
436 ({
437 ...current,
438 phase: "succeeded",
439 installedVersion: asset.version,
440 message: null,
441 }) satisfies ProviderInstallState,
442 );
443 },
444 Effect.scoped,
445 Effect.mapError(
446 wrapFailure(
447 "activate",
448 "Could not activate Antigravity. The previous runtime is unchanged. Check for locked files and try again.",
449 ),
450 ),
451 Effect.uninterruptible,
452 );
453
454 if (yield* fs.exists(destination)) {
455 const existing = yield* completedRelease(asset.sha256);
456 if (existing.version !== asset.version) {
457 return yield* installationError(
458 "verify",
459 "The existing managed release has the wrong version. Remove it before reinstalling.",
460 );
461 }
462 yield* report("verifying", "Checking the installed runtime.");
463 yield* validate(existing, asset.version).pipe(
464 Effect.scoped,
465 Effect.timeout(VALIDATION_TIMEOUT),
466 );
467 yield* activate();
468 return;
469 }
470
471 const available = yield* Effect.tryPromise(() =>
472 NodeFSP.statfs(versionsDirectory, { bigint: true }),
473 ).pipe(Effect.option);
474 const required =
475 asset.archiveBytes + asset.executable.bytes + asset.harness.bytes + FREE_SPACE_MARGIN;
476 if (
477 Option.isSome(available) &&
478 available.value.bavail * available.value.bsize < BigInt(required)
479 ) {
480 return yield* installationError(
481 "download",
482 `Antigravity needs at least ${Math.ceil(required / 1024 / 1024)} MiB of free space to install.`,
483 );
484 }
485 const staging = yield* fs.makeTempDirectoryScoped({
486 directory: versionsDirectory,
487 prefix: ".install-",
488 });
489 const archivePath = path.join(staging, "download.zip");
490 const pairDirectory = path.join(staging, "runtime");
491 yield* fs.makeDirectory(pairDirectory);
492 const hash = NodeCrypto.createHash("sha256");
493 let downloadedBytes = 0;
494 let lastProgressAt = yield* Clock.currentTimeMillis;
495 yield* Effect.gen(function* () {
496 const response = yield* http
497 .execute(HttpClientRequest.get(asset.url))
498 .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
499 // dl.google.com gzips the zip when the client accepts it, so
500 // `content-length` is the encoded size. The decoded stream is still
501 // checked against the pinned byte count and hash below.
502 const contentLength = response.headers["content-length"];
503 const contentEncoding = response.headers["content-encoding"]?.trim().toLowerCase();
504 const identityBody = contentEncoding === undefined || contentEncoding === "identity";
505 if (
506 identityBody &&
507 contentLength !== undefined &&
508 Number(contentLength) !== asset.archiveBytes
509 ) {
510 return yield* installationError(
511 "download",
512 "The Antigravity download size did not match the pinned release.",
513 );
514 }
515 yield* response.stream.pipe(
516 Stream.tap((chunk) =>
517 Effect.gen(function* () {
518 downloadedBytes += chunk.byteLength;
519 if (downloadedBytes > asset.archiveBytes) {
520 return yield* installationError(
521 "download",
522 "The Antigravity download exceeded the pinned release size.",
523 );
524 }
525 hash.update(chunk);
526 const now = yield* Clock.currentTimeMillis;
527 if (now - lastProgressAt >= 250 || downloadedBytes === asset.archiveBytes) {
528 lastProgressAt = now;
529 yield* SubscriptionRef.update(state, (current) => ({
530 ...current,
531 downloadedBytes,
532 }));
533 }
534 }),
535 ),
536 Stream.run(fs.sink(archivePath, { flag: "wx", mode: 0o600 })),
537 );
538 }).pipe(Effect.timeout(DOWNLOAD_TIMEOUT));
539 if (downloadedBytes !== asset.archiveBytes || hash.digest("hex") !== asset.sha256) {
540 return yield* installationError(
541 "download",
542 "The Antigravity download failed its size or SHA-256 check. Nothing was installed.",
543 );
544 }
545
546 yield* report("extracting", "Extracting the verified runtime.");
547 yield* Effect.gen(function* () {
548 const archive = yield* openZipArchive(archivePath, (detail, cause) =>
549 installationError("extract", detail, cause),
550 );
551 if (archive.entryCount !== 2) {
552 return yield* installationError(
553 "extract",
554 "The archive must contain exactly the Antigravity executable and its harness.",
555 );
556 }
557 const seen = new Set<string>();
558 for (;;) {
559 const entry = yield* archive.next;
560 if (!entry) break;
561 const expected = [asset.executable, asset.harness].find(
562 (file) => file.name === entry.fileName,
563 );
564 const unixType = (entry.externalFileAttributes >>> 16) & 0o170000;
565 if (
566 !expected ||
567 seen.has(entry.fileName) ||
568 entry.fileName.includes("/") ||
569 entry.fileName.includes("\\") ||
570 (unixType !== 0 && unixType !== 0o100000) ||
571 (entry.externalFileAttributes & 0x10) !== 0 ||
572 (entry.generalPurposeBitFlag & 1) !== 0 ||
573 ![0, 8].includes(entry.compressionMethod) ||
574 entry.uncompressedSize !== expected.bytes
575 ) {
576 return yield* installationError(
577 "extract",
578 "The archive contains an unexpected, unsafe, or incorrectly sized member.",
579 );
580 }
581 seen.add(entry.fileName);
582 yield* Effect.gen(function* () {
583 const readable = yield* archive.streamEntry(entry);
584 let extractedBytes = 0;
585 yield* EffectNodeStream.fromReadable<Uint8Array, AntigravityInstallationError>({
586 evaluate: () => readable,
587 onError: wrapFailure("extract", "Could not extract the Antigravity runtime."),
588 }).pipe(
589 Stream.tap((chunk) =>
590 Effect.gen(function* () {
591 extractedBytes += chunk.byteLength;
592 if (extractedBytes > expected.bytes) {
593 return yield* installationError(
594 "extract",
595 "An archive member exceeded its pinned size.",
596 );
597 }
598 }),
599 ),
600 Stream.run(
601 fs.sink(path.join(pairDirectory, entry.fileName), { flag: "wx", mode: 0o700 }),
602 ),
603 );
604 if (extractedBytes !== expected.bytes) {
605 return yield* installationError("extract", "An archive member was truncated.");
606 }
607 }).pipe(Effect.scoped);
608 }
609 if (!seen.has(asset.executable.name) || !seen.has(asset.harness.name)) {
610 return yield* installationError(
611 "extract",
612 "The archive is missing the Antigravity executable or its harness.",
613 );
614 }
615 }).pipe(Effect.scoped);
616 yield* fs.remove(archivePath);
617 if (platform !== "win32") {
618 yield* fs.chmod(path.join(pairDirectory, asset.executable.name), 0o755);
619 yield* fs.chmod(path.join(pairDirectory, asset.harness.name), 0o755);
620 }
621 yield* report("verifying", "Checking the downloaded runtime.");
622 yield* validate(
623 {
624 executablePath: path.join(pairDirectory, asset.executable.name),
625 harnessPath: path.join(pairDirectory, asset.harness.name),
626 source: "managed",
627 version: asset.version,
628 managedVersionDirectory: pairDirectory,
629 },
630 asset.version,
631 ).pipe(Effect.scoped, Effect.timeout(VALIDATION_TIMEOUT));
632 const record: InstalledRelease = {
633 releaseId: asset.sha256,
634 version: asset.version,
635 executable: asset.executable,
636 harness: asset.harness,
637 };
638 yield* fs.writeFileString(
639 path.join(pairDirectory, RELEASE_RECORD),
640 yield* encodeInstalledRelease(record),
641 { flag: "wx", mode: 0o600 },
642 );
643 yield* fs.rename(pairDirectory, destination).pipe(
644 Effect.catch((cause) =>
645 completedRelease(asset.sha256).pipe(
646 Effect.flatMap((existing) =>
647 existing.version === asset.version
648 ? validate(existing, asset.version).pipe(
649 Effect.scoped,
650 Effect.timeout(VALIDATION_TIMEOUT),
651 )
652 : Effect.fail(
653 installationError(
654 "activate",
655 "Another installation published a different Antigravity release.",
656 ),
657 ),
658 ),
659 Effect.mapError(() =>
660 installationError(
661 "activate",
662 "Could not publish the Antigravity runtime. The previous release is unchanged. Try again.",
663 cause,
664 ),
665 ),
666 ),
667 ),
668 );
669 yield* activate();
670 },
671 Effect.scoped,
672 Effect.mapError(
673 wrapFailure(
674 "install",
675 "Could not install Antigravity. Check free disk space and directory access, then try again.",
676 ),
677 ),
678 );
679
680 const start = gate
681 .withPermit(
682 Effect.gen(function* () {
683 const current = yield* SubscriptionRef.get(state);
684 if (isRunning(current)) return current;
685 if (!releaseAsset) {
686 return yield* installationError(
687 "start",
688 `Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`,
689 );
690 }
691 const operationId = yield* crypto.randomUUIDv4();
692 const next: ProviderInstallState = {
693 driver: DRIVER,
694 operationId,
695 phase: "downloading",
696 downloadedBytes: 0,
697 totalBytes: releaseAsset.archiveBytes,
698 version: releaseAsset.version,
699 installedVersion: current.installedVersion,
700 canRemove: current.canRemove,
701 message: "Downloading Google's official Antigravity runtime.",
702 };
703 yield* SubscriptionRef.set(state, next);
704 const work = install(releaseAsset).pipe(
705 Effect.onExit((exit) =>
706 Exit.isFailure(exit)
707 ? SubscriptionRef.update(state, (value) => {
708 if (value.operationId !== operationId || value.phase === "succeeded")
709 return value;
710 const error = Cause.findErrorOption(exit.cause);
711 const cancelled = Cause.hasInterruptsOnly(exit.cause);
712 return {
713 ...value,
714 phase: cancelled ? "cancelled" : "failed",
715 message: cancelled
716 ? "Installation cancelled. The previous runtime is unchanged."
717 : Option.isSome(error)
718 ? error.value.detail
719 : "Could not finish the Antigravity installation. Check disk space and directory access.",
720 } satisfies ProviderInstallState;
721 })
722 : Effect.void,
723 ),
724 Effect.ignoreCause,
725 Effect.ensuring(
726 Effect.sync(() => {
727 if (running?.operationId === operationId) running = undefined;
728 }),
729 ),
730 );
731 const fiber = yield* Effect.forkIn(Effect.interruptible(work), serviceScope);
732 running = { operationId, fiber };
733 return next;
734 }).pipe(Effect.uninterruptible),
735 )
736 .pipe(Effect.mapError(wrapFailure("start", "Could not start the Antigravity installation.")));
737
738 const cancel = Effect.fn("AntigravityInstallation.cancel")(function* (operationId: string) {
739 return yield* gate.withPermit(
740 Effect.gen(function* () {
741 const current = yield* SubscriptionRef.get(state);
742 if (current.operationId !== operationId) {
743 return yield* installationError(
744 "cancel",
745 "This installation is no longer current. Refresh its status before cancelling.",
746 );
747 }
748 if (running?.operationId === operationId && isRunning(current)) {
749 yield* Fiber.interrupt(running.fiber);
750 }
751 return yield* SubscriptionRef.get(state);
752 }),
753 );
754 });
755
756 const remove = Effect.fn("AntigravityInstallation.remove")(
757 function* (protectedBinaryPaths: ReadonlyArray<string> = []) {
758 yield* gate.withPermit(
759 Effect.gen(function* () {
760 if (isRunning(yield* SubscriptionRef.get(state)) || leases.size > 0) {
761 return yield* installationError(
762 "remove",
763 "Stop Antigravity sessions and sign-in flows before removing its managed runtime.",
764 );
765 }
766 const realManaged = yield* fs.realPath(managedDirectory).pipe(Effect.option);
767 if (Option.isSome(realManaged)) {
768 for (const binaryPath of protectedBinaryPaths) {
769 if (!binaryPath.trim()) continue;
770 const selected = yield* resolve(binaryPath).pipe(Effect.option);
771 if (Option.isSome(selected) && selected.value.managedVersionDirectory) {
772 return yield* installationError(
773 "remove",
774 "A provider instance has a custom path inside this managed runtime. Clear that path before removing it.",
775 );
776 }
777 const resolved = yield* fs.realPath(binaryPath).pipe(Effect.option);
778 const candidate = Option.getOrElse(resolved, () => path.resolve(binaryPath));
779 if (candidate.startsWith(`${realManaged.value}${path.sep}`)) {
780 return yield* installationError(
781 "remove",
782 "A provider instance has a custom path inside this managed runtime. Clear that path before removing it.",
783 );
784 }
785 }
786 }
787 yield* fs.remove(managedDirectory, { recursive: true, force: true });
788 yield* SubscriptionRef.update(
789 state,
790 (current) =>
791 ({
792 ...current,
793 operationId: null,
794 phase: "idle",
795 downloadedBytes: 0,
796 installedVersion: null,
797 canRemove: false,
798 message: null,
799 }) satisfies ProviderInstallState,
800 );
801 }).pipe(Effect.uninterruptible),
802 );
803 },
804 Effect.mapError(
805 wrapFailure(
806 "remove",
807 "Could not remove the managed Antigravity runtime. Check for open processes and try again.",
808 ),
809 ),
810 );
811
812 yield* Effect.gen(function* () {
813 const canRemove = yield* fs.exists(managedDirectory);
814 yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove }));
815 if (!(yield* fs.exists(activePath))) return;
816 const active = yield* readRecord(activePath, ActiveRelease);
817 const installed = yield* completedRelease(active.releaseId);
818 yield* SubscriptionRef.update(state, (current) => ({
819 ...current,
820 installedVersion: installed.version,
821 }));
822 }).pipe(
823 Effect.catch(() =>
824 SubscriptionRef.update(
825 state,
826 (current) =>
827 ({
828 ...current,
829 phase: "failed",
830 message: "The managed Antigravity runtime is incomplete. Remove it and reinstall.",
831 }) satisfies ProviderInstallState,
832 ),
833 ),
834 );
835
836 return AntigravityInstallation.of({
837 managedDirectory,
838 resolve,
839 acquire,
840 start,
841 cancel,
842 state: SubscriptionRef.get(state),
843 changes: SubscriptionRef.changes(state),
844 remove,
845 });
846});