diff --git a/CONTEXT.md b/CONTEXT.md index b51b0c93f9..a427fae367 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,6 +12,12 @@ - CloudArtifact: provider-hosted session output such as video, Appium logs, device logs, automation logs, or provider dashboard links. Cloud artifacts stay under the `cloudArtifacts` response field so they do not collide with daemon-managed local/downloadable `artifacts`. +- DaemonArtifactType: optional semantic category supplied by the command or adapter that owns a + daemon-managed downloadable artifact, such as `screenshot`, `screen-recording`, or `trace-log`. + Finalization and inventory code must preserve this value when present, not infer it from + filenames, fields, or MIME types. Missing artifact types must not prevent artifact registration. + The type documents known values while allowing provider or command owners to introduce more + specific strings. - Provider transcript: exact record of provider calls used when a test must verify platform command translation. - Scenario transcript: command-level integration flow that describes user-visible behavior through daemon commands. - In-process provider scenario harness: integration runner that invokes the daemon request handler directly without opening an HTTP listener. diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 28175e0edd..83babf0511 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { createAgentDeviceClient, type AgentDeviceClientConfig } from '../client/client.ts'; import { runCommand } from '../commands/command-surface.ts'; -import type { DaemonRequest, DaemonResponse } from '../kernel/contracts.ts'; +import type { DaemonRequest, DaemonResponse, DaemonResponseData } from '../kernel/contracts.ts'; import { AppError } from '../kernel/errors.ts'; function createTransport( @@ -809,11 +809,11 @@ test('sessions.stateDir resolves locally without contacting the daemon', async ( }); test('capture.screenshot passes a digest (non-default level) payload through unnormalized', async () => { - const digest = { + const digest: DaemonResponseData = { path: '/tmp/shot.png', overlayCount: 2, overlayRefs: [{ ref: 'e1', label: 'Login' }], - artifacts: [{ field: 'path', artifactId: 'a1' }], + artifacts: [{ field: 'path', artifactType: 'screenshot', artifactId: 'a1' }], }; const setup = createTransport(async (req) => { assert.equal(req.command, 'screenshot'); diff --git a/src/__tests__/daemon-entrypoint.test.ts b/src/__tests__/daemon-entrypoint.test.ts index eca6b33cb8..5ead02e1b4 100644 --- a/src/__tests__/daemon-entrypoint.test.ts +++ b/src/__tests__/daemon-entrypoint.test.ts @@ -64,7 +64,11 @@ test('daemon runtime starts HTTP transport in-process and shuts down cleanly', a const paths = resolveDaemonPaths(stateDir); const artifactPath = path.join(stateDir, 'runtime-artifact.txt'); fs.writeFileSync(artifactPath, 'runtime-artifact'); - const artifactId = trackDownloadableArtifact({ artifactPath, fileName: 'runtime-artifact.txt' }); + const artifactId = trackDownloadableArtifact({ + artifactPath, + artifactType: 'runtime-artifact', + fileName: 'runtime-artifact.txt', + }); const stdout: string[] = []; const stderr: string[] = []; let exitCode: number | undefined; diff --git a/src/__tests__/runtime-public.test.ts b/src/__tests__/runtime-public.test.ts index 168cf23578..7c8559438b 100644 --- a/src/__tests__/runtime-public.test.ts +++ b/src/__tests__/runtime-public.test.ts @@ -120,6 +120,7 @@ test('local artifact adapter marks command outputs and temp files by visibility' const output = await adapter.reserveOutput(undefined, { field: 'path', ext: '.png', + artifactType: 'screenshot', visibility: 'client-visible', }); const temp = await adapter.createTempFile({ @@ -153,7 +154,7 @@ test('local artifact adapter can constrain explicit local paths to a root', asyn () => adapter.reserveOutput( { kind: 'path', path: path.join(path.dirname(root), 'outside.png') }, - { field: 'path', ext: '.png' }, + { field: 'path', ext: '.png', artifactType: 'screenshot' }, ), /outside the artifact adapter root/, ); diff --git a/src/cli/commands/__tests__/screenshot.test.ts b/src/cli/commands/__tests__/screenshot.test.ts index 2e2494a078..4347faa47f 100644 --- a/src/cli/commands/__tests__/screenshot.test.ts +++ b/src/cli/commands/__tests__/screenshot.test.ts @@ -40,7 +40,7 @@ test('screenshot --level digest --json preserves the digest payload through the path: '/tmp/shot.png', overlayCount: 2, overlayRefs: [{ ref: 'e1', label: 'Login' }], - artifacts: [{ field: 'path', artifactId: 'a1' }], + artifacts: [{ field: 'path', artifactType: 'screenshot', artifactId: 'a1' }], }; const client = clientReturning(digest, 'digest'); const flags = { json: true, responseLevel: 'digest' } as CliFlags; diff --git a/src/cloud-artifacts.ts b/src/cloud-artifacts.ts index aac1c1ffd5..aecbef3fad 100644 --- a/src/cloud-artifacts.ts +++ b/src/cloud-artifacts.ts @@ -1,3 +1,5 @@ +import type { DaemonArtifactType } from './kernel/contracts.ts'; + const CLOUD_ARTIFACT_KINDS = [ 'video', 'appium-log', @@ -36,6 +38,8 @@ export type CloudArtifactsResult = { export type DaemonArtifactInventoryEntry = { id: string; + // Optional on the wire (see DaemonArtifact.artifactType). + artifactType?: DaemonArtifactType; filename: string; mimeType: string; sizeBytes: number; diff --git a/src/commands/capture/runtime/diff-screenshot.ts b/src/commands/capture/runtime/diff-screenshot.ts index dc01222477..554f9e5be3 100644 --- a/src/commands/capture/runtime/diff-screenshot.ts +++ b/src/commands/capture/runtime/diff-screenshot.ts @@ -86,6 +86,7 @@ export const diffScreenshotCommand: RuntimeCommand< ? await reserveCommandOutput(runtime, options.out, { field: 'diffPath', ext: '.png', + artifactType: 'screenshot-diff', }) : undefined; @@ -161,6 +162,7 @@ async function maybeAttachCurrentOverlay( const overlayOutput = await reserveCommandOutput(runtime, overlayOutputRef, { field: 'currentOverlayPath', ext: '.png', + artifactType: 'screenshot', }); try { diff --git a/src/commands/capture/runtime/screenshot.ts b/src/commands/capture/runtime/screenshot.ts index fa77ce98b3..d5b94def67 100644 --- a/src/commands/capture/runtime/screenshot.ts +++ b/src/commands/capture/runtime/screenshot.ts @@ -22,6 +22,7 @@ export const screenshotCommand: RuntimeCommand< const reserved = await reserveCommandOutput(runtime, options.out, { field: 'path', ext: '.png', + artifactType: 'screenshot', }); let artifact: ArtifactDescriptor | undefined; diff --git a/src/commands/management/output.test.ts b/src/commands/management/output.test.ts index 23a74ad034..e4b0e6df7f 100644 --- a/src/commands/management/output.test.ts +++ b/src/commands/management/output.test.ts @@ -90,6 +90,7 @@ describe('artifactsCliOutput', () => { artifacts: [ { id: 'artifact-1', + artifactType: 'screenshot', filename: 'screenshot.png', mimeType: 'application/octet-stream', sizeBytes: 123, @@ -100,10 +101,12 @@ describe('artifactsCliOutput', () => { }, }); - expect(output.text).toBe('screenshot.png: application/octet-stream 123 bytes id=artifact-1'); + expect(output.text).toBe( + 'screenshot.png (screenshot): application/octet-stream 123 bytes id=artifact-1', + ); expect(output.data).toMatchObject({ source: 'daemon', - artifacts: [{ id: 'artifact-1', filename: 'screenshot.png' }], + artifacts: [{ id: 'artifact-1', artifactType: 'screenshot', filename: 'screenshot.png' }], }); }); }); diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index 9859161ccd..6313d9ccc6 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -194,7 +194,8 @@ function formatCloudArtifactLine(artifact: CloudArtifactsResult['cloudArtifacts' } function formatDaemonArtifactLine(artifact: DaemonArtifactsResult['artifacts'][number]): string { - return `${artifact.filename}: ${artifact.mimeType} ${artifact.sizeBytes} bytes id=${artifact.id}`; + const type = artifact.artifactType ? ` (${artifact.artifactType})` : ''; + return `${artifact.filename}${type}: ${artifact.mimeType} ${artifact.sizeBytes} bytes id=${artifact.id}`; } function formatCloudArtifactsRetryCommand(result: CloudArtifactsResult): string | undefined { diff --git a/src/commands/recording/runtime/recording.test.ts b/src/commands/recording/runtime/recording.test.ts index 61c58b4dbf..ab019a04ec 100644 --- a/src/commands/recording/runtime/recording.test.ts +++ b/src/commands/recording/runtime/recording.test.ts @@ -108,6 +108,7 @@ test('record keeps successful reserved outputs available after publish', async ( publish: async () => ({ kind: 'artifact', field: options.field, + artifactType: options.artifactType, artifactId: 'recording-1', fileName: 'recording.mp4', }), diff --git a/src/commands/recording/runtime/recording.ts b/src/commands/recording/runtime/recording.ts index 7da485102f..f55235df15 100644 --- a/src/commands/recording/runtime/recording.ts +++ b/src/commands/recording/runtime/recording.ts @@ -69,6 +69,7 @@ export const recordCommand: RuntimeCommand< ? await reserveCommandOutput(runtime, options.out, { field: 'path', ext: '.mp4', + artifactType: 'screen-recording', }) : undefined; try { @@ -100,6 +101,7 @@ export const traceCommand: RuntimeCommand< ? await reserveCommandOutput(runtime, options.out, { field: 'outPath', ext: '.trace', + artifactType: 'trace-log', }) : undefined; try { diff --git a/src/daemon/__tests__/http-server-artifacts.test.ts b/src/daemon/__tests__/http-server-artifacts.test.ts index 0cc0efab22..99d46a2bab 100644 --- a/src/daemon/__tests__/http-server-artifacts.test.ts +++ b/src/daemon/__tests__/http-server-artifacts.test.ts @@ -20,6 +20,7 @@ import { type ArtifactInventoryResponse = { artifacts: Array<{ id: string; + artifactType?: string; filename: string; mimeType: string; sizeBytes: number; @@ -37,15 +38,21 @@ test('downloadable artifact inventory is filtered by tenant', async () => { fs.writeFileSync(tenantAPath, 'tenant-a'); fs.writeFileSync(tenantBPath, 'tenant-b'); const artifactIds = [ - trackDownloadableArtifact({ artifactPath: publicPath, fileName: 'public.txt' }), + trackDownloadableArtifact({ + artifactPath: publicPath, + artifactType: 'test-public-file', + fileName: 'public.txt', + }), trackDownloadableArtifact({ artifactPath: tenantAPath, tenantId: 'tenant-a', + artifactType: 'test-tenant-file', fileName: 'tenant-a.txt', }), trackDownloadableArtifact({ artifactPath: tenantBPath, tenantId: 'tenant-b', + artifactType: 'test-tenant-file', fileName: 'tenant-b.txt', }), ]; @@ -79,8 +86,16 @@ test('downloadable artifact inventory skips directory artifacts that fail to arc fs.mkdirSync(tracePath, { recursive: true }); fs.writeFileSync(path.join(tracePath, 'metadata.json'), '{}\n'); const artifactIds = [ - trackDownloadableArtifact({ artifactPath: filePath, fileName: 'report.json' }), - trackDownloadableArtifact({ artifactPath: tracePath, fileName: 'profile.trace' }), + trackDownloadableArtifact({ + artifactPath: filePath, + artifactType: 'test-report', + fileName: 'report.json', + }), + trackDownloadableArtifact({ + artifactPath: tracePath, + artifactType: 'trace-log', + fileName: 'profile.trace', + }), ]; try { @@ -112,6 +127,7 @@ test('daemon artifact inventory exposes directory artifacts as tar.gz downloads' fs.writeFileSync(path.join(tracePath, 'metadata.json'), '{"ok":true}\n'); const artifactId = trackDownloadableArtifact({ artifactPath: tracePath, + artifactType: 'trace-log', fileName: 'profile.trace', }); const server = await createDaemonHttpServer({ @@ -174,6 +190,7 @@ test('daemon artifact inventory lists artifacts and downloads consume them', asy fs.writeFileSync(artifactPath, 'png-body'); const artifactId = trackDownloadableArtifact({ artifactPath, + artifactType: 'screenshot', fileName: 'shot.png', }); const server = await createDaemonHttpServer({ @@ -191,6 +208,7 @@ test('daemon artifact inventory lists artifacts and downloads consume them', asy const body = (await inventory.json()) as ArtifactInventoryResponse; const artifact = body.artifacts.find((entry) => entry.id === artifactId); assert.ok(artifact, `expected ${artifactId} in artifact inventory`); + assert.equal(artifact.artifactType, 'screenshot'); assert.equal(artifact.filename, 'shot.png'); assert.equal(artifact.mimeType, 'application/octet-stream'); assert.equal(artifact.sizeBytes, 'png-body'.length); @@ -228,6 +246,7 @@ test('daemon artifact downloads can keep the source file while consuming the inv fs.writeFileSync(artifactPath, 'runner-output'); const artifactId = trackDownloadableArtifact({ artifactPath, + artifactType: 'runner-output', fileName: 'runner-output.txt', deleteAfterDownload: false, }); @@ -256,12 +275,11 @@ test('daemon artifact downloads can keep the source file while consuming the inv assert.equal(await consumingDownload.text(), 'runner-output'); assert.equal(fs.existsSync(artifactPath), true); - const inventoryAfterConsume = await fetch(`${baseUrl}/artifacts`, { headers: auth }); - const consumedBody = (await inventoryAfterConsume.json()) as ArtifactInventoryResponse; - assert.equal( - consumedBody.artifacts.some((entry) => entry.id === artifactId), - false, - ); + await waitFor(async () => { + const inventoryAfterConsume = await fetch(`${baseUrl}/artifacts`, { headers: auth }); + const consumedBody = (await inventoryAfterConsume.json()) as ArtifactInventoryResponse; + return !consumedBody.artifacts.some((entry) => entry.id === artifactId); + }); } finally { cleanupDownloadableArtifact(artifactId); await closeLoopbackServer(server); @@ -277,6 +295,7 @@ test('daemon artifact downloads can be forced retained by server option', async fs.writeFileSync(artifactPath, 'log-body'); const artifactId = trackDownloadableArtifact({ artifactPath, + artifactType: 'session-log', fileName: 'session-log.txt', }); const server = await createDaemonHttpServer({ @@ -314,9 +333,10 @@ test('daemon artifact downloads can be forced retained by server option', async } }); -async function waitFor(condition: () => boolean): Promise { +async function waitFor(condition: () => boolean | Promise): Promise { for (let attempt = 0; attempt < 20; attempt++) { - if (condition()) return; + if (await condition()) return; await new Promise((resolve) => setTimeout(resolve, 10)); } + throw new Error('Timed out waiting for condition'); } diff --git a/src/daemon/__tests__/request-finalization.test.ts b/src/daemon/__tests__/request-finalization.test.ts index 8eeba9a37b..57c7941593 100644 --- a/src/daemon/__tests__/request-finalization.test.ts +++ b/src/daemon/__tests__/request-finalization.test.ts @@ -1,6 +1,7 @@ import { test, expect } from 'vitest'; import { finalizeDaemonResponse } from '../request-finalization.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; +import type { DaemonArtifactType } from '../../kernel/contracts.ts'; test('finalizeDaemonResponse preserves handler error hints from details', () => { const req: DaemonRequest = { @@ -29,3 +30,143 @@ test('finalizeDaemonResponse preserves handler error hints from details', () => expect(finalized.error.hint).toBe('Run agent-device session list and reuse --session default.'); } }); + +test('finalizeDaemonResponse registers downloadable artifact type', () => { + const req: DaemonRequest = { + token: 'token', + session: 'default', + command: 'record', + positionals: ['stop'], + meta: { tenantId: 'tenant-a' }, + }; + const response: DaemonResponse = { + ok: true, + data: { + artifacts: [ + { + field: 'telemetryPath', + artifactType: 'screen-recording-telemetry', + path: '/tmp/telemetry.json', + localPath: '/client/telemetry.json', + fileName: 'telemetry.json', + }, + { + field: 'rawPath', + artifactType: undefined, + path: '/tmp/raw.bin', + localPath: '/client/raw.bin', + fileName: 'raw.bin', + }, + ], + }, + }; + const tracked: Array<{ + artifactPath: string; + tenantId?: string; + artifactType?: DaemonArtifactType; + fileName?: string; + }> = []; + + const finalized = finalizeDaemonResponse(req, response, (opts) => { + tracked.push(opts); + return `artifact-id-${tracked.length}`; + }); + + expect(finalized).toEqual({ + ok: true, + data: { + artifacts: [ + { + field: 'telemetryPath', + artifactType: 'screen-recording-telemetry', + artifactId: 'artifact-id-1', + fileName: 'telemetry.json', + localPath: '/client/telemetry.json', + }, + { + field: 'rawPath', + artifactId: 'artifact-id-2', + fileName: 'raw.bin', + localPath: '/client/raw.bin', + }, + ], + }, + }); + // The untyped artifact must omit the key entirely (optional wire contract), + // not carry an explicit undefined — toEqual alone cannot tell these apart. + const finalizedArtifacts = + finalized.ok === true + ? (finalized.data?.artifacts as Array>) + : undefined; + expect(finalizedArtifacts?.[1]).not.toHaveProperty('artifactType'); + expect(tracked).toEqual([ + { + artifactPath: '/tmp/telemetry.json', + tenantId: 'tenant-a', + artifactType: 'screen-recording-telemetry', + fileName: 'telemetry.json', + }, + { + artifactPath: '/tmp/raw.bin', + tenantId: 'tenant-a', + artifactType: undefined, + fileName: 'raw.bin', + }, + ]); +}); + +test('finalizeDaemonResponse keeps screenshot path fallback as screenshot artifact type', () => { + const req: DaemonRequest = { + token: 'token', + session: 'default', + command: 'screenshot', + positionals: [], + meta: { + clientArtifactPaths: { + path: '/client/screenshot.png', + }, + tenantId: 'tenant-a', + }, + }; + const response: DaemonResponse = { + ok: true, + data: { + path: '/tmp/screenshot.png', + }, + }; + const tracked: Array<{ + artifactPath: string; + tenantId?: string; + artifactType?: DaemonArtifactType; + fileName?: string; + }> = []; + + const finalized = finalizeDaemonResponse(req, response, (opts) => { + tracked.push(opts); + return 'artifact-id'; + }); + + expect(finalized).toEqual({ + ok: true, + data: { + path: '/tmp/screenshot.png', + artifacts: [ + { + field: 'path', + artifactType: 'screenshot', + artifactId: 'artifact-id', + fileName: 'screenshot.png', + localPath: '/client/screenshot.png', + }, + ], + }, + }); + expect(tracked).toEqual([ + { + artifactPath: '/tmp/screenshot.png', + tenantId: 'tenant-a', + artifactType: 'screenshot', + fileName: 'screenshot.png', + }, + ]); +}); diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index 61f06f6b67..5a40a75ef4 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -301,6 +301,7 @@ function trackProxyLeaseArtifact(): { artifactId: string; tempDir: string } { tempDir, artifactId: trackDownloadableArtifact({ artifactPath, + artifactType: 'screenshot', fileName: 'proxy-shot.png', tenantId: 'tenant-a', }), diff --git a/src/daemon/__tests__/response-views.test.ts b/src/daemon/__tests__/response-views.test.ts index f752a942c9..a40d9d3b15 100644 --- a/src/daemon/__tests__/response-views.test.ts +++ b/src/daemon/__tests__/response-views.test.ts @@ -65,7 +65,14 @@ const SCREENSHOT_DATA: DaemonResponseData = { overlayRef('e1', 'Continue'), overlayRef('e2', undefined), // label omitted → stays undefined in the digest ], - artifacts: [{ field: 'path', artifactId: 'art-1', fileName: 'screenshot.png' }], // cheap retrieval handle — preserved + artifacts: [ + { + field: 'path', + artifactType: 'screenshot', + artifactId: 'art-1', + fileName: 'screenshot.png', + }, + ], // cheap retrieval handle — preserved }; test('screenshot view is registered', () => { @@ -81,7 +88,14 @@ test('digest collapses overlay geometry to count + leveled refs, keeps cheap fie { ref: 'e1', label: 'Continue' }, { ref: 'e2', label: undefined }, ], - artifacts: [{ field: 'path', artifactId: 'art-1', fileName: 'screenshot.png' }], + artifacts: [ + { + field: 'path', + artifactType: 'screenshot', + artifactId: 'art-1', + fileName: 'screenshot.png', + }, + ], }); // The per-overlay geometry (the token sink) is dropped from every ref. expect(digest.overlayRefs).not.toContainEqual( diff --git a/src/daemon/artifact-tracking.ts b/src/daemon/artifact-tracking.ts index 618c7a1c6c..9b0468cc6e 100644 --- a/src/daemon/artifact-tracking.ts +++ b/src/daemon/artifact-tracking.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { AppError } from '../kernel/errors.ts'; +import type { DaemonArtifactType } from '../kernel/contracts.ts'; import { runCmd } from '../utils/exec.ts'; // --- Downloadable artifact tracking --- @@ -21,6 +22,7 @@ type DirectoryArchive = { type ArtifactEntry = { artifactPath: string; tenantId?: string; + artifactType: DaemonArtifactType | undefined; fileName?: string; deleteAfterDownload: boolean; createdAt: number; @@ -33,6 +35,8 @@ const pendingArtifacts = new Map(); export type DownloadableArtifactInventoryEntry = { id: string; + // Optional on the wire (see DaemonArtifact.artifactType). + artifactType?: DaemonArtifactType; filename: string; mimeType: string; sizeBytes: number; @@ -50,6 +54,7 @@ export type PreparedDownloadableArtifact = { export function trackDownloadableArtifact(params: { artifactPath: string; tenantId?: string; + artifactType: DaemonArtifactType | undefined; fileName?: string; deleteAfterDownload?: boolean; }): string { @@ -62,6 +67,7 @@ export function trackDownloadableArtifact(params: { pendingArtifacts.set(artifactId, { artifactPath: params.artifactPath, tenantId: params.tenantId, + artifactType: params.artifactType, fileName: params.fileName, deleteAfterDownload: params.deleteAfterDownload !== false, createdAt, @@ -102,6 +108,7 @@ export async function listDownloadableArtifacts( if (!payload) continue; artifacts.push({ id, + ...(entry.artifactType !== undefined ? { artifactType: entry.artifactType } : {}), filename: payload.fileName ?? id, mimeType: payload.mimeType, sizeBytes: payload.sizeBytes, diff --git a/src/daemon/handlers/__tests__/session-replay.test.ts b/src/daemon/handlers/__tests__/session-replay.test.ts index 69d0047f91..fa5544eda7 100644 --- a/src/daemon/handlers/__tests__/session-replay.test.ts +++ b/src/daemon/handlers/__tests__/session-replay.test.ts @@ -119,6 +119,7 @@ function stopMockRecording(params: { artifacts: [ { field: 'outPath', + artifactType: 'screen-recording', path: state.recordingPath, fileName: path.basename(state.recordingPath), }, diff --git a/src/daemon/handlers/record-trace-recording.ts b/src/daemon/handlers/record-trace-recording.ts index 967206f3ff..48bb9fed29 100644 --- a/src/daemon/handlers/record-trace-recording.ts +++ b/src/daemon/handlers/record-trace-recording.ts @@ -213,6 +213,7 @@ function buildRecordStopResponse( const artifacts: DaemonArtifact[] = [ { field: 'outPath', + artifactType: 'screen-recording', path: recording.outPath, localPath: recording.clientOutPath, fileName: path.basename(recording.clientOutPath ?? recording.outPath), @@ -222,6 +223,7 @@ function buildRecordStopResponse( artifacts.push( ...chunks.slice(1).map((chunk) => ({ field: 'chunkPath', + artifactType: 'screen-recording-chunk' as const, path: chunk.path, localPath: deriveAndroidChunkClientPath(recording, chunk.index), fileName: path.basename(deriveAndroidChunkClientPath(recording, chunk.index) ?? chunk.path), @@ -231,6 +233,7 @@ function buildRecordStopResponse( if (recording.telemetryPath) { artifacts.push({ field: 'telemetryPath', + artifactType: 'screen-recording-telemetry', path: recording.telemetryPath, localPath: deriveClientTelemetryPath(recording), fileName: path.basename(recording.telemetryPath), diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index a9a4f53b06..e9d9a63178 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -1,4 +1,5 @@ import type { CommandFlags } from '../core/dispatch.ts'; +import type { DaemonArtifactType } from '../kernel/contracts.ts'; import { withKeyedLock } from '../utils/keyed-lock.ts'; import { emitDiagnostic, @@ -182,6 +183,7 @@ export function prepareLockedRequestScope(params: { trackDownloadableArtifact: (opts: { artifactPath: string; tenantId?: string; + artifactType: DaemonArtifactType | undefined; fileName?: string; }) => string; }): LockedRequestScopeResult { diff --git a/src/daemon/request-finalization.ts b/src/daemon/request-finalization.ts index d8a02651f3..6d8f95677f 100644 --- a/src/daemon/request-finalization.ts +++ b/src/daemon/request-finalization.ts @@ -5,12 +5,18 @@ import { flushDiagnosticsToSessionFile, getDiagnosticsMeta, } from '../utils/diagnostics.ts'; -import type { DaemonArtifact, DaemonRequest, DaemonResponse, DaemonResponseData } from './types.ts'; +import type { DaemonRequest, DaemonResponse, DaemonResponseData } from './types.ts'; +import type { DaemonArtifact, DaemonArtifactType } from '../kernel/contracts.ts'; export function finalizeDaemonResponse( req: DaemonRequest, response: DaemonResponse, - trackArtifact: (opts: { artifactPath: string; tenantId?: string; fileName?: string }) => string, + trackArtifact: (opts: { + artifactPath: string; + tenantId?: string; + artifactType: DaemonArtifactType | undefined; + fileName?: string; + }) => string, ): DaemonResponse { const details = getDiagnosticsMeta(); if (!response.ok) { @@ -52,7 +58,12 @@ export function finalizeDaemonResponse( function registerDownloadableArtifacts( req: DaemonRequest, data: DaemonResponseData | undefined, - trackArtifact: (opts: { artifactPath: string; tenantId?: string; fileName?: string }) => string, + trackArtifact: (opts: { + artifactPath: string; + tenantId?: string; + artifactType: DaemonArtifactType | undefined; + fileName?: string; + }) => string, ): DaemonResponseData | undefined { if (!data) return data; const pendingArtifacts = collectPendingArtifacts(req, data); @@ -63,9 +74,13 @@ function registerDownloadableArtifacts( const artifactPath = artifact.path as string; return { field: artifact.field, + // Omitted (not null/undefined-valued) when untyped, matching the + // optional wire contract on DaemonArtifact. + ...(artifact.artifactType !== undefined ? { artifactType: artifact.artifactType } : {}), artifactId: trackArtifact({ artifactPath, tenantId: req.meta?.tenantId, + artifactType: artifact.artifactType, fileName: artifact.fileName, }), fileName: artifact.fileName, @@ -82,6 +97,7 @@ function collectPendingArtifacts(req: DaemonRequest, data: DaemonResponseData): if (req.command === 'screenshot' && !hasField('path') && typeof data.path === 'string') { artifacts.push({ field: 'path', + artifactType: 'screenshot', path: data.path, localPath: req.meta?.clientArtifactPaths?.path, fileName: path.basename(req.meta?.clientArtifactPaths?.path ?? data.path), diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index ef74ed1951..c73519423d 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -5,7 +5,7 @@ import { import { AppError, normalizeError, retriableForErrorCode } from '../kernel/errors.ts'; import { supportedPlatformsForCommand } from '../core/capabilities.ts'; import { timingSafeStringEqual } from '../utils/timing-safe-equal.ts'; -import type { DaemonError, ResponseCost } from '../kernel/contracts.ts'; +import type { DaemonArtifactType, DaemonError, ResponseCost } from '../kernel/contracts.ts'; import type { CloudArtifactProvider } from '../cloud-artifacts.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, DaemonResponseData } from './types.ts'; import { RESPONSE_VIEWS } from './response-views.ts'; @@ -66,6 +66,7 @@ export type RequestRouterDeps = { trackDownloadableArtifact: (opts: { artifactPath: string; tenantId?: string; + artifactType: DaemonArtifactType | undefined; fileName?: string; }) => string; }; diff --git a/src/io.ts b/src/io.ts index 78001c35bb..446387a5e6 100644 --- a/src/io.ts +++ b/src/io.ts @@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { AppError } from './kernel/errors.ts'; +import type { DaemonArtifactType } from './kernel/contracts.ts'; export type FileInputRef = | { @@ -28,6 +29,7 @@ export type ArtifactDescriptor = | { kind: 'localPath'; field: string; + artifactType: DaemonArtifactType | undefined; path: string; fileName?: string; metadata?: Record; @@ -35,6 +37,7 @@ export type ArtifactDescriptor = | { kind: 'artifact'; field: string; + artifactType: DaemonArtifactType | undefined; artifactId: string; fileName?: string; url?: string; @@ -70,6 +73,7 @@ export type ResolveInputOptions = { export type ReserveOutputOptions = { field: string; ext: string; + artifactType: DaemonArtifactType | undefined; requestedClientPath?: string; visibility?: OutputVisibility; }; @@ -139,6 +143,7 @@ export function createLocalArtifactAdapter( ? { kind: 'localPath', field: outputOptions.field, + artifactType: outputOptions.artifactType, path: outputPath, fileName: ref.fileName ?? path.basename(ref.clientPath ?? outputPath), } diff --git a/src/kernel/contracts.ts b/src/kernel/contracts.ts index c1509423de..fe90603dda 100644 --- a/src/kernel/contracts.ts +++ b/src/kernel/contracts.ts @@ -110,8 +110,23 @@ export type DaemonRequest = { meta?: DaemonRequestMeta; }; +export type DaemonArtifactKnownType = + | 'screenshot' + | 'screenshot-diff' + | 'screen-recording' + | 'screen-recording-chunk' + | 'screen-recording-telemetry' + | 'trace-log'; + +export type DaemonArtifactType = DaemonArtifactKnownType | (string & {}); + export type DaemonArtifact = { field: string; + // Optional on the wire: missing metadata is valid, JSON drops undefined, and + // remote/older daemons may omit the field entirely. Producer-owned APIs + // (reserveOutput, trackDownloadableArtifact) keep the required + // `DaemonArtifactType | undefined` form so artifact owners must decide. + artifactType?: DaemonArtifactType; artifactId?: string; fileName?: string; localPath?: string; diff --git a/test/integration/provider-scenarios/daemon-http-server.test.ts b/test/integration/provider-scenarios/daemon-http-server.test.ts index 8b40daf980..be2e4a802f 100644 --- a/test/integration/provider-scenarios/daemon-http-server.test.ts +++ b/test/integration/provider-scenarios/daemon-http-server.test.ts @@ -476,6 +476,7 @@ test('Provider-backed integration daemon HTTP server accepts uploads and streams fs.writeFileSync(downloadablePath, 'png-binary'); const artifactId = trackDownloadableArtifact({ artifactPath: downloadablePath, + artifactType: 'screenshot', fileName: 'screen.png', }); const server = await createDaemonHttpServer({