Skip to content
Merged
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
30 changes: 22 additions & 8 deletions src/adapter-a.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface ADevice {
export interface AClient {
devices: {
list(options?: { udid?: string }): Promise<ADevice[]>;
boot(options?: { udid?: string }): Promise<unknown>;
boot(options?: { udid?: string; headless?: boolean }): Promise<unknown>;
shutdown(options?: { udid?: string }): Promise<unknown>;
};
apps: {
Expand All @@ -48,6 +48,8 @@ export interface AClient {
press(options: { target: ATarget; udid?: string; settle?: boolean }): Promise<unknown>;
longPress(options: { target: ATarget; udid?: string; durationMs?: number }): Promise<unknown>;
fill(options: { target: ATarget; text: string; udid?: string; settle?: boolean }): Promise<unknown>;
/** Hide the on-screen keyboard (engine-backed, device-independent). */
keyboardDismiss(options: { udid?: string }): Promise<unknown>;
scroll(options: { direction: string; udid?: string; settle?: boolean }): Promise<unknown>;
find(options: { query: string; action?: string; value?: string; first?: boolean; udid?: string }): Promise<unknown>;
};
Expand All @@ -70,18 +72,30 @@ export interface AClient {
};
}

let cached: { key: string; client: AClient } | null = null;
/** One client per (engine, session) — the engine binds a session NAME to one
* device; a shared 'alloy' session made any cross-device sequence collide
* ("is owned by session alloy"). Per-device sessions (`alloy-<udid8>`) give
* each device its own session + claim; DEVICE-SCOPED tools pass d.udid,
* device-agnostic ones (list/health) use the bare 'alloy' default. */
const clients = new Map<string, AClient>();

export async function loadEngineAClient(resolved: ResolvedEngines['engineA']): Promise<AClient> {
if (cached && cached.key === resolved.importUrl) return cached.client;
export function sessionForUdid(udid: string | undefined): string {
if (!udid) return 'alloy';
return `alloy-${udid.replace(/-/g, '').slice(0, 8).toLowerCase()}`;
}

export async function loadEngineAClient(resolved: ResolvedEngines['engineA'], session = 'alloy'): Promise<AClient> {
const key = `${resolved.importUrl}::${session}`;
const cached = clients.get(key);
if (cached) return cached;
const mod = (await import(resolved.importUrl)) as Record<string, unknown>;
const factoryName = resolved.clientFactory ?? 'createClient';
const factory = mod[factoryName];
if (typeof factory !== 'function') {
throw new AlloyError('ENGINE_UNAVAILABLE', 'engine A client factory missing at configured entry', { engine: 'A' });
}
const client = (factory as (config?: { session?: string }) => AClient)({ session: 'alloy' });
cached = { key: resolved.importUrl, client };
const client = (factory as (config?: { session?: string }) => AClient)({ session });
clients.set(key, client);
return client;
}

Expand Down Expand Up @@ -127,12 +141,12 @@ export function toSettingsOptions(
}

export function resetEngineACacheForTests(): void {
cached = null;
clients.clear();
}

/** Production seam (see resetEngineBCache): drop the cached engine-A client so
* the next loadEngineAClient() re-creates it — same stale-handle recovery class
* as engine B. */
export function resetEngineACache(): void {
cached = null;
clients.clear();
}
63 changes: 45 additions & 18 deletions src/registrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { buildHealthReport, probeImports, type HealthReport } from './health.ts'
import type { ServerState } from './server.ts';
import {
loadEngineAClient,
sessionForUdid,
toTarget,
toFindQuery,
toAlertAction,
Expand Down Expand Up @@ -74,12 +75,14 @@ export function registerPhase0Tools(deps: StubDeps): void {

// ── engine A surface (Phase 1) ───────────────────────────────────────────
defineToolFromRow(mustRow('alloy_devices'), async (input) => {
const client = await loadEngineAClient(requireEngineA(deps).engineA);
const d = input as { action: 'list' | 'boot' | 'shutdown'; udid?: string };
const d0 = input as { udid?: string };
const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid));
const d = input as { action: 'list' | 'boot' | 'shutdown'; udid?: string; headless?: boolean };
if (d.action === 'list') return client.devices.list();
if (d.action === 'boot') {
const o: { udid?: string } = {};
const o: { udid?: string; headless?: boolean } = {};
opt(o, 'udid', d.udid);
opt(o, 'headless', d.headless);
return client.devices.boot(o);
}
const o: { udid?: string } = {};
Expand All @@ -88,7 +91,8 @@ export function registerPhase0Tools(deps: StubDeps): void {
});

defineToolFromRow(mustRow('alloy_apps'), async (input) => {
const client = await loadEngineAClient(requireEngineA(deps).engineA);
const d0 = input as { udid?: string };
const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid));
const d = input as {
action: 'open' | 'install' | 'reinstall' | 'list';
app?: string;
Expand Down Expand Up @@ -140,7 +144,8 @@ export function registerPhase0Tools(deps: StubDeps): void {
});

defineToolFromRow(mustRow('alloy_release'), async (input) => {
const client = await loadEngineAClient(requireEngineA(deps).engineA);
const d0 = input as { udid?: string };
const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid));
const d = input as { udid: string };
// Release engine session (SESSION_NOT_FOUND means nothing to close — not an error)
try {
Expand All @@ -156,7 +161,8 @@ export function registerPhase0Tools(deps: StubDeps): void {
});

defineToolFromRow(mustRow('alloy_snapshot'), async (input) => {
const client = await loadEngineAClient(requireEngineA(deps).engineA);
const d0 = input as { udid?: string };
const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid));
const d = input as { udid: string; interactiveOnly: boolean };
return client.capture.snapshot({ udid: d.udid, interactiveOnly: d.interactiveOnly });
});
Expand All @@ -168,7 +174,8 @@ export function registerPhase0Tools(deps: StubDeps): void {
});

defineToolFromRow(mustRow('alloy_screenshot'), async (input) => {
const client = await loadEngineAClient(requireEngineA(deps).engineA);
const d0 = input as { udid?: string };
const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid));
const d = input as { udid: string; outPath?: string };
const o: { udid?: string; outPath?: string } = {};
opt(o, 'udid', d.udid);
Expand All @@ -177,15 +184,17 @@ export function registerPhase0Tools(deps: StubDeps): void {
});

