diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 96fd2656d..8d4a08700 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -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 diff --git a/src/client/client-types.ts b/src/client/client-types.ts index c77b2ecc7..cf1bb13ca 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -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'; @@ -969,7 +969,7 @@ export type AgentDeviceClient = { close: ( options?: AgentDeviceRequestOverrides & { shutdown?: boolean }, ) => Promise; - artifacts: (options?: CloudArtifactsOptions) => Promise; + artifacts: (options?: CloudArtifactsOptions) => Promise; }; apps: { install: (options: AppInstallOptions) => Promise; diff --git a/src/client/client.ts b/src/client/client.ts index 93b39bb35..9a4538df8 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -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 = {}, @@ -156,7 +156,7 @@ export function createAgentDeviceClient( }; }, artifacts: async (options = {}) => - await executeCommand('artifacts', options), + await executeCommand('artifacts', options), }, apps: { install: async (options: AppInstallOptions) => diff --git a/src/cloud-artifacts.ts b/src/cloud-artifacts.ts index 565b3c9c5..aac1c1ffd 100644 --- a/src/cloud-artifacts.ts +++ b/src/cloud-artifacts.ts @@ -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; diff --git a/src/commands/management/artifacts.ts b/src/commands/management/artifacts.ts index d0f4625a0..fa2abf95a 100644 --- a/src/commands/management/artifacts.ts +++ b/src/commands/management/artifacts.ts @@ -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.'), @@ -23,8 +23,8 @@ const artifactsCommandDefinition = defineExecutableCommand( ); const artifactsCliSchema = { - summary: 'List cloud provider session artifacts', - usageOverride: 'artifacts [provider-session-id] --provider ', + summary: 'List daemon or cloud provider session artifacts', + usageOverride: 'artifacts [provider-session-id] [--provider ]', positionalArgs: ['provider-session-id?'], allowedFlags: ['provider', 'providerSessionId'], } as const satisfies CommandSchemaOverride; diff --git a/src/commands/management/output.test.ts b/src/commands/management/output.test.ts index ff255d690..23a74ad03 100644 --- a/src/commands/management/output.test.ts +++ b/src/commands/management/output.test.ts @@ -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', () => { diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index f6ab4f092..9859161cc 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -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 { @@ -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}`); @@ -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)); } @@ -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`; diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index a5f49a1dd..61f06f6b6 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -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'; @@ -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-'); @@ -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 | 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 { + 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; +} + 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); diff --git a/src/daemon/downloadable-artifact-http.ts b/src/daemon/downloadable-artifact-http.ts index ca374144c..d5254732a 100644 --- a/src/daemon/downloadable-artifact-http.ts +++ b/src/daemon/downloadable-artifact-http.ts @@ -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) { diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 83927577e..70560ff75 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -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?: ( @@ -54,7 +59,10 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise, }; } case 'lease_allocate': { @@ -104,12 +112,41 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise, cloudArtifactProvider: CloudArtifactProvider | undefined, -) { +): Promise { 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, + providerSessionId: string | undefined, +): boolean { + return isProxyLeaseScope(leaseScope) || (!leaseScope.leaseProvider && !providerSessionId); +} + +async function listDaemonArtifacts(tenantId: string | undefined): Promise { + const artifacts = await listDownloadableArtifacts(tenantId); + return { + source: 'daemon', + status: 'ready', + artifacts, + ...(artifacts.length === 0 ? { message: 'No daemon artifacts available.' } : {}), + }; +} + +async function listCloudArtifactsForRequest( + leaseScope: ReturnType, + providerSessionId: string | undefined, + cloudArtifactProvider: CloudArtifactProvider | undefined, +): Promise { if (!leaseScope.leaseProvider) { throw new AppError( 'INVALID_ARGS',