Skip to content
7 changes: 6 additions & 1 deletion packages/provider-limrun/src/android.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
LimrunRuntimeDependencies,
} from './runtime-dependencies.ts';
import type { AndroidAdbInvocation } from '@agent-device/platform-android/mechanics';
import type { LimrunInstanceOwnership } from './instance-access.ts';
import { normalizeOptionalString } from './strings.ts';
import {
awaitLimrunDeploymentOperation,
Expand All @@ -33,6 +34,7 @@ type LimrunAndroidAdbSession = {
platform: 'android';
lease: DeviceLease;
instanceId: string;
readonly ownership: LimrunInstanceOwnership;
device: DeviceInfo;
client: LimrunAndroidClient;
/** Instance bearer token; the recording download the SDK would run inline is done by the host instead. */
Expand All @@ -51,6 +53,7 @@ export async function createLimrunAndroidSession(
options: {
lease: DeviceLease;
instanceId: string;
ownership: LimrunInstanceOwnership;
device: DeviceInfo;
apiUrl: string;
adbUrl: string;
Expand All @@ -68,6 +71,7 @@ export async function createLimrunAndroidSession(
platform: 'android',
lease: options.lease,
instanceId: options.instanceId,
ownership: options.ownership,
device: options.device,
client,
token: options.token,
Expand Down Expand Up @@ -165,9 +169,10 @@ async function cleanupAndroidPortReverse(session: LimrunAndroidSession): Promise
const mappings = await reverse.list().catch(() => []);
const owners = new Set<string>();
const unownedLocals: LimrunPortReverseEndpoint[] = [];
const sharedDevice = session.ownership === 'attached';
for (const mapping of mappings) {
if (mapping.ownerId) owners.add(mapping.ownerId);
else unownedLocals.push(mapping.local);
else if (!sharedDevice) unownedLocals.push(mapping.local);
}
await Promise.allSettled([
...[...owners].map(async (ownerId) => await reverse.removeAllOwned(ownerId)),
Expand Down
25 changes: 25 additions & 0 deletions packages/provider-limrun/src/app-log-descriptor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { expect, test } from 'vitest';
import { limrunAppLogDescriptorCodec } from './app-log-descriptor.ts';

const body = {
transport: 'limrun-log-poller',
platform: 'ios',
leaseId: 'lease-a',
instanceId: 'ios_instance',
appBundleId: 'com.example.app',
outputPath: '/sessions/one/app.log',
};

test('decodes descriptors written before ownership as created instances', () => {
expect(limrunAppLogDescriptorCodec.decode(body)).toMatchObject({
status: 'decoded',
descriptor: { ownership: 'created' },
});
expect(limrunAppLogDescriptorCodec.decode({ ...body, ownership: 'attached' })).toMatchObject({
status: 'decoded',
descriptor: { ownership: 'attached' },
});
expect(limrunAppLogDescriptorCodec.decode({ ...body, ownership: 'borrowed' }).status).toBe(
'invalid',
);
});
11 changes: 11 additions & 0 deletions packages/provider-limrun/src/app-log-descriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import type {
import type { RuntimeOwnerRef } from '@agent-device/contracts/platform-runtime';
import { APP_LOG_RESOURCE_KIND } from '@agent-device/contracts/app-log-runtime';
import { createDurableResourceEnvelope, encodeDurableDescriptor } from '@agent-device/capture-kit';
import type { LimrunInstanceOwnership } from './instance-access.ts';

export type LimrunAppLogDescriptor = Readonly<{
transport: 'limrun-log-poller';
platform: 'ios' | 'android';
leaseId: string;
instanceId: string;
ownership: LimrunInstanceOwnership;
appBundleId: string;
outputPath: string;
}>;
Expand All @@ -24,7 +26,9 @@ export const limrunAppLogDescriptorCodec: DurableDescriptorCodec<
version: 1,
encode: (descriptor) => ({ ...descriptor }),
decode: (body) => {
const ownership = decodeOwnership(body.ownership);
if (
!ownership ||
body.transport !== 'limrun-log-poller' ||
(body.platform !== 'ios' && body.platform !== 'android') ||
!isNonEmptyString(body.leaseId) ||
Expand All @@ -41,6 +45,7 @@ export const limrunAppLogDescriptorCodec: DurableDescriptorCodec<
platform: body.platform,
leaseId: body.leaseId,
instanceId: body.instanceId,
ownership,
appBundleId: body.appBundleId,
outputPath: body.outputPath,
}),
Expand Down Expand Up @@ -78,6 +83,12 @@ export function createLimrunAppLogEnvelope(input: {
});
}

/** Descriptors written before attach support carry no ownership; those named created instances. */
function decodeOwnership(value: unknown): LimrunInstanceOwnership | undefined {
if (value === undefined) return 'created';
return value === 'created' || value === 'attached' ? value : undefined;
}

function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
5 changes: 5 additions & 0 deletions packages/provider-limrun/src/app-log-poller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('Limrun app-log poller', () => {
platform: 'ios',
leaseId: 'lease-1',
instanceId: 'instance-1',
ownership: 'created',
readLogs: vi.fn(async () => 'old line\nshared\nnew line\n'),
[Symbol.asyncDispose]: async () => {
readerDisposed = true;
Expand Down Expand Up @@ -50,6 +51,7 @@ describe('Limrun app-log poller', () => {
platform: 'ios',
leaseId: 'lease-1',
instanceId: 'instance-1',
ownership: 'created',
readLogs: async () => '',
[Symbol.asyncDispose]: dispose,
};
Expand Down Expand Up @@ -78,6 +80,7 @@ describe('Limrun app-log poller', () => {
platform: 'ios',
leaseId: 'lease-1',
instanceId: 'instance-1',
ownership: 'created',
readLogs: async () => `${'b'.repeat(240_000)}\n`,
[Symbol.asyncDispose]: async () => {},
};
Expand All @@ -100,6 +103,7 @@ describe('Limrun app-log poller', () => {
platform: 'ios',
leaseId: 'lease-1',
instanceId: 'instance-1',
ownership: 'created',
readLogs: async () => '',
[Symbol.asyncDispose]: async () => {
throw new Error('reader cleanup failed');
Expand Down Expand Up @@ -132,6 +136,7 @@ describe('Limrun app-log poller', () => {
platform: 'android',
leaseId: 'lease-1',
instanceId: 'instance-1',
ownership: 'created',
readLogs: vi.fn(async () => await new Promise<string>(() => {})),
[Symbol.asyncDispose]: async () => {
readerDisposed = true;
Expand Down
2 changes: 2 additions & 0 deletions packages/provider-limrun/src/app-log-poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import { AsyncCleanupStack } from '@agent-device/contracts/async-lifecycle';
import { createAppLogLiveHandleFromFinish } from '@agent-device/capture-kit';
import type { LogBackend } from '@agent-device/contracts/observability';
import { awaitLimrunOperation } from './request-cancellation.ts';
import type { LimrunInstanceOwnership } from './instance-access.ts';

export type LimrunAppLogReader = AsyncDisposable &
Readonly<{
platform: 'ios' | 'android';
leaseId: string;
instanceId: string;
ownership: LimrunInstanceOwnership;
readLogs(appBundleId: string, lineLimit: number): Promise<string>;
}>;

Expand Down
73 changes: 73 additions & 0 deletions packages/provider-limrun/src/app-log-reconnect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
type AndroidAdbInvocation,
} from '@agent-device/platform-android/mechanics';
import { reconnectLimrunAppLogReader } from './app-log-reconnect.ts';
import { attachedLimrunInstanceId } from './device.ts';
import { createInstanceClient as createIosInstanceClient } from '@limrun/api/ios-client';
import type {
LimrunAdbCommandOptions,
LimrunAdbExecutor,
Expand All @@ -54,6 +56,7 @@ test('reattaches an owned Limrun instance without persisting credentials', async
platform: 'ios',
leaseId: 'lease-a',
instanceId: 'instance-a',
ownership: 'created',
appBundleId: 'com.example.app',
outputPath: '/sessions/one/app.log',
},
Expand Down Expand Up @@ -83,6 +86,7 @@ test('fails closed when the instance labels do not match the descriptor lease',
platform: 'ios',
leaseId: 'lease-a',
instanceId: 'instance-a',
ownership: 'created',
appBundleId: 'com.example.app',
outputPath: '/sessions/one/app.log',
},
Expand Down Expand Up @@ -113,6 +117,7 @@ test('disconnects the Android instance client when tunnel acquisition fails', as
platform: 'android',
leaseId: 'lease-a',
instanceId: 'instance-a',
ownership: 'created',
appBundleId: 'com.example.app',
outputPath: '/sessions/one/app.log',
},
Expand Down Expand Up @@ -154,6 +159,7 @@ test('addresses app-log adb traffic at the tunnel serial and hands cleanup a com
platform: 'android',
leaseId: 'lease-a',
instanceId: 'instance-a',
ownership: 'created',
appBundleId: 'com.example.app',
outputPath: '/sessions/one/app.log',
},
Expand Down Expand Up @@ -209,3 +215,70 @@ test('addresses app-log adb traffic at the tunnel serial and hands cleanup a com
expect(closeTunnel).toHaveBeenCalledOnce();
expect(androidClient.disconnect).toHaveBeenCalledOnce();
});

const ATTACHED_ACCESS = { apiUrl: 'https://attached.example/api', token: 'instance-token' };

function attachedDescriptor(
instanceId = attachedLimrunInstanceId(ATTACHED_ACCESS.apiUrl),
ownership: 'created' | 'attached' = 'attached',
) {
return {
transport: 'limrun-log-poller',
platform: 'ios',
leaseId: 'lease-a',
instanceId,
ownership,
appBundleId: 'com.example.app',
outputPath: '/sessions/one/app.log',
} as const;
}

test('reattaches app logs to the attached instance the descriptor names', async () => {
const outcome = await reconnectLimrunAppLogReader({
instances: { ios: ATTACHED_ACCESS },
descriptor: attachedDescriptor(),
dependencies: {} as LimrunRuntimeDependencies,
});
expect(vi.mocked(createIosInstanceClient)).toHaveBeenLastCalledWith({
...ATTACHED_ACCESS,
logLevel: 'warn',
});
expect(outcome.status).toBe('opened');

const replaced = await reconnectLimrunAppLogReader({
instances: { ios: { ...ATTACHED_ACCESS, apiUrl: 'https://another-instance.example/api' } },
descriptor: attachedDescriptor(),
dependencies: {} as LimrunRuntimeDependencies,
});
expect(replaced).toEqual({ status: 'ownership-lost' });
});

test('reattaches a created instance through the organization API while another is attached', async () => {
const get = vi.fn(async () => ({
metadata: { labels: { provider: 'limrun', leaseId: 'lease-a' } },
status: { state: 'ready', apiUrl: 'https://created.example/api', token: 'created-token' },
}));
const outcome = await reconnectLimrunAppLogReader({
limrun: { iosInstances: { get } } as never,
instances: { ios: ATTACHED_ACCESS },
descriptor: attachedDescriptor('ios_created_instance', 'created'),
dependencies: {} as LimrunRuntimeDependencies,
});
expect(get).toHaveBeenCalledOnce();
expect(outcome.status).toBe('opened');
});

test('does not connect once the reconnect request is aborted', async () => {
const controller = new AbortController();
controller.abort();
vi.mocked(createIosInstanceClient).mockClear();
await expect(
reconnectLimrunAppLogReader({
instances: { ios: ATTACHED_ACCESS },
descriptor: attachedDescriptor(),
dependencies: {} as LimrunRuntimeDependencies,
signal: controller.signal,
}),
).rejects.toThrow();
expect(vi.mocked(createIosInstanceClient)).not.toHaveBeenCalled();
});
Loading
Loading