Files
orca/src/shared/relay-artifacts.ts
T
OrcaWinandm4air 9ddcc9b9f0 fix(ssh): collect relay versions only when provably exited; runtimes/ store GC (#24130)
* fix(ssh): relay version GC deletes only on an exited verdict and keeps the previous build

The relay records .relay-pid in its version dir once it owns its socket. GC calls a
relay version dir exited only when that PID is provably dead and every relay-*.sock
refuses a connection; a dir without a PID file keeps the test -S rule. The most
recently completed other relay build is pinned like orcad's rollback target.
Design D5 GC liveness.

* feat(ssh): collect the shared runtimes/ Node store and give it its own owner

runtimes/ gets its own owner in the install model, so no version-dir GC (new or old
clients, whose listings are prefix-scoped) can list or delete it. A store pass
removes node-<sha> only when no retained dir references it, it is neither a current
pin nor the newest other verified runtime, and a ps or /proc check ran and found no
process using it. Legacy relay-*/orcad-* dirs are read for references and reported
as diagnostics only (design D10 two-step). Wired behind orcad GC's nodeRuntimePins.

* fix(ssh): runtime store process check holds runtimes reached through a symlinked home

/proc exe resolves symlinks and argv keeps whatever spelling launched the runtime, so
filtering on the exact $root path missed in-use runtimes on hosts like /home -> /var/home.
Filter on the store segment instead; the parser already attributes holds root-agnostically.

* test(ssh): wait for the holder process to spawn instead of a fixed delay

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-10-01 01:10:47 -07:00

107 lines
4.8 KiB
TypeScript

/**
* What a packaged relay directory must contain, declared once. The build, the
* content hash, and the remote install probe all read this list; they used to
* keep three of their own, which is how the WSL helper reached the desktop app
* but never a relay.
*
* Order is load-bearing: the hash concatenates these files in sequence.
*
* Keep this file erasable-only TypeScript — build-relay.mjs imports it directly
* under Node's type stripping, which rejects enums, namespaces, and parameter
* properties.
*/
/** Every platform the relay is bundled for; each gets the full artifact set. */
export const RELAY_BUILD_PLATFORMS = [
'linux-x64',
'linux-arm64',
'darwin-x64',
'darwin-arm64',
'win32-x64',
'win32-arm64'
] as const
export type RelayBuildPlatform = (typeof RELAY_BUILD_PLATFORMS)[number]
export function isWindowsRelayPlatform(platform: string): boolean {
return platform.startsWith('win32-')
}
export type RelayArtifact = {
filename: string
/** Only Windows relays ship it; other hosts must neither receive nor probe it. */
windowsOnly?: boolean
/**
* Present only when the build could produce it, so it is hashed when there and
* never probed. Required artifacts stay required: a probe that demanded an
* optional one would loop forever redeploying a relay that is already correct.
*/
optional?: boolean
/**
* Forked by the relay daemon as a long-lived child of its own. These are relay
* infrastructure, never user work, and the reap gate subtracts them from a daemon's
* child census; see src/main/ssh/relay-daemon-service-children.ts.
*/
daemonServiceChild?: boolean
}
/** The bare Windows process-table addon; see docs/reference/windows-process-enumeration.md. */
export const RELAY_WINDOWS_PROCESS_TREE_FILENAME = 'windows-process-tree.node'
export const RELAY_OPENCODE_SQLITE_READER_FILENAME = 'opencode-sqlite-reader.cjs'
export const RELAY_ARTIFACTS: readonly RelayArtifact[] = [
{ filename: 'relay.js' },
{ filename: 'relay-watcher.js', daemonServiceChild: true },
{ filename: 'relay-ai-vault-service.js', daemonServiceChild: true },
{ filename: RELAY_OPENCODE_SQLITE_READER_FILENAME },
{ filename: 'managed-hook-runtime.js' },
// Forked by the AI Vault title reader; without it a relay answers every WSL
// title request with no title and no error.
{ filename: 'wsl-transcript-fs-process-entry.js' },
{ filename: 'node-pty-1.1.0-console-list-agent-patch.cjs', windowsOnly: true },
// The ConPTY teardown release the desktop's own node-pty patch already carries; pnpm patches do
// not cross the SSH boundary, so a relay ran the unpatched npm tree and leaked one Windows File
// handle per terminal for the life of the relay process.
{ filename: 'node-pty-1.1.0-windows-pty-teardown-patch.cjs', windowsOnly: true },
// Only Linux relays run it, but it ships everywhere: the manifest's only
// platform axis is Windows, and a second one would buy nothing but a fork in
// the hash. Its presence is what moves a host to a fresh relay directory, and
// therefore to a re-install that can apply it.
{ filename: 'node-pty-1.1.0-master-cloexec-patch.cjs' },
// Optional because only a Windows build machine can compile it. Without it the
// relay reads the process table through a PowerShell scan instead -- slower,
// but correct, so a relay built anywhere else is still shippable.
{ filename: RELAY_WINDOWS_PROCESS_TREE_FILENAME, windowsOnly: true, optional: true }
]
/**
* The daemon's own service children, by entry filename. Anything else under a relay pid is
* either user work or unidentified, and both keep the relay unreapable.
*/
export const RELAY_DAEMON_SERVICE_ENTRY_FILENAMES: readonly string[] = RELAY_ARTIFACTS.filter(
(artifact) => artifact.daemonServiceChild
).map((artifact) => artifact.filename)
/** Written after the artifacts, so it is never an input to its own hash. */
export const RELAY_VERSION_FILENAME = '.version'
/** Written last by the installer; its absence means a torn install. */
export const RELAY_INSTALL_COMPLETE_FILENAME = '.install-complete'
/** PID of the last relay daemon that bound a socket from this version dir; GC liveness evidence. */
export const RELAY_PID_FILENAME = '.relay-pid'
/** Artifacts every relay must have; the remote install probe requires each one. */
export function relayArtifactFilenames(isWindows: boolean): string[] {
return RELAY_ARTIFACTS.filter(
(artifact) => !artifact.optional && (!artifact.windowsOnly || isWindows)
).map((artifact) => artifact.filename)
}
/** Artifacts a build may or may not emit. Hashed when present, never probed. */
export function relayOptionalArtifactFilenames(isWindows: boolean): string[] {
return RELAY_ARTIFACTS.filter(
(artifact) => artifact.optional && (!artifact.windowsOnly || isWindows)
).map((artifact) => artifact.filename)
}