Skip to content
Open
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
10 changes: 10 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ on:
- 'scripts/__tests__/check-doc-links.test.ts'
- 'scripts/check-doc-prose.ts'
- 'scripts/__tests__/check-doc-prose.test.ts'
- 'scripts/capability-matrix/**'
Comment thread
thymikee marked this conversation as resolved.
- 'test/repo-guards/capability-matrix.test.ts'
Comment thread
thymikee marked this conversation as resolved.
- 'scripts/check-affected/**'
- 'package.json'
- 'pnpm-lock.yaml'
Expand All @@ -32,6 +34,8 @@ on:
- 'scripts/__tests__/check-doc-links.test.ts'
- 'scripts/check-doc-prose.ts'
- 'scripts/__tests__/check-doc-prose.test.ts'
- 'scripts/capability-matrix/**'
- 'test/repo-guards/capability-matrix.test.ts'
- 'scripts/check-affected/**'
- 'package.json'
- 'pnpm-lock.yaml'
Expand Down Expand Up @@ -64,6 +68,12 @@ jobs:
uses: ./.github/actions/run-gate
with: { gate: docs-prose }

# The page's cells are generated from runtime facts; the guard regenerates,
# diffs, and proves its own negative cases before anything reads the page.
- name: Check the capabilities page against runtime facts
uses: ./.github/actions/run-gate
with: { gate: capabilities-matrix }

# Builds the site (root install covers the website workspace), then checks it.
- name: Check docs links
uses: ./.github/actions/run-gate
Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -248,11 +248,14 @@ jobs:

- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm
with:
install-deps: 'false'

# main must never carry a published version. Retried because merges can
# land on main while the release runs.
#
# The capabilities page stamps the version it covers and the drift check
# compares that stamp to package.json, so the version bump restamps the
# page in the same commit. The collector reads runtime sources and needs
# no device.
- name: Commit the next development version
env:
VERSION: ${{ needs.plan.outputs.version }}
Expand All @@ -264,6 +267,7 @@ jobs:
git fetch origin main
git reset --hard origin/main
next="$(node scripts/release-version.mjs advance "$VERSION")"
pnpm generate:capabilities
if git diff --quiet; then
echo "main already builds towards $next."
exit 0
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ The same session and evidence model works at every step: the agent explores the

`agent-device` keeps device state in sessions. It uses a local accessibility bridge for iOS Simulator snapshots and XCTest for iOS interactions, physical iOS, and tvOS; ADB and the snapshot helper on Android, Android TV, and Fire OS; HDC and ArkUI `uitest` on HarmonyOS; Vega CLI/VDA on the Vega Virtual Device; a local helper on macOS, including Electron and other Chromium-based apps through the accessibility tree; and AT-SPI on Linux.

