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
25 changes: 15 additions & 10 deletions packages/opencode/src/effect/run-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Layer, ManagedRuntime } from "effect"
import { Effect, Fiber, Layer, ManagedRuntime } from "effect"
import * as Context from "effect/Context"
import { Instance } from "@/project/instance"
import { LocalContext } from "@/util/local-context"
Expand All @@ -24,15 +24,20 @@ export function attachWith<A, E, R>(effect: Effect.Effect<A, E, R>, refs: Refs):
}

export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try {
return attachWith(effect, {
instance: Instance.current,
workspace: WorkspaceContext.workspaceID,
})
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
return effect
const workspace = WorkspaceContext.workspaceID
const instance = (() => {
try {
return Instance.current
} catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err
}
})()
if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace })
const fiber = Fiber.getCurrent()
return attachWith(effect, {
instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined),
workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined),
})
}

export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
Expand Down
40 changes: 8 additions & 32 deletions packages/opencode/src/project/instance.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,18 @@
import { Effect } from "effect"
import { InstanceRef } from "@/effect/instance-ref"
import * as Project from "./project"
import { context, type InstanceContext } from "./instance-context"
import { InstanceStore } from "./instance-store"

export type { InstanceContext } from "./instance-context"
export type { LoadInput } from "./instance-store"

type LegacyLoadInput = {
directory: string
init?: () => Promise<unknown>
project?: Project.Info
worktree?: string
}

// Promise-style legacy inits often read Instance.directory etc. from the ALS context.
// The new Effect-typed init path doesn't bind ALS — it provides InstanceRef. To keep
// legacy inits working without forcing every test to convert, bind ALS around the
// Promise call here using the instance ctx that the store provides via InstanceRef.
const liftLegacyInput = (input: LegacyLoadInput): InstanceStore.LoadInput => {
const { init, ...rest } = input
if (!init) return rest
return {
...rest,
init: Effect.gen(function* () {
const ctx = yield* InstanceRef
yield* Effect.promise(() => (ctx ? context.provide(ctx, init) : init()))
}),
}
}

