Skip to content

Commit e638e0a

Browse files
authored
feat: add full session log coverage and diagnostics workflows (#106)
* feat: add full session log coverage with doctor and marker tooling * chore: split logs skill guidance into reference doc
1 parent 71df2ff commit e638e0a

16 files changed

Lines changed: 1304 additions & 4 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Minimal operating guide for AI coding agents in this repo.
3838
## Routing
3939
- Keep `src/daemon.ts` as a thin router.
4040
- Put command logic in handler modules:
41-
- session/apps/appstate/open/close/replay: `src/daemon/handlers/session.ts`
41+
- session/apps/appstate/open/close/replay/logs: `src/daemon/handlers/session.ts`
4242
- click/fill/get/is: `src/daemon/handlers/interaction.ts`
4343
- snapshot/wait/alert/settings: `src/daemon/handlers/snapshot.ts`
4444
- find: `src/daemon/handlers/find.ts`

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The project is in early development and considered experimental. Pull requests a
1616
- Platforms: iOS (simulator + physical device core automation) and Android (emulator + device).
1717
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`.
1818
- Inspection commands: `snapshot` (accessibility tree), `diff snapshot` (structural baseline diff), `appstate`, `apps`, `devices`.
19+
- App logs: `logs path` returns session log metadata; `logs start` / `logs stop` stream app output; `logs doctor` checks readiness; `logs mark` writes timeline markers.
1920
- Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode).
2021
- Minimal dependencies; TypeScript executed directly on Node 22+ (no build step).
2122

@@ -142,6 +143,7 @@ agent-device scrollintoview @e42
142143
- `press` (alias: `click`), `focus`, `type`, `fill`, `long-press`, `swipe`, `scroll`, `scrollintoview`, `pinch`, `is`
143144
- `alert`, `wait`, `screenshot`
144145
- `trace start`, `trace stop`
146+
- `logs path`, `logs start`, `logs stop`, `logs doctor`, `logs mark` (session app log file for grep; iOS simulator + iOS device + Android)
145147
- `settings wifi|airplane|location on|off`
146148
- `settings faceid match|nonmatch|enroll|unenroll` (iOS simulator only)
147149
- `appstate`, `apps`, `devices`, `session list`
@@ -296,6 +298,12 @@ App state:
296298

297299
## Debug
298300

301+
- **App logs (token-efficient):** With an active session, run `logs path` to get path + state metadata (e.g. `~/.agent-device/sessions/<session>/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" <path>`) instead of pulling full logs into context. Supported on iOS simulator, iOS physical device, and Android.
302+
- `logs start` appends to `app.log` and rotates to `app.log.1` when the file exceeds 5 MB.
303+
- Android log streaming automatically rebinds to the app PID after process restarts.
304+
- iOS log capture relies on Unified Logging signals (for example `os_log`); plain stdout/stderr output may be limited depending on app/runtime.
305+
- Retention knobs: set `AGENT_DEVICE_APP_LOG_MAX_BYTES` and `AGENT_DEVICE_APP_LOG_MAX_FILES` to override rotation limits.
306+
- Optional write-time redaction patterns: set `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` to a comma-separated regex list.
299307
- `agent-device trace start`
300308
- `agent-device trace stop ./trace.log`
301309
- The trace log includes snapshot logs and XCTest runner logs for the session.

skills/agent-device/SKILL.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,19 @@ iOS settings helpers are simulator-only.
109109
Use `match`/`nonmatch` as the canonical command values.
110110
Think of them as validate/invalidate outcomes when describing intent.
111111

112+
### Logs (token-efficient debugging)
113+
114+
Use the detailed logs workflow reference:
115+
`skills/agent-device/references/logs.md`
116+
117+
Recommended minimum:
118+
119+
```bash
120+
agent-device logs doctor
121+
agent-device logs start
122+
agent-device logs path
123+
```
124+
112125
### App state
113126

114127
```bash
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Logs (Token-Efficient Debugging)
2+
3+
App output is written to a session-scoped file so agents can grep it instead of loading full logs into context.
4+
5+
## Quick Flow
6+
7+
```bash
8+
agent-device open MyApp --platform ios
9+
agent-device logs start # Start streaming app logs to session file
10+
agent-device logs path # Print path, e.g. ~/.agent-device/sessions/default/app.log
11+
agent-device logs doctor # Check tool/runtime readiness for current session/device
12+
agent-device logs mark "before tap" # Insert a timeline marker into app.log
13+
# ... run flows; on failure, grep the path (see below)
14+
agent-device logs stop # Stop streaming (optional; close also stops)
15+
```
16+
17+
## Command Notes
18+
19+
- `logs path`: returns log file path and metadata (`active`, `state`, `backend`, size, timestamps).
20+
- `logs start`: starts streaming; requires an active app session (`open` first). Supported on iOS simulator, iOS device, and Android.
21+
- `logs stop`: stops streaming. Session `close` also stops logging.
22+
- `logs doctor`: reports backend/tool checks and readiness notes for troubleshooting.
23+
- `logs mark`: writes a timestamped marker line to the session log.
24+
25+
## Behavior and Limits
26+
27+
- `logs start` appends to `app.log` and rotates to `app.log.1` when `app.log` exceeds 5 MB.
28+
- Android log streaming automatically rebinds to the app PID after process restarts.
29+
- iOS log capture relies on Unified Logging signals (for example `os_log`); plain stdout/stderr output may be limited depending on app/runtime.
30+
- Retention knobs:
31+
- `AGENT_DEVICE_APP_LOG_MAX_BYTES`
32+
- `AGENT_DEVICE_APP_LOG_MAX_FILES`
33+
- Optional write-time redaction patterns:
34+
- `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` (comma-separated regex)
35+
36+
## Grep Patterns
37+
38+
After getting the path from `logs path`, run `grep` (or `grep -E`) so only matching lines enter context.
39+
40+
```bash
41+
# Get path first, then grep it; -n adds line numbers
42+
grep -n "Error\|Exception\|Fatal" <path>
43+
grep -n -E "Error|Exception|Fatal|crash" <path>
44+
45+
# Bounded context: last N lines only
46+
tail -50 <path>
47+
```
48+
49+
- Use `-n` for line numbers.
50+
- Use `-E` for extended regex so `|` in the pattern does not need escaping.
51+
- Prefer targeted patterns (e.g. `Error`, `Exception`, or app-specific tags) over reading the full file.

src/cli.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,38 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
240240
if (logTailStopper) logTailStopper();
241241
return;
242242
}
243+
if (command === 'logs') {
244+
const data = response.data as Record<string, unknown> | undefined;
245+
const pathOut = typeof data?.path === 'string' ? data.path : '';
246+
if (pathOut) {
247+
process.stdout.write(`${pathOut}\n`);
248+
const active = typeof data?.active === 'boolean' ? data.active : undefined;
249+
const state = typeof data?.state === 'string' ? data.state : undefined;
250+
const backend = typeof data?.backend === 'string' ? data.backend : undefined;
251+
const sizeBytes = typeof data?.sizeBytes === 'number' ? data.sizeBytes : undefined;
252+
if (!flags.json && (active !== undefined || state || backend || sizeBytes !== undefined)) {
253+
const meta = [
254+
active !== undefined ? `active=${active}` : '',
255+
state ? `state=${state}` : '',
256+
backend ? `backend=${backend}` : '',
257+
sizeBytes !== undefined ? `sizeBytes=${sizeBytes}` : '',
258+
].filter(Boolean).join(' ');
259+
if (meta) process.stderr.write(`${meta}\n`);
260+
}
261+
if (data?.hint && !flags.json) {
262+
process.stderr.write(`${data.hint}\n`);
263+
}
264+
if (Array.isArray(data?.notes) && !flags.json) {
265+
for (const note of data.notes) {
266+
if (typeof note === 'string' && note.length > 0) {
267+
process.stderr.write(`${note}\n`);
268+
}
269+
}
270+
}
271+
}
272+
if (logTailStopper) logTailStopper();
273+
return;
274+
}
243275
if (command === 'click' || command === 'press') {
244276
const ref = (response.data as any)?.ref ?? '';
245277
const x = (response.data as any)?.x;

src/core/__tests__/capabilities.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ test('core commands support iOS simulator, iOS device, and Android', () => {
6767
'get',
6868
'home',
6969
'longpress',
70+
'logs',
7071
'open',
7172
'press',
7273
'record',

src/core/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
2929
get: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3030
is: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3131
home: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
32+
logs: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3233
longpress: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3334
open: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3435
reinstall: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },

src/daemon.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { handleSnapshotCommands } from './daemon/handlers/snapshot.ts';
1616
import { handleFindCommands } from './daemon/handlers/find.ts';
1717
import { handleRecordTraceCommands } from './daemon/handlers/record-trace.ts';
1818
import { handleInteractionCommands } from './daemon/handlers/interaction.ts';
19+
import { cleanupStaleAppLogProcesses } from './daemon/app-log.ts';
1920
import { assertSessionSelectorMatches } from './daemon/session-selector.ts';
2021
import { resolveEffectiveSessionName } from './daemon/session-routing.ts';
2122
import { clearRequestCanceled, isRequestCanceled, markRequestCanceled } from './daemon/request-cancel.ts';
@@ -30,6 +31,7 @@ const infoPath = path.join(baseDir, 'daemon.json');
3031
const lockPath = path.join(baseDir, 'daemon.lock');
3132
const logPath = path.join(baseDir, 'daemon.log');
3233
const sessionsDir = path.join(baseDir, 'sessions');
34+
cleanupStaleAppLogProcesses(sessionsDir);
3335
const sessionStore = new SessionStore(sessionsDir);
3436
const version = readVersion();
3537
const token = crypto.randomBytes(24).toString('hex');
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import {
7+
APP_LOG_PID_FILENAME,
8+
appendAppLogMarker,
9+
assertAndroidPackageArgSafe,
10+
buildIosDeviceLogStreamArgs,
11+
buildIosLogPredicate,
12+
cleanupStaleAppLogProcesses,
13+
getAppLogPathMetadata,
14+
runAppLogDoctor,
15+
rotateAppLogIfNeeded,
16+
stopAppLog,
17+
} from '../app-log.ts';
18+
19+
test('buildIosLogPredicate includes bundle-aware filters', () => {
20+
const predicate = buildIosLogPredicate('com.example.app');
21+
assert.match(predicate, /subsystem == "com\.example\.app"/);
22+
assert.match(predicate, /processImagePath ENDSWITH\[c\] "\/com\.example\.app"/);
23+
assert.match(predicate, /senderImagePath ENDSWITH\[c\] "\/com\.example\.app"/);
24+
assert.match(predicate, /eventMessage CONTAINS\[c\] "com\.example\.app"/);
25+
});
26+
27+
test('assertAndroidPackageArgSafe rejects unsafe values', () => {
28+
assert.doesNotThrow(() => assertAndroidPackageArgSafe('com.example.app'));
29+
assert.throws(() => assertAndroidPackageArgSafe('com.example.app;rm -rf /'), /Invalid Android package/);
30+
});
31+
32+
test('rotateAppLogIfNeeded rotates and truncates oldest by configured max files', () => {
33+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-rotate-'));
34+
const outPath = path.join(root, 'app.log');
35+
fs.writeFileSync(outPath, 'a'.repeat(20));
36+
fs.writeFileSync(`${outPath}.1`, 'old1');
37+
fs.writeFileSync(`${outPath}.2`, 'old2');
38+
39+
rotateAppLogIfNeeded(outPath, { maxBytes: 10, maxRotatedFiles: 2 });
40+
41+
assert.equal(fs.existsSync(outPath), false);
42+
assert.equal(fs.readFileSync(`${outPath}.1`, 'utf8').length, 20);
43+
assert.equal(fs.readFileSync(`${outPath}.2`, 'utf8'), 'old1');
44+
});
45+
46+
test('stopAppLog delegates stop and waits for completion', async () => {
47+
let stopped = false;
48+
let resolved = false;
49+
const wait = new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => {
50+
setTimeout(() => {
51+
resolved = true;
52+
resolve({ stdout: '', stderr: '', exitCode: 0 });
53+
}, 5);
54+
});
55+
await stopAppLog({
56+
backend: 'android',
57+
getState: () => 'active',
58+
startedAt: Date.now(),
59+
stop: async () => {
60+
stopped = true;
61+
},
62+
wait,
63+
});
64+
assert.equal(stopped, true);
65+
assert.equal(resolved, true);
66+
});
67+
68+
test('cleanupStaleAppLogProcesses removes pid files even when pid is stale', () => {
69+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-clean-'));
70+
const sessionDir = path.join(root, 'default');
71+
fs.mkdirSync(sessionDir, { recursive: true });
72+
const pidPath = path.join(sessionDir, APP_LOG_PID_FILENAME);
73+
fs.writeFileSync(pidPath, '999999\n');
74+
75+
cleanupStaleAppLogProcesses(root);
76+
77+
assert.equal(fs.existsSync(pidPath), false);
78+
});
79+
80+
test('buildIosDeviceLogStreamArgs builds expected devicectl command args', () => {
81+
assert.deepEqual(buildIosDeviceLogStreamArgs('00008150-0000AAAA'), [
82+
'devicectl',
83+
'device',
84+
'log',
85+
'stream',
86+
'--device',
87+
'00008150-0000AAAA',
88+
]);
89+
});
90+
91+
test('cleanupStaleAppLogProcesses removes legacy plain pid files safely', () => {
92+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-clean-legacy-'));
93+
const sessionDir = path.join(root, 'default');
94+
fs.mkdirSync(sessionDir, { recursive: true });
95+
const pidPath = path.join(sessionDir, APP_LOG_PID_FILENAME);
96+
fs.writeFileSync(pidPath, '1\n');
97+
98+
cleanupStaleAppLogProcesses(root);
99+
100+
assert.equal(fs.existsSync(pidPath), false);
101+
});
102+
103+
test('appendAppLogMarker writes marker lines and metadata reflects file', () => {
104+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-app-log-mark-'));
105+
const outPath = path.join(root, 'app.log');
106+
appendAppLogMarker(outPath, 'checkpoint');
107+
const content = fs.readFileSync(outPath, 'utf8');
108+
assert.match(content, /checkpoint/);
109+
const metadata = getAppLogPathMetadata(outPath);
110+
assert.equal(metadata.exists, true);
111+
assert.ok(metadata.sizeBytes > 0);
112+
});
113+
114+
test('runAppLogDoctor returns note when app bundle is missing', async () => {
115+
const result = await runAppLogDoctor({
116+
platform: 'android',
117+
id: 'emulator-5554',
118+
name: 'Pixel',
119+
kind: 'emulator',
120+
});
121+
assert.equal(Array.isArray(result.notes), true);
122+
assert.ok(result.notes.some((note) => note.includes('Run open <app> first')));
123+
});

0 commit comments

Comments
 (0)