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 src/cli/parser/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,7 @@ Direct proxy flow for a remote Mac/simulator:
agent-device devices --platform ios
agent-device open Maps --platform ios --device "iPhone 17 Pro"
agent-device snapshot -i --platform ios --device "iPhone 17 Pro"
agent-device artifacts --json
agent-device close
agent-device disconnect

Expand Down
4 changes: 2 additions & 2 deletions src/client/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import type {
RemoteConnectionProfileFields,
} from '../remote/remote-config-schema.ts';
import type { CommandResult } from '../core/command-descriptor/command-result.ts';
import type { CloudArtifactsResult, CloudProviderSessionResult } from '../cloud-artifacts.ts';
import type { AgentArtifactsResult, CloudProviderSessionResult } from '../cloud-artifacts.ts';

export type { FindLocator } from '../utils/finders.ts';
export type { CompanionTunnelScope, MetroBridgeScope } from './client-companion-tunnel-contract.ts';
Expand Down Expand Up @@ -969,7 +969,7 @@ export type AgentDeviceClient = {
close: (
options?: AgentDeviceRequestOverrides & { shutdown?: boolean },
) => Promise<SessionCloseResult>;
artifacts: (options?: CloudArtifactsOptions) => Promise<CloudArtifactsResult>;
artifacts: (options?: CloudArtifactsOptions) => Promise<AgentArtifactsResult>;
};
apps: {
install: (options: AppInstallOptions) => Promise<AppDeployResult>;
Expand Down
4 changes: 2 additions & 2 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import { isNonDefaultResponseLevel, type ResponseLevel } from '../kernel/contrac
import { readSerializedSnapshotCaptureAnnotations } from '../snapshot-capture-annotations.ts';
import { readSnapshotDiagnosticsSummary } from '../snapshot-diagnostics.ts';
import type { CommandFlags } from '../core/dispatch-context.ts';
import type { CloudArtifactsResult } from '../cloud-artifacts.ts';
import type { AgentArtifactsResult } from '../cloud-artifacts.ts';

export function createAgentDeviceClient(
config: AgentDeviceClientConfig = {},
Expand Down Expand Up @@ -156,7 +156,7 @@ export function createAgentDeviceClient(
};
},
artifacts: async (options = {}) =>
await executeCommand<CloudArtifactsResult>('artifacts', options),
await executeCommand<AgentArtifactsResult>('artifacts', options),
},
apps: {
install: async (options: AppInstallOptions) =>
Expand Down
18 changes: 18 additions & 0 deletions src/cloud-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ export type CloudArtifactsResult = {
message?: string;
};

export type DaemonArtifactInventoryEntry = {
id: string;
filename: string;
mimeType: string;
sizeBytes: number;
createdAt: string;
expiresAt: string;
};

export type DaemonArtifactsResult = {
source: 'daemon';
status: 'ready';
artifacts: DaemonArtifactInventoryEntry[];
message?: string;
};

export type AgentArtifactsResult = CloudArtifactsResult | DaemonArtifactsResult;

export type CloudArtifactsQuery = {
provider?: string;
leaseId?: string;
Expand Down
6 changes: 3 additions & 3 deletions src/commands/management/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { managementCliOutputFormatters } from './output.ts';

const artifactsCommandMetadata = defineFieldCommandMetadata(
'artifacts',
'List cloud provider artifacts for an active or completed provider session.',
'List daemon or cloud provider artifacts for an active or completed session.',
{
provider: stringField('Cloud provider name, for example browserstack or aws-device-farm.'),
providerSessionId: stringField('Cloud provider session id or ARN.'),
Expand All @@ -23,8 +23,8 @@ const artifactsCommandDefinition = defineExecutableCommand(
);

const artifactsCliSchema = {
summary: 'List cloud provider session artifacts',
usageOverride: 'artifacts [provider-session-id] --provider <name>',
summary: 'List daemon or cloud provider session artifacts',
usageOverride: 'artifacts [provider-session-id] [--provider <name>]',
positionalArgs: ['provider-session-id?'],
allowedFlags: ['provider', 'providerSessionId'],
} as const satisfies CommandSchemaOverride;
Expand Down
26 changes: 26 additions & 0 deletions src/commands/management/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,32 @@ describe('artifactsCliOutput', () => {
].join('\n'),
);
});

test('prints daemon artifact inventory and preserves JSON data', () => {
const output = managementCliOutputFormatters.artifacts({
input: {},
result: {
source: 'daemon',
status: 'ready',
artifacts: [
{
id: 'artifact-1',
filename: 'screenshot.png',
mimeType: 'application/octet-stream',
sizeBytes: 123,
createdAt: '2026-07-02T12:00:00.000Z',
expiresAt: '2026-07-02T12:15:00.000Z',
},
],
},
});

expect(output.text).toBe('screenshot.png: application/octet-stream 123 bytes id=artifact-1');
expect(output.data).toMatchObject({
source: 'daemon',
artifacts: [{ id: 'artifact-1', filename: 'screenshot.png' }],
});
});
});

