mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(wsl): a WSL hosts block can ask for already-moved projects back (#35762)
The cached policy gains an optional movedProjects: keep | bring-back, read only with a block. An unknown value reads as keep, so it never drops the block, and older builds ignore the field. The gate records the choice for the run (wslHostsBringBackMovedProjects) and telemetry reports it as wsl_hosts_moved_projects. Nothing acts on it yet; bringing projects back follows. Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import { loadWindowsNativeRegistry, WINDOWS_REG_SZ } from '../windows-native-reg
|
||||
import { readWslDistroRegistrations } from '../wsl/wsl-distro-registry-id'
|
||||
import { listWslHostDistros } from '../wsl/wsl-host-distro-list'
|
||||
import {
|
||||
wslHostsAppliedMovedProjects,
|
||||
wslHostsAppliedPolicyState,
|
||||
wslHostsGateOpen,
|
||||
wslHostsGateState
|
||||
@@ -167,7 +168,8 @@ export async function gatherWslUsageSnapshotInput(
|
||||
windowsDriveReposWithWslWorktrees: await countWindowsDriveReposWithWslWorktrees(store),
|
||||
wslHostsGateOpen: gateOpen,
|
||||
wslHostsGateState: wslHostsGateState(),
|
||||
wslHostsPolicy: wslHostsAppliedPolicyState()
|
||||
wslHostsPolicy: wslHostsAppliedPolicyState(),
|
||||
wslHostsMovedProjects: wslHostsAppliedMovedProjects()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,25 @@ describe('buildWslUsageSnapshot', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['keep', 'bring-back'] as const)(
|
||||
'reports what a block asks for moved projects (%s) as an optional field',
|
||||
(wslHostsMovedProjects) => {
|
||||
const props = buildWslUsageSnapshot({
|
||||
...baseInput,
|
||||
wslHostsPolicy: 'block',
|
||||
wslHostsMovedProjects
|
||||
})
|
||||
expect(props.wsl_hosts_moved_projects).toBe(wslHostsMovedProjects)
|
||||
expect(validate('wsl_usage_snapshot', props).ok).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('omits moved projects when nothing blocks', () => {
|
||||
expect(
|
||||
buildWslUsageSnapshot({ ...baseInput, wslHostsPolicy: 'allow', wslHostsMovedProjects: null })
|
||||
).not.toHaveProperty('wsl_hosts_moved_projects')
|
||||
})
|
||||
|
||||
it('omits the remote policy when the internal gate left it unread', () => {
|
||||
expect(buildWslUsageSnapshot({ ...baseInput, wslHostsPolicy: null })).not.toHaveProperty(
|
||||
'wsl_hosts_policy'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Count distro identities locally; only buckets/booleans reach the telemetry payload.
|
||||
import type { WslHostsGateState } from '../../shared/wsl-host-internal-gate'
|
||||
import type { WslHostsPolicyState } from '../../shared/wsl-hosts-policy'
|
||||
import type { WslHostsMovedProjects, WslHostsPolicyState } from '../../shared/wsl-hosts-policy'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
|
||||
import { isDrvfsLinuxPath, parseWslUncPath } from '../../shared/wsl-paths'
|
||||
@@ -37,6 +37,8 @@ export type WslUsageSnapshotInput = {
|
||||
wslHostsGateState?: WslHostsGateState
|
||||
/** The cached remote policy applied at launch; absent when the internal gate left it unread. */
|
||||
wslHostsPolicy?: WslHostsPolicyState | null
|
||||
/** What an applied block asks for moved projects; absent when nothing blocks. */
|
||||
wslHostsMovedProjects?: WslHostsMovedProjects | null
|
||||
}
|
||||
|
||||
/** `2.4.13.0` → `2.4`; anything else is dropped rather than sent. */
|
||||
@@ -120,6 +122,9 @@ export function buildWslUsageSnapshot(input: WslUsageSnapshotInput): WslUsageSna
|
||||
project_distros_used: bucketWslUsageCount(projectDistros.size),
|
||||
wsl_hosts_gate_open: input.wslHostsGateOpen,
|
||||
...(input.wslHostsGateState ? { wsl_hosts_gate_state: input.wslHostsGateState } : {}),
|
||||
...(input.wslHostsPolicy ? { wsl_hosts_policy: input.wslHostsPolicy } : {})
|
||||
...(input.wslHostsPolicy ? { wsl_hosts_policy: input.wslHostsPolicy } : {}),
|
||||
...(input.wslHostsMovedProjects
|
||||
? { wsl_hosts_moved_projects: input.wslHostsMovedProjects }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { wslMoveJournalDirectory } from '../persistence/moving-wsl-projects/wsl-
|
||||
import {
|
||||
resetWslHostsGateForTests,
|
||||
wslHostsAppliedPolicyState,
|
||||
wslHostsBringBackMovedProjects,
|
||||
wslHostsGateOpen,
|
||||
wslHostsGateState,
|
||||
wslHostsPolicyBlocked
|
||||
@@ -68,10 +69,10 @@ describe('WSL hosts gate startup promotion', () => {
|
||||
})
|
||||
|
||||
describe('with the internal gate open and a cached remote policy', () => {
|
||||
const cachePolicy = (wslHosts: 'allow' | 'block') =>
|
||||
const cachePolicy = (wslHosts: 'allow' | 'block', movedProjects?: 'keep' | 'bring-back') =>
|
||||
writeFileSync(
|
||||
join(userDataPath, wslHostsPolicyCacheFileName('stable')),
|
||||
JSON.stringify({ version: 1, revision: 1, wslHosts })
|
||||
JSON.stringify({ version: 1, revision: 1, wslHosts, movedProjects })
|
||||
)
|
||||
const seedSavedHost = () =>
|
||||
writeWslHostDeployment(userDataPath, 'wsl:ubuntu', {
|
||||
@@ -132,6 +133,23 @@ describe('WSL hosts gate startup promotion', () => {
|
||||
expect(wslHostsGateOpen()).toBe(false)
|
||||
})
|
||||
|
||||
it('reads a block that asks for moved projects back', () => {
|
||||
cachePolicy('block', 'bring-back')
|
||||
seedSavedHost()
|
||||
configureWslHostsGateFromDisk(userDataPath, STABLE)
|
||||
|
||||
expect(wslHostsGateState()).toBe('serve-only')
|
||||
expect(wslHostsBringBackMovedProjects()).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores bring-back on an allow', () => {
|
||||
cachePolicy('allow', 'bring-back')
|
||||
configureWslHostsGateFromDisk(userDataPath, STABLE)
|
||||
|
||||
expect(wslHostsGateState()).toBe('open')
|
||||
expect(wslHostsBringBackMovedProjects()).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a block cached for another channel', () => {
|
||||
cachePolicy('block')
|
||||
configureWslHostsGateFromDisk(userDataPath, '1.4.3-rc.1')
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { readWslHostDeployments } from '../../shared/wsl-host-deployment-store'
|
||||
import { wslHostsPolicyState } from '../../shared/wsl-hosts-policy'
|
||||
import {
|
||||
wslHostsPolicyMovedProjects,
|
||||
wslHostsPolicyState,
|
||||
type WslHostsPolicy
|
||||
} from '../../shared/wsl-hosts-policy'
|
||||
import { listWslMoveCutovers } from '../persistence/moving-wsl-projects/wsl-move-cutover-journal'
|
||||
import { configureWslHostsGate } from './wsl-host-gate'
|
||||
import { readCachedWslHostsPolicy } from './wsl-hosts-policy-live-update'
|
||||
|
||||
/** Reads the policy only with the internal gate open, and saved work only once it is closed. */
|
||||
export function configureWslHostsGateFromDisk(userDataPath: string, appVersion: string): void {
|
||||
// One read serves both facts, so they can't disagree about a cache rewritten in between.
|
||||
let cached: { policy: WslHostsPolicy | null } | null = null
|
||||
const readPolicy = (): WslHostsPolicy | null =>
|
||||
(cached ??= { policy: readCachedWslHostsPolicy(userDataPath, appVersion) }).policy
|
||||
configureWslHostsGate({
|
||||
hasSavedWslHost: () => readWslHostDeployments(userDataPath).deployments.length > 0,
|
||||
// Any journal entry, committed or not: recovery must reset an in-flight move whose host is gone.
|
||||
hasWslMoveCutover: () => listWslMoveCutovers(userDataPath).length > 0,
|
||||
policy: () => wslHostsPolicyState(readCachedWslHostsPolicy(userDataPath, appVersion))
|
||||
policy: () => wslHostsPolicyState(readPolicy()),
|
||||
movedProjects: () => wslHostsPolicyMovedProjects(readPolicy())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
isGatedOffWslHost,
|
||||
resetWslHostsGateForTests,
|
||||
wslHostsGateOpen,
|
||||
wslHostsAppliedMovedProjects,
|
||||
wslHostsAppliedPolicyState,
|
||||
wslHostsBringBackMovedProjects,
|
||||
wslHostsGateState,
|
||||
wslHostsPolicyBlocked,
|
||||
wslHostsServing,
|
||||
@@ -209,4 +211,55 @@ describe('WSL hosts gate', () => {
|
||||
expect(process.env[WSL_HOSTS_GATE_STATE_ENV]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('moved projects under a block', () => {
|
||||
it('asks for moved projects back only when the block says so', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
configureWslHostsGate({
|
||||
...facts(true, true),
|
||||
policy: () => 'block',
|
||||
movedProjects: () => 'bring-back'
|
||||
})
|
||||
|
||||
expect(wslHostsBringBackMovedProjects()).toBe(true)
|
||||
expect(wslHostsAppliedMovedProjects()).toBe('bring-back')
|
||||
expect(wslHostsGateState()).toBe('serve-only')
|
||||
})
|
||||
|
||||
it('never reads it without a block', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
const movedProjects = vi.fn(() => 'bring-back' as const)
|
||||
configureWslHostsGate({ ...facts(true, true), policy: () => 'allow', movedProjects })
|
||||
|
||||
expect(movedProjects).not.toHaveBeenCalled()
|
||||
expect(wslHostsBringBackMovedProjects()).toBe(false)
|
||||
expect(wslHostsAppliedMovedProjects()).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps moved projects served when the choice cannot be read', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
configureWslHostsGate({
|
||||
...facts(true, true),
|
||||
policy: () => 'block',
|
||||
movedProjects: () => {
|
||||
throw new Error('unreadable')
|
||||
}
|
||||
})
|
||||
|
||||
expect(wslHostsBringBackMovedProjects()).toBe(false)
|
||||
expect(wslHostsAppliedMovedProjects()).toBe('keep')
|
||||
})
|
||||
|
||||
it('clears on reset', () => {
|
||||
vi.stubEnv('ORCA_INTERNAL_WSL_HOSTS', '1')
|
||||
configureWslHostsGate({
|
||||
...facts(true, true),
|
||||
policy: () => 'block',
|
||||
movedProjects: () => 'bring-back'
|
||||
})
|
||||
resetWslHostsGateForTests()
|
||||
|
||||
expect(wslHostsBringBackMovedProjects()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type WslHostsGateState
|
||||
} from '../../shared/wsl-host-internal-gate'
|
||||
import type { OrcadDeploymentLink } from '../../shared/runtime-environments'
|
||||
import type { WslHostsPolicyState } from '../../shared/wsl-hosts-policy'
|
||||
import type { WslHostsMovedProjects, WslHostsPolicyState } from '../../shared/wsl-hosts-policy'
|
||||
|
||||
export const WSL_HOSTS_GATED_OFF_MESSAGE = 'WSL hosts are not available in this build.'
|
||||
|
||||
@@ -19,6 +19,8 @@ let promoted: { hasSavedWslHost: boolean; hasWslMoveCutover: boolean } | null =
|
||||
let policyBlocked = false
|
||||
// The cached remote policy this launch read; null when the internal gate was closed, so none was read.
|
||||
let policyState: WslHostsPolicyState | null = null
|
||||
// What a block asks for already-moved projects; `keep` whenever nothing blocks.
|
||||
let movedProjects: WslHostsMovedProjects = 'keep'
|
||||
|
||||
// Unreadable state counts as absent: today's closed behavior.
|
||||
function readFact(fact: () => boolean): boolean {
|
||||
@@ -41,10 +43,21 @@ function readPolicy(read: (() => WslHostsPolicyState) | undefined): WslHostsPoli
|
||||
}
|
||||
}
|
||||
|
||||
function notePolicy(state: WslHostsPolicyState | null): void {
|
||||
function notePolicy(
|
||||
state: WslHostsPolicyState | null,
|
||||
moved: (() => WslHostsMovedProjects) | undefined
|
||||
): void {
|
||||
policyState = state
|
||||
policyBlocked = state === 'block'
|
||||
noteWslHostsPolicyBlocked(policyBlocked)
|
||||
movedProjects = 'keep'
|
||||
if (policyBlocked && moved) {
|
||||
try {
|
||||
movedProjects = moved()
|
||||
} catch {
|
||||
// Unreadable: moved projects stay served, which is what a block alone does.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,8 +68,10 @@ export function configureWslHostsGate(facts: {
|
||||
hasSavedWslHost: () => boolean
|
||||
hasWslMoveCutover: () => boolean
|
||||
policy?: () => WslHostsPolicyState
|
||||
/** Read only when the policy blocks. */
|
||||
movedProjects?: () => WslHostsMovedProjects
|
||||
}): void {
|
||||
notePolicy(readPolicy(facts.policy))
|
||||
notePolicy(readPolicy(facts.policy), facts.movedProjects)
|
||||
promoted = wslHostsGateOpen()
|
||||
? null
|
||||
: {
|
||||
@@ -72,7 +87,7 @@ export function configureWslHostsGate(facts: {
|
||||
export function resetWslHostsGateForTests(): void {
|
||||
promoted = null
|
||||
noteWslHostsServeOnly(false)
|
||||
notePolicy(null)
|
||||
notePolicy(null, undefined)
|
||||
publishWslHostsGateState(process.env, null)
|
||||
}
|
||||
|
||||
@@ -107,6 +122,16 @@ export function wslHostsAppliedPolicyState(): WslHostsPolicyState | null {
|
||||
return policyState
|
||||
}
|
||||
|
||||
/** This run's block asks for already-moved projects to come back to this computer. */
|
||||
export function wslHostsBringBackMovedProjects(): boolean {
|
||||
return movedProjects === 'bring-back'
|
||||
}
|
||||
|
||||
/** What this run's block asks for moved projects; null when nothing blocks. */
|
||||
export function wslHostsAppliedMovedProjects(): WslHostsMovedProjects | null {
|
||||
return policyBlocked ? movedProjects : null
|
||||
}
|
||||
|
||||
/** Saved hosts and moved projects stay reachable: `open` or `serve-only`. */
|
||||
export function wslHostsServing(): boolean {
|
||||
return wslHostsGateState() !== 'closed'
|
||||
|
||||
@@ -2,7 +2,11 @@ import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { parseWslHostsPolicy, wslHostsPolicyState } from '../../shared/wsl-hosts-policy'
|
||||
import {
|
||||
parseWslHostsPolicy,
|
||||
wslHostsPolicyMovedProjects,
|
||||
wslHostsPolicyState
|
||||
} from '../../shared/wsl-hosts-policy'
|
||||
import {
|
||||
readCachedWslHostsPolicy,
|
||||
WslHostsPolicyLiveUpdater,
|
||||
@@ -36,6 +40,28 @@ describe('WSL hosts policy parsing', () => {
|
||||
expect(parseWslHostsPolicy(text).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('reads moved projects as keep unless a block asks for them back', () => {
|
||||
const parse = (fields: Record<string, unknown>) => {
|
||||
const parsed = parseWslHostsPolicy(JSON.stringify({ version: 1, revision: 1, ...fields }))
|
||||
if (!parsed.ok) {
|
||||
throw new Error(parsed.error)
|
||||
}
|
||||
return parsed.policy
|
||||
}
|
||||
expect(wslHostsPolicyMovedProjects(null)).toBe('keep')
|
||||
expect(wslHostsPolicyMovedProjects(parse({ wslHosts: 'block' }))).toBe('keep')
|
||||
expect(
|
||||
wslHostsPolicyMovedProjects(parse({ wslHosts: 'block', movedProjects: 'bring-back' }))
|
||||
).toBe('bring-back')
|
||||
expect(
|
||||
wslHostsPolicyMovedProjects(parse({ wslHosts: 'allow', movedProjects: 'bring-back' }))
|
||||
).toBe('keep')
|
||||
// A later value must not drop the block it rides on.
|
||||
const later = parse({ wslHosts: 'block', movedProjects: 'some-later-mode' })
|
||||
expect(wslHostsPolicyState(later)).toBe('block')
|
||||
expect(wslHostsPolicyMovedProjects(later)).toBe('keep')
|
||||
})
|
||||
|
||||
it('reads no policy as missing, never as a block', () => {
|
||||
expect(wslHostsPolicyState(null)).toBe('missing')
|
||||
expect(wslHostsPolicyState({ version: 1, revision: 1, wslHosts: 'allow' })).toBe('allow')
|
||||
|
||||
@@ -49,7 +49,9 @@ export const wslUsageSnapshotSchema = z
|
||||
/** open / serve-only / closed. Optional, so older builds' payloads still validate. */
|
||||
wsl_hosts_gate_state: z.enum(['open', 'serve-only', 'closed']).optional(),
|
||||
/** The cached remote WSL hosts policy applied at launch. Optional, for older builds. */
|
||||
wsl_hosts_policy: z.enum(['allow', 'block', 'missing']).optional()
|
||||
wsl_hosts_policy: z.enum(['allow', 'block', 'missing']).optional(),
|
||||
/** With a block only: whether it asks for moved projects back. Optional, for older builds. */
|
||||
wsl_hosts_moved_projects: z.enum(['keep', 'bring-back']).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@ const WslHostsPolicySchema = z.object({
|
||||
version: z.literal(1),
|
||||
/** Monotonic: a lower or equal revision never replaces the cached one. */
|
||||
revision: z.number().int().positive(),
|
||||
wslHosts: z.enum(['allow', 'block'])
|
||||
wslHosts: z.enum(['allow', 'block']),
|
||||
/**
|
||||
* With `block` only: `bring-back` returns already-moved projects to this computer. An unknown
|
||||
* value reads as absent (`keep`), so a later value never drops the block itself.
|
||||
*/
|
||||
movedProjects: z.enum(['keep', 'bring-back']).optional().catch(undefined)
|
||||
})
|
||||
|
||||
export type WslHostsPolicy = z.infer<typeof WslHostsPolicySchema>
|
||||
@@ -37,3 +42,12 @@ export type WslHostsPolicyState = 'allow' | 'block' | 'missing'
|
||||
export function wslHostsPolicyState(policy: WslHostsPolicy | null): WslHostsPolicyState {
|
||||
return policy?.wslHosts ?? 'missing'
|
||||
}
|
||||
|
||||
export type WslHostsMovedProjects = 'keep' | 'bring-back'
|
||||
|
||||
/** `keep` unless the policy blocks and asks for moved projects back. */
|
||||
export function wslHostsPolicyMovedProjects(policy: WslHostsPolicy | null): WslHostsMovedProjects {
|
||||
return policy?.wslHosts === 'block' && policy.movedProjects === 'bring-back'
|
||||
? 'bring-back'
|
||||
: 'keep'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user