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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Add `ensureDaemon({ stateDir })` and the client config `daemonLifecycle: 'attach' | 'managed'`. An `attach` client connects to the daemon registered in the state directory and never starts, replaces or kills one, including after a request times out. It fails with `details.reason: 'daemon-not-running'` (the hint points at `ensureDaemon()`) or `'daemon-incompatible'` (both identities in `details`, the hint names `agent-device daemon stop`). `ensureDaemon()` starts a missing daemon and replaces an incompatible one that answers only through an authenticated quiesce request the daemon answers itself (a resumable upload still streaming and a human-control hold that names no lease are not counted, so a replacement can end them): it refuses with `daemon-busy` while any request is in flight or any session, repair session or live lease exists, and a daemon from a release before this one is never replaced. A registered daemon that answers nothing is retired, as is one that times out on the quiesce request and then fails repeated liveness probes; neither can be asked. A remote `daemonBaseUrl` connection never starts or retires a local daemon in either mode. `managed` stays the default and behaves as before until 0.22.
- Add `uninstall <app>` and `client.apps.uninstall({ app })` to remove an installed app on adb-backed Android targets, iOS simulators, and CoreDevice iOS devices. Both report `wasInstalled`. A package name or bundle id that is not installed succeeds with `wasInstalled: false`. Other targets, Appium and Limrun sessions included, refuse it before anything runs.
- Add `settings mobile-data on|off` for adb-backed Android targets. It switches mobile data through `svc data` and reports the `mobileData` state stored in the global `mobile_data` row; multi-SIM read-back is not covered yet. Every other target refuses it before anything runs, and `--app` is refused because the switch is device-wide.
- On iOS, a `fill` that runs under a degraded XCTest snapshot channel now aims at a point, including ref, selector and replay fills. When the runner identifies no text input under that point, it refuses with `NO_TEXT_INPUT_AT_POINT` (`error.details.reason: no_text_input_at_point`, `dispatched: no`) before dispatching its focus tap, and no field changes; when the input lookup itself cannot answer, it refuses with `TEXT_INPUT_PROBE_UNAVAILABLE` (`error.details.reason: text_input_probe_unavailable`). Previously such a fill cleared and typed through the app-level first responder, so it could rewrite a field the caller never named, or report a clear failure after emptying it. A ref or selector fill whose rect centre misses the real field (an outer container, a non-input node) now needs a point inside the input's own rect.
Expand Down
54 changes: 54 additions & 0 deletions packages/contracts/src/client-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import type {
DaemonLockPolicy,
DaemonServerMode,
DaemonTransportPreference,
DaemonRequest,
DaemonResponse,
LeaseBackend,
Expand All @@ -15,8 +17,58 @@ import type {
RemoteConnectionProfileFields,
} from './remote-config-fields.ts';

/**
* Who owns the daemon on a client's state directory.
*
* - `managed`: the client starts a missing daemon and replaces an incompatible one itself, and may
* retire a hung one after a timeout. This is the default until 0.22.
* - `attach`: the client only connects to the daemon registered in the state directory. It never
* starts, replaces or kills one. A missing daemon fails with `details.reason: 'daemon-not-running'`
* and an incompatible one with `'daemon-incompatible'`; `ensureDaemon()` is the explicit way to
* start or replace one.
*
* Independent of `daemonTransport`, `daemonServerMode` and `daemonBaseUrl`: a remote connection
* never starts or retires a local daemon in either mode.
*/
export type DaemonLifecycle = 'attach' | 'managed';

/** What a client can learn about a daemon without talking to it: how it identifies itself on disk. */
export type DaemonIdentity = {
pid: number;
version?: string;
/** `installed` for a published package, `checkout` for a source tree. */
codeOrigin?: 'installed' | 'checkout';
codeSignature?: string;
processStartTime?: string;
};

export type EnsureDaemonOptions = {
/** The state directory the daemon owns; defaults to `AGENT_DEVICE_STATE_DIR`, then the user default. */
stateDir?: string;
daemonTransport?: DaemonTransportPreference;
daemonServerMode?: DaemonServerMode;
/**
* Called when this call started a daemon, before `ensureDaemon` returns. `missing` means no
* usable daemon was registered; `incompatible` means a daemon this client cannot use was
* replaced after it agreed to leave. `previous` is the daemon that was replaced, when there was
* one: a `missing` start with a `previous` replaced a registered daemon that answered nothing,
* either unreachable or hung and force-retired.
*/
onStart?: (reason: 'missing' | 'incompatible', previous?: DaemonIdentity) => void;
};

/** A reachable local daemon. It carries no credentials. */
export type DaemonConnection = {
stateDir: string;
daemon: DaemonIdentity;
/** True when this call started the daemon. */
started: boolean;
};

export type AgentDeviceDaemonTransportContext = {
authToken?: string;
/** How this request treats the local daemon; `managed` when omitted. */
daemonLifecycle?: DaemonLifecycle;
/**
* Cancels this one in-flight request. A built-in transport that sees an abort destroys the
* request's connection, which makes the daemon mark the request canceled; the promise rejects
Expand All @@ -33,6 +85,8 @@ export type AgentDeviceDaemonTransport = (

export type AgentDeviceClientConfig = RemoteConnectionProfileFields &
CloudProviderProfileFields & {
/** See `DaemonLifecycle`. */
daemonLifecycle?: DaemonLifecycle;
session?: string;
lockPolicy?: DaemonLockPolicy;
lockPlatform?: PlatformSelector;
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/src/facades/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ export type {
AgentDeviceIdentifiers,
AgentDeviceRequestOverrides,
AgentDeviceSelectionOptions,
DaemonConnection,
DaemonIdentity,
DaemonLifecycle,
DeviceCommandBaseOptions,
EnsureDaemonOptions,
} from '../client-connection.ts';
export type {
AgentDeviceCapabilitiesResult,
Expand Down
39 changes: 39 additions & 0 deletions packages/daemon-contracts/src/daemon-resolution-quiesce.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { readDaemonQuiesceCensus, readDaemonQuiesceFence } from './daemon-resolution.ts';

test('a census is read only when every count is a non-negative integer', () => {
const census = {
startingUp: false,
inFlightRequests: 1,
sessions: 0,
repairSessions: 2,
leases: 3,
};
assert.deepEqual(readDaemonQuiesceCensus({ reason: 'daemon-busy', ...census }), census);

for (const malformed of [-1, 1.5, '1', Number.NaN, Number.POSITIVE_INFINITY, null]) {
assert.equal(readDaemonQuiesceCensus({ ...census, leases: malformed }), undefined);
}
assert.equal(readDaemonQuiesceCensus({ sessions: 1 }), undefined);
// Only a daemon that is starting up says so; an older one omits the field.
assert.equal(readDaemonQuiesceCensus({ ...census, startingUp: true })?.startingUp, true);
const { startingUp: _omitted, ...withoutStartingUp } = census;
assert.equal(readDaemonQuiesceCensus(withoutStartingUp)?.startingUp, false);
assert.equal(readDaemonQuiesceCensus({ ...census, startingUp: 'yes' }), undefined);
assert.equal(readDaemonQuiesceCensus(undefined), undefined);
});

test('a fence names an integer pid and a start time or null, nothing else', () => {
assert.deepEqual(readDaemonQuiesceFence({ pid: 7, processStartTime: 'Fri' }), {
pid: 7,
processStartTime: 'Fri',
});
assert.deepEqual(readDaemonQuiesceFence({ pid: 7, processStartTime: null }), {
pid: 7,
processStartTime: null,
});
assert.equal(readDaemonQuiesceFence({ pid: 7 }), undefined);
assert.equal(readDaemonQuiesceFence({ pid: 7.5, processStartTime: null }), undefined);
assert.equal(readDaemonQuiesceFence('7'), undefined);
});
37 changes: 37 additions & 0 deletions packages/daemon-contracts/src/daemon-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { expandUserHomePath, resolveUserPath } from '@agent-device/host-kit/file
import { findProjectRoot, isSourceCheckoutProjectRoot } from '@agent-device/host-kit/version';
import { type EnvMap } from '@agent-device/kernel/source-value';

import type { DaemonWireRequest } from '@agent-device/contracts/command';
import type { DaemonServerMode, DaemonTransportPreference } from '@agent-device/kernel/contracts';
export type { DaemonServerMode, DaemonTransportPreference };

Expand Down Expand Up @@ -134,6 +135,22 @@ export type DaemonQuiesceCensus = Readonly<{
leases: number;
}>;

export function buildDaemonQuiesceRequest(params: {
token: string;
fence: DaemonQuiesceFence;
requestId: string;
}): DaemonWireRequest {
const { token, fence, requestId } = params;
return {
token,
session: 'default',
command: DAEMON_QUIESCE_COMMAND,
positionals: [],
input: { pid: fence.pid, processStartTime: fence.processStartTime },
meta: { requestId },
};
}

export function readDaemonQuiesceFence(input: unknown): DaemonQuiesceFence | undefined {
if (typeof input !== 'object' || input === null) return undefined;
const { pid, processStartTime } = input as Record<string, unknown>;
Expand All @@ -146,3 +163,23 @@ export function readDaemonCapabilities(value: unknown): readonly DaemonCapabilit
if (!Array.isArray(value)) return [];
return DAEMON_CAPABILITIES.filter((capability) => value.includes(capability));
}

export function readDaemonQuiesceCensus(details: unknown): DaemonQuiesceCensus | undefined {
if (typeof details !== 'object' || details === null) return undefined;
const { startingUp, inFlightRequests, sessions, repairSessions, leases } = details as Record<
string,
unknown
>;
if (startingUp !== undefined && typeof startingUp !== 'boolean') return undefined;
const counts = [inFlightRequests, sessions, repairSessions, leases];
if (!counts.every((count) => Number.isSafeInteger(count) && (count as number) >= 0)) {
return undefined;
}
return {
startingUp: startingUp === true,
inFlightRequests: inFlightRequests as number,
sessions: sessions as number,
repairSessions: repairSessions as number,
leases: leases as number,
};
}
18 changes: 1 addition & 17 deletions packages/daemon/src/server/http/daemon-runtime-quiesce.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,12 @@ import net from 'node:net';
import { afterEach, expect, test, vi } from 'vitest';
import { mkdtempForTestSync } from '@agent-device/test-kit';
import {
DAEMON_QUIESCE_COMMAND,
buildDaemonQuiesceRequest,
resolveDaemonPaths,
type DaemonQuiesceFence,
} from '@agent-device/daemon-contracts/daemon-resolution';
import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process';
import type { DaemonResponse } from '@agent-device/kernel/contracts';

function buildDaemonQuiesceRequest(params: {
token: string;
fence: DaemonQuiesceFence;
requestId: string;
}) {
return {
token: params.token,
session: 'default',
command: DAEMON_QUIESCE_COMMAND,
positionals: [],
input: { pid: params.fence.pid, processStartTime: params.fence.processStartTime },
meta: { requestId: params.requestId },
};
}

// A quiesce is the daemon's own answer to "may I leave?", so these tests boot the real runtime and
// ask over its real socket: the census, the closing of admission and the drain are one flow.
const probe = vi.hoisted(() => ({
Expand Down
47 changes: 47 additions & 0 deletions src/__tests__/agent-device-client-daemon-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import type {
AgentDeviceDaemonTransport,
AgentDeviceDaemonTransportContext,
DaemonLifecycle,
} from '@agent-device/contracts/client';
import { createAgentDeviceClient } from '../agent-device-client.ts';

// `daemonLifecycle` is a client choice that only the transport can act on, so the client's job is to
// hand it over exactly and to refuse a value it does not know.

function recordingTransport() {
const contexts: Array<AgentDeviceDaemonTransportContext | undefined> = [];
const transport: AgentDeviceDaemonTransport = async (_request, context) => {
contexts.push(context);
return { ok: true, data: { devices: [] } };
};
return { contexts, transport };
}

test('the configured daemon lifecycle reaches the transport', async () => {
const { contexts, transport } = recordingTransport();
const attached = createAgentDeviceClient({ daemonLifecycle: 'attach' }, { transport });
const plain = createAgentDeviceClient({}, { transport });

const managed = createAgentDeviceClient({ daemonLifecycle: 'managed' }, { transport });

await attached.devices.list();
await managed.devices.list();
await plain.devices.list();

assert.deepEqual(
contexts.map((context) => context?.daemonLifecycle),
['attach', 'managed', undefined],
);
});

test('an unknown daemon lifecycle is refused instead of becoming managed', () => {
const { transport } = recordingTransport();
const mistyped = 'Attach' as DaemonLifecycle;

assert.throws(
() => createAgentDeviceClient({ daemonLifecycle: mistyped }, { transport }),
(error: { code?: string }) => error.code === 'INVALID_ARGS',
);
});
7 changes: 5 additions & 2 deletions src/__tests__/daemon-registration-owner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ import {
spawnRegisteredDaemonFixture,
finishRegisteredDaemonFixture,
} from './test-utils/registered-daemon-fixture.ts';
import { ensureDaemon, resolveClientSettings } from '../daemon-client/daemon-client-lifecycle.ts';
import {
ensureManagedDaemon,
resolveClientSettings,
} from '../daemon-client/daemon-client-lifecycle.ts';
import { inspectProcessLock } from '@agent-device/host-kit/file';
import { AppError } from '@agent-device/kernel/errors';
import { sleep } from '@agent-device/host-kit/retry';
Expand Down Expand Up @@ -791,7 +794,7 @@ test('cutover refuses an already-running legacy daemon before signaling or chang
assert.ok(disposition, 'a new daemon must refuse an occupied legacy file');
assert.equal(disposition.exitCode, DAEMON_STARTUP_EXIT_CODES.unproven);
await assert.rejects(
ensureDaemon(
ensureManagedDaemon(
resolveClientSettings({
session: 'default',
command: 'devices',
Expand Down
16 changes: 15 additions & 1 deletion src/agent-device-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
CaptureScreenshotResult,
CaptureSnapshotOptions,
CaptureSnapshotResult,
DaemonLifecycle,
DragOptions,
FlingOptions,
MaterializationReleaseOptions,
Expand Down Expand Up @@ -84,6 +85,8 @@ export function createAgentDeviceClient(
deps: { transport?: AgentDeviceDaemonTransport } = {},
): AgentDeviceClient {
const transport = deps.transport ?? sendToDaemon;
// A typo in a JSON or JS config must not fall through to the default and manage a daemon.
readDaemonLifecycle(config.daemonLifecycle);

// A non-default responseLevel (digest/full) makes the daemon return a leveled
// shape; the per-command client normalizers assume the default shape, so the
Expand Down Expand Up @@ -120,7 +123,11 @@ export function createAgentDeviceClient(
// mark the request canceled.
const response = await cancellation.guard(
async () =>
await transport(request, { authToken: merged.daemonAuthToken, signal: merged.signal }),
await transport(request, {
authToken: merged.daemonAuthToken,
signal: merged.signal,
daemonLifecycle: merged.daemonLifecycle,
Comment thread
thymikee marked this conversation as resolved.
Comment thread
thymikee marked this conversation as resolved.
}),
);
if (!response.ok) {
throwDaemonError(response.error);
Expand Down Expand Up @@ -585,6 +592,13 @@ function optionalSnapshotResponseFields(
};
}

function readDaemonLifecycle(value: unknown): DaemonLifecycle | undefined {
if (value === undefined || value === 'attach' || value === 'managed') return value;
throw new AppError('INVALID_ARGS', "daemonLifecycle must be 'attach' or 'managed'.", {
daemonLifecycle: value,
});
}

function mergeClientOptions(
config: AgentDeviceClientConfig,
options: InternalRequestOptions,
Expand Down
Loading
Loading