Skip to content
14 changes: 14 additions & 0 deletions packages/command-registry/src/flag-definitions-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ export const WORKFLOW_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
projectConfig: true,
recorded: false,
},
{
key: 'waitMs',
names: ['--wait'],
type: 'int',
min: 100,
max: 120000,
usageLabel: '--wait <ms>',
usageDescription:
'Open: wait up to n ms for a device another session is holding before failing with DEVICE_IN_USE',
inputDescription:
"Block this open for up to n milliseconds (100-120000) while another session holds the device, then fail with DEVICE_IN_USE naming that session. Only session contention is waited for: a device claim held by another workspace is refused at once with its recovery command. A device that never frees, or is taken again while this open waits, costs the full budget, which extends this command's timeout envelope rather than eating into it.",
projectConfig: false,
recorded: false,
},
{
key: 'retries',
names: ['--retries'],
Expand Down
25 changes: 21 additions & 4 deletions packages/command-registry/src/timeout-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ type BoundedTimeoutPolicy = CommandTimeoutPolicy & { envelopeMs: number };
type FlagTimeoutBudget = Extract<CommandTimeoutBudget, { source: 'flag' }>;
type RequestTimeoutInput = Readonly<{
positionals?: string[];
flags?: Readonly<{ timeoutMs?: number; settle?: boolean }>;
flags?: Readonly<{ timeoutMs?: number; settle?: boolean; waitMs?: number }>;
}>;

/** Resolves the request envelope from its declared policy and user-supplied budget. */
Expand Down Expand Up @@ -81,13 +81,30 @@ function resolveFlagBudgetTimeoutMs(
return resolveWideningFlagBudget(policy, policy.budget, flags);
}
if (policy.budget.envelope === 'margin') {
return typeof flags?.timeoutMs === 'number'
? widenToUserBudget(policy, flags.timeoutMs)
: policy.envelopeMs;
return resolveMarginEnvelopeTimeoutBudget(policy, flags);
}
return typeof flags?.timeoutMs === 'number' ? flags.timeoutMs : policy.envelopeMs;
}

/**
* The `--timeout` budget, widened again for a `--wait` budget. A wait is work the command does
* before its own startup budget can even begin — the device is still somebody else's — so it
* extends the envelope rather than eating into it. With no wait this is exactly the margin
* envelope `--timeout` has always produced.
*/
function resolveMarginEnvelopeTimeoutBudget(
policy: BoundedTimeoutPolicy,
flags: RequestTimeoutInput['flags'],
): number {
const startupEnvelopeMs =
typeof flags?.timeoutMs === 'number'
? widenToUserBudget(policy, flags.timeoutMs)
: policy.envelopeMs;
return typeof flags?.waitMs === 'number'
? widenPastBaseEnvelope({ ...policy, envelopeMs: startupEnvelopeMs }, flags.waitMs)
: startupEnvelopeMs;
}

function resolveWideningFlagBudget(
policy: BoundedTimeoutPolicy,
budget: FlagTimeoutBudget,
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export type CliFlags = CloudProviderProfileFields &
record?: boolean;
retainPaths?: boolean;
retentionMs?: number;
waitMs?: number;
replayMaestro?: boolean;
reporter?: string[];
reportJunit?: string;
Expand Down
8 changes: 8 additions & 0 deletions packages/contracts/src/client-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,14 @@ export type AppOpenOptions = AgentDeviceRequestOverrides &
relaunch?: boolean;
/** Startup budget in milliseconds: bounds the Simulator boot wait on a cold device. */
timeoutMs?: number;
/**
* Block this open for up to n milliseconds (100-120000) while another session holds the device,
* then fail with DEVICE_IN_USE naming that session. Only session contention is waited for: a
* device claim held by another workspace is refused at once with its recovery command. A device
* that never frees, or is taken again while this open waits, costs the full budget, which
* extends this command's timeout envelope rather than eating into it.
*/
waitMs?: number;
// Editor documentation for a public type: a `.d.ts` is read where no
// FlagDefinition resolves, and nothing generates these docs. It is not a second
// statement of what the option does — it is the option's ONE declaration (the
Expand Down
29 changes: 29 additions & 0 deletions packages/platform-apple/src/inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,35 @@ test('Apple inventory rejects unsupported hosts and missing tools with typed err
);
});

