Files
callstack__agent-device/src/agent-device-client.ts
T
Michał Pierzchała 60f6356b04 fix: read replay scripts on the caller and ship them with the request (#1810)
* fix: read replay scripts on the caller and ship them with the request

Closes #1802

* test: assert the caller-side replay path as a substring, not a hand-escaped regex

* perf(cli): load the Maestro engine only when a replay entry is a flow

The command registry evaluates every command family on CLI startup, so the replay script-source builder's static @agent-device/maestro import put the YAML parser on the --help path. It now loads on demand behind the format check, and the startup import-closure guard covers the engine the way it already covers node:http.

* refactor: share the replay request field vocabulary across the CLI and client views

The new replay script-source flags appear in both CliFlags and CommandExecutionOptions, which fallow flagged as a clone; ReplayRequestFields declares them once. The test-suite handler's missing-sources rejection now travels the typed-error path its sibling rejections already use, so the fix adds no branch to handleSessionReplayCommands.
2026-08-18 14:56:34 +02:00

711 lines
28 KiB
TypeScript

import type { CommandFlags } from '@agent-device/contracts/command';
import {
readSerializedSnapshotCaptureAnnotations,
readSnapshotDiagnosticsSummary,
} from '@agent-device/contracts/capture';
import type {
AgentDeviceClientConfig,
AgentDeviceDaemonTransport,
AppCloseOptions,
AppDeployOptions,
AppInstallFromSourceOptions,
AppInstallOptions,
AppListOptions,
AppOpenOptions,
CaptureScreenshotOptions,
CaptureScreenshotResult,
CaptureSnapshotOptions,
CaptureSnapshotResult,
DragOptions,
FlingOptions,
InternalRequestOptions,
Lease,
MaterializationReleaseOptions,
PanOptions,
PinchOptions,
RotateGestureOptions,
SessionSaveScriptOptions,
SessionSaveScriptResult,
SwipeGestureOptions,
TransformGestureOptions,
} from '@agent-device/contracts/client';
import type { OrientationCommandResult } from '@agent-device/contracts/interaction';
import type { AgentArtifactsResult } from '@agent-device/contracts/observability';
import type { MetroPrepareOptions } from '@agent-device/contracts/remote';
import {
isNonDefaultResponseLevel,
type ResponseLevel,
type SessionRuntimeHints,
} from '@agent-device/kernel/contracts';
import { AppError, throwDaemonError } from '@agent-device/kernel/errors';
import {
buildMeta,
normalizeDeployResult,
normalizeDevice,
normalizeOpenForegroundComposition,
normalizeInstallFromSourceResult,
normalizeMaterializationReleaseResult,
normalizeOpenDevice,
normalizeRuntimeHints,
normalizeSession,
normalizeStartupSample,
normalizeTargetShutdownResult,
readOptionalString,
readRequiredString,
readSnapshotNodes,
resolveSessionName,
} from './client/client-normalizers.ts';
import type {
AgentDeviceClient,
AgentDeviceCommandClient,
MetroPrepareResult,
RotateCommandResult,
} from './client/client-types.ts';
import { INTERNAL_COMMANDS } from './command-catalog.ts';
import { buildRequestFlags } from './commands/command-flags.ts';
import {
prepareDaemonCommandRequest,
type DaemonCommandName,
} from './commands/command-projection.ts';
import { systemCommandFamily } from './commands/system/index.ts';
import type { ProjectedNavigationCommandClient } from './commands/system/navigation-projection.ts';
import type { CommandResult } from './core/command-descriptor/command-result.ts';
import { sendToDaemon } from './daemon/client/daemon-client.ts';
import { resolveDaemonPaths } from './daemon/config.ts';
import { prepareMetroRuntime, reloadMetro } from './metro/client-metro.ts';
import {
clearMetroSessionHints,
readMetroSessionHints,
writeMetroSessionHints,
type MetroSessionHints,
} from './metro/metro-session-hints.ts';
import { isRecord } from './utils/parsing.ts';
import { readScreenshotResultData } from './utils/screenshot-result.ts';
type ProjectedSystemCommandClient = ProjectedNavigationCommandClient<InternalRequestOptions> &
Pick<AgentDeviceCommandClient, 'appState' | 'keyboard' | 'clipboard' | 'rotate'>;
export function createAgentDeviceClient(
config: AgentDeviceClientConfig = {},
deps: { transport?: AgentDeviceDaemonTransport } = {},
): AgentDeviceClient {
const transport = deps.transport ?? sendToDaemon;
// A non-default responseLevel (digest/full) makes the daemon return a leveled
// shape; the per-command client normalizers assume the default shape, so the
// capture methods pass the leveled payload through unnormalized instead.
const isLeveledResponse = (options: { responseLevel?: ResponseLevel }): boolean =>
isNonDefaultResponseLevel(options.responseLevel ?? config.responseLevel);
const execute = async (
command: string,
positionals: string[] = [],
options: InternalRequestOptions = {},
metadataFlags?: Partial<CommandFlags>,
input?: Record<string, unknown>,
): Promise<Record<string, unknown>> => {
const merged = mergeClientOptions(config, options);
const request = {
session: resolveSessionName(merged.session),
command,
positionals,
...(input ? { input } : {}),
flags: buildRequestFlags(merged, metadataFlags),
runtime: merged.runtime,
meta: buildMeta(merged),
};
const response = await transport(request, { authToken: merged.daemonAuthToken });
if (!response.ok) {
throwDaemonError(response.error);
}
return (response.data ?? {}) as Record<string, unknown>;
};
const listSessions = async (options = {}) => {
const data = await execute(INTERNAL_COMMANDS.sessionList, [], options);
const sessions = Array.isArray(data.sessions) ? data.sessions : [];
return sessions.map(normalizeSession);
};
const executeCommand = async <T>(
command: DaemonCommandName,
options: InternalRequestOptions = {},
): Promise<T> => {
const request = await prepareDaemonCommandRequest(command, options);
return (await execute(
request.command,
request.positionals,
request.options,
request.metadataFlags,
request.input,
)) as T;
};
const resolveRequestSession = (options: InternalRequestOptions = {}) =>
resolveSessionName(mergeClientOptions(config, options).session);
const projectedSystemCommands = buildProjectedSystemCommandClient(executeCommand);
return {
command: {
wait: async (options) => await executeCommand<CommandResult<'wait'>>('wait', options),
alert: async (options = {}) => await executeCommand('alert', options),
...projectedSystemCommands,
reactNative: async (options) => await executeCommand('react-native', options),
doctor: async (options = {}) =>
await executeCommand<CommandResult<'doctor'>>('doctor', options),
prepare: async (options) =>
await executeCommand<CommandResult<'prepare'>>('prepare', options),
viewport: async (options) =>
await executeCommand<CommandResult<'viewport'>>('viewport', options),
},
devices: {
list: async (options = {}) => {
const data = await executeCommand<Record<string, unknown>>('devices', options);
const devices = Array.isArray(data.devices) ? data.devices : [];
return devices.map(normalizeDevice);
},
capabilities: async (options = {}) => {
const data = await executeCommand<Record<string, unknown>>('capabilities', options);
const availableCommands = Array.isArray(data.availableCommands)
? data.availableCommands.filter(
(command): command is string => typeof command === 'string',
)
: [];
return {
device: normalizeDevice(data.device),
availableCommands,
};
},
boot: async (options = {}) => await executeCommand<CommandResult<'boot'>>('boot', options),
shutdown: async (options = {}) =>
await executeCommand<CommandResult<'shutdown'>>('shutdown', options),
},
sessions: {
list: async (options = {}) => await listSessions(options),
// Pure local resolution; mirrors how the daemon client picks its state dir.
stateDir: async (options = {}) => {
const merged = mergeClientOptions(config, options);
return resolveDaemonPaths(merged.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR).baseDir;
},
close: async (options = {}) => {
const session = resolveRequestSession(options);
try {
const data = await executeCommand<Record<string, unknown>>('close', options);
return {
session,
shutdown: normalizeTargetShutdownResult(data.shutdown),
provider: readObject(data.provider),
savedScript: readOptionalString(data, 'savedScript'),
identifiers: { session },
};
} finally {
// Close is teardown intent: drop the dev-server binding even if the daemon call fails.
clearMetroSessionHintsQuietly(config, options);
}
},
saveScript: async (
options: SessionSaveScriptOptions = {},
): Promise<SessionSaveScriptResult> => {
const data = await execute(
INTERNAL_COMMANDS.sessionSaveScript,
options.path !== undefined ? [options.path] : [],
options,
{ force: options.force },
);
const actionCount = data.actionCount;
if (typeof actionCount !== 'number' || !Number.isInteger(actionCount) || actionCount < 0) {
throw new AppError(
'COMMAND_FAILED',
'Daemon returned an invalid saved-script action count.',
);
}
const publishedSession = readRequiredString(data, 'session');
return {
session: publishedSession,
savedScript: readRequiredString(data, 'savedScript'),
actionCount,
identifiers: { session: publishedSession },
};
},
artifacts: async (options = {}) =>
await executeCommand<AgentArtifactsResult>('artifacts', options),
},
apps: {
install: async (options: AppInstallOptions) =>
normalizeDeployResult(
await executeCommand('install', options),
resolveRequestSession(options),
),
reinstall: async (options: AppDeployOptions) =>
normalizeDeployResult(
await executeCommand('reinstall', options),
resolveRequestSession(options),
),
installFromSource: async (options: AppInstallFromSourceOptions) =>
normalizeInstallFromSourceResult(
await executeCommand('install-from-source', options),
resolveRequestSession(options),
),
list: async (options: AppListOptions = {}) => {
const data = await executeCommand<Record<string, unknown>>('apps', options);
return Array.isArray(data.apps)
? data.apps.filter((app): app is string => typeof app === 'string')
: [];
},
open: async (options: AppOpenOptions) => {
const session = resolveRequestSession(options);
const data = await executeCommand<Record<string, unknown>>('open', options);
recordMetroSessionHintsAfterOpen({
config,
options,
runtime: mergeClientOptions(config, options).runtime,
sessionReused: data.sessionReused === true,
});
const device = normalizeOpenDevice(data);
const appBundleId = readOptionalString(data, 'appBundleId');
const appId = appBundleId;
const warnings = Array.isArray(data.warnings)
? data.warnings.filter((warning): warning is string => typeof warning === 'string')
: [];
return {
session,
...(warnings.length > 0 ? { warnings } : {}),
sessionStateDir: readOptionalString(data, 'sessionStateDir'),
runnerLogPath: readOptionalString(data, 'runnerLogPath'),
requestLogPath: readOptionalString(data, 'requestLogPath'),
eventLogPath: readOptionalString(data, 'eventLogPath'),
appName: readOptionalString(data, 'appName'),
appBundleId,
appId,
startup: normalizeStartupSample(data.startup),
runtime: normalizeRuntimeHints(data.runtime),
device,
...normalizeOpenForegroundComposition(data),
identifiers: {
session,
deviceId: device?.id,
deviceName: device?.name,
udid: device?.ios?.udid,
serial: device?.android?.serial,
appId,
appBundleId,
},
};
},
close: async (options: AppCloseOptions = {}) => {
const session = resolveRequestSession(options);
const data = await executeCommand<Record<string, unknown>>('close', options);
return {
session,
closedApp: options.app,
shutdown: normalizeTargetShutdownResult(data.shutdown),
savedScript: readOptionalString(data, 'savedScript'),
identifiers: { session },
};
},
push: async (options) => await executeCommand<CommandResult<'push'>>('push', options),
triggerEvent: async (options) =>
await executeCommand<CommandResult<'trigger-app-event'>>('trigger-app-event', options),
},
materializations: {
release: async (options: MaterializationReleaseOptions) =>
normalizeMaterializationReleaseResult(
await execute(INTERNAL_COMMANDS.releaseMaterializedPaths, [], {
...options,
materializationId: options.materializationId,
}),
),
},
leases: {
allocate: async (options) =>
normalizeLease(
await execute(INTERNAL_COMMANDS.leaseAllocate, [], {
...options,
leaseId: undefined,
}),
),
heartbeat: async (options) =>
normalizeLease(await execute(INTERNAL_COMMANDS.leaseHeartbeat, [], options)),
release: async (options) => {
const data = await execute(INTERNAL_COMMANDS.leaseRelease, [], options);
return { released: data.released === true, provider: readObject(data.provider) };
},
},
metro: {
prepare: async (options: MetroPrepareOptions) => {
const result = await prepareMetroRuntime({
projectRoot: options.projectRoot ?? config.cwd,
kind: options.kind,
publicBaseUrl: options.publicBaseUrl,
proxyBaseUrl: options.proxyBaseUrl,
proxyBearerToken: options.bearerToken,
bridgeScope: options.bridgeScope,
launchUrl: options.launchUrl,
companionProfileKey: options.companionProfileKey,
companionConsumerKey: options.companionConsumerKey,
metroPort: options.port,
listenHost: options.listenHost,
statusHost: options.statusHost,
startupTimeoutMs: options.startupTimeoutMs,
probeTimeoutMs: options.probeTimeoutMs,
reuseExisting: options.reuseExisting,
installDependenciesIfNeeded: options.installDependenciesIfNeeded,
runtimeFilePath: options.runtimeFilePath,
logPath: options.logPath,
});
persistMetroSessionHints(config, result);
return result;
},
reload: async (options = {}) =>
await reloadMetro({
metroHost: options.metroHost,
metroPort: options.metroPort,
bundleUrl: options.bundleUrl,
runtime: config.runtime ?? resolveMetroSessionHints(config),
timeoutMs: options.timeoutMs,
}),
},
capture: {
snapshot: async (options: CaptureSnapshotOptions = {}) => {
const session = resolveRequestSession(options);
const data = await executeCommand<Record<string, unknown>>('snapshot', options);
// A non-default responseLevel returns the leveled snapshot digest
// ({ nodeCount, refs, … }); normalizeSnapshotResult expects the full
// `nodes` tree and would collapse it to an empty snapshot. Pass the
// leveled payload through verbatim. (Mirrors capture.screenshot; the
// caller opted into the level, so the runtime value is the leveled shape.)
if (isLeveledResponse(options)) return data as unknown as CaptureSnapshotResult;
return normalizeSnapshotResult(data, session);
},
screenshot: async (options: CaptureScreenshotOptions = {}) => {
const session = resolveRequestSession(options);
const data = await executeCommand<Record<string, unknown>>('screenshot', options);
// A non-default responseLevel returns a leveled (digest) screenshot shape
// — `overlayCount`, leveled `overlayRefs`, `artifacts` — that the default
// normalizer below would drop. Pass the leveled payload through verbatim.
// (The caller opted into a non-default level, so the static type is the
// default shape; the runtime value is the leveled payload.)
if (isLeveledResponse(options)) return data as unknown as CaptureScreenshotResult;
const screenshot = readScreenshotResultData(data);
return {
path: readRequiredString(data, 'path'),
width: screenshot?.width,
height: screenshot?.height,
logicalWidth: screenshot?.logicalWidth,
logicalHeight: screenshot?.logicalHeight,
pixelDensity: screenshot?.pixelDensity,
overlayRefs: screenshot?.overlayRefs,
identifiers: { session },
};
},
diff: async (options) => await executeCommand<CommandResult<'diff'>>('diff', options),
},
interactions: {
click: async (options) => await executeCommand('click', options),
press: async (options) => await executeCommand('press', options),
longPress: async (options) => await executeCommand('longpress', options),
hover: async (options) => await executeCommand('hover', options),
swipe: async (options) => await executeCommand('swipe', options),
pan: async (options) => await executeCommand('gesture', panGestureInput(options)),
drag: async (options) => await executeCommand('gesture', dragGestureInput(options)),
fling: async (options) => await executeCommand('gesture', flingGestureInput(options)),
swipeGesture: async (options) =>
await executeCommand('gesture', swipePresetGestureInput(options)),
focus: async (options) => await executeCommand('focus', options),
type: async (options) => await executeCommand('type', options),
fill: async (options) => await executeCommand('fill', options),
scroll: async (options) => await executeCommand('scroll', options),
pinch: async (options) => await executeCommand('gesture', pinchGestureInput(options)),
rotateGesture: async (options) =>
await executeCommand('gesture', rotateGestureInput(options)),
transformGesture: async (options) =>
await executeCommand('gesture', transformGestureInput(options)),
get: async (options) => await executeCommand('get', options),
is: async (options) => await executeCommand('is', options),
find: async (options) => await executeCommand('find', options),
},
replay: {
run: async (options) => await executeCommand<CommandResult<'replay'>>('replay', options),
test: async (options) => await executeCommand<CommandResult<'test'>>('test', options),
},
batch: {
run: async (options) => await executeCommand('batch', options),
},
observability: {
perf: async (options = {}) => await executeCommand('perf', options),
logs: async (options = {}) => await executeCommand('logs', options),
events: async (options = {}) => await executeCommand('events', options),
network: async (options = {}) => await executeCommand('network', options),
audio: async (options = {}) => await executeCommand('audio', options),
},
debug: {
symbols: async (options) => {
const { symbolicateCrashArtifact } =
await import('./platforms/apple/core/debug-symbols.ts');
return symbolicateCrashArtifact({ cwd: options.cwd ?? config.cwd, ...options });
},
},
recording: {
record: async (options) => await executeCommand<CommandResult<'record'>>('record', options),
trace: async (options) => await executeCommand<CommandResult<'trace'>>('trace', options),
},
settings: {
update: async (options) => await executeCommand('settings', options),
},
};
}
function panGestureInput(options: PanOptions): InternalRequestOptions & Record<string, unknown> {
const { x, y, dx, dy, ...common } = options;
return { ...common, kind: 'pan', origin: { x, y }, delta: { x: dx, y: dy } };
}
function dragGestureInput(options: DragOptions): InternalRequestOptions & Record<string, unknown> {
return { ...options, kind: 'drag' };
}
function flingGestureInput(
options: FlingOptions,
): InternalRequestOptions & Record<string, unknown> {
const { x, y, ...common } = options;
return { ...common, kind: 'fling', origin: { x, y } };
}
function swipePresetGestureInput(
options: SwipeGestureOptions,
): InternalRequestOptions & Record<string, unknown> {
return { ...options, kind: 'swipe' };
}
function pinchGestureInput(
options: PinchOptions,
): InternalRequestOptions & Record<string, unknown> {
const { x, y, ...common } = options;
assertCompleteGestureCenter(x, y, 'pinch');
return {
...common,
kind: 'pinch',
...(x === undefined && y === undefined ? {} : { origin: { x, y } }),
};
}
function rotateGestureInput(
options: RotateGestureOptions,
): InternalRequestOptions & Record<string, unknown> {
const { x, y, ...common } = options;
assertCompleteGestureCenter(x, y, 'rotate');
return {
...common,
kind: 'rotate',
...(x === undefined && y === undefined ? {} : { origin: { x, y } }),
};
}
function transformGestureInput(
options: TransformGestureOptions,
): InternalRequestOptions & Record<string, unknown> {
const { x, y, dx, dy, ...common } = options;
return { ...common, kind: 'transform', origin: { x, y }, delta: { x: dx, y: dy } };
}
function assertCompleteGestureCenter(
x: number | undefined,
y: number | undefined,
gesture: 'pinch' | 'rotate',
): void {
if ((x === undefined) !== (y === undefined)) {
throw new AppError('INVALID_ARGS', `gesture ${gesture} center requires both x and y`);
}
}
function normalizeSnapshotResult(
data: Record<string, unknown>,
session: string | undefined,
): CaptureSnapshotResult {
const appBundleId = readOptionalString(data, 'appBundleId');
return {
nodes: readSnapshotNodes(data.nodes),
truncated: data.truncated === true,
appName: readOptionalString(data, 'appName'),
appBundleId,
...optionalSnapshotResponseFields(data),
identifiers: {
session,
appId: appBundleId,
appBundleId,
},
};
}
function optionalSnapshotResponseFields(
data: Record<string, unknown>,
): Partial<
Pick<
CaptureSnapshotResult,
| 'androidSnapshot'
| 'unchanged'
| 'visibility'
| 'warnings'
| 'snapshotQuality'
| 'snapshotDiagnostics'
| 'fallbackScreenshotPath'
| 'refsGeneration'
>
> {
const visibility = readObject(data.visibility);
const unchanged = readObject(data.unchanged);
const snapshotDiagnostics = readSnapshotDiagnosticsSummary(data.snapshotDiagnostics);
return {
...(visibility ? { visibility: visibility as CaptureSnapshotResult['visibility'] } : {}),
...readSerializedSnapshotCaptureAnnotations(data),
...(unchanged ? { unchanged: unchanged as CaptureSnapshotResult['unchanged'] } : {}),
...(snapshotDiagnostics ? { snapshotDiagnostics } : {}),
...(typeof data.fallbackScreenshotPath === 'string'
? { fallbackScreenshotPath: data.fallbackScreenshotPath }
: {}),
// ADR 0014: keep the response-level ref-frame generation on Node.js results
// so callers can pin refs (`@e12~s<refsGeneration>`) before a mutation.
...(typeof data.refsGeneration === 'number' ? { refsGeneration: data.refsGeneration } : {}),
};
}
function buildProjectedSystemCommandClient(
executeCommand: <T>(command: DaemonCommandName, options?: InternalRequestOptions) => Promise<T>,
): ProjectedSystemCommandClient {
const methods: Record<string, (options?: InternalRequestOptions) => Promise<unknown>> = {};
for (const [method, command] of Object.entries(systemCommandFamily.clientCommandMethods ?? {})) {
methods[method] = async (options = {}) =>
await executeCommand<CommandResult<typeof command>>(command as DaemonCommandName, options);
}
// Deprecated (v0.18/v0.19): `rotate` was renamed to `orientation`. Retain a
// thin wrapper that delegates to `orientation` and restores the legacy
// `action: 'rotate'` response contract for existing consumers.
const orientation = methods.orientation;
if (!orientation) {
throw new Error('orientation client method missing from the system command family');
}
methods.rotate = async (options = {}) => {
const result = (await orientation(options)) as OrientationCommandResult;
return { ...result, action: 'rotate' } satisfies RotateCommandResult;
};
return methods as unknown as ProjectedSystemCommandClient;
}
function readObject(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined;
}
function mergeClientOptions(
config: AgentDeviceClientConfig,
options: InternalRequestOptions,
): InternalRequestOptions {
return { ...config, ...options };
}
function metroSessionHintsScope(
config: AgentDeviceClientConfig,
options: InternalRequestOptions = {},
): { stateDir: string; session: string } {
const merged = mergeClientOptions(config, options);
return {
stateDir: resolveDaemonPaths(merged.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR).baseDir,
session: resolveSessionName(merged.session),
};
}
// The metro-sessions file is the session's dev-server binding; see MetroSessionHints.
function persistMetroSessionHints(
config: AgentDeviceClientConfig,
result: Pick<MetroPrepareResult, 'statusUrl' | 'bridge' | 'iosRuntime' | 'androidRuntime'>,
): void {
try {
const url = new URL(result.statusUrl);
const port = Number.parseInt(url.port, 10);
if (!url.hostname || !Number.isInteger(port)) return;
// Bridge runtimes carry remote bundle URLs; persist local-flow bundle URLs only.
const bundleUrl = result.bridge
? undefined
: (result.iosRuntime.bundleUrl ?? result.androidRuntime.bundleUrl);
writeMetroSessionHints({
...metroSessionHintsScope(config),
hints: { metroHost: url.hostname, metroPort: port, bundleUrl },
});
} catch {
// Session-hint persistence is best-effort; reload still works with explicit flags.
}
}
function resolveMetroSessionHints(config: AgentDeviceClientConfig): MetroSessionHints | undefined {
try {
return readMetroSessionHints(metroSessionHintsScope(config));
} catch {
return undefined;
}
}
function metroHintsFromRuntime(
runtime: SessionRuntimeHints | undefined,
): MetroSessionHints | undefined {
if (!runtime) return undefined;
const { metroHost, metroPort, bundleUrl } = runtime;
if (metroHost === undefined && metroPort === undefined && bundleUrl === undefined) {
return undefined;
}
return { metroHost, metroPort, bundleUrl };
}
// Hint flags rebind the session's dev server; a hintless open that created the session clears
// any leftover binding from a previous same-name session.
function recordMetroSessionHintsAfterOpen(params: {
config: AgentDeviceClientConfig;
options: InternalRequestOptions;
runtime: SessionRuntimeHints | undefined;
sessionReused: boolean;
}): void {
try {
const scope = metroSessionHintsScope(params.config, params.options);
const hints = metroHintsFromRuntime(params.runtime);
if (hints) {
writeMetroSessionHints({ ...scope, hints });
return;
}
if (!params.sessionReused) {
clearMetroSessionHints(scope);
}
} catch {
// Session-hint sync is best-effort; reload still works with explicit flags.
}
}
function clearMetroSessionHintsQuietly(
config: AgentDeviceClientConfig,
options: InternalRequestOptions,
): void {
try {
clearMetroSessionHints(metroSessionHintsScope(config, options));
} catch {
// Session-hint cleanup is best-effort; close must not fail on local file state.
}
}
function normalizeLease(data: Record<string, unknown>): Lease {
const rawLease = data.lease;
if (!isRecord(rawLease)) {
throw new Error('Invalid lease response from daemon');
}
return {
leaseId: readRequiredString(rawLease, 'leaseId'),
tenantId: readRequiredString(rawLease, 'tenantId'),
runId: readRequiredString(rawLease, 'runId'),
backend: readRequiredString(rawLease, 'backend') as Lease['backend'],
leaseProvider: readOptionalString(rawLease, 'leaseProvider'),
clientId: readOptionalString(rawLease, 'clientId'),
deviceKey: readOptionalString(rawLease, 'deviceKey'),
createdAt: typeof rawLease.createdAt === 'number' ? rawLease.createdAt : undefined,
heartbeatAt: typeof rawLease.heartbeatAt === 'number' ? rawLease.heartbeatAt : undefined,
expiresAt: typeof rawLease.expiresAt === 'number' ? rawLease.expiresAt : undefined,
};
}
export type * from './client/client-types.ts';