-
Notifications
You must be signed in to change notification settings - Fork 0
feat(dev): add the dev-seam Effect boundary module (wave 3.5 stage 3, PR 1) #158
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = <A, E, R>(): Effect.Effect<A, E, R> => | ||
| Effect.interrupt as Effect.Effect<A, E, R>; | ||
|
|
||
| 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 = <E>(cause: Cause.Cause<E>): Error => { | ||
| if (Cause.hasInterruptsOnly(cause)) return abortError(cause); | ||
| return toDevError(Cause.squash(cause)); | ||
| }; | ||
|
|
||
| const throwExitFailure = <A, E>(exit: Exit.Exit<A, E>): 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 <A, E>( | ||
| effect: Effect.Effect<A, E>, | ||
| options?: RunPromiseOptions, | ||
| ): Promise<A> => throwExitFailure(await Effect.runPromiseExit(effect, runOptions(options))); | ||
|
|
||
| /** Promise edge that preserves the Effect `Exit` for callers that branch on cause. */ | ||
| export const runPromiseExit = async <A, E>( | ||
| effect: Effect.Effect<A, E>, | ||
| options?: RunPromiseOptions, | ||
| ): Promise<Exit.Exit<A, E>> => 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 = <A, E>(effect: Effect.Effect<A, E>): A => | ||
| throwExitFailure(Effect.runSyncExit(effect)); | ||
|
|
||
| export const runSyncExit = <A, E>(effect: Effect.Effect<A, E>): Exit.Exit<A, E> => | ||
| 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<R> { | ||
| close(): Promise<void>; | ||
| run<A, E2>(effect: Effect.Effect<A, E2, R>, options?: RunPromiseOptions): Promise<A>; | ||
| } | ||
|
|
||
| export const makeScopedEffectRuntime = <R, E>( | ||
| layer: Layer.Layer<R, E>, | ||
| ): ScopedEffectRuntime<R> => { | ||
| const runtime = ManagedRuntime.make(layer); | ||
| let closing: Promise<void> | undefined; | ||
| return Object.freeze({ | ||
| close(): Promise<void> { | ||
| closing ??= runtime.dispose(); | ||
| return closing; | ||
| }, | ||
| async run<A, E2>( | ||
| effect: Effect.Effect<A, E2, R>, | ||
| options?: RunPromiseOptions, | ||
| ): Promise<A> { | ||
| 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 = <A, E, R>( | ||
| effect: Effect.Effect<A, E, R>, | ||
| signal: AbortSignal, | ||
| ): Effect.Effect<A, E, R> => { | ||
| if (signal.aborted) return interruptAs(); | ||
| return Effect.raceFirst( | ||
| effect, | ||
| Effect.callback<never>((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<AbortSignal, never, Scope.Scope> = Effect.abortSignal; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.