mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
docs+ux: make device ownership discoverable end to end (#2165)
* docs+ux: make device ownership discoverable end to end Complete the #1320 agent experience so 'busy? -> inspect -> choose or release' is discoverable from every surface an agent actually reads: - devices now projects the blocking claim owner per row (claimedBy with session and workspace, observe-policy projection; provably dead owners are excluded because the next open replaces them automatically), so an agent told a device is busy can pick a free one from the same listing. - help debugging gains a 'Device busy and ownership' section separating the two DEVICE_IN_USE flavors and their exact recoveries. - AGENTS.md documents both flavors; docs/agents/device-verification.md retires the last ps/kill recovery guidance in favor of device status, daemon stop --state-dir, and device release --stale (Stage 5 of #1320). - ADR-0010 no longer calls DEVICE_IN_USE 'the only retriable code' without naming the claim path's non-retriable override. - The rendered cross-worktree claim error gains a help-conformance quiz case binding (sample-output-device-claim-inspects-owner). - README points at device status / device release --stale. Part of #1320. * fix: key ownership projection by canonical device identity end to end Review findings on #2165: - blockingClaimOwnersByDevice keyed claims and inventory rows by bare device.id, so a live Android claim could project claimedBy onto an unrelated same-id Apple/Harmony/Vega row, with scan order picking the displayed owner. Both sides now use the canonical local device key (claim.deviceKey against canonicalLocalDeviceKey of the row's claim identity). The cross-family same-id regression was observed red against the bare-id keying. - The projection is now asserted across every hop the PR promises: client normalization preserves well-formed claimedBy and drops malformed ones, and the devices CLI formatter carries it through JSON data and renders the text line (MCP shares the same serialization).
This commit is contained in:
committed by
GitHub
parent
2d2aa6c670
commit
34e8cbb7a2
@@ -113,8 +113,10 @@ cross-language rules change through golden tables under `contracts/fixtures/`.
|
||||
regenerate baselines to accept unrelated findings.
|
||||
- The first Node process after a newly signed Apple runner launches may block during Gatekeeper
|
||||
verification. Warm it with a throwaway `node -e 0` before measuring.
|
||||
- `DEVICE_IN_USE` normally means another session still owns the device. Follow the error's targeted
|
||||
`close --session` hint instead of debugging the daemon.
|
||||
- `DEVICE_IN_USE` has two flavors. "already in use by session X" is this daemon — follow its
|
||||
`close --session` hint. "owned by session X in workspace Y" is another worktree's device
|
||||
claim — non-retriable; run the error's `device status`/`device release --stale` recovery,
|
||||
never PID hunting.
|
||||
- A changing timeout failure set that passes in isolation is host contention. Reproduce the same
|
||||
test on `origin/main` under the same load before treating it as a regression.
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ The same session and evidence model works at every step: the agent explores the
|
||||
|
||||
Support depth varies by target. Newer backends such as HarmonyOS and Vega OS cover a subset of commands; run `agent-device capabilities --platform <platform>` to see what a target 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. The same commands drive hosted devices on [BrowserStack, AWS Device Farm, and Limrun](https://oss.callstack.com/agent-device/docs/device-clouds).
|
||||
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. Inspect ownership without a daemon via `agent-device device status`, and settle provably dead owners with `agent-device device release --stale`. The same commands drive hosted devices on [BrowserStack, AWS Device Farm, and Limrun](https://oss.callstack.com/agent-device/docs/device-clouds).
|
||||
|
||||
`agent-device` uses the inspect-act-verify process from Vercel's [agent-browser](https://github.com/vercel-labs/agent-browser) for mobile, TV, and desktop apps. Basic `--platform web` support runs `agent-browser` in the same session and replay system.
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ agent failures (selector/ref misses), and consumers that drop fields (MCP tool e
|
||||
`err instanceof AppError ? err : new AppError(...)`.
|
||||
2. **Code selection.** Use the most specific `KnownAppErrorCode`; `COMMAND_FAILED` is for genuine
|
||||
runtime failures of a well-formed request, never a catch-all for capability gaps
|
||||
(`UNSUPPORTED_OPERATION`), contention (`DEVICE_IN_USE`, the only retriable code), or ambiguity
|
||||
(`UNSUPPORTED_OPERATION`), contention (`DEVICE_IN_USE`, the only code retriable by default;
|
||||
the cross-worktree device-claim path overrides it to `retriable: false`), or ambiguity
|
||||
(`AMBIGUOUS_MATCH`). New codes are added to the union deliberately; machine-dispatchable
|
||||
sub-classification rides in `details.reason` (the lease registry is the model).
|
||||
3. **Hints answer "what should the agent run next".** A hint is required wherever the per-code
|
||||
|
||||
@@ -38,11 +38,13 @@ failed verification attempts.
|
||||
- Use a purpose-specific session name for experiments, and an isolated `--state-dir` under
|
||||
`/private/tmp` when you need cleanup isolation beyond the current worktree's default daemon.
|
||||
- Track opened sessions in working notes; close each one before the final response.
|
||||
- If `close` is blocked by stale daemon metadata, inspect processes first with
|
||||
`ps -ax | rg "agent-device|xcodebuild test-without-building"`. Stop only exact stale PIDs belonging
|
||||
to this verification run, then `pnpm clean:daemon`.
|
||||
- If cleanup cannot be completed, report the remaining session name, state dir, PIDs, and metadata
|
||||
paths as a blocker.
|
||||
- If `close` is blocked or ownership looks stuck, inspect it with
|
||||
`agent-device device status --stale` (daemonless), stop the owning daemon with
|
||||
`agent-device daemon stop --state-dir <dir>` (add `--clean` to remove retained runners), and
|
||||
release provably dead owners with `agent-device device release --stale`. Do not hunt PIDs with
|
||||
`ps`/`kill`.
|
||||
- If cleanup cannot be completed, report the remaining session name, state dir, and the
|
||||
`device status --stale` output as a blocker.
|
||||
|
||||
## Sandboxed environments
|
||||
|
||||
|
||||
@@ -21,6 +21,15 @@ export type AgentDeviceDevice = {
|
||||
*/
|
||||
appleOs?: AppleOS;
|
||||
identifiers: AgentDeviceIdentifiers;
|
||||
/**
|
||||
* Present when a host-local device claim currently blocks foreign use of
|
||||
* this device (#1320). Provably dead owners are not projected — the next
|
||||
* open reconciles and replaces them automatically.
|
||||
*/
|
||||
claimedBy?: {
|
||||
session: string;
|
||||
workspace: string;
|
||||
};
|
||||
ios?: {
|
||||
udid: string;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AMBIGUOUS_MATCH_SAMPLE,
|
||||
APP_NOT_INSTALLED_SAMPLE,
|
||||
BROWSERSTACK_CONNECT_SAMPLE,
|
||||
DEVICE_CLAIM_IN_USE_SAMPLE,
|
||||
DEVICE_IN_USE_SAMPLE,
|
||||
FOREGROUND_SNAPSHOT_FAILURE_SAMPLE,
|
||||
MERGED_CARD_ACTIONS_SAMPLE,
|
||||
@@ -575,6 +576,28 @@ Use the output already shown to determine whether the feed-search UI is present,
|
||||
{ id: 'noRawCoordinateTarget', pattern: RAW_COORDINATE_TARGET },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sample-output-device-claim-inspects-owner',
|
||||
docs: ['--help:first30', 'debugging'],
|
||||
recovery: { code: 'DEVICE_IN_USE', sample: DEVICE_CLAIM_IN_USE_SAMPLE },
|
||||
task: quiz(
|
||||
DEVICE_CLAIM_IN_USE_SAMPLE,
|
||||
'A different worktree owns this device and you must not interrupt it. What command should run next?',
|
||||
),
|
||||
expectations: ['validPlanCommands', 'fullPrefix'],
|
||||
matchers: [
|
||||
{
|
||||
id: 'runsDeviceStatus',
|
||||
pattern:
|
||||
/(?:^|\n)agent-device\s+device\s+status\s+--platform\s+android\s+--serial\s+emulator-5554\b/i,
|
||||
},
|
||||
],
|
||||
forbidden: [
|
||||
{ id: 'noRetryOpen', pattern: /(?:^|\n)agent-device\s+open\b/i },
|
||||
{ id: 'noForeignClose', pattern: /(?:^|\n)agent-device\s+close\b/i },
|
||||
{ id: 'noPidHunting', pattern: /(?:^|\n)\s*(?:ps|kill|pkill)\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sample-output-stale-ref-resnapshots',
|
||||
docs: ['--help:first30'],
|
||||
|
||||
@@ -512,6 +512,11 @@ Example:
|
||||
agent-device react-devtools profile report @c5
|
||||
agent-device network dump --include headers
|
||||
|
||||
Device busy and ownership:
|
||||
DEVICE_IN_USE has two flavors. "already in use by session X" is this daemon: reuse it with --session X, or run close --session X first. "owned by session X in workspace Y" is another worktree's daemon holding the host-global device claim: it is never retriable — run the error's exact recovery command instead of retrying.
|
||||
Inspect ownership without any daemon: agent-device device status (add --stale for proven-dead owners; settle and release those with agent-device device release --stale). devices marks rows that are claimed, so pick an unclaimed device instead of contending.
|
||||
A live foreign owner is released only by closing its session from its own workspace or stopping its daemon: agent-device daemon stop --state-dir <owner state dir> (the error names the state dir). Never recover by hunting PIDs with ps/kill. boot/install/shutdown take the same claims as open and refuse foreign-claimed devices identically.
|
||||
|
||||
Use snapshot, screenshot, logs, network, perf frames, and perf memory for device/app runtime evidence. Use react-devtools when component internals or React rendering behavior matters.`,
|
||||
},
|
||||
cdp: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'vitest';
|
||||
import { normalizeOpenForegroundComposition } from './client-normalizers.ts';
|
||||
import { expect, test } from 'vitest';
|
||||
import { normalizeDevice, normalizeOpenForegroundComposition } from './client-normalizers.ts';
|
||||
|
||||
test('embedded daemon errors sanitize an untrusted cause before client exposure', () => {
|
||||
const secret = 'adc_live_remote-secret';
|
||||
@@ -27,3 +27,22 @@ test('embedded daemon errors sanitize an untrusted cause before client exposure'
|
||||
assert.equal(cause.code?.length, 400);
|
||||
assert.match(cause.code ?? '', /<truncated>$/);
|
||||
});
|
||||
|
||||
test('device normalization preserves the projected claim owner and drops malformed ones', () => {
|
||||
const base = {
|
||||
platform: 'ios',
|
||||
target: 'mobile',
|
||||
kind: 'simulator',
|
||||
id: 'sim-1',
|
||||
name: 'iPhone 17 Pro',
|
||||
booted: true,
|
||||
};
|
||||
const claimed = normalizeDevice({
|
||||
...base,
|
||||
claimedBy: { session: 'qa', workspace: '/worktrees/qa' },
|
||||
});
|
||||
expect(claimed.claimedBy).toEqual({ session: 'qa', workspace: '/worktrees/qa' });
|
||||
|
||||
expect(normalizeDevice(base).claimedBy).toBeUndefined();
|
||||
expect(normalizeDevice({ ...base, claimedBy: { session: 42 } }).claimedBy).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -108,10 +108,21 @@ export function normalizeDevice(value: unknown): AgentDeviceDevice {
|
||||
// a non-Apple record with a stray appleOs value is not preserved.
|
||||
...(isApplePlatform(platform) && appleOs ? { appleOs } : {}),
|
||||
identifiers: buildDeviceIdentifiers(platform, id, name),
|
||||
...readClaimedBy(record),
|
||||
...buildClientDevicePlatformFields(platform, id),
|
||||
};
|
||||
}
|
||||
|
||||
function readClaimedBy(record: Record<string, unknown>): Pick<AgentDeviceDevice, 'claimedBy'> {
|
||||
const value = record.claimedBy;
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
const claimedBy = value as Record<string, unknown>;
|
||||
const session = claimedBy.session;
|
||||
const workspace = claimedBy.workspace;
|
||||
if (typeof session !== 'string' || typeof workspace !== 'string') return {};
|
||||
return { claimedBy: { session, workspace } };
|
||||
}
|
||||
|
||||
export function normalizeSession(value: unknown): AgentDeviceSession {
|
||||
const { record, platform, id, name, target } = readClientDeviceIdentity(value, 'name');
|
||||
const deviceName = readRequiredString(record, 'device');
|
||||
|
||||
@@ -313,3 +313,45 @@ describe('doctorCliOutput', () => {
|
||||
expect(output.text).toBe(['Doctor: pass', 'No blockers found.'].join('\n'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('devices output', () => {
|
||||
test('carries the projected claim owner through JSON data and the text line', () => {
|
||||
const output = managementCliOutputFormatters.devices({
|
||||
input: {},
|
||||
result: [
|
||||
{
|
||||
platform: 'ios',
|
||||
appleOs: 'ios',
|
||||
target: 'mobile',
|
||||
kind: 'simulator',
|
||||
id: 'sim-free',
|
||||
name: 'Free iPhone',
|
||||
booted: true,
|
||||
identifiers: { deviceId: 'sim-free', deviceName: 'Free iPhone', udid: 'sim-free' },
|
||||
},
|
||||
{
|
||||
platform: 'ios',
|
||||
appleOs: 'ios',
|
||||
target: 'mobile',
|
||||
kind: 'simulator',
|
||||
id: 'sim-claimed',
|
||||
name: 'Claimed iPhone',
|
||||
booted: true,
|
||||
identifiers: {
|
||||
deviceId: 'sim-claimed',
|
||||
deviceName: 'Claimed iPhone',
|
||||
udid: 'sim-claimed',
|
||||
},
|
||||
claimedBy: { session: 'qa', workspace: '/worktrees/qa' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const devices = (output.data as { devices: Record<string, unknown>[] }).devices;
|
||||
expect(devices[0]).not.toHaveProperty('claimedBy');
|
||||
expect(devices[1]?.claimedBy).toEqual({ session: 'qa', workspace: '/worktrees/qa' });
|
||||
const lines = (output.text ?? '').split('\n');
|
||||
expect(lines[0]).not.toContain('claimed by');
|
||||
expect(lines[1]).toContain('claimed by session "qa" in /worktrees/qa');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,7 +232,10 @@ function formatDeviceLine(device: AgentDeviceDevice): string {
|
||||
const kind = device.kind ? ` ${device.kind}` : '';
|
||||
const target = device.target ? ` target=${device.target}` : '';
|
||||
const booted = typeof device.booted === 'boolean' ? ` booted=${device.booted}` : '';
|
||||
return `${device.name} (${device.platform}${kind}${target})${booted}`;
|
||||
const claimed = device.claimedBy
|
||||
? ` claimed by session "${device.claimedBy.session}" in ${device.claimedBy.workspace}`
|
||||
: '';
|
||||
return `${device.name} (${device.platform}${kind}${target})${booted}${claimed}`;
|
||||
}
|
||||
|
||||
function formatCloudArtifactLine(artifact: CloudArtifactsResult['cloudArtifacts'][number]): string {
|
||||
|
||||
@@ -231,7 +231,8 @@ function isAbandonedClaimOfThisDaemon(
|
||||
return isAbandonedDeviceClaim(claim) && isClaimOwnedByThisDaemon(claim, stateDir, owner);
|
||||
}
|
||||
|
||||
function deviceClaimIdentity(device: DeviceInfo): DeviceIdentity {
|
||||
/** The canonical claim-facing identity of a local device: family, Apple OS, and id. */
|
||||
export function deviceClaimIdentity(device: DeviceInfo): DeviceIdentity {
|
||||
return deviceIdentity({
|
||||
...device,
|
||||
...(isApplePlatform(device.platform) ? { appleOs: resolveDeviceAppleOs(device) } : {}),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { handleSessionCommands } from './session-command-harness.ts';
|
||||
import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts';
|
||||
import { withTestDeviceInventory } from '../../../__tests__/test-utils/device-inventory-gateways.ts';
|
||||
import type { DeviceInfo } from '@agent-device/kernel/device';
|
||||
import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process';
|
||||
|
||||
test('devices filters Apple-family platform selectors', async () => {
|
||||
const sessionStore = makeSessionStore();
|
||||
@@ -497,3 +498,152 @@ test('release_materialized_paths removes retained install artifacts', async () =
|
||||
expect(fs.existsSync(retained.installablePath)).toBe(false);
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('devices projects the blocking claim owner and hides provably dead owners', async () => {
|
||||
const claimsDir = mkdtempForTestSync('agent-device-devices-claims-');
|
||||
const previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR;
|
||||
process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir;
|
||||
const owner = readCurrentOwnerIdentity();
|
||||
const writeClaim = (id: string, ownerPid: number, ownerStartTime: string | null) => {
|
||||
fs.writeFileSync(
|
||||
path.join(claimsDir, `${id}.json`),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
deviceKey: `local:android:none:${id}`,
|
||||
device: { platform: 'android', id, name: id, kind: 'emulator' },
|
||||
session: `${id}-session`,
|
||||
workspace: `/worktrees/${id}`,
|
||||
stateDir: process.cwd(),
|
||||
ownerPid,
|
||||
ownerStartTime,
|
||||
ownerToken: `${id}-token`,
|
||||
createdAtMs: 1,
|
||||
updatedAtMs: 1,
|
||||
}),
|
||||
);
|
||||
};
|
||||
writeClaim('emulator-5554', owner.pid, owner.startTime);
|
||||
writeClaim('emulator-5556', 999_999_999, 'long-gone');
|
||||
const sessionStore = makeSessionStore();
|
||||
const inventory: DeviceInfo[] = [
|
||||
makeAndroidInventoryDevice('emulator-5554'),
|
||||
makeAndroidInventoryDevice('emulator-5556'),
|
||||
];
|
||||
try {
|
||||
const response = await withTestDeviceInventory(
|
||||
{ local: async () => inventory },
|
||||
async () =>
|
||||
await handleSessionCommands({
|
||||
req: {
|
||||
token: 't',
|
||||
session: 'default',
|
||||
command: 'devices',
|
||||
positionals: [],
|
||||
flags: { platform: 'android' },
|
||||
},
|
||||
sessionName: 'default',
|
||||
logPath: path.join(os.tmpdir(), 'daemon.log'),
|
||||
sessionStore,
|
||||
invoke: noopInvoke,
|
||||
}),
|
||||
);
|
||||
expect(response?.ok).toBeTruthy();
|
||||
if (response?.ok) {
|
||||
const devices = response.data?.devices as Array<Record<string, unknown>> | undefined;
|
||||
expect(devices).toHaveLength(2);
|
||||
expect(devices?.[0]?.claimedBy).toEqual({
|
||||
session: 'emulator-5554-session',
|
||||
workspace: '/worktrees/emulator-5554',
|
||||
});
|
||||
// The dead owner is not projected: the next open reconciles and replaces it.
|
||||
expect(devices?.[1]?.claimedBy).toBeUndefined();
|
||||
}
|
||||
} finally {
|
||||
if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR;
|
||||
else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir;
|
||||
fs.rmSync(claimsDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeAndroidInventoryDevice(id: string): DeviceInfo {
|
||||
return {
|
||||
platform: 'android',
|
||||
id,
|
||||
name: id,
|
||||
kind: 'emulator',
|
||||
target: 'mobile',
|
||||
booted: true,
|
||||
};
|
||||
}
|
||||
|
||||
test('a claim never projects onto a same-id device from another platform family', async () => {
|
||||
const claimsDir = mkdtempForTestSync('agent-device-devices-claims-');
|
||||
const previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR;
|
||||
process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir;
|
||||
const owner = readCurrentOwnerIdentity();
|
||||
fs.writeFileSync(
|
||||
path.join(claimsDir, 'shared-id.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
deviceKey: 'local:android:none:shared-id',
|
||||
device: { platform: 'android', id: 'shared-id', name: 'Claimed Pixel', kind: 'emulator' },
|
||||
session: 'android-session',
|
||||
workspace: '/worktrees/android',
|
||||
stateDir: process.cwd(),
|
||||
ownerPid: owner.pid,
|
||||
ownerStartTime: owner.startTime,
|
||||
ownerToken: 'shared-id-token',
|
||||
createdAtMs: 1,
|
||||
updatedAtMs: 1,
|
||||
}),
|
||||
);
|
||||
const sessionStore = makeSessionStore();
|
||||
const inventory: DeviceInfo[] = [
|
||||
{
|
||||
platform: 'apple',
|
||||
appleOs: 'ios',
|
||||
id: 'shared-id',
|
||||
name: 'Colliding iPhone',
|
||||
kind: 'simulator',
|
||||
target: 'mobile',
|
||||
booted: true,
|
||||
},
|
||||
makeAndroidInventoryDevice('shared-id'),
|
||||
];
|
||||
try {
|
||||
const response = await withTestDeviceInventory(
|
||||
{ local: async () => inventory },
|
||||
async () =>
|
||||
await handleSessionCommands({
|
||||
req: {
|
||||
token: 't',
|
||||
session: 'default',
|
||||
command: 'devices',
|
||||
positionals: [],
|
||||
flags: {},
|
||||
},
|
||||
sessionName: 'default',
|
||||
logPath: path.join(os.tmpdir(), 'daemon.log'),
|
||||
sessionStore,
|
||||
invoke: noopInvoke,
|
||||
}),
|
||||
);
|
||||
expect(response?.ok).toBeTruthy();
|
||||
if (response?.ok) {
|
||||
const devices = response.data?.devices as Array<Record<string, unknown>> | undefined;
|
||||
expect(devices).toHaveLength(2);
|
||||
const byPlatform = new Map(devices?.map((row) => [row.platform, row]));
|
||||
// Claim ownership is canonical family/OS/id: the Android claim must not
|
||||
// appear on the Apple row that happens to share the bare id.
|
||||
expect(byPlatform.get('ios')?.claimedBy).toBeUndefined();
|
||||
expect(byPlatform.get('android')?.claimedBy).toEqual({
|
||||
session: 'android-session',
|
||||
workspace: '/worktrees/android',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR;
|
||||
else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir;
|
||||
fs.rmSync(claimsDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
resolveAndroidSerialAllowlist,
|
||||
resolveIosSimulatorDeviceSetPath,
|
||||
} from '@agent-device/kernel/device-isolation';
|
||||
import { deviceClaimOwnerCannotRelease, inspectDeviceClaims } from '../device-claim-inspection.ts';
|
||||
import { canonicalLocalDeviceKey } from '../device-claim-paths.ts';
|
||||
import { deviceClaimIdentity } from '../device-claims.ts';
|
||||
import type { DaemonRequest, DaemonResponse, SessionRef } from '../types.ts';
|
||||
import { resolveSessionRunnerLogPath, SessionStore } from '../session-store.ts';
|
||||
import {
|
||||
@@ -130,9 +133,15 @@ function publicSessionInfo({ address, session }: SessionRef, sessionStore: Sessi
|
||||
|
||||
async function devicesInventoryResponse(req: DaemonRequest): Promise<DaemonResponse> {
|
||||
try {
|
||||
const blockingClaims = blockingClaimOwnersByDevice();
|
||||
return {
|
||||
ok: true,
|
||||
data: { devices: (await resolveInventoryDevices(req)).map(publicDeviceInfo) },
|
||||
data: {
|
||||
devices: (await resolveInventoryDevices(req)).map((device) => ({
|
||||
...publicDeviceInfo(device),
|
||||
...claimedByProjection(device, blockingClaims),
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const appErr = asAppError(error);
|
||||
@@ -140,6 +149,36 @@ async function devicesInventoryResponse(req: DaemonRequest): Promise<DaemonRespo
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #1320 ownership projection (`observe` policy): device rows name the claim
|
||||
* owner that would block a foreign `open` right now, so an agent told a device
|
||||
* is busy can pick a free one from the same listing. Provably dead owners are
|
||||
* excluded — the next open reconciles and replaces them automatically. Both
|
||||
* sides key by the canonical local device key (family, Apple OS, id): distinct
|
||||
* platform devices may share a bare id, and a claim must never project onto
|
||||
* another family's row.
|
||||
*/
|
||||
function blockingClaimOwnersByDevice(): Map<string, { session: string; workspace: string }> {
|
||||
const owners = new Map<string, { session: string; workspace: string }>();
|
||||
for (const entry of inspectDeviceClaims({})) {
|
||||
const claim = entry.claim;
|
||||
if (!claim || deviceClaimOwnerCannotRelease(entry.classification)) continue;
|
||||
owners.set(claim.deviceKey, {
|
||||
session: claim.session,
|
||||
workspace: claim.workspace,
|
||||
});
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
function claimedByProjection(
|
||||
device: DeviceInfo,
|
||||
owners: Map<string, { session: string; workspace: string }>,
|
||||
): { claimedBy?: { session: string; workspace: string } } {
|
||||
const owner = owners.get(canonicalLocalDeviceKey(deviceClaimIdentity(device)));
|
||||
return owner ? { claimedBy: owner } : {};
|
||||
}
|
||||
|
||||
async function resolveInventoryDevices(req: DaemonRequest): Promise<DeviceInfo[]> {
|
||||
const requestedPlatform = req.flags?.platform;
|
||||
const devices = await listDeviceInventory(inventoryDeviceQuery(req, requestedPlatform));
|
||||
|
||||
@@ -95,6 +95,7 @@ export function serializeDevice(device: AgentDeviceDevice): Record<string, unkno
|
||||
kind: device.kind,
|
||||
target: device.target,
|
||||
...(typeof device.booted === 'boolean' ? { booted: device.booted } : {}),
|
||||
...(device.claimedBy ? { claimedBy: device.claimedBy } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user