mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
feat(wsl-host): start a WSL host's distro when one of its automations comes due (E9 B) (#33839)
* feat(wsl-host): start a WSL host's distro when one of its automations comes due E9 connect policy, part 2. A WSL host's automations run inside its distro, so a distro that is off (a reboot, an idle release) missed them. While a host is connected the desktop now reads its enabled automations' next run (on connect and every 15 min) and keeps the soonest one per host in userData. When that time comes with the host off, it boots the distro once, and orcad's startup catch-up fires the run within its missed- run grace. A run due while the computer was off fires once Orca starts. Nothing wakes past the grace window, and a connected host is left to its own scheduler. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-host): a due automation boots a parked host once, never with WSL hosts off Co-Authored-By: Claude <noreply@anthropic.com> * fix(wsl-host): keep the following run, and forget changed automations (E9 review) Review of #33839: - A run handled while connected dropped the host's next due time. The wake now keeps each host's enabled schedules and computes the following run locally whatever happens to this one (wake failed, past grace, handled by the host), and re-reads the real one from a connected host. - A deleted, disabled or rescheduled automation kept waking the distro at its old time. A successful automation.create/update/delete from this desktop now re-reads the host's automations, as do the 15-minute re-read and every connect. Nothing boots a distro to check: on wake, a distro already running is just reconnected. - wakeForAutomation/isConnected live here now (moved out of #33838); isConnected reads the host's reachability, never booting. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(wsl-host): say isConnected answers for this process; keep the service in bounds isConnected reports whether the host answers through this process; false is not "stopped" (a fresh process has no bridge yet, and the wake just connects). The service's quiet record read moves beside the other record helpers so the file stays within its line limit after the E9 additions. Co-Authored-By: Claude <noreply@anthropic.com> * refactor(wsl-host): give the connector contract its own file; one wake path for focus and automations After rebasing onto #34063, #34093 and #33110 the WSL host service was over its line limit. The connector contract (what the service drives) moves to wsl-host-connector-contract.ts, re-exported from the service, and the focus entry and the automation wake share one private wake step: lift the park, then reconnect unless the host answers, read with #33110's focusReachability so neither pays a WSL round trip when this process has no bridge yet. An automation wake no longer reconnects a host that already answers. Co-Authored-By: Claude <noreply@anthropic.com> * test(wsl-host): an automation wake with no bridge skips the reachability probe Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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'
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>): 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<string, WslHostAutomationDue>; connected?: boolean }) {
|
||||
let now = 0
|
||||
let timer: { at: number; task: () => void } | null = null
|
||||
const saved: Record<string, WslHostAutomationDue>[] = []
|
||||
const wake = vi.fn(async (_environmentId: string) => {})
|
||||
const automations = vi.fn(async (): Promise<Automation[]> => [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({})
|
||||
})
|
||||
})
|
||||
@@ -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<string, WslHostAutomationDue>
|
||||
save: (due: Record<string, WslHostAutomationDue>) => void
|
||||
listAutomations: (environmentId: string) => Promise<Automation[]>
|
||||
/**
|
||||
* 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<boolean>
|
||||
/** Boots the host's distro and connects; it may refuse (host removed, gate closed). */
|
||||
wake: (environmentId: string) => Promise<void>
|
||||
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<Automation | null>(
|
||||
(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<string, WslHostAutomationDue>
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, WslHostAutomationDue> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(join(userDataPath, FILE_NAME), 'utf8'))
|
||||
const due: Record<string, WslHostAutomationDue> = {}
|
||||
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<string, WslHostAutomationDue>
|
||||
): void {
|
||||
writeFileSync(join(userDataPath, FILE_NAME), JSON.stringify(due))
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WslHostDistroCandidate[]>
|
||||
/** Runs in the distro, so it boots a stopped one: only after the user chose to. */
|
||||
readOrcadPublicKey(distro: string): Promise<string | null>
|
||||
/** User-initiated; may boot the distro. An intent retries or forces a skipped update. */
|
||||
connect(distro: string, options?: WslOrcadConnectIntent): Promise<WslOrcadConnection>
|
||||
/** 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<boolean>
|
||||
/** The same check, telling a confirmed-stopped distro from an unconfirmed one. */
|
||||
reachability(distro: string, environmentId: string): Promise<WslHostReachability>
|
||||
/** Liveness is deciding whether the distro stopped; its running list may be stale. */
|
||||
isSettling(distro: string): boolean
|
||||
disconnect(distro: string): Promise<void>
|
||||
/** Ends the host's terminals, orcad and its terminal daemon; never boots a stopped distro. */
|
||||
stopServer(distro: string, environmentId: string): Promise<WslHostServerStop>
|
||||
releaseHolder(distro: string): Promise<void>
|
||||
adoptHolders(): Promise<void>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<WslHostDistroCandidate[]>
|
||||
/** Runs in the distro, so it boots a stopped one: only after the user chose to. */
|
||||
readOrcadPublicKey(distro: string): Promise<string | null>
|
||||
/** User-initiated; may boot the distro. An intent retries or forces a skipped update. */
|
||||
connect(distro: string, options?: WslOrcadConnectIntent): Promise<WslOrcadConnection>
|
||||
/** 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<boolean>
|
||||
/** The same check, telling a confirmed-stopped distro from an unconfirmed one. */
|
||||
reachability(distro: string, environmentId: string): Promise<WslHostReachability>
|
||||
/** Liveness is deciding whether the distro stopped; its running list may be stale. */
|
||||
isSettling(distro: string): boolean
|
||||
disconnect(distro: string): Promise<void>
|
||||
/** Ends the host's terminals, orcad and its terminal daemon; never boots a stopped distro. */
|
||||
stopServer(distro: string, environmentId: string): Promise<WslHostServerStop>
|
||||
releaseHolder(distro: string): Promise<void>
|
||||
adoptHolders(): Promise<void>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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 {
|
||||
|
||||
@@ -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<unknown> => ({
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<unknown>
|
||||
): 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)
|
||||
|
||||
Reference in New Issue
Block a user