Skip to content

Commit ebdaa76

Browse files
authored
feat: delegate reviewed managed automation (#2312)
* feat: delegate reviewed automation through managed lease authority * fix: preserve lazy simulator readiness through scoped authority * fix: admit managed operations at their dispatch boundary * test: move managed automation scenarios to integration lane * chore(gates): declare the private managed readiness scope export
1 parent 07e2508 commit ebdaa76

18 files changed

Lines changed: 809 additions & 183 deletions

packages/platform-android/src/deployment/native.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { PushNotificationInput } from '@agent-device/contracts/app-deployme
33
import type { DeviceInfo } from '@agent-device/kernel/device';
44
import { AppError } from '@agent-device/kernel/errors';
55
import path from 'node:path';
6+
import { currentManagedDeviceScope } from '@agent-device/provision-kit/managed-device-scope';
67

78
export async function installAndroidArtifact(
89
host: PlatformRuntimeHost,
@@ -11,6 +12,7 @@ export async function installAndroidArtifact(
1112
packageNameHint: string | undefined,
1213
signal: AbortSignal,
1314
): Promise<string | undefined> {
15+
assertManagedAndroidInstallablePath(installablePath);
1416
const before = packageNameHint ? undefined : await listInstalledPackages(host, device, signal);
1517
if (path.extname(installablePath).toLowerCase() === '.aab') {
1618
await installAndroidBundle(host, device, installablePath, signal);
@@ -29,6 +31,19 @@ export async function installAndroidArtifact(
2931
return installed.length === 1 ? installed[0] : undefined;
3032
}
3133

34+
export function assertManagedAndroidInstallablePath(installablePath: string): void {
35+
if (currentManagedDeviceScope() && path.extname(installablePath).toLowerCase() === '.aab') {
36+
throw new AppError(
37+
'UNSUPPORTED_OPERATION',
38+
'Managed Android deployment does not support app bundles.',
39+
{
40+
reason: 'managed-bundle-install-unavailable',
41+
hint: 'Install an APK on this managed device.',
42+
},
43+
);
44+
}
45+
}
46+
3247
export async function uninstallAndroidPackage(
3348
host: PlatformRuntimeHost,
3449
device: DeviceInfo,

packages/platform-android/src/deployment/runtime.ts

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,21 @@ import type {
44
AppDeploymentRuntimeOperations,
55
DeployMaterializedAppInput,
66
MaterializeAppSourceInput,
7+
MaterializedAppSource,
78
PushNotificationInput,
89
} from '@agent-device/contracts/app-deployment-runtime';
910
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
1011
import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime';
1112
import type { DeviceInfo } from '@agent-device/kernel/device';
1213
import { AppError } from '@agent-device/kernel/errors';
14+
import { currentManagedDeviceScope } from '@agent-device/provision-kit/managed-device-scope';
1315
import { ensureAndroidReady } from '../readiness/runtime.ts';
1416
import {
1517
inferAndroidAppName,
1618
installAndroidArtifact,
1719
pushAndroidNotification,
1820
uninstallAndroidPackage,
21+
assertManagedAndroidInstallablePath,
1922
} from './native.ts';
2023

2124
const available = Object.freeze({ available: true } as const);
@@ -66,36 +69,51 @@ async function deployAndroidApp(
6669
// cache boundary here in the Android deployment owner: clear before boot/resolve/uninstall
6770
// and after materialization/install, including partial failures.
6871
return await host.androidDeployment.withInvalidatedAppResolutionCache(device, async () => {
69-
if (device.booted !== true) {
70-
await ensureAndroidReady(host, device, { headless: false }, signal);
71-
}
72-
const packageName = await host.androidDeployment.resolveAppPackage(device, input.app);
73-
await uninstallAndroidPackage(host, device, packageName, signal);
74-
const artifact = await host.androidDeployment.prepareArtifact(
75-
{ source: { kind: 'path', path: input.appPath } },
76-
{ resolveIdentity: false, signal },
77-
);
72+
let artifact: MaterializedAppSource | undefined;
7873
try {
74+
if (currentManagedDeviceScope()) {
75+
artifact = await host.androidDeployment.prepareArtifact(
76+
{ source: { kind: 'path', path: input.appPath } },
77+
{ resolveIdentity: false, signal },
78+
);
79+
assertManagedAndroidInstallablePath(artifact.installablePath);
80+
}
81+
if (device.booted !== true) {
82+
await ensureAndroidReady(host, device, { headless: false }, signal);
83+
}
84+
const packageName = await host.androidDeployment.resolveAppPackage(device, input.app);
85+
await uninstallAndroidPackage(host, device, packageName, signal);
86+
artifact ??= await host.androidDeployment.prepareArtifact(
87+
{ source: { kind: 'path', path: input.appPath } },
88+
{ resolveIdentity: false, signal },
89+
);
7990
await installAndroidArtifact(host, device, artifact.installablePath, undefined, signal);
8091
return { packageName, launchTarget: packageName };
8192
} finally {
82-
await artifact.cleanup();
93+
await artifact?.cleanup();
8394
}
8495
});
8596
}
8697

8798
// Existing install semantics wait for a selected Android target before taking the
8899
// before-install package inventory. Keep that platform rule here, rather than
89100
// reviving a daemon readiness adapter.
90-
if (device.booted !== true) {
91-
await ensureAndroidReady(host, device, { headless: false }, signal);
92-
}
93-
94-
const artifact = await host.androidDeployment.prepareArtifact(
95-
{ source: { kind: 'path', path: input.appPath } },
96-
{ resolveIdentity: true, signal },
97-
);
101+
let artifact: MaterializedAppSource | undefined;
98102
try {
103+
if (currentManagedDeviceScope()) {
104+
artifact = await host.androidDeployment.prepareArtifact(
105+
{ source: { kind: 'path', path: input.appPath } },
106+
{ resolveIdentity: true, signal },
107+
);
108+
assertManagedAndroidInstallablePath(artifact.installablePath);
109+
}
110+
if (device.booted !== true) {
111+
await ensureAndroidReady(host, device, { headless: false }, signal);
112+
}
113+
artifact ??= await host.androidDeployment.prepareArtifact(
114+
{ source: { kind: 'path', path: input.appPath } },
115+
{ resolveIdentity: true, signal },
116+
);
99117
const packageName = await installAndroidArtifact(
100118
host,
101119
device,
@@ -113,7 +131,7 @@ async function deployAndroidApp(
113131
launchTarget: packageName,
114132
};
115133
} finally {
116-
await artifact.cleanup();
134+
await artifact?.cleanup();
117135
}
118136
}
119137

packages/platform-android/src/readiness/runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type AndroidReadinessHost = Pick<
88
>;
99
import type { DeviceInfo } from '@agent-device/kernel/device';
1010
import { AppError } from '@agent-device/kernel/errors';
11+
import { delegateManagedDeviceReadiness } from '@agent-device/provision-kit/managed-device-scope';
1112

1213
const BOOT_TIMEOUT_MS = 120_000;
1314
const POLL_MS = 1_000;
@@ -19,6 +20,7 @@ export async function ensureAndroidReady(
1920
signal: AbortSignal,
2021
): Promise<DeviceInfo> {
2122
signal.throwIfAborted();
23+
if (await delegateManagedDeviceReadiness(device)) return { ...device, booted: true };
2224
if (device.kind === 'emulator' && (device.booted !== true || !isRunningEmulator(device))) {
2325
return await ensureEmulatorReady(host, device, input, signal);
2426
}

packages/platform-apple/src/core/simulator.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Deadline, retryWithPolicy } from '@agent-device/host-kit/retry';
55

66
import { createTtlMemo } from '@agent-device/kernel/ttl-memo';
77
import { bootFailureHint, classifyBootFailure } from '@agent-device/provision-kit/boot-diagnostics';
8+
import { createScopedProvider } from '@agent-device/kernel/scoped-provider';
89

910
import {
1011
IOS_BOOT_TIMEOUT_MS,
@@ -17,6 +18,17 @@ import { runAppleToolCommand, runXcrun } from './tool-provider.ts';
1718
const IOS_SIMULATOR_HOST_APPS = ['Simulator'] as const;
1819
const IOS_DEVICE_HUB_HOST_APPS = ['Device Hub', 'Simulator'] as const;
1920

21+
const simulatorReadiness = createScopedProvider<
22+
((device: DeviceInfo) => Promise<void>) | undefined
23+
>(undefined);
24+
25+
export async function withSimulatorReadiness<T>(
26+
ensureReady: (device: DeviceInfo) => Promise<void>,
27+
task: () => Promise<T>,
28+
): Promise<T> {
29+
return await simulatorReadiness.run(ensureReady, task);
30+
}
31+
2032
type OpenIosSimulatorAppOptions = {
2133
background?: boolean;
2234
deviceHub?: boolean;
@@ -85,6 +97,12 @@ export async function ensureBootedSimulator(
8597
): Promise<void> {
8698
if (device.kind !== 'simulator') return;
8799
options.signal?.throwIfAborted();
100+
const ensureReady = simulatorReadiness.resolve();
101+
if (ensureReady) {
102+
await ensureReady(device);
103+
options.signal?.throwIfAborted();
104+
return;
105+
}
88106

89107
const state = wasSimulatorRecentlyObservedBooted(device)
90108
? 'Booted'

packages/platform-apple/src/readiness/runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
22
import { isMacOs, type DeviceInfo } from '@agent-device/kernel/device';
33
import { AppError } from '@agent-device/kernel/errors';
4+
import { delegateManagedDeviceReadiness } from '@agent-device/provision-kit/managed-device-scope';
45
import { getSimulatorState, simctlArgs } from '../simulator-state.ts';
56

67
/** Readiness reads exactly these host ports; the lifecycle binding composes the same subset. */
@@ -27,6 +28,7 @@ export async function ensureAppleReady(
2728
options: AppleReadinessOptions = {},
2829
): Promise<DeviceInfo> {
2930
signal.throwIfAborted();
31+
if (await delegateManagedDeviceReadiness(device)) return { ...device, booted: true };
3032
if (isMacOs(device)) return { ...device, booted: true };
3133
if (device.kind === 'device') {
3234
await host.deviceReadiness.applePhysical.ensureConnected(device, signal);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { expect, test, vi } from 'vitest';
2+
import { managedLocalRuntimeOwner } from '@agent-device/contracts/platform-runtime';
3+
import { withManagedDeviceScope } from '@agent-device/provision-kit/managed-device-scope';
4+
import { IOS_SIMULATOR } from './__tests__/device-fixtures.ts';
5+
import { ensureBootedSimulator } from './core/simulator.ts';
6+
import { createLocalAppleToolProvider, withAppleToolProvider } from './core/tool-provider.ts';
7+
import { bindSimulatorReadiness } from './runtime-simulator-readiness.ts';
8+
9+
test('ordinary simulator operations preserve their binding without a readiness override', () => {
10+
const operations = { ensureBootedSimulator };
11+
expect(bindSimulatorReadiness(operations)).toBe(operations);
12+
});
13+
14+
test('bound simulator readiness captures authority and refuses mismatches or failures before local boot', async () => {
15+
await withAppleToolProvider(
16+
createLocalAppleToolProvider({
17+
runCommand: async () => {
18+
throw new Error('Unexpected local readiness');
19+
},
20+
}),
21+
async () => {
22+
const device = { ...IOS_SIMULATOR, simulatorSetPath: '/managed/set' };
23+
const admit = vi.fn(async (): Promise<never> => {
24+
throw new Error('Deep readiness must not recursively admit');
25+
});
26+
const operations = await withManagedDeviceScope(
27+
{
28+
device,
29+
owner: managedLocalRuntimeOwner('allocator'),
30+
fence: { token: 'fence', generation: 1 },
31+
admit,
32+
run: async <T>(task: () => Promise<T>) => await task(),
33+
},
34+
async () => bindSimulatorReadiness(Object.freeze({ ensureBootedSimulator })),
35+
);
36+
await operations.ensureBootedSimulator(device);
37+
expect(admit).not.toHaveBeenCalled();
38+
await expect(
39+
operations.ensureBootedSimulator({ ...device, simulatorSetPath: undefined }),
40+
).rejects.toMatchObject({ details: { reason: 'managed-device-transport-mismatch' } });
41+
expect(admit).not.toHaveBeenCalled();
42+
const abort = new AbortController();
43+
abort.abort(new Error('cancelled'));
44+
await expect(
45+
operations.ensureBootedSimulator(device, { signal: abort.signal }),
46+
).rejects.toThrow('cancelled');
47+
expect(admit).not.toHaveBeenCalled();
48+
},
49+
);
50+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { withMethodScope } from '@agent-device/kernel/scoped-provider';
2+
import { resolveManagedDeviceReadiness } from '@agent-device/provision-kit/managed-device-scope';
3+
import { withSimulatorReadiness } from './core/simulator.ts';
4+
5+
export function bindSimulatorReadiness<T extends object>(operations: T): Readonly<T> {
6+
const ensureReady = resolveManagedDeviceReadiness();
7+
if (!ensureReady) return Object.freeze(operations);
8+
return Object.freeze({
9+
...withMethodScope({ ...operations }, (task) => withSimulatorReadiness(ensureReady, task)),
10+
});
11+
}

packages/platform-apple/src/runtime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
localRuntimeOwner,
55
whenAdmitted,
66
} from '@agent-device/contracts/platform-runtime';
7+
import { bindSimulatorReadiness } from './runtime-simulator-readiness.ts';
78
import type { NetworkDumpInput } from '@agent-device/contracts/network-runtime';
89
import type {
910
PlatformRuntimeHost,
@@ -494,7 +495,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR
494495
device: logs.device,
495496
owner,
496497
facts,
497-
operations: Object.freeze(operations),
498+
operations: bindSimulatorReadiness(operations),
498499
[Symbol.asyncDispose]: async () => await logs[Symbol.asyncDispose](),
499500
}) satisfies DeviceBinding<PlatformRuntimeOperations>;
500501
},

packages/provision-kit/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
"@agent-device/kernel": "workspace:*"
1111
},
1212
"exports": {
13+
"./managed-device-scope": {
14+
"types": "./src/managed-device-scope.ts",
15+
"default": "./src/managed-device-scope.ts"
16+
},
1317
"./app-resolution-cache": {
1418
"types": "./src/app-resolution-cache.ts",
1519
"default": "./src/app-resolution-cache.ts"
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { expect, test } from 'vitest';
2+
import { managedLocalRuntimeOwner } from '@agent-device/contracts/platform-runtime';
3+
import {
4+
currentManagedDeviceScope,
5+
delegateManagedDeviceReadiness,
6+
withManagedDeviceScope,
7+
} from './managed-device-scope.ts';
8+
9+
test('managed readiness scopes isolate concurrent devices and leave ordinary readiness untouched', async () => {
10+
const ready: string[] = [];
11+
const device = {
12+
platform: 'android' as const,
13+
kind: 'emulator' as const,
14+
id: 'one',
15+
name: 'one',
16+
};
17+
const managed = {
18+
device,
19+
owner: managedLocalRuntimeOwner('allocator'),
20+
fence: { token: 'fence', generation: 1 },
21+
admit: async <T>(_task: () => Promise<T>): Promise<T> => {
22+
throw new Error('Readiness must not recursively admit');
23+
},
24+
run: async <T>(task: () => Promise<T>) => await task(),
25+
};
26+
await Promise.all(
27+
['one', 'two'].map(async (id) => {
28+
const selected = { ...device, id };
29+
await withManagedDeviceScope(
30+
{
31+
...managed,
32+
device: selected,
33+
},
34+
async () => {
35+
await Promise.resolve();
36+
expect(await delegateManagedDeviceReadiness(selected)).toBe(true);
37+
ready.push(id);
38+
await expect(
39+
delegateManagedDeviceReadiness({ ...selected, id: 'foreign' }),
40+
).rejects.toMatchObject({ details: { reason: 'managed-device-transport-mismatch' } });
41+
},
42+
);
43+
}),
44+
);
45+
expect(ready.sort()).toEqual(['one', 'two']);
46+
expect(currentManagedDeviceScope()).toBeUndefined();
47+
expect(await delegateManagedDeviceReadiness(device)).toBe(false);
48+
});

0 commit comments

Comments
 (0)