describe('doctorCliOutput', () => {
Expand Down
26 changes: 24 additions & 2 deletions src/commands/management/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ import type {
CommandRequestResult,
SessionCloseResult,
} from '../../client/client-types.ts';
import type { CloudArtifactsResult } from '../../cloud-artifacts.ts';
import type {
AgentArtifactsResult,
CloudArtifactsResult,
DaemonArtifactsResult,
} from '../../cloud-artifacts.ts';
import { readCommandMessage } from '../../utils/success-text.ts';
import type { CliOutput } from '../command-contract.ts';
import {
Expand Down Expand Up @@ -79,7 +83,17 @@ function closeCliOutput(result: AppCloseResult | SessionCloseResult): CliOutput
return messageCliOutput(serializeCloseResult(result));
}

function artifactsCliOutput(result: CloudArtifactsResult): CliOutput {
function artifactsCliOutput(result: AgentArtifactsResult): CliOutput {
if (isDaemonArtifactsResult(result)) {
return {
data: result,
text:
result.artifacts.length > 0
? result.artifacts.map(formatDaemonArtifactLine).join('\n')
: (result.message ?? 'No daemon artifacts available.'),
};
}

const emptyText = [result.message ?? `No cloud artifacts available for ${result.provider}.`];
const retryCommand = formatCloudArtifactsRetryCommand(result);
if (retryCommand) emptyText.push(`Retry: ${retryCommand}`);
Expand All @@ -92,6 +106,10 @@ function artifactsCliOutput(result: CloudArtifactsResult): CliOutput {
};
}

function isDaemonArtifactsResult(result: AgentArtifactsResult): result is DaemonArtifactsResult {
return 'source' in result && result.source === 'daemon';
}

function deployCliOutput(result: AppDeployResult): CliOutput {
return messageCliOutput(serializeDeployResult(result));
}
Expand Down Expand Up @@ -175,6 +193,10 @@ function formatCloudArtifactLine(artifact: CloudArtifactsResult['cloudArtifacts'
return `${artifact.kind}: ${artifact.name}${availability}${url}`;
}

function formatDaemonArtifactLine(artifact: DaemonArtifactsResult['artifacts'][number]): string {
return `${artifact.filename}: ${artifact.mimeType} ${artifact.sizeBytes} bytes id=${artifact.id}`;
}

function formatCloudArtifactsRetryCommand(result: CloudArtifactsResult): string | undefined {
if (!result.providerSessionId) return undefined;
return `agent-device artifacts ${result.providerSessionId} --provider ${result.provider} --json`;
Expand Down
88 changes: 88 additions & 0 deletions src/daemon/__tests__/request-handler-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
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 { withTargetDeviceResolutionScope } from '../../core/dispatch-resolve.ts';
import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
import { getDaemonCommandRoute, type DaemonCommandRoute } from '../daemon-command-registry.ts';
import { cleanupDownloadableArtifact, trackDownloadableArtifact } from '../artifact-tracking.ts';
import { contextFromFlags } from '../context.ts';
import { handleLeaseCommands } from '../handlers/lease.ts';
import { LeaseRegistry } from '../lease-registry.ts';
Expand Down Expand Up @@ -150,6 +154,26 @@ test('lease handler preserves device-aware lease fields', async () => {
assert.equal(heartbeatLease.leaseProvider, 'proxy');
});

test('lease artifacts lists daemon inventory for proxy lease scopes', async () => {
const leaseRegistry = new LeaseRegistry();
const sessionStore = makeSessionStore('agent-device-lease-artifacts-');
const tracked = trackProxyLeaseArtifact();

try {
const response = await handleLeaseCommands({
req: proxyArtifactsRequest(),
sessionName: 'catalog-test',
sessionStore,
leaseRegistry,
});

assertProxyLeaseArtifactInventory(response, tracked.artifactId);
} finally {
cleanupDownloadableArtifact(tracked.artifactId);
fs.rmSync(tracked.tempDir, { recursive: true, force: true });
}
});

test('lease release calls provider hook using the released lease without heartbeat mutation', async () => {
const leaseRegistry = new LeaseRegistry();
const sessionStore = makeSessionStore('agent-device-lease-release-');
Expand Down Expand Up @@ -269,6 +293,70 @@ function catalogRouteRequest(command: string): DaemonRequest {
};
}

function trackProxyLeaseArtifact(): { artifactId: string; tempDir: string } {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-lease-artifacts-'));
const artifactPath = path.join(tempDir, 'proxy-shot.png');
fs.writeFileSync(artifactPath, 'png-body');
return {
tempDir,
artifactId: trackDownloadableArtifact({
artifactPath,
fileName: 'proxy-shot.png',
tenantId: 'tenant-a',
}),
};
}

function proxyArtifactsRequest(): DaemonRequest {
return {
command: PUBLIC_COMMANDS.artifacts,
token: 'test-token',
session: 'catalog-test',
meta: {
tenantId: 'tenant-a',
runId: 'run-a',
leaseId: 'lease-a',
leaseProvider: 'proxy',
deviceKey: 'device-1',
clientId: 'client-a',
},
positionals: [],
};
}

function assertProxyLeaseArtifactInventory(
response: DaemonResponse | null,
artifactId: string,
): void {
assert.equal(response?.ok, true);
const data = response.data as Record<string, unknown> | undefined;
assert.equal(data?.source, 'daemon');
const artifact = readSingleArtifactRecord(data?.artifacts);
assert.deepEqual(
{
id: artifact.id,
filename: artifact.filename,
mimeType: artifact.mimeType,
sizeBytes: artifact.sizeBytes,
},
{
id: artifactId,
filename: 'proxy-shot.png',
mimeType: 'application/octet-stream',
sizeBytes: 'png-body'.length,
},
);
assert.equal(typeof artifact.createdAt, 'string');
assert.equal(typeof artifact.expiresAt, 'string');
}

function readSingleArtifactRecord(value: unknown): Record<string, unknown> {
assert.ok(Array.isArray(value));
assert.equal(value.length, 1);
assert.ok(value[0] && typeof value[0] === 'object' && !Array.isArray(value[0]));
return value[0] as Record<string, unknown>;
}

function assertNoRoutingMismatch(error: unknown, command: string): void {
assert.ok(error instanceof Error, `${command} threw a non-error value`);
assert.doesNotMatch(error.message, new RegExp(ROUTING_MISMATCH_MESSAGE), command);
Expand Down
1 change: 1 addition & 0 deletions src/daemon/downloadable-artifact-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ async function handleArtifactDownload(
didCleanupArtifact = true;
cleanupDownloadableArtifact(artifactId);
};
stream.on('end', cleanupCompletedDownload);
res.on('finish', cleanupCompletedDownload);
res.on('close', () => {
if (res.writableFinished) {
Expand Down
47 changes: 42 additions & 5 deletions src/daemon/handlers/lease.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { PUBLIC_COMMANDS } from '../../command-catalog.ts';
import type { CloudArtifactProvider } from '../../cloud-artifacts.ts';
import type { AgentArtifactsResult, CloudArtifactProvider } from '../../cloud-artifacts.ts';
import type { DaemonRequest, DaemonResponse } from '../types.ts';
import type { DeviceLease, LeaseRegistry } from '../lease-registry.ts';
import type { SessionStore } from '../session-store.ts';
import { resolveLeaseScope, resolveRequestOrSessionLeaseScope } from '../lease-context.ts';
import {
isProxyLeaseScope,
resolveLeaseScope,
resolveRequestOrSessionLeaseScope,
} from '../lease-context.ts';
import {
leaseScopeToAllocateRequest,
leaseScopeToHeartbeatRequest,
leaseScopeToReleaseRequest,
} from '../../core/lease-scope.ts';
import { AppError } from '../../kernel/errors.ts';
import { listDownloadableArtifacts } from '../artifact-tracking.ts';

export type LeaseLifecycleProvider = {
allocate?: (
Expand Down Expand Up @@ -54,7 +59,10 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise<Daemo
const artifactScope = resolveRequestOrSessionLeaseScope(req, sessionStore.get(sessionName));
return {
ok: true,
data: await listCloudArtifactsForRequest(req, artifactScope, cloudArtifactProvider),
data: (await listArtifactsForRequest(req, artifactScope, cloudArtifactProvider)) as Record<
string,
unknown
>,
};
}
case 'lease_allocate': {
Expand Down Expand Up @@ -104,12 +112,41 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise<Daemo
}
}

async function listCloudArtifactsForRequest(
async function listArtifactsForRequest(
req: DaemonRequest,
leaseScope: ReturnType<typeof resolveLeaseScope>,
cloudArtifactProvider: CloudArtifactProvider | undefined,
) {
): Promise<AgentArtifactsResult> {
const providerSessionId = readFlagString(req.flags, 'providerSessionId');
if (shouldListDaemonArtifacts(leaseScope, providerSessionId)) {
return await listDaemonArtifacts(leaseScope.tenantId);
}

return await listCloudArtifactsForRequest(leaseScope, providerSessionId, cloudArtifactProvider);
}

function shouldListDaemonArtifacts(
leaseScope: ReturnType<typeof resolveLeaseScope>,
providerSessionId: string | undefined,
): boolean {
return isProxyLeaseScope(leaseScope) || (!leaseScope.leaseProvider && !providerSessionId);
}

async function listDaemonArtifacts(tenantId: string | undefined): Promise<AgentArtifactsResult> {
const artifacts = await listDownloadableArtifacts(tenantId);
return {
source: 'daemon',
status: 'ready',
artifacts,
...(artifacts.length === 0 ? { message: 'No daemon artifacts available.' } : {}),
};
}

async function listCloudArtifactsForRequest(
leaseScope: ReturnType<typeof resolveLeaseScope>,
providerSessionId: string | undefined,
cloudArtifactProvider: CloudArtifactProvider | undefined,
): Promise<AgentArtifactsResult> {
if (!leaseScope.leaseProvider) {
throw new AppError(
'INVALID_ARGS',
Expand Down
Loading