import type { Clock } from "../ports/index.js"; import { DeviceOperationClaims, type DeviceOperation, type DeviceOperationClaim, } from "./device-operation-claims.js"; import { assertVirtual, type DeviceRecord, type DeviceState, type DeviceTransitionUpdate, type LeaseRecord, specMode, type VirtualDevice, } from "./domain.js"; import { readyTransitionUpdate, type DriverDevice, toDriverDevice } from "./driver.js"; import { DriverCatalog } from "./driver-catalog.js"; import { SerializedDecision } from "./serialized-decision.js"; export interface ManagedDeviceRegistry { readonly snapshot: { readonly virtualDevices: readonly VirtualDevice[]; readonly leases: readonly LeaseRecord[]; }; transitionDevice( deviceId: string, to: DeviceState, event?: | { readonly event: "device.ready"; readonly payload: { readonly deviceId: string; readonly bootDuration: number }; } | { readonly event: "device.shutdown"; readonly payload: { readonly deviceId: string; readonly initiator: string }; } | { readonly event: "device.deleted"; readonly payload: { readonly deviceId: string; readonly initiator: string }; }, update?: DeviceTransitionUpdate, ): Promise; } interface ClaimedDevice { readonly claim: DeviceOperationClaim; readonly device: VirtualDevice; readonly release: () => void; } /** An exclusively claimed ready device that must be leased or released. */ export interface ReadyDeviceHandoff { readonly claim: DeviceOperationClaim; readonly device: VirtualDevice; } /** * Performs registry-owned device operations. Driver calls are deliberately * outside the decision gate; each operation is claimed and revalidated first. */ export class ManagedDeviceLifecycle { constructor( private readonly catalog: DriverCatalog, private readonly registry: ManagedDeviceRegistry, private readonly decisions: SerializedDecision, private readonly claims: DeviceOperationClaims, private readonly clock: Clock, ) {} /** Makes a device ready while retaining its claim for an immediate lease handoff. */ async bootForLease( target: VirtualDevice, claim: DeviceOperationClaim, label?: string, ): Promise { return this.#makeReadyForLease(target, "shutdown", claim, label); } /** * Boots a shut-down device back to warm under the `boot` claim the caller took, and ends the * claim once the `ready` commit is made or has failed. Where `bootForLease` keeps the claim for * a handoff to a lease, nothing follows this boot, so the device is released the moment it is * ready. `undefined` when the device is no longer a shut-down, unleased one. */ async bootWarm( target: VirtualDevice, claim: DeviceOperationClaim, ): Promise { const handoff = await this.#makeReadyForLease(target, "shutdown", claim); if (handoff === undefined) return undefined; await this.#release({ release: claim.release }); return handoff.device; } /** Makes a provisioned device ready while retaining its claim for lease handoff. */ async readyProvisionedForLease( target: VirtualDevice, claim?: DeviceOperationClaim, label?: string, ): Promise { return this.#makeReadyForLease(target, "provisioning", claim, label); } async shutdown( target: VirtualDevice, initiator: string, operation: Exclude, claim?: DeviceOperationClaim, ): Promise { const claimed = await this.#claim(target, ["ready"], operation, claim); if (claimed === undefined) return undefined; try { await this.catalog.get(claimed.device.spec.platform).shutdown(toDriverDevice(claimed.device)); } catch (error: unknown) { await this.#release(claimed); throw error; } return this.#commit(claimed, ["ready"], "shutdown", { event: "device.shutdown", payload: { deviceId: claimed.device.id, initiator }, }); } async destroy( target: VirtualDevice, initiator: string, operation: DeviceOperation, claim?: DeviceOperationClaim, ): Promise { const claimed = await this.#claim(target, ["provisioning", "shutdown"], operation, claim); if (claimed === undefined) return undefined; try { await this.catalog.get(claimed.device.spec.platform).destroy(toDriverDevice(claimed.device)); } catch (error: unknown) { await this.#release(claimed); throw error; } return this.#commit(claimed, ["provisioning", "shutdown"], "deleted", { event: "device.deleted", payload: { deviceId: claimed.device.id, initiator }, }); } /** * Reboots a device that is currently leased and whose underlying process * died outside simlock, so the lease can continue on the same device. The * device stays `leased` throughout: this performs no registry transition * and emits no event, deliberately -- the caller (a `LeaseHealthMonitor`) * owns deciding what happened and telling the holder. * * Calls `makeReady` with `purpose: "recover"` (see `Driver.makeReady`), never `"prepare"`: safety rule 2 permits this exception to reboot an already-provisioned device and * nothing more, so a driver must not use this reboot to apply any configuration change (the * iOS driver's slim pass) it would otherwise make on a normal `"prepare"` boot -- that would * be a second, unannounced reboot and a configuration change under an active lease, which is * exactly the broader privilege the rule says this exception does not grant. */ async recoverLeased(target: VirtualDevice, leaseId: string): Promise { const claimed = await this.#claimLeased(target, leaseId); if (claimed === undefined) return undefined; try { // The address `makeReady` re-reads is deliberately dropped here, unlike every other // readiness transition (see `Driver.makeReady`): recovery keeps the device `leased` // throughout, so there is no transition to commit it through, and neither driver moves // a device's address when rebooting one that already exists -- iOS UDIDs are fixed, and // Android reuses the console port already in the device's driver data. A driver that // ever does move it would need this to persist the new value, because the holder is // still using the address from its original grant. await this.catalog .get(claimed.device.spec.platform) .makeReady(toDriverDevice(claimed.device), { mode: specMode(claimed.device.spec), purpose: "recover", }); } catch (error: unknown) { await this.#release(claimed); throw error; } return this.decisions.run(() => { try { // The ~30s boot ran outside the gate; the holder may have released the // lease, or the TTL may have expired and moved the device on, while it // was in flight. Re-verify before handing the device back as recovered. return this.#leasedTarget(claimed.device, leaseId); } finally { claimed.release(); } }); } /** Shuts down a ready device when needed, then destroys it under one claim. */ async dispose( target: VirtualDevice, initiator: string, operation: Exclude, existingClaim?: DeviceOperationClaim, ): Promise { const claimed = await this.#claim(target, ["ready", "shutdown"], operation, existingClaim); if (claimed === undefined) return undefined; try { let current = claimed.device; if (current.state === "ready") { await this.catalog.get(current.spec.platform).shutdown(toDriverDevice(current)); const shutdown = await this.#commitWithoutRelease(claimed, ["ready"], "shutdown", { event: "device.shutdown", payload: { deviceId: current.id, initiator }, }); if (shutdown === undefined) { await this.#release(claimed); return undefined; } current = shutdown; } await this.catalog.get(current.spec.platform).destroy(toDriverDevice(current)); return this.#commit(claimed, ["shutdown"], "deleted", { event: "device.deleted", payload: { deviceId: current.id, initiator }, }); } catch (error: unknown) { await this.#release(claimed); throw error; } } async #makeReadyForLease( target: VirtualDevice, expectedState: "provisioning" | "shutdown", existingClaim?: DeviceOperationClaim, label?: string, ): Promise { const claimed = await this.#claim(target, [expectedState], "boot", existingClaim); if (claimed === undefined) return undefined; const startedAt = this.clock.now(); let ready: DriverDevice; try { ready = await this.catalog .get(claimed.device.spec.platform) .makeReady(toDriverDevice(claimed.device), { mode: specMode(claimed.device.spec), purpose: "prepare", ...(label === undefined ? {} : { label }), }); } catch (error: unknown) { await this.#release(claimed); throw error; } try { const device = await this.#commitWithoutRelease( claimed, [expectedState], "ready", { event: "device.ready", payload: { bootDuration: this.clock.now() - startedAt, deviceId: claimed.device.id }, }, readyTransitionUpdate(ready), ); if (device === undefined) { await this.#release(claimed); return undefined; } return { claim: claimed.claim, device }; } catch (error: unknown) { await this.#release(claimed); throw error; } } async #claim( target: VirtualDevice, expectedStates: readonly DeviceState[], operation: DeviceOperation, existingClaim?: DeviceOperationClaim, ): Promise { return this.decisions.run(() => { const device = this.#registeredTarget(target, expectedStates); if (device === undefined) return undefined; if (existingClaim !== undefined) { if ( existingClaim.deviceId !== device.id || existingClaim.operation !== operation || !this.claims.isActive(existingClaim) ) { return undefined; } return { claim: existingClaim, device, release: existingClaim.release }; } const claim = this.claims.tryClaim(device.id, operation); return claim === undefined ? undefined : { claim, device, release: claim.release }; }); } async #claimLeased(target: VirtualDevice, leaseId: string): Promise { return this.decisions.run(() => { const device = this.#leasedTarget(target, leaseId); if (device === undefined) return undefined; const claim = this.claims.tryClaim(device.id, "recovery"); return claim === undefined ? undefined : { claim, device, release: claim.release }; }); } async #commit( claimed: ClaimedDevice, expectedStates: readonly DeviceState[], to: DeviceState, event: Parameters[2], ): Promise { return this.decisions.run(async () => { try { const device = this.#registeredTarget(claimed.device, expectedStates); if (device === undefined) return undefined; return assertVirtual(await this.registry.transitionDevice(device.id, to, event)); } finally { claimed.release(); } }); } async #commitWithoutRelease( claimed: ClaimedDevice, expectedStates: readonly DeviceState[], to: DeviceState, event: Parameters[2], update?: DeviceTransitionUpdate, ): Promise { return this.decisions.run(async () => { const device = this.#registeredTarget(claimed.device, expectedStates); if (device === undefined) return undefined; return assertVirtual(await this.registry.transitionDevice(device.id, to, event, update)); }); } async #release(claimed: Pick): Promise { await this.decisions.run(() => claimed.release()); } #registeredTarget( target: VirtualDevice, expectedStates: readonly DeviceState[], ): VirtualDevice | undefined { const device = this.registry.snapshot.virtualDevices.find( (candidate) => candidate.id === target.id, ); if ( device === undefined || device.driverDeviceId !== target.driverDeviceId || !expectedStates.includes(device.state) || this.registry.snapshot.leases.some((lease) => lease.deviceId === device.id) ) { return undefined; } return device; } /** * The single, deliberate exception to safety rule 2 ("never touch a leased * device"). This is `#registeredTarget` inverted, not removed or * parameterised away: normal operations require no lease on the device, * this requires a lease with exactly the given id. A lease-id mismatch -- * someone else's lease, no lease at all, or the lease having moved on -- * always returns undefined, which is what stops recovery from ever acting * on a device leased by someone else or on an unleased device. */ #leasedTarget(target: VirtualDevice, leaseId: string): VirtualDevice | undefined { const device = this.registry.snapshot.virtualDevices.find( (candidate) => candidate.id === target.id, ); if (device === undefined || device.driverDeviceId !== target.driverDeviceId) return undefined; if (device.state !== "leased") return undefined; const lease = this.registry.snapshot.leases.find((candidate) => candidate.id === leaseId); return lease === undefined || lease.deviceId !== device.id ? undefined : device; } }