1import { describe, expect, it } from "@effect/vitest";
2import type {
3 DesktopHostTelemetrySnapshot,
4 ResourceMonitorSnapshotEvent,
5} from "@t3tools/contracts";
6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as Stream from "effect/Stream";
11
12import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts";
13import * as NativeTelemetryClient from "../resourceTelemetry/NativeTelemetryClient.ts";
14import * as ResourceAttribution from "../resourceTelemetry/ResourceAttribution.ts";
15import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
16import * as ProcessDiagnostics from "./ProcessDiagnostics.ts";
17
18function makeNativeSnapshot(
19 processes: ResourceMonitorSnapshotEvent["processes"],
20): ResourceMonitorSnapshotEvent {
21 return {
22 version: 3,
23 type: "snapshot",
24 sequence: 1,
25 sampledAtUnixMs: DateTime.toEpochMillis(DateTime.makeUnsafe("2026-05-05T10:00:00.000Z")),
26 collectionDurationMicros: 250,
27 scannedProcessCount: processes.length,
28 retainedProcessCount: processes.length,
29 inaccessibleProcessCount: 0,
30 processes,
31 };
32}
33
34function layerTelemetryFor(
35 snapshot: ResourceMonitorSnapshotEvent,
36 desktopSnapshot?: DesktopHostTelemetrySnapshot,
37) {
38 const layerNative = NativeTelemetryClient.layerTest({
39 sampleNow: Effect.succeed({ generation: 0, snapshot }),
40 health: Effect.succeed({
41 status: "healthy",
42 hello: Option.none(),
43 lastSampleAt: Option.some(DateTime.makeUnsafe(snapshot.sampledAtUnixMs)),
44 lastError: Option.none(),
45 restartCount: 0,
46 sampleIntervalMs: 1_000,
47 }),
48 });
49 const layerDesktop = desktopSnapshot
50 ? DesktopTelemetryReceiver.layerTest({
51 latest: Effect.succeedSome(desktopSnapshot),
52 health: Effect.succeed({
53 status: "healthy",
54 lastSampleAt: Option.some(DateTime.makeUnsafe(desktopSnapshot.sampledAtUnixMs)),
55 lastError: Option.none(),
56 }),
57 })
58 : DesktopTelemetryReceiver.layerTest();
59 return ResourceTelemetry.layer.pipe(
60 Layer.provide(Layer.mergeAll(layerNative, layerDesktop, ResourceAttribution.layer)),
61 );
62}
63
64describe("ProcessDiagnostics", () => {
65 it.effect("projects live process data from resource telemetry", () =>
66 Effect.gen(function* () {
67 const snapshot = makeNativeSnapshot([
68 {
69 pid: process.pid,
70 ppid: 1,
71 startTimeMs: 1_000,
72 runTimeMs: 60_000,
73 name: "node",
74 command: "t3 server",
75 status: "Running",
76 cpuPercent: 0,
77 cpuTimeMs: 100,
78 residentBytes: 1_024,
79 virtualBytes: 2_048,
80 ioReadBytes: 100,
81 ioWriteBytes: 200,
82 ioSemantics: "storage",
83 },
84 {
85 pid: 4_242,
86 ppid: process.pid,
87 startTimeMs: 2_000,
88 runTimeMs: 4_000,
89 name: "agent",
90 command: "codex app-server",
91 status: "Running",
92 cpuPercent: 1.5,
93 cpuTimeMs: 60,
94 residentBytes: 2_048,
95 virtualBytes: 4_096,
96 ioReadBytes: 300,
97 ioWriteBytes: 400,
98 ioSemantics: "storage",
99 },
100 ]);
101 const layerTelemetry = layerTelemetryFor(snapshot);
102 const layer = ProcessDiagnostics.layer.pipe(Layer.provideMerge(layerTelemetry));
103
104 const diagnostics = yield* Effect.gen(function* () {
105 const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics;
106 return yield* processDiagnostics.read;
107 }).pipe(Effect.provide(layer));
108
109 expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]);
110 expect(diagnostics.processes[0]?.startTimeMs).toBe(2_000);
111 expect(diagnostics.processes[0]?.cpuPercent).toBe(1.5);
112 expect(diagnostics.processes[0]?.rssBytes).toBe(2_048);
113 }),
114 );
115
116 it.effect("rejects stale process identities before signaling", () =>
117 Effect.gen(function* () {
118 const snapshot = makeNativeSnapshot([]);
119 const layerTelemetry = layerTelemetryFor(snapshot);
120 const layer = ProcessDiagnostics.layer.pipe(Layer.provide(layerTelemetry));
121
122 const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
123 Effect.flatMap((processDiagnostics) =>
124 processDiagnostics.signal({
125 pid: 4_242,
126 startTimeMs: 2_000,
127 signal: "SIGINT",
128 }),
129 ),
130 Effect.provide(layer),
131 );
132
133 expect(result).toEqual({
134 pid: 4242,
135 signal: "SIGINT",
136 signaled: false,
137 message: Option.some("Process 4242 no longer matches the selected process identity."),
138 });
139 }),
140 );
141
142 it.effect("refuses to signal when a fresh identity check cannot be collected", () =>
143 Effect.gen(function* () {
144 const snapshot = makeNativeSnapshot([
145 {
146 pid: 4_242,
147 ppid: process.pid,
148 startTimeMs: 2_000,
149 runTimeMs: 4_000,
150 name: "agent",
151 command: "codex app-server",
152 status: "Running",
153 cpuPercent: 1.5,
154 cpuTimeMs: 60,
155 residentBytes: 2_048,
156 virtualBytes: 4_096,
157 ioReadBytes: 300,
158 ioWriteBytes: 400,
159 ioSemantics: "storage",
160 },
161 ]);
162 const staleTelemetry = yield* Effect.service(ResourceTelemetry.ResourceTelemetry).pipe(
163 Effect.flatMap((telemetry) => telemetry.latest),
164 Effect.provide(layerTelemetryFor(snapshot)),
165 );
166 const layerTelemetry = Layer.succeed(
167 ResourceTelemetry.ResourceTelemetry,
168 ResourceTelemetry.ResourceTelemetry.of({
169 latest: Effect.succeed(staleTelemetry),
170 changes: Stream.empty,
171 subscribe: Effect.die("unused"),
172 readHistory: () => Effect.die("unused"),
173 refresh: Effect.fail(
174 new ResourceTelemetry.ResourceTelemetryRefreshFailed({
175 operation: "refresh",
176 cause: new Error("collector unavailable"),
177 }),
178 ),
179 validateProcessIdentity: () => Effect.die("unused"),
180 retry: Effect.die("unused"),
181 }),
182 );
183 const layer = ProcessDiagnostics.layer.pipe(Layer.provide(layerTelemetry));
184
185 const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
186 Effect.flatMap((processDiagnostics) =>
187 processDiagnostics.signal({
188 pid: 4_242,
189 startTimeMs: 2_000,
190 signal: "SIGINT",
191 }),
192 ),
193 Effect.provide(layer),
194 );
195
196 expect(result).toEqual({
197 pid: 4_242,
198 signal: "SIGINT",
199 signaled: false,
200 message: Option.some(
201 "Could not refresh process 4242; refusing to signal a stale identity.",
202 ),
203 });
204 }),
205 );
206
207 it.effect("rejects Electron processes as signal targets", () =>
208 Effect.gen(function* () {
209 const sampledAtUnixMs = DateTime.toEpochMillis(
210 DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"),
211 );
212 const snapshot = makeNativeSnapshot([
213 {
214 pid: 4_242,
215 ppid: 1,
216 startTimeMs: 2_000,
217 runTimeMs: 4_000,
218 name: "electron",
219 command: "electron",
220 status: "Running",
221 cpuPercent: 1.5,
222 cpuTimeMs: 60,
223 residentBytes: 2_048,
224 virtualBytes: 4_096,
225 ioReadBytes: 300,
226 ioWriteBytes: 400,
227 ioSemantics: "storage",
228 },
229 ]);
230 const sampledAt = DateTime.makeUnsafe(sampledAtUnixMs);
231 const layerTelemetry = layerTelemetryFor(snapshot, {
232 version: 1,
233 type: "desktopTelemetry",
234 sequence: 1,
235 sampledAtUnixMs,
236 electronPid: 4_242,
237 power: {
238 source: "electron-main",
239 idle: "false",
240 idleSeconds: 0,
241 locked: "false",
242 suspended: false,
243 onBattery: "false",
244 lowPowerMode: "unknown",
245 thermalState: "nominal",
246 stale: false,
247 updatedAt: sampledAt,
248 },
249 speedLimitPercent: Option.none(),
250 electronProcesses: [
251 {
252 pid: 4_242,
253 creationTimeMs: 2_000,
254 type: "Browser",
255 name: "electron",
256 cpuPercent: 1.5,
257 idleWakeupsPerSecond: 0,
258 workingSetBytes: 2_048,
259 peakWorkingSetBytes: 2_048,
260 },
261 ],
262 });
263 const layer = ProcessDiagnostics.layer.pipe(Layer.provide(layerTelemetry));
264
265 const result = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
266 Effect.flatMap((processDiagnostics) =>
267 processDiagnostics.signal({
268 pid: 4_242,
269 startTimeMs: 2_000,
270 signal: "SIGKILL",
271 }),
272 ),
273 Effect.provide(layer),
274 );
275
276 expect(result).toEqual({
277 pid: 4_242,
278 signal: "SIGKILL",
279 signaled: false,
280 message: Option.some("Process 4242 is not a signalable T3 backend descendant."),
281 });
282
283 const diagnostics = yield* Effect.service(ProcessDiagnostics.ProcessDiagnostics).pipe(
284 Effect.flatMap((processDiagnostics) => processDiagnostics.read),
285 Effect.provide(layer),
286 );
287 expect(diagnostics.processes).toEqual([]);
288 expect(diagnostics.processCount).toBe(0);
289 expect(diagnostics.totalCpuPercent).toBe(0);
290 expect(diagnostics.totalRssBytes).toBe(0);
291 }),
292 );
293});