feat(codemode): let a synchronous global block on an Effect

This commit is contained in:
Aiden Cline
2026-11-01 22:11:06 -06:00
Unverified
parent e5c9b0151b
commit 06de01cd1c
4 changed files with 34 additions and 16 deletions
+3 -2
View File
@@ -114,8 +114,9 @@ that returns a Promise or an Effect, so a denied call rejects its promise like a
Extension.make({ name: "web", globals: { fetch: Extension.async((url: string) => globalThis.fetch(url)) } })
```
A global may return a value, a Promise, or an Effect. An Effect's requirements join the runtime's environment the same
way a tool's do, so the host can provide per-execution services such as the calling session:
A global may return a value, a Promise, or an Effect. A synchronous global's Effect runs to completion before the call
returns, so the host can offer blocking calls such as `fs.readFileSync`. An Effect's requirements join the runtime's
environment the same way a tool's do, so the host can provide per-execution services such as the calling session:
```ts
const web = Extension.make({
+5 -3
View File
@@ -8,7 +8,9 @@ import type { Effect } from "effect"
* function inside a result is callable the same way. Extension calls are not tool calls. A global may return a value,
* a Promise, or an Effect, and an Effect's requirements join the runtime's environment.
*
* A global is synchronous unless it is an `async` function or is marked with `async`. The `extension.before` hook
* A global is synchronous unless it is an `async` function or is marked with `async`. A synchronous global that
* returns an Effect blocks the program until the Effect completes and returns its value, as `fs.readFileSync` does;
* an asynchronous one hands the program a promise. The `extension.before` hook
* runs before the call, so a denied call never reaches the host; it fails the way the global would, by rejecting the
* promise of an asynchronous global and by throwing from a synchronous one. Functions inside a global object are
* called the same way, named by their path (`fs.readFile`).
@@ -20,13 +22,13 @@ export type Extension<Globals extends Readonly<Record<string, unknown>> = Readon
readonly globals: Globals
}
/** The Effect environment required by an extension's Effect-returning functions, at the top level or one level in. */
/** The Effect environment required by an extension's Effect-returning functions, at any depth (`fs.promises.readFile`). */
export type Services<E> = E extends { readonly globals: infer Globals } ? GlobalServices<Globals[keyof Globals]> : never
type GlobalServices<Global> = Global extends (...args: never) => unknown
? Requirements<Global>
: Global extends object
? Requirements<Global[keyof Global]>
? GlobalServices<Global[keyof Global]>
: never
type Requirements<F> = F extends (...args: never) => infer Result ? Effect.Services<Result> : never
@@ -185,7 +185,8 @@ export const extensionGlobals = <R>(
)
if (before) yield ctx.runner.blocking(before)
const host = callHost<R>(value, args)
if (host.async) return pending(settle(host.effect), site)
// A synchronous global's Effect runs to completion before the call returns, like `readFileSync`.
if (host.result === "promise") return pending(settle(host.effect), site)
return (yield ctx.runner.blocking(settle(host.effect))) as Value
},
)
@@ -216,20 +217,17 @@ export const extensionGlobals = <R>(
)
}
/**
* Calls a host function and describes what the program waits on. A returned Effect or Promise settles later; a
* returned value or a throw settles now.
*/
/** Calls a host function and describes what it returned: an Effect, a Promise, or a value (or a throw) as an Effect. */
const callHost = <R>(value: Function, args: ReadonlyArray<unknown>) => {
try {
const result: unknown = value.apply(undefined, args)
// Extension.Services adds an Effect global's requirements to R.
if (Effect.isEffect(result)) return { async: true, effect: result as Effect.Effect<unknown, unknown, R> }
if (Effect.isEffect(result)) return { result: "effect", effect: result as Effect.Effect<unknown, unknown, R> }
if (result instanceof Promise)
return { async: true, effect: Effect.tryPromise({ try: () => result, catch: (reason) => reason }) }
return { async: false, effect: Effect.succeed(result) }
return { result: "promise", effect: Effect.tryPromise({ try: () => result, catch: (reason) => reason }) }
return { result: "value", effect: Effect.succeed(result) }
} catch (reason) {
return { async: false, effect: Effect.fail(reason) }
return { result: "value", effect: Effect.fail(reason) }
}
}
+19 -2
View File
@@ -417,13 +417,18 @@ describe("Effect globals", () => {
const web = Extension.make({
name: "web",
globals: {
fetch: (url: string) =>
fetch: Extension.async((url: string) =>
Effect.gen(function* () {
const session = yield* Session
sent.push(url)
return { url, session: session.id }
}),
fail: () => Effect.fail(new RangeError("denied")),
),
fail: Extension.async(() => Effect.fail(new RangeError("denied"))),
fs: {
readSync: (path: string) => Session.use((session) => Effect.succeed(`${session.id}:${path}`)),
failSync: () => Effect.fail(new RangeError("missing")),
},
},
})
@@ -458,6 +463,18 @@ describe("Effect globals", () => {
expect(result).toMatchObject({ ok: true, value: [true, "denied"] })
})
test("a synchronous Effect global returns its value, or throws, before the next statement", async () => {
const result = await Effect.runPromise(
CodeMode.make({ extensions: [web] })
.execute(
`const text = fs.readSync("a")
try { fs.failSync() } catch (e) { return [text, e instanceof RangeError, e.message] }`,
)
.pipe(Effect.provideService(Session, { id: "ses_1" })),
)
expect(result).toMatchObject({ ok: true, value: ["ses_1:a", true, "missing"] })
})
test("a denying before hook stops the Effect before it runs", async () => {
sent.length = 0
const runtime = CodeMode.make({