defineToolFromRow(mustRow('alloy_act'), async (input) => {
const client = await loadEngineAClient(requireEngineA(deps).engineA);
const d0 = input as { udid?: string };
const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid));
const d = input as {
udid: string;
action: 'press' | 'fill' | 'scroll' | 'longpress';
action: 'press' | 'fill' | 'scroll' | 'longpress' | 'keyboardDismiss';
target?: string;
text?: string;
direction?: 'up' | 'down' | 'left' | 'right';
settle: boolean;
};
if (d.action === 'keyboardDismiss') return client.interactions.keyboardDismiss({ udid: d.udid });
if (d.action === 'scroll') {
return client.interactions.scroll({ direction: d.direction!, udid: d.udid, settle: d.settle });
}
Expand All @@ -200,7 +209,8 @@ export function registerPhase0Tools(deps: StubDeps): void {
});

defineToolFromRow(mustRow('alloy_find'), async (input) => {
const client = await loadEngineAClient(requireEngineA(deps).engineA);
const d0 = input as { udid?: string };
const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid));
const d = input as { udid: string; by: 'text' | 'label' | 'role' | 'id'; value: string; action: 'none' | 'tap' };
const o: Record<string, unknown> = {
query: toFindQuery(d.by, d.value),
Expand All @@ -212,13 +222,15 @@ export function registerPhase0Tools(deps: StubDeps): void {
});

defineToolFromRow(mustRow('alloy_alert'), async (input) => {
const client = await loadEngineAClient(requireEngineA(deps).engineA);
const d0 = input as { udid?: string };
const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid));
const d = input as { udid: string; action: 'inspect' | 'accept' | 'dismiss' };
return client.command.alert({ action: toAlertAction(d.action), udid: d.udid });
});

defineToolFromRow(mustRow('alloy_settings'), async (input) => {
const client = await loadEngineAClient(requireEngineA(deps).engineA);
const d0 = input as { udid?: string };
const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid));
const d = input as { udid: string; area: string; values?: Record<string, string> };
return client.settings.update(toSettingsOptions(d.area, d.values, d.udid));
});
Expand All @@ -232,7 +244,13 @@ export function registerPhase0Tools(deps: StubDeps): void {
return callBTool(client, 'describe', { udid: d.udid });
});

