diff --git a/src/main/ipc/wsl-host-automation-wake-wiring.ts b/src/main/ipc/wsl-host-automation-wake-wiring.ts new file mode 100644 index 00000000000..12454abe672 --- /dev/null +++ b/src/main/ipc/wsl-host-automation-wake-wiring.ts @@ -0,0 +1,69 @@ +/** Production wiring for the E9 automation wake: host automations over runtime RPC, a userData file. */ +import type { Automation } from '../../shared/automations-types' +import { + readWslHostAutomationWakes, + WslHostAutomationWake, + writeWslHostAutomationWakes +} from '../wsl/wsl-host-automation-wake' +import type { WslHostService } from '../wsl/wsl-host-service' +import { callRuntimeEnvironment } from './runtime-environments/runtime-environment-transport-routing' +import { onWslOrcadHostAutomationsChanged } from '../wsl/wsl-orcad-host-environments' + +// A connected host's schedule can change from any client; re-read it this often. +const RELEARN_MS = 15 * 60 * 1000 + +export function createWslHostAutomationWake( + userDataPath: () => string, + service: WslHostService +): WslHostAutomationWake { + const wake = new WslHostAutomationWake({ + load: () => readWslHostAutomationWakes(userDataPath()), + save: (due) => writeWslHostAutomationWakes(userDataPath(), due), + listAutomations: async (environmentId) => { + const response = await callRuntimeEnvironment( + userDataPath(), + environmentId, + 'automation.list', + undefined + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = response.result + return result && typeof result === 'object' && 'automations' in result + ? automationsFrom(result.automations) + : [] + }, + isConnected: (environmentId) => service.isConnected(environmentId), + wake: (environmentId) => service.wakeForAutomation(environmentId) + }) + const relearn = setInterval(() => { + for (const status of service.getStatuses()) { + if (status.state === 'ready') { + void wake.learn(status.environmentId) + } + } + }, RELEARN_MS) + relearn.unref?.() + // A deleted, disabled or rescheduled automation must not keep its old wake time. + onWslOrcadHostAutomationsChanged((environmentId) => void wake.learn(environmentId)) + wake.start() + return wake +} + +/** Only rows carrying what the wake reads; anything else from an odd host is ignored. */ +function automationsFrom(value: unknown): Automation[] { + if (!Array.isArray(value)) { + return [] + } + return value.filter( + (row): row is Automation => + Boolean(row) && + typeof row === 'object' && + typeof row.enabled === 'boolean' && + typeof row.rrule === 'string' && + typeof row.dtstart === 'number' && + typeof row.nextRunAt === 'number' && + typeof row.missedRunGraceMinutes === 'number' + ) +} diff --git a/src/main/ipc/wsl-host-handlers.ts b/src/main/ipc/wsl-host-handlers.ts index 1b78a8233fb..405d5505a42 100644 --- a/src/main/ipc/wsl-host-handlers.ts +++ b/src/main/ipc/wsl-host-handlers.ts @@ -14,6 +14,8 @@ import { import { hasOrcadTemplate } from '../orcad/orcad-artifact-materializer' import { WslHostService } from '../wsl/wsl-host-service' import { WslHostVisibleWorkByWindow } from './wsl-host-visible-work-windows' +import type { WslHostAutomationWake } from '../wsl/wsl-host-automation-wake' +import { createWslHostAutomationWake } from './wsl-host-automation-wake-wiring' import { wslHostsGateOpen } from '../wsl/wsl-host-gate' import { getWslHostService, setWslHostService } from '../wsl/wsl-host-service-instance' import { retireRemovedRuntimeEnvironment } from './runtime-environments/runtime-environment-removal-cleanup' @@ -111,6 +113,7 @@ function createService(args: WslHostHandlerArgs): WslHostService | null { if (process.platform !== 'win32' && !fake) { return null } + let automationWake: WslHostAutomationWake | null = null const created = new WslHostService({ userDataPath: args.getUserDataPath, isEnabled: () => args.store.getSettings().experimentalWslHosts === true, @@ -125,14 +128,17 @@ function createService(args: WslHostHandlerArgs): WslHostService | null { if (distro) { pushHostAccounts(args.getUserDataPath, environmentId, distro) } + void automationWake?.learn(environmentId) }, - removed: (environmentId) => - retireRemovedRuntimeEnvironment( + removed: (environmentId) => { + automationWake?.forget(environmentId) + return retireRemovedRuntimeEnvironment( environmentId, args.invalidateTransport, args.store, args.getUserDataPath() ) + } }) const onChange = created.handleConnectionChange.bind(created) const isKnownDistro = (distro: string): boolean => created.isKnownDistro(distro) @@ -148,6 +154,7 @@ function createService(args: WslHostHandlerArgs): WslHostService | null { runtime }) ) + automationWake = createWslHostAutomationWake(args.getUserDataPath, created) return created } diff --git a/src/main/wsl/wsl-host-automation-wake.test.ts b/src/main/wsl/wsl-host-automation-wake.test.ts new file mode 100644 index 00000000000..5d099a369ea --- /dev/null +++ b/src/main/wsl/wsl-host-automation-wake.test.ts @@ -0,0 +1,203 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Automation } from '../../shared/automations-types' +import { nextAutomationOccurrenceAfter } from '../../shared/automation-schedule-occurrences' +import { + earliestWslHostAutomationDue, + nextWslHostAutomationDue, + readWslHostAutomationWakes, + WslHostAutomationWake, + writeWslHostAutomationWakes, + type WslHostAutomationDue +} from './wsl-host-automation-wake' + +const HOUR = 60 * 60 * 1000 +const HOURLY = '0 * * * *' + +function automation(overrides: Partial): Automation { + return { + id: 'a', + name: 'nightly', + prompt: '', + precheck: null, + agentId: 'claude', + projectId: 'p', + executionTargetType: 'local', + executionTargetId: 'p', + schedulerOwner: 'remote_host_service', + workspaceMode: 'new_per_run', + workspaceId: null, + baseBranch: null, + reuseSession: false, + timezone: 'UTC', + rrule: HOURLY, + dtstart: 0, + enabled: true, + nextRunAt: 10 * HOUR, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 0, + updatedAt: 0, + ...overrides + } +} + +function harness(options: { due?: Record; connected?: boolean }) { + let now = 0 + let timer: { at: number; task: () => void } | null = null + const saved: Record[] = [] + const wake = vi.fn(async (_environmentId: string) => {}) + const automations = vi.fn(async (): Promise => [automation({})]) + const subject = new WslHostAutomationWake({ + load: () => options.due ?? {}, + save: (due) => saved.push(due), + listAutomations: automations, + isConnected: async () => options.connected ?? false, + wake, + now: () => now, + setTimer: (delayMs, task) => { + timer = { at: now + delayMs, task } + return () => { + timer = null + } + } + }) + return { + subject, + wake, + automations, + saved, + timerAt: () => timer?.at ?? null, + /** Moves the clock to the armed timer and runs it. */ + async runTimer(at: number) { + now = at + const current = timer + const savesBefore = saved.length + timer = null + current?.task() + await vi.waitFor(() => expect(saved.length).toBeGreaterThan(savesBefore)) + } + } +} + +function due(nextRunAt: number, graceHours = 12): WslHostAutomationDue { + return { + nextRunAt, + wakeUntil: nextRunAt + graceHours * HOUR, + schedules: [{ rrule: HOURLY, dtstart: 0, missedRunGraceMinutes: graceHours * 60 }] + } +} + +describe('earliestWslHostAutomationDue', () => { + it('takes the soonest enabled run, its grace window, and every enabled schedule', () => { + expect( + earliestWslHostAutomationDue([ + automation({ nextRunAt: 5 * HOUR, enabled: false }), + automation({ nextRunAt: 8 * HOUR, missedRunGraceMinutes: 60 }), + automation({ nextRunAt: 9 * HOUR }) + ]) + ).toEqual({ + nextRunAt: 8 * HOUR, + wakeUntil: 9 * HOUR + 2 * 60_000, + schedules: [ + { rrule: HOURLY, dtstart: 0, missedRunGraceMinutes: 60 }, + { rrule: HOURLY, dtstart: 0, missedRunGraceMinutes: 720 } + ] + }) + expect(earliestWslHostAutomationDue([automation({ enabled: false })])).toBeNull() + }) + + it('computes the following run from the schedules alone', () => { + const now = 10 * HOUR + 5 * 60_000 + expect(nextWslHostAutomationDue(due(0).schedules, now)?.nextRunAt).toBe( + nextAutomationOccurrenceAfter(HOURLY, 0, now) + ) + }) +}) + +describe('WslHostAutomationWake', () => { + it('boots the host once when a learned run comes due with the host off', async () => { + const h = harness({}) + await h.subject.learn('wsl:ubuntu') + expect(h.timerAt()).toBe(HOUR) // capped re-arm, not 10 h + await h.runTimer(HOUR) + expect(h.wake).not.toHaveBeenCalled() + await h.runTimer(10 * HOUR) + expect(h.wake).toHaveBeenCalledExactlyOnceWith('wsl:ubuntu') + }) + + it('keeps the following run when a due one was handled while the host was connected', async () => { + const h = harness({ due: { 'wsl:ubuntu': due(-HOUR) }, connected: true }) + h.automations.mockResolvedValueOnce([automation({ nextRunAt: 2 * HOUR })]) + h.subject.start() + await h.runTimer(0) + expect(h.wake).not.toHaveBeenCalled() + // Re-read from the connected host, not dropped. + expect(h.saved.at(-1)?.['wsl:ubuntu']?.nextRunAt).toBe(2 * HOUR) + }) + + it('keeps the following run when a wake fails or the run is past its grace', async () => { + const failed = harness({ due: { 'wsl:ubuntu': due(-HOUR) } }) + failed.wake.mockRejectedValueOnce(new Error('boot failed')) + failed.subject.start() + await failed.runTimer(0) + const late = harness({ due: { 'wsl:ubuntu': due(-3 * HOUR, 1) } }) + late.subject.start() + await late.runTimer(0) + expect(late.wake).not.toHaveBeenCalled() + for (const { saved } of [failed, late]) { + expect(saved.at(-1)?.['wsl:ubuntu']?.nextRunAt).toBe( + nextAutomationOccurrenceAfter(HOURLY, 0, 0) + ) + } + }) + + it('fires a run that came due while the computer was off as soon as Orca starts', async () => { + const h = harness({ due: { 'wsl:ubuntu': due(-HOUR) } }) + h.subject.start() + expect(h.timerAt()).toBe(0) + await h.runTimer(0) + expect(h.wake).toHaveBeenCalledExactlyOnceWith('wsl:ubuntu') + }) + + it('a deleted or disabled automation no longer wakes the host once this desktop re-learns', async () => { + const h = harness({ due: { 'wsl:ubuntu': due(5 * HOUR) } }) + h.automations.mockResolvedValueOnce([automation({ enabled: false })]) + await h.subject.learn('wsl:ubuntu') + expect(h.saved.at(-1)).toEqual({}) + expect(h.timerAt()).toBeNull() + }) + + it('keeps what it knew when the host cannot be read, and forgets a removed host', async () => { + const h = harness({ due: { 'wsl:ubuntu': due(5 * HOUR) } }) + h.automations.mockRejectedValueOnce(new Error('offline')) + await h.subject.learn('wsl:ubuntu') + expect(h.saved).toEqual([]) + h.subject.forget('wsl:ubuntu') + expect(h.saved.at(-1)).toEqual({}) + expect(h.timerAt()).toBeNull() + }) +}) + +describe('persisted wake times', () => { + let dir: string | null = null + afterEach(() => { + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('round-trips and drops malformed entries', () => { + dir = mkdtempSync(join(tmpdir(), 'orca-wsl-wake-')) + writeWslHostAutomationWakes(dir, { a: due(1) }) + expect(readWslHostAutomationWakes(dir)).toEqual({ a: due(1) }) + writeFileSync( + join(dir, 'wsl-host-automation-wakes.json'), + JSON.stringify({ b: { nextRunAt: 1 } }) + ) + expect(readWslHostAutomationWakes(dir)).toEqual({}) + expect(readWslHostAutomationWakes(join(dir, 'missing'))).toEqual({}) + }) +}) diff --git a/src/main/wsl/wsl-host-automation-wake.ts b/src/main/wsl/wsl-host-automation-wake.ts new file mode 100644 index 00000000000..5fdbaa0f9aa --- /dev/null +++ b/src/main/wsl/wsl-host-automation-wake.ts @@ -0,0 +1,256 @@ +/** + * E9: a WSL host's automations run inside its distro, so a stopped distro misses them. While a host + * is connected (and after this desktop changes its automations) the desktop learns its enabled + * schedules and keeps them across restarts; when the soonest run comes due with the host off, this + * boots the distro once and orcad's startup catch-up fires the run. A due run is the user's own + * standing request, so it is the one non-interactive wake. Nothing here boots a distro to check. + */ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { Automation } from '../../shared/automations-types' +import { nextAutomationOccurrenceAfter } from '../../shared/automation-schedule-occurrences' + +export type WslHostAutomationSchedule = { + rrule: string + dtstart: number + missedRunGraceMinutes: number +} + +export type WslHostAutomationDue = { + nextRunAt: number + /** Past this, orcad would only record the run as missed, so a wake is pointless. */ + wakeUntil: number + /** Every enabled schedule, so the next due time survives a run handled while off. */ + schedules: WslHostAutomationSchedule[] +} + +export type WslHostAutomationWakeDeps = { + load: () => Record + save: (due: Record) => void + listAutomations: (environmentId: string) => Promise + /** + * Whether the host's server answers through this process now; never starts a stopped distro. + * False is not "stopped": a fresh process has no bridge yet, and the wake then just connects. + */ + isConnected: (environmentId: string) => Promise + /** Boots the host's distro and connects; it may refuse (host removed, gate closed). */ + wake: (environmentId: string) => Promise + now?: () => number + setTimer?: (delayMs: number, task: () => void) => () => void +} + +// Re-arm at least this often, so a clock change or sleep can't strand a far-off timer. +const MAX_TIMER_MS = 60 * 60 * 1000 +// orcad's own tick jitter allowance on top of each automation's grace. +const GRACE_SLACK_MS = 2 * 60 * 1000 + +/** The soonest enabled run and the schedules behind it, or null when nothing is scheduled. */ +export function earliestWslHostAutomationDue( + automations: readonly Automation[] +): WslHostAutomationDue | null { + const enabled = automations.filter( + (automation) => automation.enabled && Number.isFinite(automation.nextRunAt) + ) + const soonest = enabled.reduce( + (best, automation) => (!best || automation.nextRunAt < best.nextRunAt ? automation : best), + null + ) + return soonest + ? { + nextRunAt: soonest.nextRunAt, + wakeUntil: graceEnd(soonest.nextRunAt, soonest.missedRunGraceMinutes), + schedules: enabled.map(({ rrule, dtstart, missedRunGraceMinutes }) => ({ + rrule, + dtstart, + missedRunGraceMinutes + })) + } + : null +} + +/** The next due run strictly after `after`, from the learned schedules. */ +export function nextWslHostAutomationDue( + schedules: readonly WslHostAutomationSchedule[], + after: number +): WslHostAutomationDue | null { + let due: WslHostAutomationDue | null = null + for (const schedule of schedules) { + let nextRunAt: number + try { + nextRunAt = nextAutomationOccurrenceAfter(schedule.rrule, schedule.dtstart, after) + } catch { + continue + } + if (Number.isFinite(nextRunAt) && (!due || nextRunAt < due.nextRunAt)) { + due = { + nextRunAt, + wakeUntil: graceEnd(nextRunAt, schedule.missedRunGraceMinutes), + schedules: [...schedules] + } + } + } + return due +} + +function graceEnd(nextRunAt: number, graceMinutes: number): number { + return nextRunAt + graceMinutes * 60_000 + GRACE_SLACK_MS +} + +export class WslHostAutomationWake { + private due: Record + private cancelTimer: (() => void) | null = null + private readonly now: () => number + private readonly setTimer: (delayMs: number, task: () => void) => () => void + + constructor(private readonly deps: WslHostAutomationWakeDeps) { + this.now = deps.now ?? Date.now + this.setTimer = + deps.setTimer ?? + ((delayMs, task) => { + const timer = setTimeout(task, delayMs) + timer.unref?.() + return () => clearTimeout(timer) + }) + this.due = deps.load() + } + + /** Arms the timer from what earlier runs learned; a run due during a reboot fires now. */ + start(): void { + this.arm() + } + + /** Called while the host is connected; a failed read keeps what was known. */ + async learn(environmentId: string): Promise { + let next: WslHostAutomationDue | null + try { + next = earliestWslHostAutomationDue(await this.deps.listAutomations(environmentId)) + } catch { + return + } + this.set(environmentId, next) + this.persist() + this.arm() + } + + forget(environmentId: string): void { + this.set(environmentId, null) + this.persist() + this.arm() + } + + dispose(): void { + this.cancelTimer?.() + this.cancelTimer = null + } + + private set(environmentId: string, next: WslHostAutomationDue | null): void { + const { [environmentId]: _previous, ...rest } = this.due + this.due = next ? { ...rest, [environmentId]: next } : rest + } + + private persist(): void { + try { + this.deps.save(this.due) + } catch (error) { + console.warn('[wsl-hosts] Could not save automation wake times:', error) + } + } + + private arm(): void { + this.cancelTimer?.() + const times = Object.values(this.due).map((entry) => entry.nextRunAt) + if (times.length === 0) { + this.cancelTimer = null + return + } + const delayMs = Math.min(Math.max(0, Math.min(...times) - this.now()), MAX_TIMER_MS) + this.cancelTimer = this.setTimer(delayMs, () => void this.fire()) + } + + private async fire(): Promise { + const now = this.now() + for (const [environmentId, entry] of Object.entries(this.due)) { + if (entry.nextRunAt > now) { + continue + } + // Keep the following run whatever happens to this one; a connect re-learns the truth. + this.set(environmentId, nextWslHostAutomationDue(entry.schedules, now)) + if (await this.deps.isConnected(environmentId)) { + // Its own scheduler ran it; read the real next run instead of the local estimate. + await this.learn(environmentId) + continue + } + if (now > entry.wakeUntil) { + continue + } + await this.deps.wake(environmentId).catch((error: unknown) => { + console.warn(`[wsl-hosts] Could not start ${environmentId} for a scheduled run:`, error) + }) + } + this.persist() + this.arm() + } +} + +const FILE_NAME = 'wsl-host-automation-wakes.json' + +export function readWslHostAutomationWakes( + userDataPath: string +): Record { + try { + const parsed: unknown = JSON.parse(readFileSync(join(userDataPath, FILE_NAME), 'utf8')) + const due: Record = {} + if (parsed && typeof parsed === 'object') { + for (const [environmentId, value] of Object.entries(parsed)) { + const entry = parseDue(value) + if (entry) { + due[environmentId] = entry + } + } + } + return due + } catch { + return {} + } +} + +function parseDue(value: unknown): WslHostAutomationDue | null { + if ( + !value || + typeof value !== 'object' || + !('nextRunAt' in value) || + !('wakeUntil' in value) || + !('schedules' in value) || + typeof value.nextRunAt !== 'number' || + typeof value.wakeUntil !== 'number' || + !Array.isArray(value.schedules) + ) { + return null + } + const schedules = value.schedules.flatMap((schedule: unknown): WslHostAutomationSchedule[] => + schedule && + typeof schedule === 'object' && + 'rrule' in schedule && + 'dtstart' in schedule && + 'missedRunGraceMinutes' in schedule && + typeof schedule.rrule === 'string' && + typeof schedule.dtstart === 'number' && + typeof schedule.missedRunGraceMinutes === 'number' + ? [ + { + rrule: schedule.rrule, + dtstart: schedule.dtstart, + missedRunGraceMinutes: schedule.missedRunGraceMinutes + } + ] + : [] + ) + return { nextRunAt: value.nextRunAt, wakeUntil: value.wakeUntil, schedules } +} + +export function writeWslHostAutomationWakes( + userDataPath: string, + due: Record +): void { + writeFileSync(join(userDataPath, FILE_NAME), JSON.stringify(due)) +} diff --git a/src/main/wsl/wsl-host-connection-record.ts b/src/main/wsl/wsl-host-connection-record.ts index 350da383d54..b33fc2bcfa7 100644 --- a/src/main/wsl/wsl-host-connection-record.ts +++ b/src/main/wsl/wsl-host-connection-record.ts @@ -1,5 +1,8 @@ import { refreshWslHostPairing } from '../../shared/runtime-environment-wsl-host-store' -import type { WslHostDeploymentRecord } from '../../shared/wsl-host-deployment-store' +import { + readWslHostDeployments, + type WslHostDeploymentRecord +} from '../../shared/wsl-host-deployment-store' import type { WslHostStatus } from '../../shared/wsl-host-types' import type { WslOrcadConnection } from './wsl-orcad-connection' import type { WslOrcadConnectionStatus } from './wsl-orcad-connection-status' @@ -41,3 +44,13 @@ export function toWslHostStatus( : null } } + +/** The registered WSL hosts; an unreadable store reads as none rather than failing the caller. */ +export function readWslHostRecordsQuietly(userDataPath: string): WslHostDeploymentRecord[] { + try { + return readWslHostDeployments(userDataPath).deployments + } catch (error) { + console.warn('[wsl-hosts] Could not read WSL host records:', error) + return [] + } +} diff --git a/src/main/wsl/wsl-host-connector-contract.ts b/src/main/wsl/wsl-host-connector-contract.ts new file mode 100644 index 00000000000..289ff00da06 --- /dev/null +++ b/src/main/wsl/wsl-host-connector-contract.ts @@ -0,0 +1,32 @@ +/** What the WSL host service drives: the C3 holder, C5 connections and C6 liveness, or an e2e fake. */ +import type { WslHostServerStop } from '../../shared/wsl-host-types' +import type { WslHostDistroCandidate } from './wsl-host-distro-list' +import type { WslOrcadConnection } from './wsl-orcad-connection' +import type { WslOrcadConnectIntent } from './wsl-orcad-connection-status' + +/** `stopped` and `unreachable` rest on a confirmed running list; `unavailable` could not read it. */ +export type WslHostReachability = 'available' | 'unreachable' | 'stopped' | 'unavailable' + +/** The WSL side the service drives: C3 holder, C5 connections, C6 liveness (or an e2e fake). */ +export type WslHostConnector = { + listDistros(): Promise + /** Runs in the distro, so it boots a stopped one: only after the user chose to. */ + readOrcadPublicKey(distro: string): Promise + /** User-initiated; may boot the distro. An intent retries or forces a skipped update. */ + connect(distro: string, options?: WslOrcadConnectIntent): Promise + /** Explicit retry must clear pairing-repair failures and spent caps. */ + forgetPairingFailures?(environmentId: string): void + get(distro: string): WslOrcadConnection | null + /** Checks the paired server without starting a stopped or unconfirmed distro. */ + isReachable(distro: string, environmentId: string): Promise + /** The same check, telling a confirmed-stopped distro from an unconfirmed one. */ + reachability(distro: string, environmentId: string): Promise + /** Liveness is deciding whether the distro stopped; its running list may be stale. */ + isSettling(distro: string): boolean + disconnect(distro: string): Promise + /** Ends the host's terminals, orcad and its terminal daemon; never boots a stopped distro. */ + stopServer(distro: string, environmentId: string): Promise + releaseHolder(distro: string): Promise + adoptHolders(): Promise + dispose(): Promise +} diff --git a/src/main/wsl/wsl-host-service.test.ts b/src/main/wsl/wsl-host-service.test.ts index 6b6cdd56770..8fca42f1070 100644 --- a/src/main/wsl/wsl-host-service.test.ts +++ b/src/main/wsl/wsl-host-service.test.ts @@ -589,6 +589,38 @@ describe('WslHostService', () => { expect(connector.connect).toHaveBeenCalledWith('Ubuntu', { noBoot: true }) }) + it('a due automation boots a parked host once, and never with the experiment off', async () => { + const connector = makeConnector([UBUNTU]) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + await connector.disconnect('Ubuntu') + service.handleConnectionChange('Ubuntu', null, { + state: 'distro-stopped', + terminals: 'exited', + recovering: false + }) + connector.connect.mockClear() + enabled = false + await service.wakeForAutomation('wsl:ubuntu') + expect(connector.connect).not.toHaveBeenCalled() + enabled = true + await service.wakeForAutomation('wsl:ubuntu') + expect(connector.connect).toHaveBeenCalledExactlyOnceWith('Ubuntu') + await expect(service.isConnected('wsl:ubuntu')).resolves.toBe(true) + }) + + it('an automation wake with no bridge yet connects without a WSL reachability probe', async () => { + const connector = makeConnector([UBUNTU]) + const service = makeService(connector) + await service.add({ distro: 'Ubuntu' }) + await connector.disconnect('Ubuntu') + connector.connect.mockClear() + connector.reachability.mockClear() + await service.wakeForAutomation('wsl:ubuntu') + expect(connector.reachability).not.toHaveBeenCalled() + expect(connector.connect).toHaveBeenCalledExactlyOnceWith('Ubuntu') + }) + it('lifts a park on an explicit Connect', async () => { const connector = makeConnector([UBUNTU]) const service = makeService(connector) diff --git a/src/main/wsl/wsl-host-service.ts b/src/main/wsl/wsl-host-service.ts index 5d2ec7b19ec..083d53eb6cd 100644 --- a/src/main/wsl/wsl-host-service.ts +++ b/src/main/wsl/wsl-host-service.ts @@ -6,10 +6,7 @@ import { errorMessage } from '../../shared/error-message' import { focusReachability } from './wsl-host-focus-reachability' import { removeWslHostEnvironment } from '../../shared/runtime-environment-wsl-host-store' -import { - readWslHostDeployments, - type WslHostDeploymentRecord -} from '../../shared/wsl-host-deployment-store' +import type { WslHostDeploymentRecord } from '../../shared/wsl-host-deployment-store' import type { WslHostAddResult, WslHostConnectIntent, @@ -17,9 +14,13 @@ import type { WslHostServerStop, WslHostStatus } from '../../shared/wsl-host-types' -import type { WslHostDistroCandidate } from './wsl-host-distro-list' +import type { WslHostConnector } from './wsl-host-connector-contract' import { wslDistroHolderKey } from './wsl-distro-holder-record' -import { persistWslHostConnection, toWslHostStatus } from './wsl-host-connection-record' +import { + persistWslHostConnection, + readWslHostRecordsQuietly, + toWslHostStatus +} from './wsl-host-connection-record' import { sameWslDistro, wslHostNotConnectedMessage, @@ -29,39 +30,14 @@ import { } from './wsl-host-add-admission' import type { WslOrcadConnection } from './wsl-orcad-connection' import { registerWslHost } from './wsl-host-registration' -import type { WslOrcadConnectIntent, WslOrcadConnectionStatus } from './wsl-orcad-connection-status' +import type { WslOrcadConnectionStatus } from './wsl-orcad-connection-status' import { forgetWslHostToWake, forgetWslHostsToWake, rememberWslHostsToWake } from './wsl-host-early-wake' -/** `stopped` and `unreachable` rest on a confirmed running list; `unavailable` could not read it. */ -export type WslHostReachability = 'available' | 'unreachable' | 'stopped' | 'unavailable' - -/** The WSL side the service drives: C3 holder, C5 connections, C6 liveness (or an e2e fake). */ -export type WslHostConnector = { - listDistros(): Promise - /** Runs in the distro, so it boots a stopped one: only after the user chose to. */ - readOrcadPublicKey(distro: string): Promise - /** User-initiated; may boot the distro. An intent retries or forces a skipped update. */ - connect(distro: string, options?: WslOrcadConnectIntent): Promise - /** Explicit retry must clear pairing-repair failures and spent caps. */ - forgetPairingFailures?(environmentId: string): void - get(distro: string): WslOrcadConnection | null - /** Checks the paired server without starting a stopped or unconfirmed distro. */ - isReachable(distro: string, environmentId: string): Promise - /** The same check, telling a confirmed-stopped distro from an unconfirmed one. */ - reachability(distro: string, environmentId: string): Promise - /** Liveness is deciding whether the distro stopped; its running list may be stale. */ - isSettling(distro: string): boolean - disconnect(distro: string): Promise - /** Ends the host's terminals, orcad and its terminal daemon; never boots a stopped distro. */ - stopServer(distro: string, environmentId: string): Promise - releaseHolder(distro: string): Promise - adoptHolders(): Promise - dispose(): Promise -} +export type { WslHostConnector, WslHostReachability } from './wsl-host-connector-contract' export type WslHostServiceDependencies = { userDataPath: () => string @@ -286,21 +262,43 @@ export class WslHostService { */ focusHost(environmentId: string | null): void { const record = environmentId ? this.recordForEnvironment(environmentId) : null - const runnable = - record && this.connector && this.canRun() && !this.removing.has(record.environmentId) + const runnable = record !== null && this.mayWake(record) // The next launch starts booting only this distro, early (none when off WSL or not runnable). - rememberWslHostsToWake(this.deps.userDataPath(), runnable ? [record.distro] : []) - if (!record || !runnable || !this.connector) { - return - } - this.parked.delete(record.environmentId) - void focusReachability(this.connector, record) - .then((reachability) => - reachability === 'available' ? undefined : this.connector?.connect(record.distro) - ) - .catch((error: unknown) => { + rememberWslHostsToWake(this.deps.userDataPath(), record && runnable ? [record.distro] : []) + if (record && runnable) { + this.wake(record).catch((error: unknown) => { console.warn(`[wsl-hosts] Could not connect to ${record.distro}:`, error) }) + } + } + + /** E9: an enabled automation came due with the host off; boots the distro once, lifting a park. */ + async wakeForAutomation(environmentId: string): Promise { + const record = this.recordForEnvironment(environmentId) + if (record && this.mayWake(record)) { + await this.wake(record) + } + } + + private mayWake(record: WslHostDeploymentRecord): boolean { + return Boolean(this.connector) && this.canRun() && !this.removing.has(record.environmentId) + } + + /** A focus entry or a due automation: lifts a park and may boot the distro. */ + private async wake(record: WslHostDeploymentRecord): Promise { + this.parked.delete(record.environmentId) + // No bridge in this process yet means nothing answers; skip the WSL round trip (#33110). + if (this.connector && (await focusReachability(this.connector, record)) !== 'available') { + await this.connector.connect(record.distro) + } + } + + /** Whether the host's server answers through this process now; never starts a stopped distro. */ + async isConnected(environmentId: string): Promise { + const record = this.recordForEnvironment(environmentId) + return record && this.connector + ? (await this.connector.reachability(record.distro, environmentId)) === 'available' + : false } /** Whether anything may still be started for this distro (spawn guard). */ @@ -353,12 +351,7 @@ export class WslHostService { } private records(): WslHostDeploymentRecord[] { - try { - return readWslHostDeployments(this.deps.userDataPath()).deployments - } catch (error) { - console.warn('[wsl-hosts] Could not read WSL host records:', error) - return [] - } + return readWslHostRecordsQuietly(this.deps.userDataPath()) } private recordForDistro(distro: string): WslHostDeploymentRecord | null { diff --git a/src/main/wsl/wsl-orcad-host-environments.test.ts b/src/main/wsl/wsl-orcad-host-environments.test.ts index 2c284924a80..df578faab22 100644 --- a/src/main/wsl/wsl-orcad-host-environments.test.ts +++ b/src/main/wsl/wsl-orcad-host-environments.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import { observeWslOrcadHostResponse, + onWslOrcadHostAutomationsChanged, registerWslOrcadHostEnvironment, unregisterWslOrcadHostEnvironment, wslOrcadHostSupports @@ -64,3 +65,25 @@ describe('wslOrcadHostSupports', () => { ) }) }) + +describe('onWslOrcadHostAutomationsChanged (E9)', () => { + afterEach(() => unregisterWslOrcadHostEnvironment('env-1', 'Ubuntu')) + + it('tells the wake schedule when this desktop changed a WSL host’s automations', () => { + registerWslOrcadHostEnvironment('env-1', 'Ubuntu') + const listener = vi.fn() + const stop = onWslOrcadHostAutomationsChanged(listener) + const ok = (id: string): RuntimeRpcResponse => ({ + id, + ok: true, + result: {}, + _meta: { runtimeId: 'r' } + }) + observeWslOrcadHostResponse('env-1', 'automation.delete', ok('automation.delete')) + observeWslOrcadHostResponse('env-1', 'automation.update', ok('automation.update')) + observeWslOrcadHostResponse('env-1', 'automation.list', ok('automation.list')) + observeWslOrcadHostResponse('ssh-box', 'automation.create', ok('automation.create')) + expect(listener.mock.calls).toEqual([['env-1'], ['env-1']]) + stop() + }) +}) diff --git a/src/main/wsl/wsl-orcad-host-environments.ts b/src/main/wsl/wsl-orcad-host-environments.ts index 59e6e98db89..b8bf52a789c 100644 --- a/src/main/wsl/wsl-orcad-host-environments.ts +++ b/src/main/wsl/wsl-orcad-host-environments.ts @@ -47,12 +47,34 @@ function capabilitiesOf(result: unknown): readonly string[] | null { } /** Records a WSL orcad's capabilities from any `status.get` answer that passes the desktop. */ +const AUTOMATION_MUTATIONS = new Set([ + 'automation.create', + 'automation.update', + 'automation.delete' +]) +const automationMutationListeners = new Set<(environmentId: string) => void>() + +/** E9: this desktop changed a WSL host's automations; the wake schedule re-reads them. */ +export function onWslOrcadHostAutomationsChanged( + listener: (environmentId: string) => void +): () => void { + automationMutationListeners.add(listener) + return () => automationMutationListeners.delete(listener) +} + export function observeWslOrcadHostResponse( environmentId: string, method: string, response: RuntimeRpcResponse ): void { - if (method !== 'status.get' || !response.ok || !distrosByEnvironment.has(environmentId)) { + if (!response.ok || !distrosByEnvironment.has(environmentId)) { + return + } + if (AUTOMATION_MUTATIONS.has(method)) { + automationMutationListeners.forEach((listener) => listener(environmentId)) + return + } + if (method !== 'status.get') { return } const capabilities = capabilitiesOf(response.result)