Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,41 @@ test('quiesces reconnect and waits for the Host process before update install',
await owner.close();
});

test('quiesces Local reconnect while a managed service changes', async () => {
const current = candidateHarness({ lifecycleMode: 'service' });
const replacement = candidateHarness({
lifecycleMode: 'service',
hostEpoch: 'service-after',
});
let starts = 0;
let finishChange!: () => void;
const change = new Promise<void>((resolve) => {
finishChange = resolve;
});
const owner = await startRuntimeHostDesktopManager(
{} as DesktopRuntimeHostCandidateStartInput,
{
startCandidate: async () => {
starts += 1;
return ready(starts === 1 ? current.candidate : replacement.candidate);
},
},
);

const changing = owner.runManagedLocalHostChange(async () => {
current.disconnect();
await change;
});
await new Promise<void>((resolve) => setImmediate(resolve));
assert.equal(starts, 1);

finishChange();
await changing;
await owner.waitUntilReady('local', 'test-host-epoch');
assert.equal(starts, 2);
await owner.close();
});

test('waits through a reconnect gap before quiescing Host retirement', async () => {
const first = candidateHarness();
const replacement = candidateHarness({ disconnectOnPrepare: true });
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import test from 'node:test';
import { runtimeHostLocalSetupCommand } from '../runtime-host-local-operator.js';

test('local setup installs one managed service for the Desktop root with Direct peer enabled', () => {
assert.deepEqual(
runtimeHostLocalSetupCommand({
packageSpecifier: 'maka-agent@0.2.0',
clientDataRoot: '/Users/ada/Library/Application Support/Maka',
rootPath: '/Users/ada/Library/Application Support/Maka/workspaces/default',
principalId: 'desktop-owner:pairing',
coordinationRelays: ['/dns4/discovery.example/udp/443/quic-v1'],
}),
{
executable: 'npm',
args: [
'exec', '--yes', '--package', 'maka-agent@0.2.0', '--',
'maka', 'runtime-host', 'setup',
'--client-data-root', '/Users/ada/Library/Application Support/Maka',
'--root', '/Users/ada/Library/Application Support/Maka/workspaces/default',
'--principal', 'desktop-owner:pairing',
'--preset', 'desktop-client',
'--defer-pairing-commit',
'--enable-direct-peer',
'--coordination-relay', '/dns4/discovery.example/udp/443/quic-v1',
'--json',
],
},
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { decodeRuntimeHostOwnerConnectionCode } from '@maka/runtime-host/client';
import type { RuntimeHostDesktopManager } from '../runtime-host-desktop-manager.js';
import { createDesktopLocalRuntimeHostRemoteAccess } from '../runtime-host-local-remote-access.js';
import type { createDesktopRuntimeHostLocalOperator } from '../runtime-host-local-operator.js';

test('enabling remote access hands the same root to one managed service before Desktop resumes', async (t) => {
const base = await mkdtemp(join(tmpdir(), 'maka-local-remote-access-'));
t.after(() => rm(base, { recursive: true, force: true }));
const clientDataRoot = join(base, 'client');
const rootPath = join(clientDataRoot, 'workspaces', 'default');
await mkdir(rootPath, { recursive: true });
const handlers = new Map<string, Parameters<Electron.IpcMain['handle']>[1]>();
let retired = false;
let resumed = false;
const manager = {
async retireOwnedLocalHost() {
retired = true;
return { kind: 'retired' as const, resume: () => { resumed = true; } };
},
} as unknown as RuntimeHostDesktopManager;
const peer = {
peerId: '12D3KooWpeer',
routeHints: ['/ip4/192.0.2.1/udp/41000/quic-v1'],
coordinationRelays: [],
};
const operator = {
async runSetup(input: { readonly rootPath: string }) {
assert.equal(retired, true);
assert.equal(input.rootPath, rootPath);
return {
serviceId: 'b'.repeat(64),
operatorPath: join(base, 'operator'),
rootPath,
rootId: 'a'.repeat(64),
credential: 'pending-credential',
directPeer: peer,
};
},
async runPeer() {
return {
kind: 'result' as const,
action: 'status' as const,
status: {
state: 'enabled' as const,
serviceState: 'running',
rootId: 'a'.repeat(64),
...peer,
},
};
},
async runService() {
throw new Error('rollback is not expected');
},
async close() {},
} as unknown as ReturnType<typeof createDesktopRuntimeHostLocalOperator>;
const service = createDesktopLocalRuntimeHostRemoteAccess({
ipcMain: {
handle: (channel, handler) => { handlers.set(channel, handler); },
removeHandler: (channel) => { handlers.delete(channel); },
},
clientDataRoot,
rootPath,
directPeerAvailable: true,
manager: () => manager,
resolveSetupPackage: async () => ({ kind: 'npm', specifier: 'maka-agent@0.2.0' }),
operator,
});
t.after(() => service.close());

const enable = handlers.get('local-runtime-host-remote-access:enable');
assert.ok(enable);
const result = await enable({} as Electron.IpcMainInvokeEvent, {
allowInterruptActiveTasks: false,
coordinationRelays: [],
}) as { readonly kind: string; readonly connectionCode: string };
assert.equal(result.kind, 'enabled');
assert.equal(resumed, true);
assert.deepEqual(decodeRuntimeHostOwnerConnectionCode(result.connectionCode), {
name: decodeRuntimeHostOwnerConnectionCode(result.connectionCode).name,
rootId: 'a'.repeat(64),
transport: { kind: 'libp2p-direct', ...peer },
credential: 'pending-credential',
});
assert.equal(
JSON.parse(await readFile(join(clientDataRoot, 'runtime-host-local-service.json'), 'utf8'))
.rootPath,
rootPath,
);
});
13 changes: 13 additions & 0 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ import {
} from "./runtime-host-ssh-terminal.js";
import { createRuntimeHostSetupPackageResolver } from "./runtime-host-setup-package.js";
import { configureDesktopRuntimeHostPeerClient } from './runtime-host-peer-client.js';
import { createDesktopRuntimeHostLocalOperator } from './runtime-host-local-operator.js';
import { createDesktopLocalRuntimeHostRemoteAccess } from './runtime-host-local-remote-access.js';
import { createDesktopRuntimeHostOnboarding } from "./runtime-host-onboarding.js";
import { createDesktopRuntimeHostManagement } from "./runtime-host-management.js";
import { registerRuntimeHostOAuthIpc } from "./runtime-host-oauth-ipc-main.js";
Expand Down Expand Up @@ -386,6 +388,16 @@ const runtimeHostSetupPackage = createRuntimeHostSetupPackageResolver({
appPath: app.getAppPath(),
environment: process.env,
});
const localRuntimeHostOperator = createDesktopRuntimeHostLocalOperator();
const localRuntimeHostRemoteAccess = createDesktopLocalRuntimeHostRemoteAccess({
ipcMain,
clientDataRoot: userDataDir,
rootPath: startupLocalStorageRoot.canonicalPath,
directPeerAvailable: runtimeHostDirectPeerAvailable,
manager: () => runtimeHostManager,
resolveSetupPackage: runtimeHostSetupPackage.resolve,
operator: localRuntimeHostOperator,
});
const native = assembleDesktopNativeCapabilities({
isComputerUseRealModelE2e,
locale: desktopLocale,
Expand Down Expand Up @@ -1602,6 +1614,7 @@ async function closeRuntimeHostDesktop(): Promise<void> {
Promise.resolve().then(() => runtimeHostManagement.close()),
runtimeHostManager?.close(),
runtimeHostOnboarding.close(),
localRuntimeHostRemoteAccess.close(),
runtimeHostSetupPackage.close(),
Promise.resolve().then(() => workBoardIpc.close()),
runtimeHostSshTerminal.close(),
Expand Down
27 changes: 24 additions & 3 deletions apps/desktop/src/main/runtime-host-desktop-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export interface RuntimeHostDesktopManager {
previousHostEpoch?: string,
signal?: AbortSignal,
): Promise<void>;
runManagedLocalHostChange<T>(change: () => Promise<T>): Promise<T>;
setDefaultProfile(profileId: string): void;
retireOwnedLocalHost(mode: RuntimeHostRetirementMode): Promise<DesktopLocalHostRetirement>;
close(): Promise<void>;
Expand Down Expand Up @@ -517,6 +518,23 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
await this.#removeTarget(target);
}

runManagedLocalHostChange<T>(change: () => Promise<T>): Promise<T> {
return this.#mutateTarget(LOCAL_RUNTIME_HOST_PROFILE.id, async () => {
const lifecycle = this.#requireLifecycle(
this.#requireTarget(LOCAL_RUNTIME_HOST_PROFILE.id),
);
const quiescence = await lifecycle.quiesce();
try {
if (quiescence.current.hostLifecycleMode !== 'service') {
throw new Error('The Local Runtime Host is not managed by a background service');
}
return await change();
} finally {
quiescence.resume();
}
});
}

setDefaultProfile(profileId: string): void {
this.#defaultProfileId = profileId;
this.onDefaultProfileChanged?.(profileId);
Expand Down Expand Up @@ -842,19 +860,22 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
}
}

#mutateTarget(profileId: string, operation: () => Promise<void>): Promise<void> {
#mutateTarget<T>(profileId: string, operation: () => Promise<T>): Promise<T> {
if (this.#closed) {
return Promise.reject(new Error('Desktop Runtime Host manager is closed'));
}
const previous = this.#targetMutations.get(profileId) ?? Promise.resolve();
const pending = previous.catch(() => undefined).then(operation);
const settled = pending.finally(() => {
const settled = pending.then(
() => undefined,
() => undefined,
).finally(() => {
if (this.#targetMutations.get(profileId) === settled) {
this.#targetMutations.delete(profileId);
}
});
this.#targetMutations.set(profileId, settled);
return settled;
return pending;
}

#activate(target: DesktopRuntimeHostTargetGeneration): void {
Expand Down
95 changes: 95 additions & 0 deletions apps/desktop/src/main/runtime-host-framed-output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

export function createRuntimeHostFramedOutputFilter<Frame>(input: {
readonly prefix: string;
readonly pendingMaxBytes: number;
readonly decode: (line: string) => Frame | undefined;
readonly label: string;
readonly onFrame: (frame: Frame) => void;
readonly onError: (error: Error) => void;
}): { push(data: string): string; finish(): string } {
let pending = '';
let discardReservedLine = false;
const drain = (finished: boolean): string => {
let visible = '';
while (pending) {
if (discardReservedLine) {
const newline = pending.indexOf('\n');
if (newline < 0) {
pending = '';
break;
}
pending = pending.slice(newline + 1);
discardReservedLine = false;
continue;
}
const marker = pending.indexOf(input.prefix);
if (marker >= 0) {
visible += pending.slice(0, marker);
pending = pending.slice(marker);
const newline = pending.indexOf('\n');
if (newline < 0) {
if (finished) {
input.onError(new Error(`${input.label} returned an incomplete result`));
pending = '';
} else if (pending.length > input.pendingMaxBytes) {
input.onError(new Error(`${input.label} returned an oversized result`));
pending = '';
discardReservedLine = true;
}
break;
}
const line = pending.slice(0, newline + 1);
pending = pending.slice(newline + 1);
const frame = input.decode(line);
if (frame) input.onFrame(frame);
else input.onError(new Error(`${input.label} returned an invalid result`));
continue;
}
if (finished) {
visible += pending;
pending = '';
break;
}
const retained = markerSuffixLength(pending, input.prefix);
visible += pending.slice(0, pending.length - retained);
pending = pending.slice(pending.length - retained);
break;
}
return visible;
};
return {
push(data) {
pending += data;
return drain(false);
},
finish() {
return drain(true);
},
};
}

function markerSuffixLength(value: string, prefix: string): number {
const limit = Math.min(value.length, prefix.length - 1);
for (let length = limit; length > 0; length -= 1) {
if (prefix.startsWith(value.slice(-length))) return length;
}
return 0;
}
Loading
Loading