diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index 7d16a11c829..1597a357962 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts @@ -1,8 +1,9 @@ -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"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -11,6 +12,7 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ProcessDiagnostics from "./ProcessDiagnostics.ts"; const encoder = new TextEncoder(); +const encodeJsonFixture = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); function mockHandle(result: { readonly stdout?: string; @@ -67,6 +69,108 @@ describe("ProcessDiagnostics", () => { }), ); + it.effect("parses Windows CIM JSON arrays with command fallback defaults", () => + Effect.sync(() => { + const rows = ProcessDiagnostics.parseWindowsProcessRows( + encodeJsonFixture([ + { + ProcessId: 10, + ParentProcessId: 1, + CommandLine: "node server.js", + Name: "node.exe", + Status: "Running", + WorkingSetSize: 1234.4, + PercentProcessorTime: 2.5, + }, + { + ProcessId: 11, + ParentProcessId: 10, + CommandLine: "", + Name: "agent.exe", + }, + ]), + ); + + assert.deepEqual(rows, [ + { + pid: 10, + ppid: 1, + pgid: null, + status: "Running", + cpuPercent: 2.5, + rssBytes: 1234, + elapsed: "", + command: "node server.js", + }, + { + pid: 11, + ppid: 10, + pgid: null, + status: "Live", + cpuPercent: 0, + rssBytes: 0, + elapsed: "", + command: "agent.exe", + }, + ]); + }), + ); + + it.effect("parses single Windows CIM JSON objects", () => + Effect.sync(() => { + const rows = ProcessDiagnostics.parseWindowsProcessRows( + encodeJsonFixture({ + ProcessId: 20, + ParentProcessId: 10, + CommandLine: "codex app-server", + WorkingSetSize: 4096, + PercentProcessorTime: 1, + }), + ); + + assert.deepEqual(rows, [ + { + pid: 20, + ppid: 10, + pgid: null, + status: "Live", + cpuPercent: 1, + rssBytes: 4096, + elapsed: "", + command: "codex app-server", + }, + ]); + }), + ); + + it.effect("ignores malformed Windows CIM output and invalid records", () => + Effect.sync(() => { + assert.deepEqual(ProcessDiagnostics.parseWindowsProcessRows("{not-json"), []); + assert.deepEqual( + ProcessDiagnostics.parseWindowsProcessRows( + encodeJsonFixture([ + { ProcessId: 0, ParentProcessId: 1, CommandLine: "missing-pid" }, + { ProcessId: 30, ParentProcessId: -1, CommandLine: "missing-parent" }, + { ProcessId: 31, ParentProcessId: 1, CommandLine: "" }, + { ProcessId: 32, ParentProcessId: 1, CommandLine: "valid" }, + ]), + ), + [ + { + pid: 32, + ppid: 1, + pgid: null, + status: "Live", + cpuPercent: 0, + rssBytes: 0, + elapsed: "", + command: "valid", + }, + ], + ); + }), + ); + 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..296855d1ebb 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -121,6 +121,21 @@ const ProcessDiagnosticsError = Schema.Union([ type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type; const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); +const WindowsCimProcessRecord = Schema.Struct({ + ProcessId: Schema.Number, + ParentProcessId: Schema.Number, + CommandLine: Schema.optional(Schema.NullOr(Schema.String)), + Name: Schema.optional(Schema.NullOr(Schema.String)), + WorkingSetSize: Schema.optional(Schema.NullOr(Schema.Number)), + PercentProcessorTime: Schema.optional(Schema.NullOr(Schema.Number)), + Status: Schema.optional(Schema.NullOr(Schema.String)), +}); +type WindowsCimProcessRecord = typeof WindowsCimProcessRecord.Type; +const decodeWindowsCimProcessRecord = Schema.decodeUnknownOption(WindowsCimProcessRecord); +const decodeWindowsCimProcessJson = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Unknown), +); + function parsePositiveInt(value: string): number | null { const parsed = Number.parseInt(value, 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : null; @@ -201,51 +216,57 @@ 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; - const workingSet = - typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize) - ? Math.max(0, Math.round(record.WorkingSetSize)) - : 0; - const cpuPercent = - typeof record.PercentProcessorTime === "number" && Number.isFinite(record.PercentProcessorTime) - ? Math.max(0, record.PercentProcessorTime) - : 0; - - if (!pid || pid <= 0 || ppid === null || ppid < 0 || !commandLine) return null; - return { - pid, - ppid, - pgid: null, - status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live", - cpuPercent, - rssBytes: workingSet, - elapsed: "", - command: commandLine, - }; +function nonEmptyStringOption(value: string | null | undefined): Option.Option { + return typeof value === "string" && value.trim().length > 0 ? Option.some(value) : Option.none(); +} + +function optionalNonNegativeNumber(value: number | null | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0; +} + +function normalizeWindowsProcessRow(value: unknown): Option.Option { + return Option.flatMap(decodeWindowsCimProcessRecord(value), (record) => { + if ( + !Number.isInteger(record.ProcessId) || + record.ProcessId <= 0 || + !Number.isInteger(record.ParentProcessId) || + record.ParentProcessId < 0 + ) { + return Option.none(); + } + + return Option.map( + nonEmptyStringOption(record.CommandLine).pipe( + Option.orElse(() => nonEmptyStringOption(record.Name)), + ), + (command) => ({ + pid: record.ProcessId, + ppid: record.ParentProcessId, + pgid: null, + status: Option.getOrElse(nonEmptyStringOption(record.Status), () => "Live"), + cpuPercent: optionalNonNegativeNumber(record.PercentProcessorTime), + rssBytes: Math.round(optionalNonNegativeNumber(record.WorkingSetSize)), + elapsed: "", + 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(decodeWindowsCimProcessJson(output), { + onNone: () => [], + onSome: (parsed) => { + const rows: ProcessRow[] = []; + const records = Array.isArray(parsed) ? parsed : [parsed]; + for (const record of records) { + const row = normalizeWindowsProcessRow(record); + if (Option.isSome(row)) rows.push(row.value); + } + return rows; + }, + }); } export function buildDescendantEntries( diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index cecffbc1993..f77986ac6cf 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -185,8 +185,15 @@ 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", + module: "FileSystem", + method: "realPath", + pathOrDescriptor: resolvedPath, + }, + }); }), ); }); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e2dc9cbbb39..b4a1b2f63c4 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"; @@ -26,6 +24,7 @@ import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; +const PROJECT_READ_FILE_MAX_SIZE = FileSystem.Size(PROJECT_READ_FILE_MAX_BYTES); export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass()( "WorkspaceFileSystemOperationError", @@ -132,6 +131,23 @@ export const make = Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const operationError = + ( + input: ProjectReadFileInput | ProjectWriteFileInput, + resolvedPath: string, + operationPath: string, + operation: WorkspaceFileSystemOperationError["operation"], + ) => + (cause: unknown) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath, + operationPath, + operation, + cause, + }); + const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( "WorkspaceFileSystem.readFile", )(function* (input) { @@ -140,30 +156,20 @@ 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( + operationError(input, target.absolutePath, input.cwd, "realpath-workspace-root"), + ), + ); + const realTargetPath = yield* fileSystem + .realPath(target.absolutePath) + .pipe( + Effect.mapError( + operationError(input, target.absolutePath, target.absolutePath, "realpath-target"), + ), + ); const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); if ( relativeRealPath.startsWith(`..${path.sep}`) || @@ -178,84 +184,48 @@ export const make = Effect.gen(function* () { }); } - return yield* Effect.acquireUseRelease( - Effect.tryPromise({ - try: () => NodeFSP.open(realTargetPath, "r"), - catch: (cause) => - new WorkspaceFileSystemOperationError({ + return yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fileSystem + .open(realTargetPath, { flag: "r" }) + .pipe(Effect.mapError(operationError(input, realTargetPath, realTargetPath, "open"))); + const stat = yield* file.stat.pipe( + Effect.mapError(operationError(input, realTargetPath, realTargetPath, "stat")), + ); + if (stat.type !== "File") { + return yield* new WorkspacePathNotFileError({ 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({ - 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 bytesToRead = + stat.size > PROJECT_READ_FILE_MAX_SIZE ? PROJECT_READ_FILE_MAX_BYTES : Number(stat.size); + const fileBytes = + bytesToRead === 0 + ? new Uint8Array() + : yield* file + .readAlloc(bytesToRead) + .pipe( + Effect.map(Option.getOrElse(() => new Uint8Array())), + Effect.mapError(operationError(input, realTargetPath, realTargetPath, "read")), + ); + if (fileBytes.includes(0)) { + return yield* new WorkspaceBinaryFileError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, }); - const fileBytes = buffer.subarray(0, bytesRead); - 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: stat.size, - truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, - }; - }), - (handle) => - Effect.tryPromise({ - try: () => handle.close(), - catch: (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: realTargetPath, - operationPath: realTargetPath, - operation: "close", - cause, - }), - }), + return { + relativePath: target.relativePath, + contents: new TextDecoder("utf-8").decode(fileBytes), + byteLength: Number(stat.size), + truncated: stat.size > PROJECT_READ_FILE_MAX_SIZE, + }; + }), ); }); @@ -267,32 +237,25 @@ export const make = Effect.gen(function* () { relativePath: input.relativePath, }); - yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( - Effect.mapError( - (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: target.absolutePath, - operationPath: path.dirname(target.absolutePath), - operation: "make-directory", - cause, - }), - ), - ); - yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( - Effect.mapError( - (cause) => - new WorkspaceFileSystemOperationError({ - workspaceRoot: input.cwd, - relativePath: input.relativePath, - resolvedPath: target.absolutePath, - operationPath: target.absolutePath, - operation: "write-file", - cause, - }), - ), - ); + yield* fileSystem + .makeDirectory(path.dirname(target.absolutePath), { recursive: true }) + .pipe( + Effect.mapError( + operationError( + input, + target.absolutePath, + path.dirname(target.absolutePath), + "make-directory", + ), + ), + ); + yield* fileSystem + .writeFileString(target.absolutePath, input.contents) + .pipe( + Effect.mapError( + operationError(input, target.absolutePath, target.absolutePath, "write-file"), + ), + ); yield* workspaceEntries.refresh(input.cwd); return { relativePath: target.relativePath }; });