diff --git a/.changeset/effect-filesystem-tempdirs.md b/.changeset/effect-filesystem-tempdirs.md new file mode 100644 index 000000000..bcef6fce6 --- /dev/null +++ b/.changeset/effect-filesystem-tempdirs.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Stage the throwaway artifact behind `listMcp`, `invokeMcp`, `runMcp`, `listHooks`, and `simulateHook` (when no `artifact` is given) and the Codex validator's schema-generation output in Effect `FileSystem` temporary directories that are removed on every exit path, including interruption; results, diagnostics, and thrown errors are unchanged. `agent-bundle` now depends on `@effect/platform-node-shared` (adds `@types/node`, `@types/ws`, `undici-types` to a consumer install, ≈4 MB). (#508) diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index bad3b1a35..447201108 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -217,9 +217,19 @@ Re-evaluated 2026-09-03 against `effect@4.0.0-rc.112` + `@effect/platform-node@4.0.0-rc.112`; **decision = adopted for ordinary filesystem I/O and path operations** (the 2026-09-01 decline is superseded). `FileSystem.FileSystem` and `Path.Path` from the `effect` package are the -sanctioned way for framework code to touch the filesystem; the Node -implementations come from `@effect/platform-node` (`NodeServices.layer`, or -the narrower `NodeFileSystem.layer` / `NodePath.layer`). The API gap that +sanctioned way for framework code to touch the filesystem. The Node +implementations come from `@effect/platform-node` (`NodeServices.layer`) in +`create-agent-bundle`, which bundles its dependencies, and from +`@effect/platform-node-shared` (the package that implements +`NodeFileSystem` / `NodePath` / `NodeChildProcessSpawner` / `NodeStdio` / +`NodeTerminal` / `NodeCrypto`; `@effect/platform-node`'s modules are +re-exports of it) in `agent-bundle`, which every consumer installs: +`@effect/platform-node@rc.112` would add `undici`, `mime`, and — through a +non-optional `redis` peer that npm auto-installs — a Redis client (+23 MB, ++17 packages) to each consumer install. `agent-bundle`'s `platformLayer` +composes the same six services the same way `NodeServices.layer` does. The +`ws` / `@types/ws` / `@types/node` dependencies of `platform-node-shared` +(≈4 MB) do land in consumer installs. The API gap that motivated the decline is still real and is what the keep-raw list below encodes: the pinned `FileSystem` has no `lstat`, `OpenFlag` accepts only string flags (no `O_NOFOLLOW`), and there is no directory fsync. @@ -237,16 +247,26 @@ contract). modules. `fromFileUrl` fails with `BadArgument`; `Effect.orDie` it when the URL is built from `import.meta.url`. - Temporary directories whose lifetime ends with the enclosing operation: - `makeTempDirectoryScoped` inside `Effect.scoped`, replacing `mkdtemp` + - `try`/`finally` `rm`. **Not** when ownership of the directory is + in `agent-bundle`, `withTempDirectory(options, use)` from + `src/effect/platform.ts`, the bracket that reproduces `mkdtemp` + + `try`/`finally` `rm(dir, { recursive: true, force: true })` exactly — + `force`, cleanup failure as a typed `PlatformError` that wins over the + operation's failure, cleanup on interruption. Not + `fs.makeTempDirectoryScoped` in library code: the rc.112 finalizer removes + without `force` and `orDie`s, so an operation that deleted its own staging + directory would fail an already successful call, and a real cleanup error + would surface as the `PlatformError` wrapper (scope finalizers cannot fail + typed). Tests may use `makeTempDirectoryScoped` for fixtures. + **Not** when ownership of the directory is transferred to a longer-lived object (the MCP session plugin-data dir in `dev/mcp-session/mcp-session-service.ts`): a scoped temp is removed when the scope closes, which is too early there. - File handles whose use is bounded by one program: scoped `open`. -- Layer wiring: one composition root per process. The first-party CLI and - the scaffolder provide `NodeServices.layer` immediately before their - boundary's `runPromise`; the dev server (phase 2) gets one - `makeScopedEffectRuntime(NodeServices.layer)` in `startDevServer`, disposed +- Layer wiring: one composition root per process. The scaffolder provides + `NodeServices.layer` immediately before its boundary's `runPromise`; + `agent-bundle`'s public API functions provide `platformLayer` through + `runWithPlatform`; the dev server (phase 2) gets one + `makeScopedEffectRuntime(platformLayer)` in `startDevServer`, disposed from the session's `close`. Never provide a platform layer deep inside library code. - Errors: `PlatformError` flows through the Effect error channel and is @@ -300,6 +320,21 @@ provides `NodeServices.layer` once. Measured on rc.112 (bundled by Rslib, 110.2 kB; `--help` cold start ≈40 ms → ≈65 ms. `undici` is not pulled into the bundle. +`packages/agent-bundle/src/effect/platform.ts` owns the framework's platform +layer: `platformLayer` (the `NodeServices` union composed from +`@effect/platform-node-shared`), `withTempDirectory`, +`unwrapPlatformError`, and `runWithPlatform`, which provides the layer and unwraps `PlatformError` +before handing off to `boundary.ts`'s `runPromise`. It is the only module +that imports `effect/PlatformError`: `boundary.ts` is bundled into every +emitted hook wrapper, and the error class would drag `Data.TaggedError` into +each one (measured: +12 kB per hook). Phase-1 callers are the throwaway +artifact in `api.ts` (`listMcp` / `invokeMcp` / `runMcp` / `listHooks` / +`simulateHook` without `artifact`) and the Codex validator's +schema-generation directory, both through `withTempDirectory`. Emitted +artifacts, hook wrappers, and compiler hot paths +never import this module; the dev server picks it up in phase 2 through +`makeScopedEffectRuntime(platformLayer)`. + ## Effect Schema wire contracts (Schema projections) Evaluated 2026-09-01 against `effect@4.0.0-rc.112` for the wire-contract @@ -376,7 +411,7 @@ wire contracts](#effect-schema-wire-contracts-schema-projections). | Module | Adopted in | Re-verify | | --- | --- | --- | | `effect/unstable/reactivity` (+ `@effect/atom-react` bindings) | Workbench Agent Document panel (#105 phase 1) and route editor (#105 phase 2) | re-pin bumps @effect/atom-react in lockstep; re-run disposal regression + bundle measurement; stream-backed derived atoms stay banned until the rc.112 disposal fix ships | -| `@effect/platform-node` (`NodeServices.layer`; `FileSystem` / `Path` services live in `effect`) | **adopted** (2026-09-03) for ordinary I/O — `create-agent-bundle` scaffolder (phase 1); see [Effect platform services](#effect-platform-services-effectplatform-node) for the keep-raw list | re-pin bumps `@effect/platform-node` in lockstep with `effect`; re-check whether `lstat` / `O_NOFOLLOW` / directory fsync landed (would shrink the keep-raw list) and the `runMain` 130/143 exit contract | +| `@effect/platform-node` (`NodeServices.layer`, `create-agent-bundle`) and `@effect/platform-node-shared` (`agent-bundle`'s `platformLayer`); `FileSystem` / `Path` services live in `effect` | **adopted** (2026-09-03) for ordinary I/O — `create-agent-bundle` scaffolder and the `agent-bundle` temp directories in `api.ts` / the Codex validator (phase 1); see [Effect platform services](#effect-platform-services-effectplatform-node) for the keep-raw list and the consumer-footprint reason for the split | re-pin bumps both in lockstep with `effect`; re-check whether `@effect/platform-node` still forces a `redis` peer (if it stops, `agent-bundle` can move to `NodeServices.layer`); re-check whether `lstat` / `O_NOFOLLOW` / directory fsync landed (would shrink the keep-raw list) and the `runMain` 130/143 exit contract | | `Schema` / `SchemaAST` / `SchemaParser` projections (`toType` / `toEncoded`) for wire contracts | **declined** (2026-09-01) | revisit at Effect GA or on the first encoded/decoded-divergent wire contract; re-pin re-checks the projections API and the `onExcessProperty` parse-option default | ## Language service @@ -408,7 +443,8 @@ must not regress it: `pnpm bench:hook-cold-start -- --check`. `packages/agent-bundle/package.json`, `packages/workbench/package.json`, and `packages/create-agent-bundle/package.json`. 2. Synchronize `@effect/atom-react` in `packages/workbench/package.json` and - `@effect/platform-node` in `packages/create-agent-bundle/package.json` to + `@effect/platform-node` in `packages/create-agent-bundle/package.json` and + `@effect/platform-node-shared` in `packages/agent-bundle/package.json` to the same RC; re-run the Workbench disposal regression test and production bundle measurement (rsbuild size table), and re-measure the scaffolder bundle (`pnpm --filter create-agent-bundle build` prints the size table). @@ -432,5 +468,5 @@ soon as the trigger fires and retire the row. | --- | --- | --- | --- | | 2026-09-03 | `@rslib/core` **`0.23.2`** — root, `packages/agent-bundle`, `packages/rsc-runtime`, `packages/create-agent-bundle` devDependencies. Stays on `0.23.x` until rslib 1.0 leaves rc. | `npm view @rslib/core dist-tags`: `latest` `0.23.2`, `rc` `1.0.0-rc.2`, `beta` `1.0.0-beta.3`, `canary` `0.20.0-canary-202603101`. | `latest` becomes `1.x`. Bump all four pins in one chore; re-run `pnpm build`, `lint:package`, `check:release`, and the Rslib-driven compile tests. | | 2026-09-03 | `effect-rstest` **pkg.pr.new preview `e5f8d5f`** (`https://pkg.pr.new/ScriptedAlchemy/effect-rstest@e5f8d5f`) — `packages/agent-bundle`, `packages/rsc-runtime`, `packages/create-agent-bundle` devDependencies (three pins). Needs a real release pin once published. | `npm view effect-rstest versions`: **E404 — not published to npm** (no versions, no dist-tags). | First npm publish of `effect-rstest`. Replace all three preview URLs with the exact published version, refresh `pnpm-lock.yaml`, re-run `pnpm test:unit` (`it.effect` / `it.live` suites). | -| 2026-09-03 | `effect` **`4.0.0-rc.112`** (`packages/agent-bundle`, `packages/rsc-runtime`, `packages/workbench`, `packages/create-agent-bundle`), `@effect/atom-react` `4.0.0-rc.112` (`packages/workbench`), `@effect/platform-node` `4.0.0-rc.112` (`packages/create-agent-bundle`), `@effect/language-service` `0.87.2` and `@effect/tsgo` `0.39.0` (root). Auto re-pin in lockstep + `repos/effect` subtree + Workbench atom phase 4 unblock (stream-backed derived atoms) once the post-rc.112 disposal fix ships. | `npm view effect dist-tags`: `rc` **`4.0.0-rc.112`** (unchanged), `beta` `4.0.0-beta.107`, `latest` `3.22.1`. `@effect/atom-react`: `rc` `4.0.0-rc.112`. `@effect/language-service`: `latest` `0.87.2`. `@effect/tsgo`: `latest` `0.39.1` (patch ahead of the `0.39.0` pin; rides the lockstep chore). | `effect@rc` advances past `4.0.0-rc.112`. Run the re-pin chore steps 1–6 above, bumping `effect`, `@effect/atom-react`, `@effect/language-service`, and `@effect/tsgo` together, then lift the stream-backed derived-atom ban in the Workbench if the disposal fix is in the new RC. | +| 2026-09-03 | `effect` **`4.0.0-rc.112`** (`packages/agent-bundle`, `packages/rsc-runtime`, `packages/workbench`, `packages/create-agent-bundle`), `@effect/atom-react` `4.0.0-rc.112` (`packages/workbench`), `@effect/platform-node` `4.0.0-rc.112` (`packages/create-agent-bundle`), `@effect/platform-node-shared` `4.0.0-rc.112` (`packages/agent-bundle`), `@effect/language-service` `0.87.2` and `@effect/tsgo` `0.39.0` (root). Auto re-pin in lockstep + `repos/effect` subtree + Workbench atom phase 4 unblock (stream-backed derived atoms) once the post-rc.112 disposal fix ships. | `npm view effect dist-tags`: `rc` **`4.0.0-rc.112`** (unchanged), `beta` `4.0.0-beta.107`, `latest` `3.22.1`. `@effect/atom-react`: `rc` `4.0.0-rc.112`. `@effect/language-service`: `latest` `0.87.2`. `@effect/tsgo`: `latest` `0.39.1` (patch ahead of the `0.39.0` pin; rides the lockstep chore). | `effect@rc` advances past `4.0.0-rc.112`. Run the re-pin chore steps 1–6 above, bumping `effect`, `@effect/atom-react`, `@effect/language-service`, and `@effect/tsgo` together, then lift the stream-backed derived-atom ban in the Workbench if the disposal fix is in the new RC. | | 2026-09-03 | Agent Plugins specification **`1.0.0`** — `packages/agent-bundle/src/adapters/schemas/portable/{plugin,mcp}.schema.json` + `PROVENANCE.json` (spec repo `agentplugins/agent-plugins-spec` @ `ff8ab5e392cc87bd88d87c060815a87490e51003`, 2026-08-19), portable `adapterRevision` `1.8.0`, pins in `tests/adapter-metadata.test.ts`. Spec watch for #426; not an npm pin, so re-verify with `curl`/`gh api`, not `npm view`. | Live `https://agent-plugins.org/schemas/1.0.0/{plugin,mcp}.schema.json` rehash to the pinned sha256 (1805 / 3408 bytes). Repo `main` HEAD unchanged at the pinned commit; **no tags, no GitHub releases**. `spec/1.1.0.md` is "Status: Working Draft" (started 2026-08-15, `a2afd7ec`); in-repo `schemas/1.1.0/*.schema.json` differ from 1.0.0 only in the `$id`/`const`/`description` version strings; `https://agent-plugins.org/schemas/1.1.0/*.schema.json` → 404. Observed latest published version: **1.0.0**. | `spec/1.1.0.md` (or later) flips to "Published" **and** `agent-plugins.org/schemas//` serves both schemas. Re-pin under `schemas/portable/` with a dated `PROVENANCE.json` (sha/bytes/date/commit), bump the portable `adapterRevision`, refresh the metadata pins, run `pnpm test:unit` (portable adapter + plugin-validation suites) and `pnpm test:host-install:build`, and add a capability row per additive field. | diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index c9e47b5d6..5dfdeb2c9 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -97,6 +97,7 @@ "@rsbuild/plugin-react": "2.1.0", "@rslib/core": "0.23.2", "@rslint/core": "0.8.2", + "@effect/platform-node-shared": "4.0.0-rc.112", "@rstackjs/load-config": "0.1.2", "acorn": "8.18.0", "ajv": "8.20.0", diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 8314b5808..560d39358 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -1,8 +1,9 @@ import { execFile as executeFile } from 'node:child_process'; -import { mkdtemp, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; +import { Effect } from 'effect'; + import { capabilityIsSupported, unavailableCapability } from './adapters/capability-state.ts'; import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts'; import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts'; @@ -162,6 +163,11 @@ import { type McpListOptions, type McpListResult, } from './services/mcp-service.ts'; +// Imported after the service modules on purpose: the position of +// `effect/lift.ts` in the module graph fixes its position in the emitted +// hook bundles, and this order keeps those bundles byte-identical. +import { runWithPlatform, withTempDirectory } from './effect/platform.ts'; +import { liftPromise } from './effect/lift.ts'; export { compareInstalledHostContract, @@ -569,21 +575,24 @@ const temporaryArtifact = async ( ): Promise => { if (options.artifact !== undefined) return operation(resolve(options.artifact)); - const artifact = await mkdtemp(join(resolve(options.root), '.agent-bundle-artifact-')); - try { - await build({ - configPath: options.configPath, - logger: options.logger, - mode: options.mode, - output: artifact, - registry: options.registry, - root: options.root, - targets: options.targets, - }); - return await operation(artifact); - } finally { - await rm(artifact, { force: true, recursive: true }); - } + // The staging directory lives exactly as long as the operation: created + // next to the project (same filesystem as a real `artifact/`), removed + // when the build or the operation settles, success, failure or interrupt. + return runWithPlatform(withTempDirectory( + { directory: resolve(options.root), prefix: '.agent-bundle-artifact-' }, + (artifact) => Effect.gen(function* () { + yield* liftPromise(() => build({ + configPath: options.configPath, + logger: options.logger, + mode: options.mode, + output: artifact, + registry: options.registry, + root: options.root, + targets: options.targets, + })); + return yield* liftPromise(() => operation(artifact)); + }), + )); }; type HostValidatedTarget = 'claude' | 'codex' | 'cursor' | 'plugin' | 'portable'; diff --git a/packages/agent-bundle/src/effect/boundary.ts b/packages/agent-bundle/src/effect/boundary.ts index 1f6392739..56e3bedd3 100644 --- a/packages/agent-bundle/src/effect/boundary.ts +++ b/packages/agent-bundle/src/effect/boundary.ts @@ -25,6 +25,11 @@ import { CodedError } from '../core/errors.ts'; * diagnostic carrier `DiagnosticError`) ride the fail channel as ordinary * `Error` instances and rethrow unchanged, so callers of `runPromise` see * exactly the same types they see today. + * + * Platform failures (`PlatformError` from `FileSystem` / `Path`) are mapped + * by `src/effect/platform.ts`, which owns the platform layer, not here: this + * module is bundled into every emitted hook wrapper, and importing + * `effect/PlatformError` would ship `Data.TaggedError` to every host hook. */ export interface RunPromiseOptions { diff --git a/packages/agent-bundle/src/effect/platform.ts b/packages/agent-bundle/src/effect/platform.ts new file mode 100644 index 000000000..cc3aaffde --- /dev/null +++ b/packages/agent-bundle/src/effect/platform.ts @@ -0,0 +1,103 @@ +import * as NodeChildProcessSpawner from '@effect/platform-node-shared/NodeChildProcessSpawner'; +import * as NodeCrypto from '@effect/platform-node-shared/NodeCrypto'; +import * as NodeFileSystem from '@effect/platform-node-shared/NodeFileSystem'; +import * as NodePath from '@effect/platform-node-shared/NodePath'; +import * as NodeStdio from '@effect/platform-node-shared/NodeStdio'; +import * as NodeTerminal from '@effect/platform-node-shared/NodeTerminal'; +import { Effect, FileSystem, Layer } from 'effect'; +import { PlatformError } from 'effect/PlatformError'; + +import { runPromise, type RunPromiseOptions } from './boundary.ts'; + +/** + * The Node platform layer for this package's Effect programs: the same + * services, composed the same way, as `@effect/platform-node`'s + * `NodeServices.layer`, but built from `@effect/platform-node-shared`, the + * package that actually implements them (`@effect/platform-node`'s + * `NodeFileSystem` etc. are re-exports). `agent-bundle` is a runtime + * dependency of every consumer, and `@effect/platform-node@rc.112` would add + * `undici`, `mime`, and — through a non-optional `redis` peer that npm + * auto-installs — a Redis client (+23 MB, +17 packages) to each install for + * a filesystem layer. `create-agent-bundle`, which bundles its + * dependencies, keeps `NodeServices.layer`. + * + * Provided at Promise edges only — the public API functions in `api.ts` and + * the exported host validators are the composition roots for programmatic + * callers — never deep inside library code. The dev server (phase 2) reuses + * `platformLayer` through one `makeScopedEffectRuntime(platformLayer)` + * owned by `startDevServer` and disposed from the session's `close`, and + * `unwrapPlatformError` on the programs it runs. + * + * This module, not `boundary.ts`, imports `effect/PlatformError`: the + * boundary is bundled into every emitted hook wrapper, and the error class + * would drag `Data.TaggedError` into each of them. Emitted artifacts and + * compiler hot paths never import this module. See + * `docs/effect-conventions.md`, "Effect platform services". + */ +export const platformLayer = Layer.provideMerge( + NodeChildProcessSpawner.layer, + Layer.mergeAll( + NodeFileSystem.layer, + NodeCrypto.layer, + NodePath.layer, + NodeStdio.layer, + NodeTerminal.layer, + ), +); + +/** `ChildProcessSpawner | Crypto | FileSystem | Path | Stdio | Terminal` — the `NodeServices` union. */ +export type PlatformServices = Layer.Success; + +/** + * A `PlatformError` carries the `NodeJS.ErrnoException` that `node:fs` + * threw; a site that moved onto `FileSystem` keeps throwing that same + * `ENOENT ...` error. Programs that own an AB#### diagnostic for a platform + * failure map it to `DiagnosticError` themselves, before this runs. + */ +export const unwrapPlatformError = (error: E): Exclude | Error => + error instanceof PlatformError + ? (error.cause instanceof Error ? error.cause : error) + : (error as Exclude); + +/** + * `const dir = await mkdtemp(...); try { return await use(dir) } finally + * { await rm(dir, { recursive: true, force: true }) }` as an Effect, with + * the same contract the two `try`/`finally` sites had before they moved + * onto Effect: + * + * - `force: true` — an operation that removed (or renamed away) its own + * staging directory does not fail the call; + * - the cleanup failure is a typed `PlatformError` on the error channel + * (unwrapped to its Node cause by `runWithPlatform`), and when both the + * operation and the cleanup fail the cleanup error wins, as a throwing + * `finally` did; + * - cleanup runs on interruption as well, uninterruptibly. + * + * Not `fs.makeTempDirectoryScoped`: in rc.112 its finalizer removes without + * `force` and `orDie`s, so a missing directory would reject an already + * successful call, and an `EACCES` would surface as the `PlatformError` + * wrapper (scope finalizers cannot fail typed). + */ +export const withTempDirectory = ( + options: { readonly directory?: string; readonly prefix?: string } | undefined, + use: (directory: string) => Effect.Effect, +): Effect.Effect => + Effect.uninterruptibleMask((restore) => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectory(options); + const exit = yield* Effect.exit(restore(use(directory))); + yield* fs.remove(directory, { force: true, recursive: true }); + return yield* exit; + })); + +/** + * Run a platform-dependent Effect program at a Promise edge. Same failure + * contract as `runPromise`, with `PlatformError` unwrapped to its Node cause. + */ +export const runWithPlatform = ( + effect: Effect.Effect, + options?: RunPromiseOptions, +): Promise => runPromise( + Effect.provide(effect, platformLayer).pipe(Effect.mapError(unwrapPlatformError)), + options, +); diff --git a/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts index 6879ff66d..c6decfd6d 100644 --- a/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts @@ -1,7 +1,9 @@ -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; +import { readFile, readdir } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; +import { Effect, FileSystem, Option } from 'effect'; +import type { PlatformError } from 'effect/PlatformError'; + import type { Diagnostic, DiagnosticSeverity } from '../core/diagnostics.ts'; import { freezeDiagnostics } from '../core/diagnostics.ts'; import { sha256Hex } from '../core/digest.ts'; @@ -21,6 +23,11 @@ import { type BoundedChildProcessRequest, type BoundedChildProcessResult, } from './process.ts'; +// Imported last on purpose: see the matching note in `src/api.ts` — the +// position of `effect/lift.ts` in the module graph keeps the emitted hook +// bundles byte-identical. +import { runWithPlatform, withTempDirectory } from '../effect/platform.ts'; +import { liftPromise } from '../effect/lift.ts'; const maximumOutputBytes = 1024 * 1024; const schemaGenerationTimeoutMs = 15_000; @@ -253,7 +260,13 @@ const compareGeneratedSchemas = async ( const schemaVerbUnavailable = (output: string): boolean => /(?:unrecognized|unknown|invalid) (?:subcommand|command)|no such (?:subcommand|command)/iu.test(output); -const schemaGenerationDiagnostics = async ( +/** + * Runs the live schema generator into a temporary directory and compares + * its output with the pinned revision. Every generator failure is reported + * as a diagnostic (AB6033 / AB6031), never thrown; the directory is removed + * whichever way the program settles. + */ +const schemaGenerationDiagnostics = ( options: Readonly<{ readonly cwd: string; readonly executable: string; @@ -262,17 +275,15 @@ const schemaGenerationDiagnostics = async ( readonly target: string; readonly version: string | undefined; }>, -): Promise => { - const outputDirectory = await mkdtemp(join(tmpdir(), 'agent-bundle-codex-schema-')); - try { - let result: CodexPluginCommandResult; - try { - result = await options.run(Object.freeze({ - args: Object.freeze(['app-server', 'generate-json-schema', '--out', outputDirectory]), - cwd: options.cwd, - executable: options.executable, - })); - } catch { +): Effect.Effect => withTempDirectory( + { prefix: 'agent-bundle-codex-schema-' }, + (outputDirectory) => Effect.gen(function* () { + const started = yield* liftPromise(() => options.run(Object.freeze({ + args: Object.freeze(['app-server', 'generate-json-schema', '--out', outputDirectory]), + cwd: options.cwd, + executable: options.executable, + }))).pipe(Effect.option); + if (Option.isNone(started)) { return freezeDiagnostics([diagnostic( 'AB6033', 'Codex CLI schema generation could not be started.', @@ -281,6 +292,7 @@ const schemaGenerationDiagnostics = async ( 'Verify the Codex CLI starts and supports app-server schema generation, then rerun artifact validation.', )]); } + const result: CodexPluginCommandResult = started.value; if (result.termination !== undefined) { return freezeDiagnostics([diagnostic( @@ -312,26 +324,20 @@ const schemaGenerationDiagnostics = async ( )]); } - try { - return await compareGeneratedSchemas( - outputDirectory, - options.version, - options.strict, - options.target, - ); - } catch { - return freezeDiagnostics([diagnostic( - 'AB6033', - 'Codex CLI generated schema output could not be inspected.', - 'error', - options.target, - 'Ensure the generated schema directory is readable, then rerun artifact validation.', - )]); - } - } finally { - await rm(outputDirectory, { force: true, recursive: true }); - } -}; + return yield* liftPromise(() => compareGeneratedSchemas( + outputDirectory, + options.version, + options.strict, + options.target, + )).pipe(Effect.catch(() => Effect.succeed(freezeDiagnostics([diagnostic( + 'AB6033', + 'Codex CLI generated schema output could not be inspected.', + 'error', + options.target, + 'Ensure the generated schema directory is readable, then rerun artifact validation.', + )])))); + }), +); export const validateCodexPlugin = async ( options: ValidateCodexPluginOptions, @@ -397,14 +403,14 @@ export const validateCodexPlugin = async ( 'Use the vendored pinned schema diagnostics until Codex publishes a plugin validation developer tool.', ), ...await validateCodexPluginFiles({ pluginDirectory, target: options.target }), - ...await schemaGenerationDiagnostics({ + ...await runWithPlatform(schemaGenerationDiagnostics({ cwd, executable, run, strict: options.strict === true, target: options.target, version, - }), + })), ]); const failed = diagnostics.some((entry) => entry.severity === 'error'); const warnings = diagnostics.some((entry) => entry.severity === 'warning'); diff --git a/packages/agent-bundle/tests/codex-plugin-validation.test.ts b/packages/agent-bundle/tests/codex-plugin-validation.test.ts index 3d12e7d11..b5fd17eb9 100644 --- a/packages/agent-bundle/tests/codex-plugin-validation.test.ts +++ b/packages/agent-bundle/tests/codex-plugin-validation.test.ts @@ -1,4 +1,4 @@ -import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { access, copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -145,6 +145,10 @@ it('validates Codex bundle documents and matching generated schemas without shel executable: 'codex', }), ]); + // The generator's scoped output directory is gone once the report settles. + const outputDirectory = (fixture.calls[1] as { readonly args: readonly string[] }).args[3]!; + expect(outputDirectory).toMatch(/agent-bundle-codex-schema-/u); + await expect(access(outputDirectory)).rejects.toMatchObject({ code: 'ENOENT' }); expect(report).toEqual({ diagnostics: [expect.objectContaining({ code: 'AB6030', diff --git a/packages/agent-bundle/tests/effect-platform.test.ts b/packages/agent-bundle/tests/effect-platform.test.ts new file mode 100644 index 000000000..9d4c8e30e --- /dev/null +++ b/packages/agent-bundle/tests/effect-platform.test.ts @@ -0,0 +1,174 @@ +import { access, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { Cause, Effect, Exit, FileSystem, Path, PlatformError } from 'effect'; +import { describe, expect, it } from '@rstest/core'; + +import { DiagnosticError } from '../src/core/diagnostics.ts'; +import { liftPromise } from '../src/effect/lift.ts'; +import { platformLayer, runWithPlatform, unwrapPlatformError, withTempDirectory } from '../src/effect/platform.ts'; +import * as devApi from '../src/dev/index.ts'; +import * as rootApi from '../src/index.ts'; + +/** + * `runWithPlatform` is the Promise edge for platform-dependent programs: + * the `withTempDirectory` bracket the public API (`temporaryArtifact`) and + * the Codex validator use must remove the directory whichever way the + * operation settles, and the failure contract must stay the one the + * `try`/`finally` sites had. + */ +describe('effect platform layer (agent-bundle)', () => { + it('is not part of any public export', () => { + expect('runWithPlatform' in rootApi).toBe(false); + expect('platformLayer' in rootApi).toBe(false); + expect('runWithPlatform' in devApi).toBe(false); + }); + + it('unwraps a PlatformError to the Node error it carries, and keeps a bare one', async () => { + const enoent: NodeJS.ErrnoException = new Error("ENOENT: no such file or directory, mkdtemp '/nope/.agent-bundle-artifact-'"); + enoent.code = 'ENOENT'; + const wrapped = PlatformError.systemError({ + _tag: 'NotFound', + cause: enoent, + method: 'makeTempDirectoryScoped', + module: 'FileSystem', + pathOrDescriptor: '/nope', + }); + expect(unwrapPlatformError(wrapped)).toBe(enoent); + await expect(runWithPlatform(Effect.fail(wrapped))).rejects.toBe(enoent); + + const bare = PlatformError.systemError({ + _tag: 'NotFound', + method: 'readFile', + module: 'FileSystem', + pathOrDescriptor: '/nope', + }); + expect(unwrapPlatformError(bare)).toBe(bare); + const typed = new DiagnosticError([{ code: 'AB7200', message: 'rebuild failed', severity: 'error' }]); + expect(unwrapPlatformError(typed)).toBe(typed); + }); + + it('provides FileSystem and Path', async () => { + const joined = await runWithPlatform(Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + expect(yield* fs.exists(tmpdir())).toBe(true); + return path.join('a', 'b'); + })); + expect(joined).toBe(join('a', 'b')); + expect(platformLayer).toBeDefined(); + }); + + it('removes the temp directory after the operation succeeds', async () => { + const parent = await mkdtemp(join(tmpdir(), 'agent-bundle-platform-')); + try { + const directory = await runWithPlatform(withTempDirectory( + { directory: parent, prefix: '.staging-' }, + (created) => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + expect(created.startsWith(join(parent, '.staging-'))).toBe(true); + yield* fs.writeFileString(join(created, 'manifest.json'), '{}'); + yield* Effect.promise(() => access(created)); + return created; + }), + )); + await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(parent, { force: true, recursive: true }); + } + }); + + it('removes the temp directory and rethrows the typed failure when the operation fails', async () => { + const parent = await mkdtemp(join(tmpdir(), 'agent-bundle-platform-')); + let directory: string | undefined; + const failure = new DiagnosticError([{ code: 'AB7200', message: 'rebuild failed', severity: 'error' }]); + try { + await expect(runWithPlatform(withTempDirectory( + { directory: parent, prefix: '.staging-' }, + (created) => { + directory = created; + return liftPromise(() => Promise.reject(failure)); + }, + ))).rejects.toBe(failure); + expect(directory).toBeDefined(); + await expect(access(directory!)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(parent, { force: true, recursive: true }); + } + }); + + it('keeps the result when the operation already removed its temp directory', async () => { + const parent = await mkdtemp(join(tmpdir(), 'agent-bundle-platform-')); + try { + const result = await runWithPlatform(withTempDirectory( + { directory: parent, prefix: '.staging-' }, + (created) => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(created, { recursive: true }); + return 'settled'; + }), + )); + expect(result).toBe('settled'); + } finally { + await rm(parent, { force: true, recursive: true }); + } + }); + + it('removes the temp directory when the operation is interrupted', async () => { + const parent = await mkdtemp(join(tmpdir(), 'agent-bundle-platform-')); + let directory: string | undefined; + try { + const exit = await runWithPlatform(Effect.exit(withTempDirectory( + { directory: parent, prefix: '.staging-' }, + (created) => { + directory = created; + return Effect.interrupt; + }, + ))); + expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true); + expect(directory).toBeDefined(); + await expect(access(directory!)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(parent, { force: true, recursive: true }); + } + }); + + it('throws the Node error when the temp directory cannot be created', async () => { + const missingParent = join(tmpdir(), 'agent-bundle-platform-missing', String(process.pid)); + await expect(runWithPlatform(withTempDirectory( + { directory: missingParent, prefix: '.staging-' }, + (created) => Effect.succeed(created), + ))).rejects.toMatchObject({ code: 'ENOENT', syscall: 'mkdtemp' }); + }); + + describe('cleanup failures (FileSystem.layerNoop)', () => { + const eacces: NodeJS.ErrnoException = new Error("EACCES: permission denied, rmdir '/virtual/tmp/.staging-1'"); + eacces.code = 'EACCES'; + const failingRemove = FileSystem.layerNoop({ + makeTempDirectory: () => Effect.succeed('/virtual/tmp/.staging-1'), + remove: (path) => Effect.fail(PlatformError.systemError({ + _tag: 'PermissionDenied', + cause: eacces, + method: 'remove', + module: 'FileSystem', + pathOrDescriptor: path, + })), + }); + + it('throws the Node cleanup error after a successful operation, as the former finally did', async () => { + await expect(runWithPlatform(withTempDirectory( + { prefix: '.staging-' }, + (created) => Effect.succeed(created), + ).pipe(Effect.provide(failingRemove)))).rejects.toBe(eacces); + }); + + it('lets the cleanup error win when the operation failed too', async () => { + const failure = new DiagnosticError([{ code: 'AB7200', message: 'rebuild failed', severity: 'error' }]); + await expect(runWithPlatform(withTempDirectory( + { prefix: '.staging-' }, + () => Effect.fail(failure), + ).pipe(Effect.provide(failingRemove)))).rejects.toBe(eacces); + }); + }); +}); diff --git a/packages/agent-bundle/tests/packed-consumer.test.ts b/packages/agent-bundle/tests/packed-consumer.test.ts index e99167434..1d6fb6714 100644 --- a/packages/agent-bundle/tests/packed-consumer.test.ts +++ b/packages/agent-bundle/tests/packed-consumer.test.ts @@ -9,7 +9,6 @@ import { realpath, rm, stat, - symlink, writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -20,7 +19,7 @@ import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; import { sha256Hex } from '../src/core/digest.ts'; -import { cachedNpmInstallArguments, installedEnvironment, packOutputFromJson } from './support/shared-pack.ts'; +import { cachedNpmInstallArguments, installedEnvironment, linkWorkspaceTypes, packOutputFromJson } from './support/shared-pack.ts'; const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); @@ -362,12 +361,7 @@ it('uses only an installed tarball after source deletion', async () => { cwd: frameworkRoot, env: installedEnvironment(), }); - // Declaration generation resolves typescript and ambient node types from - // the consumer project, exactly like a real devDependency install. - await Promise.all([ - symlink(join(workspaceRoot, 'node_modules', 'typescript'), join(frameworkRoot, 'node_modules', 'typescript'), 'dir'), - symlink(join(workspaceRoot, 'node_modules', '@types'), join(frameworkRoot, 'node_modules', '@types'), 'dir'), - ]); + await linkWorkspaceTypes(frameworkRoot, { typescript: true }); const frameworkCli = join(frameworkRoot, 'node_modules', '.bin', 'agent-bundle'); const frameworkArtifact = join(frameworkRoot, 'artifact'); await runInstalled(frameworkCli, frameworkRoot, ['build', '--root', frameworkRoot, '--output', frameworkArtifact]); diff --git a/packages/agent-bundle/tests/public-api-packed.test.ts b/packages/agent-bundle/tests/public-api-packed.test.ts index 60ff5f02d..e207ed96d 100644 --- a/packages/agent-bundle/tests/public-api-packed.test.ts +++ b/packages/agent-bundle/tests/public-api-packed.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { mkdtemp, mkdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; @@ -8,7 +8,7 @@ import { beforeAll, expect, it } from '@rstest/core'; import { isolatedCommandEnvironment } from '../../../rstest.worker-isolation.ts'; import { writeFixtureManifest } from './support/manifest.ts'; -import { cachedNpmInstallArguments, sharedPackedTarball } from './support/shared-pack.ts'; +import { cachedNpmInstallArguments, linkWorkspaceTypes, sharedPackedTarball } from './support/shared-pack.ts'; interface PackageManifest { bin: { @@ -134,11 +134,7 @@ it('imports the externalized config entry from a packed npm consumer', async () ].join('\n'), ], { cwd: consumerRoot, env: isolatedCommandEnvironment() }), ).resolves.toMatchObject({ stderr: '', stdout: '' }); - await symlink( - join(workspaceRoot, 'node_modules', '@types'), - join(consumerRoot, 'node_modules', '@types'), - 'dir', - ); + await linkWorkspaceTypes(consumerRoot); await writeFile(join(consumerRoot, 'config.mts'), [ "import { defineConfig, type AgentBundleConfig } from 'agent-bundle/config';", '', diff --git a/packages/agent-bundle/tests/support/shared-pack.ts b/packages/agent-bundle/tests/support/shared-pack.ts index 4cf21e3ca..3946dfcf5 100644 --- a/packages/agent-bundle/tests/support/shared-pack.ts +++ b/packages/agent-bundle/tests/support/shared-pack.ts @@ -1,6 +1,6 @@ import { execFile as executeFile } from 'node:child_process'; import { rmSync } from 'node:fs'; -import { mkdtemp, readFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, readdir, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; @@ -59,6 +59,35 @@ export const npmInstallArguments = ['--ignore-scripts', '--no-audit', '--no-fund */ export const cachedNpmInstallArguments = [...npmInstallArguments, '--prefer-offline'] as const; +/** + * Link the workspace's `typescript` and ambient `@types/*` packages into a + * consumer fixture so declaration generation resolves them from the consumer + * project, exactly like a real devDependency install. Entries the packed + * install already brought (an `agent-bundle` install carries `@types/ws` and + * `@types/node` through `@effect/platform-node`) are left as installed; + * `EEXIST` on any of them is not a fixture failure. + */ +export const linkWorkspaceTypes = async ( + consumerRoot: string, + options: Readonly<{ readonly typescript?: boolean }> = {}, +): Promise => { + const source = join(workspaceRoot, 'node_modules'); + const target = join(consumerRoot, 'node_modules'); + await mkdir(join(target, '@types'), { recursive: true }); + const link = async (relativePath: string): Promise => { + try { + await symlink(join(source, relativePath), join(target, relativePath), 'dir'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + }; + const types = await readdir(join(source, '@types')); + await Promise.all([ + ...(options.typescript === true ? [link('typescript')] : []), + ...types.map((name) => link(join('@types', name))), + ]); +}; + const packs = new Map>(); let fallbackBuild: Promise | undefined; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ee6e0e7d..62a02cb3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,6 +252,9 @@ importers: '@agent-bundle/runtime': specifier: workspace:* version: link:../rsc-runtime + '@effect/platform-node-shared': + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112) '@modelcontextprotocol/client': specifier: 2.0.0 version: 2.0.0