Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion apps/server/src/diagnostics/ProcessDiagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "@effect/vitest";
import { assert, describe, expect, it } from "@effect/vitest";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -67,6 +67,48 @@ describe("ProcessDiagnostics", () => {
}),
);

it("schema-decodes Windows process arrays and skips invalid records", () => {
const rows = ProcessDiagnostics.parseWindowsProcessRows(
`[{"ProcessId":10,"ParentProcessId":1,"Name":"node.exe","CommandLine":"node server.js","Status":"Running","WorkingSetSize":4096,"PercentProcessorTime":12.5},{"ProcessId":"invalid","ParentProcessId":10,"Name":"ignored.exe"}]`,
);

assert.deepStrictEqual(rows, [
{
pid: 10,
ppid: 1,
pgid: null,
status: "Running",
cpuPercent: 12.5,
rssBytes: 4096,
elapsed: "",
command: "node server.js",
},
]);
});

it("accepts a single Windows process and defaults optional metrics", () => {
const rows = ProcessDiagnostics.parseWindowsProcessRows(
`{"ProcessId":11,"ParentProcessId":10,"Name":"agent.exe","CommandLine":null}`,
);

assert.deepStrictEqual(rows, [
{
pid: 11,
ppid: 10,
pgid: null,
status: "Live",
cpuPercent: 0,
rssBytes: 0,
elapsed: "",
command: "agent.exe",
},
]);
});

it("returns no Windows processes for malformed JSON", () => {
assert.deepStrictEqual(ProcessDiagnostics.parseWindowsProcessRows("{invalid"), []);
});

it.effect("aggregates only descendants of the server process", () =>
Effect.sync(() => {
const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({
Expand Down
81 changes: 50 additions & 31 deletions apps/server/src/diagnostics/ProcessDiagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,20 @@ function parseNumber(value: string): number | null {
return Number.isFinite(parsed) ? parsed : null;
}

const WindowsProcessRecord = Schema.Struct({
ProcessId: Schema.Number,
ParentProcessId: Schema.Number,
Name: Schema.optional(Schema.NullOr(Schema.String)),
CommandLine: Schema.optional(Schema.NullOr(Schema.String)),
Status: Schema.optional(Schema.NullOr(Schema.String)),
WorkingSetSize: Schema.optional(Schema.NullOr(Schema.Number)),
PercentProcessorTime: Schema.optional(Schema.NullOr(Schema.Number)),
});
type WindowsProcessRecord = typeof WindowsProcessRecord.Type;

const decodeWindowsProcessJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown));
const decodeWindowsProcessRecord = Schema.decodeUnknownOption(WindowsProcessRecord);

export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> {
const rows: ProcessRow[] = [];
const rowPattern =
Expand Down Expand Up @@ -201,51 +215,56 @@ export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow>
return rows;
}

function normalizeWindowsProcessRow(value: unknown): ProcessRow | null {
if (typeof value !== "object" || value === null) return null;
const record = value as Record<string, unknown>;
const pid = typeof record.ProcessId === "number" ? record.ProcessId : null;
const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null;
const commandLine =
typeof record.CommandLine === "string" && record.CommandLine.trim().length > 0
? record.CommandLine
: typeof record.Name === "string"
? record.Name
: null;
function normalizeWindowsProcessRow(record: WindowsProcessRecord): Option.Option<ProcessRow> {
const commandLine = Option.fromNullishOr(record.CommandLine).pipe(
Option.filter((value) => value.trim().length > 0),
Option.orElse(() =>
Option.fromNullishOr(record.Name).pipe(Option.filter((value) => value.trim().length > 0)),
),
);
const workingSet =
typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize)
record.WorkingSetSize !== null &&
record.WorkingSetSize !== undefined &&
Number.isFinite(record.WorkingSetSize)
? Math.max(0, Math.round(record.WorkingSetSize))
: 0;
const cpuPercent =
typeof record.PercentProcessorTime === "number" && Number.isFinite(record.PercentProcessorTime)
record.PercentProcessorTime !== null &&
record.PercentProcessorTime !== undefined &&
Number.isFinite(record.PercentProcessorTime)
? Math.max(0, record.PercentProcessorTime)
: 0;

if (!pid || pid <= 0 || ppid === null || ppid < 0 || !commandLine) return null;
return {
pid,
ppid,
if (record.ProcessId <= 0 || record.ParentProcessId < 0) return Option.none();
return Option.map(commandLine, (command) => ({
pid: record.ProcessId,
ppid: record.ParentProcessId,
pgid: null,
status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live",
status: record.Status && record.Status.length > 0 ? record.Status : "Live",
cpuPercent,
rssBytes: workingSet,
elapsed: "",
command: commandLine,
};
command,
}));
}

function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
export function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
if (output.trim().length === 0) return [];
try {
const parsed = JSON.parse(output) as unknown;
const records = Array.isArray(parsed) ? parsed : [parsed];
return records.flatMap((record) => {
const row = normalizeWindowsProcessRow(record);
return row ? [row] : [];
});
} catch {
return [];
}
return Option.match(decodeWindowsProcessJson(output), {
onNone: () => [],
onSome: (parsed) => {
const records = Array.isArray(parsed) ? parsed : [parsed];
return records.flatMap((value) =>
Option.match(
Option.flatMap(decodeWindowsProcessRecord(value), normalizeWindowsProcessRow),
{
onNone: () => [],
onSome: (row) => [row],
},
),
);
},
});
}

export function buildDescendantEntries(
Expand Down
29 changes: 26 additions & 3 deletions apps/server/src/workspace/WorkspaceFileSystem.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { it, describe, expect } from "@effect/vitest";
import { assert, describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -73,6 +73,24 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
}),
);

it.effect("bounds previews while reporting the complete file size", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const cwd = yield* makeTempDir;
const contents = "a".repeat(1024 * 1024 + 5);
yield* writeTextFile(cwd, "large.txt", contents);

const result = yield* workspaceFileSystem.readFile({
cwd,
relativePath: "large.txt",
});

assert.strictEqual(result.contents.length, 1024 * 1024);
assert.strictEqual(result.byteLength, contents.length);
assert.isTrue(result.truncated);
}),
);

it.effect("rejects reads outside the workspace root", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
Expand Down Expand Up @@ -185,8 +203,13 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
operationPath: resolvedPath,
operation: "realpath-target",
});
expect(error.cause).toBeInstanceOf(Error);
expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT");
expect(error.cause).toMatchObject({
_tag: "PlatformError",
reason: {
_tag: "NotFound",
pathOrDescriptor: resolvedPath,
},
});
}),
);
});
Expand Down
Loading
Loading