test('the non-macOS refusal names what this host can drive instead', async () => {
const refusal = await createAppleInventorySource(createInventoryHost({ hostOs: 'linux' }))
.discover({ platform: 'ios' }, inventoryScope)
.catch((error: unknown) => error);

assert.ok(refusal instanceof AppError);
assert.equal(refusal.code, 'UNSUPPORTED_PLATFORM');
assert.equal(refusal.details?.supportedOn, 'macOS');
assert.equal(refusal.details?.hostOs, 'linux');
assert.equal(refusal.details?.retriable, false);
const hint = String(refusal.details?.hint);
assert.match(hint, /runs Linux/);
assert.match(hint, /agent-device devices --platform android/);
assert.match(hint, /agent-device open <app\.apk> --platform android/);
assert.match(hint, /--daemon-base-url/);
});

// The label is what makes the refusal readable, and an unmapped host must still refuse
// rather than render `undefined`.
test('every host operating system renders a readable refusal', async () => {
for (const hostOs of ['win32', 'other'] as const) {
const refusal = await createAppleInventorySource(createInventoryHost({ hostOs }))
.discover({ platform: 'ios' }, inventoryScope)
.catch((error: unknown) => error);
assert.ok(refusal instanceof AppError);
assert.match(String(refusal.details?.hint), /runs (Windows|a non-macOS host),/);
}
});

test('simulator-only inventory avoids physical discovery and observes fresh booted devices', async () => {
const calls: string[][] = [];
const observed: string[] = [];
Expand Down
30 changes: 29 additions & 1 deletion packages/platform-apple/src/inventory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { DeviceInventoryRequest } from '@agent-device/contracts/device';
import type {
DeviceInventoryHostFor,
HostOperatingSystem,
PlatformRequestScope,
} from '@agent-device/contracts/platform-runtime-host';
import type { DeviceInventorySource } from '@agent-device/contracts/platform-module';
Expand All @@ -26,7 +27,7 @@ async function discoverAppleDevices(
return [hostMacDevice(host)];
}
if (host.hostOs !== 'darwin') {
throw new AppError('UNSUPPORTED_PLATFORM', 'Apple tools are only available on macOS');
throw appleHostUnsupportedError(host.hostOs);
}
if (!(await host.appleTools.isXcrunAvailable(scope.signal))) {
throw new AppError('TOOL_MISSING', 'xcrun not found in PATH');
Expand Down Expand Up @@ -59,6 +60,33 @@ function isMacOnlyRequest(request: Readonly<DeviceInventoryRequest>): boolean {
);
}

const HOST_OS_LABELS: Record<HostOperatingSystem, string> = {
darwin: 'macOS',
linux: 'Linux',
win32: 'Windows',
other: 'a non-macOS host',
};

/**
* The refusal a non-macOS host gets for Apple sessions. Naming only the requirement left
* Linux and Windows callers at a dead end — the host OS never changes for a running
* daemon, so there is nothing to retry and nothing to install here. The hint carries the
* Android shape of the same request, which is what this host can drive, and the one way
* to reach a real iOS Simulator from here: a daemon running on a Mac.
*/
function appleHostUnsupportedError(hostOs: HostOperatingSystem): AppError {
return new AppError('UNSUPPORTED_PLATFORM', 'Apple tools are only available on macOS', {
supportedOn: 'macOS',
hostOs,
retriable: false,
hint:
`This host runs ${HOST_OS_LABELS[hostOs]}, so it cannot boot iOS Simulators or run macOS sessions. ` +
'Drive Android here instead with the same command shape: agent-device devices --platform android to list ' +
'targets, then agent-device open <app.apk> --platform android. To use an iOS Simulator from this machine, ' +
'run agent-device on a macOS host and pass --daemon-base-url <that daemon URL>.',
});
}

function hostMacDevice(host: DeviceInventoryHostFor<'apple'>): DeviceInfo {
return {
platform: 'apple',
Expand Down
8 changes: 8 additions & 0 deletions scripts/integration-progress-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,14 @@ function summarizeProviderScenarioFlagExclusions() {
owner: 'daemon session-open-foreground lifecycle unit tests',
keys: ['foreground'],
},
{
// Contention is this daemon's session store and this host's device-claim files. A
// provider scenario allocates a device of its own, so no provider-backed flow can be
// refused as busy and there is nothing for a scenario lane to observe.
name: 'local device contention waiting',
owner: 'daemon session-open contention unit tests',
keys: ['waitMs'],
},
{
name: 'Apple simulator screenshot rendering options',
owner: 'iOS platform and screenshot-diff runtime tests',
Expand Down
34 changes: 34 additions & 0 deletions src/__tests__/command-descriptor-timeout-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,4 +364,38 @@ test('open and prepare startup budgets keep a client-envelope margin over the da
resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('open'), { ...base }),
90_000,
);

// A --wait budget is spent before the startup budget can begin, so it extends the envelope
// instead of being consumed by it; otherwise a 60s wait would leave a cold boot 30s of envelope
// and end in a client-side daemon reset rather than the open's own refusal.
assert.equal(
resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('open'), {
...base,
flags: { waitMs: 60_000 },
}),
180_000,
);
assert.equal(
resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('open'), {
...base,
flags: { timeoutMs: 600_000, waitMs: 60_000 },
}),
720_000,
);
// Even a small wait keeps the margin over the base, so the open's own refusal wins the race.
assert.equal(
resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('open'), {
...base,
flags: { waitMs: 1_000 },
}),
121_000,
);
// Only `open` reads a wait budget; other commands' envelopes ignore it.
assert.equal(
resolveCommandRequestTimeoutMs(resolveCommandTimeoutPolicy('press'), {
...base,
flags: { waitMs: 60_000 },
}),
90_000,
);
});
70 changes: 68 additions & 2 deletions src/cli/parser/__tests__/command-suggestions.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { isKnownCliCommandName } from '@agent-device/command-registry/catalog';
import { isKnownCliCommandName, listCliCommandNames } from '@agent-device/command-registry/catalog';
import { keyboardCliReader } from '../../../commands/system/index.ts';
import { AppError } from '@agent-device/kernel/errors';
import { parseArgs } from '../args.ts';
import type { CliFlags } from '@agent-device/contracts/command';
import { listCommandAliasSuggestionEntries, suggestCommandFor } from '../command-suggestions.ts';
import { getFlagDefinitions } from '../../../cli-schema/command-schema.ts';
import { isFlagSupportedForCommand } from '../../../cli-schema/option-schema.ts';
import {
listCommandAliasSuggestionEntries,
listFlagNameSuggestionEntries,
suggestCommandFor,
suggestFlagFor,
} from '../command-suggestions.ts';