defineToolFromRow(mustRow('alloy_native_tree'), async (input) => {
defineToolFromRow(mustRow('alloy_restart_app'), async (input) => {
const client = await loadEngineBClient(requireEngineB(deps).engineB);
const d = input as { udid: string; bundleId: string };
return callBTool(client, 'restart-app', { udid: d.udid, bundleId: d.bundleId });
});

defineToolFromRow(mustRow('alloy_native_tree'), async (input) => {
const client = await loadEngineBClient(requireEngineB(deps).engineB);
const d = input as {
udid: string;
Expand All @@ -241,6 +259,9 @@ export function registerPhase0Tools(deps: StubDeps): void {
id?: string;
x?: number;
y?: number;
/** interactable-at: engine B requires the app's bundle id (precheck
* gate + view-space mapping). find-views ignores it. */
bundleId?: string;
};
if (d.query === 'hierarchy') {
return callBTool(client, 'native-full-hierarchy', { udid: d.udid });
Expand All @@ -252,6 +273,7 @@ export function registerPhase0Tools(deps: StubDeps): void {
}
return callBTool(client, 'native-user-interactable-view-at-point', {
udid: d.udid,
bundleId: d.bundleId!,
x: d.x!,
y: d.y!,
});
Expand Down Expand Up @@ -319,15 +341,17 @@ export function registerPhase0Tools(deps: StubDeps): void {

// ── Phase 3: diagnostics (engine A) + js runtime (engine B) ──────────────
defineToolFromRow(mustRow('alloy_network'), async (input) => {
const client = await loadEngineAClient(requireEngineA(deps).engineA);
const d0 = input as { udid?: string };
const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid));
const d = input as { udid: string; limit: number; include?: 'none' | 'headers' | 'bodies' | 'all' };
const o: Record<string, unknown> = { action: 'dump', limit: d.limit, udid: d.udid };
if (d.include !== undefined) o['include'] = d.include;
return client.observability.network(o);
});

defineToolFromRow(mustRow('alloy_logs'), async (input) => {
const client = await loadEngineAClient(requireEngineA(deps).engineA);
const d0 = input as { udid?: string };
const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid));
const d = input as {
udid: string;
action: 'mark' | 'capture' | 'start' | 'stop' | 'clear';
Expand All @@ -341,13 +365,15 @@ export function registerPhase0Tools(deps: StubDeps): void {
});

defineToolFromRow(mustRow('alloy_perf'), async (input) => {
const client = await loadEngineAClient(requireEngineA(deps).engineA);
const d0 = input as { udid?: string };
const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid));
const d = input as { udid: string; area: 'frames' | 'memory' | 'cpu' | 'trace'; action: string };
return client.observability.perf({ area: d.area, action: d.action, udid: d.udid });
});

defineToolFromRow(mustRow('alloy_push'), async (input) => {
const client = await loadEngineAClient(requireEngineA(deps).engineA);
const d0 = input as { udid?: string };
const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid));
const d = input as { udid: string; app: string; payload: Record<string, unknown> };
return client.apps.push({ app: d.app, payload: d.payload, udid: d.udid });
});
Expand All @@ -373,7 +399,8 @@ export function registerPhase0Tools(deps: StubDeps): void {
});

