feat: tag daemon artifacts with semantic types (#1066)

* feat: tag daemon artifacts with semantic types

* Normalize artifact type threading through daemon tracking

* Add timeout error to http server artifact test helper

* test: restore artifact wait helper behavior

* test: fail artifact wait helper on timeout

* refactor: model artifactType as optional on wire shapes

Applies the design-review tweak: producer-owned APIs (reserveOutput,
trackDownloadableArtifact, finalization callbacks) keep the required
'DaemonArtifactType | undefined' form so artifact owners must explicitly
decide, while public/wire/result shapes (DaemonArtifact, both artifact
inventory entry types) become 'artifactType?:' — missing metadata is
valid, JSON drops undefined, and remote/older daemons may omit the
field. Construction sites now omit the key for untyped artifacts, and
the finalization test asserts key absence (toEqual cannot distinguish
absent from explicitly-undefined).

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
This commit is contained in:
Stanisław Chmiela
2026-07-04 12:19:47 +02:00
committed by GitHub
parent 664b713130
commit 9407839f6e
25 changed files with 278 additions and 26 deletions
+6
View File
@@ -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.
+3 -3
View File
@@ -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');
+5 -1
View File
@@ -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;
+2 -1
View File
@@ -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/,
);
@@ -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;
+4
View File
@@ -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;
@@ -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 {
@@ -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;
+5 -2
View File
@@ -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' }],
});
});
});
+2 -1
View File
@@ -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 {
@@ -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',
}),
@@ -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 {
@@ -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<void> {
async function waitFor(condition: () => boolean | Promise<boolean>): Promise<void> {
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');
}
@@ -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<Record<string, unknown>>)
: 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',
},
]);
});
@@ -301,6 +301,7 @@ function trackProxyLeaseArtifact(): { artifactId: string; tempDir: string } {
tempDir,
artifactId: trackDownloadableArtifact({
artifactPath,
artifactType: 'screenshot',
fileName: 'proxy-shot.png',
tenantId: 'tenant-a',
}),
+16 -2
View File
@@ -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(
+7
View File
@@ -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';
import {
expiredTenantOwnedEntryError,
@@ -31,6 +32,7 @@ type DirectoryArchive = {
type ArtifactEntry = {
artifactPath: string;
tenantId?: string;
artifactType: DaemonArtifactType | undefined;
fileName?: string;
deleteAfterDownload: boolean;
createdAt: number;
@@ -43,6 +45,8 @@ const pendingArtifacts = new Map<string, ArtifactEntry>();
export type DownloadableArtifactInventoryEntry = {
id: string;
// Optional on the wire (see DaemonArtifact.artifactType).
artifactType?: DaemonArtifactType;
filename: string;
mimeType: string;
sizeBytes: number;
@@ -60,6 +64,7 @@ export type PreparedDownloadableArtifact = {
export function trackDownloadableArtifact(params: {
artifactPath: string;
tenantId?: string;
artifactType: DaemonArtifactType | undefined;
fileName?: string;
deleteAfterDownload?: boolean;
}): string {
@@ -72,6 +77,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,
@@ -112,6 +118,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,
@@ -119,6 +119,7 @@ function stopMockRecording(params: {
artifacts: [
{
field: 'outPath',
artifactType: 'screen-recording',
path: state.recordingPath,
fileName: path.basename(state.recordingPath),
},
@@ -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),
+2
View File
@@ -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 {
+19 -3
View File
@@ -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),
+2 -1
View File
@@ -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;
};
+5
View File
@@ -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<string, unknown>;
@@ -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),
}
+15
View File
@@ -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;
@@ -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({