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
12 changes: 12 additions & 0 deletions src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ export type DaemonError = {
diagnosticId?: string;
logPath?: string;
details?: Record<string, unknown>;
/**
* Machine-readable typed-error signals (Phase 2). Additive: present only when
* derivable, so the default error wire shape is unchanged.
*
* `retriable` flags a transient failure an agent should retry (vs. a
* deterministic one where a retry is wasted). `supportedOn` lists the platform
* families that DO support the command (derived from the capability matrix),
* surfaced on platform-mismatch errors so an agent self-corrects without a
* wasted round-trip.
*/
retriable?: boolean;
supportedOn?: string;
};

export type DaemonResponse =
Expand Down
19 changes: 19 additions & 0 deletions src/core/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,22 @@ export function unsupportedHintForDevice(command: string, device: DeviceInfo): s
export function listCapabilityCommands(): string[] {
return Object.keys(COMMAND_CAPABILITY_MATRIX).sort();
}

/**
* The platform families that DO support `command`, derived from the capability
* matrix (a family counts when it has at least one supported device kind). Used
* by the typed-error graft to populate `DaemonError.supportedOn` on platform
* mismatches. Returns `[]` for commands with no capability row (supported
* everywhere) so callers can omit the signal.
*/
export function supportedPlatformsForCommand(command: string): string[] {
const capability = COMMAND_CAPABILITY_MATRIX[command];
if (!capability) return [];
const families: Array<keyof CommandCapability> = ['apple', 'android', 'linux', 'web'];
const supported: string[] = [];
for (const family of families) {
const kinds = capability[family] as KindMatrix | undefined;
if (kinds && Object.values(kinds).some((value) => value === true)) supported.push(family);
}
return supported;
}
123 changes: 123 additions & 0 deletions src/daemon/__tests__/request-router-typed-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { test, expect, vi, beforeEach } from 'vitest';
import os from 'node:os';
import path from 'node:path';

vi.mock('../../core/dispatch.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../core/dispatch.ts')>();
return { ...actual, dispatchCommand: vi.fn(async () => ({})) };
});

vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/ios/runner-client.ts')>();
return { ...actual, stopIosRunnerSession: vi.fn(async () => {}) };
});

