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
5 changes: 5 additions & 0 deletions .changeset/leased-recording-retention.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
alcpereira marked this conversation as resolved.
Resolved
1 change: 1 addition & 0 deletions docs/adr/0007-remote-device-leases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<lease-id>`.
The daemon sweeps for expired leases every second.
Expand Down
6 changes: 6 additions & 0 deletions docs/adr/0024-screen-recording-provable-signal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/cloud-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
5 changes: 5 additions & 0 deletions packages/daemon/src/core/session-tenant-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
alcpereira marked this conversation as resolved.
Resolved
}

/**
* Whether a caller in `namespace` may address `sessionName` by name — the read
* counterpart of `scopeRequestSession`'s naming decision, and the only tenant
Expand Down
22 changes: 22 additions & 0 deletions packages/daemon/src/resources/artifact-tracking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
22 changes: 22 additions & 0 deletions packages/daemon/src/resources/artifact-tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type ArtifactEntry = {
artifactType: DaemonArtifactType | undefined;
fileName?: string;
displayRotation?: DeviceRotation;
sessionName?: string;
deleteAfterDownload: boolean;
createdAt: number;
directoryArchive?: DirectoryArchive;
Expand Down Expand Up @@ -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;
Expand All @@ -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(() => {
Expand All @@ -103,13 +113,24 @@ 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,
});
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,
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading