diff --git a/.changeset/leased-recording-retention.md b/.changeset/leased-recording-retention.md new file mode 100644 index 0000000000..5e21f4a7b3 --- /dev/null +++ b/.changeset/leased-recording-retention.md @@ -0,0 +1,5 @@ +--- +'agent-device': patch +--- + +Keep a remote recording downloadable when its leased session ends mid-recording (lease expiry, release, or `close`). Teardown finishes the recording and registers the video and gesture telemetry as downloadable artifacts of the lease's tenant, instead of leaving them unreachable in the daemon's temp directory. `GET /artifacts` entries now carry an optional `sessionName` for such artifacts. A later `record stop` replay returns the same artifact ids. diff --git a/docs/adr/0007-remote-device-leases.md b/docs/adr/0007-remote-device-leases.md index 2d5b99a282..a8367ef263 100644 --- a/docs/adr/0007-remote-device-leases.md +++ b/docs/adr/0007-remote-device-leases.md @@ -76,6 +76,7 @@ Idle reap then shuts the daemon down within one more idle window. A session cannot outlive the lease under which it was opened. When the lease is released or expires, the daemon tears the session down under its execution locks and clears its device claim. +Any teardown of a leased session, including `close`, finishes an active recording. A finished export at a remote temp path then belongs to the lease's tenant as a downloadable artifact, like the artifact of any `record stop` (ADR 0024 2.5). This applies even when the device no longer answers. A release is a tenant `lease_release` or a host `DELETE /admin/leases/`. The daemon sweeps for expired leases every second. diff --git a/docs/adr/0024-screen-recording-provable-signal.md b/docs/adr/0024-screen-recording-provable-signal.md index bb0d587650..69eae5537d 100644 --- a/docs/adr/0024-screen-recording-provable-signal.md +++ b/docs/adr/0024-screen-recording-provable-signal.md @@ -342,6 +342,12 @@ Artifact cleanup is a separate, explicit decision (rule 6). Session teardown and failed-start rollback dispose through the backend under the fence. They do this only after the manifest records that no recoverable export is lost. +A leased session's teardown that commits an export at a remote temp path registers it, and its telemetry, as downloadable artifacts of the lease's tenant (rule 1, #2281). +The inventory names the session, and the usual delete-after-download and 15-minute expiry apply. +A later `record stop` replay of that manifest serves the same artifact ids. +If registration fails, teardown deletes the files. +A failed teardown finish still runs forced cleanup (disposal intent) and registers nothing. + ### 2.6 Native markers These markers cover the windows between the spawn of a native recorder and the publication of the manifest that names it: diff --git a/packages/contracts/src/cloud-artifacts.ts b/packages/contracts/src/cloud-artifacts.ts index 97ee5ff56e..5d3056a716 100644 --- a/packages/contracts/src/cloud-artifacts.ts +++ b/packages/contracts/src/cloud-artifacts.ts @@ -48,6 +48,12 @@ export type DaemonArtifactInventoryEntry = { expiresAt: string; /** Display rotation a screenshot was rendered in; absent when the capture reported none. */ displayRotation?: DeviceRotation; + /** + * Public name of the session that produced the artifact, without a tenant prefix. Present on an + * artifact the daemon registered outside a request reply, such as a recording kept when its + * leased session was torn down. + */ + sessionName?: string; }; export type DaemonArtifactsResult = { diff --git a/packages/daemon/src/core/session-tenant-scope.ts b/packages/daemon/src/core/session-tenant-scope.ts index 849686432d..305e530ca8 100644 --- a/packages/daemon/src/core/session-tenant-scope.ts +++ b/packages/daemon/src/core/session-tenant-scope.ts @@ -41,6 +41,11 @@ export function tenantScopedSessionName(tenant: string, session: string): string return isTenantOwnedSessionName(tenant, session) ? session : `${tenant}:${session}`; } +/** The tenant-relative name that addresses this session under tenant scoping. */ +export function tenantPublicSessionName(tenant: string, session: string): string { + return isTenantOwnedSessionName(tenant, session) ? session.slice(tenant.length + 1) : session; +} + /** * Whether a caller in `namespace` may address `sessionName` by name — the read * counterpart of `scopeRequestSession`'s naming decision, and the only tenant diff --git a/packages/daemon/src/resources/artifact-tracking.test.ts b/packages/daemon/src/resources/artifact-tracking.test.ts index 4b0b95b4b4..c0c013e4f1 100644 --- a/packages/daemon/src/resources/artifact-tracking.test.ts +++ b/packages/daemon/src/resources/artifact-tracking.test.ts @@ -27,3 +27,25 @@ test('disposing a runtime removes the temporary files it still tracks', () => { expect(fs.existsSync(uploadDir)).toBe(false); expect(fs.existsSync(artifactPath)).toBe(false); }); + +test('a file tracked twice for one tenant keeps one id, and another tenant gets its own', () => { + const root = mkdtempForTestSync('agent-device-artifact-reuse-'); + const artifactPath = path.join(root, 'recording.mp4'); + fs.writeFileSync(artifactPath, 'video'); + const runtime = createRuntimeStateHandle(); + + const [first, again, otherTenant] = runtime.run(() => { + const track = (tenantId: string) => + trackDownloadableArtifact({ + artifactPath, + tenantId, + artifactType: 'screen-recording', + fileName: 'recording.mp4', + }); + return [track('tenant-a'), track('tenant-a'), track('tenant-b')]; + }); + runtime.dispose(); + + expect(again).toBe(first); + expect(otherTenant).not.toBe(first); +}); diff --git a/packages/daemon/src/resources/artifact-tracking.ts b/packages/daemon/src/resources/artifact-tracking.ts index 70791de7c1..f3da5a83fc 100644 --- a/packages/daemon/src/resources/artifact-tracking.ts +++ b/packages/daemon/src/resources/artifact-tracking.ts @@ -38,6 +38,7 @@ type ArtifactEntry = { artifactType: DaemonArtifactType | undefined; fileName?: string; displayRotation?: DeviceRotation; + sessionName?: string; deleteAfterDownload: boolean; createdAt: number; directoryArchive?: DirectoryArchive; @@ -77,6 +78,8 @@ export type DownloadableArtifactRegistration = { artifactType: DaemonArtifactType | undefined; fileName?: string; displayRotation?: DeviceRotation; + /** Public session name, for an artifact registered outside a request reply. */ + sessionName?: string; }; export type TrackDownloadableArtifact = (registration: DownloadableArtifactRegistration) => string; @@ -88,9 +91,16 @@ export type PreparedDownloadableArtifact = { sizeBytes: number; }; +/** + * Registers one file for download and returns its id. A file the same tenant can already download + * returns its existing entry unchanged: two ids for one file would each delete it after their own + * download. + */ export function trackDownloadableArtifact( params: DownloadableArtifactRegistration & { deleteAfterDownload?: boolean }, ): string { + const existingId = findTrackedArtifactId(params.artifactPath, params.tenantId); + if (existingId) return existingId; const artifactId = crypto.randomUUID(); const createdAt = Date.now(); const timer = setTimeout(() => { @@ -103,6 +113,7 @@ export function trackDownloadableArtifact( artifactType: params.artifactType, fileName: params.fileName, ...(params.displayRotation !== undefined ? { displayRotation: params.displayRotation } : {}), + ...(params.sessionName !== undefined ? { sessionName: params.sessionName } : {}), deleteAfterDownload: params.deleteAfterDownload !== false, createdAt, timer, @@ -110,6 +121,16 @@ export function trackDownloadableArtifact( return artifactId; } +function findTrackedArtifactId( + artifactPath: string, + tenantId: string | undefined, +): string | undefined { + for (const [artifactId, entry] of pendingArtifacts()) { + if (entry.artifactPath === artifactPath && entry.tenantId === tenantId) return artifactId; + } + return undefined; +} + export async function prepareDownloadableArtifact( artifactId: string, tenantId: string | undefined, @@ -151,6 +172,7 @@ export async function listDownloadableArtifacts( createdAt: new Date(entry.createdAt).toISOString(), expiresAt: new Date(entry.createdAt + ARTIFACT_CLEANUP_TIMEOUT_MS).toISOString(), ...(entry.displayRotation !== undefined ? { displayRotation: entry.displayRotation } : {}), + ...(entry.sessionName !== undefined ? { sessionName: entry.sessionName } : {}), }); } return artifacts; diff --git a/packages/daemon/src/sessions/__tests__/leased-recording-retention.test.ts b/packages/daemon/src/sessions/__tests__/leased-recording-retention.test.ts new file mode 100644 index 0000000000..f3a621e980 --- /dev/null +++ b/packages/daemon/src/sessions/__tests__/leased-recording-retention.test.ts @@ -0,0 +1,303 @@ +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { afterEach, expect, test, vi } from 'vitest'; +import { + IOS_SIMULATOR, + closeLoopbackServer, + listenOnLoopback, + mkdtempForTestSync, + skipWhenLoopbackUnavailable, +} from '@agent-device/test-kit'; +import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; +import { screenRecordingResourceStore } from '@agent-device/capture-kit/screen-recording-resource-store'; +import { resolveScreenRecordingStopRecovery } from '@agent-device/capture-kit/screen-recording-stop-recovery'; +import { buildRemoteTempArtifactPath } from '@agent-device/contracts/daemon-http'; +import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; +import type { + ScreenRecordingCompletion, + ScreenRecordingLiveHandle, +} from '@agent-device/contracts/screen-recording-runtime'; +import { deriveRecordingTelemetryPath } from '@agent-device/capture-kit/recording-telemetry'; +import { makeIosSession } from '../../__tests__/support/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/support/store-factory.ts'; +import { platformResourceCleanupFixture } from '../../__tests__/support/platform-resource-cleanup-fixture.ts'; +import type { SessionStore } from '../../core/session-store.ts'; +import type { SessionState } from '../../core/session-state.ts'; +import { + cleanupDownloadableArtifact, + listDownloadableArtifacts, + trackDownloadableArtifact, +} from '../../resources/artifact-tracking.ts'; +import { tryHandleDownloadableArtifactHttpRoute } from '../../server/downloadable-artifact-http.ts'; +import { finalizeDaemonResponse } from '../../server/request-finalization.ts'; +import { buildRecordingStopResponse } from '../handlers/record-runtime-response.ts'; +import { teardownSessionResources } from '../session-teardown.ts'; + +const tracking = vi.hoisted(() => ({ failOnCall: 0, calls: 0 })); +vi.mock('../../resources/artifact-tracking.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + trackDownloadableArtifact: ( + ...args: Parameters + ): string => { + tracking.calls += 1; + if (tracking.calls === tracking.failOnCall) throw new Error('registration failed'); + return actual.trackDownloadableArtifact(...args); + }, + }; +}); + +const TENANT = 'tenant-a'; +const ADDRESS = `${TENANT}:checkout-flow`; +const createdPaths: string[] = []; + +afterEach(async () => { + for (const { id } of await listDownloadableArtifacts(TENANT, process.env)) { + cleanupDownloadableArtifact(id); + } + for (const filePath of createdPaths.splice(0)) fs.rmSync(filePath, { force: true }); + tracking.failOnCall = 0; + tracking.calls = 0; + vi.useRealTimers(); +}); + +type RecordingExport = { outPath: string; clientOutPath?: string; telemetryPath: string }; + +function remoteRecordingExport(): RecordingExport { + const outPath = buildRemoteTempArtifactPath('recording', 'mp4'); + return { + outPath, + clientOutPath: '/Users/client/checkout.mp4', + telemetryPath: deriveRecordingTelemetryPath(outPath), + }; +} + +function localRecordingExport(): RecordingExport { + const outPath = path.join(mkdtempForTestSync('agent-device-local-recording-'), 'checkout.mp4'); + return { outPath, telemetryPath: deriveRecordingTelemetryPath(outPath) }; +} + +/** Publishes a session whose live recording finishes into `recordingExport`, or fails to stop. */ +function publishRecordingSession( + sessionStore: SessionStore, + recordingExport: RecordingExport, + options: { leased?: boolean; finishFails?: boolean } = {}, +) { + const session: SessionState = makeIosSession(ADDRESS, { + appBundleId: 'com.example.app', + ...(options.leased === false + ? {} + : { lease: { leaseId: 'lease-1', tenantId: TENANT, runId: 'run-1' } }), + }); + const completion: ScreenRecordingCompletion = { + backend: 'simctl recordVideo', + outPath: recordingExport.outPath, + ...(recordingExport.clientOutPath ? { clientOutPath: recordingExport.clientOutPath } : {}), + telemetryPath: recordingExport.telemetryPath, + startedAt: Date.now() - 5_000, + completedAt: Date.now(), + scope: 'app', + showTouches: true, + recordOnlySession: false, + }; + const finish = vi.fn(async () => { + if (options.finishFails) { + return { + status: 'cleanup-pending', + reason: 'transport-failed', + message: 'failed to stop recording', + } as const; + } + createdPaths.push(recordingExport.outPath, recordingExport.telemetryPath); + fs.writeFileSync(recordingExport.outPath, 'video'); + fs.writeFileSync(recordingExport.telemetryPath, '{"events":[]}'); + return { status: 'completed', result: completion } as const; + }); + const forceCleanup = vi.fn(async () => ({ status: 'cleaned' }) as const); + const handle: ScreenRecordingLiveHandle = { + inspect: () => ({ ...completion, gestureEvents: [] }), + appendGestureEvents: () => {}, + setTouchReferenceFrame: () => {}, + setRunnerSessionId: () => {}, + invalidate: () => {}, + finish, + forceCleanup, + [Symbol.asyncDispose]: async () => {}, + }; + const envelope = createDurableResourceEnvelope({ + resourceKind: 'screen-recording', + sessionId: ADDRESS, + device: { id: session.device.id, family: 'apple', appleOs: 'ios', kind: 'simulator' }, + owner: localRuntimeOwner('apple'), + fence: { token: 'recording-fence', generation: 1 }, + lifecycle: 'open', + descriptor: { version: 1, body: { recordingId: 'checkout-flow' } }, + metadata: { phase: 'active' }, + }); + session.screenRecording = { handle, envelope }; + screenRecordingResourceStore.write( + screenRecordingResourceStore.resolvePath(sessionStore.resolveSessionDir(ADDRESS)), + envelope, + ); + return { ref: sessionStore.publish(ADDRESS, session), finish, forceCleanup }; +} + +async function teardown(sessionStore: SessionStore) { + await teardownSessionResources({ + appLog: 'already-settled', + ref: sessionStore.lookup(ADDRESS)!, + sessionStore, + platformCleanup: platformResourceCleanupFixture(), + }); +} + +test('a leased session teardown keeps the remote recording export downloadable by its tenant', async () => { + const sessionStore = makeSessionStore('agent-device-leased-recording-'); + const recordingExport = remoteRecordingExport(); + publishRecordingSession(sessionStore, recordingExport); + + await teardown(sessionStore); + + const artifacts = await listDownloadableArtifacts(TENANT, process.env); + expect(artifacts).toEqual([ + expect.objectContaining({ + artifactType: 'screen-recording', + filename: 'checkout.mp4', + sessionName: 'checkout-flow', + }), + expect.objectContaining({ + artifactType: 'screen-recording-telemetry', + filename: path.basename(recordingExport.telemetryPath), + sessionName: 'checkout-flow', + }), + ]); + expect(await listDownloadableArtifacts('tenant-b', process.env)).toEqual([]); +}); + +test('a retained recording is deleted once its tenant downloads it', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const sessionStore = makeSessionStore('agent-device-leased-recording-download-'); + const recordingExport = remoteRecordingExport(); + publishRecordingSession(sessionStore, recordingExport); + await teardown(sessionStore); + const [video] = await listDownloadableArtifacts(TENANT, process.env); + const server = http.createServer((req, res) => { + tryHandleDownloadableArtifactHttpRoute({ + req, + res, + authorize: async () => ({ tenantId: TENANT }), + hostEnv: process.env, + }); + }); + const port = await listenOnLoopback(server); + try { + const response = await fetch(`http://127.0.0.1:${port}/artifacts/${video!.id}`); + + expect(await response.text()).toBe('video'); + await vi.waitFor(() => expect(fs.existsSync(recordingExport.outPath)).toBe(false)); + expect( + (await listDownloadableArtifacts(TENANT, process.env)).map((a) => a.artifactType), + ).toEqual(['screen-recording-telemetry']); + } finally { + await closeLoopbackServer(server); + } +}); + +test('a retained recording nobody downloads is deleted when it expires', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + const sessionStore = makeSessionStore('agent-device-leased-recording-expiry-'); + const recordingExport = remoteRecordingExport(); + publishRecordingSession(sessionStore, recordingExport); + await teardown(sessionStore); + expect(await listDownloadableArtifacts(TENANT, process.env)).toHaveLength(2); + + await vi.advanceTimersByTimeAsync(15 * 60 * 1000); + + expect(await listDownloadableArtifacts(TENANT, process.env)).toEqual([]); + expect(fs.existsSync(recordingExport.outPath)).toBe(false); + expect(fs.existsSync(recordingExport.telemetryPath)).toBe(false); +}); + +test('a failed teardown finish runs forced cleanup and keeps nothing for download', async () => { + const sessionStore = makeSessionStore('agent-device-leased-recording-failed-'); + const { forceCleanup } = publishRecordingSession(sessionStore, remoteRecordingExport(), { + finishFails: true, + }); + + await expect(teardown(sessionStore)).rejects.toThrow(/recording: .*failed to stop recording/); + + expect(forceCleanup).toHaveBeenCalledOnce(); + expect(await listDownloadableArtifacts(TENANT, process.env)).toEqual([]); +}); + +test('a leased teardown that cannot register the export deletes it instead of leaking it', async () => { + tracking.failOnCall = 2; + const sessionStore = makeSessionStore('agent-device-leased-recording-unregistered-'); + const recordingExport = remoteRecordingExport(); + publishRecordingSession(sessionStore, recordingExport); + + await teardown(sessionStore); + + expect(await listDownloadableArtifacts(TENANT, process.env)).toEqual([]); + expect(fs.existsSync(recordingExport.outPath)).toBe(false); + expect(fs.existsSync(recordingExport.telemetryPath)).toBe(false); +}); + +test('a record stop replay after a leased teardown serves the artifacts teardown kept', async () => { + const sessionStore = makeSessionStore('agent-device-leased-recording-replay-'); + publishRecordingSession(sessionStore, remoteRecordingExport()); + await teardown(sessionStore); + const retained = await listDownloadableArtifacts(TENANT, process.env); + + const recovery = resolveScreenRecordingStopRecovery({ + sessionName: ADDRESS, + sessionStore, + device: IOS_SIMULATOR, + }); + if (recovery.kind !== 'completed') throw new Error(`expected a replay, got ${recovery.kind}`); + const replayed = finalizeDaemonResponse( + { + token: 't', + session: ADDRESS, + command: 'record', + positionals: ['stop'], + flags: {}, + meta: { tenantId: TENANT }, + }, + buildRecordingStopResponse(recovery.completion), + trackDownloadableArtifact, + ); + + expect(replayed.ok && replayed.data?.artifacts?.map((a) => a.artifactId)).toEqual( + retained.map((a) => a.id), + ); + expect(await listDownloadableArtifacts(TENANT, process.env)).toEqual([ + expect.objectContaining({ id: retained[0]!.id, sessionName: 'checkout-flow' }), + expect.objectContaining({ id: retained[1]!.id, sessionName: 'checkout-flow' }), + ]); +}); + +test('a leased teardown leaves a local caller export at its path and registers nothing', async () => { + const sessionStore = makeSessionStore('agent-device-leased-recording-local-'); + const recordingExport = localRecordingExport(); + publishRecordingSession(sessionStore, recordingExport); + + await teardown(sessionStore); + + expect(await listDownloadableArtifacts(TENANT, process.env)).toEqual([]); + expect(fs.readFileSync(recordingExport.outPath, 'utf8')).toBe('video'); +}); + +test('an unleased session teardown registers nothing for a remote export', async () => { + const sessionStore = makeSessionStore('agent-device-unleased-recording-'); + const recordingExport = remoteRecordingExport(); + publishRecordingSession(sessionStore, recordingExport, { leased: false }); + + await teardown(sessionStore); + + expect(await listDownloadableArtifacts(TENANT, process.env)).toEqual([]); + expect(await listDownloadableArtifacts(undefined, process.env)).toEqual([]); + expect(fs.existsSync(recordingExport.outPath)).toBe(true); +}); diff --git a/packages/daemon/src/sessions/handlers/record-runtime-response.ts b/packages/daemon/src/sessions/handlers/record-runtime-response.ts index b0a182a8a8..01f6afc1fe 100644 --- a/packages/daemon/src/sessions/handlers/record-runtime-response.ts +++ b/packages/daemon/src/sessions/handlers/record-runtime-response.ts @@ -53,37 +53,13 @@ export function buildRecordingUnsupportedResponse( } export function buildRecordingStopResponse(completion: ScreenRecordingCompletion): DaemonResponse { - const artifacts: DaemonArtifact[] = [recordingArtifact(completion)]; - if (completion.chunks && completion.chunks.length > 1) { - artifacts.push( - ...completion.chunks.slice(1).map((chunk) => ({ - field: 'chunkPath', - artifactType: 'screen-recording-chunk' as const, - path: chunk.path, - localPath: chunk.clientOutPath, - fileName: path.basename(chunk.clientOutPath ?? chunk.path), - })), - ); - } - if (completion.telemetryPath) { - const clientTelemetryPath = completion.clientOutPath - ? deriveRecordingTelemetryPath(completion.clientOutPath) - : undefined; - artifacts.push({ - field: 'telemetryPath', - artifactType: 'screen-recording-telemetry', - path: completion.telemetryPath, - localPath: clientTelemetryPath, - fileName: path.basename(completion.telemetryPath), - }); - } return { ok: true, data: { recording: 'stopped', outPath: completion.outPath, telemetryPath: completion.telemetryPath, - artifacts, + artifacts: recordingStopArtifacts(completion), recordingBackend: completion.backend, recordingScope: completion.scope, recordOnlySession: completion.recordOnlySession, @@ -111,6 +87,35 @@ export function buildRecordingStopResponse(completion: ScreenRecordingCompletion }; } +/** The files a finished recording hands to its caller: the video, later chunks, and telemetry. */ +export function recordingStopArtifacts(completion: ScreenRecordingCompletion): DaemonArtifact[] { + const artifacts: DaemonArtifact[] = [recordingArtifact(completion)]; + if (completion.chunks && completion.chunks.length > 1) { + artifacts.push( + ...completion.chunks.slice(1).map((chunk) => ({ + field: 'chunkPath', + artifactType: 'screen-recording-chunk' as const, + path: chunk.path, + localPath: chunk.clientOutPath, + fileName: path.basename(chunk.clientOutPath ?? chunk.path), + })), + ); + } + if (completion.telemetryPath) { + const clientTelemetryPath = completion.clientOutPath + ? deriveRecordingTelemetryPath(completion.clientOutPath) + : undefined; + artifacts.push({ + field: 'telemetryPath', + artifactType: 'screen-recording-telemetry', + path: completion.telemetryPath, + localPath: clientTelemetryPath, + fileName: path.basename(completion.telemetryPath), + }); + } + return artifacts; +} + function recordingArtifact(completion: ScreenRecordingCompletion): DaemonArtifact { return { field: 'outPath', diff --git a/packages/daemon/src/sessions/leased-recording-retention.ts b/packages/daemon/src/sessions/leased-recording-retention.ts new file mode 100644 index 0000000000..bee9949a58 --- /dev/null +++ b/packages/daemon/src/sessions/leased-recording-retention.ts @@ -0,0 +1,74 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { isRemoteTempArtifactPath } from '@agent-device/contracts/daemon-http'; +import type { ScreenRecordingCompletion } from '@agent-device/contracts/screen-recording-runtime'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { errorMessage } from '@agent-device/kernel/errors'; +import type { SessionRef } from '../core/session-state.ts'; +import { tenantPublicSessionName } from '../core/session-tenant-scope.ts'; +import { + cleanupDownloadableArtifact, + trackDownloadableArtifact, +} from '../resources/artifact-tracking.ts'; +import { recordingStopArtifacts } from './handlers/record-runtime-response.ts'; + +/** + * Keeps the export of a recording that a leased session's teardown finished, so the tenant can + * still download it (ADR 0024 rule 1). Only a remote caller's export qualifies: it sits at a + * daemon temp path that only a downloadable-artifact entry ever removes. A local caller's export + * already sits at the path that the caller named. + */ +export function retainLeasedSessionRecording( + ref: SessionRef, + completion: ScreenRecordingCompletion, +): void { + const lease = ref.session.lease; + if (!lease || !isRemoteRecordingExport(completion)) return; + const sessionName = tenantPublicSessionName(lease.tenantId, ref.address); + const artifacts = recordingStopArtifacts(completion).filter( + (artifact) => typeof artifact.path === 'string' && artifact.localPath, + ); + const artifactIds: string[] = []; + try { + for (const artifact of artifacts) { + artifactIds.push( + trackDownloadableArtifact({ + artifactPath: artifact.path!, + tenantId: lease.tenantId, + artifactType: artifact.artifactType, + fileName: artifact.fileName, + sessionName, + }), + ); + } + } catch (error) { + for (const artifactId of artifactIds) cleanupDownloadableArtifact(artifactId); + for (const artifact of artifacts) removeBestEffort(artifact.path!); + emitDiagnostic({ + level: 'warn', + phase: 'leased_session_recording_retention_failed', + data: { session: ref.address, leaseId: lease.leaseId, error: errorMessage(error) }, + }); + return; + } + emitDiagnostic({ + level: 'info', + phase: 'leased_session_recording_retained', + data: { session: ref.address, sessionName, leaseId: lease.leaseId, artifactIds }, + }); +} + +function removeBestEffort(filePath: string): void { + try { + fs.rmSync(filePath, { force: true }); + } catch { + // A file that cannot be removed must not stop the removal of the others. + } +} + +function isRemoteRecordingExport(completion: ScreenRecordingCompletion): boolean { + return ( + completion.clientOutPath !== undefined && + isRemoteTempArtifactPath(completion.outPath, 'recording', path.extname(completion.outPath)) + ); +} diff --git a/packages/daemon/src/sessions/session-teardown.ts b/packages/daemon/src/sessions/session-teardown.ts index e42d9366a7..ccbf5b4a01 100644 --- a/packages/daemon/src/sessions/session-teardown.ts +++ b/packages/daemon/src/sessions/session-teardown.ts @@ -203,7 +203,11 @@ export async function finishSessionScreenRecording(params: { sessionStore: SessionStore; }): Promise { const binding = bindSessionScreenRecording(params.sessionStore, params.ref); - if (binding.read()) await finishLiveScreenRecording({ binding, intent: 'disposal' }); + if (!binding.read()) return; + const completion = await finishLiveScreenRecording({ binding, intent: 'disposal' }); + if (!params.ref.session.lease) return; + const { retainLeasedSessionRecording } = await import('./leased-recording-retention.ts'); + retainLeasedSessionRecording(params.ref, completion); } export async function finishSessionAudioProbe(params: { diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 44b10e9716..ff3ffe7d79 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -190,7 +190,7 @@ Batch: agent-device test ./e2e/maestro --maestro --device udid1,emulator-5554 --shard-all 2 Recording: - record start/stop. Default scope is app (needs an active open session); use --scope device/system for whole-screen capture spanning multiple apps/home/settings. --quality medium|high on Android and Apple targets. stop burns touch overlays into the video by default; --hide-touches skips that for the fastest raw recording, and is recommended for gesture-heavy iOS simulator proof videos since overlay timing depends on a stable runner session. Android adb screenrecord has a 180s limit, so long Android recordings return as multiple MP4 chunks while the daemon stays alive; after a daemon restart, record stop recovers only manifest-owned chunks. record stop is safe to repeat: if its request window ended while the daemon was still exporting, running it again in that session returns the completed recording instead of starting a second one. + record start/stop. Default scope is app (needs an active open session); use --scope device/system for whole-screen capture spanning multiple apps/home/settings. --quality medium|high on Android and Apple targets. stop burns touch overlays into the video by default; --hide-touches skips that for the fastest raw recording, and is recommended for gesture-heavy iOS simulator proof videos since overlay timing depends on a stable runner session. Android adb screenrecord has a 180s limit, so long Android recordings return as multiple MP4 chunks while the daemon stays alive; after a daemon restart, record stop recovers only manifest-owned chunks. record stop is safe to repeat: if its request window ended while the daemon was still exporting, running it again in that session returns the completed recording instead of starting a second one. If a leased remote session ends while a recording runs (lease expiry, release, or close), the daemon finishes it and keeps the video and telemetry downloadable by the tenant for 15 minutes; GET /artifacts lists them with the sessionName. record contact-sheet [--out ] reads an exported MP4 back and writes one PNG holding the frames where the screen visibly changed, each labeled with its elapsed time. It is how you read a recording you cannot play, and it needs no session or device; the frames come out of that file, so the sheet cannot describe a screen the video does not contain. The sample grid is bounded and spread over the whole clip, so it reports coverage, not a review: a flash that opens and closes between two sample times is not in it. Needs a macOS host and an MP4 (a WebM recording is refused). Tracing: trace start ./trace.log, trace stop ./trace.log (path is positional, not --path).`, }, diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 890ddadf54..3d39ed9acc 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1056,6 +1056,7 @@ agent-device record stop # Stop active recording - Limrun sessions always record the whole screen and refuse `--fps` and `--hide-touches` with `INVALID_ARGS`. A failed download is retried by the next `record stop`; nothing survives a daemon restart. - A busy iOS simulator recording slot returns non-retriable `DEVICE_IN_USE` with `details.reason: apple_simulator_recording_busy`. Run `record stop` in the session that owns the recording; if no recording is active, ask the host operator to restart the CoreSimulator stream service. - `record stop` is safe to repeat. If the request window ends during export, a second `record stop` in the same session returns the finished recording. A recording whose video file is gone reports `no active recording`. +- On a remote daemon, if a leased session ends while a recording runs (lease expiry, release, or `close`), the daemon finishes the recording. It keeps the video and telemetry downloadable by the lease's tenant: `GET /artifacts` lists them with `sessionName`, and `GET /artifacts/` downloads them. Each file is deleted after its download, or after 15 minutes. - `record stop` may also report `recorder` (`confirmed` or `lost`) and `nativePathDisposition` (`retirable` or `retired`). See `agent-device help record` and [ADR 0024](https://github.com/callstack/agent-device/blob/main/docs/adr/0024-screen-recording-provable-signal.md). - `record contact-sheet [--out ]` turns an exported MP4 into one PNG grid of the frames where the screen visibly changed, each labeled with its elapsed time. It needs no session or device. The default output is `.contact-sheet.png`, and `--out` cannot point at the recording itself. `--json` adds per-cell times and change shares plus `sampledFrameCount`, `decodedFrameCount`, and `skippedSampleCount`. - A contact sheet samples at most 48 times across the clip (every 250ms for short clips) and prints at most 24 cells, so a transient between samples can be missing; `warning` says when cells were thinned or samples skipped. It runs only on macOS hosts (`details.reason: contact_sheet_unsupported_host` elsewhere) and refuses WebM with `contact_sheet_container_unsupported`. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index 7d28626c98..ad0a776c00 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -240,7 +240,7 @@ stop that daemon first. A denied request fails with `UNAUTHORIZED` and ## What the proxy exposes -The proxy serves only the daemon HTTP contract: `/health`, `/rpc`, `/upload` plus resumable `/upload/*` routes, and `/artifacts/*`, with the same routes also available under `/agent-device/*`. Health checks are unauthenticated; command, upload, and artifact routes require the bearer token. +The proxy serves only the daemon HTTP contract: `/health`, `/rpc`, `/upload` plus resumable `/upload/*` routes, and `/artifacts/*`, with the same routes also available under `/agent-device/*`. Health checks are unauthenticated; command, upload, and artifact routes require the bearer token. `GET /artifacts` lists the caller tenant's downloadable artifacts. An entry has `sessionName` when the daemon registered it outside a request reply, for example a recording kept when its leased session ended. The proxy checks the client token and forwards authorized requests with the local daemon token. Remote clients never see the daemon token.