Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
acd5fde
refactor(capture-kit): read swift cache, app-log and PNG worker setti…
thymikee Oct 17, 2026
1217c67
refactor(provision-kit): read proxy settings from the command host
thymikee Oct 17, 2026
3a4c59b
refactor(react-native): read dev-server settings from the command host
thymikee Oct 17, 2026
6df731e
fix(host): run the dev-server helpers inside the client's and the SDK…
thymikee Oct 17, 2026
9bae094
refactor(platform-apple): give detached runner and bridge children an…
thymikee Oct 17, 2026
94fbb68
refactor(platform-apple): read runner settings from the command host
thymikee Oct 17, 2026
b064bc6
fix(host): run the daemon clean script's runner lease cleanup inside …
thymikee Oct 17, 2026
20f00ed
refactor(host): hand leaf modules their environment instead of import…
thymikee Oct 17, 2026
a45b6ec
test(host): bind the command host in the install-artifact suites
thymikee Oct 17, 2026
52ee0a1
fix(host): answer review of the platform host environment change
thymikee Oct 17, 2026
2e0e4f1
refactor(react-native): take the host explicitly instead of reading t…
thymikee Oct 17, 2026
4f02fcf
refactor(platforms): pass the host explicitly where a platform packag…
thymikee Oct 17, 2026
7ef7690
refactor(daemon): hand the app-log marker the request host's environment
thymikee Oct 17, 2026
8bfc940
fix(capture-kit): report an unavailable PNG worker in the first diagn…
thymikee Oct 17, 2026
774d705
test(host): share the test command host binding through test-kit and …
thymikee Oct 17, 2026
58232c5
refactor(capture-kit): expose the app-log live handle adapter through…
thymikee Oct 17, 2026
96556c0
refactor(test-kit): build the test app-log handle without importing c…
thymikee Oct 17, 2026
e71ad8c
refactor(capture-kit): take the host explicitly for Swift cache and r…
thymikee Oct 17, 2026
8752e8c
fix(capture-kit): report a worker that dies under a request in that r…
thymikee Oct 17, 2026
ca49585
refactor(platform-android): take the install environment from the caller
thymikee Oct 17, 2026
b647f9c
test(platform-apple): pin the detached snapshot bridge's starting dir…
thymikee Oct 17, 2026
1c61b40
test(capture-kit): fail when the pre-warm starts no worker to kill
thymikee Oct 17, 2026
3f35707
test(host): keep the provider scenario host's Swift cache directory f…
thymikee Oct 17, 2026
298babc
fix(capture-kit): key the cached Swift helper paths on the host they …
thymikee Oct 17, 2026
d53f78b
test(host): give the Android recording scenarios their host env throu…
thymikee Oct 17, 2026
7cb529c
test(host): scenarios name their own host env; the harness no longer …
thymikee Oct 17, 2026
f0b20fa
test(host): drop the iOS recording scenario's process-env block now t…
thymikee Oct 17, 2026
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 @@ -12,7 +12,7 @@ export type AppLogRotationConfig = Readonly<{
}>;

