everything-dev 0.1.3 → 0.1.4

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.
@@ -0,0 +1,298 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { execShellSafe, powershellSafe } from "../command";
3
+ import type {
4
+ MemoryInfo,
5
+ PlatformOperations,
6
+ PortInfo,
7
+ ProcessInfo,
8
+ } from "../types";
9
+ import { PlatformService } from "../types";
10
+
11
+ const getPortInfo = (
12
+ ports: number[]
13
+ ): Effect.Effect<Record<number, PortInfo>, never> =>
14
+ Effect.gen(function* () {
15
+ yield* Effect.logInfo(`[windows] Checking ${ports.length} ports`);
16
+
17
+ const result: Record<number, PortInfo> = {};
18
+ for (const port of ports) {
19
+ result[port] = { port, pid: null, command: null, state: "FREE" };
20
+ }
21
+
22
+ if (ports.length === 0) return result;
23
+
24
+ const output = yield* execShellSafe("netstat -ano -p TCP");
25
+ if (!output) {
26
+ yield* Effect.logDebug(
27
+ "[windows] No netstat output, all ports appear free"
28
+ );
29
+ return result;
30
+ }
31
+
32
+ const lines = output.split("\n").filter(Boolean);
33
+ yield* Effect.logDebug(`[windows] Parsing ${lines.length} netstat lines`);
34
+
35
+ for (const line of lines) {
36
+ if (!line.includes("LISTENING")) continue;
37
+
38
+ const parts = line.trim().split(/\s+/);
39
+ if (parts.length < 5) continue;
40
+
41
+ const localAddr = parts[1];
42
+ const portMatch = localAddr.match(/:(\d+)$/);
43
+ if (!portMatch) continue;
44
+
45
+ const port = parseInt(portMatch[1], 10);
46
+ if (!ports.includes(port)) continue;
47
+
48
+ const pid = parseInt(parts[4], 10);
49
+
50
+ let command: string | null = null;
51
+ if (pid) {
52
+ const cmdOutput = yield* execShellSafe(
53
+ `wmic process where ProcessId=${pid} get Name /format:list`
54
+ );
55
+ const nameMatch = cmdOutput.match(/Name=(.+)/);
56
+ command = nameMatch ? nameMatch[1].trim() : null;
57
+ }
58
+
59
+ result[port] = {
60
+ port,
61
+ pid,
62
+ command,
63
+ state: "LISTEN",
64
+ };
65
+
66
+ yield* Effect.logDebug(
67
+ `[windows] Port :${port} bound to PID ${pid} (${command})`
68
+ );
69
+ }
70
+
71
+ const boundCount = Object.values(result).filter(
72
+ (p) => p.state === "LISTEN"
73
+ ).length;
74
+ yield* Effect.logInfo(
75
+ `[windows] Found ${boundCount}/${ports.length} ports in use`
76
+ );
77
+
78
+ return result;
79
+ });
80
+
81
+ const getProcessTree = (
82
+ rootPids: number[]
83
+ ): Effect.Effect<ProcessInfo[], never> =>
84
+ Effect.gen(function* () {
85
+ yield* Effect.logInfo(
86
+ `[windows] Building process tree for ${rootPids.length} root PIDs`
87
+ );
88
+
89
+ const processes: ProcessInfo[] = [];
90
+ const visited = new Set<number>();
91
+
92
+ const getProcess = (
93
+ pid: number
94
+ ): Effect.Effect<ProcessInfo | null, never> =>
95
+ Effect.gen(function* () {
96
+ if (visited.has(pid)) return null;
97
+ visited.add(pid);
98
+
99
+ const output = yield* execShellSafe(
100
+ `wmic process where ProcessId=${pid} get ProcessId,ParentProcessId,WorkingSetSize,Name,CommandLine /format:csv`
101
+ );
102
+ const lines = output
103
+ .split("\n")
104
+ .filter((l) => l.trim() && !l.startsWith("Node"));
105
+ if (lines.length === 0) return null;
106
+
107
+ const parts = lines[0].split(",");
108
+ if (parts.length < 5) return null;
109
+
110
+ const cmdLine = parts[1] || "";
111
+ const name = parts[2] || "";
112
+ const ppid = parseInt(parts[3], 10);
113
+ const procId = parseInt(parts[4], 10);
114
+ const rss = parseInt(parts[5], 10) || 0;
115
+
116
+ yield* Effect.logDebug(
117
+ `[windows] Process ${pid}: ${name} (RSS: ${(rss / 1024).toFixed(0)}KB)`
118
+ );
119
+
120
+ return {
121
+ pid: procId,
122
+ ppid,
123
+ command: name,
124
+ args: cmdLine.split(/\s+/).slice(1),
125
+ rss,
126
+ children: [],
127
+ };
128
+ });
129
+
130
+ const getChildren = (pid: number): Effect.Effect<number[], never> =>
131
+ Effect.gen(function* () {
132
+ const output = yield* execShellSafe(
133
+ `wmic process where ParentProcessId=${pid} get ProcessId /format:list`
134
+ );
135
+ const matches = output.match(/ProcessId=(\d+)/g);
136
+ if (!matches) return [];
137
+
138
+ const children = matches.map((m) =>
139
+ parseInt(m.replace("ProcessId=", ""), 10)
140
+ );
141
+
142
+ if (children.length > 0) {
143
+ yield* Effect.logDebug(
144
+ `[windows] PID ${pid} has ${children.length} children: ${children.join(", ")}`
145
+ );
146
+ }
147
+
148
+ return children;
149
+ });
150
+
151
+ const traverse = (pid: number): Effect.Effect<void, never> =>
152
+ Effect.gen(function* () {
153
+ const proc = yield* getProcess(pid);
154
+ if (!proc) return;
155
+
156
+ const children = yield* getChildren(pid);
157
+ proc.children = children;
158
+ processes.push(proc);
159
+
160
+ for (const child of children) {
161
+ yield* traverse(child);
162
+ }
163
+ });
164
+
165
+ for (const pid of rootPids) {
166
+ yield* traverse(pid);
167
+ }
168
+
169
+ yield* Effect.logInfo(
170
+ `[windows] Process tree contains ${processes.length} processes`
171
+ );
172
+
173
+ return processes;
174
+ });
175
+
176
+ const getMemoryInfo = (): Effect.Effect<MemoryInfo, never> =>
177
+ Effect.gen(function* () {
178
+ yield* Effect.logDebug("[windows] Getting memory info");
179
+
180
+ const script = `
181
+ $os = Get-CimInstance Win32_OperatingSystem
182
+ $total = $os.TotalVisibleMemorySize * 1024
183
+ $free = $os.FreePhysicalMemory * 1024
184
+ Write-Output "$total,$free"
185
+ `;
186
+ const output = yield* powershellSafe(script);
187
+
188
+ if (!output) {
189
+ yield* Effect.logWarning("[windows] Could not get memory info");
190
+ return { total: 0, used: 0, free: 0, processRss: 0 };
191
+ }
192
+
193
+ const [totalStr, freeStr] = output.split(",");
194
+ const total = parseInt(totalStr, 10) || 0;
195
+ const free = parseInt(freeStr, 10) || 0;
196
+
197
+ const totalMB = (total / 1024 / 1024).toFixed(0);
198
+ const usedMB = ((total - free) / 1024 / 1024).toFixed(0);
199
+ yield* Effect.logDebug(
200
+ `[windows] Memory: ${usedMB}MB used / ${totalMB}MB total`
201
+ );
202
+
203
+ return {
204
+ total,
205
+ used: total - free,
206
+ free,
207
+ processRss: 0,
208
+ };
209
+ });
210
+
211
+ const getAllProcesses = (): Effect.Effect<ProcessInfo[], never> =>
212
+ Effect.gen(function* () {
213
+ yield* Effect.logDebug("[windows] Getting all processes");
214
+
215
+ const processes: ProcessInfo[] = [];
216
+
217
+ const output = yield* execShellSafe(
218
+ "wmic process get ProcessId,ParentProcessId,WorkingSetSize,Name /format:csv"
219
+ );
220
+ const lines = output
221
+ .split("\n")
222
+ .filter((l) => l.trim() && !l.startsWith("Node"));
223
+
224
+ for (const line of lines) {
225
+ const parts = line.split(",");
226
+ if (parts.length < 4) continue;
227
+
228
+ const name = parts[1] || "";
229
+ const ppid = parseInt(parts[2], 10);
230
+ const pid = parseInt(parts[3], 10);
231
+ const rss = parseInt(parts[4], 10) || 0;
232
+
233
+ if (isNaN(pid)) continue;
234
+
235
+ processes.push({
236
+ pid,
237
+ ppid: isNaN(ppid) ? 0 : ppid,
238
+ command: name,
239
+ args: [],
240
+ rss,
241
+ children: [],
242
+ });
243
+ }
244
+
245
+ yield* Effect.logDebug(
246
+ `[windows] Found ${processes.length} total processes`
247
+ );
248
+
249
+ return processes;
250
+ });
251
+
252
+ const findChildProcesses = (pid: number): Effect.Effect<number[], never> =>
253
+ Effect.gen(function* () {
254
+ yield* Effect.logDebug(`[windows] Finding all children of PID ${pid}`);
255
+
256
+ const children: number[] = [];
257
+ const visited = new Set<number>();
258
+
259
+ const recurse = (parentPid: number): Effect.Effect<void, never> =>
260
+ Effect.gen(function* () {
261
+ if (visited.has(parentPid)) return;
262
+ visited.add(parentPid);
263
+
264
+ const output = yield* execShellSafe(
265
+ `wmic process where ParentProcessId=${parentPid} get ProcessId /format:list`
266
+ );
267
+ const matches = output.match(/ProcessId=(\d+)/g);
268
+ if (!matches) return;
269
+
270
+ for (const match of matches) {
271
+ const childPid = parseInt(match.replace("ProcessId=", ""), 10);
272
+ if (!isNaN(childPid)) {
273
+ children.push(childPid);
274
+ yield* recurse(childPid);
275
+ }
276
+ }
277
+ });
278
+
279
+ yield* recurse(pid);
280
+
281
+ if (children.length > 0) {
282
+ yield* Effect.logDebug(
283
+ `[windows] PID ${pid} has ${children.length} descendants`
284
+ );
285
+ }
286
+
287
+ return children;
288
+ });
289
+
290
+ const windowsOperations: PlatformOperations = {
291
+ getPortInfo,
292
+ getProcessTree,
293
+ getMemoryInfo,
294
+ getAllProcesses,
295
+ findChildProcesses,
296
+ };
297
+
298
+ export const WindowsLayer = Layer.succeed(PlatformService, windowsOperations);
@@ -0,0 +1,204 @@
1
+ import { Effect } from "effect";
2
+ import { getConfigPath, getPortsFromConfig, loadConfig } from "../../config";
3
+ import { PlatformService, withPlatform } from "./platform";
4
+ import type { MonitorConfig, ProcessInfo, Snapshot } from "./types";
5
+
6
+ export const getPortsToMonitor = (
7
+ config?: MonitorConfig
8
+ ): Effect.Effect<number[]> =>
9
+ Effect.gen(function* () {
10
+ if (config?.ports && config.ports.length > 0) {
11
+ yield* Effect.logDebug(
12
+ `Using configured ports: ${config.ports.join(", ")}`
13
+ );
14
+ return config.ports;
15
+ }
16
+
17
+ return yield* Effect.try({
18
+ try: () => {
19
+ loadConfig(config?.configPath);
20
+ const portConfig = getPortsFromConfig();
21
+ const ports = [portConfig.host, portConfig.ui, portConfig.api].filter(
22
+ (p) => p > 0
23
+ );
24
+ return ports;
25
+ },
26
+ catch: () => new Error("Config not found"),
27
+ }).pipe(Effect.catchAll(() => Effect.succeed([3000, 3002, 3014])));
28
+ });
29
+
30
+ export const getConfigPathSafe = (
31
+ config?: MonitorConfig
32
+ ): Effect.Effect<string | null> =>
33
+ Effect.try({
34
+ try: () => {
35
+ loadConfig(config?.configPath);
36
+ return getConfigPath();
37
+ },
38
+ catch: () => new Error("Config not found"),
39
+ }).pipe(Effect.catchAll(() => Effect.succeed(null)));
40
+
41
+ export const createSnapshot = (
42
+ config?: MonitorConfig
43
+ ): Effect.Effect<Snapshot, never, PlatformService> =>
44
+ Effect.gen(function* () {
45
+ yield* Effect.logInfo("Creating system snapshot");
46
+
47
+ const platform = yield* PlatformService;
48
+ const ports = yield* getPortsToMonitor(config);
49
+ const configPath = yield* getConfigPathSafe(config);
50
+
51
+ yield* Effect.logDebug(`Monitoring ports: ${ports.join(", ")}`);
52
+
53
+ const portInfo = yield* platform.getPortInfo(ports);
54
+ const boundPorts = Object.values(portInfo).filter(
55
+ (p) => p.state !== "FREE"
56
+ );
57
+
58
+ yield* Effect.logInfo(`Found ${boundPorts.length} bound ports`);
59
+
60
+ const rootPids = boundPorts
61
+ .map((p) => p.pid)
62
+ .filter((pid): pid is number => pid !== null);
63
+
64
+ const processes =
65
+ rootPids.length > 0 ? yield* platform.getProcessTree(rootPids) : [];
66
+
67
+ yield* Effect.logInfo(`Tracked ${processes.length} processes in tree`);
68
+
69
+ const memory = yield* platform.getMemoryInfo();
70
+
71
+ const totalRss = processes.reduce((sum, p) => sum + p.rss, 0);
72
+ memory.processRss = totalRss;
73
+
74
+ yield* Effect.logDebug(
75
+ `Total process RSS: ${(totalRss / 1024 / 1024).toFixed(1)}MB`
76
+ );
77
+
78
+ const snapshot: Snapshot = {
79
+ timestamp: Date.now(),
80
+ configPath,
81
+ ports: portInfo,
82
+ processes,
83
+ memory,
84
+ platform: process.platform,
85
+ };
86
+
87
+ yield* Effect.logInfo(
88
+ `Snapshot created at ${new Date(snapshot.timestamp).toISOString()}`
89
+ );
90
+
91
+ return snapshot;
92
+ });
93
+
94
+ export const createSnapshotWithPlatform = (
95
+ config?: MonitorConfig
96
+ ): Effect.Effect<Snapshot> => withPlatform(createSnapshot(config));
97
+
98
+ export const findProcessesByPattern = (
99
+ patterns: string[]
100
+ ): Effect.Effect<ProcessInfo[], never, PlatformService> =>
101
+ Effect.gen(function* () {
102
+ yield* Effect.logDebug(
103
+ `Finding processes matching: ${patterns.join(", ")}`
104
+ );
105
+
106
+ const platform = yield* PlatformService;
107
+ const allProcesses = yield* platform.getAllProcesses();
108
+
109
+ const matched = allProcesses.filter((proc) =>
110
+ patterns.some((pattern) =>
111
+ proc.command.toLowerCase().includes(pattern.toLowerCase())
112
+ )
113
+ );
114
+
115
+ yield* Effect.logInfo(
116
+ `Found ${matched.length} processes matching patterns`
117
+ );
118
+
119
+ return matched;
120
+ });
121
+
122
+ export const findBosProcesses = (): Effect.Effect<
123
+ ProcessInfo[],
124
+ never,
125
+ PlatformService
126
+ > => {
127
+ const patterns = ["bun", "rspack", "rsbuild", "esbuild", "webpack", "node"];
128
+ return findProcessesByPattern(patterns);
129
+ };
130
+
131
+ export const isProcessAliveSync = (pid: number): boolean => {
132
+ try {
133
+ process.kill(pid, 0);
134
+ return true;
135
+ } catch {
136
+ return false;
137
+ }
138
+ };
139
+
140
+ export const isProcessAlive = (pid: number): Effect.Effect<boolean> =>
141
+ Effect.sync(() => isProcessAliveSync(pid));
142
+
143
+ export const waitForProcessDeath = (
144
+ pid: number,
145
+ timeoutMs = 5000
146
+ ): Effect.Effect<boolean> =>
147
+ Effect.gen(function* () {
148
+ yield* Effect.logDebug(
149
+ `Waiting for PID ${pid} to die (timeout: ${timeoutMs}ms)`
150
+ );
151
+ const start = Date.now();
152
+
153
+ while (Date.now() - start < timeoutMs) {
154
+ const alive = yield* isProcessAlive(pid);
155
+ if (!alive) {
156
+ yield* Effect.logDebug(`PID ${pid} is dead`);
157
+ return true;
158
+ }
159
+ yield* Effect.sleep("100 millis");
160
+ }
161
+
162
+ const finalAlive = yield* isProcessAlive(pid);
163
+ if (finalAlive) {
164
+ yield* Effect.logWarning(`PID ${pid} still alive after ${timeoutMs}ms`);
165
+ }
166
+ return !finalAlive;
167
+ });
168
+
169
+ export const waitForPortFree = (
170
+ port: number,
171
+ timeoutMs = 5000
172
+ ): Effect.Effect<boolean, never, PlatformService> =>
173
+ Effect.gen(function* () {
174
+ yield* Effect.logDebug(
175
+ `Waiting for port :${port} to be free (timeout: ${timeoutMs}ms)`
176
+ );
177
+ const platform = yield* PlatformService;
178
+ const start = Date.now();
179
+
180
+ while (Date.now() - start < timeoutMs) {
181
+ const portInfo = yield* platform.getPortInfo([port]);
182
+ if (portInfo[port].state === "FREE") {
183
+ yield* Effect.logDebug(`Port :${port} is now free`);
184
+ return true;
185
+ }
186
+ yield* Effect.sleep("100 millis");
187
+ }
188
+
189
+ const finalPortInfo = yield* platform.getPortInfo([port]);
190
+ const isFree = finalPortInfo[port].state === "FREE";
191
+
192
+ if (!isFree) {
193
+ yield* Effect.logWarning(
194
+ `Port :${port} still bound after ${timeoutMs}ms`
195
+ );
196
+ }
197
+
198
+ return isFree;
199
+ });
200
+
201
+ export const waitForPortFreeWithPlatform = (
202
+ port: number,
203
+ timeoutMs = 5000
204
+ ): Effect.Effect<boolean> => withPlatform(waitForPortFree(port, timeoutMs));
@@ -0,0 +1,74 @@
1
+ import { Context, Effect } from "effect";
2
+
3
+ export interface PortInfo {
4
+ port: number;
5
+ pid: number | null;
6
+ command: string | null;
7
+ state: "LISTEN" | "ESTABLISHED" | "TIME_WAIT" | "FREE";
8
+ name?: string;
9
+ }
10
+
11
+ export interface ProcessInfo {
12
+ pid: number;
13
+ ppid: number;
14
+ command: string;
15
+ args: string[];
16
+ rss: number;
17
+ children: number[];
18
+ startTime?: number;
19
+ }
20
+
21
+ export interface MemoryInfo {
22
+ total: number;
23
+ used: number;
24
+ free: number;
25
+ processRss: number;
26
+ }
27
+
28
+ export interface Snapshot {
29
+ timestamp: number;
30
+ configPath: string | null;
31
+ ports: Record<number, PortInfo>;
32
+ processes: ProcessInfo[];
33
+ memory: MemoryInfo;
34
+ platform: NodeJS.Platform;
35
+ }
36
+
37
+ export interface SnapshotDiff {
38
+ from: Snapshot;
39
+ to: Snapshot;
40
+ orphanedProcesses: ProcessInfo[];
41
+ stillBoundPorts: PortInfo[];
42
+ freedPorts: number[];
43
+ memoryDeltaBytes: number;
44
+ newProcesses: ProcessInfo[];
45
+ killedProcesses: ProcessInfo[];
46
+ }
47
+
48
+ export interface MonitorConfig {
49
+ ports?: number[];
50
+ processPatterns?: string[];
51
+ refreshInterval?: number;
52
+ configPath?: string;
53
+ }
54
+
55
+ export interface PlatformOperations {
56
+ readonly getPortInfo: (
57
+ ports: number[]
58
+ ) => Effect.Effect<Record<number, PortInfo>, never>;
59
+
60
+ readonly getProcessTree: (
61
+ rootPids: number[]
62
+ ) => Effect.Effect<ProcessInfo[], never>;
63
+
64
+ readonly getMemoryInfo: () => Effect.Effect<MemoryInfo, never>;
65
+
66
+ readonly getAllProcesses: () => Effect.Effect<ProcessInfo[], never>;
67
+
68
+ readonly findChildProcesses: (pid: number) => Effect.Effect<number[], never>;
69
+ }
70
+
71
+ export class PlatformService extends Context.Tag("PlatformService")<
72
+ PlatformService,
73
+ PlatformOperations
74
+ >() {}
@@ -0,0 +1,102 @@
1
+ import { Data } from "effect";
2
+
3
+ export class SessionTimeout extends Data.TaggedError("SessionTimeout")<{
4
+ readonly timeoutMs: number;
5
+ readonly elapsedMs: number;
6
+ }> {
7
+ get message() {
8
+ return `Session timed out after ${this.elapsedMs}ms (limit: ${this.timeoutMs}ms)`;
9
+ }
10
+ }
11
+
12
+ export class BrowserLaunchFailed extends Data.TaggedError("BrowserLaunchFailed")<{
13
+ readonly reason: string;
14
+ readonly headless: boolean;
15
+ }> {
16
+ get message() {
17
+ return `Failed to launch browser (headless: ${this.headless}): ${this.reason}`;
18
+ }
19
+ }
20
+
21
+ export class ServerStartFailed extends Data.TaggedError("ServerStartFailed")<{
22
+ readonly server: string;
23
+ readonly port: number;
24
+ readonly reason: string;
25
+ }> {
26
+ get message() {
27
+ return `Failed to start ${this.server} on port ${this.port}: ${this.reason}`;
28
+ }
29
+ }
30
+
31
+ export class ServerNotReady extends Data.TaggedError("ServerNotReady")<{
32
+ readonly servers: string[];
33
+ readonly timeoutMs: number;
34
+ }> {
35
+ get message() {
36
+ return `Servers not ready after ${this.timeoutMs}ms: ${this.servers.join(", ")}`;
37
+ }
38
+ }
39
+
40
+ export class FlowExecutionFailed extends Data.TaggedError("FlowExecutionFailed")<{
41
+ readonly flowName: string;
42
+ readonly step: string;
43
+ readonly reason: string;
44
+ }> {
45
+ get message() {
46
+ return `Flow "${this.flowName}" failed at step "${this.step}": ${this.reason}`;
47
+ }
48
+ }
49
+
50
+ export class SnapshotFailed extends Data.TaggedError("SnapshotFailed")<{
51
+ readonly reason: string;
52
+ }> {
53
+ get message() {
54
+ return `Failed to capture snapshot: ${this.reason}`;
55
+ }
56
+ }
57
+
58
+ export class ExportFailed extends Data.TaggedError("ExportFailed")<{
59
+ readonly path: string;
60
+ readonly reason: string;
61
+ }> {
62
+ get message() {
63
+ return `Failed to export session to ${this.path}: ${this.reason}`;
64
+ }
65
+ }
66
+
67
+ export class BrowserMetricsFailed extends Data.TaggedError("BrowserMetricsFailed")<{
68
+ readonly reason: string;
69
+ }> {
70
+ get message() {
71
+ return `Failed to collect browser metrics: ${this.reason}`;
72
+ }
73
+ }
74
+
75
+ export class PopupNotDetected extends Data.TaggedError("PopupNotDetected")<{
76
+ readonly timeoutMs: number;
77
+ }> {
78
+ get message() {
79
+ return `Popup window not detected within ${this.timeoutMs}ms`;
80
+ }
81
+ }
82
+
83
+ export class AuthenticationFailed extends Data.TaggedError("AuthenticationFailed")<{
84
+ readonly step: string;
85
+ readonly reason: string;
86
+ }> {
87
+ get message() {
88
+ return `Authentication failed at "${this.step}": ${this.reason}`;
89
+ }
90
+ }
91
+
92
+ export type SessionRecorderError =
93
+ | SessionTimeout
94
+ | BrowserLaunchFailed
95
+ | ServerStartFailed
96
+ | ServerNotReady
97
+ | FlowExecutionFailed
98
+ | SnapshotFailed
99
+ | ExportFailed
100
+ | BrowserMetricsFailed
101
+ | PopupNotDetected
102
+ | AuthenticationFailed;