mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
* feat(wsl): serve saved WSL work behind a closed hosts gate (serve-only) The WSL hosts gate gains three states. A closed gate with a saved WSL host or a committed move becomes serve-only: saved hosts stay listed and reachable, committed rewrites, retention, interrupted-move recovery and the way back keep running, and the preload API is exposed through a stamped window argument. Adding hosts and starting moves still need the open gate. With nothing saved the gate stays closed exactly as before. * fix(wsl): stamp serve-only into the dashboard popout and Space windows too They load the same preload, so without the argument they hid saved WSL hosts' status and Connect in serve-only. One helper now supplies the argument for every window on the shared preload. * fix(wsl): reviewer follow-ups for the serve-only gate - Refuse wslHosts:add unless the internal gate is open here, so this PR is safe on its own. - Promote on any move journal entry, so recovery can reset an in-flight move whose host record is gone. - List unfinished moves in serve-only. - An unreadable WSL deployment store fails closed in serve-only too, through a shared serving flag main sets at startup. - Report the gate state in wsl_usage_snapshot as an optional field.
157 lines
6.1 KiB
TypeScript
157 lines
6.1 KiB
TypeScript
import {
|
|
JsonStringifyByteLimitError,
|
|
stringifyJsonWithinByteLimit
|
|
} from './node-bounded-json-stringify'
|
|
import { writeSecureFile } from './secure-file'
|
|
import {
|
|
RuntimeEnvironmentStoreSchema,
|
|
type KnownRuntimeEnvironment,
|
|
type RuntimeEnvironmentStore
|
|
} from './runtime-environments'
|
|
import {
|
|
overlayRuntimeEnvironmentSidecar,
|
|
readRuntimeEnvironmentSidecar,
|
|
RuntimeEnvironmentSidecarInvalidError
|
|
} from './runtime-environment-sidecar'
|
|
import {
|
|
getEnvironmentStorePath,
|
|
MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES,
|
|
readEnvironmentStoreRecords,
|
|
backUpBlankEnvironmentStore,
|
|
RuntimeEnvironmentStoreError,
|
|
type EnvironmentStoreRecordsReadOptions,
|
|
warnUnreadableEnvironmentState
|
|
} from './runtime-environment-store-records'
|
|
import {
|
|
overlayWslHostDeployments,
|
|
readWslHostDeployments,
|
|
WslHostDeploymentStoreInvalidError
|
|
} from './wsl-host-deployment-store'
|
|
import { isWslHostsServing } from './wsl-host-internal-gate'
|
|
|
|
export {
|
|
getEnvironmentStorePath,
|
|
MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES,
|
|
RuntimeEnvironmentStoreError,
|
|
type RuntimeEnvironmentStoreErrorCode
|
|
} from './runtime-environment-store-records'
|
|
|
|
/** Readable records only; writeEnvironmentStore preserves unreadable corrupt/newer records verbatim. */
|
|
export function readPersistedEnvironmentStore(
|
|
userDataPath: string,
|
|
options: EnvironmentStoreRecordsReadOptions = {}
|
|
): RuntimeEnvironmentStore {
|
|
const { environments, unreadable } = readEnvironmentStoreRecords(userDataPath, options)
|
|
if (unreadable.length > 0) {
|
|
warnUnreadableEnvironmentState(
|
|
`Skipping ${unreadable.length} unreadable server record(s) in ${getEnvironmentStorePath(userDataPath)} (ids: ${unreadable.map((record) => record.id ?? '<none>').join(', ')}); they may need a newer version of Orca and are kept on disk.`
|
|
)
|
|
}
|
|
return {
|
|
version: 1,
|
|
environments: environments.sort((a, b) => a.name.localeCompare(b.name))
|
|
}
|
|
}
|
|
|
|
/** Unreadable WSL state fails closed only while WSL hosts are served; otherwise it cannot hide SSH/paired servers. */
|
|
function readWslHostDeploymentsForRegistry(
|
|
userDataPath: string
|
|
): ReturnType<typeof readWslHostDeployments> {
|
|
const gateOpen = isWslHostsServing(process.env)
|
|
try {
|
|
const read = readWslHostDeployments(userDataPath)
|
|
return gateOpen ? read : { deployments: read.deployments, unreadableEnvironmentIds: new Set() }
|
|
} catch (error) {
|
|
if (!(error instanceof WslHostDeploymentStoreInvalidError) || gateOpen) {
|
|
throw error
|
|
}
|
|
warnUnreadableEnvironmentState(error.message)
|
|
return { deployments: [], unreadableEnvironmentIds: new Set() }
|
|
}
|
|
}
|
|
|
|
export function readEnvironmentStore(
|
|
userDataPath: string,
|
|
options: EnvironmentStoreRecordsReadOptions = {}
|
|
): {
|
|
version: 1
|
|
environments: KnownRuntimeEnvironment[]
|
|
} {
|
|
const store = readPersistedEnvironmentStore(userDataPath, options)
|
|
let sidecar: ReturnType<typeof readRuntimeEnvironmentSidecar>
|
|
let wslDeployments: ReturnType<typeof readWslHostDeployments>
|
|
try {
|
|
sidecar = readRuntimeEnvironmentSidecar(userDataPath)
|
|
wslDeployments = readWslHostDeploymentsForRegistry(userDataPath)
|
|
} catch (error) {
|
|
// Fail closed like the main file: a link we cannot read may pin a host key we must not drop.
|
|
if (
|
|
error instanceof RuntimeEnvironmentSidecarInvalidError ||
|
|
error instanceof WslHostDeploymentStoreInvalidError
|
|
) {
|
|
throw new RuntimeEnvironmentStoreError('runtime_error', error.message)
|
|
}
|
|
throw error
|
|
}
|
|
// Hide unreadable overlays: connecting/editing without them could silently discard SSH pins or deployment ownership.
|
|
const isHidden = (id: string): boolean => Object.hasOwn(sidecar.unreadable, id)
|
|
const hidden = store.environments.filter((environment) => isHidden(environment.id))
|
|
if (hidden.length > 0) {
|
|
warnUnreadableEnvironmentState(
|
|
`Hiding ${hidden.length} server(s) whose links cannot be read (ids: ${hidden.map((environment) => environment.id).join(', ')}); they may need a newer version of Orca.`
|
|
)
|
|
}
|
|
return {
|
|
version: 1,
|
|
environments: overlayWslHostDeployments(
|
|
store.environments
|
|
.filter((environment) => !isHidden(environment.id))
|
|
.map((environment) =>
|
|
overlayRuntimeEnvironmentSidecar(environment, sidecar.entries[environment.id])
|
|
),
|
|
wslDeployments
|
|
)
|
|
}
|
|
}
|
|
|
|
/** Write persisted fields only; preserve unreadable on-disk records verbatim/in order. Sidecar writes stay separate. */
|
|
export function writeEnvironmentStore(userDataPath: string, store: RuntimeEnvironmentStore): void {
|
|
const path = getEnvironmentStorePath(userDataPath)
|
|
const environments = RuntimeEnvironmentStoreSchema.parse(store).environments
|
|
// The new registry replaces a blank file in one atomic write, so no reader sees it emptied early.
|
|
const onDisk = readEnvironmentStoreRecords(userDataPath, { recoverBlankStore: true })
|
|
const readableIds = new Set(onDisk.environments.map((environment) => environment.id))
|
|
const unreadableIds = new Set(onDisk.unreadable.map((record) => record.id))
|
|
// A new readable record must not shadow one a newer build owns under the same id.
|
|
if (environments.some(({ id }) => unreadableIds.has(id) && !readableIds.has(id))) {
|
|
throw new RuntimeEnvironmentStoreError(
|
|
'invalid_argument',
|
|
'A server with this id already exists in a format that needs a newer version of Orca.'
|
|
)
|
|
}
|
|
let serialized: string
|
|
try {
|
|
serialized = stringifyJsonWithinByteLimit(
|
|
{
|
|
version: 1,
|
|
environments: [...environments, ...onDisk.unreadable.map((record) => record.raw)]
|
|
},
|
|
MAX_RUNTIME_ENVIRONMENT_STORE_FILE_BYTES
|
|
).serialized
|
|
} catch (error) {
|
|
if (error instanceof JsonStringifyByteLimitError) {
|
|
throw new RuntimeEnvironmentStoreError(
|
|
'runtime_error',
|
|
`Could not write Orca environments at ${path}; the store exceeds its durable capacity.`
|
|
)
|
|
}
|
|
throw error
|
|
}
|
|
// Copied only once the write is certain to be attempted, so rejected saves leave no copies.
|
|
if (onDisk.blankFile) {
|
|
backUpBlankEnvironmentStore(userDataPath)
|
|
}
|
|
// Without fsync before the rename, a crash can publish the name over NUL-filled data (#18766).
|
|
writeSecureFile(path, serialized, { durable: true })
|
|
}
|