Support depth varies by target. Some backends, such as HarmonyOS and Vega OS, cover a subset of commands; run `agent-device capabilities --platform <platform>` to see what a target supports.
Support depth varies by target. Some backends, such as HarmonyOS and Vega OS, cover a subset of commands; run `agent-device capabilities --platform <platform>` to see what a target supports. The [capabilities matrix](https://oss.callstack.com/agent-device/docs/capabilities) shows which commands each target, desktop, web, and cloud provider supports.

Sessions are scoped to the caller's git worktree, and host-local device claims stop parallel agents from taking over each other's simulators and emulators. Run `agent-device device status` to inspect ownership without a daemon, and `agent-device device release --stale` to release claims whose owner is provably no longer running. The same commands drive hosted devices on [BrowserStack, AWS Device Farm, TestMu AI, and Limrun](https://oss.callstack.com/agent-device/docs/device-clouds).

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@
"check:bundle-owner-files": "node --experimental-strip-types scripts/check-bundle-owner-files.ts",
"check:package": "node --experimental-strip-types scripts/check-package.ts",
"check:command-docs": "vitest run --project unit-core test/repo-guards/command-doc-coverage.test.ts",
"check:capabilities-matrix": "vitest run --project unit-core test/repo-guards/capability-matrix.test.ts",
"generate:capabilities": "node --experimental-strip-types scripts/capability-matrix/generate.ts --write",
"check:doc-links": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/__tests__/check-doc-links.test.ts && pnpm --dir website build && node --experimental-strip-types scripts/check-doc-links.ts",
"check:doc-prose": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/__tests__/check-doc-prose.test.ts && node --experimental-strip-types scripts/check-doc-prose.ts",
"check:agent-guidance": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/__tests__/agent-guidance-contract.test.ts",
Expand Down
42 changes: 41 additions & 1 deletion packages/command-registry/src/planned-operations.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { RuntimeFacts, RuntimeOperationFact } from '@agent-device/contracts/platform-runtime';
import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations';
import type { RuntimeUseStep } from '@agent-device/contracts/command-platform-execution';
import {
isRuntimeOperationName,
type RuntimeOperationName,
} from '@agent-device/contracts/runtime-operation-names';
import { commandDescriptors } from './registry.ts';
import { commandRuntimeUseRequirements, commandDescriptors } from './registry.ts';
import type { CommandDescriptor } from './types.ts';

const descriptorsByName = new Map<string, CommandDescriptor>(
Expand Down Expand Up @@ -48,3 +50,41 @@ function requiredOperationsOf(step: PlannedStep): readonly PlannedRuntimeOperati
// operations union drifted; treat the plan as unproven rather than guess.
return required.every(isRuntimeOperationName) ? required : undefined;
}

/**
* The one answer to "can this device run this command," read from the device's
* runtime facts. The `capabilities` inventory and every offline reader of those
* facts (the capabilities-page generator) decide through here, so a generated
* page and a live session cannot disagree about the same device.
*
* One fully admitted use is enough, because a request names exactly one action.
* An empty `required` set is not one admitted use: `[].every` is vacuously true,
* and a plan that needs no operation must not read as proof that the device can
* run the command. Descriptor execution metadata is structurally validated as
* string arrays, not as keys of the concrete facts catalog, so an operation the
* inspected owner does not state keeps the projection fail-closed instead of
* throwing out of the whole response. A command that is no device-runtime
* command, and a device that states no facts, are never available.
*/
export function commandIsAvailableFromFacts(
command: string,
facts: RuntimeFacts<PlatformRuntimeOperations> | undefined,
): boolean {
const declaredUses = commandRuntimeUseRequirements(command);
if (!declaredUses || !facts) return false;
return declaredUses.some(
(required) =>
required.length > 0 && required.every((operation) => hasAvailableFact(facts, operation)),
);
}

function hasAvailableFact(
facts: RuntimeFacts<PlatformRuntimeOperations>,
operation: string,
): boolean {
if (!Object.hasOwn(facts.operations, operation)) return false;
return (
(facts.operations as Readonly<Record<string, RuntimeOperationFact>>)[operation]?.available ===
true
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ import type { HostEnvironment } from '@agent-device/kernel/source-value';
import { resolveSessionRunnerLogPath } from '@agent-device/daemon-contracts/session-artifact-paths';
import type { DaemonResponse } from '@agent-device/kernel/contracts';
import type { DaemonPlatformServices } from '../../../core/platform-services.ts';
import {
commandRuntimeUseRequirements,
listRuntimeFactCommands,
} from '@agent-device/command-registry/registry';
import { listRuntimeFactCommands } from '@agent-device/command-registry/registry';
import { commandIsAvailableFromFacts } from '@agent-device/command-registry/planned-operations';
import { listDeviceInventory } from '@agent-device/device-selection/device-inventory-context';
import {
assertResolvedAppsFilter,
Expand Down Expand Up @@ -45,11 +43,7 @@ import {
selectorTargetsSessionDevice,
} from '../../../resources/session-device-resolution.ts';
import { resolveSessionScope, sessionMatchesInventoryScope } from '../../session-routing.ts';
import type {
BoundDeviceRuntime,
RuntimeFacts,
RuntimeOperationFact,
} from '@agent-device/contracts/platform-runtime';
import type { BoundDeviceRuntime, RuntimeFacts } from '@agent-device/contracts/platform-runtime';
import {
type PlatformRuntimeOperations,
appsRuntimeUse,
Expand Down Expand Up @@ -295,14 +289,14 @@ async function capabilitiesInventoryResponse(params: {
ok: true,
data: {
device: publicDeviceInfo(device),
...(device.platform === 'android' && factOwnedCapabilityAvailable('press', facts)
...(device.platform === 'android' && commandIsAvailableFromFacts('press', facts)
? { targetReadiness: { press: 'bounded-geometry' } }
: {}),
availableCommands: listRuntimeFactCommands().filter((command) => {
// A session that already owns an app identity answers appstate itself, so it stays
// available even when the sessionless runtime probe cannot see a foreground app.
if (command === 'appstate' && sessionOwnedAppStateAvailable) return true;
return factOwnedCapabilityAvailable(command, facts);
return commandIsAvailableFromFacts(command, facts);
}),
},
};
Expand Down Expand Up @@ -335,39 +329,6 @@ function hasMacSessionSurface(
);
}

function factOwnedCapabilityAvailable(
command: string,
facts: RuntimeFacts<PlatformRuntimeOperations> | undefined,
): boolean {
const declaredUses = commandRuntimeUseRequirements(command);
if (!declaredUses) return false;
if (!facts) return false;
// A request names exactly one action, so one fully admitted use is enough. An empty `required`
// is not one: `[].every` is vacuously true, and a plan that needs no operation must not read as
// proof that the device can run the command.
return declaredUses.some(
(required) =>
required.length > 0 && required.every((operation) => hasAvailableFact(facts, operation)),
);
}

/**
* Descriptor execution metadata is structurally validated as string arrays, not as keys of the
* concrete facts catalog. Keep the projection fail-closed when a descriptor names an operation the
* inspected owner does not state, instead of letting an arbitrary property lookup throw out of the
* whole `capabilities` response.
*/
function hasAvailableFact(
facts: RuntimeFacts<PlatformRuntimeOperations>,
operation: string,
): boolean {
if (!Object.hasOwn(facts.operations, operation)) return false;
return (
(facts.operations as Readonly<Record<string, RuntimeOperationFact>>)[operation]?.available ===
true
);
}

async function handleAppsInventory(params: {
env: HostEnvironment['env'];
req: DaemonRequest;
Expand Down
Loading
Loading