Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ test('record start preserves the requested path while the runtime receives the e
);
});

test('record start without a path records a remote caller default on the daemon host', async () => {
const harness = makeRecordRuntimeHarness('record-runtime-remote-default-');

const response = await harness.run(['start'], {
cwd: '/client/project',
clientArtifactPaths: { outPath: '/client/project/recording-1' },
});

expect(response).toMatchObject({
ok: true,
data: { outPath: '/client/project/recording-1.mp4' },
});
expect(harness.runtime.start).toHaveBeenCalledWith(
expect.objectContaining({
outputPath: expect.stringMatching(/^\/tmp\/agent-device-recording-\d+-[a-z0-9]+\.mp4$/),
clientOutputPath: '/client/project/recording-1.mp4',
}),
);
});

test.each(['linux', 'vega'] as const)(
'record start on %s reports public unsupported guidance',
async (platform) => {
Expand Down
46 changes: 36 additions & 10 deletions packages/daemon/src/sessions/handlers/record-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from '@agent-device/contracts/screen-recording-runtime-plan';
import { isWholeScreenRecordingScope } from '@agent-device/contracts/recording';
import { AppError, normalizeError } from '@agent-device/kernel/errors';
import type { Platform } from '@agent-device/kernel/device';
import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve';
import { ensureBoundDeviceReady } from '../../core/request-runtime-binding.ts';
import { type ScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger';
Expand Down Expand Up @@ -46,7 +47,13 @@ import {
prepareRecordingRequest,
readRecordingScope,
} from './record-runtime-request.ts';
import { resolveRecordingOutputPaths } from '@agent-device/capture-kit/recording-output-path';
import {
appendRecordingExtensionWhenMissing,
type RecordingOutputPaths,
recordingExtensionForPlatform,
resolveRecordingOutputPaths,
} from '@agent-device/capture-kit/recording-output-path';
import { buildRemoteTempArtifactPath } from '@agent-device/contracts/daemon-http';
import {
buildRecordingStartResponse,
buildRecordingStartedAction,
Expand Down Expand Up @@ -163,7 +170,7 @@ async function startRecording(
const { fence, outputPaths } = prepareRecordingStart(params, session);
binding.assertAdoptable();
const started = await runtime.operations.screenRecordingStart(
screenRecordingStartInput(params, session, prepared, fence, outputPaths.outputPath),
screenRecordingStartInput(params, session, prepared, fence, outputPaths),
);
await adoptStartedScreenRecording({
admissionLedger: params.admissionLedger,
Expand Down Expand Up @@ -203,11 +210,30 @@ function prepareRecordingStart(params: RecordRuntimeHandlerParams, session: Sess
resourcePath,
device: session.device,
}),
outputPaths: resolveRecordingOutputPaths({
requestedPath: params.req.positionals?.[1],
platform: session.device.platform,
cwd: params.req.meta?.cwd,
}),
outputPaths: resolveRecordingStartPaths(params.req, session.device.platform),
};
}

type RecordingStartPaths = RecordingOutputPaths & Readonly<{ clientOutputPath?: string }>;

function resolveRecordingStartPaths(req: DaemonRequest, platform: Platform): RecordingStartPaths {
const requestedPath = req.positionals?.[1];
const clientOutputPath = req.meta?.clientArtifactPaths?.outPath;
if (requestedPath === undefined && clientOutputPath !== undefined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Blank path arguments are treated as remote defaults by the client but not by the daemon. Treat an empty or whitespace-only requestedPath as absent here so the daemon uses its temp artifact and completes the client extension.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/daemon/src/sessions/handlers/record-runtime.ts, line 222:

<comment>Blank path arguments are treated as remote defaults by the client but not by the daemon. Treat an empty or whitespace-only `requestedPath` as absent here so the daemon uses its temp artifact and completes the client extension.</comment>

<file context>
@@ -206,12 +210,30 @@ function prepareRecordingStart(params: RecordRuntimeHandlerParams, session: Sess
+function resolveRecordingStartPaths(req: DaemonRequest, platform: Platform): RecordingStartPaths {
+  const requestedPath = req.positionals?.[1];
+  const clientOutputPath = req.meta?.clientArtifactPaths?.outPath;
+  if (requestedPath === undefined && clientOutputPath !== undefined) {
+    // A remote caller that names no path cannot know the session platform's extension, so it sends
+    // its local destination without one; its cwd does not exist on this host.
</file context>
Suggested change
if (requestedPath === undefined && clientOutputPath !== undefined) {
if (
(requestedPath === undefined || requestedPath.trim().length === 0) &&
clientOutputPath !== undefined
) {

// A remote caller that names no path cannot know the session platform's extension, so it sends
// its local destination without one; its cwd does not exist on this host.
const extension = recordingExtensionForPlatform(platform);
return {
...resolveRecordingOutputPaths({
requestedPath: buildRemoteTempArtifactPath('recording', extension),
platform,
}),
clientOutputPath: appendRecordingExtensionWhenMissing(clientOutputPath, extension),
};
}
return {
...resolveRecordingOutputPaths({ requestedPath, platform, cwd: req.meta?.cwd }),
...(clientOutputPath === undefined ? {} : { clientOutputPath }),
};
}

Expand All @@ -216,12 +242,12 @@ function screenRecordingStartInput(
session: SessionState,
prepared: ReturnType<typeof prepareRecordingRequest>,
fence: ScreenRecordingStartInput['fence'],
outputPath: string,
outputPaths: RecordingStartPaths,
): ScreenRecordingStartInput {
return {
sessionId: params.sessionName,
outputPath,
clientOutputPath: params.req.meta?.clientArtifactPaths?.outPath,
outputPath: outputPaths.outputPath,
clientOutputPath: outputPaths.clientOutputPath,
scope: prepared.scope,
showTouches: prepared.showTouches,
hideTouchesRequested: prepared.hideTouchesRequested,
Expand Down
7 changes: 6 additions & 1 deletion src/remote/daemon-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,12 @@ function prepareRemoteArtifactCommand(
}
if (req.command === 'record' && (positionals[0] ?? '').toLowerCase() === 'start') {
if (!recordingHasRequestedClientPath(req) && req.flags?.platform === undefined) {
return null;
// The extension follows the session platform, which only the daemon knows: it completes
// this destination and records to a temp path on its own host.
return {
field: 'outPath',
localPath: resolveAbsoluteClientPath(`recording-${Date.now()}`, req.meta?.cwd),
};
}
const fallbackExtension = recordingFallbackExtension(req);
const localPath = normalizeRecordingClientArtifactPath(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ test('remote web recording appends WebM extension to extensionless client paths'
assert.equal(prepared.clientArtifactPaths?.outPath, '/tmp/project/recording.webm');
});

test('remote recording without platform or requested path lets daemon choose session-specific default', async () => {
test('remote recording without platform or requested path lets daemon name the client default', async () => {
const prepared = await prepareRemoteRequestArtifacts(
{
session: 'default',
Expand All @@ -723,7 +723,7 @@ test('remote recording without platform or requested path lets daemon choose ses
);

assert.deepEqual(prepared.positionals, ['start']);
assert.equal(prepared.clientArtifactPaths, undefined);
assert.match(prepared.clientArtifactPaths?.outPath ?? '', /^\/tmp\/project\/recording-\d+$/);
});

test('Provider-backed integration daemon proxy forwards remote client RPC commands', async (t) => {
Expand Down
2 changes: 1 addition & 1 deletion website/docs/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Remote daemons and tenants:

- `--tenant`, `--session-isolation tenant`, `--run-id`, and `--lease-id` enforce lease admission on tenant-scoped daemons.
- `--daemon-base-url http(s)://host:port[/base-path]` calls a remote HTTP daemon directly instead of starting a local one. Add `--daemon-auth-token <token>` (or `AGENT_DEVICE_DAEMON_AUTH_TOKEN`) for non-loopback daemon URLs.
- Screenshots and recordings from a remote daemon are downloaded to the path you pass, so `screenshot page.png` and `record start session.mp4` work as they do locally.
- Screenshots and recordings from a remote daemon are downloaded to the path you pass, so `screenshot page.png` and `record start session.mp4` work as they do locally. `record start` without a path downloads to `recording-<timestamp>.mp4` (`.webm` on web) in your working directory.
- To drive a Mac's simulators and devices from another machine, see [Remote Proxy](/docs/remote-proxy). For hosted devices in CI, see [BrowserStack](/docs/browserstack), [AWS Device Farm](/docs/aws-device-farm), or [TestMu AI](/docs/testmu). For `connect --remote-config`, see [Remote Metro workflow](#remote-metro-workflow).

```bash
Expand Down
Loading