diff --git a/packages/effect-acp/package.json b/packages/effect-acp/package.json index 4455dd460e7..5ecd05009fb 100644 --- a/packages/effect-acp/package.json +++ b/packages/effect-acp/package.json @@ -38,6 +38,7 @@ "generate": "node scripts/generate.ts" }, "dependencies": { + "@t3tools/shared": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/effect-acp/scripts/generate.test.ts b/packages/effect-acp/scripts/generate.test.ts new file mode 100644 index 00000000000..e11cb460ecf --- /dev/null +++ b/packages/effect-acp/scripts/generate.test.ts @@ -0,0 +1,175 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as Generator from "./generate.ts"; + +const isDownloadError = Schema.is(Generator.AcpGeneratorDownloadError); +const isDownloadFileError = Schema.is(Generator.AcpGeneratorDownloadFileError); +const isDocumentDecodeError = Schema.is(Generator.AcpGeneratorDocumentDecodeError); +const isFormatExitError = Schema.is(Generator.AcpGeneratorFormatExitError); +const isSchemaNameParseError = Schema.is(Generator.AcpGeneratorSchemaNameParseError); +const isSchemaValueDeclarationMissingError = Schema.is( + Generator.AcpGeneratorSchemaValueDeclarationMissingError, +); + +const httpClient = (response: Response) => + HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, response))); + +function processHandle(exitCode: number) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +describe("ACP schema generator errors", () => { + it.effect("retains safe URL diagnostics, output path, and HTTP cause when a download fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "acp-generator-test-" }); + const url = + "https://generator-user:generator-password@example.test/private/schema.json?token=generator-secret#fragment"; + const outputPath = `${directory}/schema.json`; + const error = yield* Generator.downloadFile(url, outputPath).pipe( + Effect.provideService( + HttpClient.HttpClient, + httpClient(new Response("unavailable", { status: 503 })), + ), + Effect.flip, + ); + + assert(isDownloadError(error)); + expect(error).toMatchObject({ + urlInputLength: url.length, + urlProtocol: "https:", + urlHostname: "example.test", + }); + expect(error).not.toHaveProperty("url"); + expect(error.outputPath).toBe(outputPath); + expect(error.stage).toBe("request"); + expect(error.cause).toBeDefined(); + expect(error.message).toContain(outputPath); + const { cause: _, ...directDiagnostics } = error; + expect(directDiagnostics).not.toHaveProperty("url"); + expect(directDiagnostics.urlProtocol).toBe("https:"); + expect(directDiagnostics.urlHostname).toBe("example.test"); + expect(error.message).not.toMatch( + /generator-user|generator-password|private|token|generator-secret|fragment/, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("retains download context when the response cannot be written", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const outputPath = yield* fs.makeTempDirectoryScoped({ prefix: "acp-generator-test-" }); + const url = "https://example.test/schema.json"; + const error = yield* Generator.downloadFile(url, outputPath).pipe( + Effect.provideService( + HttpClient.HttpClient, + httpClient(new Response("{}", { status: 200 })), + ), + Effect.flip, + ); + + assert(isDownloadFileError(error)); + expect(error).toMatchObject({ + urlInputLength: url.length, + urlProtocol: "https:", + urlHostname: "example.test", + }); + expect(error).not.toHaveProperty("url"); + expect(error.outputPath).toBe(outputPath); + expect(error.stage).toBe("write-file"); + expect(error.cause).toBeDefined(); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("adds source file context to upstream document decode failures", () => + Effect.gen(function* () { + const schemaPath = "/tmp/upstream-schema.json"; + const schemaError = yield* Generator.decodeUpstreamSchemaDocument( + "not-json", + schemaPath, + ).pipe(Effect.flip); + assert(isDocumentDecodeError(schemaError)); + expect(schemaError.document).toBe("schema"); + expect(schemaError.filePath).toBe(schemaPath); + expect(schemaError.cause).toBeDefined(); + + const metadataPath = "/tmp/upstream-meta.json"; + const metadataError = yield* Generator.decodeMetaDocument("not-json", metadataPath).pipe( + Effect.flip, + ); + assert(isDocumentDecodeError(metadataError)); + expect(metadataError.document).toBe("metadata"); + expect(metadataError.filePath).toBe(metadataPath); + expect(metadataError.cause).toBeDefined(); + }), + ); + + it.effect("reports formatter commands and nonzero exit codes structurally", () => { + let spawned: ChildProcess.StandardCommand | undefined; + const spawner = ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) { + spawned = command; + } + return Effect.succeed(processHandle(23)); + }); + + return Effect.gen(function* () { + const generatedDir = "/tmp/acp-generated"; + const error = yield* Generator.formatGeneratedFiles(generatedDir).pipe(Effect.flip); + + assert(isFormatExitError(error)); + expect(error.command).toBe("bun"); + expect(error.argumentCount).toBe(2); + expect(error).not.toHaveProperty("args"); + expect(error.generatedDir).toBe(generatedDir); + expect(error.exitCode).toBe(23); + expect(error.message).toContain("23"); + expect(spawned?.command).toBe("bun"); + expect(spawned?.args).toEqual(["oxfmt", generatedDir]); + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + }); + + it.effect("returns malformed generated schema declarations as typed failures", () => + Effect.gen(function* () { + const missingValueError = yield* Generator.collectSchemaEntries( + "export type Session = string;", + ).pipe(Effect.flip); + assert(isSchemaValueDeclarationMissingError(missingValueError)); + expect(missingValueError).toMatchObject({ + lineIndex: 0, + typeDeclarationLength: 29, + nextLinePresent: false, + }); + expect(missingValueError).not.toHaveProperty("typeDeclaration"); + + const nameParseError = yield* Generator.collectSchemaEntries( + "export type @ = string;\nexport const invalid = Schema.String;", + ).pipe(Effect.flip); + assert(isSchemaNameParseError(nameParseError)); + expect(nameParseError).toMatchObject({ + lineIndex: 0, + typeDeclarationLength: 23, + }); + expect(nameParseError).not.toHaveProperty("typeDeclaration"); + }), + ); +}); diff --git a/packages/effect-acp/scripts/generate.ts b/packages/effect-acp/scripts/generate.ts index 1fe8e2edf8a..de88d5640e3 100644 --- a/packages/effect-acp/scripts/generate.ts +++ b/packages/effect-acp/scripts/generate.ts @@ -3,6 +3,7 @@ import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator"; +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -15,11 +16,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; const CURRENT_SCHEMA_RELEASE = "v0.11.3"; -interface GenerateCommandError { - readonly _tag: "GenerateCommandError"; - readonly message: string; -} - interface GeneratedPaths { readonly generatedDir: string; readonly upstreamSchemaPath: string; @@ -28,6 +24,118 @@ interface GeneratedPaths { readonly metaOutputPath: string; } +const urlDiagnosticsSchema = { + urlInputLength: Schema.Number, + urlProtocol: Schema.optionalKey(Schema.String), + urlHostname: Schema.optionalKey(Schema.String), +}; + +function urlDiagnosticFields(url: string) { + const diagnostics = getUrlDiagnostics(url); + return { + urlInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { urlProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { urlHostname: diagnostics.hostname }), + }; +} + +export class AcpGeneratorDownloadError extends Schema.TaggedErrorClass()( + "AcpGeneratorDownloadError", + { + ...urlDiagnosticsSchema, + outputPath: Schema.String, + stage: Schema.Literals(["request", "read-response"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + const source = this.urlHostname === undefined ? "the configured source" : this.urlHostname; + return `Failed to download the ACP generator input from ${source} to ${this.outputPath} during ${this.stage}.`; + } +} + +export class AcpGeneratorDownloadFileError extends Schema.TaggedErrorClass()( + "AcpGeneratorDownloadFileError", + { + ...urlDiagnosticsSchema, + outputPath: Schema.String, + stage: Schema.Literals(["create-directory", "write-file"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to store the ACP generator download at ${this.outputPath} during ${this.stage}.`; + } +} + +export class AcpGeneratorDocumentDecodeError extends Schema.TaggedErrorClass()( + "AcpGeneratorDocumentDecodeError", + { + document: Schema.Literals(["schema", "metadata"]), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode the upstream ACP ${this.document} document at ${this.filePath}.`; + } +} + +export class AcpGeneratorFormatProcessError extends Schema.TaggedErrorClass()( + "AcpGeneratorFormatProcessError", + { + stage: Schema.Literals(["spawn", "wait-for-exit"]), + command: Schema.String, + argumentCount: Schema.Number, + generatedDir: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `ACP generator formatting command ${this.command} failed during ${this.stage} for ${this.generatedDir}.`; + } +} + +export class AcpGeneratorFormatExitError extends Schema.TaggedErrorClass()( + "AcpGeneratorFormatExitError", + { + command: Schema.String, + argumentCount: Schema.Number, + generatedDir: Schema.String, + exitCode: Schema.Number, + }, +) { + override get message(): string { + return `ACP generator formatting command ${this.command} exited with code ${this.exitCode} for ${this.generatedDir}.`; + } +} + +export class AcpGeneratorSchemaValueDeclarationMissingError extends Schema.TaggedErrorClass()( + "AcpGeneratorSchemaValueDeclarationMissingError", + { + lineIndex: Schema.Number, + typeDeclarationLength: Schema.Number, + nextLinePresent: Schema.Boolean, + nextLineLength: Schema.optional(Schema.Number), + }, +) { + override get message(): string { + return `Generated ACP schema type declaration at line ${this.lineIndex + 1} has no following value declaration.`; + } +} + +export class AcpGeneratorSchemaNameParseError extends Schema.TaggedErrorClass()( + "AcpGeneratorSchemaNameParseError", + { + lineIndex: Schema.Number, + typeDeclarationLength: Schema.Number, + }, +) { + override get message(): string { + return `Could not extract an ACP schema name from generated declaration at line ${this.lineIndex + 1}.`; + } +} + const UpstreamJsonSchemaSchema = Schema.Struct({ $defs: Schema.Record(Schema.String, Schema.Json), }); @@ -66,18 +174,57 @@ const ensureGeneratedDir = Effect.fn("ensureGeneratedDir")(function* () { yield* fs.makeDirectory(generatedDir, { recursive: true }); }); -const downloadFile = Effect.fn("downloadFile")(function* (url: string, outputPath: string) { +export const downloadFile = Effect.fn("downloadFile")(function* (url: string, outputPath: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true }); + yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true }).pipe( + Effect.mapError( + (cause) => + new AcpGeneratorDownloadFileError({ + ...urlDiagnosticFields(url), + outputPath, + stage: "create-directory", + cause, + }), + ), + ); - const text = yield* HttpClient.get(url).pipe( + const response = yield* HttpClient.get(url).pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap((response) => response.text), + Effect.mapError( + (cause) => + new AcpGeneratorDownloadError({ + ...urlDiagnosticFields(url), + outputPath, + stage: "request", + cause, + }), + ), + ); + const text = yield* response.text.pipe( + Effect.mapError( + (cause) => + new AcpGeneratorDownloadError({ + ...urlDiagnosticFields(url), + outputPath, + stage: "read-response", + cause, + }), + ), ); - yield* fs.writeFileString(outputPath, text); + yield* fs.writeFileString(outputPath, text).pipe( + Effect.mapError( + (cause) => + new AcpGeneratorDownloadFileError({ + ...urlDiagnosticFields(url), + outputPath, + stage: "write-file", + cause, + }), + ), + ); }); const downloadSchemas = Effect.fn("downloadSchemas")(function* (tag: string) { @@ -85,14 +232,14 @@ const downloadSchemas = Effect.fn("downloadSchemas")(function* (tag: string) { const fs = yield* FileSystem.FileSystem; const baseUrl = `https://github.com/agentclientprotocol/agent-client-protocol/releases/download/${tag}`; - yield* downloadFile(`${baseUrl}/schema.unstable.json`, upstreamSchemaPath); - yield* downloadFile(`${baseUrl}/meta.unstable.json`, upstreamMetaPath); - yield* Effect.addFinalizer(() => Effect.all([fs.remove(upstreamSchemaPath), fs.remove(upstreamMetaPath)]).pipe( Effect.ignoreCause({ log: true }), ), ); + + yield* downloadFile(`${baseUrl}/schema.unstable.json`, upstreamSchemaPath); + yield* downloadFile(`${baseUrl}/meta.unstable.json`, upstreamMetaPath); }); const readFileString = Effect.fn("readJsonFile")(function* (filePath: string) { @@ -100,6 +247,69 @@ const readFileString = Effect.fn("readJsonFile")(function* (filePath: string) { return yield* fs.readFileString(filePath); }); +export const decodeUpstreamSchemaDocument = Effect.fn("decodeUpstreamSchemaDocument")(function* ( + raw: string, + filePath: string, +) { + return yield* decodeUpstreamSchema(raw).pipe( + Effect.mapError( + (cause) => new AcpGeneratorDocumentDecodeError({ document: "schema", filePath, cause }), + ), + ); +}); + +export const decodeMetaDocument = Effect.fn("decodeMetaDocument")(function* ( + raw: string, + filePath: string, +) { + return yield* decodeMetaJson(raw).pipe( + Effect.mapError( + (cause) => new AcpGeneratorDocumentDecodeError({ document: "metadata", filePath, cause }), + ), + ); +}); + +export const formatGeneratedFiles = Effect.fn("formatGeneratedFiles")(function* ( + generatedDir: string, +) { + const command = "bun"; + const args = ["oxfmt", generatedDir]; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(ChildProcess.make(command, args)).pipe( + Effect.mapError( + (cause) => + new AcpGeneratorFormatProcessError({ + stage: "spawn", + command, + argumentCount: args.length, + generatedDir, + cause, + }), + ), + ); + const exitCode = yield* child.exitCode.pipe( + Effect.mapError( + (cause) => + new AcpGeneratorFormatProcessError({ + stage: "wait-for-exit", + command, + argumentCount: args.length, + generatedDir, + cause, + }), + ), + ); + + if (exitCode !== 0) { + return yield* new AcpGeneratorFormatExitError({ + command, + argumentCount: args.length, + generatedDir, + exitCode, + }); + } +}); + const writeGeneratedFiles = Effect.fn("writeGeneratedFiles")(function* ( schemaOutput: string, metaOutput: string, @@ -111,9 +321,7 @@ const writeGeneratedFiles = Effect.fn("writeGeneratedFiles")(function* ( yield* fs.writeFileString(metaOutputPath, metaOutput); }); -function collectSchemaEntries( - chunk: string, -): ReadonlyArray<{ readonly name: string; readonly code: string }> { +export const collectSchemaEntries = Effect.fn("collectSchemaEntries")(function* (chunk: string) { const lines = chunk .split("\n") .map((line) => line.trim()) @@ -128,12 +336,20 @@ function collectSchemaEntries( const constLine = lines[index + 1]; if (!constLine?.startsWith("export const ")) { - throw new Error(`Malformed generator output near: ${typeLine}`); + return yield* new AcpGeneratorSchemaValueDeclarationMissingError({ + lineIndex: index, + typeDeclarationLength: typeLine.length, + nextLinePresent: constLine !== undefined, + ...(constLine === undefined ? {} : { nextLineLength: constLine.length }), + }); } const match = /^export type ([A-Za-z0-9_]+)/.exec(typeLine); if (!match?.[1]) { - throw new Error(`Could not extract schema name from: ${typeLine}`); + return yield* new AcpGeneratorSchemaNameParseError({ + lineIndex: index, + typeDeclarationLength: typeLine.length, + }); } entries.push({ @@ -144,7 +360,7 @@ function collectSchemaEntries( } return entries; -} +}); function normalizeNullableTypes(value: Schema.Json): Schema.Json { if (Array.isArray(value)) { @@ -204,10 +420,10 @@ const generateSchemas = Effect.fn("generateSchemas")(function* (skipDownload: bo yield* downloadSchemas(CURRENT_SCHEMA_RELEASE); } - const upstreamSchema = yield* readFileString(upstreamSchemaPath).pipe( - Effect.flatMap(decodeUpstreamSchema), - ); - const upstreamMeta = yield* readFileString(upstreamMetaPath).pipe(Effect.flatMap(decodeMetaJson)); + const upstreamSchemaRaw = yield* readFileString(upstreamSchemaPath); + const upstreamSchema = yield* decodeUpstreamSchemaDocument(upstreamSchemaRaw, upstreamSchemaPath); + const upstreamMetaRaw = yield* readFileString(upstreamMetaPath); + const upstreamMeta = yield* decodeMetaDocument(upstreamMetaRaw, upstreamMetaPath); const normalizedDefinitions = Object.fromEntries( Object.entries(upstreamSchema.$defs).map(([name, schema]) => [ name, @@ -227,7 +443,8 @@ const generateSchemas = Effect.fn("generateSchemas")(function* (skipDownload: bo const output = generator.generate("openapi-3.1", normalizedDefinitions as never, false).trim(); if (output.length > 0) { - for (const entry of collectSchemaEntries(output)) { + const schemaEntries = yield* collectSchemaEntries(output); + for (const entry of schemaEntries) { if (!generatedEntries.has(entry.name)) { generatedEntries.set(entry.name, entry.code); } @@ -264,18 +481,7 @@ const generateSchemas = Effect.fn("generateSchemas")(function* (skipDownload: bo ); const { generatedDir } = yield* getGeneratedPaths(); - yield* Effect.service(ChildProcessSpawner.ChildProcessSpawner).pipe( - Effect.flatMap((spawner) => spawner.spawn(ChildProcess.make("bun", ["oxfmt", generatedDir]))), - Effect.flatMap((child) => child.exitCode), - Effect.tap((code) => - code === 0 - ? Effect.void - : Effect.fail({ - _tag: "GenerateCommandError", - message: `oxfmt failed with exit code ${code}`, - }), - ), - ); + yield* formatGeneratedFiles(generatedDir); }); const generateCommand = Command.make( @@ -292,8 +498,10 @@ const runtimeLayer = Layer.mergeAll( FetchHttpClient.layer, ); -Command.run(generateCommand, { version: "0.0.0" }).pipe( - Effect.scoped, - Effect.provide(runtimeLayer), - NodeRuntime.runMain, -); +if (import.meta.main) { + Command.run(generateCommand, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(runtimeLayer), + NodeRuntime.runMain, + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 192723b7663..acd73bf83aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -736,6 +736,9 @@ importers: packages/effect-acp: dependencies: + '@t3tools/shared': + specifier: workspace:* + version: link:../shared effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)