History

Use schema decoding for Effect parsing paths

Cursor Agent committed 5 months ago

Commit cb6fa9c1 parent d1e85c44 files · +148 −61Browse files

Unified · Split

4 files
  1. apps/server/src/diagnostics/ProcessDiagnostics.test.ts +51 −1
  2. apps/server/src/diagnostics/ProcessDiagnostics.ts +59 −32
  3. apps/server/src/vcs/VcsProjectConfig.test.ts +20 −0
  4. apps/server/src/vcs/VcsProjectConfig.ts +18 −28
apps/server/src/diagnostics/ProcessDiagnostics.test.ts+51−1
@@ -1,4 +1,4 @@
1−import { describe, expect, it } from "@effect/vitest";
1+import { assert, describe, expect, it } from "@effect/vitest";
22 import * as DateTime from "effect/DateTime";
33 import * as Effect from "effect/Effect";
44 import * as Layer from "effect/Layer";
@@ -66,6 +66,56 @@ describe("ProcessDiagnostics", () => { · 61 unmodified lines
6666 }),
6767 );
6868
69+ it.effect("parses Windows process JSON through schemas", () =>
70+ Effect.sync(() => {
71+ const rows = ProcessDiagnostics.parseWindowsProcessRows(`
72+ [
73+ {
74+ "ProcessId": 4242,
75+ "ParentProcessId": 100,
76+ "CommandLine": "node server.js",
77+ "WorkingSetSize": 4096.7,
78+ "PercentProcessorTime": 2.5,
79+ "Status": "Running"
80+ },
81+ {
82+ "ProcessId": 0,
83+ "ParentProcessId": 100,
84+ "Name": "invalid.exe"
85+ },
86+ {
87+ "ProcessId": 4243,
88+ "ParentProcessId": 4242,
89+ "Name": "child.exe"
90+ }
91+ ]
92+ `);
93+
94+ assert.deepEqual(rows, [
95+ {
96+ pid: 4242,
97+ ppid: 100,
98+ pgid: null,
99+ status: "Running",
100+ cpuPercent: 2.5,
101+ rssBytes: 4097,
102+ elapsed: "",
103+ command: "node server.js",
104+ },
105+ {
106+ pid: 4243,
107+ ppid: 4242,
108+ pgid: null,
109+ status: "Live",
110+ cpuPercent: 0,
111+ rssBytes: 0,
112+ elapsed: "",
113+ command: "child.exe",
114+ },
115+ ]);
116+ }),
117+ );
118+
69119 it.effect("aggregates only descendants of the server process", () =>
70120 Effect.sync(() => {
71121 const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({
apps/server/src/diagnostics/ProcessDiagnostics.ts+59−32
@@ -26,9 +26,23 @@ export interface ProcessRow { · 25 unmodified lines
2626 readonly command: string;
2727 }
2828
29−const PROCESS_QUERY_TIMEOUT_MS = 1_000;
3029 const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";
3130 const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
31+const PROCESS_QUERY_TIMEOUT = Duration.seconds(1);
32+
33+const WindowsProcessRecord = Schema.Struct({
34+ ProcessId: Schema.Number,
35+ ParentProcessId: Schema.Number,
36+ CommandLine: Schema.optional(Schema.String),
37+ Name: Schema.optional(Schema.String),
38+ WorkingSetSize: Schema.optional(Schema.Number),
39+ PercentProcessorTime: Schema.optional(Schema.Number),
40+ Status: Schema.optional(Schema.String),
41+});
42+type WindowsProcessRecord = typeof WindowsProcessRecord.Type;
43+const WindowsProcessJson = Schema.fromJsonString(Schema.Unknown);
44+const decodeWindowsProcessRecord = Schema.decodeUnknownOption(WindowsProcessRecord);
45+const decodeWindowsProcessJson = Schema.decodeUnknownOption(WindowsProcessJson);
3246
3347 export interface ProcessDiagnosticsShape {
3448 readonly read: Effect.Effect<ServerProcessDiagnosticsResult>;
@@ -74,6 +88,9 @@ function parseNumber(value: string): number | null { · 39 unmodified lines
7488 return Number.isFinite(parsed) ? parsed : null;
7589 }
7690
91+const nonEmptyWindowsString = (value: string | undefined): Option.Option<string> =>
92+ Option.fromUndefinedOr(value).pipe(Option.filter((text) => text.trim().length > 0));
93+
7794 export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> {
7895 const rows: ProcessRow[] = [];
7996 const rowPattern =
@@ -139,51 +156,61 @@ export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> · 59 unmodified lines
139156 return rows;
140157 }
141158
142−function normalizeWindowsProcessRow(value: unknown): ProcessRow | null {
143− if (typeof value !== "object" || value === null) return null;
144− const record = value as Record<string, unknown>;
145− const pid = typeof record.ProcessId === "number" ? record.ProcessId : null;
146− const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null;
147− const commandLine =
148− typeof record.CommandLine === "string" && record.CommandLine.trim().length > 0
149− ? record.CommandLine
150− : typeof record.Name === "string"
151− ? record.Name
152− : null;
159+function normalizeWindowsProcessRow(record: WindowsProcessRecord): Option.Option<ProcessRow> {
160+ const pid = Number.isInteger(record.ProcessId) ? Option.some(record.ProcessId) : Option.none();
161+ const ppid = Number.isInteger(record.ParentProcessId)
162+ ? Option.some(record.ParentProcessId)
163+ : Option.none();
164+ const commandLine = nonEmptyWindowsString(record.CommandLine).pipe(
165+ Option.orElse(() => nonEmptyWindowsString(record.Name)),
166+ );
153167 const workingSet =
154− typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize)
168+ record.WorkingSetSize !== undefined && Number.isFinite(record.WorkingSetSize)
155169 ? Math.max(0, Math.round(record.WorkingSetSize))
156170 : 0;
157171 const cpuPercent =
158− typeof record.PercentProcessorTime === "number" && Number.isFinite(record.PercentProcessorTime)
172+ record.PercentProcessorTime !== undefined && Number.isFinite(record.PercentProcessorTime)
159173 ? Math.max(0, record.PercentProcessorTime)
160174 : 0;
161175
162− if (!pid || pid <= 0 || ppid === null || ppid < 0 || !commandLine) return null;
163− return {
164− pid,
165− ppid,
176+ if (
177+ Option.isNone(pid) ||
178+ pid.value <= 0 ||
179+ Option.isNone(ppid) ||
180+ ppid.value < 0 ||
181+ Option.isNone(commandLine)
182+ ) {
183+ return Option.none<ProcessRow>();
184+ }
185+ return Option.some({
186+ pid: pid.value,
187+ ppid: ppid.value,
166188 pgid: null,
167− status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live",
189+ status: record.Status !== undefined && record.Status.length > 0 ? record.Status : "Live",
168190 cpuPercent,
169191 rssBytes: workingSet,
170192 elapsed: "",
171− command: commandLine,
172− };
193+ command: commandLine.value,
194+ });
173195 }
174196
175−function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
197+export function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
176198 if (output.trim().length === 0) return [];
177− try {
178− const parsed = JSON.parse(output) as unknown;
179− const records = Array.isArray(parsed) ? parsed : [parsed];
180− return records.flatMap((record) => {
181− const row = normalizeWindowsProcessRow(record);
182− return row ? [row] : [];
199+ const parsed = decodeWindowsProcessJson(output);
200+ if (Option.isNone(parsed)) return [];
201+
202+ const records = Array.isArray(parsed.value) ? parsed.value : [parsed.value];
203+ return records.flatMap((record) => {
204+ const decoded = decodeWindowsProcessRecord(record);
205+ return Option.match(decoded, {
206+ onNone: () => [],
207+ onSome: (windowsRecord) =>
208+ Option.match(normalizeWindowsProcessRow(windowsRecord), {
209+ onNone: () => [],
210+ onSome: (row) => [row],
211+ }),
183212 });
184− } catch {
185− return [];
186− }
213+ });
187214 }
188215
189216 export function buildDescendantEntries(
@@ -309,7 +336,7 @@ const runProcess = Effect.fn("runProcess")( · 119 unmodified lines
309336 (effect, input) =>
310337 effect.pipe(
311338 Effect.scoped,
312− Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),
339+ Effect.timeoutOption(PROCESS_QUERY_TIMEOUT),
313340 Effect.flatMap((result) =>
314341 Option.match(result, {
315342 onNone: () => Effect.fail(toProcessDiagnosticsError(`${input.errorMessage} timed out.`)),
apps/server/src/vcs/VcsProjectConfig.test.ts+20−0
@@ -67,4 +67,24 @@ describe("VcsProjectConfig", () => { · 66 unmodified lines
6767 }),
6868 );
6969 });
70+
71+ it.layer(TestLayer)("falls back to auto when config is invalid JSON", (it) => {
72+ it.effect("returns auto", () =>
73+ Effect.gen(function* () {
74+ const fileSystem = yield* FileSystem.FileSystem;
75+ const path = yield* Path.Path;
76+ const root = yield* fileSystem.makeTempDirectoryScoped({
77+ prefix: "t3-vcs-config-test-",
78+ });
79+ const configDir = path.join(root, ".t3code");
80+ yield* fileSystem.makeDirectory(configDir, { recursive: true });
81+ yield* fileSystem.writeFileString(path.join(configDir, "vcs.json"), "{ nope");
82+
83+ const config = yield* VcsProjectConfig.VcsProjectConfig;
84+ const kind = yield* config.resolveKind({ cwd: root });
85+
86+ assert.equal(kind, "auto");
87+ }),
88+ );
89+ });
7090 });
apps/server/src/vcs/VcsProjectConfig.ts+18−28
@@ -2,6 +2,7 @@ import * as Context from "effect/Context"; · 1 unmodified line
22 import * as Effect from "effect/Effect";
33 import * as FileSystem from "effect/FileSystem";
44 import * as Layer from "effect/Layer";
5+import * as Option from "effect/Option";
56 import * as Path from "effect/Path";
67 import * as Schema from "effect/Schema";
78
@@ -15,16 +16,10 @@ const ProjectVcsConfig = Schema.Struct({ · 7 unmodified lines
1516 ),
1617 vcsKind: Schema.optional(VcsDriverKind),
1718 });
18−const isProjectVcsConfig = Schema.is(ProjectVcsConfig);
19+const ProjectVcsConfigJson = Schema.fromJsonString(ProjectVcsConfig);
20+const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson);
1921
20−interface ProjectVcsConfigFile {
21− readonly vcs?:
22− | {
23− readonly kind?: VcsDriverKindType | undefined;
24− }
25− | undefined;
26− readonly vcsKind?: VcsDriverKindType | undefined;
27−}
22+type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type;
2823
2924 export interface VcsProjectConfigResolveInput {
3025 readonly cwd: string;
@@ -45,13 +40,8 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto · 14 unmodified lines
4540 return config.vcs?.kind ?? config.vcsKind ?? "auto";
4641 }
4742
48−function parseConfig(raw: string): ProjectVcsConfigFile | null {
49− try {
50− const parsed = JSON.parse(raw) as unknown;
51− return isProjectVcsConfig(parsed) ? parsed : null;
52− } catch {
53− return null;
54− }
43+function parseConfig(raw: string): Option.Option<ProjectVcsConfigFile> {
44+ return decodeProjectVcsConfigJson(raw);
5545 }
5646
5747 export const make = Effect.fn("makeVcsProjectConfig")(function* () {
@@ -63,12 +53,12 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { · 5 unmodified lines
6353 while (true) {
6454 const candidate = path.join(current, ".t3code", "vcs.json");
6555 if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
66− return candidate;
56+ return Option.some(candidate);
6757 }
6858
6959 const parent = path.dirname(current);
7060 if (parent === current) {
71− return null;
61+ return Option.none<string>();
7262 }
7363 current = parent;
7464 }
@@ -78,26 +68,27 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { · 3 unmodified lines
7868 configPath: string,
7969 ) {
8070 const raw = yield* fileSystem.readFileString(configPath).pipe(
71+ Effect.map(Option.some),
8172 Effect.catch((error) =>
8273 Effect.logWarning("failed to read VCS project config", {
8374 configPath,
8475 error,
85− }).pipe(Effect.as(null)),
76+ }).pipe(Effect.as(Option.none<string>())),
8677 ),
8778 );
88− if (raw === null) {
79+ if (Option.isNone(raw)) {
8980 return "auto" as const;
9081 }
9182
92− const parsed = parseConfig(raw);
93− if (parsed === null) {
83+ const parsed = parseConfig(raw.value);
84+ if (Option.isNone(parsed)) {
9485 yield* Effect.logWarning("invalid VCS project config", {
9586 configPath,
9687 });
9788 return "auto" as const;
9889 }
9990
100− return configuredKind(parsed);
91+ return configuredKind(parsed.value);
10192 });
10293
10394 const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn(
@@ -108,11 +99,10 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { · 4 unmodified lines
10899 }
109100
110101 const configPath = yield* findConfigPath(input.cwd);
111− if (configPath === null) {
112− return "auto";
113− }
114−
115− return yield* readConfiguredKind(configPath);
102+ return yield* Option.match(configPath, {
103+ onNone: () => Effect.succeed("auto" as const),
104+ onSome: readConfiguredKind,
105+ });
116106 });
117107
118108 return VcsProjectConfig.of({