feat(core): include resolved location info

This commit is contained in:
Dax Raad 2026-06-03 17:47:38 -04:00
commit 0bd61d2826
20 changed files with 92 additions and 82 deletions

View file

@ -30,7 +30,7 @@ export type Payload<D extends Definition = Definition> = {
readonly type: D["type"]
readonly data: Data<D>
readonly version?: number
readonly location?: Location.Ref
readonly location?: Location.Info
readonly metadata?: Record<string, unknown>
}
@ -77,7 +77,7 @@ export function define<const Type extends string, Fields extends Schema.Struct.F
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type),
version: Schema.optional(Schema.Number),
location: Schema.optional(Location.Ref),
location: Schema.optional(Location.Info),
data: Data,
}).annotate({ identifier: input.type })
@ -106,7 +106,7 @@ export function definitions() {
export interface PublishOptions {
readonly id?: ID
readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref
readonly location?: Location.Info
}
export interface Interface {
@ -264,7 +264,11 @@ export const layer = Layer.effect(
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
? new Location.Info({
directory: serviceLocation.directory,
workspaceID: serviceLocation.workspaceID,
project: serviceLocation.project,
})
: undefined)
return yield* publishEvent({
id: options?.id ?? ID.create(),

View file

@ -10,16 +10,23 @@ export const Ref = Schema.Struct({
}).annotate({ identifier: "Location.Ref" })
export type Ref = typeof Ref.Type
export interface Interface {
readonly directory: AbsolutePath
readonly workspaceID?: string
readonly project: {
readonly id: Project.ID
readonly directory: AbsolutePath
}
export class Info extends Schema.Class<Info>("Location.Info")({
directory: AbsolutePath,
workspaceID: Schema.String.pipe(Schema.optional),
project: Schema.Struct({
id: Project.ID,
directory: AbsolutePath,
}),
}) {}
export interface Interface extends Info {
readonly vcs?: Project.Vcs
}
export function response<S extends Schema.Top>(data: S) {
return Schema.Struct({ location: Info, data })
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
export const layer = (ref: Ref) =>

View file

@ -6,6 +6,7 @@ import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { Policy } from "@opencode-ai/core/policy"
import { Project } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
@ -187,7 +188,12 @@ describe("CatalogV2", () => {
yield* events.publish(
PluginV2.Event.Added,
{ id: PluginV2.ID.make("test-transform") },
{ location: { directory: AbsolutePath.make("other") } },
{
location: new Location.Info({
directory: AbsolutePath.make("other"),
project: { id: Project.ID.global, directory: AbsolutePath.make("other") },
}),
},
)
yield* Effect.yieldNow

View file

@ -5,6 +5,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Project } from "@opencode-ai/core/project"
import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
@ -80,7 +81,11 @@ describe("EventV2", () => {
expect(event.type).toBe("test.message")
expect(event).not.toHaveProperty("version")
expect(event.data).toEqual({ text: "hello" })
expect(event.location).toEqual({ directory: AbsolutePath.make("project"), workspaceID: "workspace" })
expect(event.location).toEqual({
directory: AbsolutePath.make("project"),
workspaceID: "workspace",
project: { id: Project.ID.global, directory: AbsolutePath.make("project") },
})
}),
)

View file

@ -3,6 +3,8 @@
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { GlobalBus } from "@/bus/global"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import "@opencode-ai/core/account"
import "@opencode-ai/core/catalog"
@ -24,10 +26,11 @@ export const layer = Layer.effect(
const workspaceID = yield* WorkspaceRef
return yield* events.publish(definition, data, {
...options,
location: {
location: new Location.Info({
directory: AbsolutePath.make(ctx.directory),
...(workspaceID ? { workspaceID } : {}),
},
project: { id: Project.ID.make(ctx.project.id), directory: AbsolutePath.make(ctx.worktree) },
}),
})
})

View file

@ -1,4 +1,5 @@
import { CommandV2 } from "@opencode-ai/core/command"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { V2Authorization } from "../../middleware/authorization"
@ -8,7 +9,7 @@ export const CommandGroup = HttpApiGroup.make("v2.command")
.add(
HttpApiEndpoint.get("commands", "/api/command", {
query: LocationQuery,
success: Schema.Array(CommandV2.Info),
success: Location.response(Schema.Array(CommandV2.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(

View file

@ -1,4 +1,5 @@
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { RelativePath } from "@opencode-ai/core/schema"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@ -21,7 +22,7 @@ export const FileSystemGroup = HttpApiGroup.make("v2.fs")
.add(
HttpApiEndpoint.get("read", "/api/fs/read", {
query: ReadQuery,
success: FileSystem.Content,
success: Location.response(FileSystem.Content),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
@ -35,7 +36,7 @@ export const FileSystemGroup = HttpApiGroup.make("v2.fs")
.add(
HttpApiEndpoint.get("list", "/api/fs/list", {
query: ListQuery,
success: Schema.Array(FileSystem.Entry),
success: Location.response(Schema.Array(FileSystem.Entry)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(

View file

@ -9,7 +9,7 @@ import { SkillV2 } from "@opencode-ai/core/skill"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect, Layer, Schema } from "effect"
import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
export const LocationQuery = Schema.Struct({
@ -36,6 +36,20 @@ export const locationQueryOpenApi = OpenApi.annotations({
},
})
export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const location = yield* Location.Service
return {
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: yield* data,
}
})
}
export class V2LocationMiddleware extends HttpApiMiddleware.Service<
V2LocationMiddleware,
{
@ -67,19 +81,7 @@ export const layer = Layer.effect(
return V2LocationMiddleware.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* Effect.gen(function* () {
const location = yield* Location.Service
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.succeed(
HttpServerResponse.setHeaders(response, {
"x-opencode-directory": location.directory,
"x-opencode-project-id": location.project.id,
...(location.workspaceID ? { "x-opencode-workspace": location.workspaceID } : {}),
}),
),
)
return yield* effect
}).pipe(Effect.provide(locations.get(ref(request))))
return yield* effect.pipe(Effect.provide(locations.get(ref(request))))
}),
)
}),

View file

@ -1,4 +1,5 @@
import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ServiceUnavailableError } from "../../errors"
@ -9,7 +10,7 @@ export const ModelGroup = HttpApiGroup.make("v2.model")
.add(
HttpApiEndpoint.get("models", "/api/model", {
query: LocationQuery,
success: Schema.Array(ModelV2.Info),
success: Location.response(Schema.Array(ModelV2.Info)),
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)

View file

@ -1,4 +1,5 @@
import { PermissionV2 } from "@opencode-ai/core/permission"
import { Location } from "@opencode-ai/core/location"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionV2 } from "@opencode-ai/core/session"
@ -12,7 +13,7 @@ export const PermissionGroup = HttpApiGroup.make("v2.permission")
.add(
HttpApiEndpoint.get("permissionRequests", "/api/permission/request", {
query: LocationQuery,
success: Schema.Array(PermissionV2.Request),
success: Location.response(Schema.Array(PermissionV2.Request)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(

View file

@ -1,4 +1,5 @@
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors"
@ -9,7 +10,7 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
.add(
HttpApiEndpoint.get("providers", "/api/provider", {
query: LocationQuery,
success: Schema.Array(ProviderV2.Info),
success: Location.response(Schema.Array(ProviderV2.Info)),
error: ServiceUnavailableError,
})
.annotateMerge(locationQueryOpenApi)
@ -25,7 +26,7 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
HttpApiEndpoint.get("provider", "/api/provider/:providerID", {
params: { providerID: ProviderV2.ID },
query: LocationQuery,
success: ProviderV2.Info,
success: Location.response(ProviderV2.Info),
error: [ProviderNotFoundError, ServiceUnavailableError],
})
.annotateMerge(locationQueryOpenApi)

View file

@ -1,4 +1,5 @@
import { SkillV2 } from "@opencode-ai/core/skill"
import { Location } from "@opencode-ai/core/location"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { V2Authorization } from "../../middleware/authorization"
@ -8,7 +9,7 @@ export const SkillGroup = HttpApiGroup.make("v2.skill")
.add(
HttpApiEndpoint.get("skills", "/api/skill", {
query: LocationQuery,
success: Schema.Array(SkillV2.Info),
success: Location.response(Schema.Array(SkillV2.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(

View file

@ -2,7 +2,8 @@ import { CommandV2 } from "@opencode-ai/core/command"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { response } from "../../groups/v2/location"
export const commandHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.command", (handlers) =>
handlers.handle("commands", () => CommandV2.Service.use((command) => command.list())),
handlers.handle("commands", () => response(CommandV2.Service.use((command) => command.list()))),
)

View file

@ -2,11 +2,12 @@ import { FileSystem } from "@opencode-ai/core/filesystem"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { response } from "../../groups/v2/location"
export const fileSystemHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.fs", (handlers) =>
Effect.gen(function* () {
return handlers
.handle("read", (ctx) => FileSystem.Service.use((fs) => fs.read(ctx.query)))
.handle("list", (ctx) => FileSystem.Service.use((fs) => fs.list(ctx.query)))
.handle("read", (ctx) => response(FileSystem.Service.use((fs) => fs.read(ctx.query))))
.handle("list", (ctx) => response(FileSystem.Service.use((fs) => fs.list(ctx.query))))
}),
)

View file

@ -4,6 +4,7 @@ import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { ServiceUnavailableError } from "../../errors"
import { response } from "../../groups/v2/location"
const catalogUnavailable = new ServiceUnavailableError({
message: "Model catalog is unavailable",
@ -18,7 +19,7 @@ export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
return yield* catalog.model.available()
return yield* response(catalog.model.available())
}),
)
}),

View file

@ -9,6 +9,7 @@ import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
import { response } from "../../groups/v2/location"
function missingRequest(id: PermissionV2.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
@ -19,7 +20,7 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.perm
return handlers.handle(
"permissionRequests",
Effect.fn(function* () {
return yield* (yield* PermissionV2.Service).list()
return yield* response((yield* PermissionV2.Service).list())
}),
)
}),

View file

@ -4,6 +4,7 @@ import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { ProviderNotFoundError, ServiceUnavailableError } from "../../errors"
import { response } from "../../groups/v2/location"
const catalogUnavailable = new ServiceUnavailableError({
message: "Provider catalog is unavailable",
@ -19,7 +20,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provid
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
return yield* catalog.provider.available()
return yield* response(catalog.provider.available())
}),
)
.handle(
@ -28,7 +29,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.provid
const catalog = yield* Catalog.Service
const pluginBoot = yield* PluginBoot.Service
yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable)))
return yield* catalog.provider.get(ctx.params.providerID).pipe(
return yield* response(catalog.provider.get(ctx.params.providerID)).pipe(
Effect.catchTag("CatalogV2.ProviderNotFound", (error) =>
Effect.fail(
new ProviderNotFoundError({

View file

@ -1,7 +1,8 @@
import { SkillV2 } from "@opencode-ai/core/skill"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { response } from "../../groups/v2/location"
export const skillHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.skill", (handlers) =>
handlers.handle("skills", () => SkillV2.Service.use((skill) => skill.list())),
handlers.handle("skills", () => response(SkillV2.Service.use((skill) => skill.list()))),
)

View file

@ -40,7 +40,7 @@ import type { Provider } from "@/provider/provider"
import { Permission } from "@/permission"
import { Global } from "@opencode-ai/core/global"
import { Effect, Layer, Option, Context, Schema, Types } from "effect"
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
@ -112,13 +112,6 @@ export function fromRow(row: SessionRow): Info {
}
}
function eventLocation(info: Pick<Info, "directory" | "workspaceID">) {
return {
directory: AbsolutePath.make(info.directory),
workspaceID: info.workspaceID,
}
}
export function toRow(info: Info) {
return {
id: info.id,
@ -544,20 +537,6 @@ export const layer: Layer.Layer<
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
const locationForSession = Effect.fnUntraced(function* (sessionID: SessionID) {
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row) return
return {
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspaceID ?? undefined,
}
})
const createNext = Effect.fn("Session.createNext")(function* (input: {
id?: SessionID
title?: string
@ -597,7 +576,6 @@ export const layer: Layer.Layer<
yield* events.publish(
SessionV1.Event.Created,
{ sessionID: result.id, info: result },
{ location: eventLocation(result) },
)
return result
@ -688,7 +666,6 @@ export const layer: Layer.Layer<
yield* events.publish(
SessionV1.Event.Deleted,
{ sessionID, info: session },
{ location: eventLocation(session) },
)
yield* events.remove(sessionID)
} catch (e) {
@ -698,14 +675,12 @@ export const layer: Layer.Layer<
const updateMessage = <T extends SessionV1.Info>(msg: T): Effect.Effect<T> =>
Effect.gen(function* () {
const location = yield* locationForSession(msg.sessionID)
yield* events.publish(SessionV1.Event.MessageUpdated, { sessionID: msg.sessionID, info: msg }, { location })
yield* events.publish(SessionV1.Event.MessageUpdated, { sessionID: msg.sessionID, info: msg })
return msg
}).pipe(Effect.withSpan("Session.updateMessage"))
const updatePart = <T extends SessionV1.Part>(part: T): Effect.Effect<T> =>
Effect.gen(function* () {
const location = yield* locationForSession(part.sessionID)
yield* events.publish(
SessionV1.Event.PartUpdated,
{
@ -713,7 +688,6 @@ export const layer: Layer.Layer<
part: structuredClone(part),
time: Date.now(),
},
{ location },
)
return part
}).pipe(Effect.withSpan("Session.updatePart"))
@ -819,7 +793,7 @@ export const layer: Layer.Layer<
revert: info.revert === null ? undefined : (info.revert ?? current.revert),
permission: info.permission === null ? undefined : (info.permission ?? current.permission),
} as Info
yield* events.publish(SessionV1.Event.Updated, { sessionID, info: next }, { location: eventLocation(next) })
yield* events.publish(SessionV1.Event.Updated, { sessionID, info: next })
})
const touch = Effect.fn("Session.touch")(function* (sessionID: SessionID) {
@ -917,14 +891,12 @@ export const layer: Layer.Layer<
sessionID: SessionID
messageID: MessageID
}) {
const location = yield* locationForSession(input.sessionID)
yield* events.publish(
SessionV1.Event.MessageRemoved,
{
sessionID: input.sessionID,
messageID: input.messageID,
},
{ location },
)
return input.messageID
})
@ -934,7 +906,6 @@ export const layer: Layer.Layer<
messageID: MessageID
partID: PartID
}) {
const location = yield* locationForSession(input.sessionID)
yield* events.publish(
SessionV1.Event.PartRemoved,
{
@ -942,7 +913,6 @@ export const layer: Layer.Layer<
messageID: input.messageID,
partID: input.partID,
},
{ location },
)
return input.partID
})

View file

@ -26,15 +26,16 @@ afterEach(async () => {
})
describe("v2 location HttpApi", () => {
test("returns command and skill snapshots with resolved location headers", async () => {
test("returns command and skill snapshots with resolved locations", async () => {
await using tmp = await tmpdir({ git: true })
for (const route of ["/api/command", "/api/skill"]) {
const response = await request(route, tmp.path)
expect(response.status).toBe(200)
expect(await response.json()).toBeArray()
expect(response.headers.get("x-opencode-directory")).toBe(tmp.path)
expect(response.headers.get("x-opencode-project-id")).toBeTruthy()
const body = (await response.json()) as { location: { directory: string; project: { id: string } }; data: unknown }
expect(body.data).toBeArray()
expect(body.location.directory).toBe(tmp.path)
expect(body.location.project.id).toBeTruthy()
}
})
})