Unified · Split
| @@ -1,4 +1,4 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { assert, describe, expect, it } from "@effect/vitest"; |
| 2 | import * as DateTime from "effect/DateTime"; | 2 | import * as DateTime from "effect/DateTime"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * 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({ |
| @@ -26,9 +26,23 @@ export interface ProcessRow { · 25 unmodified lines | |||
| 26 | readonly command: string; | 26 | readonly command: string; |
| 27 | } | 27 | } |
| 28 | 28 | ||
| 29 | const PROCESS_QUERY_TIMEOUT_MS = 1_000; | ||
| 30 | const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="; | 29 | const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="; |
| 31 | const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; | 30 | 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); | ||
| 32 | 46 | ||
| 33 | export interface ProcessDiagnosticsShape { | 47 | export 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 | ||
| 91 | const nonEmptyWindowsString = (value: string | undefined): Option.Option<string> => | ||
| 92 | Option.fromUndefinedOr(value).pipe(Option.filter((text) => text.trim().length > 0)); | ||
| 93 | |||
| 77 | export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> { | 94 | export 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 | ||
| 142 | function normalizeWindowsProcessRow(value: unknown): ProcessRow | null { | 159 | function 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 > 0 | 165 | Option.orElse(() => nonEmptyWindowsString(record.Name)), |
| 149 | ? record.CommandLine | 166 | ); |
| 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 | ||
| 175 | function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> { | 197 | export 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 | ||
| 189 | export function buildDescendantEntries( | 216 | export 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.`)), |
| @@ -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 | }); |
| @@ -2,6 +2,7 @@ import * as Context from "effect/Context"; · 1 unmodified line | |||
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; | 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | ||
| 5 | import * as Path from "effect/Path"; | 6 | import * as Path from "effect/Path"; |
| 6 | import * as Schema from "effect/Schema"; | 7 | import * 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 | }); |
| 18 | const isProjectVcsConfig = Schema.is(ProjectVcsConfig); | 19 | const ProjectVcsConfigJson = Schema.fromJsonString(ProjectVcsConfig); |
| 20 | const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson); | ||
| 19 | 21 | ||
| 20 | interface ProjectVcsConfigFile { | 22 | type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type; |
| 21 | readonly vcs?: | ||
| 22 | | { | ||
| 23 | readonly kind?: VcsDriverKindType | undefined; | ||
| 24 | } | ||
| 25 | | undefined; | ||
| 26 | readonly vcsKind?: VcsDriverKindType | undefined; | ||
| 27 | } | ||
| 28 | 23 | ||
| 29 | export interface VcsProjectConfigResolveInput { | 24 | export 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 | ||
| 48 | function parseConfig(raw: string): ProjectVcsConfigFile | null { | 43 | function 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 | ||
| 57 | export const make = Effect.fn("makeVcsProjectConfig")(function* () { | 47 | export 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({ |