diff --git a/AGENTS.md b/AGENTS.md index c3271f47f..79e6af9ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ Minimal operating guide for AI coding agents in this repo. ## Routing - Keep `src/daemon.ts` as a thin router. - Put command logic in handler modules: - - session/apps/appstate/open/close/replay: `src/daemon/handlers/session.ts` + - session/apps/appstate/open/close/replay/logs: `src/daemon/handlers/session.ts` - click/fill/get/is: `src/daemon/handlers/interaction.ts` - snapshot/wait/alert/settings: `src/daemon/handlers/snapshot.ts` - find: `src/daemon/handlers/find.ts` diff --git a/README.md b/README.md index ca73cdb94..d76476248 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ The project is in early development and considered experimental. Pull requests a - Platforms: iOS (simulator + physical device core automation) and Android (emulator + device). - Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`. - Inspection commands: `snapshot` (accessibility tree), `diff snapshot` (structural baseline diff), `appstate`, `apps`, `devices`. +- App logs: `logs path` returns session log metadata; `logs start` / `logs stop` stream app output; `logs doctor` checks readiness; `logs mark` writes timeline markers. - Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode). - Minimal dependencies; TypeScript executed directly on Node 22+ (no build step). @@ -142,6 +143,7 @@ agent-device scrollintoview @e42 - `press` (alias: `click`), `focus`, `type`, `fill`, `long-press`, `swipe`, `scroll`, `scrollintoview`, `pinch`, `is` - `alert`, `wait`, `screenshot` - `trace start`, `trace stop` +- `logs path`, `logs start`, `logs stop`, `logs doctor`, `logs mark` (session app log file for grep; iOS simulator + iOS device + Android) - `settings wifi|airplane|location on|off` - `settings faceid match|nonmatch|enroll|unenroll` (iOS simulator only) - `appstate`, `apps`, `devices`, `session list` @@ -296,6 +298,12 @@ App state: ## Debug +- **App logs (token-efficient):** With an active session, run `logs path` to get path + state metadata (e.g. `~/.agent-device/sessions//app.log`). Run `logs start` to stream app output to that file; use `logs stop` to stop. Run `logs doctor` for tool/runtime checks and `logs mark "step"` to insert timeline markers. Grep the file when you need to inspect errors (e.g. `grep -n "Error\|Exception" `) instead of pulling full logs into context. Supported on iOS simulator, iOS physical device, and Android. +- `logs start` appends to `app.log` and rotates to `app.log.1` when the file exceeds 5 MB. +- Android log streaming automatically rebinds to the app PID after process restarts. +- iOS log capture relies on Unified Logging signals (for example `os_log`); plain stdout/stderr output may be limited depending on app/runtime. +- Retention knobs: set `AGENT_DEVICE_APP_LOG_MAX_BYTES` and `AGENT_DEVICE_APP_LOG_MAX_FILES` to override rotation limits. +- Optional write-time redaction patterns: set `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` to a comma-separated regex list. - `agent-device trace start` - `agent-device trace stop ./trace.log` - The trace log includes snapshot logs and XCTest runner logs for the session. diff --git a/skills/agent-device/SKILL.md b/skills/agent-device/SKILL.md index 085951be6..6ced9984b 100644 --- a/skills/agent-device/SKILL.md +++ b/skills/agent-device/SKILL.md @@ -109,6 +109,19 @@ iOS settings helpers are simulator-only. Use `match`/`nonmatch` as the canonical command values. Think of them as validate/invalidate outcomes when describing intent. +### Logs (token-efficient debugging) + +Use the detailed logs workflow reference: +`skills/agent-device/references/logs.md` + +Recommended minimum: + +```bash +agent-device logs doctor +agent-device logs start +agent-device logs path +``` + ### App state ```bash diff --git a/skills/agent-device/references/logs.md b/skills/agent-device/references/logs.md new file mode 100644 index 000000000..e0063be5a --- /dev/null +++ b/skills/agent-device/references/logs.md @@ -0,0 +1,51 @@ +# Logs (Token-Efficient Debugging) + +App output is written to a session-scoped file so agents can grep it instead of loading full logs into context. + +## Quick Flow + +```bash +agent-device open MyApp --platform ios +agent-device logs start # Start streaming app logs to session file +agent-device logs path # Print path, e.g. ~/.agent-device/sessions/default/app.log +agent-device logs doctor # Check tool/runtime readiness for current session/device +agent-device logs mark "before tap" # Insert a timeline marker into app.log +# ... run flows; on failure, grep the path (see below) +agent-device logs stop # Stop streaming (optional; close also stops) +``` + +## Command Notes + +- `logs path`: returns log file path and metadata (`active`, `state`, `backend`, size, timestamps). +- `logs start`: starts streaming; requires an active app session (`open` first). Supported on iOS simulator, iOS device, and Android. +- `logs stop`: stops streaming. Session `close` also stops logging. +- `logs doctor`: reports backend/tool checks and readiness notes for troubleshooting. +- `logs mark`: writes a timestamped marker line to the session log. + +## Behavior and Limits + +- `logs start` appends to `app.log` and rotates to `app.log.1` when `app.log` exceeds 5 MB. +- Android log streaming automatically rebinds to the app PID after process restarts. +- iOS log capture relies on Unified Logging signals (for example `os_log`); plain stdout/stderr output may be limited depending on app/runtime. +- Retention knobs: + - `AGENT_DEVICE_APP_LOG_MAX_BYTES` + - `AGENT_DEVICE_APP_LOG_MAX_FILES` +- Optional write-time redaction patterns: + - `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` (comma-separated regex) + +## Grep Patterns + +After getting the path from `logs path`, run `grep` (or `grep -E`) so only matching lines enter context. + +```bash +# Get path first, then grep it; -n adds line numbers +grep -n "Error\|Exception\|Fatal" +grep -n -E "Error|Exception|Fatal|crash" + +# Bounded context: last N lines only +tail -50 +``` + +- Use `-n` for line numbers. +- Use `-E` for extended regex so `|` in the pattern does not need escaping. +- Prefer targeted patterns (e.g. `Error`, `Exception`, or app-specific tags) over reading the full file. diff --git a/src/cli.ts b/src/cli.ts index 26112cee7..1f4c61bd0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -240,6 +240,38 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): if (logTailStopper) logTailStopper(); return; } + if (command === 'logs') { + const data = response.data as Record | undefined; + const pathOut = typeof data?.path === 'string' ? data.path : ''; + if (pathOut) { + process.stdout.write(`${pathOut}\n`); + const active = typeof data?.active === 'boolean' ? data.active : undefined; + const state = typeof data?.state === 'string' ? data.state : undefined; + const backend = typeof data?.backend === 'string' ? data.backend : undefined; + const sizeBytes = typeof data?.sizeBytes === 'number' ? data.sizeBytes : undefined; + if (!flags.json && (active !== undefined || state || backend || sizeBytes !== undefined)) { + const meta = [ + active !== undefined ? `active=${active}` : '', + state ? `state=${state}` : '', + backend ? `backend=${backend}` : '', + sizeBytes !== undefined ? `sizeBytes=${sizeBytes}` : '', + ].filter(Boolean).join(' '); + if (meta) process.stderr.write(`${meta}\n`); + } + if (data?.hint && !flags.json) { + process.stderr.write(`${data.hint}\n`); + } + if (Array.isArray(data?.notes) && !flags.json) { + for (const note of data.notes) { + if (typeof note === 'string' && note.length > 0) { + process.stderr.write(`${note}\n`); + } + } + } + } + if (logTailStopper) logTailStopper(); + return; + } if (command === 'click' || command === 'press') { const ref = (response.data as any)?.ref ?? ''; const x = (response.data as any)?.x; diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index e65a82341..b57761aba 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -67,6 +67,7 @@ test('core commands support iOS simulator, iOS device, and Android', () => { 'get', 'home', 'longpress', + 'logs', 'open', 'press', 'record', diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 1812456d4..5ab2f03c7 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -29,6 +29,7 @@ const COMMAND_CAPABILITY_MATRIX: Record = { get: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } }, is: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } }, home: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } }, + logs: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } }, longpress: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } }, open: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } }, reinstall: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } }, diff --git a/src/daemon.ts b/src/daemon.ts index 8675b7ffa..48c2a020a 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -16,6 +16,7 @@ import { handleSnapshotCommands } from './daemon/handlers/snapshot.ts'; import { handleFindCommands } from './daemon/handlers/find.ts'; import { handleRecordTraceCommands } from './daemon/handlers/record-trace.ts'; import { handleInteractionCommands } from './daemon/handlers/interaction.ts'; +import { cleanupStaleAppLogProcesses } from './daemon/app-log.ts'; import { assertSessionSelectorMatches } from './daemon/session-selector.ts'; import { resolveEffectiveSessionName } from './daemon/session-routing.ts'; import { clearRequestCanceled, isRequestCanceled, markRequestCanceled } from './daemon/request-cancel.ts'; @@ -30,6 +31,7 @@ const infoPath = path.join(baseDir, 'daemon.json'); const lockPath = path.join(baseDir, 'daemon.lock'); const logPath = path.join(baseDir, 'daemon.log'); const sessionsDir = path.join(baseDir, 'sessions'); +cleanupStaleAppLogProcesses(sessionsDir); const sessionStore = new SessionStore(sessionsDir); const version = readVersion(); const token = crypto.randomBytes(24).toString('hex'); diff --git a/src/daemon/__tests__/app-log.test.ts b/src/daemon/__tests__/app-log.test.ts new file mode 100644 index 000000000..dd909e309 --- /dev/null +++ b/src/daemon/__tests__/app-log.test.ts @@ -0,0 +1,123 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + APP_LOG_PID_FILENAME, + appendAppLogMarker, + assertAndroidPackageArgSafe, + buildIosDeviceLogStreamArgs, + buildIosLogPredicate, + cleanupStaleAppLogProcesses, + getAppLogPathMetadata, + runAppLogDoctor, + rotateAppLogIfNeeded, + stopAppLog, +} from '../app-log.ts'; + +test('buildIosLogPredicate includes bundle-aware filters', () => { + const predicate = buildIosLogPredicate('com.example.app'); + assert.match(predicate, /subsystem == "com\.example\.app"/); + assert.match(predicate, /processImagePath ENDSWITH\[c\] "\/com\.example\.app"/); + assert.match(predicate, /senderImagePath ENDSWITH\[c\] "\/com\.example\.app"/); + assert.match(predicate, /eventMessage CONTAINS\[c\] "com\.example\.app"/); +}); + +test('assertAndroidPackageArgSafe rejects unsafe values', () => { + assert.doesNotThrow(() => assertAndroidPackageArgSafe('com.example.app')); + assert.throws(() => assertAndroidPackageArgSafe('com.example.app;rm -rf /'), /Invalid Android package/); +}); + +test('rotateAppLogIfNeeded rotates and truncates oldest by configured max files', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-rotate-')); + const outPath = path.join(root, 'app.log'); + fs.writeFileSync(outPath, 'a'.repeat(20)); + fs.writeFileSync(`${outPath}.1`, 'old1'); + fs.writeFileSync(`${outPath}.2`, 'old2'); + + rotateAppLogIfNeeded(outPath, { maxBytes: 10, maxRotatedFiles: 2 }); + + assert.equal(fs.existsSync(outPath), false); + assert.equal(fs.readFileSync(`${outPath}.1`, 'utf8').length, 20); + assert.equal(fs.readFileSync(`${outPath}.2`, 'utf8'), 'old1'); +}); + +test('stopAppLog delegates stop and waits for completion', async () => { + let stopped = false; + let resolved = false; + const wait = new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => { + setTimeout(() => { + resolved = true; + resolve({ stdout: '', stderr: '', exitCode: 0 }); + }, 5); + }); + await stopAppLog({ + backend: 'android', + getState: () => 'active', + startedAt: Date.now(), + stop: async () => { + stopped = true; + }, + wait, + }); + assert.equal(stopped, true); + assert.equal(resolved, true); +}); + +test('cleanupStaleAppLogProcesses removes pid files even when pid is stale', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-clean-')); + const sessionDir = path.join(root, 'default'); + fs.mkdirSync(sessionDir, { recursive: true }); + const pidPath = path.join(sessionDir, APP_LOG_PID_FILENAME); + fs.writeFileSync(pidPath, '999999\n'); + + cleanupStaleAppLogProcesses(root); + + assert.equal(fs.existsSync(pidPath), false); +}); + +test('buildIosDeviceLogStreamArgs builds expected devicectl command args', () => { + assert.deepEqual(buildIosDeviceLogStreamArgs('00008150-0000AAAA'), [ + 'devicectl', + 'device', + 'log', + 'stream', + '--device', + '00008150-0000AAAA', + ]); +}); + +test('cleanupStaleAppLogProcesses removes legacy plain pid files safely', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-clean-legacy-')); + const sessionDir = path.join(root, 'default'); + fs.mkdirSync(sessionDir, { recursive: true }); + const pidPath = path.join(sessionDir, APP_LOG_PID_FILENAME); + fs.writeFileSync(pidPath, '1\n'); + + cleanupStaleAppLogProcesses(root); + + assert.equal(fs.existsSync(pidPath), false); +}); + +test('appendAppLogMarker writes marker lines and metadata reflects file', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-mark-')); + const outPath = path.join(root, 'app.log'); + appendAppLogMarker(outPath, 'checkpoint'); + const content = fs.readFileSync(outPath, 'utf8'); + assert.match(content, /checkpoint/); + const metadata = getAppLogPathMetadata(outPath); + assert.equal(metadata.exists, true); + assert.ok(metadata.sizeBytes > 0); +}); + +test('runAppLogDoctor returns note when app bundle is missing', async () => { + const result = await runAppLogDoctor({ + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + }); + assert.equal(Array.isArray(result.notes), true); + assert.ok(result.notes.some((note) => note.includes('Run open first'))); +}); diff --git a/src/daemon/app-log.ts b/src/daemon/app-log.ts new file mode 100644 index 000000000..98936b214 --- /dev/null +++ b/src/daemon/app-log.ts @@ -0,0 +1,499 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { DeviceInfo } from '../utils/device.ts'; +import { AppError } from '../utils/errors.ts'; +import { runCmd, type ExecResult } from '../utils/exec.ts'; +import { readProcessCommand, readProcessStartTime } from '../utils/process-identity.ts'; + +const DEFAULT_MAX_APP_LOG_BYTES = 5 * 1024 * 1024; +const DEFAULT_MAX_ROTATED_FILES = 1; +const APP_LOG_PID_FILE = 'app-log.pid'; + +type StoredAppLogProcessMeta = { + pid: number; + startTime?: string; + command?: string; +}; + +export type AppLogResult = { + backend: 'ios-simulator' | 'ios-device' | 'android'; + getState: () => 'active' | 'failed'; + startedAt: number; + stop: () => Promise; + wait: Promise; +}; + +export type AppLogDoctorResult = { + checks: Record; + notes: string[]; +}; + +function parsePositiveIntEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +function getAppLogConfig(): { maxBytes: number; maxRotatedFiles: number } { + return { + maxBytes: parsePositiveIntEnv('AGENT_DEVICE_APP_LOG_MAX_BYTES', DEFAULT_MAX_APP_LOG_BYTES), + maxRotatedFiles: parsePositiveIntEnv('AGENT_DEVICE_APP_LOG_MAX_FILES', DEFAULT_MAX_ROTATED_FILES), + }; +} + +function getAppLogRedactionPatterns(): RegExp[] { + const raw = process.env.AGENT_DEVICE_APP_LOG_REDACT_PATTERNS; + if (!raw) return []; + const patterns = raw + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + const result: RegExp[] = []; + for (const pattern of patterns) { + try { + result.push(new RegExp(pattern, 'gi')); + } catch { + // Skip invalid user pattern. + } + } + return result; +} + +function parsePidFile(raw: string): StoredAppLogProcessMeta | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + if (/^\d+$/.test(trimmed)) { + return { pid: Number.parseInt(trimmed, 10) }; + } + try { + const parsed = JSON.parse(trimmed) as StoredAppLogProcessMeta; + if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null; + return parsed; + } catch { + return null; + } +} + +function isManagedAppLogCommand(command: string): boolean { + const normalized = command.toLowerCase().replaceAll('\\', '/'); + return normalized.includes('log stream') + || normalized.includes('logcat') + || normalized.includes('devicectl device log stream'); +} + +function shouldTerminateStoredProcess(meta: StoredAppLogProcessMeta): boolean { + const currentStartTime = readProcessStartTime(meta.pid); + if (!currentStartTime) return false; + if (meta.startTime && currentStartTime !== meta.startTime) return false; + const currentCommand = readProcessCommand(meta.pid); + if (!currentCommand || !isManagedAppLogCommand(currentCommand)) return false; + if (meta.command && currentCommand !== meta.command) return false; + return true; +} + +function writePidFile(pidPath: string | undefined, pid: number): void { + if (!pidPath) return; + const dir = path.dirname(pidPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const metadata: StoredAppLogProcessMeta = { + pid, + startTime: readProcessStartTime(pid) ?? undefined, + command: readProcessCommand(pid) ?? undefined, + }; + fs.writeFileSync(pidPath, `${JSON.stringify(metadata)}\n`); +} + +function clearPidFile(pidPath: string | undefined): void { + if (!pidPath || !fs.existsSync(pidPath)) return; + try { + fs.unlinkSync(pidPath); + } catch { + // best-effort cleanup + } +} + +function ensureLogPath(outPath: string): void { + const dir = path.dirname(outPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + rotateAppLogIfNeeded(outPath, getAppLogConfig()); +} + +export function getAppLogPathMetadata(outPath: string): { + exists: boolean; + sizeBytes: number; + modifiedAt?: string; +} { + if (!fs.existsSync(outPath)) { + return { exists: false, sizeBytes: 0 }; + } + const stats = fs.statSync(outPath); + return { + exists: true, + sizeBytes: stats.size, + modifiedAt: stats.mtime.toISOString(), + }; +} + +export function rotateAppLogIfNeeded( + outPath: string, + config: { maxBytes: number; maxRotatedFiles: number }, +): void { + if (!fs.existsSync(outPath)) return; + const stats = fs.statSync(outPath); + if (stats.size < config.maxBytes) return; + + for (let index = config.maxRotatedFiles; index >= 1; index -= 1) { + const from = index === 1 ? outPath : `${outPath}.${index - 1}`; + const to = `${outPath}.${index}`; + if (!fs.existsSync(from)) continue; + if (fs.existsSync(to)) fs.unlinkSync(to); + fs.renameSync(from, to); + } +} + +export function buildIosLogPredicate(appBundleId: string): string { + return [ + `subsystem == "${appBundleId}"`, + `processImagePath ENDSWITH[c] "/${appBundleId}"`, + `senderImagePath ENDSWITH[c] "/${appBundleId}"`, + `eventMessage CONTAINS[c] "${appBundleId}"`, + ].join(' OR '); +} + +export function buildIosDeviceLogStreamArgs(deviceId: string): string[] { + return ['devicectl', 'device', 'log', 'stream', '--device', deviceId]; +} + +export function assertAndroidPackageArgSafe(appBundleId: string): void { + if (!/^[a-zA-Z0-9._:-]+$/.test(appBundleId)) { + throw new AppError('INVALID_ARGS', `Invalid Android package name for logs: ${appBundleId}`); + } +} + +async function waitForChildExit(wait: Promise, timeoutMs = 2_000): Promise { + await Promise.race([ + wait.then(() => undefined).catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +function redactChunk(chunk: string, patterns: RegExp[]): string { + if (patterns.length === 0) return chunk; + let output = chunk; + for (const pattern of patterns) { + output = output.replace(pattern, '[REDACTED]'); + } + return output; +} + +function createLineWriter( + stream: fs.WriteStream, + options: { redactionPatterns: RegExp[]; includeTokens?: string[] }, +): { onChunk: (chunk: string) => void; flush: () => void } { + const includeTokens = options.includeTokens?.filter((token) => token.length > 0) ?? []; + let pending = ''; + + const writeLine = (line: string): void => { + if (includeTokens.length > 0) { + const shouldInclude = includeTokens.some((token) => line.includes(token)); + if (!shouldInclude) return; + } + stream.write(redactChunk(line, options.redactionPatterns)); + }; + + return { + onChunk: (chunk: string) => { + const combined = `${pending}${chunk}`; + const lines = combined.split('\n'); + pending = lines.pop() ?? ''; + for (const line of lines) { + writeLine(`${line}\n`); + } + }, + flush: () => { + if (!pending) return; + writeLine(pending); + pending = ''; + }, + }; +} + +function attachChildToStream( + child: ReturnType, + stream: fs.WriteStream, + options: { endStreamOnClose: boolean; writer: { onChunk: (chunk: string) => void; flush: () => void } }, +): Promise { + const stdout = child.stdout; + const stderr = child.stderr; + if (!stdout || !stderr) { + return Promise.resolve({ stdout: '', stderr: 'missing stdio pipes', exitCode: 1 }); + } + stdout.setEncoding('utf8'); + stderr.setEncoding('utf8'); + stdout.on('data', options.writer.onChunk); + stderr.on('data', options.writer.onChunk); + stream.on('error', () => { + if (!child.killed) child.kill('SIGKILL'); + }); + child.on('error', () => stream.destroy()); + return new Promise((resolve) => { + child.on('close', (code) => { + options.writer.flush(); + if (options.endStreamOnClose) stream.end(); + resolve({ stdout: '', stderr: '', exitCode: code ?? 1 }); + }); + }); +} + +async function resolveAndroidPid(deviceId: string, appBundleId: string): Promise { + const pidResult = await runCmd('adb', ['-s', deviceId, 'shell', 'pidof', appBundleId], { + allowFailure: true, + }); + const pid = pidResult.stdout.trim().split(/\s+/)[0]; + if (!pid || !/^\d+$/.test(pid)) return null; + return pid; +} + +async function startIosAppLog( + appBundleId: string, + stream: fs.WriteStream, + redactionPatterns: RegExp[], + pidPath?: string, +): Promise { + let state: 'active' | 'failed' = 'active'; + const child = spawn('log', ['stream', '--style', 'compact', '--predicate', buildIosLogPredicate(appBundleId)], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + const writer = createLineWriter(stream, { redactionPatterns }); + if (typeof child.pid === 'number') { + writePidFile(pidPath, child.pid); + } + const wait = attachChildToStream(child, stream, { endStreamOnClose: true, writer }).then((result) => { + if (result.exitCode !== 0) state = 'failed'; + clearPidFile(pidPath); + return result; + }); + return { + backend: 'ios-simulator', + getState: () => state, + startedAt: Date.now(), + wait, + stop: async () => { + if (!child.killed) child.kill('SIGINT'); + await waitForChildExit(wait); + if (!child.killed) child.kill('SIGKILL'); + await waitForChildExit(wait); + clearPidFile(pidPath); + }, + }; +} + +async function startIosDeviceAppLog( + deviceId: string, + stream: fs.WriteStream, + redactionPatterns: RegExp[], + pidPath?: string, +): Promise { + let state: 'active' | 'failed' = 'active'; + const child = spawn('xcrun', buildIosDeviceLogStreamArgs(deviceId), { + stdio: ['ignore', 'pipe', 'pipe'], + }); + const writer = createLineWriter(stream, { redactionPatterns }); + if (typeof child.pid === 'number') { + writePidFile(pidPath, child.pid); + } + const wait = attachChildToStream(child, stream, { endStreamOnClose: true, writer }).then((result) => { + if (result.exitCode !== 0) state = 'failed'; + clearPidFile(pidPath); + return result; + }); + return { + backend: 'ios-device', + getState: () => state, + startedAt: Date.now(), + wait, + stop: async () => { + if (!child.killed) child.kill('SIGINT'); + await waitForChildExit(wait); + if (!child.killed) child.kill('SIGKILL'); + await waitForChildExit(wait); + clearPidFile(pidPath); + }, + }; +} + +async function startAndroidAppLog( + deviceId: string, + appBundleId: string, + stream: fs.WriteStream, + redactionPatterns: RegExp[], + pidPath?: string, +): Promise { + let state: 'active' | 'failed' = 'active'; + let stopped = false; + let activeChild: ReturnType | undefined; + let activeWait: Promise | undefined; + + const wait = (async (): Promise => { + try { + while (!stopped) { + const pid = await resolveAndroidPid(deviceId, appBundleId); + if (!pid) { + await sleep(1_000); + continue; + } + const child = spawn('adb', ['-s', deviceId, 'logcat', '-v', 'time', '--pid', pid], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + activeChild = child; + const writer = createLineWriter(stream, { redactionPatterns }); + activeWait = attachChildToStream(child, stream, { endStreamOnClose: false, writer }); + if (typeof child.pid === 'number') { + writePidFile(pidPath, child.pid); + } + const result = await activeWait; + clearPidFile(pidPath); + activeChild = undefined; + activeWait = undefined; + if (stopped) return { stdout: '', stderr: '', exitCode: 0 }; + if (result.exitCode !== 0) { + state = 'failed'; + } + await sleep(500); + } + return { stdout: '', stderr: '', exitCode: 0 }; + } finally { + stream.end(); + clearPidFile(pidPath); + } + })(); + + return { + backend: 'android', + getState: () => state, + startedAt: Date.now(), + wait, + stop: async () => { + stopped = true; + if (activeChild && !activeChild.killed) { + activeChild.kill('SIGINT'); + } + if (activeWait) await waitForChildExit(activeWait); + if (activeChild && !activeChild.killed) { + activeChild.kill('SIGKILL'); + } + await waitForChildExit(wait); + clearPidFile(pidPath); + }, + }; +} + +export async function startAppLog( + device: DeviceInfo, + appBundleId: string, + outPath: string, + pidPath?: string, +): Promise { + ensureLogPath(outPath); + const stream = fs.createWriteStream(outPath, { flags: 'a' }); + const redactionPatterns = getAppLogRedactionPatterns(); + if (device.platform === 'ios') { + if (device.kind === 'device') { + return await startIosDeviceAppLog(device.id, stream, redactionPatterns, pidPath); + } + return await startIosAppLog(appBundleId, stream, redactionPatterns, pidPath); + } + if (device.platform === 'android') { + assertAndroidPackageArgSafe(appBundleId); + return await startAndroidAppLog(device.id, appBundleId, stream, redactionPatterns, pidPath); + } + stream.end(); + throw new AppError('UNSUPPORTED_PLATFORM', `unsupported platform: ${device.platform}`); +} + +export async function stopAppLog(appLog: AppLogResult): Promise { + await appLog.stop(); + await waitForChildExit(appLog.wait); +} + +export async function runAppLogDoctor( + device: DeviceInfo, + appBundleId?: string, +): Promise { + const checks: Record = {}; + const notes: string[] = []; + if (!appBundleId) { + notes.push('No app bundle is tracked in this session. Run open first for app-scoped logs.'); + } + if (device.platform === 'android') { + try { + const adb = await runCmd('adb', ['version'], { allowFailure: true }); + checks.adbAvailable = adb.exitCode === 0; + } catch { + checks.adbAvailable = false; + } + if (appBundleId) { + try { + const pidof = await runCmd('adb', ['-s', device.id, 'shell', 'pidof', appBundleId], { allowFailure: true }); + checks.androidPidVisible = pidof.stdout.trim().length > 0; + } catch { + checks.androidPidVisible = false; + } + } + } + if (device.platform === 'ios' && device.kind === 'simulator') { + try { + const simctl = await runCmd('xcrun', ['simctl', 'help'], { allowFailure: true }); + checks.simctlAvailable = simctl.exitCode === 0; + } catch { + checks.simctlAvailable = false; + } + } + if (device.platform === 'ios' && device.kind === 'device') { + try { + const devicectl = await runCmd('xcrun', ['devicectl', '--version'], { allowFailure: true }); + checks.devicectlAvailable = devicectl.exitCode === 0; + } catch { + checks.devicectlAvailable = false; + } + } + return { checks, notes }; +} + +export function cleanupStaleAppLogProcesses(sessionsDir: string): void { + if (!fs.existsSync(sessionsDir)) return; + const entries = fs.readdirSync(sessionsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const pidPath = path.join(sessionsDir, entry.name, APP_LOG_PID_FILE); + if (!fs.existsSync(pidPath)) continue; + try { + const meta = parsePidFile(fs.readFileSync(pidPath, 'utf8')); + if (meta && shouldTerminateStoredProcess(meta)) { + try { + process.kill(meta.pid, 'SIGTERM'); + } catch { + // process already gone + } + } + } catch { + // ignore malformed pid files + } finally { + clearPidFile(pidPath); + } + } +} + +export function appendAppLogMarker(outPath: string, marker: string): void { + ensureLogPath(outPath); + const line = `[agent-device][mark][${new Date().toISOString()}] ${marker.trim() || 'marker'}\n`; + fs.appendFileSync(outPath, line, 'utf8'); +} + +export const APP_LOG_PID_FILENAME = APP_LOG_PID_FILE; diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index b1da795e7..2ac726960 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -1163,3 +1163,396 @@ test('replay inherits parent device selectors for each invoked step', async () = assert.equal(invoked[0]?.flags?.device, 'thymikee-iphone'); assert.equal(invoked[0]?.flags?.udid, '00008150-001849640CF8401C'); }); + +test('logs requires an active session', async () => { + const sessionStore = makeSessionStore(); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'logs', + positionals: ['path'], + flags: {}, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + assert.ok(response); + assert.equal(response?.ok, false); + if (response && !response.ok) { + assert.equal(response.error.code, 'SESSION_NOT_FOUND'); + } +}); + +test('logs path returns path and active flag when session exists', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'default'; + sessionStore.set( + sessionName, + makeSession(sessionName, { + platform: 'ios', + id: 'sim-1', + name: 'iPhone Simulator', + kind: 'simulator', + booted: true, + }), + ); + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'logs', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + assert.ok(response); + assert.equal(response?.ok, true); + if (response && response.ok && response.data) { + assert.equal(typeof response.data.path, 'string'); + assert.ok((response.data.path as string).endsWith('app.log')); + assert.equal(response.data.active, false); + assert.equal(response.data.backend, 'ios-simulator'); + assert.equal(typeof response.data.hint, 'string'); + } +}); + +test('logs rejects invalid action', async () => { + const sessionStore = makeSessionStore(); + sessionStore.set( + 'default', + makeSession('default', { + platform: 'ios', + id: 'sim-1', + name: 'iPhone', + kind: 'simulator', + booted: true, + }), + ); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'logs', + positionals: ['invalid'], + flags: {}, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + assert.ok(response); + assert.equal(response?.ok, false); + if (response && !response.ok) { + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /path, start, stop, doctor, or mark/); + } +}); + +test('logs start requires app session (appBundleId)', async () => { + const sessionStore = makeSessionStore(); + sessionStore.set( + 'default', + makeSession('default', { + platform: 'ios', + id: 'sim-1', + name: 'iPhone', + kind: 'simulator', + booted: true, + }), + ); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'logs', + positionals: ['start'], + flags: {}, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + assert.ok(response); + assert.equal(response?.ok, false); + if (response && !response.ok) { + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /app session|open first/i); + } +}); + +test('logs stop requires active app log stream', async () => { + const sessionStore = makeSessionStore(); + sessionStore.set( + 'default', + makeSession('default', { + platform: 'ios', + id: 'sim-1', + name: 'iPhone', + kind: 'simulator', + booted: true, + }), + ); + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'logs', + positionals: ['stop'], + flags: {}, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + assert.ok(response); + assert.equal(response?.ok, false); + if (response && !response.ok) { + assert.equal(response.error.code, 'INVALID_ARGS'); + assert.match(response.error.message, /no app log stream/i); + } +}); + +test('logs start stores session app log state on success', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'default'; + sessionStore.set( + sessionName, + { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.app', + }, + ); + let startCalls = 0; + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'logs', + positionals: ['start'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + appLogOps: { + start: async (_device, _bundleId, _outPath) => { + startCalls += 1; + return { + backend: 'android', + startedAt: 123, + getState: () => 'active' as const, + stop: async () => {}, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }; + }, + stop: async () => {}, + }, + }); + assert.ok(response); + assert.equal(response?.ok, true); + assert.equal(startCalls, 1); + const session = sessionStore.get(sessionName); + assert.ok(session?.appLog); + assert.equal(session?.appLog?.getState(), 'active'); + assert.equal(session?.appLog?.backend, 'android'); + assert.equal(session?.appLog?.startedAt, 123); +}); + +test('logs stop clears active session app log state', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'default'; + sessionStore.set( + sessionName, + { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.app', + appLog: { + platform: 'android', + backend: 'android', + outPath: '/tmp/app.log', + startedAt: Date.now(), + getState: () => 'active', + stop: async () => {}, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }, + }, + ); + let stopCalls = 0; + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'logs', + positionals: ['stop'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + appLogOps: { + start: async () => { + throw new Error('should not be called'); + }, + stop: async () => { + stopCalls += 1; + }, + }, + }); + assert.ok(response); + assert.equal(response?.ok, true); + assert.equal(stopCalls, 1); + const session = sessionStore.get(sessionName); + assert.equal(session?.appLog, undefined); +}); + +test('close auto-stops active app log stream', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'default'; + sessionStore.set( + sessionName, + { + ...makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }), + appBundleId: 'com.example.app', + appLog: { + platform: 'android', + backend: 'android', + outPath: '/tmp/app.log', + startedAt: Date.now(), + getState: () => 'active', + stop: async () => {}, + wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }), + }, + }, + ); + let stopCalls = 0; + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + appLogOps: { + start: async () => { + throw new Error('should not be called'); + }, + stop: async () => { + stopCalls += 1; + }, + }, + }); + assert.ok(response); + assert.equal(response?.ok, true); + assert.equal(stopCalls, 1); + assert.equal(sessionStore.get(sessionName), undefined); +}); + +test('logs mark appends marker and returns path', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'default'; + sessionStore.set( + sessionName, + { + ...makeSession(sessionName, { + platform: 'ios', + id: 'sim-1', + name: 'iPhone Simulator', + kind: 'simulator', + booted: true, + }), + appBundleId: 'com.example.app', + }, + ); + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'logs', + positionals: ['mark', 'checkpoint'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + assert.ok(response); + assert.equal(response?.ok, true); + if (response && response.ok) { + assert.equal(response.data?.marked, true); + const outPath = String(response.data?.path ?? ''); + assert.match(fs.readFileSync(outPath, 'utf8'), /checkpoint/); + } +}); + +test('logs doctor returns check payload', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'default'; + sessionStore.set( + sessionName, + { + ...makeSession(sessionName, { + platform: 'ios', + id: 'sim-1', + name: 'iPhone Simulator', + kind: 'simulator', + booted: true, + }), + appBundleId: 'com.example.app', + }, + ); + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'logs', + positionals: ['doctor'], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + assert.ok(response); + assert.equal(response?.ok, true); + if (response && response.ok) { + assert.equal(typeof response.data?.checks, 'object'); + assert.equal(Array.isArray(response.data?.notes), true); + } +}); diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 059e81f33..1106b115d 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -8,7 +8,7 @@ import { } from '../../core/batch.ts'; import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import { isDeepLinkTarget, resolveIosDeviceDeepLinkBundleId } from '../../core/open-target.ts'; -import { AppError, asAppError } from '../../utils/errors.ts'; +import { AppError, asAppError, normalizeError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import type { DaemonRequest, DaemonResponse, SessionAction, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; @@ -32,6 +32,7 @@ import { isClickLikeCommand, parseReplaySeriesFlags, } from '../script-utils.ts'; +import { appendAppLogMarker, getAppLogPathMetadata, runAppLogDoctor, startAppLog, stopAppLog } from '../app-log.ts'; type ReinstallOps = { ios: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleId: string }>; @@ -233,6 +234,10 @@ export async function handleSessionCommands(params: { resolveTargetDevice?: typeof resolveTargetDevice; reinstallOps?: ReinstallOps; stopIosRunner?: typeof stopIosRunnerSession; + appLogOps?: { + start: typeof startAppLog; + stop: typeof stopAppLog; + }; }): Promise { const { req, @@ -245,6 +250,10 @@ export async function handleSessionCommands(params: { resolveTargetDevice: resolveTargetDeviceOverride, reinstallOps = defaultReinstallOps, stopIosRunner: stopIosRunnerOverride, + appLogOps = { + start: startAppLog, + stop: stopAppLog, + }, } = params; const dispatch = dispatchOverride ?? dispatchCommand; const ensureReady = ensureReadyOverride ?? ensureDeviceReady; @@ -612,6 +621,107 @@ export async function handleSessionCommands(params: { } } + if (command === 'logs') { + const session = sessionStore.get(sessionName); + if (!session) { + return { ok: false, error: { code: 'SESSION_NOT_FOUND', message: 'logs requires an active session' } }; + } + const action = (req.positionals?.[0] ?? 'path').toLowerCase(); + if (!['path', 'start', 'stop', 'doctor', 'mark'].includes(action)) { + return { ok: false, error: { code: 'INVALID_ARGS', message: 'logs requires path, start, stop, doctor, or mark' } }; + } + if (action === 'path') { + const logPath = sessionStore.resolveAppLogPath(sessionName); + const metadata = getAppLogPathMetadata(logPath); + const backend = + session.appLog?.backend + ?? (session.device.platform === 'ios' + ? session.device.kind === 'device' + ? 'ios-device' + : 'ios-simulator' + : 'android'); + return { + ok: true, + data: { + path: logPath, + active: Boolean(session.appLog), + state: session.appLog?.getState() ?? 'inactive', + backend, + sizeBytes: metadata.sizeBytes, + modifiedAt: metadata.modifiedAt, + startedAt: session.appLog?.startedAt ? new Date(session.appLog.startedAt).toISOString() : undefined, + hint: 'Grep the file for token-efficient debugging, e.g. grep -n "Error\\|Exception" ', + }, + }; + } + if (action === 'doctor') { + const logPath = sessionStore.resolveAppLogPath(sessionName); + const doctor = await runAppLogDoctor(session.device, session.appBundleId); + return { + ok: true, + data: { + path: logPath, + active: Boolean(session.appLog), + state: session.appLog?.getState() ?? 'inactive', + checks: doctor.checks, + notes: doctor.notes, + }, + }; + } + if (action === 'mark') { + const marker = req.positionals?.slice(1).join(' ') ?? ''; + const logPath = sessionStore.resolveAppLogPath(sessionName); + appendAppLogMarker(logPath, marker); + return { ok: true, data: { path: logPath, marked: true } }; + } + if (action === 'start') { + if (session.appLog) { + return { ok: false, error: { code: 'INVALID_ARGS', message: 'app log already streaming; run logs stop first' } }; + } + if (!session.appBundleId) { + return { ok: false, error: { code: 'INVALID_ARGS', message: 'logs start requires an app session; run open first' } }; + } + if (!isCommandSupportedOnDevice('logs', session.device)) { + const unsupportedError = normalizeError(new AppError('UNSUPPORTED_OPERATION', 'logs is not supported on this device')); + return { + ok: false, + error: unsupportedError, + }; + } + const appLogPath = sessionStore.resolveAppLogPath(sessionName); + const appLogPidPath = sessionStore.resolveAppLogPidPath(sessionName); + try { + const appLogStream = await appLogOps.start(session.device, session.appBundleId, appLogPath, appLogPidPath); + const nextSession: SessionState = { + ...session, + appLog: { + platform: session.device.platform, + backend: appLogStream.backend, + outPath: appLogPath, + startedAt: appLogStream.startedAt, + getState: appLogStream.getState, + stop: appLogStream.stop, + wait: appLogStream.wait, + }, + }; + sessionStore.set(sessionName, nextSession); + return { ok: true, data: { path: appLogPath, started: true } }; + } catch (err) { + const normalizedError = normalizeError(err); + return { ok: false, error: normalizedError }; + } + } + if (action === 'stop') { + if (!session.appLog) { + return { ok: false, error: { code: 'INVALID_ARGS', message: 'no app log stream active' } }; + } + const outPath = session.appLog.outPath; + await appLogOps.stop(session.appLog); + sessionStore.set(sessionName, { ...session, appLog: undefined }); + return { ok: true, data: { path: outPath, stopped: true } }; + } + } + if (command === 'batch') { return await runBatchCommands(req, sessionName, invoke); } @@ -621,6 +731,9 @@ export async function handleSessionCommands(params: { if (!session) { return { ok: false, error: { code: 'SESSION_NOT_FOUND', message: 'No active session' } }; } + if (session.appLog) { + await appLogOps.stop(session.appLog); + } if (req.positionals && req.positionals.length > 0) { await dispatch(session.device, 'close', req.positionals ?? [], req.flags?.out, { ...contextFromFlags(logPath, req.flags, session.appBundleId, session.trace?.outPath), diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 73e08769c..f5e25975a 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -86,11 +86,24 @@ export class SessionStore { } defaultTracePath(session: SessionState): string { - const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_'); + const safeName = SessionStore.safeSessionName(session.name); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); return path.join(this.sessionsDir, `${safeName}-${timestamp}.trace.log`); } + /** Path to session-scoped app log file. Agent can grep this for token-efficient debugging. */ + resolveAppLogPath(sessionName: string): string { + return path.join(this.sessionsDir, SessionStore.safeSessionName(sessionName), 'app.log'); + } + + resolveAppLogPidPath(sessionName: string): string { + return path.join(this.sessionsDir, SessionStore.safeSessionName(sessionName), 'app-log.pid'); + } + + static safeSessionName(name: string): string { + return name.replace(/[^a-zA-Z0-9._-]/g, '_'); + } + static expandHome(filePath: string, cwd?: string): string { if (filePath.startsWith('~/')) { return path.join(os.homedir(), filePath.slice(2)); @@ -106,7 +119,7 @@ export class SessionStore { return SessionStore.expandHome(session.saveScriptPath); } if (!fs.existsSync(this.sessionsDir)) fs.mkdirSync(this.sessionsDir, { recursive: true }); - const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_'); + const safeName = SessionStore.safeSessionName(session.name); const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-'); return path.join(this.sessionsDir, `${safeName}-${timestamp}.ad`); } diff --git a/src/daemon/types.ts b/src/daemon/types.ts index afa8e8565..b5c8ce9db 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -57,6 +57,16 @@ export type SessionState = { outPath: string; remotePath: string; }; + /** Session-scoped app log stream; logs written to outPath for agent to grep */ + appLog?: { + platform: 'ios' | 'android'; + backend: 'ios-simulator' | 'ios-device' | 'android'; + outPath: string; + startedAt: number; + getState: () => 'active' | 'failed'; + stop: () => Promise; + wait: Promise; + }; }; export type SessionAction = { diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 8e9efe2cb..ccf7e8fd0 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -528,6 +528,13 @@ const COMMAND_SCHEMAS: Record = { allowedFlags: [], skipCapabilityCheck: true, }, + logs: { + usageOverride: 'logs path | logs start | logs stop | logs doctor | logs mark [message...]', + description: 'Session app log info, start/stop streaming, diagnostics, and markers', + positionalArgs: ['path|start|stop|doctor|mark', 'message?'], + allowsExtraPositionals: true, + allowedFlags: [], + }, find: { usageOverride: 'find [value]', description: 'Find by text/label/value/role/id and run action', diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 4caf397a5..1456930cb 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -163,6 +163,40 @@ agent-device record start session.mp4 --fps 30 # Override iOS device runner FPS agent-device record stop # Stop active recording ``` +**Session app logs (token-efficient debugging):** Logs are written to a file so agents can grep instead of loading full output into context. + +```bash +agent-device logs path # Print session log file path (e.g. ~/.agent-device/sessions/default/app.log) +agent-device logs start # Start streaming app stdout/stderr to that file (requires open first) +agent-device logs stop # Stop streaming +agent-device logs doctor # Show logs backend/tool checks and readiness hints +agent-device logs mark "before submit" # Insert timeline marker into app.log +``` + +- Supported on iOS simulator, iOS physical device, and Android. +- `logs start` appends to `app.log` and rotates to `app.log.1` when the file exceeds 5 MB. +- Android log streaming automatically rebinds to the app PID after process restarts. +- iOS log capture relies on Unified Logging signals (for example `os_log`); plain stdout/stderr output may be limited depending on app/runtime. +- Retention knobs: set `AGENT_DEVICE_APP_LOG_MAX_BYTES` and `AGENT_DEVICE_APP_LOG_MAX_FILES` to override rotation limits. +- Optional write-time redaction patterns: set `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` to a comma-separated regex list. + +**Grepping app logs:** Use `logs path` to get the file path, then run `grep` (or `grep -E`) on that path so only matching lines enter context—keeping token use low. + +```bash +# Get path first (e.g. ~/.agent-device/sessions/default/app.log) +agent-device logs path + +# Then grep the path; -n adds line numbers for reference +grep -n "Error\|Exception\|Fatal" ~/.agent-device/sessions/default/app.log +grep -n -E "Error|Exception|Fatal|crash" ~/.agent-device/sessions/default/app.log + +# Last 50 lines only (bounded context) +tail -50 ~/.agent-device/sessions/default/app.log +``` + +- Use `-n` to include line numbers. Use `-E` for extended regex and `|` without escaping in the pattern. +- Prefer targeted patterns (e.g. `Error`, `Exception`, your log tags) over reading the whole file. + - iOS `record` works on simulators and physical devices. - iOS simulator recording uses native `simctl io ... recordVideo`. - Physical iOS device capture is runner-based and built from repeated `XCUIScreen.main.screenshot()` frames (no native video stream/audio capture).