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 @@
1import { describe, expect, it } from "@effect/vitest";1import { assert, describe, expect, it } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
@@ -66,6 +66,56 @@ describe("ProcessDiagnostics", () => { · 61 unmodified lines
66 }),66 }),
67 );67 );
68 68
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
69 it.effect("aggregates only descendants of the server process", () =>119 it.effect("aggregates only descendants of the server process", () =>
70 Effect.sync(() => {120 Effect.sync(() => {
71 const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({121 const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({
apps/server/src/diagnostics/ProcessDiagnostics.ts+59−32
@@ -26,9 +26,23 @@ export interface ProcessRow { · 25 unmodified lines
26 readonly command: string;26 readonly command: string;
27}27}
28 28
29const PROCESS_QUERY_TIMEOUT_MS = 1_000;
30const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";29const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command=";
31const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;30const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
31const PROCESS_QUERY_TIMEOUT = Duration.seconds(1);
32
33const 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});
42type WindowsProcessRecord = typeof WindowsProcessRecord.Type;
43const WindowsProcessJson = Schema.fromJsonString(Schema.Unknown);
44const decodeWindowsProcessRecord = Schema.decodeUnknownOption(WindowsProcessRecord);
45const decodeWindowsProcessJson = Schema.decodeUnknownOption(WindowsProcessJson);
32 46
33export interface ProcessDiagnosticsShape {47export interface ProcessDiagnosticsShape {
34 readonly read: Effect.Effect<ServerProcessDiagnosticsResult>;48 readonly read: Effect.Effect<ServerProcessDiagnosticsResult>;
@@ -74,6 +88,9 @@ function parseNumber(value: string): number | null { · 39 unmodified lines
74 return Number.isFinite(parsed) ? parsed : null;88 return Number.isFinite(parsed) ? parsed : null;
75}89}
76 90
91const nonEmptyWindowsString = (value: string | undefined): Option.Option<string> =>
92 Option.fromUndefinedOr(value).pipe(Option.filter((text) => text.trim().length > 0));
93
77export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> {94export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> {
78 const rows: ProcessRow[] = [];95 const rows: ProcessRow[] = [];
79 const rowPattern =96 const rowPattern =
@@ -139,51 +156,61 @@ export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> · 59 unmodified lines
139 return rows;156 return rows;
140}157}
141 158
142function normalizeWindowsProcessRow(value: unknown): ProcessRow | null {159function normalizeWindowsProcessRow(record: WindowsProcessRecord): Option.Option<ProcessRow> {
143 if (typeof value !== "object" || value === null) return null;160 const pid = Number.isInteger(record.ProcessId) ? Option.some(record.ProcessId) : Option.none();
144 const record = value as Record<string, unknown>;161 const ppid = Number.isInteger(record.ParentProcessId)
145 const pid = typeof record.ProcessId === "number" ? record.ProcessId : null;162 ? Option.some(record.ParentProcessId)
146 const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null;163 : Option.none();
147 const commandLine =164 const commandLine = nonEmptyWindowsString(record.CommandLine).pipe(
148 typeof record.CommandLine === "string" && record.CommandLine.trim().length > 0165 Option.orElse(() => nonEmptyWindowsString(record.Name)),
149 ? record.CommandLine166 );
150 : typeof record.Name === "string"
151 ? record.Name
152 : null;
153 const workingSet =167 const workingSet =
154 typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize)168 record.WorkingSetSize !== undefined && Number.isFinite(record.WorkingSetSize)
155 ? Math.max(0, Math.round(record.WorkingSetSize))169 ? Math.max(0, Math.round(record.WorkingSetSize))
156 : 0;170 : 0;
157 const cpuPercent =171 const cpuPercent =
158 typeof record.PercentProcessorTime === "number" && Number.isFinite(record.PercentProcessorTime)172 record.PercentProcessorTime !== undefined && Number.isFinite(record.PercentProcessorTime)
159 ? Math.max(0, record.PercentProcessorTime)173 ? Math.max(0, record.PercentProcessorTime)
160 : 0;174 : 0;
161 175
162 if (!pid || pid <= 0 || ppid === null || ppid < 0 || !commandLine) return null;176 if (
163 return {177 Option.isNone(pid) ||
164 pid,178 pid.value <= 0 ||
165 ppid,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,
166 pgid: null,188 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",
168 cpuPercent,190 cpuPercent,
169 rssBytes: workingSet,191 rssBytes: workingSet,
170 elapsed: "",192 elapsed: "",
171 command: commandLine,193 command: commandLine.value,
172 };194 });
173}195}
174 196
175function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {197export function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
176 if (output.trim().length === 0) return [];198 if (output.trim().length === 0) return [];
177 try {199 const parsed = decodeWindowsProcessJson(output);
178 const parsed = JSON.parse(output) as unknown;200 if (Option.isNone(parsed)) return [];
179 const records = Array.isArray(parsed) ? parsed : [parsed];201
180 return records.flatMap((record) => {202 const records = Array.isArray(parsed.value) ? parsed.value : [parsed.value];
181 const row = normalizeWindowsProcessRow(record);203 return records.flatMap((record) => {
182 return row ? [row] : [];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 }),
183 });212 });
184 } catch {213 });
185 return [];
186 }
187}214}
188 215
189export function buildDescendantEntries(216export function buildDescendantEntries(
@@ -309,7 +336,7 @@ const runProcess = Effect.fn("runProcess")( · 119 unmodified lines
309 (effect, input) =>336 (effect, input) =>
310 effect.pipe(337 effect.pipe(
311 Effect.scoped,338 Effect.scoped,
312 Effect.timeoutOption(Duration.millis(PROCESS_QUERY_TIMEOUT_MS)),339 Effect.timeoutOption(PROCESS_QUERY_TIMEOUT),
313 Effect.flatMap((result) =>340 Effect.flatMap((result) =>
314 Option.match(result, {341 Option.match(result, {
315 onNone: () => Effect.fail(toProcessDiagnosticsError(`${input.errorMessage} timed out.`)),342 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
67 }),67 }),
68 );68 );
69 });69 });
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 });
70});90});
apps/server/src/vcs/VcsProjectConfig.ts+18−28
@@ -2,6 +2,7 @@ import * as Context from "effect/Context"; · 1 unmodified line
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
5import * as Path from "effect/Path";6import * as Path from "effect/Path";
6import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
7 8
@@ -15,16 +16,10 @@ const ProjectVcsConfig = Schema.Struct({ · 7 unmodified lines
15 ),16 ),
16 vcsKind: Schema.optional(VcsDriverKind),17 vcsKind: Schema.optional(VcsDriverKind),
17});18});
18const isProjectVcsConfig = Schema.is(ProjectVcsConfig);19const ProjectVcsConfigJson = Schema.fromJsonString(ProjectVcsConfig);
20const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson);
19 21
20interface ProjectVcsConfigFile {22type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type;
21 readonly vcs?:
22 | {
23 readonly kind?: VcsDriverKindType | undefined;
24 }
25 | undefined;
26 readonly vcsKind?: VcsDriverKindType | undefined;
27}
28 23
29export interface VcsProjectConfigResolveInput {24export interface VcsProjectConfigResolveInput {
30 readonly cwd: string;25 readonly cwd: string;
@@ -45,13 +40,8 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto · 14 unmodified lines
45 return config.vcs?.kind ?? config.vcsKind ?? "auto";40 return config.vcs?.kind ?? config.vcsKind ?? "auto";
46}41}
47 42
48function parseConfig(raw: string): ProjectVcsConfigFile | null {43function parseConfig(raw: string): Option.Option<ProjectVcsConfigFile> {
49 try {44 return decodeProjectVcsConfigJson(raw);
50 const parsed = JSON.parse(raw) as unknown;
51 return isProjectVcsConfig(parsed) ? parsed : null;
52 } catch {
53 return null;
54 }
55}45}
56 46
57export const make = Effect.fn("makeVcsProjectConfig")(function* () {47export const make = Effect.fn("makeVcsProjectConfig")(function* () {
@@ -63,12 +53,12 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { · 5 unmodified lines
63 while (true) {53 while (true) {
64 const candidate = path.join(current, ".t3code", "vcs.json");54 const candidate = path.join(current, ".t3code", "vcs.json");
65 if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {55 if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
66 return candidate;56 return Option.some(candidate);
67 }57 }
68 58
69 const parent = path.dirname(current);59 const parent = path.dirname(current);
70 if (parent === current) {60 if (parent === current) {
71 return null;61 return Option.none<string>();
72 }62 }
73 current = parent;63 current = parent;
74 }64 }
@@ -78,26 +68,27 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { · 3 unmodified lines
78 configPath: string,68 configPath: string,
79 ) {69 ) {
80 const raw = yield* fileSystem.readFileString(configPath).pipe(70 const raw = yield* fileSystem.readFileString(configPath).pipe(
71 Effect.map(Option.some),
81 Effect.catch((error) =>72 Effect.catch((error) =>
82 Effect.logWarning("failed to read VCS project config", {73 Effect.logWarning("failed to read VCS project config", {
83 configPath,74 configPath,
84 error,75 error,
85 }).pipe(Effect.as(null)),76 }).pipe(Effect.as(Option.none<string>())),
86 ),77 ),
87 );78 );
88 if (raw === null) {79 if (Option.isNone(raw)) {
89 return "auto" as const;80 return "auto" as const;
90 }81 }
91 82
92 const parsed = parseConfig(raw);83 const parsed = parseConfig(raw.value);
93 if (parsed === null) {84 if (Option.isNone(parsed)) {
94 yield* Effect.logWarning("invalid VCS project config", {85 yield* Effect.logWarning("invalid VCS project config", {
95 configPath,86 configPath,
96 });87 });
97 return "auto" as const;88 return "auto" as const;
98 }89 }
99 90
100 return configuredKind(parsed);91 return configuredKind(parsed.value);
101 });92 });
102 93
103 const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn(94 const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn(
@@ -108,11 +99,10 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { · 4 unmodified lines
108 }99 }
109 100
110 const configPath = yield* findConfigPath(input.cwd);101 const configPath = yield* findConfigPath(input.cwd);
111 if (configPath === null) {102 return yield* Option.match(configPath, {
112 return "auto";103 onNone: () => Effect.succeed("auto" as const),
113 }104 onSome: readConfiguredKind,
114 105 });
115 return yield* readConfiguredKind(configPath);
116 });106 });
117 107
118 return VcsProjectConfig.of({108 return VcsProjectConfig.of({