Merge branch 'dev' into simplify/v2-runner-transitions

This commit is contained in:
Kit Langton 2026-06-22 15:34:59 +02:00 committed by GitHub
commit 14c03f70eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
101 changed files with 11135 additions and 2034 deletions

View file

@ -69,10 +69,10 @@ export const layer = Layer.effect(
const policy = yield* Policy.Service
const integrations = yield* Integration.Service
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => {
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
if (provider.disabled) return false
if (typeof provider.request.body.apiKey === "string") return true
if (connected) return true
if (integration?.connections.length) return true
return !integration
}
@ -183,13 +183,8 @@ export const layer = Layer.effect(
available: Effect.fn("CatalogV2.provider.available")(function* () {
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
const connections = yield* integrations.connection.list()
return (yield* result.provider.all()).filter((provider) =>
available(
provider,
active.get(Integration.ID.make(provider.id)),
connections.has(Integration.ID.make(provider.id)),
),
available(provider, active.get(Integration.ID.make(provider.id))),
)
}),
},

View file

@ -29,33 +29,33 @@ export class Key extends Schema.Class<Key>("Credential.Key")({
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Info = Schema.Union([OAuth, Key])
export const Value = Schema.Union([OAuth, Key])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Credential.Info" })
export type Info = Schema.Schema.Type<typeof Info>
.annotate({ identifier: "Credential.Value" })
export type Value = Schema.Schema.Type<typeof Value>
export class Stored extends Schema.Class<Stored>("Credential.Stored")({
export class Info extends Schema.Class<Info>("Credential.Info")({
id: ID,
integrationID: IntegrationSchema.ID,
label: Schema.String,
value: Info,
value: Value,
}) {}
export interface Interface {
/** Returns every stored credential. */
readonly all: () => Effect.Effect<Stored[]>
readonly all: () => Effect.Effect<Info[]>
/** Returns stored credentials belonging to one integration. */
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Stored[]>
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Info[]>
/** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Stored | undefined>
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Replaces any credential for an integration and returns the new record. */
readonly create: (input: {
readonly integrationID: IntegrationSchema.ID
readonly value: Info
readonly value: Value
readonly label?: string
}) => Effect.Effect<Stored>
}) => Effect.Effect<Info>
/** Updates the label or secret value of a stored credential. */
readonly update: (id: ID, updates: Partial<Pick<Stored, "label" | "value">>) => Effect.Effect<void>
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
/** Removes a stored credential. */
readonly remove: (id: ID) => Effect.Effect<void>
}
@ -66,10 +66,10 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const decode = Schema.decodeUnknownSync(Info)
const decode = Schema.decodeUnknownSync(Value)
const stored = (row: typeof CredentialTable.$inferSelect) => {
if (!row.integration_id) return
return new Stored({
return new Info({
id: row.id,
integrationID: row.integration_id,
label: row.label,
@ -106,7 +106,7 @@ export const layer = Layer.effect(
return row ? stored(row) : undefined
}),
create: Effect.fn("Credential.create")(function* (input) {
const credential = new Stored({
const credential = new Info({
id: ID.create(),
integrationID: input.integrationID,
label: input.label ?? "default",

View file

@ -7,7 +7,7 @@ export const CredentialTable = sqliteTable("credential", {
id: text().$type<Credential.ID>().primaryKey(),
integration_id: text().$type<IntegrationSchema.ID>(),
label: text().notNull(),
value: text({ mode: "json" }).$type<Credential.Info>().notNull(),
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
connector_id: text(),
method_id: text(),
active: integer({ mode: "boolean" }),

View file

@ -123,6 +123,6 @@ const baseLayer = Layer.effect(
}),
)
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
export const locationLayer = layer

View file

@ -232,6 +232,6 @@ export const fffLayer = Layer.effect(
}),
)
export const defaultLayer = Layer.unwrap(
export const locationLayer = Layer.unwrap(
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
)

View file

@ -108,11 +108,11 @@ export type OAuthAuthorization = {
} & (
| {
readonly mode: "auto"
readonly callback: Effect.Effect<Credential.Info, unknown>
readonly callback: Effect.Effect<Credential.Value, unknown>
}
| {
readonly mode: "code"
readonly callback: (code: string) => Effect.Effect<Credential.Info, unknown>
readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
}
)
@ -214,8 +214,6 @@ export interface Interface extends State.Transformable<Draft> {
/** Returns all integrations with their methods and current connections. */
readonly list: () => Effect.Effect<Info[]>
readonly connection: {
/** Returns active connections for every registered or credential-backed integration. */
readonly list: () => Effect.Effect<Map<ID, IntegrationConnection.Info>>
/** Returns the active connection for one integration. */
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
/** Runs a key method and stores the resulting credential. */
@ -241,7 +239,7 @@ export interface Interface extends State.Transformable<Draft> {
/** Updates a stored credential exposed as a connection. */
readonly update: (
credentialID: Credential.ID,
updates: Partial<Pick<Credential.Stored, "label">>,
updates: Partial<Pick<Credential.Info, "label">>,
) => Effect.Effect<void>
/** Removes a stored credential connection. */
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
@ -353,39 +351,27 @@ export const locationLayer = Layer.effect(
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => {
const connected = saved.map((credential) => ({
type: "credential" as const,
id: credential.id,
label: credential.label,
}))
const detected = entry.methods
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
const credentials = saved
.map((credential) => ({
type: "credential" as const,
id: credential.id,
label: credential.label,
}))
.toReversed()
const env = (entry?.methods ?? [])
.filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name]))
.map((name) => ({ type: "env" as const, name }))
return [...connected, ...detected]
return [...credentials, ...env]
}
const activeConnection = (
entry: Entry | undefined,
saved: readonly Credential.Stored[],
): IntegrationConnection.Info | undefined => {
const credential = saved.at(-1)
if (credential) return { type: "credential", id: credential.id, label: credential.label }
if (!entry) return
const name = entry.methods
.filter((method) => method.type === "env")
.flatMap((method) => method.names)
.find((name) => process.env[name])
if (name) return { type: "env", name }
}
const project = (entry: Entry, saved: readonly Credential.Stored[]) =>
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
new Info({
id: entry.ref.id,
name: entry.ref.name,
methods: entry.methods,
connections: connections(entry, saved),
connections,
})
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
@ -399,7 +385,7 @@ export const locationLayer = Layer.effect(
return error instanceof Error ? error.message : String(error)
}
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Info, unknown>) {
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) {
const now = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(attempts, (current) => {
const attempt = current.get(attemptID)
@ -450,28 +436,18 @@ export const locationLayer = Layer.effect(
get: Effect.fn("Integration.get")(function* (id) {
const entry = state.get().integrations.get(id)
if (!entry) return undefined
return project(entry, yield* credentials.list(id))
return project(entry, resolveConnections(entry, yield* credentials.list(id)))
}),
list: Effect.fn("Integration.list")(function* () {
return (yield* Effect.forEach(state.get().integrations.values(), (entry) =>
Effect.gen(function* () {
return project(entry, yield* credentials.list(entry.ref.id))
}),
)).toSorted((a, b) => a.name.localeCompare(b.name))
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
return Array.from(state.get().integrations.values(), (entry) =>
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
).toSorted((a, b) => a.name.localeCompare(b.name))
}),
connection: {
list: Effect.fn("Integration.connection.list")(function* () {
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
return new Map(
new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => {
const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? [])
return connection ? [[id, connection] as const] : []
}),
)
}),
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
const entry = state.get().integrations.get(id)
return activeConnection(entry, yield* credentials.list(id))
return resolveConnections(entry, yield* credentials.list(id))[0]
}),
key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state

View file

@ -103,8 +103,16 @@ export const layer = Layer.effect(
}
}[keyof Hooks][] = []
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const locks = KeyedMutex.makeUnsafe<ID>()
const scope = yield* Scope.make()
// One registry-owned scope lets shutdown remove every plugin transform in one batch.
yield* Effect.addFinalizer((exit) =>
Effect.gen(function* () {
hooks = []
yield* State.batch(Scope.close(scope, exit))
}),
)
const svc = Service.of({
add: Effect.fn("Plugin.add")(function* (input) {
@ -112,7 +120,7 @@ export const layer = Layer.effect(
yield* locks.withLock(id)(
Effect.gen(function* () {
const existing = hooks.find((item) => item.id === id)
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore)
const childScope = yield* Scope.fork(scope)
const result = yield* input.effect.pipe(
Scope.provide(childScope),
@ -181,7 +189,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const existing = hooks.find((item) => item.id === id)
hooks = hooks.filter((item) => item.id !== id)
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore)
}),
)
}),

View file

@ -126,10 +126,7 @@ export interface Interface {
sessionID: SessionSchema.ID
after?: number
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
readonly switchAgent: (input: {
sessionID: SessionSchema.ID
agent: string
}) => Effect.Effect<void, OperationUnavailableError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: {
sessionID: SessionSchema.ID
model: ModelV2.Ref
@ -376,8 +373,14 @@ export const layer = Layer.effect(
skill: Effect.fn("V2Session.skill")(function* () {
return yield* new OperationUnavailableError({ operation: "skill" })
}),
switchAgent: Effect.fn("V2Session.switchAgent")(function* () {
return yield* new OperationUnavailableError({ operation: "switchAgent" })
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
agent: input.agent,
})
}),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
yield* result.get(input.sessionID)

View file

@ -44,7 +44,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
/** Test or embedding seam for supplying a model resolver directly. */
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => {
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Info) => {
if (credential?.value.type === "key") return Auth.value(credential.value.key)
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
@ -85,7 +85,7 @@ const apiName = (model: ModelV2.Info) =>
export const fromCatalogModel = (
model: ModelV2.Info,
connection?: IntegrationConnection.Info,
credential?: Credential.Stored,
credential?: Credential.Info,
): Effect.Effect<Model, UnsupportedApiError> => {
const resolved =
credential?.value.metadata === undefined