Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/effect-dev-seam-boundary.md
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.
2 changes: 1 addition & 1 deletion docs/effect-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
164 changes: 164 additions & 0 deletions packages/agent-bundle/src/effect/boundary.ts
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();
Comment thread
ScriptedAlchemy marked this conversation as resolved.
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;
79 changes: 79 additions & 0 deletions packages/agent-bundle/tests/effect-boundary.test.ts
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');
});
});
13 changes: 13 additions & 0 deletions packages/rsc-runtime/tests/effect-boundary-lint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,21 @@ const apply = (filename: string, visit: (listeners: ReturnType<typeof rule.creat
describe('effect-boundary lint', () => {
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', () => {
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading