From f4d508be43761c70e92415ffac57eb7c2bc11bde Mon Sep 17 00:00:00 2001 From: Khaliq Date: Fri, 14 Aug 2026 17:38:55 +0200 Subject: [PATCH] fix(mount): attach to supervised workspace mirrors --- src/cli/fleet.test.ts | 5 +- src/mount/relayfile-binary.test.ts | 60 +++++++++++++++- src/mount/relayfile-binary.ts | 56 +++++++++++++-- .../relayfile-cloud-mount-client.test.ts | 72 +++++++++++++++++++ src/mount/relayfile-cloud-mount-client.ts | 36 +++++++++- src/mount/workspace-mirror.test.ts | 36 +++++++++- src/mount/workspace-mirror.ts | 11 ++- 7 files changed, 263 insertions(+), 13 deletions(-) diff --git a/src/cli/fleet.test.ts b/src/cli/fleet.test.ts index b4c9af3..8a0c7b6 100644 --- a/src/cli/fleet.test.ts +++ b/src/cli/fleet.test.ts @@ -3468,6 +3468,7 @@ describe('fleet CLI runtime', () => { const mount = Object.assign(new FakeMountClient(), { getLocalMountRoot: () => mirrorDir, }) + const errors = buffer() await runFleetCli(['start', '--config', configPath], { fleet: new FakeFleetClient(), @@ -3479,13 +3480,15 @@ describe('fleet CLI runtime', () => { await vi.waitFor(() => expect(mounted).toHaveLength(1)) }), stdout: buffer(), - stderr: buffer(), + stderr: errors, }) expect(mounted).toEqual([dirname(mirrorDir)]) expect(ensureLocalMount).toHaveBeenCalledTimes(1) expect(mountedWhenFactoryStarted).toBeLessThan(1) expect(factory.start).toHaveBeenCalledWith({ mode: 'live' }) + expect(errors.text()).not.toContain('could not start relayfile mount') + expect(errors.text()).not.toContain('could not start Relayfile workspace mirror') } finally { await rm(root, { recursive: true, force: true }) } diff --git a/src/mount/relayfile-binary.test.ts b/src/mount/relayfile-binary.test.ts index 9e392d3..697f608 100644 --- a/src/mount/relayfile-binary.test.ts +++ b/src/mount/relayfile-binary.test.ts @@ -1,10 +1,11 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { checkMountStaleness, + isMountProcessRunning, RELAYFILE_SYNC_INTERVAL_MS, STALE_RECONCILE_MS, STALE_RECONCILE_INTERVALS, @@ -39,6 +40,13 @@ async function writeState( return statePath } +async function writePidState( + dir: string, + state: { pid: number; workspaceId?: string; localDir?: string } | number, +): Promise { + await writeFile(join(dir, 'mount.pid'), JSON.stringify(state), 'utf8') +} + describe('checkMountStaleness', () => { it('leaves missing state non-stale so the caller can decide whether to start', async () => { await withTempDir(async (dir) => { @@ -195,6 +203,38 @@ describe('checkMountStaleness', () => { }) }) + it('uses the registered mount.pid when public state omits process ownership', async () => { + await withTempDir(async (dir) => { + const statePath = await writeState(dir, { + workspaceId: 'rw_test', + lastReconcileAt: new Date().toISOString(), + }) + await writePidState(dir, { + pid: process.pid, + workspaceId: 'rw_test', + localDir: dirname(dir), + }) + + expect(checkMountStaleness(statePath, 'rw_test')).toEqual({ stale: false, pid: process.pid }) + }) + }) + + it('ignores a mount.pid registered for a different workspace', async () => { + await withTempDir(async (dir) => { + const statePath = await writeState(dir, { + workspaceId: 'rw_test', + lastReconcileAt: new Date().toISOString(), + }) + await writePidState(dir, { + pid: 12345, + workspaceId: 'rw_other', + localDir: dirname(dir), + }) + + expect(checkMountStaleness(statePath, 'rw_test')).toEqual({ stale: false }) + }) + }) + it('marks a stale mount via a dead daemon.pid even within the reconcile window', async () => { await withTempDir(async (dir) => { const statePath = await writeState(dir, { @@ -239,3 +279,21 @@ describe('checkMountStaleness', () => { }) }) }) + +describe('isMountProcessRunning', () => { + it('treats an EPERM probe as a running process owned by another user', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('not permitted'), { code: 'EPERM' }) + }) + + expect(isMountProcessRunning(12345)).toBe(true) + }) + + it('rejects a missing or dead process', () => { + expect(isMountProcessRunning(undefined)).toBe(false) + vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('not found'), { code: 'ESRCH' }) + }) + expect(isMountProcessRunning(12345)).toBe(false) + }) +}) diff --git a/src/mount/relayfile-binary.ts b/src/mount/relayfile-binary.ts index 16241eb..abd73e5 100644 --- a/src/mount/relayfile-binary.ts +++ b/src/mount/relayfile-binary.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' // Relayfile's poll mirror reconciles every 30 seconds by default. Three // intervals allow one missed poll and ordinary filesystem jitter, while still @@ -30,10 +31,54 @@ type MountState = { daemon?: { pid?: unknown } } +type MountPidState = { + pid?: unknown + workspaceId?: unknown + localDir?: unknown +} + export function coercePid(value: unknown): number | undefined { return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined } +/** Read-only liveness probe used to distinguish an attached daemon from a mount Factory owns. */ +export function isMountProcessRunning(pid: number | undefined): boolean { + if (pid === undefined) return false + try { + process.kill(pid, 0) + return true + } catch (error) { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EPERM' + } +} + +function readMountProcessPid( + stateFilePath: string, + acceptedWorkspaceIds: ReadonlySet, +): number | undefined { + let raw: string + try { + raw = readFileSync(join(dirname(stateFilePath), 'mount.pid'), 'utf8') + } catch { + return undefined + } + + try { + const parsed = JSON.parse(raw) as MountPidState | number + if (typeof parsed === 'number') return coercePid(parsed) + if (typeof parsed !== 'object' || parsed === null) return undefined + const workspaceId = typeof parsed.workspaceId === 'string' ? parsed.workspaceId : undefined + if (workspaceId && !acceptedWorkspaceIds.has(workspaceId)) return undefined + const localDir = typeof parsed.localDir === 'string' ? parsed.localDir : undefined + const expectedLocalDir = dirname(dirname(stateFilePath)) + if (localDir && resolve(localDir) !== resolve(expectedLocalDir)) return undefined + return coercePid(parsed.pid) + } catch { + const legacyPid = Number(raw.trim()) + return coercePid(legacyPid) + } +} + export function checkMountStaleness( stateFilePath: string, workspaceId: string, @@ -67,7 +112,9 @@ export function checkMountStaleness( } // Prefer the top-level pid; fall back to the SDK-launched mount's daemon.pid. - const pid = coercePid(parsed.pid) ?? coercePid(parsed.daemon?.pid) + const pid = coercePid(parsed.pid) ?? + coercePid(parsed.daemon?.pid) ?? + readMountProcessPid(stateFilePath, accepted) const lastReconcileAt = typeof parsed.lastReconcileAt === 'string' ? Date.parse(parsed.lastReconcileAt) @@ -94,12 +141,7 @@ export function checkMountStaleness( return { stale: false } } - try { - process.kill(pid, 0) - } catch (error) { - if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EPERM') { - return { stale: false, pid } - } + if (!isMountProcessRunning(pid)) { return { stale: true, reason: `mount process (pid ${pid}) is not running`, pid } } diff --git a/src/mount/relayfile-cloud-mount-client.test.ts b/src/mount/relayfile-cloud-mount-client.test.ts index 4dcfda8..58fc6b9 100644 --- a/src/mount/relayfile-cloud-mount-client.test.ts +++ b/src/mount/relayfile-cloud-mount-client.test.ts @@ -506,6 +506,78 @@ describe('RelayfileCloudMountClient', () => { } }) + it('attaches to an externally supervised registered mirror without replacing it when health turns stale', async () => { + vi.useFakeTimers() + vi.setSystemTime('2026-08-14T12:00:00.000Z') + const root = await mkdtemp(join(tmpdir(), 'factory-external-workspace-mirror-')) + const localDir = join(root, 'chief', '.integrations') + const stateDir = join(localDir, '.relay') + await mkdir(stateDir, { recursive: true }) + const writeState = async (lastReconcileAt: string): Promise => { + await writeFile(join(stateDir, 'state.json'), JSON.stringify({ + workspaceId: 'cloud-workspace-uuid', + lastReconcileAt, + intervalMs: 1_000, + })) + } + await writeState('2026-08-14T12:00:00.000Z') + await writeFile(join(stateDir, 'mount.pid'), JSON.stringify({ + pid: process.pid, + workspaceId: 'cloud-workspace-uuid', + localDir, + })) + + const fake = new FakeRelayFileClient() + const ensureMountedWorkspace = vi.fn(async () => ({ stop: vi.fn(async () => {}) })) + const healthEvents: Array<{ state: string; reason: string; degradedMounts: number }> = [] + const mount = new RelayfileCloudMountClient({ + workspaceId: 'rw_shared', + client: fake, + relayfileSetup: { joinWorkspace: vi.fn(), ensureMountedWorkspace }, + relayfileWorkspace: { + workspaceId: 'cloud-workspace-uuid', + client: () => fake, + getToken: async () => 'delegated-relayfile-token', + info: { relayfileUrl: 'https://relayfile.example' }, + }, + localMountRoot: localDir, + localMountHealthIntervalMs: 1_000, + onLocalMountHealth: (event) => { healthEvents.push(event) }, + }) + + try { + await mount.ensureLocalMount(join(root, 'unrelated-repository')) + expect(ensureMountedWorkspace).not.toHaveBeenCalled() + + // The daemon is still alive but has missed its reconcile threshold. + // Factory must report that degradation without launching a replacement. + await writeState('2026-08-14T11:59:50.000Z') + await vi.advanceTimersByTimeAsync(1_000) + expect(ensureMountedWorkspace).not.toHaveBeenCalled() + expect(healthEvents).toEqual([{ + state: 'degraded', + reason: 'mount_stale', + degradedMounts: 1, + }]) + + // Recovery is also observed in place; the external process remains the + // only daemon serving the mirror throughout the client lifetime. + await writeState('2026-08-14T12:00:02.000Z') + await vi.advanceTimersByTimeAsync(1_000) + expect(ensureMountedWorkspace).not.toHaveBeenCalled() + expect(healthEvents).toEqual([ + { state: 'degraded', reason: 'mount_stale', degradedMounts: 1 }, + { state: 'recovered', reason: 'mount_stale', degradedMounts: 0 }, + ]) + } finally { + await mount.dispose() + await rm(root, { recursive: true, force: true }) + vi.useRealTimers() + } + + expect(ensureMountedWorkspace).not.toHaveBeenCalled() + }) + it('resolves a direct client mirror through the cloud workspace identifier alias', async () => { const fake = new FakeRelayFileClient() const resolver = vi.fn((workspaceIds: readonly string[]) => diff --git a/src/mount/relayfile-cloud-mount-client.ts b/src/mount/relayfile-cloud-mount-client.ts index 2e04912..709feb4 100644 --- a/src/mount/relayfile-cloud-mount-client.ts +++ b/src/mount/relayfile-cloud-mount-client.ts @@ -52,7 +52,7 @@ import { ensureLocalMount as runLocalMountPreflight, type EnsureLocalMountOptions, } from './local-mount-preflight' -import { checkMountStaleness } from './relayfile-binary' +import { checkMountStaleness, isMountProcessRunning } from './relayfile-binary' import { MountAuthScopeError } from './mount-auth-error' import { resolveRegisteredWorkspaceMirror } from './workspace-mirror' @@ -279,6 +279,11 @@ export class RelayfileCloudMountClient implements MountClient { readonly #localMountScopes: string[] #localMountRoot?: string readonly #localMounts = new Map() + // A registered mirror that was already being served when Factory attached + // belongs to another supervisor. Once observed, keep that ownership boundary + // for this client lifetime: Factory may monitor it but must never replace or + // stop it, even if a later reconcile is stale while the daemon recovers. + readonly #externallyManagedLocalMounts = new Set() readonly #localMountSupervisions = new Map { @@ -573,6 +589,7 @@ export class RelayfileCloudMountClient implements MountClient { this.#localMountSupervisions.clear() this.#degradedLocalMounts.clear() this.#authDegradedLocalMounts.clear() + this.#externallyManagedLocalMounts.clear() const mounted = [...this.#localMounts.values()] this.#localMounts.clear() await Promise.allSettled(mounted.map(async (handle) => handle.stop())) @@ -706,6 +723,7 @@ export class RelayfileCloudMountClient implements MountClient { await mounted.stop() return } + this.#externallyManagedLocalMounts.delete(localDir) this.#localMounts.set(localDir, mounted) const supervision = this.#localMountSupervisions.get(localDir) if (supervision) { @@ -740,6 +758,22 @@ export class RelayfileCloudMountClient implements MountClient { const supervision = this.#localMountSupervisions.get(localDir) if (!supervision) return try { + if (this.#externallyManagedLocalMounts.has(localDir)) { + // Health supervision for an attached daemon is deliberately + // observation-only. Keep reporting stale/recovered transitions while + // leaving all restart and cancellation decisions to its owner. + const statePath = join(localDir, '.relay', 'state.json') + const staleness = existsSync(statePath) + ? checkMountStaleness( + statePath, + this.workspaceId, + this.#acceptableWorkspaceIds(supervision.options.acceptableWorkspaceIds), + ) + : { stale: true, reason: `mount state is missing at ${statePath}` } + if (staleness.stale) this.#markLocalMountDegraded(localDir, 'mount_stale') + else this.#markLocalMountRecovered(localDir) + return + } if ( supervision.suggestedRefreshAtMs !== undefined && Date.now() >= supervision.suggestedRefreshAtMs diff --git a/src/mount/workspace-mirror.test.ts b/src/mount/workspace-mirror.test.ts index 671270b..127fdc5 100644 --- a/src/mount/workspace-mirror.test.ts +++ b/src/mount/workspace-mirror.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -30,6 +30,40 @@ describe('resolveRegisteredWorkspaceMirror', () => { }) }) + it('resolves a registered workspace by its operator-facing name', async () => { + await withTempHome(async (home) => { + const mirror = join(home, 'chief', '.integrations') + await mkdir(join(home, '.relayfile'), { recursive: true }) + await writeFile(join(home, '.relayfile', 'workspaces.json'), JSON.stringify({ + workspaces: [{ id: 'rw_7ccfea89', name: 'default', localDir: mirror }], + })) + + expect(resolveRegisteredWorkspaceMirror(['default'], home)).toEqual({ + localDir: mirror, + source: 'workspace-registry', + }) + expect(resolveRegisteredWorkspaceMirror(['rw_7ccfea89'], home)).toEqual({ + localDir: mirror, + source: 'workspace-registry', + }) + }) + }) + + it('leaves the workspace registry byte-for-byte unchanged on lookup miss', async () => { + await withTempHome(async (home) => { + const registryPath = join(home, '.relayfile', 'workspaces.json') + await mkdir(join(home, '.relayfile'), { recursive: true }) + await writeFile(registryPath, JSON.stringify({ + workspaces: [{ id: 'rw_real', name: 'default', localDir: join(home, 'chief', '.integrations') }], + }, null, 2)) + const before = await readFile(registryPath, 'utf8') + + expect(resolveRegisteredWorkspaceMirror(['rw_missing'], home)).toBeUndefined() + + expect(await readFile(registryPath, 'utf8')).toBe(before) + }) + }) + it('anchors a legacy relative workspace registry root to the Relayfile home', async () => { await withTempHome(async (home) => { await mkdir(join(home, '.relayfile'), { recursive: true }) diff --git a/src/mount/workspace-mirror.ts b/src/mount/workspace-mirror.ts index 5d7fa20..12f5756 100644 --- a/src/mount/workspace-mirror.ts +++ b/src/mount/workspace-mirror.ts @@ -39,8 +39,15 @@ function readWorkspaceRegistry(path: string, accepted: ReadonlySet, home const mirrors = new Set() for (const record of workspaceRecords(payload)) { - const workspaceId = stringField(record, 'id') ?? stringField(record, 'workspaceId') ?? stringField(record, 'workspace') - if (!workspaceId || !accepted.has(workspaceId)) continue + // Relayfile's registry records both the stable workspace id and its + // operator-facing name. Callers may legitimately hold either identifier, + // so match every recorded alias instead of preferring `id` and making a + // name lookup miss. This remains a read-only lookup: no-match never adds a + // stub record to workspaces.json. + const workspaceAliases = ['id', 'workspaceId', 'workspace', 'name'] + .map((key) => stringField(record, key)) + .filter((value): value is string => value !== undefined) + if (!workspaceAliases.some((alias) => accepted.has(alias))) continue const localDir = stringField(record, 'localDir') ?? stringField(record, 'localRoot') ?? stringField(record, 'mirrorDir') if (localDir) mirrors.add(resolveRegisteredLocalDir(homeDir, localDir)) }