pi-deadman 1.0.0
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/README.md +110 -0
- package/__tests__/calibration.test.ts +73 -0
- package/__tests__/canary.test.ts +68 -0
- package/__tests__/fast-watchdog.test.ts +188 -0
- package/__tests__/index.test.ts +103 -0
- package/__tests__/keywords.test.ts +130 -0
- package/__tests__/logging.test.ts +128 -0
- package/__tests__/monitor.test.ts +115 -0
- package/__tests__/processes.test.ts +74 -0
- package/__tests__/signals.test.ts +59 -0
- package/__tests__/tree.test.ts +327 -0
- package/__tests__/watchdog.test.ts +421 -0
- package/__tests__/worker.test.ts +85 -0
- package/__tests__/zones.test.ts +182 -0
- package/extensions/calibration.ts +62 -0
- package/extensions/canary.ts +51 -0
- package/extensions/index.ts +363 -0
- package/extensions/keywords.ts +77 -0
- package/extensions/logging.ts +82 -0
- package/extensions/monitor.ts +512 -0
- package/extensions/processes.ts +94 -0
- package/extensions/signals.ts +172 -0
- package/extensions/tree.ts +218 -0
- package/extensions/watchdog.ts +138 -0
- package/extensions/worker.ts +208 -0
- package/extensions/zones.ts +109 -0
- package/helpers/footprint.py +72 -0
- package/helpers/footprint_worker.py +214 -0
- package/package.json +24 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// __tests__/logging.test.ts
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { Logger } from "../extensions/logging";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
|
|
8
|
+
describe("Logger", () => {
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
let logger: Logger;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "deadman-log-test-"));
|
|
14
|
+
logger = new Logger(tmpDir);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// --- system.jsonl ---
|
|
22
|
+
it("appends system poll to system.jsonl", () => {
|
|
23
|
+
logger.logSystem({ ts: Date.now(), zone: "GREEN", canary_ms: 8.0 });
|
|
24
|
+
const lines = fs.readFileSync(path.join(tmpDir, "system.jsonl"), "utf-8").trim().split("\n");
|
|
25
|
+
expect(lines).toHaveLength(1);
|
|
26
|
+
const entry = JSON.parse(lines[0]);
|
|
27
|
+
expect(entry.zone).toBe("GREEN");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("appends multiple system entries", () => {
|
|
31
|
+
logger.logSystem({ ts: 1, zone: "GREEN" });
|
|
32
|
+
logger.logSystem({ ts: 2, zone: "YELLOW" });
|
|
33
|
+
logger.logSystem({ ts: 3, zone: "ORANGE" });
|
|
34
|
+
const lines = fs.readFileSync(path.join(tmpDir, "system.jsonl"), "utf-8").trim().split("\n");
|
|
35
|
+
expect(lines).toHaveLength(3);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// --- processes.jsonl ---
|
|
39
|
+
it("appends process snapshot to processes.jsonl", () => {
|
|
40
|
+
logger.logProcesses({ ts: Date.now(), processes: [{ pid: 1, name: "test", footprint_mb: 100 }] });
|
|
41
|
+
const lines = fs.readFileSync(path.join(tmpDir, "processes.jsonl"), "utf-8").trim().split("\n");
|
|
42
|
+
expect(lines).toHaveLength(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// --- tool_impact.jsonl ---
|
|
46
|
+
it("appends tool impact to tool_impact.jsonl", () => {
|
|
47
|
+
logger.logToolImpact({
|
|
48
|
+
ts: Date.now(),
|
|
49
|
+
command: "npm install",
|
|
50
|
+
tier: 3,
|
|
51
|
+
memory_before_mb: 500,
|
|
52
|
+
memory_after_mb: 700,
|
|
53
|
+
delta_mb: 200,
|
|
54
|
+
duration_ms: 30000,
|
|
55
|
+
});
|
|
56
|
+
const lines = fs.readFileSync(path.join(tmpDir, "tool_impact.jsonl"), "utf-8").trim().split("\n");
|
|
57
|
+
expect(lines).toHaveLength(1);
|
|
58
|
+
const entry = JSON.parse(lines[0]);
|
|
59
|
+
expect(entry.command).toBe("npm install");
|
|
60
|
+
expect(entry.delta_mb).toBe(200);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// --- decisions.jsonl ---
|
|
64
|
+
it("appends decision to decisions.jsonl", () => {
|
|
65
|
+
logger.logDecision({
|
|
66
|
+
ts: Date.now(),
|
|
67
|
+
command: "docker build .",
|
|
68
|
+
tier: 3,
|
|
69
|
+
zone: "ORANGE",
|
|
70
|
+
action: "block",
|
|
71
|
+
reason: "System in ORANGE, tier 3 command blocked",
|
|
72
|
+
});
|
|
73
|
+
const lines = fs.readFileSync(path.join(tmpDir, "decisions.jsonl"), "utf-8").trim().split("\n");
|
|
74
|
+
expect(lines).toHaveLength(1);
|
|
75
|
+
const entry = JSON.parse(lines[0]);
|
|
76
|
+
expect(entry.action).toBe("block");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("logs pass decisions too", () => {
|
|
80
|
+
logger.logDecision({
|
|
81
|
+
ts: Date.now(),
|
|
82
|
+
command: "cat file.txt",
|
|
83
|
+
tier: 1,
|
|
84
|
+
zone: "GREEN",
|
|
85
|
+
action: "pass",
|
|
86
|
+
});
|
|
87
|
+
const lines = fs.readFileSync(path.join(tmpDir, "decisions.jsonl"), "utf-8").trim().split("\n");
|
|
88
|
+
const entry = JSON.parse(lines[0]);
|
|
89
|
+
expect(entry.action).toBe("pass");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// --- GC ---
|
|
93
|
+
it("gc removes entries older than retention days", () => {
|
|
94
|
+
const oldTs = Date.now() / 1000 - 86400 * 15; // 15 days ago
|
|
95
|
+
const newTs = Date.now() / 1000; // now
|
|
96
|
+
logger.logSystem({ ts: oldTs, zone: "GREEN" });
|
|
97
|
+
logger.logSystem({ ts: newTs, zone: "YELLOW" });
|
|
98
|
+
logger.gc(10); // 10-day retention
|
|
99
|
+
const lines = fs.readFileSync(path.join(tmpDir, "system.jsonl"), "utf-8").trim().split("\n");
|
|
100
|
+
expect(lines).toHaveLength(1);
|
|
101
|
+
expect(JSON.parse(lines[0]).zone).toBe("YELLOW");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("gc runs across all 4 files", () => {
|
|
105
|
+
const oldTs = Date.now() / 1000 - 86400 * 15;
|
|
106
|
+
logger.logSystem({ ts: oldTs, zone: "OLD" });
|
|
107
|
+
logger.logProcesses({ ts: oldTs, processes: [] });
|
|
108
|
+
logger.logToolImpact({ ts: oldTs, command: "old" });
|
|
109
|
+
logger.logDecision({ ts: oldTs, command: "old", action: "pass" });
|
|
110
|
+
logger.gc(10);
|
|
111
|
+
for (const file of ["system.jsonl", "processes.jsonl", "tool_impact.jsonl", "decisions.jsonl"]) {
|
|
112
|
+
const content = fs.readFileSync(path.join(tmpDir, file), "utf-8").trim();
|
|
113
|
+
expect(content).toBe("");
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// --- Resilience ---
|
|
118
|
+
it("handles corrupted lines during read", () => {
|
|
119
|
+
fs.writeFileSync(path.join(tmpDir, "system.jsonl"), '{"ts":1}\nnot json\n{"ts":2}\n');
|
|
120
|
+
expect(() => logger.gc(10)).not.toThrow();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("creates log files on first write", () => {
|
|
124
|
+
expect(fs.existsSync(path.join(tmpDir, "system.jsonl"))).toBe(false);
|
|
125
|
+
logger.logSystem({ ts: 1 });
|
|
126
|
+
expect(fs.existsSync(path.join(tmpDir, "system.jsonl"))).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// __tests__/monitor.test.ts
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { Monitor } from "../extensions/monitor";
|
|
4
|
+
import { Zone } from "../extensions/zones";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
|
|
9
|
+
describe("Monitor", () => {
|
|
10
|
+
let tmpDir: string;
|
|
11
|
+
let baselinePath: string;
|
|
12
|
+
let logDir: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "deadman-monitor-test-"));
|
|
16
|
+
baselinePath = path.join(tmpDir, "baseline.json");
|
|
17
|
+
logDir = path.join(tmpDir, "logs");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("starts in GREEN with default zone", () => {
|
|
25
|
+
const monitor = new Monitor({ baselinePath, logDir });
|
|
26
|
+
expect(monitor.currentZone).toBe(Zone.GREEN);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("reports not calibrated before calibration", () => {
|
|
30
|
+
const monitor = new Monitor({ baselinePath, logDir });
|
|
31
|
+
expect(monitor.isCalibrated).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("loads persisted baseline on construction", () => {
|
|
35
|
+
fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
|
|
36
|
+
fs.writeFileSync(baselinePath, JSON.stringify({
|
|
37
|
+
canary_ms: 7.5,
|
|
38
|
+
calibrated_at: "2026-02-28T10:00:00.000Z",
|
|
39
|
+
source: "calibrated",
|
|
40
|
+
}));
|
|
41
|
+
const monitor = new Monitor({ baselinePath, logDir });
|
|
42
|
+
expect(monitor.isCalibrated).toBe(true);
|
|
43
|
+
expect(monitor.baseline?.canary_ms).toBe(7.5);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("getSnapshot returns current state", () => {
|
|
47
|
+
const monitor = new Monitor({ baselinePath, logDir });
|
|
48
|
+
const snap = monitor.getSnapshot();
|
|
49
|
+
expect(snap).toHaveProperty("zone");
|
|
50
|
+
expect(snap).toHaveProperty("trend");
|
|
51
|
+
expect(snap).toHaveProperty("confirmed");
|
|
52
|
+
expect(snap).toHaveProperty("isCalibrated");
|
|
53
|
+
expect(snap).toHaveProperty("baseline");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("start and stop control the polling loop", async () => {
|
|
57
|
+
fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
|
|
58
|
+
fs.writeFileSync(baselinePath, JSON.stringify({
|
|
59
|
+
canary_ms: 8.0,
|
|
60
|
+
calibrated_at: "2026-02-28T10:00:00.000Z",
|
|
61
|
+
source: "calibrated",
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
const monitor = new Monitor({ baselinePath, logDir });
|
|
65
|
+
monitor.start();
|
|
66
|
+
|
|
67
|
+
// Wait for at least one poll
|
|
68
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
69
|
+
expect(monitor.pollCount).toBeGreaterThanOrEqual(1);
|
|
70
|
+
|
|
71
|
+
monitor.stop();
|
|
72
|
+
const countAfterStop = monitor.pollCount;
|
|
73
|
+
|
|
74
|
+
// Wait and confirm no more polls
|
|
75
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
76
|
+
expect(monitor.pollCount).toBe(countAfterStop);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("creates log directory on start", () => {
|
|
80
|
+
const monitor = new Monitor({ baselinePath, logDir });
|
|
81
|
+
monitor.start();
|
|
82
|
+
expect(fs.existsSync(logDir)).toBe(true);
|
|
83
|
+
monitor.stop();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// --- New tests for watchdog integration ---
|
|
87
|
+
|
|
88
|
+
it("exposes worker for external use", () => {
|
|
89
|
+
const monitor = new Monitor({ baselinePath, logDir });
|
|
90
|
+
// Worker should be accessible (may not be started yet)
|
|
91
|
+
expect(monitor.getWorker()).toBeDefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("has watchdog state with defaults", () => {
|
|
95
|
+
const monitor = new Monitor({ baselinePath, logDir });
|
|
96
|
+
const state = monitor.getWatchdogState();
|
|
97
|
+
expect(state).toHaveProperty("snapshotHistory");
|
|
98
|
+
expect(state).toHaveProperty("lastNonRedTimestamp");
|
|
99
|
+
expect(state).toHaveProperty("consecutiveNonRedPolls");
|
|
100
|
+
expect(state).toHaveProperty("lastKillTime");
|
|
101
|
+
expect(state).toHaveProperty("cooldownMs");
|
|
102
|
+
expect(state.lastKillTime).toBe(0);
|
|
103
|
+
expect(state.consecutiveNonRedPolls).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("exposes onAutoKill callback setter", () => {
|
|
107
|
+
const monitor = new Monitor({ baselinePath, logDir });
|
|
108
|
+
const kills: any[] = [];
|
|
109
|
+
monitor.onAutoKill((decision) => {
|
|
110
|
+
kills.push(decision);
|
|
111
|
+
});
|
|
112
|
+
// Just verifying the API exists, no kill will fire in GREEN
|
|
113
|
+
expect(kills).toHaveLength(0);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// __tests__/processes.test.ts
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { getTopProcesses, formatProcessList, type ProcessInfo } from "../extensions/processes";
|
|
4
|
+
|
|
5
|
+
describe("getTopProcesses", () => {
|
|
6
|
+
it("returns an array of ProcessInfo objects", async () => {
|
|
7
|
+
const procs = await getTopProcesses();
|
|
8
|
+
expect(Array.isArray(procs)).toBe(true);
|
|
9
|
+
expect(procs.length).toBeGreaterThan(0);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("each process has pid, name, footprint_mb, rss_mb", async () => {
|
|
13
|
+
const procs = await getTopProcesses();
|
|
14
|
+
const first = procs[0];
|
|
15
|
+
expect(first).toHaveProperty("pid");
|
|
16
|
+
expect(first).toHaveProperty("name");
|
|
17
|
+
expect(first).toHaveProperty("footprint_mb");
|
|
18
|
+
expect(first).toHaveProperty("rss_mb");
|
|
19
|
+
expect(typeof first.pid).toBe("number");
|
|
20
|
+
expect(typeof first.name).toBe("string");
|
|
21
|
+
expect(typeof first.footprint_mb).toBe("number");
|
|
22
|
+
expect(typeof first.rss_mb).toBe("number");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("is sorted by footprint_mb descending", async () => {
|
|
26
|
+
const procs = await getTopProcesses();
|
|
27
|
+
for (let i = 1; i < procs.length; i++) {
|
|
28
|
+
expect(procs[i - 1].footprint_mb).toBeGreaterThanOrEqual(procs[i].footprint_mb);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("footprint_mb is larger than or equal to rss_mb for most processes", async () => {
|
|
33
|
+
const procs = await getTopProcesses();
|
|
34
|
+
expect(procs[0].footprint_mb).toBeGreaterThanOrEqual(procs[0].rss_mb);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("completes in under 200ms", async () => {
|
|
38
|
+
const start = performance.now();
|
|
39
|
+
await getTopProcesses();
|
|
40
|
+
const elapsed = performance.now() - start;
|
|
41
|
+
expect(elapsed).toBeLessThan(200);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns at most 20 processes", async () => {
|
|
45
|
+
const procs = await getTopProcesses();
|
|
46
|
+
expect(procs.length).toBeLessThanOrEqual(20);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("formatProcessList", () => {
|
|
51
|
+
const mockProcesses: ProcessInfo[] = [
|
|
52
|
+
{ pid: 100, name: "firefox", footprint_mb: 800, rss_mb: 266 },
|
|
53
|
+
{ pid: 200, name: "claude", footprint_mb: 400, rss_mb: 150 },
|
|
54
|
+
{ pid: 300, name: "node", footprint_mb: 200, rss_mb: 80 },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
it("formats processes as selectable strings", () => {
|
|
58
|
+
const formatted = formatProcessList(mockProcesses, 3);
|
|
59
|
+
expect(formatted).toHaveLength(3);
|
|
60
|
+
expect(formatted[0]).toContain("firefox");
|
|
61
|
+
expect(formatted[0]).toContain("800");
|
|
62
|
+
expect(formatted[1]).toContain("claude");
|
|
63
|
+
expect(formatted[2]).toContain("node");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("limits to requested count", () => {
|
|
67
|
+
const formatted = formatProcessList(mockProcesses, 2);
|
|
68
|
+
expect(formatted).toHaveLength(2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("handles empty array", () => {
|
|
72
|
+
expect(formatProcessList([], 5)).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// __tests__/signals.test.ts
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { collectSignals, type SystemSignals } from "../extensions/signals";
|
|
4
|
+
|
|
5
|
+
describe("collectSignals", () => {
|
|
6
|
+
it("returns a SystemSignals object with all fields", async () => {
|
|
7
|
+
const s = await collectSignals();
|
|
8
|
+
expect(s).toHaveProperty("swapout_rate");
|
|
9
|
+
expect(s).toHaveProperty("swapin_rate");
|
|
10
|
+
expect(s).toHaveProperty("decomp_rate");
|
|
11
|
+
expect(s).toHaveProperty("pressure_level");
|
|
12
|
+
expect(s).toHaveProperty("memorystatus_level");
|
|
13
|
+
expect(s).toHaveProperty("swap_used_mb");
|
|
14
|
+
expect(s).toHaveProperty("swap_free_mb");
|
|
15
|
+
expect(s).toHaveProperty("compression_ratio");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("all numeric fields are non-negative", async () => {
|
|
19
|
+
const s = await collectSignals();
|
|
20
|
+
expect(s.swapout_rate).toBeGreaterThanOrEqual(0);
|
|
21
|
+
expect(s.swapin_rate).toBeGreaterThanOrEqual(0);
|
|
22
|
+
expect(s.decomp_rate).toBeGreaterThanOrEqual(0);
|
|
23
|
+
expect(s.pressure_level).toBeGreaterThanOrEqual(1);
|
|
24
|
+
expect(s.memorystatus_level).toBeGreaterThanOrEqual(0);
|
|
25
|
+
expect(s.swap_used_mb).toBeGreaterThanOrEqual(0);
|
|
26
|
+
expect(s.swap_free_mb).toBeGreaterThanOrEqual(0);
|
|
27
|
+
expect(s.compression_ratio).toBeGreaterThanOrEqual(1.0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("pressure_level is 1, 2, or 4", async () => {
|
|
31
|
+
const s = await collectSignals();
|
|
32
|
+
expect([1, 2, 4]).toContain(s.pressure_level);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("memorystatus_level is between 0 and 100", async () => {
|
|
36
|
+
const s = await collectSignals();
|
|
37
|
+
expect(s.memorystatus_level).toBeGreaterThanOrEqual(0);
|
|
38
|
+
expect(s.memorystatus_level).toBeLessThanOrEqual(100);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("swap_used_mb and swap_free_mb are reasonable (< 50GB)", async () => {
|
|
42
|
+
const s = await collectSignals();
|
|
43
|
+
expect(s.swap_used_mb).toBeLessThan(50000);
|
|
44
|
+
expect(s.swap_free_mb).toBeLessThan(50000);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("compression_ratio is at least 1.0", async () => {
|
|
48
|
+
const s = await collectSignals();
|
|
49
|
+
expect(s.compression_ratio).toBeGreaterThanOrEqual(1.0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("consecutive calls compute delta-based rates", async () => {
|
|
53
|
+
const s1 = await collectSignals();
|
|
54
|
+
await new Promise(r => setTimeout(r, 100));
|
|
55
|
+
const s2 = await collectSignals();
|
|
56
|
+
expect(typeof s2.swapout_rate).toBe("number");
|
|
57
|
+
expect(typeof s2.decomp_rate).toBe("number");
|
|
58
|
+
});
|
|
59
|
+
});
|