defineToolFromRow(mustRow('alloy_replay'), async (input) => {
const client = await loadEngineAClient(requireEngineA(deps).engineA);
const d0 = input as { udid?: string };
const client = await loadEngineAClient(requireEngineA(deps).engineA, sessionForUdid(d0.udid));
const d = input as { udid: string; scriptPath: string; timeoutMs?: number };
const o: Record<string, unknown> = { path: d.scriptPath, udid: d.udid };
if (d.timeoutMs !== undefined) o['timeoutMs'] = d.timeoutMs;
Expand Down
22 changes: 19 additions & 3 deletions src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const routingTable: readonly RoutingRow[] = [
.object({
action: z.enum(['list', 'boot', 'shutdown']).default('list'),
udid: deviceRef.optional(),
headless: z.boolean().optional(),
})
.strict()
.superRefine((v, ctx) => {
Expand All @@ -92,6 +93,13 @@ export const routingTable: readonly RoutingRow[] = [
summary: 'Release a held device lease and end the engine session on it',
schema: z.object({ udid: deviceRef }).strict(),
},
{
tool: 'alloy_restart_app',
engine: 'B',
lease: 'per-call',
summary: 'Restart app with native-devtools dylib injected (engine B restart-app)',
schema: z.object({ udid: deviceRef, bundleId: z.string().min(1) }).strict(),
},
{
tool: 'alloy_snapshot',
engine: 'A',
Expand Down Expand Up @@ -122,11 +130,14 @@ export const routingTable: readonly RoutingRow[] = [
tool: 'alloy_act',
engine: 'A',
lease: 'per-call',
// Lionidas mirror: server/src/manual/alloy-act-contract.test.ts pins this
// verb set (mirror + live-schema tiers). Changing actions/fields here?
// Update that mirror in the same change.
summary: 'Press, click, fill, scroll, or long-press with post-action settle diff',
schema: z
.object({
udid: deviceRef,
action: z.enum(['press', 'fill', 'scroll', 'longpress']),
action: z.enum(['press', 'fill', 'scroll', 'longpress', 'keyboardDismiss']),
target: z
.union([
z.string().min(1),
Expand All @@ -146,8 +157,8 @@ export const routingTable: readonly RoutingRow[] = [
if (v.action === 'scroll' && !v.direction) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'direction is required for scroll' });
}
if (v.action !== 'scroll' && !v.target) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'target is required unless action=scroll' });
if (v.action !== 'scroll' && v.action !== 'keyboardDismiss' && !v.target) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'target is required unless action=scroll or keyboardDismiss' });
}
}),
},
Expand Down Expand Up @@ -210,9 +221,14 @@ export const routingTable: readonly RoutingRow[] = [
id: z.string().min(1).optional(),
x: z.number().finite().optional(),
y: z.number().finite().optional(),
bundleId: z.string().min(1).optional(),
})
.strict()
.superRefine((v, ctx) => {
if (v.query === 'interactable-at') {
if (v.x === undefined || v.y === undefined) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'x and y required for interactable-at' });
if (!v.bundleId) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'bundleId required for interactable-at' });
}
if (v.query === 'find-views' && !v.className && !v.id) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'className or id required for find-views' });
}
Expand Down
2 changes: 1 addition & 1 deletion test/contract.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function fakeEngineAClient() {
export function makeFakeResolvedEngines(): ResolvedEngines {
const dir = mkdtempSync(join(tmpdir(), 'alloy-fake-'));
mkdirSync(join(dir, 'node_modules'));
writeFileSync(join(dir, 'entry-a.mjs'), `export function createClient() { return globalThis.__alloyFakeA; }\n`);
writeFileSync(join(dir, 'entry-a.mjs'), `export function createClient(cfg) { (globalThis.__alloySessions ??= []).push(cfg?.session); return globalThis.__alloyFakeA; }\n`);
writeFileSync(join(dir, 'entry-b.mjs'), `export function createClient() { return globalThis.__alloyFakeB; }\n`);
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fake-engine', version: '1.0.0' }));
return {
Expand Down
12 changes: 12 additions & 0 deletions test/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const VALID_INPUTS: Record<string, unknown> = {
alloy_devices: { action: 'list' },
alloy_apps: { action: 'list' },
alloy_release: { udid: 'SIM-1' },
alloy_restart_app: { udid: 'SIM-1', bundleId: 'com.example.app' },
alloy_snapshot: { udid: 'SIM-1' },
alloy_stream: { udid: 'SIM-1' },
alloy_screenshot: { udid: 'SIM-1' },
Expand Down Expand Up @@ -126,6 +127,17 @@ describe('routing table contract (generated)', () => {
expect(open.foreground).toBe(true);
});

it('per-device engine sessions: two udid dispatches create two named sessions', async () => {
const rt = freshRuntime();
(globalThis as Record<string, unknown>)['__alloySessions'] = [];
await dispatch('alloy_apps', { action: 'open', app: 'com.example.app', udid: 'SIM-AAAAAAAA-1' }, rt.deps);
await dispatch('alloy_apps', { action: 'open', app: 'com.example.app', udid: 'SIM-BBBBBBBB-2' }, rt.deps);
const sessions = (globalThis as Record<string, unknown>)['__alloySessions'] as string[];
expect(sessions).toContain('alloy-simaaaaa');
expect(sessions).toContain('alloy-simbbbbb');
expect(new Set(sessions).size).toBe(2);
});

it('open acquires held lease; release clears it and closes engine session', async () => {
const rt = freshRuntime();
await dispatch(
Expand Down
Loading
Loading