diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index 7d16a11c829..0842aecd855 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts @@ -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"; @@ -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({ diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index b39d560a228..8af34d06850 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -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 { const rows: ProcessRow[] = []; const rowPattern = @@ -201,51 +215,56 @@ export function parsePosixProcessRows(output: string): ReadonlyArray return rows; } -function normalizeWindowsProcessRow(value: unknown): ProcessRow | null { - if (typeof value !== "object" || value === null) return null; - const record = value as Record; - 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 { + 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 { +export function parseWindowsProcessRows(output: string): ReadonlyArray { 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( diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index cecffbc1993..5dcb516aa6d 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -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"; @@ -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; @@ -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, + }, + }); }), ); }); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e2dc9cbbb39..392274b23e5 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -1,4 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off /** * WorkspaceFileSystem - Effect service contract for workspace file mutations. * @@ -7,8 +6,6 @@ * * @module WorkspaceFileSystem */ -import * as NodeFSP from "node:fs/promises"; - import type { ProjectReadFileInput, ProjectReadFileResult, @@ -19,6 +16,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -140,30 +138,32 @@ export const make = Effect.gen(function* () { relativePath: input.relativePath, }); - const realWorkspaceRoot = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(input.cwd), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: target.absolutePath, - operationPath: input.cwd, - operation: "realpath-workspace-root", - cause, - }), - }); - const realTargetPath = yield* Effect.tryPromise({ - try: () => NodeFSP.realpath(target.absolutePath), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: target.absolutePath, - operationPath: target.absolutePath, - operation: "realpath-target", - cause, - }), - }); + const realWorkspaceRoot = yield* fileSystem.realPath(input.cwd).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: input.cwd, + operation: "realpath-workspace-root", + cause, + }), + ), + ); + const realTargetPath = yield* fileSystem.realPath(target.absolutePath).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: target.absolutePath, + operation: "realpath-target", + cause, + }), + ), + ); const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); if ( relativeRealPath.startsWith(`..${path.sep}`) || @@ -178,85 +178,72 @@ export const make = Effect.gen(function* () { }); } - return yield* Effect.acquireUseRelease( - Effect.tryPromise({ - try: () => NodeFSP.open(realTargetPath, "r"), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: realTargetPath, - operationPath: realTargetPath, - operation: "open", - cause, - }), - }), - (handle) => - Effect.gen(function* () { - const stat = yield* Effect.tryPromise({ - try: () => handle.stat(), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: realTargetPath, - operationPath: realTargetPath, - operation: "stat", - cause, - }), - }); - if (!stat.isFile()) { - return yield* new WorkspacePathNotFileError({ + return yield* Effect.gen(function* () { + const handle = yield* fileSystem.open(realTargetPath, { flag: "r" }).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: realTargetPath, - }); - } - - const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); - const buffer = Buffer.alloc(bytesToRead); - const { bytesRead } = yield* Effect.tryPromise({ - try: () => handle.read(buffer, 0, bytesToRead, 0), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: realTargetPath, - operationPath: realTargetPath, - operation: "read", - cause, - }), - }); - const fileBytes = buffer.subarray(0, bytesRead); - if (fileBytes.includes(0)) { - return yield* new WorkspaceBinaryFileError({ + operationPath: realTargetPath, + operation: "open", + cause, + }), + ), + ); + const stat = yield* handle.stat.pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: realTargetPath, - }); - } + operationPath: realTargetPath, + operation: "stat", + cause, + }), + ), + ); + if (stat.type !== "File") { + return yield* new WorkspacePathNotFileError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + }); + } - return { - relativePath: target.relativePath, - contents: new TextDecoder("utf-8").decode(fileBytes), - byteLength: stat.size, - truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, - }; - }), - (handle) => - Effect.tryPromise({ - try: () => handle.close(), - catch: (cause) => + const byteLength = Number(stat.size); + const bytesToRead = Math.min(byteLength, PROJECT_READ_FILE_MAX_BYTES); + const fileBytes = yield* handle.readAlloc(FileSystem.Size(bytesToRead)).pipe( + Effect.mapError( + (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: realTargetPath, operationPath: realTargetPath, - operation: "close", + operation: "read", cause, }), - }), - ); + ), + Effect.map(Option.getOrElse(() => new Uint8Array())), + ); + if (fileBytes.includes(0)) { + return yield* new WorkspaceBinaryFileError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + }); + } + + return { + relativePath: target.relativePath, + contents: new TextDecoder("utf-8").decode(fileBytes), + byteLength, + truncated: byteLength > PROJECT_READ_FILE_MAX_BYTES, + }; + }).pipe(Effect.scoped); }); const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(