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: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,14 @@ Command-only flags (like `find --first`) that do not flow to the platform layer
## Logs Contract
- Logs backend/source of truth is `src/daemon/app-log.ts`.
- `session.ts` should orchestrate only (start/stop/path/doctor/mark), not duplicate backend logic.
- App logs are distinct from runner/platform output. Keep app/device log capture in `app.log`; Apple runner and `xcodebuild` subprocess output belongs in the session-scoped `runner.log`.
- Preserve external grep/tail workflow in docs/skills.

## Diagnostics & Errors
- Diagnostics source of truth: `src/utils/diagnostics.ts`
- `withDiagnosticsScope`, `emitDiagnostic`, `withDiagnosticTimer`, `flushDiagnosticsToSessionFile`
- `withDiagnosticsScope`, `updateDiagnosticsScope`, `emitDiagnostic`, `withDiagnosticTimer`, `flushDiagnosticsToSessionFile`
- Request diagnostics belong in `sessions/<effective-session>/requests/<request-id>.ndjson` once the effective session is resolved. The top-level daemon log is for daemon lifecycle/startup and pre-session failures.
- Session artifact paths are centralized in `src/daemon/session-store.ts`; do not hand-build session log paths in handlers.
- Do not add ad-hoc stderr/file logging where diagnostics helpers apply.
- Normalize user-facing failures via `src/utils/errors.ts` (`normalizeError`).
- Failure payload contract: `code`, `message`, `hint`, `diagnosticId`, `logPath`, `details`.
Expand Down
2 changes: 2 additions & 0 deletions src/client-normalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ export function normalizeSession(value: unknown): AgentDeviceSession {
return {
name,
createdAt: readRequiredNumber(record, 'createdAt'),
sessionStateDir: readOptionalString(record, 'sessionStateDir'),
runnerLogPath: readOptionalString(record, 'runnerLogPath'),
device: {
platform,
target,
Expand Down
4 changes: 4 additions & 0 deletions src/client-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ function serializeSessionDevice(
export function serializeSessionListEntry(session: AgentDeviceSession): Record<string, unknown> {
return {
name: session.name,
...(session.sessionStateDir ? { sessionStateDir: session.sessionStateDir } : {}),
...(session.runnerLogPath ? { runnerLogPath: session.runnerLogPath } : {}),
...serializeSessionDevice(session.device, { includeAndroidSerial: false }),
createdAt: session.createdAt,
};
Expand Down Expand Up @@ -141,6 +143,8 @@ export function serializeOpenResult(result: AppOpenResult): Record<string, unkno
{
session: result.session,
...(result.sessionStateDir ? { sessionStateDir: result.sessionStateDir } : {}),
...(result.runnerLogPath ? { runnerLogPath: result.runnerLogPath } : {}),
...(result.requestLogPath ? { requestLogPath: result.requestLogPath } : {}),
...(result.appName ? { appName: result.appName } : {}),
...(result.appBundleId ? { appBundleId: result.appBundleId } : {}),
...(result.startup ? { startup: result.startup } : {}),
Expand Down
4 changes: 4 additions & 0 deletions src/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ export type AgentDeviceSessionDevice = {
export type AgentDeviceSession = {
name: string;
createdAt: number;
sessionStateDir?: string;
runnerLogPath?: string;
device: AgentDeviceSessionDevice;
identifiers: AgentDeviceIdentifiers;
};
Expand Down Expand Up @@ -185,6 +187,8 @@ export type AppOpenOptions = AgentDeviceRequestOverrides &
export type AppOpenResult = {
session: string;
sessionStateDir?: string;
runnerLogPath?: string;
requestLogPath?: string;
appName?: string;
appBundleId?: string;
appId?: string;
Expand Down
107 changes: 104 additions & 3 deletions src/daemon/__tests__/request-execution-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterAll, test, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { withDiagnosticsScope } from '../../utils/diagnostics.ts';
import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../utils/diagnostics.ts';
import {
makeAndroidSession,
makeIosSession,
Expand All @@ -14,6 +14,7 @@ import {
createRequestExecutionScope,
prepareLockedRequestScope,
} from '../request-execution-scope.ts';
import { resolveSessionRequestLogPath } from '../session-store.ts';
import type { DaemonRequest } from '../types.ts';

const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-request-execution-scope-'));
Expand Down Expand Up @@ -48,6 +49,52 @@ test('createRequestExecutionScope applies tenant scoping and lease admission', a
expect(scope.sessionName).toBe('tenant-a:default');
});

test('createRequestExecutionScope resolves session-scoped request and runner log paths', async () => {
const sessionStore = makeSessionStore('agent-device-request-scope-');
const cwd = fs.mkdtempSync(path.join(TEST_ROOT, 'cwd-scope-'));
fs.mkdirSync(path.join(cwd, '.git'));

const scope = await withDiagnosticsScope(
{ command: 'snapshot', requestId: 'request-logs-1', logPath: LOG_PATH },
async () =>
await createRequestExecutionScope({
req: makeRequest({ meta: { cwd, requestId: 'request-logs-1' } }),
sessionStore,
leaseRegistry: new LeaseRegistry(),
}),
);

expect(scope.sessionName).toMatch(/^cwd:[a-f0-9]{16}:default$/);
expect(scope.requestLogPath).toMatch(
/cwd_[a-f0-9]{16}_default\/requests\/request-logs-1\.ndjson$/,
);
expect(scope.runnerLogPath).toMatch(/cwd_[a-f0-9]{16}_default\/runner\.log$/);
});

test('request diagnostics flush into the effective session request log', async () => {
const sessionStore = makeSessionStore('agent-device-request-scope-');
const cwd = fs.mkdtempSync(path.join(TEST_ROOT, 'diag-scope-'));
fs.mkdirSync(path.join(cwd, '.git'));

const result = await withDiagnosticsScope(
{ command: 'snapshot', requestId: 'request-diag-1', logPath: LOG_PATH },
async () => {
const scope = await createRequestExecutionScope({
req: makeRequest({ meta: { cwd, requestId: 'request-diag-1' } }),
sessionStore,
leaseRegistry: new LeaseRegistry(),
});
return {
expectedPath: scope.requestLogPath,
flushedPath: flushDiagnosticsToSessionFile({ force: true }),
};
},
);

expect(result.flushedPath).toBe(result.expectedPath);
expect(fs.readFileSync(result.expectedPath, 'utf8')).toContain('"phase":"request_start"');
});

test('createRequestExecutionScope rejects tenant requests without an active lease', async () => {
await expect(
createRequestExecutionScope({
Expand All @@ -67,6 +114,40 @@ test('createRequestExecutionScope rejects tenant requests without an active leas
).rejects.toThrow(/Lease is not active/);
});

test('tenant lease rejection flushes diagnostics into the effective session request log', async () => {
const sessionStore = makeSessionStore('agent-device-request-scope-');
const requestId = 'tenant-lease-rejection';
let flushedPath: string | null = null;

await withDiagnosticsScope({ command: 'snapshot', requestId, logPath: LOG_PATH }, async () => {
await expect(
createRequestExecutionScope({
req: makeRequest({
session: 'default',
command: 'snapshot',
meta: {
tenantId: 'tenant-a',
runId: 'run-1',
leaseId: '0'.repeat(32),
sessionIsolation: 'tenant',
requestId,
},
}),
sessionStore,
leaseRegistry: new LeaseRegistry(),
}),
).rejects.toThrow(/Lease is not active/);
flushedPath = flushDiagnosticsToSessionFile({ force: true });
});

const expectedPath = resolveSessionRequestLogPath(
sessionStore.resolveSessionDir('tenant-a:default'),
requestId,
);
expect(flushedPath).toBe(expectedPath);
expect(fs.readFileSync(expectedPath, 'utf8')).toContain('"phase":"request_start"');
});

test('prepareLockedRequestScope preserves existing-session selector validation', async () => {
const sessionStore = makeSessionStore('agent-device-request-scope-');
sessionStore.set('default', makeAndroidSession('default'));
Expand All @@ -84,7 +165,6 @@ test('prepareLockedRequestScope preserves existing-session selector validation',
expect(() =>
prepareLockedRequestScope({
scope,
logPath: LOG_PATH,
sessionStore,
trackDownloadableArtifact: () => 'artifact-id',
}),
Expand Down Expand Up @@ -116,7 +196,6 @@ test('prepareLockedRequestScope blocks commands for invalidated recordings befor
const result = await withDiagnosticsScope({ command: 'snapshot', logPath: LOG_PATH }, async () =>
prepareLockedRequestScope({
scope,
logPath: LOG_PATH,
sessionStore,
trackDownloadableArtifact: () => 'artifact-id',
}),
Expand All @@ -131,6 +210,28 @@ test('prepareLockedRequestScope blocks commands for invalidated recordings befor
}
});

test('prepareLockedRequestScope passes the session runner log path into handler context', async () => {
const sessionStore = makeSessionStore('agent-device-request-scope-');
sessionStore.set('default', makeIosSession('default'));
const scope = await createRequestExecutionScope({
req: makeRequest({ command: 'snapshot' }),
sessionStore,
leaseRegistry: new LeaseRegistry(),
});

const result = prepareLockedRequestScope({
scope,
sessionStore,
trackDownloadableArtifact: () => 'artifact-id',
});

expect(result.type).toBe('scope');
if (result.type === 'scope') {
expect(result.scope.logPath).toBe(scope.runnerLogPath);
expect(result.scope.contextFromFlags(undefined).logPath).toBe(scope.runnerLogPath);
}
});

test('runLocked rejects a canceled request before executing work', async () => {
const requestId = 'request-scope-canceled-before-lock';
const scope = await createRequestExecutionScope({
Expand Down
6 changes: 6 additions & 0 deletions src/daemon/__tests__/request-router-open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ test('open returns and creates the session state directory', async () => {
if (response.ok) {
expect(response.data?.session).toBe('session-a');
expect(response.data?.sessionStateDir).toEqual(expect.stringContaining('session-a'));
expect(response.data?.runnerLogPath).toEqual(
path.join(String(response.data?.sessionStateDir), 'runner.log'),
);
expect(response.data?.requestLogPath).toEqual(
path.join(String(response.data?.sessionStateDir), 'requests', 'req-open-state.ndjson'),
);
expect(fs.existsSync(String(response.data?.sessionStateDir))).toBe(true);
}
});
Expand Down
35 changes: 20 additions & 15 deletions src/daemon/handlers/session-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
resolveIosSimulatorDeviceSetPath,
} from '../../utils/device-isolation.ts';
import type { DaemonRequest, DaemonResponse } from '../types.ts';
import { SessionStore } from '../session-store.ts';
import { resolveSessionRunnerLogPath, SessionStore } from '../session-store.ts';
import { listAndroidApps } from '../../platforms/android/app-lifecycle.ts';
import { listIosApps } from '../../platforms/ios/apps.ts';
import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts';
Expand All @@ -35,20 +35,25 @@ export async function handleSessionInventoryCommands(params: {
sessions: sessionStore
.toArray()
.filter((session) => sessionMatchesScope(session, scope))
.map((session) => ({
name: session.name,
platform: session.device.platform,
target: session.device.target ?? 'mobile',
surface: session.surface ?? 'app',
device: session.device.name,
id: session.device.id,
device_id: session.device.id,
createdAt: session.createdAt,
...(session.device.platform === 'ios' && {
device_udid: session.device.id,
ios_simulator_device_set: session.device.simulatorSetPath ?? null,
}),
})),
.map((session) => {
const sessionStateDir = sessionStore.resolveSessionDir(session.name);
return {
name: session.name,
sessionStateDir,
runnerLogPath: resolveSessionRunnerLogPath(sessionStateDir),
platform: session.device.platform,
target: session.device.target ?? 'mobile',
surface: session.surface ?? 'app',
device: session.device.name,
id: session.device.id,
device_id: session.device.id,
createdAt: session.createdAt,
...(session.device.platform === 'ios' && {
device_udid: session.device.id,
ios_simulator_device_set: session.device.simulatorSetPath ?? null,
}),
};
}),
},
};
}
Expand Down
15 changes: 12 additions & 3 deletions src/daemon/handlers/session-open-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import type { StartupPerfSample } from './session-startup-metrics.ts';

export function buildOpenResult(params: {
sessionName: string;
sessionStateDir?: string;
sessionStateDir: string;
runnerLogPath: string;
requestLogPath: string;
appName?: string;
appBundleId?: string;
surface: SessionSurface;
Expand All @@ -21,6 +23,8 @@ export function buildOpenResult(params: {
const {
sessionName,
sessionStateDir,
runnerLogPath,
requestLogPath,
appName,
appBundleId,
surface,
Expand All @@ -30,8 +34,13 @@ export function buildOpenResult(params: {
runtime,
runtimeHintCount,
} = params;
const result: Record<string, unknown> = { session: sessionName, surface };
if (sessionStateDir) result.sessionStateDir = sessionStateDir;
const result: Record<string, unknown> = {
session: sessionName,
surface,
sessionStateDir,
runnerLogPath,
requestLogPath,
};
if (appName) result.appName = appName;
if (appBundleId) result.appBundleId = appBundleId;
if (startup) result.startup = startup;
Expand Down
13 changes: 12 additions & 1 deletion src/daemon/handlers/session-open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import {
import { applyRuntimeHintsToApp } from '../runtime-hints.ts';
import type { DeviceInfo } from '../../utils/device.ts';
import type { DaemonRequest, DaemonResponse, SessionRuntimeHints, SessionState } from '../types.ts';
import { SessionStore } from '../session-store.ts';
import {
resolveSessionRequestLogPath,
resolveSessionRunnerLogPath,
SessionStore,
} from '../session-store.ts';
import {
IOS_SIMULATOR_POST_CLOSE_SETTLE_MS,
IOS_SIMULATOR_POST_OPEN_SETTLE_MS,
Expand All @@ -24,6 +28,7 @@ import { buildNextOpenSession, buildOpenResult } from './session-open-surface.ts
import { markAndroidSnapshotFreshness } from '../android-snapshot-freshness.ts';
import { resetAndroidFramePerfStats } from '../../platforms/android/perf.ts';
import { withKeyedLock } from '../../utils/keyed-lock.ts';
import { getDiagnosticsMeta } from '../../utils/diagnostics.ts';
import { inferAndroidPackageAfterOpen } from './session-open-target.ts';
import {
invalidOpenArgs,
Expand Down Expand Up @@ -255,10 +260,16 @@ async function completeOpenCommand(params: {
setSessionRuntimeHintsForOpen(sessionStore, sessionName, runtime);
}
const sessionStateDir = sessionStore.ensureSessionDir(sessionName);
const requestLogPath = resolveSessionRequestLogPath(
sessionStateDir,
req.meta?.requestId ?? getDiagnosticsMeta().requestId,
);
timing.totalDurationMs = Math.max(0, Date.now() - openCommandStartedAtMs);
const openResult = buildOpenResult({
sessionName: nextSession.name,
sessionStateDir,
runnerLogPath: resolveSessionRunnerLogPath(sessionStateDir),
requestLogPath,
appName,
appBundleId: sessionAppBundleId,
surface,
Expand Down
Loading
Loading