mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
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:
committed by
GitHub
parent
664b713130
commit
9407839f6e
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user