mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 22:25:02 +03:00
feat(codemode): let a synchronous global block on an Effect
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user