agent-dbg 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +3 -1
- package/.claude/skills/agent-dbg/SKILL.md +2 -0
- package/.claude/skills/agent-dbg/references/commands.md +2 -1
- package/bun.lock +60 -0
- package/dist/main.js +236 -75
- package/package.json +1 -1
- package/src/commands/attach.ts +4 -4
- package/src/commands/launch.ts +3 -5
- package/src/commands/logs.ts +58 -16
- package/src/daemon/client.ts +27 -5
- package/src/daemon/entry.ts +12 -2
- package/src/daemon/logger.ts +51 -0
- package/src/daemon/paths.ts +4 -0
- package/src/daemon/server.ts +9 -1
- package/src/daemon/session.ts +48 -10
- package/src/daemon/spawn.ts +27 -4
- package/src/formatter/logs.ts +15 -0
- package/tests/integration/daemon-logging.test.ts +156 -0
- package/tests/unit/daemon-logger.test.ts +117 -0
- package/tests/unit/daemon.test.ts +60 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { DaemonLogger } from "../../src/daemon/logger.ts";
|
|
6
|
+
import type { DaemonLogEntry } from "../../src/daemon/logger.ts";
|
|
7
|
+
import { getDaemonLogPath } from "../../src/daemon/paths.ts";
|
|
8
|
+
import { DebugSession } from "../../src/daemon/session.ts";
|
|
9
|
+
|
|
10
|
+
function readEntries(logPath: string): DaemonLogEntry[] {
|
|
11
|
+
if (!existsSync(logPath)) return [];
|
|
12
|
+
const content = readFileSync(logPath, "utf-8");
|
|
13
|
+
return content
|
|
14
|
+
.split("\n")
|
|
15
|
+
.filter((l) => l.trim())
|
|
16
|
+
.map((l) => JSON.parse(l) as DaemonLogEntry);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hasEvent(entries: DaemonLogEntry[], event: string): boolean {
|
|
20
|
+
return entries.some((e) => e.event === event);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function waitForState(
|
|
24
|
+
session: DebugSession,
|
|
25
|
+
state: "idle" | "running" | "paused",
|
|
26
|
+
timeoutMs = 2000,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const deadline = Date.now() + timeoutMs;
|
|
29
|
+
while (session.sessionState !== state && Date.now() < deadline) {
|
|
30
|
+
await Bun.sleep(50);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("DaemonLogger integration", () => {
|
|
35
|
+
test("DaemonLogger writes to daemon.log file", () => {
|
|
36
|
+
const logPath = join(tmpdir(), `test-daemon-write-${Date.now()}.daemon.log`);
|
|
37
|
+
try {
|
|
38
|
+
const logger = new DaemonLogger(logPath);
|
|
39
|
+
logger.info("test.event", "hello world", { key: "value" });
|
|
40
|
+
|
|
41
|
+
const entries = readEntries(logPath);
|
|
42
|
+
expect(entries).toHaveLength(1);
|
|
43
|
+
expect(entries[0]!.level).toBe("info");
|
|
44
|
+
expect(entries[0]!.event).toBe("test.event");
|
|
45
|
+
expect(entries[0]!.message).toBe("hello world");
|
|
46
|
+
expect(entries[0]!.data).toEqual({ key: "value" });
|
|
47
|
+
} finally {
|
|
48
|
+
if (existsSync(logPath)) unlinkSync(logPath);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("getDaemonLogPath returns correct path", () => {
|
|
53
|
+
const path = getDaemonLogPath("my-session");
|
|
54
|
+
expect(path).toEndWith("/my-session.daemon.log");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("DebugSession logs launch events", async () => {
|
|
58
|
+
const sessionName = `test-daemon-log-${Date.now()}`;
|
|
59
|
+
const session = new DebugSession(sessionName);
|
|
60
|
+
const logPath = getDaemonLogPath(sessionName);
|
|
61
|
+
try {
|
|
62
|
+
await session.launch(["node", "tests/fixtures/simple-app.js"], {
|
|
63
|
+
brk: true,
|
|
64
|
+
});
|
|
65
|
+
await waitForState(session, "paused");
|
|
66
|
+
|
|
67
|
+
const entries = readEntries(logPath);
|
|
68
|
+
expect(hasEvent(entries, "child.spawn")).toBe(true);
|
|
69
|
+
expect(hasEvent(entries, "inspector.detected")).toBe(true);
|
|
70
|
+
expect(hasEvent(entries, "cdp.connected")).toBe(true);
|
|
71
|
+
} finally {
|
|
72
|
+
await session.stop();
|
|
73
|
+
if (existsSync(logPath)) unlinkSync(logPath);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("DebugSession logs child stderr", async () => {
|
|
78
|
+
const sessionName = `test-daemon-stderr-${Date.now()}`;
|
|
79
|
+
const session = new DebugSession(sessionName);
|
|
80
|
+
const logPath = getDaemonLogPath(sessionName);
|
|
81
|
+
try {
|
|
82
|
+
await session.launch(["node", "tests/fixtures/simple-app.js"], {
|
|
83
|
+
brk: true,
|
|
84
|
+
});
|
|
85
|
+
await waitForState(session, "paused");
|
|
86
|
+
|
|
87
|
+
const entries = readEntries(logPath);
|
|
88
|
+
// The "Debugger listening on..." line comes from child stderr
|
|
89
|
+
const stderrEntries = entries.filter((e) => e.event === "child.stderr");
|
|
90
|
+
expect(stderrEntries.length).toBeGreaterThan(0);
|
|
91
|
+
const hasDebuggerLine = stderrEntries.some((e) =>
|
|
92
|
+
e.message.includes("Debugger listening on"),
|
|
93
|
+
);
|
|
94
|
+
expect(hasDebuggerLine).toBe(true);
|
|
95
|
+
} finally {
|
|
96
|
+
await session.stop();
|
|
97
|
+
if (existsSync(logPath)) unlinkSync(logPath);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("DebugSession logs inspector URL detection failure", async () => {
|
|
102
|
+
const sessionName = `test-daemon-fail-${Date.now()}`;
|
|
103
|
+
const session = new DebugSession(sessionName);
|
|
104
|
+
const logPath = getDaemonLogPath(sessionName);
|
|
105
|
+
try {
|
|
106
|
+
// Use echo (not node), which won't emit an inspector URL
|
|
107
|
+
await expect(
|
|
108
|
+
session.launch(["echo", "hello"], { brk: true }),
|
|
109
|
+
).rejects.toThrow("Failed to detect inspector URL");
|
|
110
|
+
|
|
111
|
+
const entries = readEntries(logPath);
|
|
112
|
+
expect(hasEvent(entries, "child.spawn")).toBe(true);
|
|
113
|
+
expect(hasEvent(entries, "inspector.failed")).toBe(true);
|
|
114
|
+
// Should capture the accumulated stderr in the failure entry
|
|
115
|
+
const failEntry = entries.find((e) => e.event === "inspector.failed");
|
|
116
|
+
expect(failEntry?.data?.stderr).toBeDefined();
|
|
117
|
+
} finally {
|
|
118
|
+
await session.stop();
|
|
119
|
+
if (existsSync(logPath)) unlinkSync(logPath);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("DebugSession logs process exit", async () => {
|
|
124
|
+
const sessionName = `test-daemon-exit-${Date.now()}`;
|
|
125
|
+
const session = new DebugSession(sessionName);
|
|
126
|
+
const logPath = getDaemonLogPath(sessionName);
|
|
127
|
+
try {
|
|
128
|
+
// Use process.exit() to force the child to terminate.
|
|
129
|
+
// Node.js waits for the debugger to disconnect before exiting,
|
|
130
|
+
// so we disconnect CDP after a delay to allow the natural exit.
|
|
131
|
+
await session.launch(
|
|
132
|
+
["node", "-e", "setTimeout(() => process.exit(0), 200)"],
|
|
133
|
+
{ brk: false },
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Disconnect CDP so Node.js can actually exit
|
|
137
|
+
// (Node prints "Waiting for the debugger to disconnect..." otherwise)
|
|
138
|
+
await Bun.sleep(300);
|
|
139
|
+
session.cdp?.disconnect();
|
|
140
|
+
|
|
141
|
+
// Wait for the child.exit log entry to be written
|
|
142
|
+
const deadline = Date.now() + 5000;
|
|
143
|
+
while (Date.now() < deadline) {
|
|
144
|
+
const entries = readEntries(logPath);
|
|
145
|
+
if (hasEvent(entries, "child.exit")) break;
|
|
146
|
+
await Bun.sleep(100);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const entries = readEntries(logPath);
|
|
150
|
+
expect(hasEvent(entries, "child.exit")).toBe(true);
|
|
151
|
+
} finally {
|
|
152
|
+
await session.stop();
|
|
153
|
+
if (existsSync(logPath)) unlinkSync(logPath);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { DaemonLogger } from "../../src/daemon/logger.ts";
|
|
6
|
+
import type { DaemonLogEntry } from "../../src/daemon/logger.ts";
|
|
7
|
+
|
|
8
|
+
const testDir = tmpdir();
|
|
9
|
+
|
|
10
|
+
function tempLogPath(): string {
|
|
11
|
+
return join(testDir, `test-daemon-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readEntries(logPath: string): DaemonLogEntry[] {
|
|
15
|
+
const content = readFileSync(logPath, "utf-8");
|
|
16
|
+
return content
|
|
17
|
+
.split("\n")
|
|
18
|
+
.filter((l) => l.trim())
|
|
19
|
+
.map((l) => JSON.parse(l) as DaemonLogEntry);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("DaemonLogger", () => {
|
|
23
|
+
const paths: string[] = [];
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
for (const p of paths) {
|
|
27
|
+
if (existsSync(p)) unlinkSync(p);
|
|
28
|
+
}
|
|
29
|
+
paths.length = 0;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("truncates on creation", () => {
|
|
33
|
+
const logPath = tempLogPath();
|
|
34
|
+
paths.push(logPath);
|
|
35
|
+
writeFileSync(logPath, "pre-existing content\n");
|
|
36
|
+
|
|
37
|
+
new DaemonLogger(logPath);
|
|
38
|
+
|
|
39
|
+
const content = readFileSync(logPath, "utf-8");
|
|
40
|
+
expect(content).toBe("");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("appends JSON lines", () => {
|
|
44
|
+
const logPath = tempLogPath();
|
|
45
|
+
paths.push(logPath);
|
|
46
|
+
const logger = new DaemonLogger(logPath);
|
|
47
|
+
|
|
48
|
+
logger.info("event.one", "first message");
|
|
49
|
+
logger.warn("event.two", "second message");
|
|
50
|
+
logger.error("event.three", "third message");
|
|
51
|
+
|
|
52
|
+
const entries = readEntries(logPath);
|
|
53
|
+
expect(entries).toHaveLength(3);
|
|
54
|
+
expect(entries[0]!.event).toBe("event.one");
|
|
55
|
+
expect(entries[1]!.event).toBe("event.two");
|
|
56
|
+
expect(entries[2]!.event).toBe("event.three");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("clear() truncates", () => {
|
|
60
|
+
const logPath = tempLogPath();
|
|
61
|
+
paths.push(logPath);
|
|
62
|
+
const logger = new DaemonLogger(logPath);
|
|
63
|
+
|
|
64
|
+
logger.info("test", "message");
|
|
65
|
+
logger.info("test", "another");
|
|
66
|
+
expect(readEntries(logPath)).toHaveLength(2);
|
|
67
|
+
|
|
68
|
+
logger.clear();
|
|
69
|
+
|
|
70
|
+
const content = readFileSync(logPath, "utf-8");
|
|
71
|
+
expect(content).toBe("");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("entries have correct structure", () => {
|
|
75
|
+
const logPath = tempLogPath();
|
|
76
|
+
paths.push(logPath);
|
|
77
|
+
const logger = new DaemonLogger(logPath);
|
|
78
|
+
const before = Date.now();
|
|
79
|
+
|
|
80
|
+
logger.info("child.spawn", "Process spawned", { pid: 1234 });
|
|
81
|
+
|
|
82
|
+
const entries = readEntries(logPath);
|
|
83
|
+
expect(entries).toHaveLength(1);
|
|
84
|
+
const entry = entries[0]!;
|
|
85
|
+
|
|
86
|
+
expect(entry.ts).toBeGreaterThanOrEqual(before);
|
|
87
|
+
expect(entry.ts).toBeLessThanOrEqual(Date.now());
|
|
88
|
+
expect(entry.level).toBe("info");
|
|
89
|
+
expect(entry.event).toBe("child.spawn");
|
|
90
|
+
expect(entry.message).toBe("Process spawned");
|
|
91
|
+
expect(entry.data).toEqual({ pid: 1234 });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("debug level works", () => {
|
|
95
|
+
const logPath = tempLogPath();
|
|
96
|
+
paths.push(logPath);
|
|
97
|
+
const logger = new DaemonLogger(logPath);
|
|
98
|
+
|
|
99
|
+
logger.debug("test.debug", "debug message");
|
|
100
|
+
|
|
101
|
+
const entries = readEntries(logPath);
|
|
102
|
+
expect(entries).toHaveLength(1);
|
|
103
|
+
expect(entries[0]!.level).toBe("debug");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("entries without data omit data field", () => {
|
|
107
|
+
const logPath = tempLogPath();
|
|
108
|
+
paths.push(logPath);
|
|
109
|
+
const logger = new DaemonLogger(logPath);
|
|
110
|
+
|
|
111
|
+
logger.info("test", "no data");
|
|
112
|
+
|
|
113
|
+
const entries = readEntries(logPath);
|
|
114
|
+
expect(entries).toHaveLength(1);
|
|
115
|
+
expect(entries[0]!.data).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -251,6 +251,66 @@ describe("dead socket detection", () => {
|
|
|
251
251
|
});
|
|
252
252
|
});
|
|
253
253
|
|
|
254
|
+
describe("stale daemon detection (Docker-style PID check)", () => {
|
|
255
|
+
test("isRunning returns false when socket exists but no lock file", () => {
|
|
256
|
+
const session = testSession("nolck");
|
|
257
|
+
const socketPath = getSocketPath(session);
|
|
258
|
+
|
|
259
|
+
writeFileSync(socketPath, "");
|
|
260
|
+
|
|
261
|
+
expect(DaemonClient.isRunning(session)).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("isRunning returns false when socket+lock exist but PID is dead", () => {
|
|
265
|
+
const session = testSession("dpid");
|
|
266
|
+
const socketPath = getSocketPath(session);
|
|
267
|
+
const lockPath = getLockPath(session);
|
|
268
|
+
|
|
269
|
+
writeFileSync(socketPath, "");
|
|
270
|
+
writeFileSync(lockPath, "999999"); // non-existent PID
|
|
271
|
+
|
|
272
|
+
expect(DaemonClient.isRunning(session)).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("isRunning returns true when daemon is actually alive", async () => {
|
|
276
|
+
const session = testSession("alive");
|
|
277
|
+
const server = new DaemonServer(session, { idleTimeout: 60 });
|
|
278
|
+
server.onRequest(async () => ({ ok: true }));
|
|
279
|
+
await server.start();
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
expect(DaemonClient.isRunning(session)).toBe(true);
|
|
283
|
+
} finally {
|
|
284
|
+
await server.stop();
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("isRunning returns false when no socket exists", () => {
|
|
289
|
+
const session = testSession("nosck");
|
|
290
|
+
expect(DaemonClient.isRunning(session)).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("cleanStaleFiles removes orphaned socket and lock", () => {
|
|
294
|
+
const session = testSession("clnst");
|
|
295
|
+
const socketPath = getSocketPath(session);
|
|
296
|
+
const lockPath = getLockPath(session);
|
|
297
|
+
|
|
298
|
+
writeFileSync(socketPath, "");
|
|
299
|
+
writeFileSync(lockPath, "999999");
|
|
300
|
+
|
|
301
|
+
DaemonClient.cleanStaleFiles(session);
|
|
302
|
+
|
|
303
|
+
expect(existsSync(socketPath)).toBe(false);
|
|
304
|
+
expect(existsSync(lockPath)).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("cleanStaleFiles is safe when files don't exist", () => {
|
|
308
|
+
const session = testSession("clnno");
|
|
309
|
+
// Should not throw
|
|
310
|
+
DaemonClient.cleanStaleFiles(session);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
254
314
|
describe("listSessions", () => {
|
|
255
315
|
test("returns active sessions", async () => {
|
|
256
316
|
const session1 = testSession("la");
|