everything-dev 0.1.2 → 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.
package/src/contract.ts CHANGED
@@ -314,6 +314,84 @@ const DockerStopResultSchema = z.object({
314
314
  error: z.string().optional(),
315
315
  });
316
316
 
317
+ const MonitorOptionsSchema = z.object({
318
+ ports: z.array(z.number()).optional(),
319
+ json: z.boolean().default(false),
320
+ watch: z.boolean().default(false),
321
+ });
322
+
323
+ const PortInfoSchema = z.object({
324
+ port: z.number(),
325
+ pid: z.number().nullable(),
326
+ command: z.string().nullable(),
327
+ state: z.enum(["LISTEN", "ESTABLISHED", "TIME_WAIT", "FREE"]),
328
+ name: z.string().optional(),
329
+ });
330
+
331
+ const ProcessInfoSchema = z.object({
332
+ pid: z.number(),
333
+ ppid: z.number(),
334
+ command: z.string(),
335
+ args: z.array(z.string()),
336
+ rss: z.number(),
337
+ children: z.array(z.number()),
338
+ startTime: z.number().optional(),
339
+ });
340
+
341
+ const MemoryInfoSchema = z.object({
342
+ total: z.number(),
343
+ used: z.number(),
344
+ free: z.number(),
345
+ processRss: z.number(),
346
+ });
347
+
348
+ const SnapshotSchema = z.object({
349
+ timestamp: z.number(),
350
+ configPath: z.string().nullable(),
351
+ ports: z.record(z.string(), PortInfoSchema),
352
+ processes: z.array(ProcessInfoSchema),
353
+ memory: MemoryInfoSchema,
354
+ platform: z.string(),
355
+ });
356
+
357
+ const MonitorResultSchema = z.object({
358
+ status: z.enum(["snapshot", "watching", "error"]),
359
+ snapshot: SnapshotSchema.optional(),
360
+ error: z.string().optional(),
361
+ });
362
+
363
+ const SessionOptionsSchema = z.object({
364
+ headless: z.boolean().default(true),
365
+ timeout: z.number().default(120000),
366
+ output: z.string().default("./session-report.json"),
367
+ format: z.enum(["json", "html"]).default("json"),
368
+ flow: z.enum(["login", "navigation", "custom"]).default("login"),
369
+ routes: z.array(z.string()).optional(),
370
+ snapshotInterval: z.number().default(2000),
371
+ });
372
+
373
+ const SessionSummarySchema = z.object({
374
+ totalMemoryDeltaMb: z.number(),
375
+ peakMemoryMb: z.number(),
376
+ averageMemoryMb: z.number(),
377
+ processesSpawned: z.number(),
378
+ processesKilled: z.number(),
379
+ orphanedProcesses: z.number(),
380
+ portsUsed: z.array(z.number()),
381
+ portsLeaked: z.number(),
382
+ hasLeaks: z.boolean(),
383
+ eventCount: z.number(),
384
+ duration: z.number(),
385
+ });
386
+
387
+ const SessionResultSchema = z.object({
388
+ status: z.enum(["completed", "leaks_detected", "error", "timeout"]),
389
+ sessionId: z.string().optional(),
390
+ reportPath: z.string().optional(),
391
+ summary: SessionSummarySchema.optional(),
392
+ error: z.string().optional(),
393
+ });
394
+
317
395
  const DepsUpdateOptionsSchema = z.object({
318
396
  category: z.enum(["ui", "api"]).default("ui"),
319
397
  packages: z.array(z.string()).optional(),
@@ -460,6 +538,16 @@ export const bosContract = oc.router({
460
538
  .route({ method: "POST", path: "/docker/stop" })
461
539
  .input(DockerStopOptionsSchema)
462
540
  .output(DockerStopResultSchema),
541
+
542
+ monitor: oc
543
+ .route({ method: "GET", path: "/monitor" })
544
+ .input(MonitorOptionsSchema)
545
+ .output(MonitorResultSchema),
546
+
547
+ session: oc
548
+ .route({ method: "POST", path: "/session" })
549
+ .input(SessionOptionsSchema)
550
+ .output(SessionResultSchema),
463
551
  });
464
552
 
465
553
  export type BosContract = typeof bosContract;
@@ -498,3 +586,9 @@ export type DepsUpdateOptions = z.infer<typeof DepsUpdateOptionsSchema>;
498
586
  export type DepsUpdateResult = z.infer<typeof DepsUpdateResultSchema>;
499
587
  export type FilesSyncOptions = z.infer<typeof FilesSyncOptionsSchema>;
500
588
  export type FilesSyncResult = z.infer<typeof FilesSyncResultSchema>;
589
+ export type MonitorOptions = z.infer<typeof MonitorOptionsSchema>;
590
+ export type MonitorResult = z.infer<typeof MonitorResultSchema>;
591
+ export type MonitorSnapshot = z.infer<typeof SnapshotSchema>;
592
+ export type SessionOptions = z.infer<typeof SessionOptionsSchema>;
593
+ export type SessionResult = z.infer<typeof SessionResultSchema>;
594
+ export type SessionSummary = z.infer<typeof SessionSummarySchema>;
package/src/lib/nova.ts CHANGED
@@ -20,7 +20,7 @@ export interface UploadResult {
20
20
 
21
21
  export const getNovaConfig = Effect.gen(function* () {
22
22
  const accountId = process.env.NOVA_ACCOUNT_ID;
23
- const sessionToken = process.env.NOVA_SESSION_TOKEN;
23
+ const sessionToken = process.env.NOVA_API_KEY;
24
24
 
25
25
  if (!accountId || !sessionToken) {
26
26
  return yield* Effect.fail(
@@ -46,7 +46,7 @@ export function getSecretsGroupId(nearAccount: string): string {
46
46
  export const registerSecretsGroup = (
47
47
  nova: NovaSdk,
48
48
  nearAccount: string,
49
- gatewayAccount: string
49
+ novaAccount: string
50
50
  ) =>
51
51
  Effect.gen(function* () {
52
52
  const groupId = getSecretsGroupId(nearAccount);
@@ -57,8 +57,8 @@ export const registerSecretsGroup = (
57
57
  });
58
58
 
59
59
  yield* Effect.tryPromise({
60
- try: () => nova.addGroupMember(groupId, gatewayAccount),
61
- catch: (e) => new Error(`Failed to add gateway to NOVA group: ${e}`),
60
+ try: () => nova.addGroupMember(groupId, novaAccount),
61
+ catch: (e) => new Error(`Failed to add gateway Nova account to group: ${e}`),
62
62
  });
63
63
 
64
64
  return groupId;
@@ -150,7 +150,7 @@ export function filterSecretsToRequired(
150
150
  }
151
151
 
152
152
  export function hasNovaCredentials(): boolean {
153
- return !!(process.env.NOVA_ACCOUNT_ID && process.env.NOVA_SESSION_TOKEN);
153
+ return !!(process.env.NOVA_ACCOUNT_ID && process.env.NOVA_API_KEY);
154
154
  }
155
155
 
156
156
  function getBosEnvPath(): string {
@@ -182,8 +182,8 @@ export const saveNovaCredentials = (accountId: string, sessionToken: string) =>
182
182
  if (trimmed.startsWith("NOVA_ACCOUNT_ID=")) {
183
183
  newLines.push(`NOVA_ACCOUNT_ID=${accountId}`);
184
184
  foundAccountId = true;
185
- } else if (trimmed.startsWith("NOVA_SESSION_TOKEN=")) {
186
- newLines.push(`NOVA_SESSION_TOKEN=${sessionToken}`);
185
+ } else if (trimmed.startsWith("NOVA_API_KEY=")) {
186
+ newLines.push(`NOVA_API_KEY=${sessionToken}`);
187
187
  foundSessionToken = true;
188
188
  } else {
189
189
  newLines.push(line);
@@ -198,7 +198,7 @@ export const saveNovaCredentials = (accountId: string, sessionToken: string) =>
198
198
  }
199
199
 
200
200
  if (!foundSessionToken) {
201
- newLines.push(`NOVA_SESSION_TOKEN=${sessionToken}`);
201
+ newLines.push(`NOVA_API_KEY=${sessionToken}`);
202
202
  }
203
203
 
204
204
  yield* Effect.tryPromise({
@@ -207,7 +207,7 @@ export const saveNovaCredentials = (accountId: string, sessionToken: string) =>
207
207
  });
208
208
 
209
209
  process.env.NOVA_ACCOUNT_ID = accountId;
210
- process.env.NOVA_SESSION_TOKEN = sessionToken;
210
+ process.env.NOVA_API_KEY = sessionToken;
211
211
  });
212
212
 
213
213
  export const removeNovaCredentials = Effect.gen(function* () {
@@ -226,7 +226,7 @@ export const removeNovaCredentials = Effect.gen(function* () {
226
226
  const lines = content.split("\n");
227
227
  const newLines = lines.filter((line) => {
228
228
  const trimmed = line.trim();
229
- return !trimmed.startsWith("NOVA_ACCOUNT_ID=") && !trimmed.startsWith("NOVA_SESSION_TOKEN=");
229
+ return !trimmed.startsWith("NOVA_ACCOUNT_ID=") && !trimmed.startsWith("NOVA_API_KEY=");
230
230
  });
231
231
 
232
232
  yield* Effect.tryPromise({
@@ -235,7 +235,7 @@ export const removeNovaCredentials = Effect.gen(function* () {
235
235
  });
236
236
 
237
237
  delete process.env.NOVA_ACCOUNT_ID;
238
- delete process.env.NOVA_SESSION_TOKEN;
238
+ delete process.env.NOVA_API_KEY;
239
239
  });
240
240
 
241
241
  export const verifyNovaCredentials = (accountId: string, sessionToken: string) =>
@@ -12,6 +12,8 @@ import { renderStreamingView } from "../components/streaming-view";
12
12
  import type { AppConfig, BosConfig } from "../config";
13
13
  import { getProcessConfig, makeDevProcess, type ProcessCallbacks, type ProcessHandle } from "./process";
14
14
 
15
+ let activeCleanup: (() => Promise<void>) | null = null;
16
+
15
17
  const LOG_NOISE_PATTERNS = [
16
18
  /\[ Federation Runtime \] Version .* from host of shared singleton module/,
17
19
  /Executing an Effect versioned \d+\.\d+\.\d+ with a Runtime of version/,
@@ -137,8 +139,11 @@ export const runDevServers = (orchestrator: AppOrchestrator) =>
137
139
  if (showLogs) {
138
140
  await exportLogs();
139
141
  }
142
+ activeCleanup = null;
140
143
  };
141
144
 
145
+ activeCleanup = cleanup;
146
+
142
147
  const useInteractive = orchestrator.interactive ?? isInteractiveSupported();
143
148
 
144
149
  view = useInteractive
@@ -220,12 +225,18 @@ export const startApp = (orchestrator: AppOrchestrator) => {
220
225
  }))
221
226
  );
222
227
 
228
+ const handleSignal = async () => {
229
+ if (activeCleanup) {
230
+ await activeCleanup();
231
+ }
232
+ };
233
+
223
234
  process.on("SIGINT", () => {
224
- setTimeout(() => process.exit(0), 500);
235
+ handleSignal().finally(() => process.exit(0));
225
236
  });
226
237
 
227
238
  process.on("SIGTERM", () => {
228
- setTimeout(() => process.exit(0), 500);
239
+ handleSignal().finally(() => process.exit(0));
229
240
  });
230
241
 
231
242
  BunRuntime.runMain(program);
@@ -117,6 +117,46 @@ const detectStatus = (
117
117
  return null;
118
118
  };
119
119
 
120
+ const killProcessTree = (pid: number) =>
121
+ Effect.gen(function* () {
122
+ const killSignal = (signal: NodeJS.Signals) =>
123
+ Effect.try({
124
+ try: () => {
125
+ process.kill(-pid, signal);
126
+ },
127
+ catch: () => null,
128
+ }).pipe(Effect.ignore);
129
+
130
+ const killDirect = (signal: NodeJS.Signals) =>
131
+ Effect.try({
132
+ try: () => {
133
+ process.kill(pid, signal);
134
+ },
135
+ catch: () => null,
136
+ }).pipe(Effect.ignore);
137
+
138
+ const isRunning = () =>
139
+ Effect.try({
140
+ try: () => {
141
+ process.kill(pid, 0);
142
+ return true;
143
+ },
144
+ catch: () => false,
145
+ });
146
+
147
+ yield* killSignal("SIGTERM");
148
+ yield* killDirect("SIGTERM");
149
+
150
+ yield* Effect.sleep("200 millis");
151
+
152
+ const stillRunning = yield* isRunning();
153
+ if (stillRunning) {
154
+ yield* killSignal("SIGKILL");
155
+ yield* killDirect("SIGKILL");
156
+ yield* Effect.sleep("100 millis");
157
+ }
158
+ });
159
+
120
160
  export const spawnDevProcess = (
121
161
  config: DevProcess,
122
162
  callbacks: ProcessCallbacks
@@ -181,11 +221,16 @@ export const spawnDevProcess = (
181
221
  name: config.name,
182
222
  pid: proc.pid,
183
223
  kill: async () => {
184
- proc.kill("SIGTERM");
185
- await new Promise((r) => setTimeout(r, 100));
186
- try {
187
- proc.kill("SIGKILL");
188
- } catch { }
224
+ const pid = proc.pid;
225
+ if (pid) {
226
+ await Effect.runPromise(killProcessTree(pid));
227
+ } else {
228
+ proc.kill("SIGTERM");
229
+ await new Promise((r) => setTimeout(r, 100));
230
+ try {
231
+ proc.kill("SIGKILL");
232
+ } catch { }
233
+ }
189
234
  },
190
235
  waitForReady: Deferred.await(readyDeferred),
191
236
  waitForExit: Effect.gen(function* () {
@@ -0,0 +1,234 @@
1
+ import { Effect } from "effect";
2
+ import {
3
+ MemoryLimitExceeded,
4
+ MemoryPercentExceeded,
5
+ OrphanedProcesses,
6
+ PortStillBound,
7
+ ProcessesStillAlive,
8
+ ResourceLeaks,
9
+ } from "./errors";
10
+ import { PlatformService, withPlatform } from "./platform";
11
+ import { isProcessAlive } from "./snapshot";
12
+ import type { Snapshot, SnapshotDiff } from "./types";
13
+
14
+ export const assertAllPortsFree = (
15
+ ports: number[]
16
+ ): Effect.Effect<void, PortStillBound, PlatformService> =>
17
+ Effect.gen(function* () {
18
+ yield* Effect.logInfo(`Asserting ${ports.length} ports are free`);
19
+
20
+ const platform = yield* PlatformService;
21
+ const portInfo = yield* platform.getPortInfo(ports);
22
+
23
+ const bound: Array<{
24
+ port: number;
25
+ pid: number | null;
26
+ command: string | null;
27
+ }> = [];
28
+
29
+ for (const [portStr, info] of Object.entries(portInfo)) {
30
+ if (info.state !== "FREE") {
31
+ bound.push({
32
+ port: parseInt(portStr, 10),
33
+ pid: info.pid,
34
+ command: info.command,
35
+ });
36
+ }
37
+ }
38
+
39
+ if (bound.length > 0) {
40
+ yield* Effect.logError(`${bound.length} ports still bound:`);
41
+ for (const p of bound) {
42
+ yield* Effect.logError(` :${p.port} ← PID ${p.pid} (${p.command})`);
43
+ }
44
+ return yield* Effect.fail(new PortStillBound({ ports: bound }));
45
+ }
46
+
47
+ yield* Effect.logInfo(`All ${ports.length} ports are free ✓`);
48
+ });
49
+
50
+ export const assertAllPortsFreeWithPlatform = (
51
+ ports: number[]
52
+ ): Effect.Effect<void, PortStillBound> =>
53
+ withPlatform(assertAllPortsFree(ports));
54
+
55
+ export const assertNoOrphanProcesses = (
56
+ runningSnapshot: Snapshot,
57
+ afterSnapshot: Snapshot
58
+ ): Effect.Effect<void, OrphanedProcesses> =>
59
+ Effect.gen(function* () {
60
+ yield* Effect.logInfo("Checking for orphaned processes");
61
+
62
+ const runningPids = new Set(runningSnapshot.processes.map((p) => p.pid));
63
+
64
+ const orphans: Array<{ pid: number; command: string; rss: number }> = [];
65
+
66
+ for (const proc of runningSnapshot.processes) {
67
+ if (!runningPids.has(proc.pid)) continue;
68
+
69
+ const stillInAfter = afterSnapshot.processes.some(
70
+ (p) => p.pid === proc.pid
71
+ );
72
+ if (stillInAfter) continue;
73
+
74
+ const alive = yield* isProcessAlive(proc.pid);
75
+ if (alive) {
76
+ orphans.push({
77
+ pid: proc.pid,
78
+ command: proc.command,
79
+ rss: proc.rss,
80
+ });
81
+ }
82
+ }
83
+
84
+ if (orphans.length > 0) {
85
+ yield* Effect.logError(`${orphans.length} orphaned processes found:`);
86
+ for (const p of orphans) {
87
+ yield* Effect.logError(
88
+ ` PID ${p.pid}: ${p.command} (${(p.rss / 1024 / 1024).toFixed(1)}MB)`
89
+ );
90
+ }
91
+ return yield* Effect.fail(new OrphanedProcesses({ processes: orphans }));
92
+ }
93
+
94
+ yield* Effect.logInfo("No orphaned processes ✓");
95
+ });
96
+
97
+ export const assertMemoryDelta = (
98
+ baseline: Snapshot,
99
+ after: Snapshot,
100
+ options: { maxDeltaMB?: number; maxDeltaPercent?: number }
101
+ ): Effect.Effect<void, MemoryLimitExceeded | MemoryPercentExceeded> =>
102
+ Effect.gen(function* () {
103
+ yield* Effect.logInfo("Checking memory delta");
104
+
105
+ const deltaMB =
106
+ (after.memory.processRss - baseline.memory.processRss) / 1024 / 1024;
107
+
108
+ yield* Effect.logDebug(
109
+ `Memory delta: ${deltaMB >= 0 ? "+" : ""}${deltaMB.toFixed(1)}MB`
110
+ );
111
+
112
+ if (options.maxDeltaMB !== undefined && deltaMB > options.maxDeltaMB) {
113
+ yield* Effect.logError(
114
+ `Memory delta ${deltaMB.toFixed(1)}MB exceeds max ${options.maxDeltaMB}MB`
115
+ );
116
+ return yield* Effect.fail(
117
+ new MemoryLimitExceeded({
118
+ deltaMB,
119
+ limitMB: options.maxDeltaMB,
120
+ baselineRss: baseline.memory.processRss,
121
+ afterRss: after.memory.processRss,
122
+ })
123
+ );
124
+ }
125
+
126
+ if (options.maxDeltaPercent !== undefined && baseline.memory.processRss > 0) {
127
+ const deltaPercent =
128
+ ((after.memory.processRss - baseline.memory.processRss) /
129
+ baseline.memory.processRss) *
130
+ 100;
131
+
132
+ yield* Effect.logDebug(
133
+ `Memory delta: ${deltaPercent >= 0 ? "+" : ""}${deltaPercent.toFixed(1)}%`
134
+ );
135
+
136
+ if (deltaPercent > options.maxDeltaPercent) {
137
+ yield* Effect.logError(
138
+ `Memory delta ${deltaPercent.toFixed(1)}% exceeds max ${options.maxDeltaPercent}%`
139
+ );
140
+ return yield* Effect.fail(
141
+ new MemoryPercentExceeded({
142
+ deltaPercent,
143
+ limitPercent: options.maxDeltaPercent,
144
+ baselineRss: baseline.memory.processRss,
145
+ afterRss: after.memory.processRss,
146
+ })
147
+ );
148
+ }
149
+ }
150
+
151
+ yield* Effect.logInfo("Memory delta within limits ✓");
152
+ });
153
+
154
+ export const assertProcessesDead = (
155
+ pids: number[]
156
+ ): Effect.Effect<void, ProcessesStillAlive> =>
157
+ Effect.gen(function* () {
158
+ yield* Effect.logInfo(`Asserting ${pids.length} processes are dead`);
159
+
160
+ const stillAlive: number[] = [];
161
+
162
+ for (const pid of pids) {
163
+ const alive = yield* isProcessAlive(pid);
164
+ if (alive) {
165
+ stillAlive.push(pid);
166
+ }
167
+ }
168
+
169
+ if (stillAlive.length > 0) {
170
+ yield* Effect.logError(
171
+ `${stillAlive.length} processes still alive: ${stillAlive.join(", ")}`
172
+ );
173
+ return yield* Effect.fail(new ProcessesStillAlive({ pids: stillAlive }));
174
+ }
175
+
176
+ yield* Effect.logInfo(`All ${pids.length} processes are dead ✓`);
177
+ });
178
+
179
+ export const assertNoLeaks = (
180
+ diff: SnapshotDiff
181
+ ): Effect.Effect<void, ResourceLeaks> =>
182
+ Effect.gen(function* () {
183
+ yield* Effect.logInfo("Checking for resource leaks");
184
+
185
+ if (
186
+ diff.orphanedProcesses.length > 0 ||
187
+ diff.stillBoundPorts.length > 0
188
+ ) {
189
+ yield* Effect.logError("Resource leaks detected:");
190
+
191
+ for (const proc of diff.orphanedProcesses) {
192
+ yield* Effect.logError(
193
+ ` Orphaned PID ${proc.pid}: ${proc.command} (${(proc.rss / 1024 / 1024).toFixed(1)}MB)`
194
+ );
195
+ }
196
+
197
+ for (const port of diff.stillBoundPorts) {
198
+ yield* Effect.logError(
199
+ ` Port :${port.port} still bound to PID ${port.pid} (${port.command})`
200
+ );
201
+ }
202
+
203
+ return yield* Effect.fail(
204
+ new ResourceLeaks({
205
+ orphanedProcesses: diff.orphanedProcesses,
206
+ stillBoundPorts: diff.stillBoundPorts,
207
+ })
208
+ );
209
+ }
210
+
211
+ yield* Effect.logInfo("No resource leaks detected ✓");
212
+ });
213
+
214
+ export const assertCleanState = (
215
+ baseline: Snapshot
216
+ ): Effect.Effect<void, PortStillBound | ProcessesStillAlive, PlatformService> =>
217
+ Effect.gen(function* () {
218
+ yield* Effect.logInfo("Asserting clean state");
219
+
220
+ const ports = Object.keys(baseline.ports).map((p) => parseInt(p, 10));
221
+ yield* assertAllPortsFree(ports);
222
+
223
+ const pids = baseline.processes.map((p) => p.pid);
224
+ if (pids.length > 0) {
225
+ yield* assertProcessesDead(pids);
226
+ }
227
+
228
+ yield* Effect.logInfo("Clean state verified ✓");
229
+ });
230
+
231
+ export const assertCleanStateWithPlatform = (
232
+ baseline: Snapshot
233
+ ): Effect.Effect<void, PortStillBound | ProcessesStillAlive> =>
234
+ withPlatform(assertCleanState(baseline));