From 79e9f61a4216bd49882b723efbae5b3ccb89c495 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 01:35:45 +0000 Subject: [PATCH 1/5] feat(api): stage temporary artifacts and Codex schema output in scoped temp directories Two ordinary temp-directory sites move onto Effect FileSystem's makeTempDirectoryScoped inside Effect.scoped: api.ts temporaryArtifact (the throwaway artifact behind listMcp/invokeMcp/runMcp/listHooks/ simulateHook when no --artifact is given) and the Codex validator's schema-generation output directory. The new src/effect/platform.ts owns the package's NodeServices layer (runWithPlatform at Promise edges; the dev server reuses platformLayer through makeScopedEffectRuntime in phase 2), and the boundary unwraps PlatformError to its Node cause so a failed mkdtemp still throws the same ErrnoException. --- .changeset/effect-filesystem-tempdirs.md | 5 + docs/effect-conventions.md | 22 ++- packages/agent-bundle/package.json | 1 + packages/agent-bundle/src/api.ts | 29 ++-- packages/agent-bundle/src/effect/boundary.ts | 5 + packages/agent-bundle/src/effect/platform.ts | 49 ++++++ .../host-contracts/codex-plugin-validation.ts | 143 +++++++++--------- .../tests/codex-plugin-validation.test.ts | 6 +- .../tests/effect-platform.test.ts | 103 +++++++++++++ pnpm-lock.yaml | 3 + 10 files changed, 283 insertions(+), 83 deletions(-) create mode 100644 .changeset/effect-filesystem-tempdirs.md create mode 100644 packages/agent-bundle/src/effect/platform.ts create mode 100644 packages/agent-bundle/tests/effect-platform.test.ts diff --git a/.changeset/effect-filesystem-tempdirs.md b/.changeset/effect-filesystem-tempdirs.md new file mode 100644 index 000000000..d50e65da9 --- /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` scoped temporary directories, provided by `@effect/platform-node`'s `NodeServices.layer` at those API edges. Both directories are now removed on every exit path, including interruption; results, diagnostics, and thrown errors are unchanged. (#PR) diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index bad3b1a35..1165f00e8 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -300,6 +300,20 @@ 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` (= `NodeServices.layer`), `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 `makeTempDirectoryScoped` inside +`Effect.scoped`. 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 +390,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`; `FileSystem` / `Path` services live in `effect`) | **adopted** (2026-09-03) for ordinary I/O — `create-agent-bundle` scaffolder and the `agent-bundle` scoped temp directories in `api.ts` / the Codex validator (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 | | `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,8 +422,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 - the same RC; re-run the Workbench disposal regression test and production + `@effect/platform-node` in `packages/create-agent-bundle/package.json` and + `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). 3. `git subtree pull --prefix=repos/effect https://github.com/Effect-TS/effect.git main --squash`. @@ -432,5 +446,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`, `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..8239d5e02 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": "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..08462f3dd 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, FileSystem } 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 } from './effect/platform.ts'; +import { liftPromise } from './effect/lift.ts'; export { compareInstalledHostContract, @@ -569,9 +575,16 @@ 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({ + // The staging directory lives exactly as long as the scope: created next + // to the project (same filesystem as a real `artifact/`), removed when the + // build or the operation settles, success or failure. + return runWithPlatform(Effect.scoped(Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const artifact = yield* fs.makeTempDirectoryScoped({ + directory: resolve(options.root), + prefix: '.agent-bundle-artifact-', + }); + yield* liftPromise(() => build({ configPath: options.configPath, logger: options.logger, mode: options.mode, @@ -579,11 +592,9 @@ const temporaryArtifact = async ( registry: options.registry, root: options.root, targets: options.targets, - }); - return await operation(artifact); - } finally { - await rm(artifact, { force: true, recursive: true }); - } + })); + 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..49db71d19 --- /dev/null +++ b/packages/agent-bundle/src/effect/platform.ts @@ -0,0 +1,49 @@ +import * as NodeServices from '@effect/platform-node/NodeServices'; +import { Effect, type 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 + * (`FileSystem`, `Path`, and the rest of `NodeServices`). + * + * 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 type PlatformServices = NodeServices.NodeServices; + +export const platformLayer: Layer.Layer = NodeServices.layer; + +/** + * 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); + +/** + * 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..6bc06fcff 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 } 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 scoped temp 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 + * when the scope closes, whichever way the program settles. + */ +const schemaGenerationDiagnostics = ( options: Readonly<{ readonly cwd: string; readonly executable: string; @@ -262,76 +275,68 @@ 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 { - return freezeDiagnostics([diagnostic( - 'AB6033', - 'Codex CLI schema generation could not be started.', - 'error', - options.target, - 'Verify the Codex CLI starts and supports app-server schema generation, then rerun artifact validation.', - )]); - } - - if (result.termination !== undefined) { - return freezeDiagnostics([diagnostic( - 'AB6033', - commandFailureMessage('schema generation', result), - 'error', - options.target, - 'Rerun Codex schema generation within the configured time and output bounds.', - )]); - } - if (result.exitCode !== 0) { - const output = `${result.stdout}\n${result.stderr}`; - if (schemaVerbUnavailable(output)) { - return freezeDiagnostics([diagnostic( - 'AB6031', - `The Codex ${options.version ?? 'unknown'} app-server generate-json-schema verb is unavailable; ` + - `live schema drift could not be checked against pinned Codex ${pinnedRevision}.`, - 'info', - options.target, - 'Use a Codex release that publishes app-server schema generation, or retain validation against the vendored pinned schemas.', - )]); - } - return freezeDiagnostics([diagnostic( - 'AB6033', - commandFailureMessage('schema generation', result), - 'error', - options.target, - 'Run `codex app-server generate-json-schema --out ` successfully, then rerun artifact validation.', - )]); - } +): Effect.Effect => Effect.scoped(Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const outputDirectory = yield* fs.makeTempDirectoryScoped({ prefix: 'agent-bundle-codex-schema-' }); + 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.', + 'error', + options.target, + 'Verify the Codex CLI starts and supports app-server schema generation, then rerun artifact validation.', + )]); + } + const result: CodexPluginCommandResult = started.value; - try { - return await compareGeneratedSchemas( - outputDirectory, - options.version, - options.strict, - options.target, - ); - } catch { + if (result.termination !== undefined) { + return freezeDiagnostics([diagnostic( + 'AB6033', + commandFailureMessage('schema generation', result), + 'error', + options.target, + 'Rerun Codex schema generation within the configured time and output bounds.', + )]); + } + if (result.exitCode !== 0) { + const output = `${result.stdout}\n${result.stderr}`; + if (schemaVerbUnavailable(output)) { return freezeDiagnostics([diagnostic( - 'AB6033', - 'Codex CLI generated schema output could not be inspected.', - 'error', + 'AB6031', + `The Codex ${options.version ?? 'unknown'} app-server generate-json-schema verb is unavailable; ` + + `live schema drift could not be checked against pinned Codex ${pinnedRevision}.`, + 'info', options.target, - 'Ensure the generated schema directory is readable, then rerun artifact validation.', + 'Use a Codex release that publishes app-server schema generation, or retain validation against the vendored pinned schemas.', )]); } - } finally { - await rm(outputDirectory, { force: true, recursive: true }); + return freezeDiagnostics([diagnostic( + 'AB6033', + commandFailureMessage('schema generation', result), + 'error', + options.target, + 'Run `codex app-server generate-json-schema --out ` successfully, then rerun artifact validation.', + )]); } -}; + + 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 +402,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..cf735d4de --- /dev/null +++ b/packages/agent-bundle/tests/effect-platform.test.ts @@ -0,0 +1,103 @@ +import { access, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { Effect, 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 } 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 scoped-temp-directory idiom the public API (`temporaryArtifact`) and + * the Codex validator use must remove the directory whichever way the + * program settles, and the failure contract must stay the boundary's. + */ +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 a scoped temp directory after the program succeeds', async () => { + const parent = await mkdtemp(join(tmpdir(), 'agent-bundle-platform-')); + try { + const directory = await runWithPlatform(Effect.scoped(Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const created = yield* fs.makeTempDirectoryScoped({ directory: parent, prefix: '.staging-' }); + 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 scoped temp directory and rethrows the typed failure when the program 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(Effect.scoped(Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + directory = yield* fs.makeTempDirectoryScoped({ directory: parent, prefix: '.staging-' }); + yield* 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('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(Effect.scoped(Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ directory: missingParent, prefix: '.staging-' }); + })))).rejects.toMatchObject({ code: 'ENOENT', syscall: 'mkdtemp' }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ee6e0e7d..6008d29e2 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': + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.2.1) '@modelcontextprotocol/client': specifier: 2.0.0 version: 2.0.0 From 33f9e37e5edb08671cf896fd72771f6d81fad7e5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 02:22:58 +0000 Subject: [PATCH 2/5] chore(changeset): reference #508 --- .changeset/effect-filesystem-tempdirs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/effect-filesystem-tempdirs.md b/.changeset/effect-filesystem-tempdirs.md index d50e65da9..fdb01c904 100644 --- a/.changeset/effect-filesystem-tempdirs.md +++ b/.changeset/effect-filesystem-tempdirs.md @@ -2,4 +2,4 @@ "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` scoped temporary directories, provided by `@effect/platform-node`'s `NodeServices.layer` at those API edges. Both directories are now removed on every exit path, including interruption; results, diagnostics, and thrown errors are unchanged. (#PR) +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` scoped temporary directories, provided by `@effect/platform-node`'s `NodeServices.layer` at those API edges. Both directories are now removed on every exit path, including interruption; results, diagnostics, and thrown errors are unchanged. (#508) From 35a12560bd58e848fa9f1de7e06c78a004a14fed Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 02:34:51 +0000 Subject: [PATCH 3/5] fix(effect): scopedTempDirectory keeps the force:true finalizer of the former try/finally rm rc.112's makeTempDirectoryScoped finalizes with rm({ recursive: true }) and orDie, so an operation that removed its own staging directory would reject an already-successful listMcp/invokeMcp/... call or the Codex validator's AB6033 result with ENOENT at scope close. Both sites now use scopedTempDirectory (makeTempDirectory + rm({ recursive, force })), with a regression test. --- docs/effect-conventions.md | 18 ++++++++----- packages/agent-bundle/src/api.ts | 7 +++--- packages/agent-bundle/src/effect/platform.ts | 23 ++++++++++++++++- .../host-contracts/codex-plugin-validation.ts | 5 ++-- .../tests/effect-platform.test.ts | 25 ++++++++++++++----- 5 files changed, 58 insertions(+), 20 deletions(-) diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 1165f00e8..a83bddb82 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -237,8 +237,14 @@ 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 + a scoped temp directory inside `Effect.scoped`, replacing `mkdtemp` + + `try`/`finally` `rm`. In `agent-bundle` use `scopedTempDirectory` from + `src/effect/platform.ts`, not `fs.makeTempDirectoryScoped` directly: the + rc.112 finalizer removes without `force`, so an operation that deletes its + own staging directory would die with ENOENT at scope close, where the old + `rm(dir, { recursive: true, force: true })` succeeded. Tests may use + `makeTempDirectoryScoped` (nothing removes the fixture underneath them). + **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. @@ -301,16 +307,16 @@ provides `NodeServices.layer` once. Measured on rc.112 (bundled by Rslib, the bundle. `packages/agent-bundle/src/effect/platform.ts` owns the framework's platform -layer: `platformLayer` (= `NodeServices.layer`), `unwrapPlatformError`, and -`runWithPlatform`, which provides the layer and unwraps `PlatformError` +layer: `platformLayer` (= `NodeServices.layer`), `scopedTempDirectory`, +`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 `makeTempDirectoryScoped` inside -`Effect.scoped`. Emitted artifacts, hook wrappers, and compiler hot paths +schema-generation directory, both `scopedTempDirectory` (the `force: true` +finalizer) inside `Effect.scoped`. Emitted artifacts, hook wrappers, and compiler hot paths never import this module; the dev server picks it up in phase 2 through `makeScopedEffectRuntime(platformLayer)`. diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 08462f3dd..90ecdaadb 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -2,7 +2,7 @@ import { execFile as executeFile } from 'node:child_process'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; -import { Effect, FileSystem } from 'effect'; +import { Effect } from 'effect'; import { capabilityIsSupported, unavailableCapability } from './adapters/capability-state.ts'; import { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts'; @@ -166,7 +166,7 @@ import { // 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 } from './effect/platform.ts'; +import { runWithPlatform, scopedTempDirectory } from './effect/platform.ts'; import { liftPromise } from './effect/lift.ts'; export { @@ -579,8 +579,7 @@ const temporaryArtifact = async ( // to the project (same filesystem as a real `artifact/`), removed when the // build or the operation settles, success or failure. return runWithPlatform(Effect.scoped(Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const artifact = yield* fs.makeTempDirectoryScoped({ + const artifact = yield* scopedTempDirectory({ directory: resolve(options.root), prefix: '.agent-bundle-artifact-', }); diff --git a/packages/agent-bundle/src/effect/platform.ts b/packages/agent-bundle/src/effect/platform.ts index 49db71d19..64cd861cf 100644 --- a/packages/agent-bundle/src/effect/platform.ts +++ b/packages/agent-bundle/src/effect/platform.ts @@ -1,5 +1,5 @@ import * as NodeServices from '@effect/platform-node/NodeServices'; -import { Effect, type Layer } from 'effect'; +import { Effect, FileSystem, type Layer, type Scope } from 'effect'; import { PlatformError } from 'effect/PlatformError'; import { runPromise, type RunPromiseOptions } from './boundary.ts'; @@ -36,6 +36,27 @@ export const unwrapPlatformError = (error: E): Exclude | Er ? (error.cause instanceof Error ? error.cause : error) : (error as Exclude); +/** + * A temporary directory that lives exactly as long as the enclosing scope, + * with the `rm(dir, { recursive: true, force: true })` finalizer the + * `try`/`finally` sites had before they moved onto Effect. rc.112's own + * `makeTempDirectoryScoped` finalizes without `force`, so an operation that + * removes (or renames away) its own staging directory would die with ENOENT + * at scope close and reject a call that had already succeeded. A finalizer + * failure other than "already gone" is still a defect, as the `finally` + * throw was. + */ +export const scopedTempDirectory = ( + options?: { readonly directory?: string; readonly prefix?: string }, +): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* Effect.acquireRelease( + fs.makeTempDirectory(options), + (directory) => Effect.orDie(fs.remove(directory, { force: true, recursive: true })), + ); + }); + /** * Run a platform-dependent Effect program at a Promise edge. Same failure * contract as `runPromise`, with `PlatformError` unwrapped to its Node cause. 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 6bc06fcff..3459c88a4 100644 --- a/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts @@ -26,7 +26,7 @@ import { // 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 } from '../effect/platform.ts'; +import { runWithPlatform, scopedTempDirectory } from '../effect/platform.ts'; import { liftPromise } from '../effect/lift.ts'; const maximumOutputBytes = 1024 * 1024; @@ -276,8 +276,7 @@ const schemaGenerationDiagnostics = ( readonly version: string | undefined; }>, ): Effect.Effect => Effect.scoped(Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const outputDirectory = yield* fs.makeTempDirectoryScoped({ prefix: 'agent-bundle-codex-schema-' }); + const outputDirectory = yield* scopedTempDirectory({ prefix: 'agent-bundle-codex-schema-' }); const started = yield* liftPromise(() => options.run(Object.freeze({ args: Object.freeze(['app-server', 'generate-json-schema', '--out', outputDirectory]), cwd: options.cwd, diff --git a/packages/agent-bundle/tests/effect-platform.test.ts b/packages/agent-bundle/tests/effect-platform.test.ts index cf735d4de..1757b55f1 100644 --- a/packages/agent-bundle/tests/effect-platform.test.ts +++ b/packages/agent-bundle/tests/effect-platform.test.ts @@ -7,7 +7,7 @@ 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 } from '../src/effect/platform.ts'; +import { platformLayer, runWithPlatform, scopedTempDirectory, unwrapPlatformError } from '../src/effect/platform.ts'; import * as devApi from '../src/dev/index.ts'; import * as rootApi from '../src/index.ts'; @@ -64,7 +64,7 @@ describe('effect platform layer (agent-bundle)', () => { try { const directory = await runWithPlatform(Effect.scoped(Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const created = yield* fs.makeTempDirectoryScoped({ directory: parent, prefix: '.staging-' }); + const created = yield* scopedTempDirectory({ directory: parent, prefix: '.staging-' }); expect(created.startsWith(join(parent, '.staging-'))).toBe(true); yield* fs.writeFileString(join(created, 'manifest.json'), '{}'); yield* Effect.promise(() => access(created)); @@ -82,8 +82,7 @@ describe('effect platform layer (agent-bundle)', () => { const failure = new DiagnosticError([{ code: 'AB7200', message: 'rebuild failed', severity: 'error' }]); try { await expect(runWithPlatform(Effect.scoped(Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - directory = yield* fs.makeTempDirectoryScoped({ directory: parent, prefix: '.staging-' }); + directory = yield* scopedTempDirectory({ directory: parent, prefix: '.staging-' }); yield* liftPromise(() => Promise.reject(failure)); })))).rejects.toBe(failure); expect(directory).toBeDefined(); @@ -93,11 +92,25 @@ describe('effect platform layer (agent-bundle)', () => { } }); + it('keeps the result when the operation already removed its scoped temp directory', async () => { + const parent = await mkdtemp(join(tmpdir(), 'agent-bundle-platform-')); + try { + const result = await runWithPlatform(Effect.scoped(Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const created = yield* scopedTempDirectory({ directory: parent, prefix: '.staging-' }); + yield* fs.remove(created, { recursive: true }); + return 'settled'; + }))); + expect(result).toBe('settled'); + } 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(Effect.scoped(Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - return yield* fs.makeTempDirectoryScoped({ directory: missingParent, prefix: '.staging-' }); + return yield* scopedTempDirectory({ directory: missingParent, prefix: '.staging-' }); })))).rejects.toMatchObject({ code: 'ENOENT', syscall: 'mkdtemp' }); }); }); From 4e2feb65d4c3fe12749c73289e91cc823f84ada7 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 02:48:24 +0000 Subject: [PATCH 4/5] fix(effect): withTempDirectory reproduces the try/finally rm contract exactly A scope finalizer cannot fail typed, so a cleanup error (EACCES, EBUSY) surfaced as the PlatformError wrapper after orDie, and when the operation had failed too Cause.squash preferred the operation's failure where the former throwing finally reported the cleanup error. withTempDirectory is a bracket: makeTempDirectory, Effect.exit(use), rm({ recursive, force }) on the typed error channel, then the operation's exit; uninterruptible around the cleanup. Tests cover both cleanup-failure orders over FileSystem.layerNoop and cleanup on interruption. --- docs/effect-conventions.md | 23 ++-- packages/agent-bundle/src/api.ts | 39 +++--- packages/agent-bundle/src/effect/platform.ts | 46 ++++--- .../host-contracts/codex-plugin-validation.ts | 114 +++++++++--------- .../tests/effect-platform.test.ts | 114 +++++++++++++----- 5 files changed, 204 insertions(+), 132 deletions(-) diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index a83bddb82..c6d33e1ea 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -237,13 +237,16 @@ 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: - a scoped temp directory inside `Effect.scoped`, replacing `mkdtemp` + - `try`/`finally` `rm`. In `agent-bundle` use `scopedTempDirectory` from - `src/effect/platform.ts`, not `fs.makeTempDirectoryScoped` directly: the - rc.112 finalizer removes without `force`, so an operation that deletes its - own staging directory would die with ENOENT at scope close, where the old - `rm(dir, { recursive: true, force: true })` succeeded. Tests may use - `makeTempDirectoryScoped` (nothing removes the fixture underneath them). + 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 @@ -307,7 +310,7 @@ provides `NodeServices.layer` once. Measured on rc.112 (bundled by Rslib, the bundle. `packages/agent-bundle/src/effect/platform.ts` owns the framework's platform -layer: `platformLayer` (= `NodeServices.layer`), `scopedTempDirectory`, +layer: `platformLayer` (= `NodeServices.layer`), `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 @@ -315,8 +318,8 @@ 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 `scopedTempDirectory` (the `force: true` -finalizer) inside `Effect.scoped`. Emitted artifacts, hook wrappers, and compiler hot paths +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)`. diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 90ecdaadb..560d39358 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -166,7 +166,7 @@ import { // 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, scopedTempDirectory } from './effect/platform.ts'; +import { runWithPlatform, withTempDirectory } from './effect/platform.ts'; import { liftPromise } from './effect/lift.ts'; export { @@ -575,25 +575,24 @@ const temporaryArtifact = async ( ): Promise => { if (options.artifact !== undefined) return operation(resolve(options.artifact)); - // The staging directory lives exactly as long as the scope: created next - // to the project (same filesystem as a real `artifact/`), removed when the - // build or the operation settles, success or failure. - return runWithPlatform(Effect.scoped(Effect.gen(function* () { - const artifact = yield* scopedTempDirectory({ - directory: resolve(options.root), - prefix: '.agent-bundle-artifact-', - }); - 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)); - }))); + // 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/platform.ts b/packages/agent-bundle/src/effect/platform.ts index 64cd861cf..df521eff9 100644 --- a/packages/agent-bundle/src/effect/platform.ts +++ b/packages/agent-bundle/src/effect/platform.ts @@ -1,5 +1,5 @@ import * as NodeServices from '@effect/platform-node/NodeServices'; -import { Effect, FileSystem, type Layer, type Scope } from 'effect'; +import { Effect, FileSystem, type Layer } from 'effect'; import { PlatformError } from 'effect/PlatformError'; import { runPromise, type RunPromiseOptions } from './boundary.ts'; @@ -37,25 +37,35 @@ export const unwrapPlatformError = (error: E): Exclude | Er : (error as Exclude); /** - * A temporary directory that lives exactly as long as the enclosing scope, - * with the `rm(dir, { recursive: true, force: true })` finalizer the - * `try`/`finally` sites had before they moved onto Effect. rc.112's own - * `makeTempDirectoryScoped` finalizes without `force`, so an operation that - * removes (or renames away) its own staging directory would die with ENOENT - * at scope close and reject a call that had already succeeded. A finalizer - * failure other than "already gone" is still a defect, as the `finally` - * throw was. + * `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 scopedTempDirectory = ( - options?: { readonly directory?: string; readonly prefix?: string }, -): Effect.Effect => - Effect.gen(function* () { +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; - return yield* Effect.acquireRelease( - fs.makeTempDirectory(options), - (directory) => Effect.orDie(fs.remove(directory, { force: true, recursive: true })), - ); - }); + 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 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 3459c88a4..c6decfd6d 100644 --- a/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/codex-plugin-validation.ts @@ -26,7 +26,7 @@ import { // 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, scopedTempDirectory } from '../effect/platform.ts'; +import { runWithPlatform, withTempDirectory } from '../effect/platform.ts'; import { liftPromise } from '../effect/lift.ts'; const maximumOutputBytes = 1024 * 1024; @@ -261,10 +261,10 @@ const schemaVerbUnavailable = (output: string): boolean => /(?:unrecognized|unknown|invalid) (?:subcommand|command)|no such (?:subcommand|command)/iu.test(output); /** - * Runs the live schema generator into a scoped temp directory and compares + * 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 - * when the scope closes, whichever way the program settles. + * whichever way the program settles. */ const schemaGenerationDiagnostics = ( options: Readonly<{ @@ -275,67 +275,69 @@ const schemaGenerationDiagnostics = ( readonly target: string; readonly version: string | undefined; }>, -): Effect.Effect => Effect.scoped(Effect.gen(function* () { - const outputDirectory = yield* scopedTempDirectory({ prefix: 'agent-bundle-codex-schema-' }); - 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.', - 'error', - options.target, - 'Verify the Codex CLI starts and supports app-server schema generation, then rerun artifact validation.', - )]); - } - const result: CodexPluginCommandResult = started.value; +): 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.', + 'error', + options.target, + '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( - 'AB6033', - commandFailureMessage('schema generation', result), - 'error', - options.target, - 'Rerun Codex schema generation within the configured time and output bounds.', - )]); - } - if (result.exitCode !== 0) { - const output = `${result.stdout}\n${result.stderr}`; - if (schemaVerbUnavailable(output)) { + if (result.termination !== undefined) { return freezeDiagnostics([diagnostic( - 'AB6031', - `The Codex ${options.version ?? 'unknown'} app-server generate-json-schema verb is unavailable; ` + - `live schema drift could not be checked against pinned Codex ${pinnedRevision}.`, - 'info', + 'AB6033', + commandFailureMessage('schema generation', result), + 'error', options.target, - 'Use a Codex release that publishes app-server schema generation, or retain validation against the vendored pinned schemas.', + 'Rerun Codex schema generation within the configured time and output bounds.', )]); } - return freezeDiagnostics([diagnostic( + if (result.exitCode !== 0) { + const output = `${result.stdout}\n${result.stderr}`; + if (schemaVerbUnavailable(output)) { + return freezeDiagnostics([diagnostic( + 'AB6031', + `The Codex ${options.version ?? 'unknown'} app-server generate-json-schema verb is unavailable; ` + + `live schema drift could not be checked against pinned Codex ${pinnedRevision}.`, + 'info', + options.target, + 'Use a Codex release that publishes app-server schema generation, or retain validation against the vendored pinned schemas.', + )]); + } + return freezeDiagnostics([diagnostic( + 'AB6033', + commandFailureMessage('schema generation', result), + 'error', + options.target, + 'Run `codex app-server generate-json-schema --out ` successfully, then rerun artifact validation.', + )]); + } + + return yield* liftPromise(() => compareGeneratedSchemas( + outputDirectory, + options.version, + options.strict, + options.target, + )).pipe(Effect.catch(() => Effect.succeed(freezeDiagnostics([diagnostic( 'AB6033', - commandFailureMessage('schema generation', result), + 'Codex CLI generated schema output could not be inspected.', 'error', options.target, - 'Run `codex app-server generate-json-schema --out ` successfully, then rerun artifact validation.', - )]); - } - - 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.', - )])))); -})); + 'Ensure the generated schema directory is readable, then rerun artifact validation.', + )])))); + }), +); export const validateCodexPlugin = async ( options: ValidateCodexPluginOptions, diff --git a/packages/agent-bundle/tests/effect-platform.test.ts b/packages/agent-bundle/tests/effect-platform.test.ts index 1757b55f1..9d4c8e30e 100644 --- a/packages/agent-bundle/tests/effect-platform.test.ts +++ b/packages/agent-bundle/tests/effect-platform.test.ts @@ -2,20 +2,21 @@ import { access, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { Effect, FileSystem, Path, PlatformError } from 'effect'; +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, scopedTempDirectory, unwrapPlatformError } from '../src/effect/platform.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 scoped-temp-directory idiom the public API (`temporaryArtifact`) and + * the `withTempDirectory` bracket the public API (`temporaryArtifact`) and * the Codex validator use must remove the directory whichever way the - * program settles, and the failure contract must stay the boundary's. + * 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', () => { @@ -59,32 +60,37 @@ describe('effect platform layer (agent-bundle)', () => { expect(platformLayer).toBeDefined(); }); - it('removes a scoped temp directory after the program succeeds', async () => { + it('removes the temp directory after the operation succeeds', async () => { const parent = await mkdtemp(join(tmpdir(), 'agent-bundle-platform-')); try { - const directory = await runWithPlatform(Effect.scoped(Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const created = yield* scopedTempDirectory({ directory: parent, prefix: '.staging-' }); - expect(created.startsWith(join(parent, '.staging-'))).toBe(true); - yield* fs.writeFileString(join(created, 'manifest.json'), '{}'); - yield* Effect.promise(() => access(created)); - return created; - }))); + 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 scoped temp directory and rethrows the typed failure when the program fails', async () => { + 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(Effect.scoped(Effect.gen(function* () { - directory = yield* scopedTempDirectory({ directory: parent, prefix: '.staging-' }); - yield* liftPromise(() => Promise.reject(failure)); - })))).rejects.toBe(failure); + 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 { @@ -92,25 +98,77 @@ describe('effect platform layer (agent-bundle)', () => { } }); - it('keeps the result when the operation already removed its scoped temp directory', async () => { + 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(Effect.scoped(Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const created = yield* scopedTempDirectory({ directory: parent, prefix: '.staging-' }); - yield* fs.remove(created, { recursive: true }); - return 'settled'; - }))); + 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(Effect.scoped(Effect.gen(function* () { - return yield* scopedTempDirectory({ directory: missingParent, prefix: '.staging-' }); - })))).rejects.toMatchObject({ code: 'ENOENT', syscall: 'mkdtemp' }); + 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); + }); }); }); From 750f22bb8cde49c05e10eaabdb51c520071474f5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 03:46:29 +0000 Subject: [PATCH 5/5] fix(deps): provide the platform layer from @effect/platform-node-shared; link workspace @types per entry in packed fixtures @effect/platform-node@rc.112 declares a non-optional redis peer that npm auto-installs, and depends on undici and mime: +23 MB / +17 packages in every consumer install of agent-bundle. platform-node's NodeFileSystem, NodePath, NodeChildProcessSpawner, NodeStdio, NodeTerminal and NodeCrypto are re-exports of platform-node-shared, so platformLayer composes the same NodeServices union from there (+4 MB: @types/node, @types/ws, undici-types). PlatformServices is derived from the layer. The packed consumer fixtures symlinked the workspace node_modules/@types directory wholesale; an agent-bundle install now brings @types/ws and @types/node, so linkWorkspaceTypes links entries individually and leaves installed ones alone. --- .changeset/effect-filesystem-tempdirs.md | 2 +- docs/effect-conventions.md | 35 +++++++++++++------ packages/agent-bundle/package.json | 2 +- packages/agent-bundle/src/effect/platform.ts | 35 +++++++++++++++---- .../tests/packed-consumer.test.ts | 10 ++---- .../tests/public-api-packed.test.ts | 10 ++---- .../agent-bundle/tests/support/shared-pack.ts | 31 +++++++++++++++- pnpm-lock.yaml | 4 +-- 8 files changed, 92 insertions(+), 37 deletions(-) diff --git a/.changeset/effect-filesystem-tempdirs.md b/.changeset/effect-filesystem-tempdirs.md index fdb01c904..bcef6fce6 100644 --- a/.changeset/effect-filesystem-tempdirs.md +++ b/.changeset/effect-filesystem-tempdirs.md @@ -2,4 +2,4 @@ "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` scoped temporary directories, provided by `@effect/platform-node`'s `NodeServices.layer` at those API edges. Both directories are now removed on every exit path, including interruption; results, diagnostics, and thrown errors are unchanged. (#508) +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 c6d33e1ea..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. @@ -252,10 +262,11 @@ contract). `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 @@ -310,7 +321,8 @@ provides `NodeServices.layer` once. Measured on rc.112 (bundled by Rslib, the bundle. `packages/agent-bundle/src/effect/platform.ts` owns the framework's platform -layer: `platformLayer` (= `NodeServices.layer`), `withTempDirectory`, +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 @@ -399,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 and the `agent-bundle` scoped temp directories in `api.ts` / the Codex validator (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 @@ -432,7 +444,8 @@ must not regress it: `pnpm bench:hook-cold-start -- --check`. 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` and - `packages/agent-bundle/package.json` to the same RC; re-run the Workbench disposal regression test and production + `@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). 3. `git subtree pull --prefix=repos/effect https://github.com/Effect-TS/effect.git main --squash`. @@ -455,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`, `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 | `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 8239d5e02..5dfdeb2c9 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -97,7 +97,7 @@ "@rsbuild/plugin-react": "2.1.0", "@rslib/core": "0.23.2", "@rslint/core": "0.8.2", - "@effect/platform-node": "4.0.0-rc.112", + "@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/effect/platform.ts b/packages/agent-bundle/src/effect/platform.ts index df521eff9..cc3aaffde 100644 --- a/packages/agent-bundle/src/effect/platform.ts +++ b/packages/agent-bundle/src/effect/platform.ts @@ -1,12 +1,25 @@ -import * as NodeServices from '@effect/platform-node/NodeServices'; -import { Effect, FileSystem, type Layer } from 'effect'; +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 - * (`FileSystem`, `Path`, and the rest of `NodeServices`). + * 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 @@ -21,9 +34,19 @@ import { runPromise, type RunPromiseOptions } from './boundary.ts'; * compiler hot paths never import this module. See * `docs/effect-conventions.md`, "Effect platform services". */ -export type PlatformServices = NodeServices.NodeServices; +export const platformLayer = Layer.provideMerge( + NodeChildProcessSpawner.layer, + Layer.mergeAll( + NodeFileSystem.layer, + NodeCrypto.layer, + NodePath.layer, + NodeStdio.layer, + NodeTerminal.layer, + ), +); -export const platformLayer: Layer.Layer = NodeServices.layer; +/** `ChildProcessSpawner | Crypto | FileSystem | Path | Stdio | Terminal` — the `NodeServices` union. */ +export type PlatformServices = Layer.Success; /** * A `PlatformError` carries the `NodeJS.ErrnoException` that `node:fs` 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 6008d29e2..62a02cb3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,9 +252,9 @@ importers: '@agent-bundle/runtime': specifier: workspace:* version: link:../rsc-runtime - '@effect/platform-node': + '@effect/platform-node-shared': specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112(effect@4.0.0-rc.112)(redis@6.2.1) + version: 4.0.0-rc.112(effect@4.0.0-rc.112) '@modelcontextprotocol/client': specifier: 2.0.0 version: 2.0.0