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
16 changes: 10 additions & 6 deletions docs/adr/0022-daemon-platform-runtime-coupling.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,14 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`.
the Apple runner owner, the Android snapshot-helper and Web orphan cleanups, and legacy
app-log marker recovery. The `resource-cleanup` edge stays, reclassified
composition-essential and carrying only `platformResourceCleanup`.
- **#2334** — open-target planning separated from platform mechanics
(`session-open-prepare.ts`, `session-selector-dispatch.ts` →
`platform-runtime-open-target.ts`), blocked by #2332.
- **Open-target planning (done, #2334)** — `platform-runtime-open-target.ts` keeps only the
neutral open plan/result surface (`resolveRequestedOpenSurface`, `validateOpenRelaunchTarget`,
`resolveSessionAppBundleIdForTarget`); Android package resolution
(`resolveAndroidPackageForOpen`, `inferAndroidPackageAfterOpen`) moved behind the Android
owning seam in `packages/platform-android`. `session-open-prepare.ts` consumes surface and
relaunch-target policy, `session-selector-dispatch.ts` consumes the app-bundle-identity
resolver; both edges are daemon-policy-essential, and the resolver stays the one
construction path for the open plan.
- **#2273/#2274** (existing) — `direct-ios-selector.ts` → `queryAppleRuntimeSelector` is the
selector seam those issues own; coordination was posted there rather than opening a second
selector producer.
Expand Down Expand Up @@ -85,7 +90,7 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`.
5. **Per-audit-area decisions.** Apple session observation: consume the neutral
`AppleSessionObservation` contract. Runtime lifecycle participation: deepen through the existing lifecycle
phases, no generic hook bag (#2333). Open-target planning: separate plan/result from
platform mechanics, one construction path preserved (#2334). Session state/store authority:
platform mechanics, one construction path preserved. Session state/store authority:
keep the current access shape, ratchet the handler-owned slice (R75). Route depth: record the
re-traced routes; collapse only the proven pass-through hops (none undertaken in this
change).
Expand Down Expand Up @@ -120,5 +125,4 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`.
`scripts/layering/check.ts` (both observed red against planted violations before acceptance).
- R7 `session-state-ownership` and the R10 merge-base ratchet for the owning-module slice.
- R65 for the concrete-platform-import ban this audit builds on.
- Child issue #2334 (and #2273/#2274 for the selector seam) for the remaining category-3
implementation work; #2333 landed (see §2.2).
- Child issues #2333 and #2334 landed (see §2.2); #2273/#2274 remain for the selector seam.
4 changes: 2 additions & 2 deletions packages/platform-android/src/app-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ async function openAndroidAppBoundDeepLink(
throw new AppError('INVALID_ARGS', 'Android app-bound open requires a valid URL target');
}
await ensureAndroidLocalhostReverse(device, deepLinkUrl);
const resolved = await resolveAndroidPackageForOpen(device, app, 'app-bound open');
const resolved = await requireAndroidPackageForOpen(device, app, 'app-bound open');
await runAndroidAdb(device, [
'shell',
'am',
Expand Down Expand Up @@ -352,7 +352,7 @@ function buildAndroidActivityLaunchArgs(
];
}

async function resolveAndroidPackageForOpen(
async function requireAndroidPackageForOpen(
device: DeviceInfo,
app: string,
label: string,
Expand Down
16 changes: 16 additions & 0 deletions packages/platform-android/src/mechanics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,22 @@ export {
formatAndroidInstalledPackageRequiredMessage,
type AndroidAppTargetKind,
} from './open-target.ts';
export async function resolveAndroidPackageForOpen(
...args: Parameters<typeof import('./open-target-resolution.ts').resolveAndroidPackageForOpen>
): Promise<
Awaited<ReturnType<typeof import('./open-target-resolution.ts').resolveAndroidPackageForOpen>>
> {
const { resolveAndroidPackageForOpen: load } = await import('./open-target-resolution.ts');
return await load(...args);
}
export async function inferAndroidPackageAfterOpen(
...args: Parameters<typeof import('./open-target-resolution.ts').inferAndroidPackageAfterOpen>
): Promise<
Awaited<ReturnType<typeof import('./open-target-resolution.ts').inferAndroidPackageAfterOpen>>
> {
const { inferAndroidPackageAfterOpen: load } = await import('./open-target-resolution.ts');
return await load(...args);
}
export {
resetAndroidFramePerfStats,
sampleAndroidFramePerf,
Expand Down
97 changes: 97 additions & 0 deletions packages/platform-android/src/open-target-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { beforeEach, expect, test, vi } from 'vitest';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';

const resolveAndroidApp = vi.hoisted(() => vi.fn());
const getAndroidAppState = vi.hoisted(() => vi.fn());

vi.mock('./app-deployment-resolution.ts', () => ({ resolveAndroidApp }));
vi.mock('./window-state.ts', () => ({ getAndroidAppState }));

const { resolveAndroidPackageForOpen, inferAndroidPackageAfterOpen } =
await import('./open-target-resolution.ts');

const androidDevice: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel Emulator',
kind: 'emulator',
booted: true,
};
const appleDevice: DeviceInfo = {
platform: 'apple',
id: '00000000-0000-0000-0000-000000000000',
name: 'iPhone Simulator',
kind: 'simulator',
booted: true,
};

beforeEach(() => {
vi.clearAllMocks();
});

test('resolveAndroidPackageForOpen returns the resolved package for a package match', async () => {
resolveAndroidApp.mockResolvedValue({ type: 'package', value: 'com.example.demo' });

await expect(resolveAndroidPackageForOpen(androidDevice, 'demo')).resolves.toBe(
'com.example.demo',
);
});

test('resolveAndroidPackageForOpen ignores an intent resolution', async () => {
resolveAndroidApp.mockResolvedValue({ type: 'intent', value: 'android.settings.SETTINGS' });

await expect(resolveAndroidPackageForOpen(androidDevice, 'settings')).resolves.toBeUndefined();
});

test('resolveAndroidPackageForOpen swallows resolution failures', async () => {
resolveAndroidApp.mockRejectedValue(new AppError('APP_NOT_INSTALLED', 'No package found'));

await expect(resolveAndroidPackageForOpen(androidDevice, 'demo')).resolves.toBeUndefined();
expect(resolveAndroidApp).toHaveBeenCalled();
});

test('resolveAndroidPackageForOpen skips non-Android devices without resolving', async () => {
await expect(resolveAndroidPackageForOpen(appleDevice, 'demo')).resolves.toBeUndefined();
expect(resolveAndroidApp).not.toHaveBeenCalled();
});

test('resolveAndroidPackageForOpen skips a deep-link target without resolving', async () => {
await expect(
resolveAndroidPackageForOpen(androidDevice, 'myapp://login'),
).resolves.toBeUndefined();
expect(resolveAndroidApp).not.toHaveBeenCalled();
});

test('inferAndroidPackageAfterOpen reads the foreground package for a deep-link open', async () => {
getAndroidAppState.mockResolvedValue({
package: 'host.exp.exponent',
activity: 'host.exp.exponent.experience.ExperienceActivity',
});

await expect(
inferAndroidPackageAfterOpen(androidDevice, 'exp://127.0.0.1:8082', undefined),
).resolves.toBe('host.exp.exponent');
});

test('inferAndroidPackageAfterOpen keeps an already-known bundle id without reading state', async () => {
await expect(
inferAndroidPackageAfterOpen(androidDevice, 'exp://127.0.0.1:8082', 'com.example.demo'),
).resolves.toBe('com.example.demo');
expect(getAndroidAppState).not.toHaveBeenCalled();
});

test('inferAndroidPackageAfterOpen leaves a non-deep-link target unchanged', async () => {
await expect(
inferAndroidPackageAfterOpen(androidDevice, 'com.example.demo', undefined),
).resolves.toBeUndefined();
expect(getAndroidAppState).not.toHaveBeenCalled();
});

test('inferAndroidPackageAfterOpen swallows a foreground-state read failure', async () => {
getAndroidAppState.mockRejectedValue(new Error('adb connection dropped'));

await expect(
inferAndroidPackageAfterOpen(androidDevice, 'exp://127.0.0.1:8082', undefined),
).resolves.toBeUndefined();
});
37 changes: 37 additions & 0 deletions packages/platform-android/src/open-target-resolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { isDeepLinkTarget } from '@agent-device/contracts/command';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { resolveAndroidApp } from './app-deployment-resolution.ts';
import { getAndroidAppState } from './window-state.ts';

/** Resolves an `open` target to an installed package; only an exact package match counts. */
export async function resolveAndroidPackageForOpen(
device: DeviceInfo,
openTarget: string | undefined,
): Promise<string | undefined> {
if (device.platform !== 'android' || !openTarget || isDeepLinkTarget(openTarget))
return undefined;
try {
const resolved = await resolveAndroidApp(device, openTarget);
return resolved.type === 'package' ? resolved.value : undefined;
} catch {
return undefined;
}
}

/** A deep-link open can foreground a different package than the one requested; read it back. */
export async function inferAndroidPackageAfterOpen(
device: DeviceInfo,
openTarget: string | undefined,
currentAppBundleId: string | undefined,
): Promise<string | undefined> {
if (currentAppBundleId) return currentAppBundleId;
if (device.platform !== 'android' || !openTarget || !isDeepLinkTarget(openTarget)) {
return currentAppBundleId;
}
try {
const foreground = await getAndroidAppState(device);
return foreground.package?.trim() || currentAppBundleId;
} catch {
return currentAppBundleId;
}
}
19 changes: 19 additions & 0 deletions scripts/layering/daemon-platform-runtime-inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,25 @@ test('R76 rejects new symbols on a classified edge', () => {
assert.match(found[0]!.message, /ensureLocalPlatformDeviceReady, extraReadiness/);
});

test('R76 rejects a reintroduced Android-mechanics import on the selector-dispatch edge', () => {
const sources = {
'src/platform-runtime-open-target.ts':
'export async function resolveSessionAppBundleIdForTarget() { return undefined; }\n' +
'export async function resolveAndroidPackageForOpen() { return undefined; }\n',
'src/daemon/handlers/session-selector-dispatch.ts':
"import { resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget } from '../../platform-runtime-open-target.ts';\n" +
'void [resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget];\n',
};
const found = edgeViolations(sources, 'src/daemon/handlers/session-selector-dispatch.ts');
assert.equal(found.length, 1);
assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE);
assert.match(found[0]!.message, /classified symbols drifted/);
assert.match(
found[0]!.message,
/resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget/,
);
});

test('R76 matches a destructured dynamic import by target with the recorded bindings', () => {
const sources = {
'src/platform-runtime-daemon-lifecycle.ts':
Expand Down
20 changes: 11 additions & 9 deletions scripts/layering/daemon-platform-runtime-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,22 +151,24 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[]
{
file: 'src/daemon/handlers/session-selector-dispatch.ts',
target: 'src/platform-runtime-open-target.ts',
symbols: ['resolveAndroidPackageForOpen', 'resolveSessionAppBundleIdForTarget'],
classification: 'leaked-platform-mechanics',
symbols: ['resolveSessionAppBundleIdForTarget'],
classification: 'daemon-policy-essential',
rationale:
'selector dispatch consumes the mixed open-target module; Android package resolution ' +
'is platform mechanics that should sit behind the Android owning seam.',
deepenedBy: '#2334',
'selector dispatch reconstructs the session app-bundle identity after a trigger-app-event ' +
'deep link through the one neutral open-plan resolver (#2334); Android package resolution ' +
'moved behind the Android owning seam in packages/platform-android, so the resolver is the ' +
'only symbol this edge names.',
},
{
file: 'src/daemon/session-lifecycle/internal/session-open-prepare.ts',
target: 'src/platform-runtime-open-target.ts',
symbols: ['resolveRequestedOpenSurface', 'validateOpenRelaunchTarget'],
classification: 'leaked-platform-mechanics',
classification: 'daemon-policy-essential',
rationale:
'open-prepare policy consumes the mixed open-target module; the neutral open ' +
'plan/result should be separated from the platform mechanics that share the file.',
deepenedBy: '#2334',
'open-prepare policy consumes only the neutral open plan/result surface (#2334): surface ' +
'classification and relaunch-target validation. The platform mechanics that used to share ' +
'the file (Android package resolution) moved behind the Android owning seam, leaving this ' +
'edge daemon policy over two neutral, non-mechanics functions.',
},
] as const;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { beforeEach, expect, test, vi } from 'vitest';
import type { DeviceInfo } from '@agent-device/kernel/device';

vi.mock('../platform-runtime-android-mechanics.ts', () => ({
loadAndroidMechanics: vi.fn(async () => {
throw new Error('adb host unavailable');
}),
}));

import { loadAndroidMechanics } from '../platform-runtime-android-mechanics.ts';
import { createAndroidApplicationTools } from '../platform-runtime-android-application-tools.ts';

const mockLoadAndroidMechanics = vi.mocked(loadAndroidMechanics);

beforeEach(() => {
mockLoadAndroidMechanics.mockClear();
});

const device: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel 9 Pro XL',
kind: 'emulator',
booted: true,
};

test('inferOpenedAppBundleId stays best-effort when Android mechanics fails to load for a targetless open', async () => {
await expect(
createAndroidApplicationTools().inferOpenedAppBundleId(device, undefined, undefined),
).resolves.toBeUndefined();
});

test('inferOpenedAppBundleId skips loading Android mechanics when the app-bundle identity is already known', async () => {
await expect(
createAndroidApplicationTools().inferOpenedAppBundleId(
device,
'exp://127.0.0.1:8082',
'com.example.demo',
),
).resolves.toBe('com.example.demo');
expect(mockLoadAndroidMechanics).not.toHaveBeenCalled();
});
14 changes: 8 additions & 6 deletions src/daemon/handlers/__tests__/session-device-claims.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ vi.mock('../../../platform-runtime-runtime-hints.ts', async (importOriginal) =>
await importOriginal<typeof import('../../../platform-runtime-runtime-hints.ts')>();
return { ...actual, applyRuntimeHintValues: vi.fn(async () => {}) };
});
vi.mock('../../../platform-runtime-open-target.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platform-runtime-open-target.ts')>();
return { ...actual, resolveAndroidPackageForOpen: vi.fn() };
});
vi.mock('@agent-device/platform-android/mechanics', () => ({
activateAndroidTestIme: vi.fn(async () => ({ activated: false })),
restoreAndroidTestIme: vi.fn(async () => ({ restored: false, reason: 'no-record' })),
stopAndroidSnapshotHelperSessionForDevice: vi.fn(async () => {}),
resolveAndroidPackageForOpen: vi.fn(),
inferAndroidPackageAfterOpen: vi.fn(
async (_device, _target, currentAppBundleId) => currentAppBundleId,
),
}));
vi.mock('@agent-device/host-kit/process', async (importOriginal) =>
(await import('../../../__tests__/test-utils/host-process-mock.ts')).pinOwnProcessStartTime(
Expand All @@ -40,8 +40,10 @@ vi.mock('@agent-device/host-kit/process', async (importOriginal) =>
import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve';
import { ensureDeviceReady } from '../../device-ready.ts';
import { applyRuntimeHintValues } from '../../../platform-runtime-runtime-hints.ts';
import { resolveAndroidPackageForOpen } from '../../../platform-runtime-open-target.ts';
import { activateAndroidTestIme } from '@agent-device/platform-android/mechanics';
import {
activateAndroidTestIme,
resolveAndroidPackageForOpen,
} from '@agent-device/platform-android/mechanics';
import {
discoverReadyAndroidEmulators,
dispatchApplicationLifecycleEffect,
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/handlers/__tests__/session-relaunch-close.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) =>
resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined),
};
});
vi.mock('../../../platform-runtime-open-target.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platform-runtime-open-target.ts')>();
vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => {
const actual = await importOriginal<typeof import('@agent-device/platform-android/mechanics')>();
return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) };
});

Expand Down
7 changes: 2 additions & 5 deletions src/daemon/handlers/__tests__/session-test-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,13 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) =>
resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined),
};
});
vi.mock('../../../platform-runtime-open-target.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platform-runtime-open-target.ts')>();
return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) };
});
vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => {
const actual = await importOriginal<typeof import('@agent-device/platform-android/mechanics')>();
return {
...actual,
activateAndroidTestIme: vi.fn(async () => ({ activated: false })),
restoreAndroidTestIme: vi.fn(async () => ({ restored: false, reason: 'no-record' })),
resolveAndroidPackageForOpen: vi.fn(async () => undefined),
};
});
vi.mock('@agent-device/host-kit/command', async (importOriginal) => {
Expand Down Expand Up @@ -113,7 +110,7 @@ import {
resolveIosApp,
resolveIosSimulatorDeepLinkBundleId,
} from '@agent-device/platform-apple/app-resolution';
import { resolveAndroidPackageForOpen } from '../../../platform-runtime-open-target.ts';
import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics';
import { runCmd } from '@agent-device/host-kit/command';
import { dispatchApplicationLifecycleEffect } from '../../__tests__/application-lifecycle-runtime-fixture.ts';

Expand Down
Loading
Loading