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
1 change: 1 addition & 0 deletions scripts/integration-progress-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ function summarizeProviderScenarioFlagExclusions() {
'stepsFile',
'proxyHost',
'proxyPort',
'stale',
],
},
{
Expand Down
125 changes: 124 additions & 1 deletion src/__tests__/cli-device-status.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,134 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'vitest';
import { readCurrentOwnerIdentity } from '../utils/owner-identity.ts';
import { runCliCapture } from './cli-capture.ts';

test('device status is daemonless and does not send a daemon request', async () => {
const result = await runCliCapture(['device', 'status', '--json']);
assert.equal(result.code, null);
assert.equal(result.calls.length, 0);
const payload = JSON.parse(result.stdout);
assert.deepEqual(payload, { success: true, data: { claims: [] } });
assert.deepEqual(payload, { success: true, data: { claims: [], hiddenStaleClaims: 0 } });
});

test('keeps normal status compact while retaining proven-stale claims for explicit inspection', async () => {
const claimsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-claims-'));
const owner = readCurrentOwnerIdentity();
try {
fs.writeFileSync(
path.join(claimsDir, 'live.json'),
JSON.stringify({
schemaVersion: 1,
deviceKey: 'local:android:none:live',
device: { platform: 'android', id: 'live', name: 'Live Pixel', kind: 'emulator' },
session: 'live-session',
workspace: '/worktrees/live',
stateDir: process.cwd(),
ownerPid: owner.pid,
ownerStartTime: owner.startTime,
ownerToken: 'live-token',
createdAtMs: 1,
updatedAtMs: 1,
}),
);
fs.writeFileSync(
path.join(claimsDir, 'stale.json'),
JSON.stringify({
schemaVersion: 1,
deviceKey: 'local:android:none:stale',
device: {
platform: 'android',
id: 'stale',
name: 'Stale Pixel; echo no',
kind: 'emulator',
},
session: 'stale-session',
workspace: '/worktrees/stale',
stateDir: process.cwd(),
ownerPid: 999_999_999,
ownerStartTime: 'old-start-time',
ownerToken: 'stale-token',
createdAtMs: 1,
updatedAtMs: 1,
}),
);

const normal = await runCliCapture(['device', 'status'], {
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
});
assert.equal(normal.code, null);
assert.equal(normal.calls.length, 0);
assert.match(normal.stdout, /Live Pixel: live/);
assert.match(
normal.stdout,
/1 stale claim hidden; inspect with: agent-device device status --stale/,
);
assert.doesNotMatch(normal.stdout, /Stale Pixel/);

const stale = await runCliCapture(['device', 'status', '--stale', '--json'], {
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
});
assert.equal(stale.code, null);
assert.equal(stale.calls.length, 0);
const payload = JSON.parse(stale.stdout);
assert.equal(payload.data.claims.length, 1);
assert.equal(payload.data.claims[0].device.name, 'Stale Pixel; echo no');
assert.equal(payload.data.claims[0].recovery, undefined);

const scoped = await runCliCapture(['device', 'status', '--device', 'Stale Pixel; echo no'], {
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
});
assert.match(
scoped.stdout,
/inspect with: agent-device device status --device 'Stale Pixel; echo no' --stale/,
);
} finally {
fs.rmSync(claimsDir, { recursive: true, force: true });
}
});

test('keeps corrupt and state-dir-gone claims visible in normal status', async () => {
const claimsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-claims-'));
const owner = readCurrentOwnerIdentity();
try {
fs.writeFileSync(path.join(claimsDir, 'corrupt.json'), '{bad json');
fs.writeFileSync(
path.join(claimsDir, 'state-dir-gone.json'),
JSON.stringify({
schemaVersion: 1,
deviceKey: 'local:android:none:state-dir-gone',
device: {
platform: 'android',
id: 'state-dir-gone',
name: 'State-dir-gone Pixel',
kind: 'emulator',
},
session: 'state-dir-gone-session',
workspace: '/worktrees/state-dir-gone',
stateDir: path.join(claimsDir, 'missing-state-dir'),
ownerPid: owner.pid,
ownerStartTime: owner.startTime,
ownerToken: 'state-dir-gone-token',
createdAtMs: 1,
updatedAtMs: 1,
}),
);

const normal = await runCliCapture(['device', 'status'], {
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
});
assert.match(normal.stdout, /corrupt.json: inconsistent/);
assert.doesNotMatch(normal.stdout, /State-dir-gone Pixel/);
assert.match(normal.stdout, /1 stale claim hidden/);

const stale = await runCliCapture(['device', 'status', '--stale'], {
env: { AGENT_DEVICE_CLAIMS_DIR: claimsDir },
});
assert.match(stale.stdout, /State-dir-gone Pixel: owner-state-dir-gone/);
} finally {
fs.rmSync(claimsDir, { recursive: true, force: true });
}
});
6 changes: 4 additions & 2 deletions src/cli-schema/command-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
supportedFlags: ['stateDir'],
},
device: {
usageOverride: 'device status [--platform <platform>] [--udid <udid>] [--serial <serial>]',
usageOverride:
'device status [--platform <platform>] [--udid <udid>] [--serial <serial>] [--stale]',
listUsageOverride: 'device status',
helpDescription:
'Inspect advisory host-local device ownership claims without starting or contacting a daemon.',
'Inspect advisory host-local device ownership claims without starting or contacting a daemon. --stale only inspects proven-stale claims; it does not reclaim claims or clean platform resources.',
summary: 'Inspect local advisory device ownership without daemon side effects',
positionalArgs: ['status'],
allowedFlags: ['stale'],
supportedFlags: ['platform', 'device', 'udid', 'serial'],
},
connect: {
Expand Down
98 changes: 70 additions & 28 deletions src/cli/commands/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,49 @@ import {
inspectDeviceClaims,
type InspectedDeviceClaim,
} from '../../daemon/device-claim-inspection.ts';
import { shellQuoteIfNeeded } from '../../utils/shell-quote.ts';
import { writeCommandOutput } from './shared.ts';
import type { ClientCommandHandler } from './router-types.ts';

export const deviceCommand: ClientCommandHandler = async ({ positionals, flags }) => {
if (positionals[0] !== 'status' || positionals.length !== 1) {
throw new AppError('INVALID_ARGS', 'device accepts only: status');
}
const claims = inspectDeviceClaims({
const inspectedClaims = inspectDeviceClaims({
platform: flags.platform,
device: flags.device,
udid: flags.udid,
serial: flags.serial,
}).map(serializeClaim);
const data = { claims };
writeCommandOutput(flags, data, () => renderDeviceStatus(claims));
});
const staleClaims = inspectedClaims.filter(isStaleClaim);
const claims = (
flags.stale ? staleClaims : inspectedClaims.filter((claim) => !isStaleClaim(claim))
).map(serializeClaim);
const data = {
claims,
...(flags.stale ? {} : { hiddenStaleClaims: staleClaims.length }),
};
writeCommandOutput(flags, data, () =>
renderDeviceStatus(claims, {
staleOnly: flags.stale === true,
hiddenStaleClaims: staleClaims.length,
staleCommand: buildStaleInspectionCommand(flags),
}),
);
return true;
};

function isStaleClaim(claim: InspectedDeviceClaim): boolean {
return (
claim.classification === 'owner-process-dead' || claim.classification === 'owner-state-dir-gone'
);
}

function serializeClaim(entry: InspectedDeviceClaim): Record<string, unknown> {
const claim = entry.claim;
return {
...(entry.deviceKey ? { deviceKey: entry.deviceKey } : {}),
...(!claim ? { fileName: entry.fileName } : {}),
classification: entry.classification,
...(claim
? {
Expand All @@ -36,37 +57,58 @@ function serializeClaim(entry: InspectedDeviceClaim): Record<string, unknown> {
pid: claim.ownerPid,
startTime: claim.ownerStartTime,
},
recovery: {
command: futureRecoveryCommand(claim.device.platform, claim.device.id),
},
}
: {}),
...(entry.error ? { error: entry.error } : {}),
};
}

function futureRecoveryCommand(platform: string, id: string): string | undefined {
if (platform === 'ios' || platform === 'macos') {
return `agent-device device release --platform ${platform} --udid ${id} --stale`;
function renderDeviceStatus(
claims: Record<string, unknown>[],
options: { staleOnly: boolean; hiddenStaleClaims: number; staleCommand: string },
): string {
const claimLines = claims.map(renderClaimLine);
if (claimLines.length === 0) {
if (options.staleOnly) return 'No stale local advisory device claims found.';
if (options.hiddenStaleClaims === 0) return 'No local advisory device claims found.';
}
if (platform === 'android') {
return `agent-device device release --platform android --serial ${id} --stale`;
}
return undefined;
return [
...claimLines,
!options.staleOnly && options.hiddenStaleClaims > 0
? `${options.hiddenStaleClaims} stale ${options.hiddenStaleClaims === 1 ? 'claim' : 'claims'} hidden; inspect with: ${options.staleCommand}`
: null,
]
.filter((line): line is string => Boolean(line))
.join('\n');
}

function renderDeviceStatus(claims: Record<string, unknown>[]): string {
if (claims.length === 0) return 'No local advisory device claims found.';
return claims
.map((claim) => {
const device = claim.device as { platform?: string; id?: string; name?: string } | undefined;
const owner = claim.owner as { session?: string; workspace?: string } | undefined;
return [
`${device?.platform ?? 'unknown'} ${device?.name ?? device?.id ?? claim.deviceKey ?? 'claim'}: ${claim.classification}`,
owner ? `session=${owner.session} workspace=${owner.workspace}` : null,
]
.filter((part): part is string => Boolean(part))
.join(' ');
})
.join('\n');
function renderClaimLine(claim: Record<string, unknown>): string {
return `${renderClaimLabel(claim)}: ${claim.classification}${renderClaimOwner(claim.owner)}`;
}

function renderClaimLabel(claim: Record<string, unknown>): string {
const device = claim.device as { platform?: string; id?: string; name?: string } | undefined;
if (!device) return String(claim.deviceKey ?? claim.fileName ?? 'claim');
return `${device.platform ?? 'unknown'} ${device.name ?? device.id ?? 'claim'}`;
}

function renderClaimOwner(value: unknown): string {
if (!value || typeof value !== 'object') return '';
const owner = value as { session?: string; workspace?: string };
return ` session=${owner.session} workspace=${owner.workspace}`;
}

function buildStaleInspectionCommand(flags: {
platform?: string;
device?: string;
udid?: string;
serial?: string;
}): string {
const selectors = [
flags.platform ? `--platform ${shellQuoteIfNeeded(flags.platform)}` : null,
flags.device ? `--device ${shellQuoteIfNeeded(flags.device)}` : null,
flags.udid ? `--udid ${shellQuoteIfNeeded(flags.udid)}` : null,
flags.serial ? `--serial ${shellQuoteIfNeeded(flags.serial)}` : null,
].filter((part): part is string => Boolean(part));
return ['agent-device device status', ...selectors, '--stale'].join(' ');
}
7 changes: 7 additions & 0 deletions src/commands/cli-grammar/flag-definitions-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
usageLabel: '--serial <serial>',
usageDescription: 'Android device serial',
},
{
key: 'stale',
names: ['--stale'],
type: 'boolean',
usageLabel: '--stale',
usageDescription: 'Device status: show only claims with a provably stale owner',
},
{
key: 'surface',
names: ['--surface'],
Expand Down
1 change: 1 addition & 0 deletions src/contracts/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export type CliFlags = CloudProviderProfileFields &
device?: string;
udid?: string;
serial?: string;
stale?: boolean;
iosSimulatorDeviceSet?: string;
iosXctestrunFile?: string;
iosXctestDerivedDataPath?: string;
Expand Down
4 changes: 2 additions & 2 deletions test/skillgym/suites/agent-device-smoke-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1108,10 +1108,10 @@ const SKILL_GUIDANCE_CASES: Case[] = [
contract: [
'Another worktree may have an advisory claim on Android emulator-5554',
'Need to inspect local ownership without starting or contacting any daemon',
'Stage 1 is observational only: device release and --stale are not available yet',
'Claims are observational only: device release is unavailable; device status --stale only inspects proven-stale records and does not clean them',
],
task: 'Plan the one command that inspects this Android device claim directly. Do not use ps, daemon commands, or an unavailable release command.',
outputs: [plannedCommand('device status')],
outputs: [plannedCommand('device status --platform android --serial emulator-5554')],
forbiddenOutputs: [/\bdaemon\b/i, /\bdevice\s+release\b/i, /\bps\b/i],
strictFinalOutput: true,
}),
Expand Down
2 changes: 1 addition & 1 deletion website/docs/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ agent-device app-switcher
- `shutdown` turns off the selected Apple simulator or Android emulator.
- `shutdown` must not target an active session device; use `close --shutdown` to end the session and turn it off.
- `daemon stop --state-dir <path>` verifies the daemon PID/start-time identity, requests graceful shutdown, and reports whether provider-release state is known. Use `daemon stop --clean` to also remove retained Apple runner processes and leases owned by that daemon.
- `device status` reads host-local advisory device claims without starting or contacting a daemon. Scope it with `--platform` plus `--udid` (Apple) or `--serial` (Android) to inspect one target. Stage 1 claims are observational: a live claim is reported but does not yet block `open` or offer a release command.
- `device status` reads host-local advisory device claims without starting or contacting a daemon. Normal output shows live and attention-needed claims, then summarizes proven-stale records in one line; use `device status --stale` to inspect the hidden records. Scope either view with `--platform` plus `--udid` (Apple) or `--serial` (Android). Claims remain observational: a live claim does not yet block `open`, and stale inspection does not delete a claim or clean platform resources.
- `--platform apple` is an alias for the Apple automation backend (`ios`, `tvOS`, `macOS` selection).
- Use `--target mobile|tv|desktop` with `--platform` (required) to select phone/tablet vs TV-class vs desktop-class targets.
- `boot` is mainly needed when starting a new session and `open` fails because no booted simulator/emulator is available.
Expand Down
Loading