/** Shared lower-level file policy for daemon markers and runtime output sinks. */
export function ensureAppLogPath(outPath: string, env: NodeJS.ProcessEnv = process.env): void {
export function ensureAppLogPath(outPath: string, env: NodeJS.ProcessEnv): void {
const directory = path.dirname(outPath);
if (!fs.existsSync(directory)) fs.mkdirSync(directory, { recursive: true });
assertAppLogFileIsNotSymbolicLink(outPath);
Expand Down
11 changes: 11 additions & 0 deletions packages/capture-kit/src/host-environment.fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { captureHostEnvironment } from '@agent-device/host-kit/process';
import type { HostEnvironment } from '@agent-device/kernel/source-value';

/**
* The host a capture-kit test hands to code that takes one explicitly: a snapshot of the process
* as it is when called, so `vi.stubEnv` made earlier in the test reaches it. Test-kit depends on
* capture-kit, so capture-kit keeps this local.
*/
export function createTestHost(): HostEnvironment {
return captureHostEnvironment(process);
}
74 changes: 74 additions & 0 deletions packages/capture-kit/src/png-worker-client-unavailable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { EventEmitter } from 'node:events';
import { expect, test, vi } from 'vitest';
import { PNG } from './png.ts';

test('an unavailable worker is reported by the first diagnostics scope that reaches the fallback', async () => {
vi.resetModules();
vi.doMock('./internal-entry.ts', () => ({ resolveInternalEntryModulePath: () => null }));
// Loaded after the reset so the client and the scope share one diagnostics module.
const { countDiagnosticEventsByPhase, withDiagnosticsScope } =
await import('@agent-device/host-kit/diagnostics');
const { decodePngAsync, prewarmPngWorker } = await import('./png-worker-client.ts');
const png = PNG.sync.write(new PNG({ width: 1, height: 1 }));

// The daemon pre-warms outside any request scope, where the report would be dropped.
prewarmPngWorker();

const reported = await withDiagnosticsScope({ command: 'screenshot' }, async () => {
await decodePngAsync(png, 'first');
const afterFirst = countDiagnosticEventsByPhase(['png_worker_unavailable']);
await decodePngAsync(png, 'second');
return { afterFirst, afterSecond: countDiagnosticEventsByPhase(['png_worker_unavailable']) };
});
const laterScope = await withDiagnosticsScope({ command: 'screenshot' }, async () => {
await decodePngAsync(png, 'third');
return countDiagnosticEventsByPhase(['png_worker_unavailable']);
});

expect(reported).toEqual({ afterFirst: 1, afterSecond: 1 });
expect(laterScope).toBe(0);
vi.doUnmock('./internal-entry.ts');
});

test('a worker that dies under a request is reported in that request, not only the next one', async () => {
vi.resetModules();
class FakeWorker extends EventEmitter {
static last: FakeWorker | undefined;
constructor() {
super();
FakeWorker.last = this;
}
postMessage(): void {}
ref(): void {}
unref(): void {}
terminate(): Promise<number> {
return Promise.resolve(0);
}
}
vi.doMock('node:worker_threads', async (importOriginal) => ({
...(await importOriginal<typeof import('node:worker_threads')>()),
Worker: FakeWorker,
}));
const { countDiagnosticEventsByPhase, withDiagnosticsScope } =
await import('@agent-device/host-kit/diagnostics');
const { decodePngAsync, prewarmPngWorker } = await import('./png-worker-client.ts');
const png = PNG.sync.write(new PNG({ width: 1, height: 1 }));
prewarmPngWorker();

let jobStarted!: () => void;
const started = new Promise<void>((resolve) => (jobStarted = resolve));
const scoped = withDiagnosticsScope({ command: 'screenshot' }, async () => {
const decoded = decodePngAsync(png, 'under-request');
jobStarted();
await decoded;
return countDiagnosticEventsByPhase(['png_worker_unavailable']);
});
await started;
// The worker's own error event carries no diagnostics scope.
const worker = FakeWorker.last;
if (!worker) throw new Error('the pre-warm did not start a worker');
worker.emit('error', new Error('worker crashed'));

expect(await scoped).toBe(1);
vi.doUnmock('node:worker_threads');
});
45 changes: 32 additions & 13 deletions packages/capture-kit/src/png-worker-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Worker } from 'node:worker_threads';
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
import { emitDiagnostic, getDiagnosticsMeta } from '@agent-device/host-kit/diagnostics';
import { AppError, toAppErrorCode } from '@agent-device/kernel/errors';
import type { Rect } from '@agent-device/kernel/snapshot';
import { resolveInternalEntryModulePath } from './internal-entry.ts';
Expand Down Expand Up @@ -39,21 +39,31 @@ type PendingJob = {

let worker: Worker | null = null;
let workerUnavailable = false;
let warnedWorkerUnavailable = false;
let workerUnavailableReason: string | undefined;
let reportedWorkerUnavailable = false;
let nextJobId = 0;
const pendingJobs = new Map<number, PendingJob>();

/** Permanently degrades to the sync path and reports the reason once. */
/** Permanently degrades to the sync path; the reason is reported by the first scope that sees it. */
function markWorkerUnavailable(reason: string): void {
workerUnavailable = true;
if (warnedWorkerUnavailable) return;
warnedWorkerUnavailable = true;
// Worker failures can surface outside a diagnostics scope (e.g. daemon
// startup pre-warm), so pair the scoped diagnostic with a process warning.
emitDiagnostic({ level: 'warn', phase: 'png_worker_unavailable', data: { reason } });
process.emitWarning(
Comment thread
thymikee marked this conversation as resolved.
`PNG worker unavailable, falling back to in-process PNG processing: ${reason}`,
);
workerUnavailableReason ??= reason;
reportWorkerUnavailable();
}

/**
* A pre-warm at daemon startup runs outside any diagnostics scope, where the event would be
* dropped, so the report waits for the first request scope that reaches the degraded path.
*/
function reportWorkerUnavailable(): void {
if (reportedWorkerUnavailable || workerUnavailableReason === undefined) return;
if (Object.keys(getDiagnosticsMeta()).length === 0) return;
Comment thread
thymikee marked this conversation as resolved.
reportedWorkerUnavailable = true;
emitDiagnostic({
level: 'warn',
phase: 'png_worker_unavailable',
data: { reason: workerUnavailableReason },
});
}

function handleWorkerMessage(message: PngWorkerResponse): void {
Expand Down Expand Up @@ -102,15 +112,20 @@ function updateWorkerRef(): void {
}

function obtainWorker(): Worker | null {
if (workerUnavailable) return null;
if (workerUnavailable) {
reportWorkerUnavailable();
return null;
}
if (worker) return worker;
const modulePath = resolveInternalEntryModulePath(import.meta.url, PNG_WORKER_ENTRYPOINT);
if (!modulePath) {
markWorkerUnavailable('worker entry module not found next to the current module');
return null;
}
try {
// The worker decodes and encodes bytes it is sent; it needs none of the host's environment.
const created = new Worker(modulePath, {
env: {},
execArgv: modulePath.endsWith('.ts') ? ['--experimental-strip-types'] : [],
});
created.on('message', handleWorkerMessage);
Expand Down Expand Up @@ -176,7 +191,11 @@ async function runPngJob<Kind extends PngWorkerJobKind>(
try {
return await runWorkerJob(job);
} catch (error) {
if (error instanceof PngWorkerUnavailableError) return await runSync();
if (error instanceof PngWorkerUnavailableError) {
// A worker can fail while this request waits on it, from an event callback with no scope.
reportWorkerUnavailable();
return await runSync();
}
throw error;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import fs from 'node:fs';
import path from 'node:path';
import { expect, test, vi } from 'vitest';
import { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts';

vi.mock('@agent-device/host-kit/command', async () => {
const fs = await import('node:fs');
return {
runCmd: vi.fn(async (_cmd: string, args: string[]) => {
fs.writeFileSync(args[args.indexOf('--output') + 1]!, 'processed');
return { stdout: '', stderr: '', exitCode: 0 };
}),
};
});
vi.mock('../video.ts', () => ({ waitForPlayableVideo: vi.fn(async () => {}) }));
vi.mock('../swift-cache.ts', () => ({
buildSwiftToolEnv: () => ({}),
compileSwiftSourceFile: vi.fn(async () => '/bin/true'),
resolveRecordingScriptPath: vi.fn(
(name: string, _projectRoot: string, cwd: string) => `${cwd}/${name}`,
),
}));

import { overlayRecordingTouches, startRecordingOverlayBudget } from '../overlay.ts';
import { compileSwiftSourceFile } from '../swift-cache.ts';

test('a helper script path is resolved per host working directory, not once per process', async () => {
const root = mkdtempForTestSync('agent-device-overlay-script-paths-');
const videoPath = path.join(root, 'recording.mp4');
const telemetryPath = path.join(root, 'recording.gesture-telemetry.json');
fs.writeFileSync(telemetryPath, '{"events":[]}');
const overlay = async (cwd: string) => {
fs.writeFileSync(videoPath, 'original');
await overlayRecordingTouches({
host: { env: {}, cwd },
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
});
};

await overlay('/cwd/one');
await overlay('/cwd/two');
await overlay('/cwd/one');

expect(vi.mocked(compileSwiftSourceFile).mock.calls.map(([params]) => params.sourcePath)).toEqual(
[
'/cwd/one/recording-overlay.swift',
'/cwd/two/recording-overlay.swift',
'/cwd/one/recording-overlay.swift',
],
);
});
47 changes: 40 additions & 7 deletions packages/capture-kit/src/recording/__tests__/overlay.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { createTestHost } from '../../host-environment.fixtures.ts';
import fs from 'node:fs';
import path from 'node:path';
import { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts';
Expand Down Expand Up @@ -63,12 +64,14 @@ test('overlay burns touches through a cached helper and same-directory temp outp
fs.writeFileSync(telemetryPath, '{"events":[]}');

await overlayRecordingTouches({
host: createTestHost(),
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
});
fs.writeFileSync(videoPath, 'second original');
await overlayRecordingTouches({
host: createTestHost(),
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
Expand Down Expand Up @@ -109,7 +112,12 @@ test('overlay preserves Swift helper compile hints', async () => {
});

await expect(
overlayRecordingTouches({ videoPath, telemetryPath, deadline: startRecordingOverlayBudget() }),
overlayRecordingTouches({
host: createTestHost(),
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
}),
).rejects.toMatchObject({
code: 'COMMAND_FAILED',
message: 'Failed to add touch overlays to the recording',
Expand All @@ -126,6 +134,7 @@ test('overlay defaults to the fast medium export preset', async () => {
fs.writeFileSync(telemetryPath, '{"events":[]}');

await overlayRecordingTouches({
host: createTestHost(),
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
Expand All @@ -143,6 +152,7 @@ test('overlay forwards the requested high export preset', async () => {
fs.writeFileSync(telemetryPath, '{"events":[]}');

await overlayRecordingTouches({
host: createTestHost(),
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
Expand All @@ -161,6 +171,7 @@ test('overlay caps the helper frame rate at the caller fps only when one was ask
fs.writeFileSync(telemetryPath, '{"events":[]}');

await overlayRecordingTouches({
host: createTestHost(),
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
Expand All @@ -169,6 +180,7 @@ test('overlay caps the helper frame rate at the caller fps only when one was ask

mockRunCmd.mockClear();
await overlayRecordingTouches({
host: createTestHost(),
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
Expand All @@ -185,6 +197,7 @@ test('overlay hands the helper what is left of its budget, inside the record req
fs.writeFileSync(telemetryPath, '{"events":[]}');

await overlayRecordingTouches({
host: createTestHost(),
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
Expand Down Expand Up @@ -212,7 +225,7 @@ test('overlay hands the helper only what the finalization left of the budget', a
const deadline = startRecordingOverlayBudget();
vi.setSystemTime(Date.now() + spentMs);

await overlayRecordingTouches({ videoPath, telemetryPath, deadline });
await overlayRecordingTouches({ host: createTestHost(), videoPath, telemetryPath, deadline });

const helperCall = mockRunCmd.mock.calls.find(([cmd]) => cmd !== 'xcrun');
expect(helperCall?.[2]?.timeoutMs).toBe(OVERLAY_BUDGET_MS - spentMs);
Expand All @@ -234,7 +247,12 @@ test('overlay leaves the video as recorded when compiling the helper spent its b
});

await expect(
overlayRecordingTouches({ videoPath, telemetryPath, deadline: startRecordingOverlayBudget() }),
overlayRecordingTouches({
host: createTestHost(),
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
}),
).rejects.toMatchObject({
code: 'COMMAND_FAILED',
message: 'Failed to add touch overlays to the recording',
Expand All @@ -251,6 +269,7 @@ test('overlay names its budget when the export ran out of it', async () => {
fs.writeFileSync(telemetryPath, '{"events":[]}');

await overlayRecordingTouches({
host: createTestHost(),
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
Expand All @@ -262,7 +281,12 @@ test('overlay names its budget when the export ran out of it', async () => {
});

await expect(
overlayRecordingTouches({ videoPath, telemetryPath, deadline: startRecordingOverlayBudget() }),
overlayRecordingTouches({
host: createTestHost(),
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
}),
).rejects.toMatchObject({
code: 'COMMAND_FAILED',
message: `Failed to add touch overlays to the recording: the export did not finish within its ${OVERLAY_BUDGET_MS}ms budget; a lower --fps renders faster`,
Expand All @@ -277,6 +301,7 @@ test('overlay keeps the plain failure when the export failed with budget to spar
fs.writeFileSync(telemetryPath, '{"events":[]}');

await overlayRecordingTouches({
host: createTestHost(),
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
Expand All @@ -286,7 +311,12 @@ test('overlay keeps the plain failure when the export failed with budget to spar
});

await expect(
overlayRecordingTouches({ videoPath, telemetryPath, deadline: startRecordingOverlayBudget() }),
overlayRecordingTouches({
host: createTestHost(),
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
}),
).rejects.toMatchObject({
message: 'Failed to add touch overlays to the recording',
});
Expand All @@ -304,12 +334,15 @@ test('overlay keeps the video as recorded when verifying the export spent its bu
});

await expect(
overlayRecordingTouches({ videoPath, telemetryPath, deadline }),
overlayRecordingTouches({ host: createTestHost(), videoPath, telemetryPath, deadline }),
).rejects.toMatchObject({
code: 'COMMAND_FAILED',
message: `Failed to add touch overlays to the recording: the export did not finish within its ${OVERLAY_BUDGET_MS}ms budget; a lower --fps renders faster`,
});
expect(vi.mocked(waitForPlayableVideo)).toHaveBeenCalledWith(expect.any(String), { deadline });
expect(vi.mocked(waitForPlayableVideo)).toHaveBeenCalledWith(expect.any(String), {
host: expect.objectContaining({ cwd: process.cwd() }),
deadline,
});
expect(fs.readFileSync(videoPath, 'utf8')).toBe('original');
expect(fs.readdirSync(tmpDir).filter((name) => name.includes('agent-device-'))).toEqual([]);
});
Loading
Loading