diff --git a/.changeset/effect-dev-seam-boundary.md b/.changeset/effect-dev-seam-boundary.md new file mode 100644 index 000000000..a79090c0a --- /dev/null +++ b/.changeset/effect-dev-seam-boundary.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Add the Stage-3 Effect boundary module for the dev seam (`src/effect/boundary.ts`, exact `effect@4.0.0-rc.112` pin). Internal only: no public API or artifact change. diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index c32440ed9..472f7f83f 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -22,7 +22,7 @@ Read `repos/effect/LLMS.md` before writing Effect code. Refresh Each Effect-consuming package has exactly one `src/effect/boundary.ts`: - [`packages/rsc-runtime/src/effect/boundary.ts`](../packages/rsc-runtime/src/effect/boundary.ts) — runtime + state kernel internals. -- `packages/agent-bundle/src/effect/boundary.ts` — added when the dev seam (Stage 3) starts using Effect. +- [`packages/agent-bundle/src/effect/boundary.ts`](../packages/agent-bundle/src/effect/boundary.ts) — the dev seam (Stage 3). Maps interruption to `AbortError` and rethrows the dev seam's typed contracts (`CodedError` subclasses, `DiagnosticError`) unchanged. The boundary owns: diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index d9e11f3d3..40337bce3 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -84,6 +84,7 @@ "ajv-formats": "3.0.1", "chokidar": "5.0.0", "commander": "15.0.0", + "effect": "4.0.0-rc.112", "es-module-lexer": "2.3.2", "fast-glob": "3.3.3", "ignore": "7.0.6", diff --git a/packages/agent-bundle/src/effect/boundary.ts b/packages/agent-bundle/src/effect/boundary.ts new file mode 100644 index 000000000..4fef0c922 --- /dev/null +++ b/packages/agent-bundle/src/effect/boundary.ts @@ -0,0 +1,164 @@ +import { + Cause, + Effect, + Exit, + type Layer, + ManagedRuntime, + type Scope, +} from 'effect'; + +import { DiagnosticError } from '../core/diagnostics.ts'; +import { CodedError } from '../core/errors.ts'; + +/** + * The sole Effect → Promise / sync edge for the `agent-bundle` package + * (the dev seam, Wave 3.5 stage 3). + * + * Internals write Effect programs. This module is the only place in this + * package that may call `Effect.runPromise` / `Effect.runSync` (and + * siblings). Public authoring stays Promise + zod: nothing here is + * re-exported from any package entry. See `docs/effect-conventions.md`. + * + * Error mapping mirrors `packages/rsc-runtime/src/effect/boundary.ts`: the + * dev seam's typed contracts (`CodedError` subclasses such as + * `EpochStoreError`, `DevLockError`, `ProjectEventHubError`, and the AB + * 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. + */ + +export interface RunPromiseOptions { + readonly signal?: AbortSignal; +} + +const interruptAs = (): Effect.Effect => + Effect.interrupt as Effect.Effect; + +export const abortError = (cause?: unknown): DOMException => { + const error = new DOMException('The operation was aborted', 'AbortError'); + if (cause !== undefined) { + error.cause = cause; + } + return error; +}; + +export const isAbortError = (error: unknown): error is DOMException => + (error instanceof DOMException && error.name === 'AbortError') + || (error instanceof Error && error.name === 'AbortError'); + +/** + * True for the dev seam's typed Error contracts: `CodedError` subclasses + * (`DevLockError`, `ProjectEventHubError`, ...), any coded-shape error that + * predates `CodedError` (`EpochStoreError` carries a bare `code` string), + * and the AB diagnostic carrier `DiagnosticError`. Plain `Error` values also + * rethrow as-is; this predicate only exists so intent is greppable at the + * boundary. + */ +export const isTypedDevError = (error: unknown): error is Error => + error instanceof CodedError + || error instanceof DiagnosticError + || (error instanceof Error && typeof (error as { readonly code?: unknown }).code === 'string'); + +export const toDevError = (value: unknown): Error => { + if (isAbortError(value) || isTypedDevError(value)) return value; + if (value instanceof Error) return value; + return new Error(typeof value === 'string' ? value : String(value)); +}; + +export const mapCause = (cause: Cause.Cause): Error => { + if (Cause.hasInterruptsOnly(cause)) return abortError(cause); + return toDevError(Cause.squash(cause)); +}; + +const throwExitFailure = (exit: Exit.Exit): A => { + if (Exit.isSuccess(exit)) return exit.value; + throw mapCause(exit.cause); +}; + +const runOptions = (options: RunPromiseOptions | undefined): Effect.RunOptions | undefined => + options?.signal === undefined ? undefined : { signal: options.signal }; + +/** + * Promise edge. Interruption (including `options.signal`) becomes + * `DOMException` `AbortError`. Typed dev-seam failures rethrow as-is. + */ +export const runPromise = async ( + effect: Effect.Effect, + options?: RunPromiseOptions, +): Promise => throwExitFailure(await Effect.runPromiseExit(effect, runOptions(options))); + +/** Promise edge that preserves the Effect `Exit` for callers that branch on cause. */ +export const runPromiseExit = async ( + effect: Effect.Effect, + options?: RunPromiseOptions, +): Promise> => Effect.runPromiseExit(effect, runOptions(options)); + +/** + * Sync edge for effects that cannot suspend. Do not use for I/O, streams, + * or anything that waits on a fiber. + */ +export const runSync = (effect: Effect.Effect): A => + throwExitFailure(Effect.runSyncExit(effect)); + +export const runSyncExit = (effect: Effect.Effect): Exit.Exit => + Effect.runSyncExit(effect); + +/** + * Internal long-lived Effect runtime. Its Layer owns one Scope, which stays + * live across Promise API calls and is finalized exactly once by close(). + */ +export interface ScopedEffectRuntime { + close(): Promise; + run(effect: Effect.Effect, options?: RunPromiseOptions): Promise; +} + +export const makeScopedEffectRuntime = ( + layer: Layer.Layer, +): ScopedEffectRuntime => { + const runtime = ManagedRuntime.make(layer); + let closing: Promise | undefined; + return Object.freeze({ + close(): Promise { + closing ??= runtime.dispose(); + return closing; + }, + async run( + effect: Effect.Effect, + options?: RunPromiseOptions, + ): Promise { + return throwExitFailure(await runtime.runPromiseExit(effect, runOptions(options))); + }, + }); +}; + +/** + * AbortSignal → Effect interruption, for programs that still run inside + * Effect and receive a host signal. The Promise edge also accepts `signal` + * directly via {@link runPromise}. + */ +export const interruptWhenAborted = ( + effect: Effect.Effect, + signal: AbortSignal, +): Effect.Effect => { + if (signal.aborted) return interruptAs(); + return Effect.raceFirst( + effect, + Effect.callback((resume) => { + const onAbort = () => { + resume(Effect.interrupt); + }; + signal.addEventListener('abort', onAbort, { once: true }); + return Effect.sync(() => { + signal.removeEventListener('abort', onAbort); + }); + }), + ); +}; + +/** + * Effect interruption → AbortSignal, for Promise/fetch APIs that take a + * signal. Requires `Scope`; close the scope to abort. This is the stage-2 + * lesson's bridge: interrupt a source through its signal instead of calling + * `cancel()` on a web stream a consumer may have locked. + */ +export const scopedAbortSignal: Effect.Effect = Effect.abortSignal; diff --git a/packages/agent-bundle/tests/effect-boundary.test.ts b/packages/agent-bundle/tests/effect-boundary.test.ts new file mode 100644 index 000000000..feead003f --- /dev/null +++ b/packages/agent-bundle/tests/effect-boundary.test.ts @@ -0,0 +1,79 @@ +import { Cause, Effect, Exit } from 'effect'; +import { describe, expect, it } from '@rstest/core'; + +import { DiagnosticError } from '../src/core/diagnostics.ts'; +import * as devApi from '../src/dev/index.ts'; +import { EpochStoreError } from '../src/dev/epoch-store.ts'; +import { DevLockError } from '../src/dev/dev-lock.ts'; +import { ProjectEventHubError } from '../src/dev/events.ts'; +import { + abortError, + interruptWhenAborted, + isAbortError, + isTypedDevError, + mapCause, + runPromise, + runPromiseExit, + runSync, + toDevError, +} from '../src/effect/boundary.ts'; +import * as rootApi from '../src/index.ts'; + +describe('effect boundary (agent-bundle dev seam)', () => { + it('is not part of any public export', () => { + expect('runPromise' in rootApi).toBe(false); + expect('runSync' in rootApi).toBe(false); + expect('runPromise' in devApi).toBe(false); + expect('interruptWhenAborted' in devApi).toBe(false); + }); + + it('resolves a successful effect', async () => { + await expect(runPromise(Effect.succeed(41))).resolves.toBe(41); + expect(runSync(Effect.succeed('ok'))).toBe('ok'); + }); + + it('rethrows the dev seam typed errors from the fail channel unchanged', async () => { + const store = new EpochStoreError('EPOCH_NOT_FOUND', 'Epoch "e1" does not exist.'); + const lock = new DevLockError('DEV_LOCK_HELD', 'Another agent-bundle dev process owns this project.'); + const hub = new ProjectEventHubError('PROJECT_EVENT_CURSOR_AHEAD', 'cursor is ahead'); + const diagnostics = new DiagnosticError([ + { code: 'AB7200', message: 'rebuild failed', severity: 'error' }, + ]); + for (const error of [store, lock, hub, diagnostics]) { + expect(isTypedDevError(error)).toBe(true); + await expect(runPromise(Effect.fail(error))).rejects.toBe(error); + } + }); + + it('maps interruption to DOMException AbortError', async () => { + const mapped = mapCause(Cause.interrupt(1)); + expect(isAbortError(mapped)).toBe(true); + expect(mapped).toBeInstanceOf(DOMException); + + const controller = new AbortController(); + controller.abort(); + await expect(runPromise(Effect.never, { signal: controller.signal })).rejects.toSatisfy(isAbortError); + }); + + it('interrupts an in-flight effect when the host signal aborts', async () => { + const controller = new AbortController(); + const pending = runPromise(interruptWhenAborted(Effect.never, controller.signal)); + await Promise.resolve(); + controller.abort(); + await expect(pending).rejects.toSatisfy(isAbortError); + }); + + it('preserves Exit on runPromiseExit', async () => { + const success = await runPromiseExit(Effect.succeed(7)); + expect(Exit.isSuccess(success)).toBe(true); + if (Exit.isSuccess(success)) expect(success.value).toBe(7); + + const failure = await runPromiseExit(Effect.fail('nope')); + expect(Exit.isFailure(failure)).toBe(true); + }); + + it('wraps non-Error fail values', () => { + expect(toDevError('plain')).toEqual(new Error('plain')); + expect(abortError().name).toBe('AbortError'); + }); +}); diff --git a/packages/rsc-runtime/tests/effect-boundary-lint.test.ts b/packages/rsc-runtime/tests/effect-boundary-lint.test.ts index eb5b43e0a..b35e87619 100644 --- a/packages/rsc-runtime/tests/effect-boundary-lint.test.ts +++ b/packages/rsc-runtime/tests/effect-boundary-lint.test.ts @@ -19,8 +19,21 @@ const apply = (filename: string, visit: (listeners: ReturnType { it('recognizes only src/effect/boundary.ts as the legal runner home', () => { expect(isEffectBoundaryFile('/fast/projects/agent-bundle/packages/rsc-runtime/src/effect/boundary.ts')).toBe(true); + expect(isEffectBoundaryFile('/fast/projects/agent-bundle/packages/agent-bundle/src/effect/boundary.ts')).toBe(true); expect(isEffectBoundaryFile('C:\\repo\\packages\\rsc-runtime\\src\\effect\\boundary.ts')).toBe(true); expect(isEffectBoundaryFile('/fast/projects/agent-bundle/packages/rsc-runtime/src/dispatcher.ts')).toBe(false); + expect(isEffectBoundaryFile('/fast/projects/agent-bundle/packages/agent-bundle/src/dev/epoch-store.ts')).toBe(false); + }); + + it('rejects ad-hoc runners in agent-bundle dev seam files', () => { + const reports = apply('packages/agent-bundle/src/dev/coordinator.ts', (listeners) => { + listeners.MemberExpression?.({ + computed: false, + object: { name: 'Effect', type: 'Identifier' }, + property: { name: 'runFork', type: 'Identifier' }, + }); + }); + expect(reports).toEqual([{ data: { name: 'Effect.runFork' }, messageId: 'forbiddenCall' }]); }); it('rejects Effect.runPromise and Effect.runSync outside the boundary', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e00aaa3d..b59653a38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ importers: commander: specifier: 15.0.0 version: 15.0.0 + effect: + specifier: 4.0.0-rc.112 + version: 4.0.0-rc.112 es-module-lexer: specifier: 2.3.2 version: 2.3.2