vi.mock('../device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) }));

import { dispatchCommand } from '../../core/dispatch.ts';
import { createRequestHandler } from '../request-router.ts';
import type { DaemonRequest, SessionState } from '../types.ts';
import { LeaseRegistry } from '../lease-registry.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
import { AppError, retriableForErrorCode } from '../../utils/errors.ts';
import { supportedPlatformsForCommand } from '../../core/capabilities.ts';

const mockDispatch = vi.mocked(dispatchCommand);

function makeIosSession(name: string): SessionState {
return {
name,
createdAt: 1_700_000_000_000,
actions: [],
device: {
platform: 'ios',
target: 'mobile',
id: 'SIM-001',
name: 'iPhone 16',
kind: 'simulator',
booted: true,
simulatorSetPath: '/tmp/tenant-a/set',
},
};
}

function makeHandler(sessionStore = makeSessionStore('agent-device-router-typed-error-')) {
return {
sessionStore,
handler: createRequestHandler({
logPath: path.join(os.tmpdir(), 'daemon.log'),
token: 'test-token',
sessionStore,
leaseRegistry: new LeaseRegistry(),
trackDownloadableArtifact: () => 'artifact-id',
}),
};
}

function request(command: string, overrides: Partial<DaemonRequest> = {}): DaemonRequest {
return {
token: 'test-token',
session: 'typed-error',
command,
positionals: [],
flags: {},
...overrides,
};
}

beforeEach(() => {
mockDispatch.mockReset();
});

test('retriableForErrorCode is a conservative policy: transient => true, others => undefined', () => {
expect(retriableForErrorCode('DEVICE_IN_USE')).toBe(true);
expect(retriableForErrorCode('INVALID_ARGS')).toBeUndefined();
expect(retriableForErrorCode('UNSUPPORTED_OPERATION')).toBeUndefined();
expect(retriableForErrorCode('COMMAND_FAILED')).toBeUndefined();
});

test('UNSUPPORTED_OPERATION errors carry supportedOn derived from the capability matrix', async () => {
const { sessionStore, handler } = makeHandler();
sessionStore.set('typed-error', makeIosSession('typed-error'));
mockDispatch.mockRejectedValue(new AppError('UNSUPPORTED_OPERATION', 'nope on this platform'));

// `home` routes through the (mocked) generic dispatch and is platform-restricted.
const response = await handler(request('home'));

expect(response.ok).toBe(false);
if (response.ok) return;
const expected = supportedPlatformsForCommand('home');
expect(expected.length).toBeGreaterThan(0); // home is a platform-restricted command
expect(response.error.supportedOn).toBe(expected.join(', '));
});

test('DEVICE_IN_USE errors are flagged retriable; supportedOn stays absent', async () => {
const { sessionStore, handler } = makeHandler();
sessionStore.set('typed-error', makeIosSession('typed-error'));
mockDispatch.mockRejectedValue(new AppError('DEVICE_IN_USE', 'device busy'));

const response = await handler(request('home'));

expect(response.ok).toBe(false);
if (response.ok) return;
expect(response.error.retriable).toBe(true);
expect('supportedOn' in response.error).toBe(false);
});

test('deterministic errors (INVALID_ARGS) are returned with the default shape — no typed-error fields', async () => {
const { sessionStore, handler } = makeHandler();
sessionStore.set('typed-error', makeIosSession('typed-error'));

// Conflicting explicit selector under a reject lock policy fails with INVALID_ARGS
// before dispatch — a deterministic error.
const response = await handler(
request('home', { flags: { udid: 'SIM-999' }, meta: { lockPolicy: 'reject' } }),
);

expect(response.ok).toBe(false);
if (response.ok) return;
expect(response.error.code).toBe('INVALID_ARGS');
expect('retriable' in response.error).toBe(false);
expect('supportedOn' in response.error).toBe(false);
expect(mockDispatch).not.toHaveBeenCalled();
});
43 changes: 35 additions & 8 deletions src/daemon/request-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import {
type DeviceInventoryProvider,
withTargetDeviceResolutionScope,
} from '../core/dispatch-resolve.ts';
import { AppError, normalizeError } from '../utils/errors.ts';
import { AppError, normalizeError, retriableForErrorCode } from '../utils/errors.ts';
import { supportedPlatformsForCommand } from '../core/capabilities.ts';
import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts';
import type { ResponseCost } from '../contracts.ts';
import type { DaemonError, ResponseCost } from '../contracts.ts';
import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from './types.ts';
import { SessionStore } from './session-store.ts';
import { noActiveSessionError } from './handlers/response.ts';
Expand Down Expand Up @@ -102,13 +103,21 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn {
},
async () => {
const response = await runRequestWithinScope(req);
// Phase 2 (typed errors) graft: enrich error responses with additive,
// machine-readable signals — `supportedOn` for platform mismatches and
// `retriable` for transient failures — so an agent self-corrects without a
// wasted round-trip. Returned unchanged when neither applies, so the
// default error wire shape is preserved.
if (!response.ok) {
return { ok: false, error: enrichDaemonError(req.command, response.error) };
}
// Phase 4 (agent-cost) graft: cost is purely additive and opt-in. With
// the flag off — or on an error response — the serialized DaemonResponse
// is byte-identical to today (Maestro `.ad` recompare diffs it). Mirrors
// the conditional `registerDownloadableArtifacts` spread in
// request-finalization. Runs inside the diagnostics scope so it can read
// this request's accumulated runner-round-trip tally.
if (!req.meta?.includeCost || !response.ok) return response;
// the flag off the serialized DaemonResponse is byte-identical to today
// (Maestro `.ad` recompare diffs it). Mirrors the conditional
// `registerDownloadableArtifacts` spread in request-finalization. Runs
// inside the diagnostics scope so it can read this request's accumulated
// runner-round-trip tally.
if (!req.meta?.includeCost) return response;
const cost: ResponseCost = {
wallClockMs: Date.now() - start,
runnerRoundTrips: countDiagnosticEventsByPhase(RUNNER_ROUND_TRIP_PHASES),
Expand Down Expand Up @@ -301,3 +310,21 @@ function finalizeThrownRequestError(error: unknown): DaemonResponse {
});
return { ok: false, error: normalizedError };
}

// Phase 2 typed-error graft: add machine-readable signals to an error response.
// Returns the error unchanged unless a signal applies, so the default wire shape
// is preserved for the common codes.
function enrichDaemonError(command: string, error: DaemonError): DaemonError {
const supportedPlatforms =
error.code === 'UNSUPPORTED_OPERATION' || error.code === 'UNSUPPORTED_PLATFORM'
? supportedPlatformsForCommand(command)
: [];
const supportedOn = supportedPlatforms.length > 0 ? supportedPlatforms.join(', ') : undefined;
const retriable = retriableForErrorCode(error.code);
if (supportedOn === undefined && retriable === undefined) return error;
return {
...error,
...(retriable !== undefined ? { retriable } : {}),
...(supportedOn !== undefined ? { supportedOn } : {}),
};
}
18 changes: 18 additions & 0 deletions src/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,24 @@ function stripDiagnosticMeta(
return Object.keys(output).length > 0 ? output : undefined;
}

/**
* Conservative retriability policy for the Phase 2 typed-error graft. Returns
* `true` only for codes that are clearly transient (a retry can succeed without
* the caller changing anything) and `undefined` for ambiguous/deterministic
* codes — so the error wire shape is unchanged unless we have a confident answer.
* Intentionally small; extend as codes gain a clear retriability verdict.
*/
export function retriableForErrorCode(code: string): boolean | undefined {
switch (code) {
// The device is healthy but currently leased/busy — the same request can
// succeed once it frees up.
case 'DEVICE_IN_USE':
return true;
default:
return undefined;
}
}

export function defaultHintForCode(code: string): string | undefined {
switch (code) {
case 'INVALID_ARGS':
Expand Down
Loading