export const Instance = {
load(input: LegacyLoadInput): Promise<InstanceContext> {
return InstanceStore.runtime.runPromise((store) => store.load(liftLegacyInput(input)))
load(input: InstanceStore.LoadInput): Promise<InstanceContext> {
return InstanceStore.runtime.runPromise((store) => store.load(input))
},
async provide<R>(input: { directory: string; init?: () => Promise<unknown>; fn: () => R }): Promise<R> {
return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () =>
input.fn(),
async provide<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }): Promise<R> {
return context.provide(
await Instance.load({ directory: input.directory, init: input.init }),
async () => input.fn(),
)
},
get current() {
Expand Down Expand Up @@ -69,8 +45,8 @@ export const Instance = {
restore<R>(ctx: InstanceContext, fn: () => R): R {
return context.provide(ctx, fn)
},
async reload(input: LegacyLoadInput) {
return InstanceStore.runtime.runPromise((store) => store.reload(liftLegacyInput(input)))
async reload(input: InstanceStore.LoadInput) {
return InstanceStore.runtime.runPromise((store) => store.reload(input))
},
async dispose() {
return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current))
Expand Down
40 changes: 40 additions & 0 deletions packages/opencode/test/effect/run-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { expect } from "bun:test"
import { Effect, Layer, Context } from "effect"
import { InstanceRef } from "../../src/effect/instance-ref"
import { makeRuntime } from "../../src/effect/run-service"
import { ProjectID } from "../../src/project/schema"
import { it } from "../lib/effect"

class Shared extends Context.Service<Shared, { readonly id: number }>()("@test/Shared") {}
const testDirectory = "/tmp/opencode-test"

it.live("makeRuntime shares dependent layers through the shared memo map", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -47,3 +50,40 @@ it.live("makeRuntime shares dependent layers through the shared memo map", () =>
expect(n).toBe(1)
}),
)

it.live("makeRuntime inherits InstanceRef from the current fiber", () =>
Effect.gen(function* () {
class NeedsInstance extends Context.Service<
NeedsInstance,
{ readonly directory: () => Effect.Effect<string | undefined> }
>()("@test/NeedsInstance") {}

const runtime = makeRuntime(
NeedsInstance,
Layer.succeed(
NeedsInstance,
NeedsInstance.of({
directory: () =>
Effect.gen(function* () {
return (yield* InstanceRef)?.directory
}),
}),
),
)

const actual = yield* Effect.promise(() => runtime.runPromise((svc) => svc.directory()))

expect(actual).toBe(testDirectory)
}).pipe(
Effect.provideService(InstanceRef, {
directory: testDirectory,
worktree: testDirectory,
project: {
id: ProjectID.global,
worktree: testDirectory,
time: { created: 0, updated: 0 },
sandboxes: [],
},
}),
),
)
18 changes: 18 additions & 0 deletions packages/opencode/test/project/instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,4 +252,22 @@ describe("InstanceStore", () => {
expect(() => Instance.current).toThrow()
}),
)

it.live("does not install legacy ALS around Effect init", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()

const directory = yield* Effect.promise(() =>
Instance.provide({
directory: dir,
init: Effect.sync(() => {
expect(() => Instance.current).toThrow()
}),
fn: () => Instance.directory,
}),
)

expect(directory).toBe(dir)
}),
)
})
40 changes: 20 additions & 20 deletions packages/opencode/test/provider/amazon-bedrock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
})
await Instance.provide({
directory: tmp.path,
init: async () => {
init: Effect.promise(async () => {
set("AWS_REGION", "us-east-1")
set("AWS_PROFILE", "default")
},
}).pipe(Effect.asVoid),
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
Expand All @@ -70,10 +70,10 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async ()
})
await Instance.provide({
directory: tmp.path,
init: async () => {
init: Effect.promise(async () => {
set("AWS_REGION", "eu-west-1")
set("AWS_PROFILE", "default")
},
}).pipe(Effect.asVoid),
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
Expand Down Expand Up @@ -125,11 +125,11 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {

await Instance.provide({
directory: tmp.path,
init: async () => {
init: Effect.promise(async () => {
set("AWS_PROFILE", "")
set("AWS_ACCESS_KEY_ID", "")
set("AWS_BEARER_TOKEN_BEDROCK", "")
},
}).pipe(Effect.asVoid),
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
Expand Down Expand Up @@ -171,10 +171,10 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async
})
await Instance.provide({
directory: tmp.path,
init: async () => {
init: Effect.promise(async () => {
set("AWS_PROFILE", "default")
set("AWS_ACCESS_KEY_ID", "test-key-id")
},
}).pipe(Effect.asVoid),
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
Expand Down Expand Up @@ -203,9 +203,9 @@ test("Bedrock: includes custom endpoint in options when specified", async () =>
})
await Instance.provide({
directory: tmp.path,
init: async () => {
init: Effect.promise(async () => {
set("AWS_PROFILE", "default")
},
}).pipe(Effect.asVoid),
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
Expand Down Expand Up @@ -236,12 +236,12 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async ()
})
await Instance.provide({
directory: tmp.path,
init: async () => {
init: Effect.promise(async () => {
set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token")
set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role")
set("AWS_PROFILE", "")
set("AWS_ACCESS_KEY_ID", "")
},
}).pipe(Effect.asVoid),
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
Expand Down Expand Up @@ -279,9 +279,9 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () =>
})
await Instance.provide({
directory: tmp.path,
init: async () => {
init: Effect.promise(async () => {
set("AWS_PROFILE", "default")
},
}).pipe(Effect.asVoid),
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
Expand Down Expand Up @@ -316,9 +316,9 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => {
})
await Instance.provide({
directory: tmp.path,
init: async () => {
init: Effect.promise(async () => {
set("AWS_PROFILE", "default")
},
}).pipe(Effect.asVoid),
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
Expand Down Expand Up @@ -352,9 +352,9 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () =>
})
await Instance.provide({
directory: tmp.path,
init: async () => {
init: Effect.promise(async () => {
set("AWS_PROFILE", "default")
},
}).pipe(Effect.asVoid),
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
Expand Down Expand Up @@ -388,9 +388,9 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a
})
await Instance.provide({
directory: tmp.path,
init: async () => {
init: Effect.promise(async () => {
set("AWS_PROFILE", "default")
},
}).pipe(Effect.asVoid),
fn: async () => {
const providers = await list()
expect(providers[ProviderID.amazonBedrock]).toBeDefined()
Expand Down
Loading
Loading