// Guards against the curated alias map drifting to a command that no longer
// exists (renamed, removed, gated) in the live command registry.
Expand Down Expand Up @@ -222,3 +229,62 @@ test('unrelated unknown flags are unaffected', () => {
error.message === 'Unknown flag: --not-a-real-flag',
);
});

// Guards against the curated flag map drifting to a flag that no longer exists, or to
// one no command accepts — a suggestion the parser would then refuse itself.
test('every curated flag suggestion names a registered flag at least one command accepts', () => {
const commands = listCliCommandNames();
for (const [guess, canonical] of listFlagNameSuggestionEntries()) {
const definition = getFlagDefinitions().find((entry) => entry.names.includes(canonical));
assert.ok(definition, `flag suggestion for "${guess}" names unregistered flag "${canonical}"`);
assert.ok(
definition && commands.some((command) => isFlagSupportedForCommand(definition.key, command)),
`flag suggestion "${canonical}" for "${guess}" is accepted by no command`,
);
}
});

for (const guess of ['--output', '--path', '--Output']) {
test(`a guessed output-path flag on screenshot names ${guess} -> --out`, () => {
assert.throws(
() => parseArgs(['screenshot', guess, './shot.png']),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message === `Unknown flag: ${guess}. Did you mean --out?`,
);
});
}

// The closest negative: `--path` means nothing to `trace`, whose path is positional and
// which accepts no `--out` at all. A suggestion map applied without the per-command gate
// would push a flag this command refuses.
test('a guessed flag the command does not accept is not pushed at it', () => {
assert.throws(
() => parseArgs(['trace', 'start', '--path', './trace.log']),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message === 'Unknown flag: --path',
);
});

// The registry-derived fallback, not the curated map: `--pixel-densityy` is one edit from
// a flag screenshot accepts, and the candidate list comes from that command's own surface.
test('a mistyped flag the command accepts is suggested by nearest name', () => {
assert.throws(
() => parseArgs(['screenshot', '--pixel-densityy', '2']),
(error: unknown) =>
error instanceof AppError &&
error.code === 'INVALID_ARGS' &&
error.message === 'Unknown flag: --pixel-densityy. Did you mean --pixel-density?',
);
});

test('suggestFlagFor never throws for arbitrary input', () => {
for (const input of ['', '-', '--', '@#$%', '--a'.repeat(200), '--JSON']) {
for (const command of ['screenshot', 'open', null]) {
assert.doesNotThrow(() => suggestFlagFor(input, command));
}
}
});
2 changes: 1 addition & 1 deletion src/cli/parser/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export function parseRawArgs(argv: string[]): RawParsedArgs {
else positionals.push(arg);
continue;
}
throw new AppError('INVALID_ARGS', formatUnknownFlagMessage(token));
throw new AppError('INVALID_ARGS', formatUnknownFlagMessage(token, command));
}

const parsed = parseFlagValue(definition, token, inlineValue, argv[i + 1]);
Expand Down
Loading
Loading