refactor(opencode): replace instance bus with event v2
This commit is contained in:
@@ -36,6 +36,8 @@ export type Payload<D extends Definition = Definition> = {
|
||||
|
||||
export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
||||
type AnyProjector = (event: Payload) => Effect.Effect<void>
|
||||
export type Listener = (event: Payload) => Effect.Effect<void>
|
||||
export type Unsubscribe = Effect.Effect<void>
|
||||
|
||||
export type SerializedEvent = {
|
||||
readonly id: ID
|
||||
@@ -114,6 +116,7 @@ export interface Interface {
|
||||
) => Effect.Effect<Payload<D>>
|
||||
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
||||
readonly all: () => Stream.Stream<Payload>
|
||||
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
|
||||
readonly project: <D extends Definition>(definition: D, projector: Projector<D>) => Effect.Effect<void>
|
||||
readonly replay: (
|
||||
event: SerializedEvent,
|
||||
@@ -135,6 +138,7 @@ export const layer = Layer.effect(
|
||||
const all = yield* PubSub.unbounded<Payload>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
const projectors = new Map<string, AnyProjector[]>()
|
||||
const listeners = new Array<Listener>()
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
const getOrCreate = (definition: Definition) =>
|
||||
@@ -247,6 +251,9 @@ export const layer = Layer.effect(
|
||||
data,
|
||||
} as Payload<D>
|
||||
yield* commitSyncEvent(event as Payload)
|
||||
for (const listener of listeners) {
|
||||
yield* listener(event as Payload)
|
||||
}
|
||||
const pubsub = typed.get(event.type)
|
||||
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
|
||||
yield* PubSub.publish(all, event as Payload)
|
||||
@@ -270,6 +277,9 @@ export const layer = Layer.effect(
|
||||
} as Payload
|
||||
yield* commitSyncEvent(payload, { seq: event.seq, aggregateID: event.aggregateID, ownerID: options?.ownerID })
|
||||
if (options?.publish) {
|
||||
for (const listener of listeners) {
|
||||
yield* listener(payload)
|
||||
}
|
||||
const pubsub = typed.get(payload.type)
|
||||
if (pubsub) yield* PubSub.publish(pubsub, payload)
|
||||
yield* PubSub.publish(all, payload)
|
||||
@@ -336,6 +346,15 @@ export const layer = Layer.effect(
|
||||
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
|
||||
|
||||
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
|
||||
Effect.sync(() => {
|
||||
listeners.push(listener)
|
||||
return Effect.sync(() => {
|
||||
const index = listeners.indexOf(listener)
|
||||
if (index >= 0) listeners.splice(index, 1)
|
||||
})
|
||||
})
|
||||
|
||||
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
const list = projectors.get(definition.type) ?? []
|
||||
@@ -343,7 +362,7 @@ export const layer = Layer.effect(
|
||||
projectors.set(definition.type, list)
|
||||
})
|
||||
|
||||
return Service.of({ publish, subscribe, all: streamAll, project, replay, replayAll, remove, claim })
|
||||
return Service.of({ publish, subscribe, all: streamAll, listen, project, replay, replayAll, remove, claim })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -78,12 +78,19 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
|
||||
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
|
||||
"Session.OperationUnavailableError",
|
||||
{
|
||||
operation: Schema.Literals(["prompt", "compact", "wait"]),
|
||||
},
|
||||
) {}
|
||||
|
||||
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
}) {}
|
||||
|
||||
export type Error = NotFoundError | MessageDecodeError
|
||||
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
|
||||
@@ -99,8 +106,10 @@ export interface Interface {
|
||||
time: number
|
||||
direction: "previous" | "next"
|
||||
}
|
||||
}) => Effect.Effect<SessionMessage.Message[], NotFoundError>
|
||||
readonly context: (sessionID: SessionSchema.ID) => Effect.Effect<SessionMessage.Message[], NotFoundError>
|
||||
}) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
readonly context: (
|
||||
sessionID: SessionSchema.ID,
|
||||
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, never>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: ModelV2.Ref }) => Effect.Effect<void, never>
|
||||
readonly prompt: (input: {
|
||||
@@ -109,7 +118,7 @@ export interface Interface {
|
||||
prompt: Prompt
|
||||
delivery?: SessionSchema.Delivery
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
}) => Effect.Effect<SessionMessage.User, NotFoundError | OperationUnavailableError>
|
||||
readonly shell: (input: {
|
||||
id?: EventV2.ID
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -124,8 +133,8 @@ export interface Interface {
|
||||
delivery?: SessionSchema.Delivery
|
||||
resume?: boolean
|
||||
}) => Effect.Effect<void, never>
|
||||
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError>
|
||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -227,24 +236,91 @@ export const layer = Layer.effect(
|
||||
)
|
||||
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
|
||||
}),
|
||||
messages: Effect.fn("V2Session.messages")(function* () {
|
||||
return yield* Effect.die(new Error("Session.messages is not implemented"))
|
||||
messages: Effect.fn("V2Session.messages")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
const direction = input.cursor?.direction ?? "next"
|
||||
const requestedOrder = input.order ?? "desc"
|
||||
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
|
||||
const boundary = input.cursor
|
||||
? order === "asc"
|
||||
? or(
|
||||
gt(SessionMessageTable.time_created, input.cursor.time),
|
||||
and(
|
||||
eq(SessionMessageTable.time_created, input.cursor.time),
|
||||
gt(SessionMessageTable.id, input.cursor.id),
|
||||
),
|
||||
)
|
||||
: or(
|
||||
lt(SessionMessageTable.time_created, input.cursor.time),
|
||||
and(
|
||||
eq(SessionMessageTable.time_created, input.cursor.time),
|
||||
lt(SessionMessageTable.id, input.cursor.id),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
const where = boundary
|
||||
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
|
||||
: eq(SessionMessageTable.session_id, input.sessionID)
|
||||
const query = db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(where)
|
||||
.orderBy(
|
||||
order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created),
|
||||
order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id),
|
||||
)
|
||||
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
|
||||
}),
|
||||
context: Effect.fn("V2Session.context")(function* () {
|
||||
return yield* Effect.die(new Error("Session.context is not implemented"))
|
||||
context: Effect.fn("V2Session.context")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
const compaction = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
|
||||
.orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
compaction
|
||||
? or(
|
||||
gt(SessionMessageTable.time_created, compaction.time_created),
|
||||
and(
|
||||
eq(SessionMessageTable.time_created, compaction.time_created),
|
||||
gte(SessionMessageTable.id, compaction.id),
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return yield* Effect.forEach(rows, decode)
|
||||
}),
|
||||
prompt: Effect.fn("V2Session.prompt")(function* () {
|
||||
return yield* Effect.die(new Error("Session.prompt is not implemented"))
|
||||
prompt: Effect.fn("V2Session.prompt")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* Effect.fail(new OperationUnavailableError({ operation: "prompt" }))
|
||||
}),
|
||||
shell: Effect.fn("V2Session.shell")(function* () {}),
|
||||
skill: Effect.fn("V2Session.skill")(function* () {}),
|
||||
switchAgent: Effect.fn("V2Session.switchAgent")(function* () {}),
|
||||
switchModel: Effect.fn("V2Session.switchModel")(function* () {}),
|
||||
compact: Effect.fn("V2Session.compact")(function* () {
|
||||
return yield* Effect.die(new Error("Session.compact is not implemented"))
|
||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "compact" })
|
||||
}),
|
||||
wait: Effect.fn("V2Session.wait")(function* () {
|
||||
return yield* Effect.die(new Error("Session.wait is not implemented"))
|
||||
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
return yield* new OperationUnavailableError({ operation: "wait" })
|
||||
}),
|
||||
resume: Effect.fn("V2Session.resume")(function* () {}),
|
||||
move: Effect.fn("V2Session.move")(function* () {}),
|
||||
|
||||
@@ -27,7 +27,7 @@ type Usage = {
|
||||
}
|
||||
}
|
||||
|
||||
function usage(part: typeof SessionLegacy.Event.PartUpdated.Type["data"]["part"] | unknown): Usage | undefined {
|
||||
function usage(part: (typeof SessionLegacy.Event.PartUpdated.Type)["data"]["part"] | unknown): Usage | undefined {
|
||||
if (typeof part !== "object" || part === null) return undefined
|
||||
const value = part as Record<string, unknown>
|
||||
if (value.type !== "step-finish") return undefined
|
||||
@@ -39,7 +39,7 @@ function sessionRow(info: SessionLegacy.SessionInfo): typeof SessionTable.$infer
|
||||
return {
|
||||
id: info.id,
|
||||
project_id: info.projectID,
|
||||
workspace_id: info.workspaceID,
|
||||
workspace_id: info.workspaceID ?? null,
|
||||
parent_id: info.parentID,
|
||||
slug: info.slug,
|
||||
directory: info.directory,
|
||||
@@ -68,19 +68,23 @@ function sessionRow(info: SessionLegacy.SessionInfo): typeof SessionTable.$infer
|
||||
}
|
||||
}
|
||||
|
||||
function messageData(info: typeof SessionLegacy.Event.MessageUpdated.Type["data"]["info"]): typeof MessageTable.$inferInsert.data {
|
||||
function messageData(
|
||||
info: (typeof SessionLegacy.Event.MessageUpdated.Type)["data"]["info"],
|
||||
): typeof MessageTable.$inferInsert.data {
|
||||
const { id: _, sessionID: __, ...rest } = info
|
||||
return rest as DeepMutable<typeof rest>
|
||||
}
|
||||
|
||||
function partData(part: typeof SessionLegacy.Event.PartUpdated.Type["data"]["part"]): typeof PartTable.$inferInsert.data {
|
||||
function partData(
|
||||
part: (typeof SessionLegacy.Event.PartUpdated.Type)["data"]["part"],
|
||||
): typeof PartTable.$inferInsert.data {
|
||||
const { id: _, messageID: __, sessionID: ___, ...rest } = part
|
||||
return rest as DeepMutable<typeof rest>
|
||||
}
|
||||
|
||||
function applyUsage(
|
||||
db: DatabaseService,
|
||||
sessionID: typeof SessionLegacy.Event.MessageUpdated.Type["data"]["sessionID"],
|
||||
sessionID: (typeof SessionLegacy.Event.MessageUpdated.Type)["data"]["sessionID"],
|
||||
value: Usage,
|
||||
sign = 1,
|
||||
) {
|
||||
@@ -108,7 +112,9 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "assistant")))
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "assistant")),
|
||||
)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows
|
||||
@@ -123,7 +129,9 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "compaction")))
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "compaction")),
|
||||
)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Schema } from "effect"
|
||||
import { withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
export const ID = Schema.String.check(Schema.isStartsWith("wrk")).pipe(
|
||||
Schema.brand("WorkspaceV2.ID"),
|
||||
withStatics((schema) => ({
|
||||
ascending: (id?: string) => {
|
||||
|
||||
@@ -180,6 +180,29 @@ describe("EventV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs listeners inline after projectors", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const received = new Array<string>()
|
||||
yield* events.project(SyncMessage, () =>
|
||||
Effect.sync(() => {
|
||||
received.push("projector")
|
||||
}),
|
||||
)
|
||||
const unsubscribe = yield* events.listen(() =>
|
||||
Effect.sync(() => {
|
||||
received.push("listener")
|
||||
}),
|
||||
)
|
||||
|
||||
yield* events.publish(SyncMessage, { id: "one", text: "hello" })
|
||||
yield* unsubscribe
|
||||
yield* events.publish(SyncMessage, { id: "one", text: "after unsubscribe" })
|
||||
|
||||
expect(received).toEqual(["projector", "listener", "projector"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("inserts sync event rows on publish", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
||||
export type Definition<Type extends string = string, Properties extends Schema.Top = Schema.Top> = {
|
||||
type: Type
|
||||
properties: Properties
|
||||
}
|
||||
|
||||
const registry = new Map<string, Definition>()
|
||||
|
||||
export function define<Type extends string, Properties extends Schema.Top>(
|
||||
type: Type,
|
||||
properties: Properties,
|
||||
): Definition<Type, Properties> {
|
||||
const result = { type, properties }
|
||||
registry.set(type, result)
|
||||
return result
|
||||
}
|
||||
|
||||
export function effectPayloads() {
|
||||
return [
|
||||
...registry
|
||||
.entries()
|
||||
.map(([type, def]) =>
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.Literal(type),
|
||||
properties: def.properties,
|
||||
}).annotate({ identifier: `Event.${type}` }),
|
||||
)
|
||||
.toArray(),
|
||||
...EventV2.registry
|
||||
.values()
|
||||
.map((definition) =>
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.Literal(definition.type),
|
||||
properties: definition.data,
|
||||
}).annotate({ identifier: `Event.${definition.type}` }),
|
||||
)
|
||||
.toArray(),
|
||||
]
|
||||
}
|
||||
|
||||
export * as BusEvent from "./bus-event"
|
||||
@@ -1,224 +0,0 @@
|
||||
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream, Schema } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { BusEvent } from "./bus-event"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { GlobalBus } from "./global"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Identifier } from "@/id/id"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
|
||||
const log = Log.create({ service: "bus" })
|
||||
|
||||
type BusDefinition = BusEvent.Definition<string, Schema.Top> | EventV2.Definition<string, Schema.Top>
|
||||
type BusSchema<D extends BusDefinition> = D extends { data: infer S extends Schema.Top }
|
||||
? S
|
||||
: D extends { properties: infer S extends Schema.Top }
|
||||
? S
|
||||
: never
|
||||
type BusProperties<D extends BusDefinition> = Schema.Schema.Type<BusSchema<D>>
|
||||
|
||||
export const InstanceDisposed = BusEvent.define(
|
||||
"server.instance.disposed",
|
||||
Schema.Struct({
|
||||
directory: Schema.String,
|
||||
}),
|
||||
)
|
||||
|
||||
type Payload<D extends BusDefinition = BusDefinition> = {
|
||||
id: string
|
||||
type: D["type"]
|
||||
properties: BusProperties<D>
|
||||
}
|
||||
|
||||
type State = {
|
||||
wildcard: PubSub.PubSub<Payload>
|
||||
typed: Map<string, PubSub.PubSub<Payload>>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly publish: <D extends BusDefinition>(
|
||||
def: D,
|
||||
properties: BusProperties<D>,
|
||||
options?: { id?: string },
|
||||
) => Effect.Effect<void>
|
||||
// subscribe / subscribeAll are eager: the underlying PubSub subscription is
|
||||
// acquired in the caller's Scope at `yield*` time. Any publish after the
|
||||
// yield is delivered, even if stream consumption starts later. The previous
|
||||
// Stream-returning shape acquired the subscription lazily on first pull,
|
||||
// opening a race window during which publishes were lost — see
|
||||
// test/bus/bus-effect.test.ts RACE tests.
|
||||
readonly subscribe: <D extends BusDefinition>(
|
||||
def: D,
|
||||
) => Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope>
|
||||
readonly subscribeAll: () => Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope>
|
||||
readonly subscribeCallback: <D extends BusDefinition>(
|
||||
def: D,
|
||||
callback: (event: Payload<D>) => unknown,
|
||||
) => Effect.Effect<() => void>
|
||||
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Bus.state")(function* (ctx) {
|
||||
const wildcard = yield* PubSub.unbounded<Payload>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
// Publish InstanceDisposed before shutting down so subscribers see it
|
||||
yield* PubSub.publish(wildcard, {
|
||||
type: InstanceDisposed.type,
|
||||
id: createID(),
|
||||
properties: { directory: ctx.directory },
|
||||
})
|
||||
yield* PubSub.shutdown(wildcard)
|
||||
for (const ps of typed.values()) {
|
||||
yield* PubSub.shutdown(ps)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return { wildcard, typed }
|
||||
}),
|
||||
)
|
||||
|
||||
function getOrCreate<D extends BusDefinition>(state: State, def: D) {
|
||||
return Effect.gen(function* () {
|
||||
let ps = state.typed.get(def.type)
|
||||
if (!ps) {
|
||||
ps = yield* PubSub.unbounded<Payload>()
|
||||
state.typed.set(def.type, ps)
|
||||
}
|
||||
return ps as unknown as PubSub.PubSub<Payload<D>>
|
||||
})
|
||||
}
|
||||
|
||||
function publish<D extends BusDefinition>(def: D, properties: BusProperties<D>, options?: { id?: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties }
|
||||
log.info("publishing", { type: def.type })
|
||||
|
||||
const ps = s.typed.get(def.type)
|
||||
if (ps) yield* PubSub.publish(ps, payload)
|
||||
yield* PubSub.publish(s.wildcard, payload)
|
||||
|
||||
const dir = yield* InstanceState.directory
|
||||
const context = yield* InstanceState.context
|
||||
const workspace = yield* InstanceState.workspaceID
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory: dir,
|
||||
project: context.project.id,
|
||||
workspace,
|
||||
payload,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const subscribe = <D extends BusDefinition>(
|
||||
def: D,
|
||||
): Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
log.info("subscribing", { type: def.type })
|
||||
const s = yield* InstanceState.get(state)
|
||||
const ps = yield* getOrCreate(s, def)
|
||||
const subscription = yield* PubSub.subscribe(ps)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: def.type })))
|
||||
return Stream.fromSubscription(subscription)
|
||||
})
|
||||
|
||||
const subscribeAll = (): Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
log.info("subscribing", { type: "*" })
|
||||
const s = yield* InstanceState.get(state)
|
||||
const subscription = yield* PubSub.subscribe(s.wildcard)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: "*" })))
|
||||
return Stream.fromSubscription(subscription)
|
||||
})
|
||||
|
||||
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
|
||||
return Effect.gen(function* () {
|
||||
log.info("subscribing", { type })
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const scope = yield* Scope.make()
|
||||
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
|
||||
|
||||
yield* Scope.provide(scope)(
|
||||
Stream.fromSubscription(subscription).pipe(
|
||||
Stream.runForEach((msg) =>
|
||||
Effect.tryPromise({
|
||||
try: () => Promise.resolve().then(() => callback(msg)),
|
||||
catch: (cause) => {
|
||||
log.error("subscriber failed", { type, cause })
|
||||
},
|
||||
}).pipe(Effect.ignore),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
),
|
||||
)
|
||||
|
||||
return () => {
|
||||
log.info("unsubscribing", { type })
|
||||
bridge.fork(Scope.close(scope, Exit.void))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusDefinition>(
|
||||
def: D,
|
||||
callback: (event: Payload<D>) => unknown,
|
||||
) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const ps = yield* getOrCreate(s, def)
|
||||
return yield* on(ps, def.type, callback)
|
||||
})
|
||||
|
||||
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* on(s.wildcard, "*", callback)
|
||||
})
|
||||
|
||||
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
const { runPromise, runSync } = makeRuntime(Service, layer)
|
||||
|
||||
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
|
||||
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
|
||||
export function createID() {
|
||||
return Identifier.create("evt", "ascending")
|
||||
}
|
||||
|
||||
export async function publish<D extends BusEvent.Definition>(
|
||||
ctx: InstanceContext,
|
||||
def: D,
|
||||
properties: BusProperties<D>,
|
||||
options?: { id?: string },
|
||||
) {
|
||||
return runPromise((svc) => svc.publish(def, properties, options).pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
}
|
||||
|
||||
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => unknown) {
|
||||
return runSync((svc) => svc.subscribeCallback(def, callback))
|
||||
}
|
||||
|
||||
export function subscribeAll(callback: (event: any) => unknown) {
|
||||
return runSync((svc) => svc.subscribeAllCallback(callback))
|
||||
}
|
||||
|
||||
export * as Bus from "."
|
||||
@@ -27,8 +27,9 @@ import { Session } from "@/session/session"
|
||||
import type { SessionID } from "../../session/schema"
|
||||
import { MessageID, PartID } from "../../session/schema"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Bus } from "../../bus"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { Git } from "@/git"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
@@ -436,7 +437,7 @@ export const GithubRunCommand = effectCmd({
|
||||
const sessionSvc = yield* Session.Service
|
||||
const sessionShare = yield* SessionShare.Service
|
||||
const sessionPrompt = yield* SessionPrompt.Service
|
||||
const busSvc = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const runLocalEffect = <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -898,10 +899,12 @@ export const GithubRunCommand = effectCmd({
|
||||
|
||||
let text = ""
|
||||
await runLocalEffect(
|
||||
busSvc.subscribeCallback(MessageV2.Event.PartUpdated, (evt) => {
|
||||
if (evt.properties.part.sessionID !== session.id) return
|
||||
events.listen((evt) => {
|
||||
if (evt.type !== MessageV2.Event.PartUpdated.type) return Effect.void
|
||||
const data = evt.data as EventV2.Data<typeof MessageV2.Event.PartUpdated>
|
||||
if (data.part.sessionID !== session.id) return Effect.void
|
||||
//if (evt.properties.part.messageID === messageID) return
|
||||
const part = evt.properties.part
|
||||
const part = data.part
|
||||
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
|
||||
@@ -921,9 +924,10 @@ export const GithubRunCommand = effectCmd({
|
||||
UI.println(UI.markdown(text))
|
||||
UI.empty()
|
||||
text = ""
|
||||
return
|
||||
return Effect.void
|
||||
}
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { modify, applyEdits } from "jsonc-parser"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Bus } from "../../bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Effect } from "effect"
|
||||
|
||||
function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
||||
@@ -256,13 +257,17 @@ export const McpAuthCommand = effectCmd({
|
||||
spinner.start("Starting OAuth flow...")
|
||||
|
||||
// Subscribe to browser open failure events to show URL for manual opening
|
||||
const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
|
||||
if (evt.properties.mcpName === serverName) {
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const unsubscribe = yield* events.listen((event) => {
|
||||
if (event.type !== MCP.BrowserOpenFailed.type) return Effect.void
|
||||
const data = event.data as EventV2.Data<typeof MCP.BrowserOpenFailed>
|
||||
if (data.mcpName === serverName) {
|
||||
spinner.stop("Could not open browser automatically")
|
||||
prompts.log.warn("Please open this URL in your browser to authenticate:")
|
||||
prompts.log.info(evt.properties.url)
|
||||
prompts.log.info(data.url)
|
||||
spinner.start("Waiting for authorization...")
|
||||
}
|
||||
return Effect.void
|
||||
})
|
||||
|
||||
yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe(
|
||||
@@ -300,7 +305,7 @@ export const McpAuthCommand = effectCmd({
|
||||
prompts.log.error(error instanceof Error ? error.message : String(error))
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(Effect.sync(() => unsubscribe())),
|
||||
Effect.ensuring(unsubscribe),
|
||||
)
|
||||
|
||||
prompts.outro("Done")
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Sqlite } from "@opencode-ai/core/database/sqlite"
|
||||
import { layer as sqliteLayer } from "@opencode-ai/core/database/sqlite.bun"
|
||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
import { Option, Schema } from "effect"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { EditorSelection } from "./editor"
|
||||
|
||||
@@ -91,10 +88,12 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
|
||||
}
|
||||
|
||||
function queryZedActiveEditor(dbPath: string, cwd: string) {
|
||||
let db: Database | undefined
|
||||
try {
|
||||
const raw = zedDatabase(dbPath).runSync((db) =>
|
||||
Effect.succeed(
|
||||
db.all<ZedEditorRow>(sql.raw(`select
|
||||
db = new Database(dbPath, { readonly: true })
|
||||
const raw = db
|
||||
.query(
|
||||
`select
|
||||
i.kind as item_kind,
|
||||
e.item_id as editor_id,
|
||||
i.workspace_id as workspace_id,
|
||||
@@ -106,9 +105,9 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
|
||||
join workspaces w on w.workspace_id = i.workspace_id
|
||||
left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
|
||||
where i.active = 1 and p.active = 1
|
||||
order by w.timestamp desc`)),
|
||||
),
|
||||
)
|
||||
order by w.timestamp desc`,
|
||||
)
|
||||
.all()
|
||||
|
||||
const rows = raw.flatMap((row) => {
|
||||
const parsed = decodeZedEditorRow(row)
|
||||
@@ -127,22 +126,24 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
|
||||
return { type: "row" as const, row }
|
||||
} catch {
|
||||
return { type: "unavailable" as const }
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
|
||||
function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
|
||||
let db: Database | undefined
|
||||
try {
|
||||
const raw = zedDatabase(dbPath).runSync((db) =>
|
||||
Effect.succeed(
|
||||
db.all<Schema.Schema.Type<typeof ZedSelectionRowSchema>>(
|
||||
sql`select
|
||||
db = new Database(dbPath, { readonly: true })
|
||||
const raw = db
|
||||
.query(
|
||||
`select
|
||||
start as selection_start,
|
||||
end as selection_end
|
||||
from editor_selections
|
||||
where editor_id = ${row.editor_id} and workspace_id = ${row.workspace_id}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
where editor_id = $editorID and workspace_id = $workspaceID`,
|
||||
)
|
||||
.all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
|
||||
|
||||
const selections = raw.flatMap((selection) => {
|
||||
const parsed = decodeZedSelectionRow(selection)
|
||||
@@ -153,33 +154,33 @@ function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
|
||||
return { type: "selections" as const, selections }
|
||||
} catch {
|
||||
return { type: "unavailable" as const }
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
|
||||
function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
|
||||
let db: Database | undefined
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true })
|
||||
const parsed = decodeZedEditorContents(
|
||||
zedDatabase(dbPath).runSync((db) =>
|
||||
Effect.succeed(
|
||||
db.get(
|
||||
sql`select contents
|
||||
db
|
||||
.query(
|
||||
`select contents
|
||||
from editors
|
||||
where item_id = ${row.editor_id} and workspace_id = ${row.workspace_id}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
where item_id = $editorID and workspace_id = $workspaceID`,
|
||||
)
|
||||
.get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
|
||||
)
|
||||
if (Option.isNone(parsed)) return { type: "unavailable" as const }
|
||||
return { type: "contents" as const, contents: parsed.value.contents }
|
||||
} catch {
|
||||
return { type: "unavailable" as const }
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
|
||||
function zedDatabase(dbPath: string) {
|
||||
return makeRuntime(Sqlite.Drizzle, sqliteLayer({ filename: dbPath, readonly: true, create: false }))
|
||||
}
|
||||
|
||||
function isZedActiveEditorRow(row: ZedEditorRow): row is ZedActiveEditorRow {
|
||||
return row.item_kind === "Editor" && row.editor_id != null
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { PositiveInt } from "@opencode-ai/core/schema"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
const DEFAULT_TOAST_DURATION = 5000
|
||||
|
||||
export const TuiEvent = {
|
||||
PromptAppend: BusEvent.define("tui.prompt.append", Schema.Struct({ text: Schema.String })),
|
||||
CommandExecute: BusEvent.define(
|
||||
"tui.command.execute",
|
||||
Schema.Struct({
|
||||
PromptAppend: EventV2.define({ type: "tui.prompt.append", schema: { text: Schema.String } }),
|
||||
CommandExecute: EventV2.define({
|
||||
type: "tui.command.execute",
|
||||
schema: {
|
||||
command: Schema.Union([
|
||||
Schema.Literals([
|
||||
"session.list",
|
||||
@@ -31,23 +31,23 @@ export const TuiEvent = {
|
||||
]),
|
||||
Schema.String,
|
||||
]),
|
||||
}),
|
||||
),
|
||||
ToastShow: BusEvent.define(
|
||||
"tui.toast.show",
|
||||
Schema.Struct({
|
||||
},
|
||||
}),
|
||||
ToastShow: EventV2.define({
|
||||
type: "tui.toast.show",
|
||||
schema: {
|
||||
title: Schema.optional(Schema.String),
|
||||
message: Schema.String,
|
||||
variant: Schema.Literals(["info", "success", "warning", "error"]),
|
||||
duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
|
||||
description: "Duration in milliseconds",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
SessionSelect: BusEvent.define(
|
||||
"tui.session.select",
|
||||
Schema.Struct({
|
||||
},
|
||||
}),
|
||||
SessionSelect: EventV2.define({
|
||||
type: "tui.session.select",
|
||||
schema: {
|
||||
sessionID: SessionID.annotate({ description: "Session ID to navigate to" }),
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import { TextAttributes } from "@opentui/core"
|
||||
import { Schema } from "effect"
|
||||
import { TuiEvent } from "../event"
|
||||
|
||||
type ToastInput = Schema.Codec.Encoded<typeof TuiEvent.ToastShow.properties>
|
||||
export type ToastOptions = Schema.Schema.Type<typeof TuiEvent.ToastShow.properties>
|
||||
type ToastInput = Schema.Codec.Encoded<typeof TuiEvent.ToastShow.data>
|
||||
export type ToastOptions = Schema.Schema.Type<typeof TuiEvent.ToastShow.data>
|
||||
|
||||
const decodeToastOptions = Schema.decodeUnknownSync(TuiEvent.ToastShow.properties)
|
||||
const decodeToastOptions = Schema.decodeUnknownSync(TuiEvent.ToastShow.data)
|
||||
|
||||
export function Toast() {
|
||||
const toast = useToast()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
@@ -7,6 +6,7 @@ import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
import { MCP } from "../mcp"
|
||||
import { Skill } from "../skill"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
||||
import PROMPT_REVIEW from "./template/review.txt"
|
||||
|
||||
@@ -15,15 +15,15 @@ type State = {
|
||||
}
|
||||
|
||||
export const Event = {
|
||||
Executed: BusEvent.define(
|
||||
"command.executed",
|
||||
Schema.Struct({
|
||||
Executed: EventV2.define({
|
||||
type: "command.executed",
|
||||
schema: {
|
||||
name: Schema.String,
|
||||
sessionID: SessionID,
|
||||
arguments: Schema.String,
|
||||
messageID: MessageID,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
|
||||
@@ -6,7 +6,6 @@ import { asc } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { inArray } from "drizzle-orm"
|
||||
import { Project } from "@/project/project"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Auth } from "@/auth"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
@@ -47,19 +46,19 @@ export const ConnectionStatus = Schema.Struct({
|
||||
export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
|
||||
|
||||
export const Event = {
|
||||
Ready: BusEvent.define(
|
||||
"workspace.ready",
|
||||
Schema.Struct({
|
||||
Ready: EventV2.define({
|
||||
type: "workspace.ready",
|
||||
schema: {
|
||||
name: Schema.String,
|
||||
}),
|
||||
),
|
||||
Failed: BusEvent.define(
|
||||
"workspace.failed",
|
||||
Schema.Struct({
|
||||
},
|
||||
}),
|
||||
Failed: EventV2.define({
|
||||
type: "workspace.failed",
|
||||
schema: {
|
||||
message: Schema.String,
|
||||
}),
|
||||
),
|
||||
Status: BusEvent.define("workspace.status", ConnectionStatus),
|
||||
},
|
||||
}),
|
||||
Status: EventV2.define({ type: "workspace.status", schema: ConnectionStatus.fields }),
|
||||
}
|
||||
|
||||
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
|
||||
@@ -340,14 +339,12 @@ export const layer = Layer.effect(
|
||||
.pipe(Effect.orDie)).map((row) => row.id)
|
||||
const state = sessionIDs.length
|
||||
? Object.fromEntries(
|
||||
(
|
||||
yield* db
|
||||
.select()
|
||||
.from(EventSequenceTable)
|
||||
.where(inArray(EventSequenceTable.aggregate_id, sessionIDs))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
).map((row) => [row.aggregate_id, row.seq]),
|
||||
(yield* db
|
||||
.select()
|
||||
.from(EventSequenceTable)
|
||||
.where(inArray(EventSequenceTable.aggregate_id, sessionIDs))
|
||||
.all()
|
||||
.pipe(Effect.orDie)).map((row) => [row.aggregate_id, row.seq]),
|
||||
)
|
||||
: {}
|
||||
|
||||
@@ -800,6 +797,8 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: input.workspaceID })
|
||||
|
||||
log.info("session warp complete", {
|
||||
workspaceID: input.workspaceID,
|
||||
sessionID: input.sessionID,
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Bus } from "@/bus"
|
||||
import { Auth } from "@/auth"
|
||||
import { Account } from "@/account/account"
|
||||
import { Config } from "@/config/config"
|
||||
@@ -56,12 +55,12 @@ import { Npm } from "@opencode-ai/core/npm"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
Npm.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Database.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
Account.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
@@ -85,6 +84,7 @@ export const AppLayer = Layer.mergeAll(
|
||||
SessionStatus.defaultLayer,
|
||||
BackgroundJob.defaultLayer,
|
||||
RuntimeFlags.defaultLayer,
|
||||
EventV2Bridge.defaultLayer,
|
||||
SessionRunState.defaultLayer,
|
||||
SessionProcessor.defaultLayer,
|
||||
SessionCompaction.defaultLayer,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { ShareNext } from "@/share/share-next"
|
||||
import { File } from "@/file"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
@@ -23,7 +22,6 @@ export const BootstrapLayer = Layer.mergeAll(
|
||||
FileWatcher.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
).pipe(Layer.provide(Observability.layer))
|
||||
|
||||
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
// Temporary V2 bridge: core events are the publish path, but the rest of
|
||||
// opencode and the HTTP event stream still expect legacy bus payloads.
|
||||
// This layer goes away once consumers subscribe to core EventV2 directly.
|
||||
import { Bus as ProjectBus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
// Opencode publish boundary for core events. Attach routed instance location
|
||||
// so direct EventV2 consumers can isolate directory/workspace streams.
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import "@opencode-ai/core/account"
|
||||
import "@opencode-ai/core/catalog"
|
||||
import "@opencode-ai/core/session/event"
|
||||
import { Context, Effect, Layer, Option, Stream } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
|
||||
export class Service extends Context.Service<Service, EventV2.Interface>()("@opencode/EventV2Bridge") {}
|
||||
|
||||
@@ -17,54 +15,40 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const bus = yield* ProjectBus.Service
|
||||
|
||||
const publishGlobal = (event: EventV2.Payload) =>
|
||||
Effect.sync(() => {
|
||||
GlobalBus.emit("event", {
|
||||
workspace: event.location?.workspaceID,
|
||||
payload: {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
properties: event.data,
|
||||
const publish: EventV2.Interface["publish"] = (definition, data, options) =>
|
||||
Effect.gen(function* () {
|
||||
if (options?.location) return yield* events.publish(definition, data, options)
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* events.publish(definition, data, options)
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
return yield* events.publish(definition, data, {
|
||||
...options,
|
||||
location: {
|
||||
directory: AbsolutePath.make(ctx.directory),
|
||||
...(workspaceID ? { workspaceID } : {}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const provideEventLocation = <E, R>(event: EventV2.Payload, effect: Effect.Effect<void, E, R>) =>
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* InstanceRef
|
||||
if (ctx) return yield* effect
|
||||
const store = Option.getOrUndefined(yield* Effect.serviceOption(InstanceStore.Service))
|
||||
if (!event.location?.directory || !store) return yield* publishGlobal(event)
|
||||
return yield* store.load({ directory: event.location.directory }).pipe(
|
||||
Effect.flatMap((ctx) => {
|
||||
const withInstance = effect.pipe(Effect.provideService(InstanceRef, ctx))
|
||||
if (!event.location?.workspaceID) return withInstance
|
||||
return withInstance.pipe(Effect.provideService(WorkspaceRef, event.location.workspaceID))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* events.all().pipe(
|
||||
Stream.runForEach((event) => {
|
||||
const definition = EventV2.registry.get(event.type)
|
||||
if (!definition) return Effect.void
|
||||
return provideEventLocation(
|
||||
event,
|
||||
bus.publish({ type: definition.type, properties: definition.data }, event.data, { id: event.id }),
|
||||
)
|
||||
const workspaceID = (yield* WorkspaceRef) ?? event.location?.workspaceID
|
||||
GlobalBus.emit("event", {
|
||||
directory: event.location?.directory ?? ctx?.directory,
|
||||
project: ctx?.project.id,
|
||||
workspace: workspaceID,
|
||||
payload: { id: event.id, type: event.type, properties: event.data },
|
||||
})
|
||||
}),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
return Service.of(events)
|
||||
return Service.of({ ...events, publish })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(ProjectBus.defaultLayer),
|
||||
)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer))
|
||||
|
||||
export * as EventV2Bridge from "./event-v2-bridge"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
@@ -62,12 +62,12 @@ export const Content = Schema.Struct({
|
||||
export type Content = DeepMutable<Schema.Schema.Type<typeof Content>>
|
||||
|
||||
export const Event = {
|
||||
Edited: BusEvent.define(
|
||||
"file.edited",
|
||||
Schema.Struct({
|
||||
Edited: EventV2.define({
|
||||
type: "file.edited",
|
||||
schema: {
|
||||
file: Schema.String,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "file" })
|
||||
|
||||
@@ -4,8 +4,8 @@ import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { readdir, realpath } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
@@ -22,13 +22,13 @@ const log = Log.create({ service: "file.watcher" })
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define(
|
||||
"file.watcher.updated",
|
||||
Schema.Struct({
|
||||
Updated: EventV2.define({
|
||||
type: "file.watcher.updated",
|
||||
schema: {
|
||||
file: Schema.String,
|
||||
event: Schema.Literals(["add", "change", "unlink"]),
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
@@ -69,6 +69,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const git = yield* Git.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("FileWatcher.state")(
|
||||
@@ -98,9 +99,9 @@ export const layer = Layer.effect(
|
||||
const cb: ParcelWatcher.SubscribeCallback = bridge.bind((err, evts) => {
|
||||
// if (err) return
|
||||
for (const evt of evts) {
|
||||
if (evt.type === "create") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "add" })
|
||||
if (evt.type === "update") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "change" })
|
||||
if (evt.type === "delete") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "unlink" })
|
||||
if (evt.type === "create") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "add" }))
|
||||
if (evt.type === "update") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "change" }))
|
||||
if (evt.type === "delete") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "unlink" }))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -162,6 +163,10 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
)
|
||||
|
||||
export * as FileWatcher from "./watcher"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Schema } from "effect"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -15,12 +15,12 @@ const SUPPORTED_IDES = [
|
||||
const log = Log.create({ service: "ide" })
|
||||
|
||||
export const Event = {
|
||||
Installed: BusEvent.define(
|
||||
"ide.installed",
|
||||
Schema.Struct({
|
||||
Installed: EventV2.define({
|
||||
type: "ide.installed",
|
||||
schema: {
|
||||
ide: Schema.String,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", {})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { errorMessage } from "@/util/error"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import path from "path"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
import semver from "semver"
|
||||
@@ -20,18 +20,18 @@ export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop"
|
||||
export type ReleaseType = "patch" | "minor" | "major"
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define(
|
||||
"installation.updated",
|
||||
Schema.Struct({
|
||||
Updated: EventV2.define({
|
||||
type: "installation.updated",
|
||||
schema: {
|
||||
version: Schema.String,
|
||||
}),
|
||||
),
|
||||
UpdateAvailable: BusEvent.define(
|
||||
"installation.update-available",
|
||||
Schema.Struct({
|
||||
},
|
||||
}),
|
||||
UpdateAvailable: EventV2.define({
|
||||
type: "installation.update-available",
|
||||
schema: {
|
||||
version: Schema.String,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export function getReleaseType(current: string, latest: string): ReleaseType {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import path from "path"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
|
||||
@@ -11,8 +9,6 @@ import { Effect, Schema } from "effect"
|
||||
import type * as LSPServer from "./server"
|
||||
import { withTimeout } from "../util/timeout"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
|
||||
const DIAGNOSTICS_DEBOUNCE_MS = 150
|
||||
@@ -28,8 +24,6 @@ const FILE_CHANGE_CHANGED = 2
|
||||
const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2
|
||||
|
||||
const log = Log.create({ service: "lsp.client" })
|
||||
const busRuntime = makeRuntime(Bus.Service, Bus.layer)
|
||||
|
||||
export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
|
||||
|
||||
export type Diagnostic = VSCodeDiagnostic
|
||||
@@ -39,16 +33,6 @@ export class InitializeError extends Schema.TaggedErrorClass<InitializeError>()(
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export const Event = {
|
||||
Diagnostics: BusEvent.define(
|
||||
"lsp.client.diagnostics",
|
||||
Schema.Struct({
|
||||
serverID: Schema.String,
|
||||
path: Schema.String,
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
type DocumentDiagnosticReport = {
|
||||
items?: Diagnostic[]
|
||||
relatedDocuments?: Record<string, DocumentDiagnosticReport>
|
||||
@@ -169,15 +153,12 @@ export async function create(input: {
|
||||
const published = new Map<string, { at: number; version?: number }>()
|
||||
const diagnosticRegistrations = new Map<string, CapabilityRegistration>()
|
||||
const registrationListeners = new Set<() => void>()
|
||||
const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>()
|
||||
const mergedDiagnostics = (filePath: string) =>
|
||||
dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])])
|
||||
const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {
|
||||
pushDiagnostics.set(filePath, next)
|
||||
void busRuntime.runPromise((svc) =>
|
||||
svc
|
||||
.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
|
||||
.pipe(Effect.provideService(InstanceRef, instance)),
|
||||
)
|
||||
for (const listener of diagnosticListeners) listener({ path: filePath, serverID: input.serverID })
|
||||
}
|
||||
const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {
|
||||
pullDiagnostics.set(filePath, next)
|
||||
@@ -525,14 +506,12 @@ export async function create(input: {
|
||||
}
|
||||
|
||||
timeoutTimer = setTimeout(() => finish(false), request.timeout)
|
||||
unsub = busRuntime.runSync((svc) =>
|
||||
svc
|
||||
.subscribeCallback(Event.Diagnostics, (event) => {
|
||||
if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return
|
||||
schedule()
|
||||
})
|
||||
.pipe(Effect.provideService(InstanceRef, instance)),
|
||||
)
|
||||
const listener = (event: { path: string; serverID: string }) => {
|
||||
if (event.path !== request.path || event.serverID !== input.serverID) return
|
||||
schedule()
|
||||
}
|
||||
diagnosticListeners.add(listener)
|
||||
unsub = () => diagnosticListeners.delete(listener)
|
||||
schedule()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import * as LSPClient from "./client"
|
||||
import path from "path"
|
||||
@@ -17,7 +17,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define("lsp.updated", Schema.Struct({})),
|
||||
Updated: EventV2.define({ type: "lsp.updated", schema: {} }),
|
||||
}
|
||||
|
||||
const Position = Schema.Struct({
|
||||
@@ -144,6 +144,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("LSP.state")(function* (ctx) {
|
||||
@@ -212,9 +213,10 @@ export const layer = Layer.effect(
|
||||
const ctx = yield* InstanceState.context
|
||||
if (!containsPath(file, ctx)) return [] as LSPClient.Info[]
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const clients = yield* Effect.promise(async () => {
|
||||
const extension = path.parse(file).ext || file
|
||||
const result: LSPClient.Info[] = []
|
||||
let updated = 0
|
||||
|
||||
async function schedule(server: LSPServer.Info, root: string, key: string) {
|
||||
const handle = await server
|
||||
@@ -291,11 +293,15 @@ export const layer = Layer.effect(
|
||||
if (!client) continue
|
||||
|
||||
result.push(client)
|
||||
await Bus.publish(ctx, Event.Updated, {})
|
||||
updated++
|
||||
}
|
||||
|
||||
return result
|
||||
return { result, updated }
|
||||
})
|
||||
yield* Effect.forEach(Array.from({ length: clients.updated }), () => events.publish(Event.Updated, {}), {
|
||||
discard: true,
|
||||
})
|
||||
return clients.result
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
|
||||
@@ -500,7 +506,11 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
)
|
||||
|
||||
export * as Diagnostic from "./diagnostic"
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
import { McpOAuthCallback } from "./oauth-callback"
|
||||
import { McpAuth } from "./auth"
|
||||
import { BusEvent } from "../bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import open from "open"
|
||||
import { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect"
|
||||
@@ -48,20 +48,20 @@ export const Resource = Schema.Struct({
|
||||
}).annotate({ identifier: "McpResource" })
|
||||
export type Resource = Schema.Schema.Type<typeof Resource>
|
||||
|
||||
export const ToolsChanged = BusEvent.define(
|
||||
"mcp.tools.changed",
|
||||
Schema.Struct({
|
||||
export const ToolsChanged = EventV2.define({
|
||||
type: "mcp.tools.changed",
|
||||
schema: {
|
||||
server: Schema.String,
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export const BrowserOpenFailed = BusEvent.define(
|
||||
"mcp.browser.open.failed",
|
||||
Schema.Struct({
|
||||
export const BrowserOpenFailed = EventV2.define({
|
||||
type: "mcp.browser.open.failed",
|
||||
schema: {
|
||||
mcpName: Schema.String,
|
||||
url: Schema.String,
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export const Failed = NamedError.create("MCPFailed", {
|
||||
name: Schema.String,
|
||||
@@ -277,7 +277,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const auth = yield* McpAuth.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
|
||||
type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport
|
||||
|
||||
@@ -372,7 +372,7 @@ export const layer = Layer.effect(
|
||||
status: "needs_client_registration" as const,
|
||||
error: "Server does not support dynamic client registration. Please provide clientId in config.",
|
||||
}
|
||||
return bus
|
||||
return events
|
||||
.publish(TuiEvent.ToastShow, {
|
||||
title: "MCP Authentication Required",
|
||||
message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`,
|
||||
@@ -383,7 +383,7 @@ export const layer = Layer.effect(
|
||||
} else {
|
||||
pendingOAuthTransports.set(key, transport)
|
||||
lastStatus = { status: "needs_auth" as const }
|
||||
return bus
|
||||
return events
|
||||
.publish(TuiEvent.ToastShow, {
|
||||
title: "MCP Authentication Required",
|
||||
message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`,
|
||||
@@ -515,7 +515,7 @@ export const layer = Layer.effect(
|
||||
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
||||
|
||||
s.defs[name] = listed
|
||||
await bridge.promise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
|
||||
await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -870,7 +870,7 @@ export const layer = Layer.effect(
|
||||
),
|
||||
Effect.catch(() => {
|
||||
log.warn("failed to open browser, user must open URL manually", { mcpName })
|
||||
return bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore)
|
||||
return events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -962,7 +962,7 @@ export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(McpAuth.layer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { ConfigPermission } from "@/config/permission"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
@@ -13,6 +11,8 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
||||
import os from "os"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { PermissionID } from "./schema"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
||||
const log = Log.create({ service: "permission" })
|
||||
|
||||
@@ -67,15 +67,15 @@ export const Approval = Schema.Struct({
|
||||
export type Approval = Schema.Schema.Type<typeof Approval>
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("permission.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"permission.replied",
|
||||
Schema.Struct({
|
||||
Asked: EventV2.define({ type: "permission.asked", schema: Request.fields }),
|
||||
Replied: EventV2.define({
|
||||
type: "permission.replied",
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
requestID: PermissionID,
|
||||
reply: Reply,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
|
||||
@@ -144,7 +144,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pe
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const { db } = yield* Database.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Permission.state")(function* (ctx) {
|
||||
@@ -200,7 +200,7 @@ export const layer = Layer.effect(
|
||||
|
||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||
pending.set(id, { info, deferred })
|
||||
yield* bus.publish(Event.Asked, info)
|
||||
yield* events.publish(Event.Asked, info)
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
@@ -215,7 +215,7 @@ export const layer = Layer.effect(
|
||||
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
|
||||
|
||||
pending.delete(input.requestID)
|
||||
yield* bus.publish(Event.Replied, {
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
reply: input.reply,
|
||||
@@ -230,7 +230,7 @@ export const layer = Layer.effect(
|
||||
for (const [id, item] of pending.entries()) {
|
||||
if (item.info.sessionID !== existing.info.sessionID) continue
|
||||
pending.delete(id)
|
||||
yield* bus.publish(Event.Replied, {
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
reply: "reject",
|
||||
@@ -258,7 +258,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
if (!ok) continue
|
||||
pending.delete(id)
|
||||
yield* bus.publish(Event.Replied, {
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: item.info.sessionID,
|
||||
requestID: item.info.id,
|
||||
reply: "always",
|
||||
@@ -306,6 +306,6 @@ export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
|
||||
return PermissionV2.disabled(tools, ruleset)
|
||||
}
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(Bus.layer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer))
|
||||
|
||||
export * as Permission from "."
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
WorkspaceAdapter as PluginWorkspaceAdapter,
|
||||
} from "@opencode-ai/plugin"
|
||||
import { Config } from "@/config/config"
|
||||
import { Bus } from "../bus"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
@@ -29,6 +28,7 @@ import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } fro
|
||||
import { registerAdapter } from "@/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "@/control-plane/types"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
|
||||
const log = Log.create({ service: "plugin" })
|
||||
|
||||
@@ -112,7 +112,7 @@ async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks:
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const config = yield* Config.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
@@ -122,7 +122,7 @@ export const layer = Layer.effect(
|
||||
const bridge = yield* EffectBridge.make()
|
||||
|
||||
function publishPluginError(message: string) {
|
||||
bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
|
||||
bridge.fork(events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
|
||||
}
|
||||
|
||||
const { Server } = yield* Effect.promise(() => import("../server/server"))
|
||||
@@ -224,7 +224,7 @@ export const layer = Layer.effect(
|
||||
}).pipe(
|
||||
Effect.catch(() => {
|
||||
// TODO: make proper events for this
|
||||
// bus.publish(Session.Event.Error, {
|
||||
// events.publish(Session.Event.Error, {
|
||||
// error: new NamedError.Unknown({
|
||||
// message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||
// }).toObject(),
|
||||
@@ -244,17 +244,15 @@ export const layer = Layer.effect(
|
||||
}).pipe(Effect.ignore)
|
||||
}
|
||||
|
||||
// Subscribe to bus events, fiber interrupted when scope closes
|
||||
yield* (yield* bus.subscribeAll()).pipe(
|
||||
Stream.runForEach((input) =>
|
||||
Effect.sync(() => {
|
||||
for (const hook of hooks) {
|
||||
void hook["event"]?.({ event: input as any })
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const unsubscribe = yield* events.listen((event) => {
|
||||
if (event.location?.directory !== ctx.directory) return Effect.void
|
||||
return Effect.sync(() => {
|
||||
for (const hook of hooks) {
|
||||
void hook["event"]?.({ event: { id: event.id, type: event.type, properties: event.data } as any })
|
||||
}
|
||||
})
|
||||
})
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
return { hooks }
|
||||
}),
|
||||
@@ -289,7 +287,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ import { File } from "../file"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import * as Project from "./project"
|
||||
import * as Vcs from "./vcs"
|
||||
import { Bus } from "../bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
@@ -57,7 +56,6 @@ export const layer = Layer.effect(
|
||||
|
||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
Layer.provide([
|
||||
Bus.layer,
|
||||
Config.defaultLayer,
|
||||
File.defaultLayer,
|
||||
FileWatcher.defaultLayer,
|
||||
|
||||
@@ -5,10 +5,8 @@ import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { which } from "../util/which"
|
||||
import { Bus } from "@/bus"
|
||||
import { Command } from "@/command"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect"
|
||||
@@ -20,6 +18,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
||||
const log = Log.create({ service: "project" })
|
||||
|
||||
@@ -56,7 +56,7 @@ export const Info = Schema.Struct({
|
||||
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define("project.updated", Info),
|
||||
Updated: EventV2.define({ type: "project.updated", schema: Info.fields }),
|
||||
}
|
||||
|
||||
type Row = typeof ProjectTable.$inferSelect
|
||||
@@ -143,7 +143,7 @@ export const layer = Layer.effect(
|
||||
const proc = yield* AppProcess.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const projectV2 = yield* ProjectV2.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
@@ -398,12 +398,12 @@ export const layer = Layer.effect(
|
||||
|
||||
const initState = yield* InstanceState.make(
|
||||
Effect.fn("Project.initState")(function* (ctx) {
|
||||
yield* (yield* bus.subscribe(Command.Event.Executed)).pipe(
|
||||
Stream.runForEach((payload) =>
|
||||
payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void,
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const unsubscribe = yield* events.listen((event) => {
|
||||
if (event.type !== Command.Event.Executed.type || event.location?.directory !== ctx.directory) return Effect.void
|
||||
const data = event.data as EventV2.Data<typeof Command.Event.Executed>
|
||||
return data.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void
|
||||
})
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -474,7 +474,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Bus.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Git } from "@/git"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
||||
const log = Log.create({ service: "vcs" })
|
||||
const PATCH_CONTEXT_LINES = 2_147_483_647
|
||||
@@ -239,12 +239,12 @@ export const Mode = Schema.Literals(["git", "branch"])
|
||||
export type Mode = Schema.Schema.Type<typeof Mode>
|
||||
|
||||
export const Event = {
|
||||
BranchUpdated: BusEvent.define(
|
||||
"vcs.branch.updated",
|
||||
Schema.Struct({
|
||||
BranchUpdated: EventV2.define({
|
||||
type: "vcs.branch.updated",
|
||||
schema: {
|
||||
branch: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
@@ -305,11 +305,11 @@ interface State {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Layer.effect(
|
||||
export const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const git = yield* Git.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
@@ -327,20 +327,20 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
|
||||
const value = { current, root }
|
||||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||
|
||||
yield* (yield* bus.subscribe(FileWatcher.Event.Updated)).pipe(
|
||||
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||
Stream.runForEach((_evt) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* get()
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
yield* bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const unsubscribe = yield* events.listen((event) => {
|
||||
if (event.type !== FileWatcher.Event.Updated.type || event.location?.directory !== ctx.directory) return Effect.void
|
||||
const data = event.data as EventV2.Data<typeof FileWatcher.Event.Updated>
|
||||
if (!data.file.endsWith("HEAD")) return Effect.void
|
||||
return Effect.gen(function* () {
|
||||
const next = yield* get()
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
yield* events.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
})
|
||||
})
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
return value
|
||||
}),
|
||||
@@ -429,6 +429,9 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(Bus.layer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
)
|
||||
|
||||
export * as Vcs from "./vcs"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
@@ -92,10 +92,10 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty
|
||||
}) {}
|
||||
|
||||
export const Event = {
|
||||
Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })),
|
||||
Updated: BusEvent.define("pty.updated", Schema.Struct({ info: Info })),
|
||||
Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: NonNegativeInt })),
|
||||
Deleted: BusEvent.define("pty.deleted", Schema.Struct({ id: PtyID })),
|
||||
Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),
|
||||
Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }),
|
||||
Exited: EventV2.define({ type: "pty.exited", schema: { id: PtyID, exitCode: NonNegativeInt } }),
|
||||
Deleted: EventV2.define({ type: "pty.deleted", schema: { id: PtyID } }),
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -122,7 +122,7 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
function teardown(session: Active) {
|
||||
@@ -169,7 +169,7 @@ export const layer = Layer.effect(
|
||||
s.sessions.delete(id)
|
||||
log.info("removing session", { id })
|
||||
teardown(session)
|
||||
yield* bus.publish(Event.Deleted, { id: session.info.id })
|
||||
yield* events.publish(Event.Deleted, { id: session.info.id })
|
||||
})
|
||||
|
||||
const list = Effect.fn("Pty.list")(function* () {
|
||||
@@ -265,10 +265,10 @@ export const layer = Layer.effect(
|
||||
if (session.info.status === "exited") return
|
||||
log.info("session exited", { id, exitCode })
|
||||
session.info.status = "exited"
|
||||
bridge.fork(bus.publish(Event.Exited, { id, exitCode }))
|
||||
bridge.fork(events.publish(Event.Exited, { id, exitCode }))
|
||||
bridge.fork(remove(id))
|
||||
})
|
||||
yield* bus.publish(Event.Created, { info })
|
||||
yield* events.publish(Event.Created, { info })
|
||||
return info
|
||||
})
|
||||
|
||||
@@ -280,7 +280,7 @@ export const layer = Layer.effect(
|
||||
if (input.size) {
|
||||
session.process.resize(input.size.cols, input.size.rows)
|
||||
}
|
||||
yield* bus.publish(Event.Updated, { info: session.info })
|
||||
yield* events.publish(Event.Updated, { info: session.info })
|
||||
return session.info
|
||||
})
|
||||
|
||||
@@ -365,7 +365,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { QuestionID } from "./schema"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
||||
const log = Log.create({ service: "question" })
|
||||
|
||||
@@ -87,9 +87,9 @@ const Rejected = Schema.Struct({
|
||||
}).annotate({ identifier: "QuestionRejected" })
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("question.asked", Request),
|
||||
Replied: BusEvent.define("question.replied", Replied),
|
||||
Rejected: BusEvent.define("question.rejected", Rejected),
|
||||
Asked: EventV2.define({ type: "question.asked", schema: Request.fields }),
|
||||
Replied: EventV2.define({ type: "question.replied", schema: Replied.fields }),
|
||||
Rejected: EventV2.define({ type: "question.rejected", schema: Rejected.fields }),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
||||
@@ -132,7 +132,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Qu
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Question.state")(function* () {
|
||||
const state = {
|
||||
@@ -169,7 +169,7 @@ export const layer = Layer.effect(
|
||||
tool: input.tool,
|
||||
}
|
||||
pending.set(id, { info, deferred })
|
||||
yield* bus.publish(Event.Asked, info)
|
||||
yield* events.publish(Event.Asked, info)
|
||||
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
@@ -191,7 +191,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
pending.delete(input.requestID)
|
||||
log.info("replied", { requestID: input.requestID, answers: input.answers })
|
||||
yield* bus.publish(Event.Replied, {
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
answers: input.answers.map((a) => [...a]),
|
||||
@@ -208,7 +208,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
pending.delete(requestID)
|
||||
log.info("rejected", { requestID })
|
||||
yield* bus.publish(Event.Rejected, {
|
||||
yield* events.publish(Event.Rejected, {
|
||||
sessionID: existing.info.sessionID,
|
||||
requestID: existing.info.id,
|
||||
})
|
||||
@@ -224,6 +224,6 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
|
||||
|
||||
export * as Question from "."
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Schema } from "effect"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
||||
export const Event = {
|
||||
Connected: BusEvent.define("server.connected", Schema.Struct({})),
|
||||
Disposed: BusEvent.define("global.disposed", Schema.Struct({})),
|
||||
Connected: EventV2.define({ type: "server.connected", schema: {} }),
|
||||
Disposed: EventV2.define({ type: "global.disposed", schema: {} }),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi } from "effect/unstable/httpapi"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { ConfigApi } from "./groups/config"
|
||||
import { ControlApi } from "./groups/control"
|
||||
import { EventApi } from "./groups/event"
|
||||
@@ -22,8 +22,18 @@ import { V2Api } from "./groups/v2"
|
||||
import { Authorization } from "./middleware/authorization"
|
||||
import { SchemaErrorMiddleware } from "./middleware/schema-error"
|
||||
|
||||
// SSE event schemas built from the BusEvent and EventV2 registries.
|
||||
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
|
||||
const EventSchema = Schema.Union(
|
||||
EventV2.registry
|
||||
.values()
|
||||
.map((definition) =>
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.Literal(definition.type),
|
||||
properties: definition.data,
|
||||
}).annotate({ identifier: `Event.${definition.type}` }),
|
||||
)
|
||||
.toArray(),
|
||||
).annotate({ identifier: "Event" })
|
||||
|
||||
export const RootHttpApi = HttpApi.make("opencode-root")
|
||||
.addHttpApi(ControlApi)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Config } from "@/config/config"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import "@/server/event"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
@@ -14,7 +14,14 @@ const GlobalEventSchema = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
project: Schema.optional(Schema.String),
|
||||
workspace: Schema.optional(Schema.String),
|
||||
payload: Schema.Union(BusEvent.effectPayloads()),
|
||||
payload: Schema.Union(
|
||||
EventV2.registry
|
||||
.values()
|
||||
.map((definition) =>
|
||||
Schema.Struct({ id: Schema.String, type: Schema.Literal(definition.type), properties: definition.data }),
|
||||
)
|
||||
.toArray(),
|
||||
),
|
||||
}).annotate({ identifier: "GlobalEvent" })
|
||||
|
||||
export const GlobalUpgradeInput = Schema.Struct({
|
||||
|
||||
@@ -12,19 +12,19 @@ const root = "/tui"
|
||||
export const CommandPayload = Schema.Struct({ command: Schema.String })
|
||||
const EventTuiPromptAppend = Schema.Struct({
|
||||
type: Schema.Literal(TuiEvent.PromptAppend.type),
|
||||
properties: TuiEvent.PromptAppend.properties,
|
||||
properties: TuiEvent.PromptAppend.data,
|
||||
}).annotate({ identifier: "EventTuiPromptAppend" })
|
||||
const EventTuiCommandExecute = Schema.Struct({
|
||||
type: Schema.Literal(TuiEvent.CommandExecute.type),
|
||||
properties: TuiEvent.CommandExecute.properties,
|
||||
properties: TuiEvent.CommandExecute.data,
|
||||
}).annotate({ identifier: "EventTuiCommandExecute" })
|
||||
const EventTuiToastShow = Schema.Struct({
|
||||
type: Schema.Literal(TuiEvent.ToastShow.type),
|
||||
properties: TuiEvent.ToastShow.properties,
|
||||
properties: TuiEvent.ToastShow.data,
|
||||
}).annotate({ identifier: "EventTuiToastShow" })
|
||||
const EventTuiSessionSelect = Schema.Struct({
|
||||
type: Schema.Literal(TuiEvent.SessionSelect.type),
|
||||
properties: TuiEvent.SessionSelect.properties,
|
||||
properties: TuiEvent.SessionSelect.data,
|
||||
}).annotate({ identifier: "EventTuiSessionSelect" })
|
||||
export const TuiPublishPayload = Schema.Union([
|
||||
EventTuiPromptAppend,
|
||||
@@ -55,7 +55,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
.add(
|
||||
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: TuiEvent.PromptAppend.properties,
|
||||
payload: TuiEvent.PromptAppend.data,
|
||||
success: described(Schema.Boolean, "Prompt processed successfully"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
@@ -139,7 +139,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
),
|
||||
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: TuiEvent.ToastShow.properties,
|
||||
payload: TuiEvent.ToastShow.data,
|
||||
success: described(Schema.Boolean, "Toast notification shown successfully"),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
@@ -162,7 +162,7 @@ export const TuiApi = HttpApi.make("tui")
|
||||
),
|
||||
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: TuiEvent.SessionSelect.properties,
|
||||
payload: TuiEvent.SessionSelect.data,
|
||||
success: described(Schema.Boolean, "Session selected successfully"),
|
||||
error: [HttpApiError.BadRequest, ApiNotFoundError],
|
||||
}).annotateMerge(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { SessionID } from "@/session/schema"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { InvalidCursorError, SessionNotFoundError } from "../../errors"
|
||||
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
|
||||
import { V2Authorization } from "../../middleware/authorization"
|
||||
import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
|
||||
|
||||
@@ -36,7 +36,7 @@ export const MessageGroup = HttpApiGroup.make("v2.message")
|
||||
next: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
}).annotate({ identifier: "V2SessionMessagesResponse" }),
|
||||
error: [InvalidCursorError, SessionNotFoundError],
|
||||
error: [InvalidCursorError, SessionNotFoundError, UnknownError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.messages",
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import {
|
||||
InvalidCursorError,
|
||||
InvalidRequestError,
|
||||
ServiceUnavailableError,
|
||||
SessionNotFoundError,
|
||||
UnknownError,
|
||||
} from "../../errors"
|
||||
import { V2Authorization } from "../../middleware/authorization"
|
||||
import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
|
||||
@@ -55,12 +58,30 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: Schema.Struct({
|
||||
prompt: Prompt,
|
||||
delivery: SessionV2.Delivery.pipe(Schema.optional),
|
||||
}),
|
||||
success: SessionMessage.Message,
|
||||
error: [SessionNotFoundError, ServiceUnavailableError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.prompt",
|
||||
summary: "Send v2 message",
|
||||
description: "Create a v2 session message and queue it for the agent loop.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError],
|
||||
error: [SessionNotFoundError, ServiceUnavailableError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.compact",
|
||||
@@ -74,7 +95,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError],
|
||||
error: [SessionNotFoundError, ServiceUnavailableError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.wait",
|
||||
@@ -88,7 +109,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.Array(SessionMessage.Message),
|
||||
error: [SessionNotFoundError],
|
||||
error: [SessionNotFoundError, UnknownError],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.context",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Queue } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
@@ -18,24 +21,51 @@ function eventData(data: unknown): Sse.Event {
|
||||
}
|
||||
}
|
||||
|
||||
function eventResponse(bus: Bus.Interface) {
|
||||
function eventID() {
|
||||
return EventV2.ID.create()
|
||||
}
|
||||
|
||||
function eventResponse(events: EventV2.Interface) {
|
||||
return Effect.gen(function* () {
|
||||
// Subscribe eagerly: the bus subscription is acquired in the request scope
|
||||
// at this yield, so any publish from now on is queued for the body-pump
|
||||
// fiber to drain — closing the race where Stream.concat(server.connected,
|
||||
// lazy-subscribe) used to drop publishes in the prefix-consume window.
|
||||
const events = (yield* bus.subscribeAll()).pipe(
|
||||
Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type),
|
||||
const instance = yield* InstanceState.context
|
||||
const workspaceID = yield* InstanceState.workspaceID
|
||||
// Listener registration is eager, so events published after this point cannot
|
||||
// be lost while the HTTP body fiber is starting or emitting server.connected.
|
||||
const queue = yield* Queue.unbounded<EventV2.Payload>()
|
||||
const unsubscribe = yield* events.listen((event) => Effect.sync(() => Queue.offerUnsafe(queue, event)))
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const stream = Stream.fromQueue(queue).pipe(
|
||||
Stream.filter(
|
||||
(event) =>
|
||||
event.location?.directory === instance.directory &&
|
||||
(event.location.workspaceID === undefined || event.location.workspaceID === workspaceID),
|
||||
),
|
||||
Stream.map((event) => ({ id: event.id, type: event.type, properties: event.data })),
|
||||
)
|
||||
const disposed = Stream.callback<{ id: string; type: string; properties: unknown }>((queue) => {
|
||||
const listener = (event: { directory?: string; payload: { id?: string; type?: string; properties?: unknown } }) => {
|
||||
if (event.directory !== instance.directory || event.payload.type !== "server.instance.disposed") return
|
||||
Queue.offerUnsafe(queue, {
|
||||
id: event.payload.id ?? eventID(),
|
||||
type: "server.instance.disposed",
|
||||
properties: event.payload.properties ?? {},
|
||||
})
|
||||
}
|
||||
return Effect.acquireRelease(
|
||||
Effect.sync(() => GlobalBus.on("event", listener)),
|
||||
() => Effect.sync(() => GlobalBus.off("event", listener)),
|
||||
)
|
||||
})
|
||||
const output = stream.pipe(Stream.merge(disposed, { haltStrategy: "left" }), Stream.takeUntil((event) => event.type === "server.instance.disposed"))
|
||||
const heartbeat = Stream.tick("10 seconds").pipe(
|
||||
Stream.drop(1),
|
||||
Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })),
|
||||
Stream.map(() => ({ id: eventID(), type: "server.heartbeat", properties: {} })),
|
||||
)
|
||||
|
||||
log.info("event connected")
|
||||
return HttpServerResponse.stream(
|
||||
Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe(
|
||||
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
|
||||
Stream.make({ id: eventID(), type: "server.connected", properties: {} }).pipe(
|
||||
Stream.concat(output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
|
||||
Stream.map(eventData),
|
||||
Stream.pipeThroughChannel(Sse.encode()),
|
||||
Stream.encodeText,
|
||||
@@ -55,11 +85,11 @@ function eventResponse(bus: Bus.Interface) {
|
||||
|
||||
export const eventHandlers = HttpApiBuilder.group(EventApi, "event", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
return handlers.handleRaw(
|
||||
"subscribe",
|
||||
Effect.fn("EventHttpApi.subscribe")(function* () {
|
||||
return yield* eventResponse(bus)
|
||||
return yield* eventResponse(events)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -29,6 +29,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
||||
const project = yield* Project.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const worktreeSvc = yield* Worktree.Service
|
||||
const sessions = yield* Session.Service
|
||||
|
||||
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
|
||||
const [state, groups] = yield* Effect.all(
|
||||
@@ -127,21 +128,19 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
||||
|
||||
const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
|
||||
const limit = ctx.query.limit ?? 100
|
||||
const sessions = Array.from(
|
||||
Session.listGlobal({
|
||||
directory: ctx.query.directory,
|
||||
roots: ctx.query.roots,
|
||||
start: ctx.query.start,
|
||||
cursor: ctx.query.cursor,
|
||||
search: ctx.query.search,
|
||||
limit: limit + 1,
|
||||
archived: ctx.query.archived,
|
||||
}),
|
||||
)
|
||||
const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
|
||||
const all = yield* sessions.listGlobal({
|
||||
directory: ctx.query.directory,
|
||||
roots: ctx.query.roots,
|
||||
start: ctx.query.start,
|
||||
cursor: ctx.query.cursor,
|
||||
search: ctx.query.search,
|
||||
limit: limit + 1,
|
||||
archived: ctx.query.archived,
|
||||
})
|
||||
const list = all.length > limit ? all.slice(0, limit) : all
|
||||
return HttpServerResponse.jsonUnsafe(list, {
|
||||
headers:
|
||||
sessions.length > limit && list.length > 0
|
||||
all.length > limit && list.length > 0
|
||||
? { "x-next-cursor": String(list[list.length - 1].time.updated) }
|
||||
: undefined,
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Config } from "@/config/config"
|
||||
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { Bus } from "@/bus"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Installation } from "@/installation"
|
||||
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
@@ -44,11 +44,11 @@ function eventResponse() {
|
||||
})
|
||||
const heartbeat = Stream.tick("10 seconds").pipe(
|
||||
Stream.drop(1),
|
||||
Stream.map(() => ({ payload: { id: Bus.createID(), type: "server.heartbeat", properties: {} } })),
|
||||
Stream.map(() => ({ payload: { id: EventV2.ID.create(), type: "server.heartbeat", properties: {} } })),
|
||||
)
|
||||
|
||||
return HttpServerResponse.stream(
|
||||
Stream.make({ payload: { id: Bus.createID(), type: "server.connected", properties: {} } }).pipe(
|
||||
Stream.make({ payload: { id: EventV2.ID.create(), type: "server.connected", properties: {} } }).pipe(
|
||||
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
|
||||
Stream.map(eventData),
|
||||
Stream.pipeThroughChannel(Sse.encode()),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { Bus } from "@/bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Command } from "@/command"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
@@ -57,7 +57,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
const statusSvc = yield* SessionStatus.Service
|
||||
const todoSvc = yield* Todo.Service
|
||||
const summary = yield* SessionSummary.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
|
||||
@@ -311,7 +311,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
yield* Effect.logError("prompt_async failed").pipe(
|
||||
Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }),
|
||||
)
|
||||
yield* bus.publish(Session.Event.Error, {
|
||||
yield* events.publish(Session.Event.Error, {
|
||||
sessionID: ctx.params.sessionID,
|
||||
error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { Session } from "@/session/session"
|
||||
import { Effect } from "effect"
|
||||
@@ -26,15 +26,15 @@ const commandAliases = {
|
||||
|
||||
export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const session = yield* Session.Service
|
||||
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) =>
|
||||
bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type)
|
||||
const publishCommand = (command: typeof TuiEvent.CommandExecute.data.Type.command | undefined) =>
|
||||
events.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.data.Type)
|
||||
|
||||
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
|
||||
payload: typeof TuiEvent.PromptAppend.properties.Type
|
||||
payload: typeof TuiEvent.PromptAppend.data.Type
|
||||
}) {
|
||||
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload)
|
||||
yield* events.publish(TuiEvent.PromptAppend, ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -77,29 +77,29 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler
|
||||
})
|
||||
|
||||
const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: {
|
||||
payload: typeof TuiEvent.ToastShow.properties.Type
|
||||
payload: typeof TuiEvent.ToastShow.data.Type
|
||||
}) {
|
||||
yield* bus.publish(TuiEvent.ToastShow, ctx.payload)
|
||||
yield* events.publish(TuiEvent.ToastShow, ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) {
|
||||
if (ctx.payload.type === TuiEvent.PromptAppend.type)
|
||||
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties)
|
||||
yield* events.publish(TuiEvent.PromptAppend, ctx.payload.properties)
|
||||
if (ctx.payload.type === TuiEvent.CommandExecute.type)
|
||||
yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties)
|
||||
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties)
|
||||
yield* events.publish(TuiEvent.CommandExecute, ctx.payload.properties)
|
||||
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* events.publish(TuiEvent.ToastShow, ctx.payload.properties)
|
||||
if (ctx.payload.type === TuiEvent.SessionSelect.type)
|
||||
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties)
|
||||
yield* events.publish(TuiEvent.SessionSelect, ctx.payload.properties)
|
||||
return true
|
||||
})
|
||||
|
||||
const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: {
|
||||
payload: typeof TuiEvent.SessionSelect.properties.Type
|
||||
payload: typeof TuiEvent.SessionSelect.data.Type
|
||||
}) {
|
||||
if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({})
|
||||
yield* SessionError.mapStorageNotFound(session.get(ctx.payload.sessionID))
|
||||
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
|
||||
yield* events.publish(TuiEvent.SessionSelect, ctx.payload)
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Effect, Schema } from "effect"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
import { InvalidCursorError, SessionNotFoundError } from "../../errors"
|
||||
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
|
||||
|
||||
const DefaultMessagesLimit = 50
|
||||
|
||||
@@ -58,6 +58,20 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.MessageDecodeError", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to decode v2 session message").pipe(
|
||||
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({
|
||||
message: "Unexpected server error. Check server logs for details.",
|
||||
ref,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const first = messages[0]
|
||||
const last = messages.at(-1)
|
||||
|
||||
@@ -4,7 +4,13 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { DateTime, Effect, Option, Schema } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { InstanceHttpApi } from "../../api"
|
||||
import { InvalidCursorError, InvalidRequestError, SessionNotFoundError } from "../../errors"
|
||||
import {
|
||||
InvalidCursorError,
|
||||
InvalidRequestError,
|
||||
ServiceUnavailableError,
|
||||
SessionNotFoundError,
|
||||
UnknownError,
|
||||
} from "../../errors"
|
||||
|
||||
const DefaultSessionsLimit = 50
|
||||
|
||||
@@ -135,6 +141,35 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"prompt",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* session
|
||||
.prompt({
|
||||
sessionID: ctx.params.sessionID,
|
||||
prompt: ctx.payload.prompt,
|
||||
delivery: ctx.payload.delivery ?? SessionV2.DefaultDelivery,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("Session.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
new SessionNotFoundError({
|
||||
sessionID: error.sessionID,
|
||||
message: `Session not found: ${error.sessionID}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.OperationUnavailableError", (error) =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: `V2 session ${error.operation} is not available yet`,
|
||||
service: `v2.session.${error.operation}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"compact",
|
||||
Effect.fn(function* (ctx) {
|
||||
@@ -147,6 +182,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.OperationUnavailableError", (error) =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: `V2 session ${error.operation} is not available yet`,
|
||||
service: `v2.session.${error.operation}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
@@ -163,6 +206,14 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.OperationUnavailableError", (error) =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: `V2 session ${error.operation} is not available yet`,
|
||||
service: `v2.session.${error.operation}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
@@ -179,6 +230,20 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.catchTag("Session.MessageDecodeError", (error) => {
|
||||
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
||||
return Effect.logError("failed to decode v2 session message").pipe(
|
||||
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
|
||||
Effect.andThen(
|
||||
Effect.fail(
|
||||
new UnknownError({
|
||||
message: "Unexpected server error. Check server logs for details.",
|
||||
ref,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Effect } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import * as Fence from "@/server/shared/fence"
|
||||
|
||||
const ignoredMethods = new Set(["GET", "HEAD", "OPTIONS"])
|
||||
|
||||
export const fenceLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) =>
|
||||
export const fenceLayer = HttpRouter.middleware<{ requires: Database.Service; handles: unknown }>()(
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
if (!Flag.OPENCODE_WORKSPACE_ID || ignoredMethods.has(request.method)) return yield* effect
|
||||
const { db } = yield* Database.Service
|
||||
return (effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
if (!Flag.OPENCODE_WORKSPACE_ID || ignoredMethods.has(request.method)) return yield* effect
|
||||
|
||||
const previous = Fence.load()
|
||||
const response = yield* effect
|
||||
const current = Fence.diff(previous, Fence.load())
|
||||
if (Object.keys(current).length === 0) return response
|
||||
const previous = yield* Fence.load(db)
|
||||
const response = yield* effect
|
||||
const current = Fence.diff(previous, yield* Fence.load(db))
|
||||
if (Object.keys(current).length === 0) return response
|
||||
|
||||
return HttpServerResponse.setHeader(response, Fence.HEADER, JSON.stringify(current))
|
||||
return HttpServerResponse.setHeader(response, Fence.HEADER, JSON.stringify(current))
|
||||
})
|
||||
}),
|
||||
).layer
|
||||
|
||||
@@ -13,7 +13,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Account } from "@/account/account"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Auth } from "@/auth"
|
||||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
import { Command } from "@/command"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
@@ -189,7 +188,7 @@ export function createRoutes(
|
||||
errorLayer,
|
||||
compressionLayer,
|
||||
corsVaryFix,
|
||||
fenceLayer,
|
||||
fenceLayer.pipe(Layer.provide(Database.defaultLayer)),
|
||||
cors(corsOptions),
|
||||
Database.defaultLayer,
|
||||
Account.defaultLayer,
|
||||
@@ -231,7 +230,6 @@ export function createRoutes(
|
||||
Vcs.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
Worktree.appLayer,
|
||||
Bus.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FetchHttpClient.layer,
|
||||
HttpServer.layerServices,
|
||||
|
||||
@@ -5,24 +5,21 @@ import { Workspace } from "@/control-plane/workspace"
|
||||
import type { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export const HEADER = "x-opencode-sync"
|
||||
export type State = Record<string, number>
|
||||
const log = Log.create({ service: "fence" })
|
||||
const runtime = makeRuntime(Database.Service, Database.defaultLayer)
|
||||
|
||||
export function load(ids?: string[]) {
|
||||
return runtime.runSync(({ db }) =>
|
||||
Effect.gen(function* () {
|
||||
const rows = yield* (ids?.length
|
||||
export function load(db: Database.Interface["db"], ids?: string[]) {
|
||||
return Effect.gen(function* () {
|
||||
const rows = yield* (
|
||||
ids?.length
|
||||
? db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
|
||||
: db.select().from(EventSequenceTable).all()
|
||||
).pipe(Effect.orDie)
|
||||
).pipe(Effect.orDie)
|
||||
|
||||
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq]))
|
||||
}),
|
||||
)
|
||||
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq]))
|
||||
})
|
||||
}
|
||||
|
||||
export function diff(prev: State, next: State) {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { Bus } from "@/bus"
|
||||
import * as Session from "./session"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { Provider } from "@/provider/provider"
|
||||
@@ -22,16 +20,17 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
||||
const log = Log.create({ service: "session.compaction" })
|
||||
|
||||
export const Event = {
|
||||
Compacted: BusEvent.define(
|
||||
"session.compacted",
|
||||
Schema.Struct({
|
||||
Compacted: EventV2.define({
|
||||
type: "session.compacted",
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export const PRUNE_MINIMUM = 20_000
|
||||
@@ -214,7 +213,6 @@ export const use = serviceUse(Service)
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Service
|
||||
const session = yield* Session.Service
|
||||
const agents = yield* Agent.Service
|
||||
@@ -578,7 +576,7 @@ export const layer = Layer.effect(
|
||||
include: selected.tail_start_id,
|
||||
})
|
||||
}
|
||||
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
|
||||
yield* events.publish(Event.Compacted, { sessionID: input.sessionID })
|
||||
}
|
||||
return result
|
||||
})
|
||||
@@ -631,7 +629,6 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(SessionProcessor.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
|
||||
@@ -16,7 +16,8 @@ import type { MessageV2 } from "./message-v2"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { Bus } from "@/bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Auth } from "@/auth"
|
||||
@@ -66,6 +67,7 @@ const live: Layer.Layer<
|
||||
| Provider.Service
|
||||
| Plugin.Service
|
||||
| Permission.Service
|
||||
| EventV2Bridge.Service
|
||||
| LLMClientService
|
||||
| RuntimeFlags.Service
|
||||
> = Layer.effect(
|
||||
@@ -76,6 +78,7 @@ const live: Layer.Layer<
|
||||
const provider = yield* Provider.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const perm = yield* Permission.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const llmClient = yield* LLMClient.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
@@ -163,11 +166,15 @@ const live: Layer.Layer<
|
||||
}
|
||||
|
||||
const id = PermissionID.ascending()
|
||||
let unsub: (() => void) | undefined
|
||||
let unsub: EventV2.Unsubscribe | undefined
|
||||
try {
|
||||
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
|
||||
if (evt.properties.requestID === id) void evt.properties.reply
|
||||
})
|
||||
unsub = await bridge.promise(events.listen((event) => {
|
||||
if (event.type !== Permission.Event.Replied.type) return Effect.void
|
||||
const data = event.data as EventV2.Data<typeof Permission.Event.Replied>
|
||||
if (data.requestID !== id) return Effect.void
|
||||
void data.reply
|
||||
return Effect.void
|
||||
}))
|
||||
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
|
||||
try {
|
||||
const parsed = JSON.parse(t.args) as Record<string, unknown>
|
||||
@@ -195,7 +202,7 @@ const live: Layer.Layer<
|
||||
} catch {
|
||||
return { approved: false }
|
||||
} finally {
|
||||
unsub?.()
|
||||
if (unsub) await bridge.promise(unsub)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -371,7 +378,7 @@ const live: Layer.Layer<
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = live.pipe(Layer.provide(Permission.defaultLayer))
|
||||
export const layer = live.pipe(Layer.provide(Permission.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer))
|
||||
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
@@ -56,20 +56,20 @@ function truncateToolOutput(text: string, maxChars?: number) {
|
||||
}
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define("message.updated", SessionLegacy.Event.MessageUpdated.data),
|
||||
Removed: BusEvent.define("message.removed", SessionLegacy.Event.MessageRemoved.data),
|
||||
PartUpdated: BusEvent.define("message.part.updated", SessionLegacy.Event.PartUpdated.data),
|
||||
PartDelta: BusEvent.define(
|
||||
"message.part.delta",
|
||||
Schema.Struct({
|
||||
Updated: SessionLegacy.Event.MessageUpdated,
|
||||
Removed: SessionLegacy.Event.MessageRemoved,
|
||||
PartUpdated: SessionLegacy.Event.PartUpdated,
|
||||
PartDelta: EventV2.define({
|
||||
type: "message.part.delta",
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
messageID: MessageID,
|
||||
partID: PartID,
|
||||
field: Schema.String,
|
||||
delta: Schema.String,
|
||||
}),
|
||||
),
|
||||
PartRemoved: BusEvent.define("message.part.removed", SessionLegacy.Event.PartRemoved.data),
|
||||
},
|
||||
}),
|
||||
PartRemoved: SessionLegacy.Event.PartRemoved,
|
||||
}
|
||||
|
||||
const Cursor = Schema.Struct({
|
||||
|
||||
@@ -3,7 +3,6 @@ import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
import { Permission } from "@/permission"
|
||||
import { Plugin } from "@/plugin"
|
||||
@@ -91,7 +90,6 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const agents = yield* Agent.Service
|
||||
const llm = yield* LLM.Service
|
||||
@@ -758,7 +756,7 @@ export const layer = Layer.effect(
|
||||
const error = parse(e)
|
||||
if (SessionLegacy.ContextOverflowError.isInstance(error)) {
|
||||
ctx.needsCompaction = true
|
||||
yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
|
||||
yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
|
||||
return
|
||||
}
|
||||
if (!ctx.assistantMessage.summary) {
|
||||
@@ -775,7 +773,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
}
|
||||
ctx.assistantMessage.error = error
|
||||
yield* bus.publish(Session.Event.Error, {
|
||||
yield* events.publish(Session.Event.Error, {
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
error: ctx.assistantMessage.error,
|
||||
})
|
||||
@@ -878,7 +876,6 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(SessionSummary.defaultLayer),
|
||||
Layer.provide(SessionStatus.defaultLayer),
|
||||
Layer.provide(Image.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Provider } from "@/provider/provider"
|
||||
import { type Tool as AITool, tool, jsonSchema } from "ai"
|
||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import { SessionCompaction } from "./compaction"
|
||||
import { Bus } from "../bus"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { Instruction } from "./instruction"
|
||||
import { Plugin } from "../plugin"
|
||||
@@ -102,7 +101,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const status = yield* SessionStatus.Service
|
||||
const sessions = yield* Session.Service
|
||||
const agents = yield* Agent.Service
|
||||
@@ -366,7 +364,7 @@ export const layer = Layer.effect(
|
||||
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
|
||||
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
||||
const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` })
|
||||
yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })
|
||||
yield* events.publish(Session.Event.Error, { sessionID, error: error.toObject() })
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -511,7 +509,7 @@ export const layer = Layer.effect(
|
||||
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
|
||||
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
||||
const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` })
|
||||
yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
|
||||
yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
|
||||
throw error
|
||||
}
|
||||
const model = input.model ?? agent.model ?? (yield* currentModel(input.sessionID))
|
||||
@@ -665,7 +663,7 @@ export const layer = Layer.effect(
|
||||
const err = Cause.squash(exit.cause)
|
||||
if (Provider.ModelNotFoundError.isInstance(err)) {
|
||||
const hint = err.suggestions?.length ? ` Did you mean: ${err.suggestions.join(", ")}?` : ""
|
||||
yield* bus.publish(Session.Event.Error, {
|
||||
yield* events.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: new NamedError.Unknown({
|
||||
message: `Model not found: ${err.providerID}/${err.modelID}.${hint}`,
|
||||
@@ -703,7 +701,7 @@ export const layer = Layer.effect(
|
||||
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
|
||||
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
||||
const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })
|
||||
yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
|
||||
yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -963,7 +961,7 @@ export const layer = Layer.effect(
|
||||
const error = Cause.squash(exit.cause)
|
||||
log.error("failed to read file", { error })
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
yield* bus.publish(Session.Event.Error, {
|
||||
yield* events.publish(Session.Event.Error, {
|
||||
sessionID: input.sessionID,
|
||||
error: new NamedError.Unknown({ message }).toObject(),
|
||||
})
|
||||
@@ -985,7 +983,7 @@ export const layer = Layer.effect(
|
||||
const error = Cause.squash(exit.cause)
|
||||
log.error("failed to read directory", { error })
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
yield* bus.publish(Session.Event.Error, {
|
||||
yield* events.publish(Session.Event.Error, {
|
||||
sessionID: input.sessionID,
|
||||
error: new NamedError.Unknown({ message }).toObject(),
|
||||
})
|
||||
@@ -1340,7 +1338,7 @@ export const layer = Layer.effect(
|
||||
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
|
||||
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
||||
const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` })
|
||||
yield* bus.publish(Session.Event.Error, { sessionID, error: error.toObject() })
|
||||
yield* events.publish(Session.Event.Error, { sessionID, error: error.toObject() })
|
||||
throw error
|
||||
}
|
||||
const maxSteps = agent.steps ?? Infinity
|
||||
@@ -1524,7 +1522,7 @@ export const layer = Layer.effect(
|
||||
const available = (yield* commands.list()).map((c) => c.name)
|
||||
const hint = available.length ? ` Available commands: ${available.join(", ")}` : ""
|
||||
const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` })
|
||||
yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
|
||||
yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
|
||||
throw error
|
||||
}
|
||||
const agentName = cmd.agent ?? input.agent
|
||||
@@ -1585,7 +1583,7 @@ export const layer = Layer.effect(
|
||||
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
|
||||
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
|
||||
const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })
|
||||
yield* bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
|
||||
yield* events.publish(Session.Event.Error, { sessionID: input.sessionID, error: error.toObject() })
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -1625,7 +1623,7 @@ export const layer = Layer.effect(
|
||||
parts,
|
||||
variant: input.variant,
|
||||
})
|
||||
yield* bus.publish(Command.Event.Executed, {
|
||||
yield* events.publish(Command.Event.Executed, {
|
||||
name: input.command,
|
||||
sessionID: input.sessionID,
|
||||
arguments: input.arguments,
|
||||
@@ -1673,7 +1671,6 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
SystemPrompt.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
Reference.defaultLayer,
|
||||
Bus.layer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
RuntimeFlags.defaultLayer,
|
||||
EventV2Bridge.defaultLayer,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { Bus } from "../bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Snapshot } from "../snapshot"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -33,7 +33,7 @@ export const layer = Layer.effect(
|
||||
const sessions = yield* Session.Service
|
||||
const snap = yield* Snapshot.Service
|
||||
const storage = yield* Storage.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const summary = yield* SessionSummary.Service
|
||||
const state = yield* SessionRunState.Service
|
||||
|
||||
@@ -76,7 +76,7 @@ export const layer = Layer.effect(
|
||||
const range = all.filter((msg) => msg.info.id >= rev.messageID)
|
||||
const diffs = yield* summary.computeDiff({ messages: range })
|
||||
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
|
||||
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
||||
yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
||||
yield* sessions.setRevert({
|
||||
sessionID: input.sessionID,
|
||||
revert: rev,
|
||||
@@ -145,7 +145,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(SessionSummary.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3,14 +3,13 @@ import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import path from "path"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { Decimal } from "decimal.js"
|
||||
import type { ProviderMetadata, Usage } from "@opencode-ai/llm"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
@@ -299,6 +298,16 @@ export type ListInput = {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export type GlobalListInput = {
|
||||
directory?: string
|
||||
roots?: boolean
|
||||
start?: number
|
||||
cursor?: number
|
||||
search?: string
|
||||
limit?: number
|
||||
archived?: boolean
|
||||
}
|
||||
|
||||
const CreatedEventSchema = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
info: Info,
|
||||
@@ -342,25 +351,25 @@ const UpdatedEventSchema = Schema.Struct({
|
||||
})
|
||||
|
||||
export const Event = {
|
||||
Created: BusEvent.define("session.created", SessionLegacy.Event.Created.data),
|
||||
Updated: BusEvent.define("session.updated", SessionLegacy.Event.Updated.data),
|
||||
Deleted: BusEvent.define("session.deleted", SessionLegacy.Event.Deleted.data),
|
||||
Diff: BusEvent.define(
|
||||
"session.diff",
|
||||
Schema.Struct({
|
||||
Created: SessionLegacy.Event.Created,
|
||||
Updated: SessionLegacy.Event.Updated,
|
||||
Deleted: SessionLegacy.Event.Deleted,
|
||||
Diff: EventV2.define({
|
||||
type: "session.diff",
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
diff: Schema.Array(Snapshot.FileDiff),
|
||||
}),
|
||||
),
|
||||
Error: BusEvent.define(
|
||||
"session.error",
|
||||
Schema.Struct({
|
||||
},
|
||||
}),
|
||||
Error: EventV2.define({
|
||||
type: "session.error",
|
||||
schema: {
|
||||
sessionID: Schema.optional(SessionID),
|
||||
// Reuses SessionLegacy.Assistant.fields.error (already Schema.optional) so
|
||||
// the derived zod keeps the same discriminated-union shape on the bus.
|
||||
// the derived schema keeps the same discriminated-union shape on the event stream.
|
||||
error: SessionLegacy.Assistant.fields.error,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export function plan(input: { slug: string; time: { created: number } }, instance: InstanceContext) {
|
||||
@@ -445,6 +454,7 @@ export type NotFound = NotFoundError
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
||||
readonly listGlobal: (input?: GlobalListInput) => Effect.Effect<GlobalInfo[]>
|
||||
readonly create: (input?: {
|
||||
parentID?: SessionID
|
||||
title?: string
|
||||
@@ -469,7 +479,10 @@ export interface Interface {
|
||||
readonly setShare: (input: { sessionID: SessionID; share: Info["share"] }) => Effect.Effect<void>
|
||||
readonly setWorkspace: (input: { sessionID: SessionID; workspaceID: Info["workspaceID"] }) => Effect.Effect<void>
|
||||
readonly diff: (sessionID: SessionID) => Effect.Effect<Snapshot.FileDiff[]>
|
||||
readonly messages: (input: { sessionID: SessionID; limit?: number }) => Effect.Effect<SessionLegacy.WithParts[], NotFound>
|
||||
readonly messages: (input: {
|
||||
sessionID: SessionID
|
||||
limit?: number
|
||||
}) => Effect.Effect<SessionLegacy.WithParts[], NotFound>
|
||||
readonly children: (parentID: SessionID) => Effect.Effect<Info[]>
|
||||
readonly remove: (sessionID: SessionID) => Effect.Effect<void, NotFound>
|
||||
readonly updateMessage: <T extends SessionLegacy.Info>(msg: T) => Effect.Effect<T>
|
||||
@@ -510,14 +523,17 @@ export type Patch = Omit<Partial<Info>, "time" | "share" | "summary" | "revert"
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
BackgroundJob.Service | Bus.Service | Storage.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service
|
||||
| BackgroundJob.Service
|
||||
| Storage.Service
|
||||
| RuntimeFlags.Service
|
||||
| Database.Service
|
||||
| EventV2Bridge.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const database = yield* Database.Service
|
||||
const background = yield* BackgroundJob.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const storage = yield* Storage.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
@@ -570,13 +586,11 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
log.info("created", result)
|
||||
|
||||
yield* events.publish(SessionLegacy.Event.Created, { sessionID: result.id, info: result }, { location: eventLocation(result) })
|
||||
|
||||
if (!flags.experimentalWorkspaces) {
|
||||
// This only exist for backwards compatibility. We should not be
|
||||
// manually publishing this event; it is a sync event now
|
||||
yield* bus.publish(Event.Updated, { sessionID: result.id, info: result })
|
||||
}
|
||||
yield* events.publish(
|
||||
SessionLegacy.Event.Created,
|
||||
{ sessionID: result.id, info: result },
|
||||
{ location: eventLocation(result) },
|
||||
)
|
||||
|
||||
return result
|
||||
})
|
||||
@@ -589,9 +603,52 @@ export const layer: Layer.Layer<
|
||||
|
||||
const list = Effect.fn("Session.list")(function* (input?: ListInput) {
|
||||
const ctx = yield* InstanceState.context
|
||||
return Array.from(
|
||||
listByProject({ projectID: ctx.project.id, experimentalWorkspaces: flags.experimentalWorkspaces, ...input }),
|
||||
)
|
||||
return yield* listByProject(db, {
|
||||
projectID: ctx.project.id,
|
||||
experimentalWorkspaces: flags.experimentalWorkspaces,
|
||||
...input,
|
||||
})
|
||||
})
|
||||
|
||||
const listGlobal = Effect.fn("Session.listGlobal")(function* (input?: GlobalListInput) {
|
||||
const conditions: SQL[] = []
|
||||
if (input?.directory) conditions.push(eq(SessionTable.directory, input.directory))
|
||||
if (input?.roots) conditions.push(isNull(SessionTable.parent_id))
|
||||
if (input?.start) conditions.push(gte(SessionTable.time_updated, input.start))
|
||||
if (input?.cursor) conditions.push(lt(SessionTable.time_updated, input.cursor))
|
||||
if (input?.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
if (!input?.archived) conditions.push(isNull(SessionTable.time_archived))
|
||||
|
||||
const query =
|
||||
conditions.length > 0
|
||||
? db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(and(...conditions))
|
||||
: db.select().from(SessionTable)
|
||||
const rows = yield* query
|
||||
.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id))
|
||||
.limit(input?.limit ?? 100)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const ids = [...new Set(rows.map((row) => row.project_id))]
|
||||
const projects = new Map<string, ProjectInfo>()
|
||||
if (ids.length > 0) {
|
||||
const items = yield* db
|
||||
.select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree })
|
||||
.from(ProjectTable)
|
||||
.where(inArray(ProjectTable.id, ids))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
for (const item of items) {
|
||||
projects.set(item.id, {
|
||||
id: item.id,
|
||||
name: item.name ?? undefined,
|
||||
worktree: item.worktree,
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows.map((row) => ({ ...fromRow(row), project: projects.get(row.project_id) ?? null }))
|
||||
})
|
||||
|
||||
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
|
||||
@@ -620,7 +677,11 @@ export const layer: Layer.Layer<
|
||||
yield* remove(child.id)
|
||||
}
|
||||
|
||||
yield* events.publish(SessionLegacy.Event.Deleted, { sessionID, info: session }, { location: eventLocation(session) })
|
||||
yield* events.publish(
|
||||
SessionLegacy.Event.Deleted,
|
||||
{ sessionID, info: session },
|
||||
{ location: eventLocation(session) },
|
||||
)
|
||||
yield* events.remove(sessionID)
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
@@ -630,11 +691,7 @@ export const layer: Layer.Layer<
|
||||
const updateMessage = <T extends SessionLegacy.Info>(msg: T): Effect.Effect<T> =>
|
||||
Effect.gen(function* () {
|
||||
const location = yield* locationForSession(msg.sessionID)
|
||||
yield* events.publish(
|
||||
SessionLegacy.Event.MessageUpdated,
|
||||
{ sessionID: msg.sessionID, info: msg },
|
||||
{ location },
|
||||
)
|
||||
yield* events.publish(SessionLegacy.Event.MessageUpdated, { sessionID: msg.sessionID, info: msg }, { location })
|
||||
return msg
|
||||
}).pipe(Effect.withSpan("Session.updateMessage"))
|
||||
|
||||
@@ -780,9 +837,11 @@ export const layer: Layer.Layer<
|
||||
revert: Info["revert"]
|
||||
summary: Info["summary"]
|
||||
}) {
|
||||
yield* patch(input.sessionID, { summary: input.summary, time: { updated: Date.now() }, revert: input.revert }).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
yield* patch(input.sessionID, {
|
||||
summary: input.summary,
|
||||
time: { updated: Date.now() },
|
||||
revert: input.revert,
|
||||
}).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const clearRevert = Effect.fn("Session.clearRevert")(function* (sessionID: SessionID) {
|
||||
@@ -804,7 +863,9 @@ export const layer: Layer.Layer<
|
||||
sessionID: SessionID
|
||||
workspaceID: Info["workspaceID"]
|
||||
}) {
|
||||
yield* patch(input.sessionID, { workspaceID: input.workspaceID, time: { updated: Date.now() } }).pipe(Effect.orDie)
|
||||
yield* patch(input.sessionID, { workspaceID: input.workspaceID, time: { updated: Date.now() } }).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) {
|
||||
@@ -879,7 +940,7 @@ export const layer: Layer.Layer<
|
||||
field: string
|
||||
delta: string
|
||||
}) {
|
||||
yield* bus.publish(MessageV2.Event.PartDelta, input)
|
||||
yield* events.publish(MessageV2.Event.PartDelta, input)
|
||||
})
|
||||
|
||||
/** Finds the first message matching the predicate, searching newest-first. */
|
||||
@@ -903,6 +964,7 @@ export const layer: Layer.Layer<
|
||||
|
||||
return Service.of({
|
||||
list,
|
||||
listGlobal,
|
||||
create,
|
||||
fork,
|
||||
touch,
|
||||
@@ -932,7 +994,6 @@ export const layer: Layer.Layer<
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(BackgroundJob.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
@@ -957,7 +1018,8 @@ const cancelBackgroundJobs = Effect.fn("Session.cancelBackgroundJobs")(function*
|
||||
)
|
||||
})
|
||||
|
||||
function* listByProject(
|
||||
function listByProject(
|
||||
db: Database.Interface["db"],
|
||||
input: ListInput & {
|
||||
projectID: ProjectV2.ID
|
||||
experimentalWorkspaces: boolean
|
||||
@@ -995,19 +1057,17 @@ function* listByProject(
|
||||
|
||||
const limit = input.limit ?? 100
|
||||
|
||||
const rows = runtime.runSync(({ db }) =>
|
||||
db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(SessionTable.time_updated))
|
||||
.limit(limit)
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
for (const row of rows) {
|
||||
yield fromRow(row)
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(SessionTable.time_updated))
|
||||
.limit(limit)
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => rows.map(fromRow)),
|
||||
)
|
||||
}
|
||||
|
||||
export function* listGlobal(input?: {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { SessionID } from "./schema"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
||||
export const Info = Schema.Union([
|
||||
Schema.Struct({
|
||||
@@ -32,20 +32,20 @@ export const Info = Schema.Union([
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export const Event = {
|
||||
Status: BusEvent.define(
|
||||
"session.status",
|
||||
Schema.Struct({
|
||||
Status: EventV2.define({
|
||||
type: "session.status",
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
status: Info,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
// deprecated
|
||||
Idle: BusEvent.define(
|
||||
"session.idle",
|
||||
Schema.Struct({
|
||||
Idle: EventV2.define({
|
||||
type: "session.idle",
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -59,7 +59,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())),
|
||||
@@ -76,9 +76,9 @@ export const layer = Layer.effect(
|
||||
|
||||
const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
|
||||
const data = yield* InstanceState.get(state)
|
||||
yield* bus.publish(Event.Status, { sessionID, status })
|
||||
yield* events.publish(Event.Status, { sessionID, status })
|
||||
if (status.type === "idle") {
|
||||
yield* bus.publish(Event.Idle, { sessionID })
|
||||
yield* events.publish(Event.Idle, { sessionID })
|
||||
data.delete(sessionID)
|
||||
return
|
||||
}
|
||||
@@ -89,6 +89,6 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
|
||||
|
||||
export * as SessionStatus from "./status"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { Bus } from "@/bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import * as Session from "./session"
|
||||
@@ -77,7 +77,7 @@ export const layer = Layer.effect(
|
||||
const sessions = yield* Session.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const storage = yield* Storage.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
|
||||
const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { messages: SessionLegacy.WithParts[] }) {
|
||||
let from: string | undefined
|
||||
@@ -116,7 +116,7 @@ export const layer = Layer.effect(
|
||||
},
|
||||
})
|
||||
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
|
||||
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
||||
yield* events.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
||||
|
||||
const messages = all.filter(
|
||||
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
|
||||
@@ -152,7 +152,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { SessionID } from "./schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { asc } from "drizzle-orm"
|
||||
import { TodoTable } from "@opencode-ai/core/session/sql"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
content: Schema.String.annotate({ description: "Brief description of the task" }),
|
||||
@@ -17,13 +17,13 @@ export const Info = Schema.Struct({
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define(
|
||||
"todo.updated",
|
||||
Schema.Struct({
|
||||
Updated: EventV2.define({
|
||||
type: "todo.updated",
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
todos: Schema.Array(Info),
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -36,7 +36,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) {
|
||||
@@ -60,7 +60,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
yield* bus.publish(Event.Updated, input)
|
||||
yield* events.publish(Event.Updated, input)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) {
|
||||
@@ -82,6 +82,6 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Database.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Database.defaultLayer))
|
||||
|
||||
export * as Todo from "./todo"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Account } from "@/account/account"
|
||||
import { Bus } from "@/bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Provider } from "@/provider/provider"
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Config } from "@/config/config"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { SessionShareTable } from "@opencode-ai/core/share/sql"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
|
||||
const log = Log.create({ service: "share-next" })
|
||||
const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
|
||||
@@ -111,7 +112,7 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const account = yield* Account.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const cfg = yield* Config.Service
|
||||
const { db } = yield* Database.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
@@ -165,49 +166,39 @@ export const layer = Layer.effect(
|
||||
|
||||
if (disabled) return cache
|
||||
|
||||
const watch = <D extends { type: string }>(
|
||||
const watch = <D extends EventV2.Definition>(
|
||||
def: D,
|
||||
fn: (evt: { properties: any }) => Effect.Effect<void, unknown>,
|
||||
fn: (data: EventV2.Data<D>) => Effect.Effect<void, unknown>,
|
||||
) =>
|
||||
bus.subscribe(def as never).pipe(
|
||||
Effect.flatMap((stream) =>
|
||||
stream.pipe(
|
||||
Stream.runForEach((evt) =>
|
||||
fn(evt).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
log.error("share subscriber failed", { type: def.type, cause })
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
),
|
||||
),
|
||||
)
|
||||
events.listen((event) => {
|
||||
if (event.type !== def.type || event.location?.directory !== _ctx.directory) return Effect.void
|
||||
return fn(event.data as EventV2.Data<D>).pipe(
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.error("share subscriber failed", { type: def.type, cause }))),
|
||||
)
|
||||
})
|
||||
|
||||
yield* watch(Session.Event.Updated, (evt) =>
|
||||
yield* watch(Session.Event.Updated, (data) =>
|
||||
Effect.gen(function* () {
|
||||
const info = evt.properties.info
|
||||
yield* sync(info.id, [{ type: "session", data: info }])
|
||||
const info = data.info
|
||||
yield* sync(info.id, [{ type: "session", data: structuredClone(info) as SDK.Session }])
|
||||
}),
|
||||
)
|
||||
yield* watch(MessageV2.Event.Updated, (evt) =>
|
||||
yield* watch(MessageV2.Event.Updated, (data) =>
|
||||
Effect.gen(function* () {
|
||||
const info = evt.properties.info
|
||||
yield* sync(info.sessionID, [{ type: "message", data: info }])
|
||||
const info = data.info
|
||||
yield* sync(info.sessionID, [{ type: "message", data: structuredClone(info) as SDK.Message }])
|
||||
if (info.role !== "user") return
|
||||
const model = yield* provider.getModel(info.model.providerID, info.model.modelID)
|
||||
yield* sync(info.sessionID, [{ type: "model", data: [model] }])
|
||||
}),
|
||||
)
|
||||
yield* watch(MessageV2.Event.PartUpdated, (evt) =>
|
||||
sync(evt.properties.part.sessionID, [{ type: "part", data: evt.properties.part }]),
|
||||
yield* watch(MessageV2.Event.PartUpdated, (data) =>
|
||||
sync(data.part.sessionID, [{ type: "part", data: structuredClone(data.part) as SDK.Part }]),
|
||||
)
|
||||
yield* watch(Session.Event.Diff, (evt) =>
|
||||
sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]),
|
||||
yield* watch(Session.Event.Diff, (data) =>
|
||||
sync(data.sessionID, [{ type: "session_diff", data: structuredClone(data.diff) as SDK.SnapshotFileDiff[] }]),
|
||||
)
|
||||
yield* watch(Session.Event.Deleted, (evt) => remove(evt.properties.sessionID))
|
||||
yield* watch(Session.Event.Deleted, (data) => remove(data.sessionID))
|
||||
|
||||
return cache
|
||||
}),
|
||||
@@ -373,7 +364,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(Account.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { pathToFileURL } from "url"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Permission } from "@/permission"
|
||||
@@ -101,7 +101,7 @@ export interface Interface {
|
||||
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) {
|
||||
const add = Effect.fnUntraced(function* (state: State, match: string, events: EventV2Bridge.Service["Service"]) {
|
||||
const md = yield* Effect.tryPromise({
|
||||
try: () => ConfigMarkdown.parse(match),
|
||||
catch: (err) => err,
|
||||
@@ -112,7 +112,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I
|
||||
? err.data.message
|
||||
: `Failed to parse skill ${match}`
|
||||
const { Session } = yield* Effect.promise(() => import("@/session/session"))
|
||||
yield* bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
yield* events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
||||
log.error("failed to load skill", { skill: match, err })
|
||||
return undefined
|
||||
}),
|
||||
@@ -232,8 +232,8 @@ const discoverSkills = Effect.fnUntraced(function* (
|
||||
}
|
||||
})
|
||||
|
||||
const loadSkills = Effect.fnUntraced(function* (state: State, discovered: DiscoveryState, bus: Bus.Interface) {
|
||||
yield* Effect.forEach(discovered.matches, (match) => add(state, match, bus), {
|
||||
const loadSkills = Effect.fnUntraced(function* (state: State, discovered: DiscoveryState, events: EventV2Bridge.Service["Service"]) {
|
||||
yield* Effect.forEach(discovered.matches, (match) => add(state, match, events), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
@@ -248,7 +248,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* Discovery.Service
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const global = yield* Global.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
@@ -277,7 +277,7 @@ export const layer = Layer.effect(
|
||||
location: "<built-in>",
|
||||
content: CUSTOMIZE_OPENCODE_SKILL_BODY,
|
||||
}
|
||||
yield* loadSkills(s, yield* InstanceState.get(discovered), bus)
|
||||
yield* loadSkills(s, yield* InstanceState.get(discovered), events)
|
||||
return s
|
||||
}),
|
||||
)
|
||||
@@ -317,7 +317,7 @@ export const layer = Layer.effect(
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Discovery.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import * as Tool from "./tool"
|
||||
import { Bus } from "../bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Patch } from "../patch"
|
||||
@@ -25,7 +25,7 @@ export const ApplyPatchTool = Tool.define(
|
||||
const lsp = yield* LSP.Service
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const format = yield* Format.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
|
||||
const run = Effect.fn("ApplyPatchTool.execute")(function* (
|
||||
params: Schema.Schema.Type<typeof Parameters>,
|
||||
@@ -253,13 +253,13 @@ export const ApplyPatchTool = Tool.define(
|
||||
if (yield* format.file(edited)) {
|
||||
yield* Bom.syncFile(afs, edited, change.bom)
|
||||
}
|
||||
yield* bus.publish(File.Event.Edited, { file: edited })
|
||||
yield* events.publish(File.Event.Edited, { file: edited })
|
||||
}
|
||||
}
|
||||
|
||||
// Publish file change events
|
||||
for (const update of updates) {
|
||||
yield* bus.publish(FileWatcher.Event.Updated, update)
|
||||
yield* events.publish(FileWatcher.Event.Updated, update)
|
||||
}
|
||||
|
||||
// Notify LSP of file changes and collect diagnostics
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import DESCRIPTION from "./edit.txt"
|
||||
import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Bus } from "../bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Format } from "../format"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
@@ -61,7 +61,7 @@ export const EditTool = Tool.define(
|
||||
const lsp = yield* LSP.Service
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const format = yield* Format.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
@@ -108,8 +108,8 @@ export const EditTool = Tool.define(
|
||||
if (yield* format.file(filePath)) {
|
||||
contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
|
||||
}
|
||||
yield* bus.publish(File.Event.Edited, { file: filePath })
|
||||
yield* bus.publish(FileWatcher.Event.Updated, {
|
||||
yield* events.publish(File.Event.Edited, { file: filePath })
|
||||
yield* events.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: existed ? "change" : "add",
|
||||
})
|
||||
@@ -152,8 +152,8 @@ export const EditTool = Tool.define(
|
||||
if (yield* format.file(filePath)) {
|
||||
contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
|
||||
}
|
||||
yield* bus.publish(File.Event.Edited, { file: filePath })
|
||||
yield* bus.publish(FileWatcher.Event.Updated, {
|
||||
yield* events.publish(File.Event.Edited, { file: filePath })
|
||||
yield* events.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ import { Todo } from "../session/todo"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Instruction } from "../session/instruction"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Bus } from "../bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Git } from "@/git"
|
||||
import { Skill } from "../skill"
|
||||
@@ -99,7 +99,7 @@ export const layer: Layer.Layer<
|
||||
| LSP.Service
|
||||
| Instruction.Service
|
||||
| AppFileSystem.Service
|
||||
| Bus.Service
|
||||
| EventV2Bridge.Service
|
||||
| HttpClient.HttpClient
|
||||
| ChildProcessSpawner
|
||||
| Ripgrep.Service
|
||||
@@ -389,7 +389,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as Tool from "./tool"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import DESCRIPTION from "./write.txt"
|
||||
import { Bus } from "../bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Format } from "../format"
|
||||
@@ -29,7 +29,7 @@ export const WriteTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const format = yield* Format.Service
|
||||
|
||||
return {
|
||||
@@ -65,8 +65,8 @@ export const WriteTool = Tool.define(
|
||||
if (yield* format.file(filepath)) {
|
||||
yield* Bom.syncFile(fs, filepath, desiredBom)
|
||||
}
|
||||
yield* bus.publish(File.Event.Edited, { file: filepath })
|
||||
yield* bus.publish(FileWatcher.Event.Updated, {
|
||||
yield* events.publish(File.Event.Edited, { file: filepath })
|
||||
yield* events.publish(FileWatcher.Event.Updated, {
|
||||
file: filepath,
|
||||
event: exists ? "change" : "add",
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Git } from "@/git"
|
||||
import { Effect, Layer, Path, Schema, Scope, Context } from "effect"
|
||||
@@ -22,19 +22,19 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
const log = Log.create({ service: "worktree" })
|
||||
|
||||
export const Event = {
|
||||
Ready: BusEvent.define(
|
||||
"worktree.ready",
|
||||
Schema.Struct({
|
||||
Ready: EventV2.define({
|
||||
type: "worktree.ready",
|
||||
schema: {
|
||||
name: Schema.String,
|
||||
branch: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
Failed: BusEvent.define(
|
||||
"worktree.failed",
|
||||
Schema.Struct({
|
||||
},
|
||||
}),
|
||||
Failed: EventV2.define({
|
||||
type: "worktree.failed",
|
||||
schema: {
|
||||
message: Schema.String,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FetchHttpClient } from "effect/unstable/http"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Env } from "../../src/env"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
@@ -33,7 +33,7 @@ const configLayer = Config.layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
const pluginLayer = Plugin.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
@@ -10,77 +9,69 @@ const node = CrossSpawnSpawner.defaultLayer
|
||||
const it = testEffect(Layer.mergeAll(Auth.defaultLayer, node))
|
||||
|
||||
describe("Auth", () => {
|
||||
it.live("set normalizes trailing slashes in keys", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set("https://example.com/", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "abc",
|
||||
})
|
||||
const data = yield* auth.all()
|
||||
expect(data["https://example.com"]).toBeDefined()
|
||||
expect(data["https://example.com/"]).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
it.instance("set normalizes trailing slashes in keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set("https://example.com/", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "abc",
|
||||
})
|
||||
const data = yield* auth.all()
|
||||
expect(data["https://example.com"]).toBeDefined()
|
||||
expect(data["https://example.com/"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("set cleans up pre-existing trailing-slash entry", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set("https://example.com/", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "old",
|
||||
})
|
||||
yield* auth.set("https://example.com", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "new",
|
||||
})
|
||||
const data = yield* auth.all()
|
||||
const keys = Object.keys(data).filter((key) => key.includes("example.com"))
|
||||
expect(keys).toEqual(["https://example.com"])
|
||||
const entry = data["https://example.com"]!
|
||||
expect(entry.type).toBe("wellknown")
|
||||
if (entry.type === "wellknown") expect(entry.token).toBe("new")
|
||||
}),
|
||||
),
|
||||
it.instance("set cleans up pre-existing trailing-slash entry", () =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set("https://example.com/", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "old",
|
||||
})
|
||||
yield* auth.set("https://example.com", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "new",
|
||||
})
|
||||
const data = yield* auth.all()
|
||||
const keys = Object.keys(data).filter((key) => key.includes("example.com"))
|
||||
expect(keys).toEqual(["https://example.com"])
|
||||
const entry = data["https://example.com"]!
|
||||
expect(entry.type).toBe("wellknown")
|
||||
if (entry.type === "wellknown") expect(entry.token).toBe("new")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("remove deletes both trailing-slash and normalized keys", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set("https://example.com", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "abc",
|
||||
})
|
||||
yield* auth.remove("https://example.com/")
|
||||
const data = yield* auth.all()
|
||||
expect(data["https://example.com"]).toBeUndefined()
|
||||
expect(data["https://example.com/"]).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
it.instance("remove deletes both trailing-slash and normalized keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set("https://example.com", {
|
||||
type: "wellknown",
|
||||
key: "TOKEN",
|
||||
token: "abc",
|
||||
})
|
||||
yield* auth.remove("https://example.com/")
|
||||
const data = yield* auth.all()
|
||||
expect(data["https://example.com"]).toBeUndefined()
|
||||
expect(data["https://example.com/"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("set and remove are no-ops on keys without trailing slashes", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set("anthropic", {
|
||||
type: "api",
|
||||
key: "sk-test",
|
||||
})
|
||||
const data = yield* auth.all()
|
||||
expect(data["anthropic"]).toBeDefined()
|
||||
yield* auth.remove("anthropic")
|
||||
const after = yield* auth.all()
|
||||
expect(after["anthropic"]).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
it.instance("set and remove are no-ops on keys without trailing slashes", () =>
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set("anthropic", {
|
||||
type: "api",
|
||||
key: "sk-test",
|
||||
})
|
||||
const data = yield* auth.all()
|
||||
expect(data["anthropic"]).toBeDefined()
|
||||
yield* auth.remove("anthropic")
|
||||
const after = yield* auth.all()
|
||||
expect(after["anthropic"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Latch, Layer, Schema, Stream } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const TestEvent = {
|
||||
Ping: BusEvent.define("test.effect.ping", Schema.Struct({ value: Schema.Number })),
|
||||
Pong: BusEvent.define("test.effect.pong", Schema.Struct({ message: Schema.String })),
|
||||
Warmup: BusEvent.define("test.effect.warmup", Schema.Struct({})),
|
||||
}
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
|
||||
const live = Layer.mergeAll(Bus.layer, node)
|
||||
|
||||
const it = testEffect(live)
|
||||
|
||||
// Publishes warmup events until the latch opens, proving the forked subscriber
|
||||
// fiber has actually wired up its PubSub subscription.
|
||||
const awaitSubscriberReady = Effect.fn("test.awaitSubscriberReady")(function* (
|
||||
ready: Latch.Latch,
|
||||
warmup: Effect.Effect<void>,
|
||||
) {
|
||||
const pump = yield* Effect.forkScoped(
|
||||
Effect.gen(function* () {
|
||||
while (true) {
|
||||
yield* warmup
|
||||
yield* Effect.sleep("5 millis")
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* ready.await.pipe(Effect.timeout("2 seconds"))
|
||||
yield* Fiber.interrupt(pump)
|
||||
})
|
||||
|
||||
describe("Bus (Effect-native)", () => {
|
||||
it.instance("publish + subscribe stream delivers events", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
const ready = yield* Latch.make()
|
||||
|
||||
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.properties.value < 0) {
|
||||
yield* ready.open
|
||||
return
|
||||
}
|
||||
received.push(evt.properties.value)
|
||||
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Ping, { value: -1 }))
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 2 })
|
||||
yield* Deferred.await(done)
|
||||
|
||||
expect(received).toEqual([1, 2])
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("subscribe filters by event type", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const pings: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
const ready = yield* Latch.make()
|
||||
|
||||
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.properties.value < 0) {
|
||||
yield* ready.open
|
||||
return
|
||||
}
|
||||
pings.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Ping, { value: -1 }))
|
||||
yield* bus.publish(TestEvent.Pong, { message: "ignored" })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 42 })
|
||||
yield* Deferred.await(done)
|
||||
|
||||
expect(pings).toEqual([42])
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("subscribeAll receives all types", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const types: string[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
const ready = yield* Latch.make()
|
||||
|
||||
yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.type === TestEvent.Warmup.type) {
|
||||
yield* ready.open
|
||||
return
|
||||
}
|
||||
types.push(evt.type)
|
||||
if (types.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Warmup, {}))
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* bus.publish(TestEvent.Pong, { message: "hi" })
|
||||
yield* Deferred.await(done)
|
||||
|
||||
expect(types).toContain("test.effect.ping")
|
||||
expect(types).toContain("test.effect.pong")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("multiple subscribers each receive the event", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const a: number[] = []
|
||||
const b: number[] = []
|
||||
const doneA = yield* Deferred.make<void>()
|
||||
const doneB = yield* Deferred.make<void>()
|
||||
const readyA = yield* Latch.make()
|
||||
const readyB = yield* Latch.make()
|
||||
|
||||
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.properties.value < 0) {
|
||||
yield* readyA.open
|
||||
return
|
||||
}
|
||||
a.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneA, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Stream.runForEach(yield* bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.properties.value < 0) {
|
||||
yield* readyB.open
|
||||
return
|
||||
}
|
||||
b.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneB, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* awaitSubscriberReady(readyA, bus.publish(TestEvent.Ping, { value: -1 }))
|
||||
yield* awaitSubscriberReady(readyB, bus.publish(TestEvent.Ping, { value: -1 }))
|
||||
yield* bus.publish(TestEvent.Ping, { value: 99 })
|
||||
yield* Deferred.await(doneA)
|
||||
yield* Deferred.await(doneB)
|
||||
|
||||
expect(a).toEqual([99])
|
||||
expect(b).toEqual([99])
|
||||
}),
|
||||
)
|
||||
|
||||
// RACE 1: eager subscription means publishing immediately after yield*
|
||||
// bus.subscribe is delivered. Regression for the old lazy `Stream.unwrap`
|
||||
// shape where PubSub.subscribe ran on first pull and missed any publish
|
||||
// in the hand-off window.
|
||||
it.instance("eager subscribe: publish after yield* is delivered without consumer-activation race", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const stream = yield* bus.subscribe(TestEvent.Ping)
|
||||
|
||||
// Hand-off window: subscription is alive (we yielded). Publish goes
|
||||
// straight into the subscription queue, even with no consumer running.
|
||||
yield* bus.publish(TestEvent.Ping, { value: 99 })
|
||||
|
||||
const collected = yield* stream.pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.timeout("400 millis"),
|
||||
Effect.option,
|
||||
)
|
||||
|
||||
expect(collected._tag).toBe("Some")
|
||||
if (collected._tag === "Some") {
|
||||
const arr = Array.from(collected.value)
|
||||
expect(arr[0].properties.value).toBe(99)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// RACE 2: same property for subscribeAll.
|
||||
it.instance("eager subscribeAll: publish after yield* is delivered", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const stream = yield* bus.subscribeAll()
|
||||
|
||||
yield* bus.publish(TestEvent.Ping, { value: 42 })
|
||||
|
||||
const collected = yield* stream.pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.timeout("400 millis"),
|
||||
Effect.option,
|
||||
)
|
||||
|
||||
expect(collected._tag).toBe("Some")
|
||||
if (collected._tag === "Some") {
|
||||
const arr = Array.from(collected.value)
|
||||
expect(arr[0].type).toBe(TestEvent.Ping.type)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// RACE 3: the /event-handler shape exactly. With eager subscription, the
|
||||
// bus subscription is alive before Stream.concat ever starts. Publishes
|
||||
// during the prefix consumption window are queued and delivered.
|
||||
it.instance("eager subscribe: Stream.concat(initial, subscribe) delivers publish during prefix", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const sawInitial = yield* Deferred.make<void>()
|
||||
const sawPublish = yield* Deferred.make<number>()
|
||||
|
||||
type Frame = { marker?: "initial"; value?: number }
|
||||
const subscriptionStream = yield* bus.subscribe(TestEvent.Ping)
|
||||
const handlerStream: Stream.Stream<Frame> = Stream.make({ marker: "initial" } as Frame).pipe(
|
||||
Stream.concat(subscriptionStream.pipe(Stream.map((evt): Frame => ({ value: evt.properties.value })))),
|
||||
)
|
||||
|
||||
yield* Stream.runForEach(handlerStream, (frame) =>
|
||||
Effect.gen(function* () {
|
||||
if (frame.marker === "initial") {
|
||||
Deferred.doneUnsafe(sawInitial, Effect.void)
|
||||
return
|
||||
}
|
||||
if (frame.value !== undefined) Deferred.doneUnsafe(sawPublish, Effect.succeed(frame.value))
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Deferred.await(sawInitial).pipe(Effect.timeout("1 second"))
|
||||
|
||||
yield* bus.publish(TestEvent.Ping, { value: 7 })
|
||||
|
||||
const got = yield* Deferred.await(sawPublish).pipe(Effect.timeout("1 second"), Effect.option)
|
||||
expect(got._tag).toBe("Some")
|
||||
if (got._tag === "Some") expect(got.value).toBe(7)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("subscribeAll stream sees InstanceDisposed on disposal", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const types: string[] = []
|
||||
const seen = yield* Deferred.make<void>()
|
||||
const disposed = yield* Deferred.make<void>()
|
||||
const ready = yield* Latch.make()
|
||||
|
||||
// Set up subscriber inside the instance
|
||||
yield* Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
yield* Stream.runForEach(yield* bus.subscribeAll(), (evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.type === TestEvent.Warmup.type) {
|
||||
yield* ready.open
|
||||
return
|
||||
}
|
||||
types.push(evt.type)
|
||||
if (evt.type === TestEvent.Ping.type) Deferred.doneUnsafe(seen, Effect.void)
|
||||
if (evt.type === Bus.InstanceDisposed.type) Deferred.doneUnsafe(disposed, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* awaitSubscriberReady(ready, bus.publish(TestEvent.Warmup, {}))
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* Deferred.await(seen)
|
||||
}).pipe(provideInstance(dir))
|
||||
|
||||
// Dispose from OUTSIDE the instance scope
|
||||
yield* Effect.promise(disposeAllInstances)
|
||||
yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(types).toContain("test.effect.ping")
|
||||
expect(types).toContain(Bus.InstanceDisposed.type)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number }))
|
||||
const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
describe("Bus integration: acquireRelease subscriber pattern", () => {
|
||||
afterEach(() => disposeAllInstances())
|
||||
|
||||
it.instance("subscriber via callback facade receives events and cleans up on unsub", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: number[] = []
|
||||
const receivedTwo = yield* Deferred.make<void>()
|
||||
|
||||
const unsub = yield* bus.subscribeCallback(TestEvent, (evt) => {
|
||||
received.push(evt.properties.value)
|
||||
if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void)
|
||||
})
|
||||
yield* bus.publish(TestEvent, { value: 1 })
|
||||
yield* bus.publish(TestEvent, { value: 2 })
|
||||
yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(received).toEqual([1, 2])
|
||||
|
||||
yield* Effect.sync(unsub)
|
||||
yield* bus.publish(TestEvent, { value: 3 })
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
expect(received).toEqual([1, 2])
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("subscribeAll receives events from multiple types", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: Array<{ type: string; value?: number }> = []
|
||||
const OtherEvent = BusEvent.define("test.other", Schema.Struct({ value: Schema.Number }))
|
||||
const receivedTwo = yield* Deferred.make<void>()
|
||||
|
||||
yield* bus.subscribeAllCallback((evt) => {
|
||||
received.push({ type: evt.type, value: evt.properties.value })
|
||||
if (received.length === 2) Deferred.doneUnsafe(receivedTwo, Effect.void)
|
||||
})
|
||||
yield* bus.publish(TestEvent, { value: 10 })
|
||||
yield* bus.publish(OtherEvent, { value: 20 })
|
||||
yield* Deferred.await(receivedTwo).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(received).toEqual([
|
||||
{ type: "test.integration", value: 10 },
|
||||
{ type: "test.other", value: 20 },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("subscriber cleanup on instance disposal interrupts the stream", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const received: number[] = []
|
||||
const seen = yield* Deferred.make<void>()
|
||||
const disposed = yield* Deferred.make<void>()
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
yield* bus.subscribeAllCallback((evt) => {
|
||||
if (evt.type === Bus.InstanceDisposed.type) {
|
||||
Deferred.doneUnsafe(disposed, Effect.void)
|
||||
return
|
||||
}
|
||||
received.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(seen, Effect.void)
|
||||
})
|
||||
yield* bus.publish(TestEvent, { value: 1 })
|
||||
yield* Deferred.await(seen).pipe(Effect.timeout("2 seconds"))
|
||||
}).pipe(provideInstance(dir))
|
||||
|
||||
yield* Effect.promise(() => disposeAllInstances())
|
||||
yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(received).toEqual([1])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,240 +0,0 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const TestEvent = {
|
||||
Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })),
|
||||
Pong: BusEvent.define("test.pong", Schema.Struct({ message: Schema.String })),
|
||||
}
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Bus.layer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
describe("Bus", () => {
|
||||
afterEach(() => disposeAllInstances())
|
||||
|
||||
describe("publish + subscribe", () => {
|
||||
it.instance("subscriber is live immediately after subscribe returns", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
|
||||
received.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(done, Effect.void)
|
||||
})
|
||||
yield* bus.publish(TestEvent.Ping, { value: 42 })
|
||||
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(received).toEqual([42])
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("subscriber receives matching events", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
|
||||
received.push(evt.properties.value)
|
||||
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
})
|
||||
yield* bus.publish(TestEvent.Ping, { value: 42 })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 99 })
|
||||
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(received).toEqual([42, 99])
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("subscriber does not receive events of other types", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const pings: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
|
||||
pings.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(done, Effect.void)
|
||||
})
|
||||
yield* bus.publish(TestEvent.Pong, { message: "hello" })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(pings).toEqual([1])
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("publish with no subscribers does not throw", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("unsubscribe", () => {
|
||||
it.instance("unsubscribe stops delivery", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: number[] = []
|
||||
const first = yield* Deferred.make<void>()
|
||||
|
||||
const unsub = yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
|
||||
received.push(evt.properties.value)
|
||||
if (evt.properties.value === 1) Deferred.doneUnsafe(first, Effect.void)
|
||||
})
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* Deferred.await(first).pipe(Effect.timeout("2 seconds"))
|
||||
yield* Effect.sync(unsub)
|
||||
yield* bus.publish(TestEvent.Ping, { value: 2 })
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
expect(received).toEqual([1])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("subscribeAll", () => {
|
||||
it.instance("subscribeAll is live immediately after subscribe returns", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: string[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
yield* bus.subscribeAllCallback((evt) => {
|
||||
received.push(evt.type)
|
||||
Deferred.doneUnsafe(done, Effect.void)
|
||||
})
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(received).toEqual(["test.ping"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("receives all event types", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: string[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
yield* bus.subscribeAllCallback((evt) => {
|
||||
received.push(evt.type)
|
||||
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
})
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* bus.publish(TestEvent.Pong, { message: "hi" })
|
||||
yield* Deferred.await(done).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(received).toContain("test.ping")
|
||||
expect(received).toContain("test.pong")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("multiple subscribers", () => {
|
||||
it.instance("all subscribers for same event type are called", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const a: number[] = []
|
||||
const b: number[] = []
|
||||
const doneA = yield* Deferred.make<void>()
|
||||
const doneB = yield* Deferred.make<void>()
|
||||
|
||||
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
|
||||
a.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneA, Effect.void)
|
||||
})
|
||||
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
|
||||
b.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneB, Effect.void)
|
||||
})
|
||||
yield* bus.publish(TestEvent.Ping, { value: 7 })
|
||||
yield* Deferred.await(doneA).pipe(Effect.timeout("2 seconds"))
|
||||
yield* Deferred.await(doneB).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(a).toEqual([7])
|
||||
expect(b).toEqual([7])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("instance isolation", () => {
|
||||
it.live("events in one directory do not reach subscribers in another", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmpA = yield* tmpdirScoped()
|
||||
const tmpB = yield* tmpdirScoped()
|
||||
const receivedA: number[] = []
|
||||
const receivedB: number[] = []
|
||||
const doneA = yield* Deferred.make<void>()
|
||||
const doneB = yield* Deferred.make<void>()
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
|
||||
receivedA.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneA, Effect.void)
|
||||
})
|
||||
}).pipe(provideInstance(tmpA))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
yield* bus.subscribeCallback(TestEvent.Ping, (evt) => {
|
||||
receivedB.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneB, Effect.void)
|
||||
})
|
||||
}).pipe(provideInstance(tmpB))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
}).pipe(provideInstance(tmpA))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
yield* bus.publish(TestEvent.Ping, { value: 2 })
|
||||
}).pipe(provideInstance(tmpB))
|
||||
|
||||
yield* Deferred.await(doneA).pipe(Effect.timeout("2 seconds"))
|
||||
yield* Deferred.await(doneB).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(receivedA).toEqual([1])
|
||||
expect(receivedB).toEqual([2])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("instance disposal", () => {
|
||||
it.live("InstanceDisposed is delivered to wildcard subscribers before stream ends", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const received: string[] = []
|
||||
const seen = yield* Deferred.make<void>()
|
||||
const disposed = yield* Deferred.make<void>()
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
yield* bus.subscribeAllCallback((evt) => {
|
||||
received.push(evt.type)
|
||||
if (evt.type === TestEvent.Ping.type) Deferred.doneUnsafe(seen, Effect.void)
|
||||
if (evt.type === Bus.InstanceDisposed.type) Deferred.doneUnsafe(disposed, Effect.void)
|
||||
})
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* Deferred.await(seen).pipe(Effect.timeout("2 seconds"))
|
||||
}).pipe(provideInstance(tmp))
|
||||
|
||||
yield* Effect.promise(disposeAllInstances)
|
||||
yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
expect(received).toContain("test.ping")
|
||||
expect(received).toContain(Bus.InstanceDisposed.type)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -398,7 +398,6 @@ database tools
|
||||
Commands:
|
||||
opencode db [query] open an interactive sqlite3 shell or run a query [default]
|
||||
opencode db path print the database path
|
||||
opencode db migrate migrate JSON data to SQLite (merges with existing data)
|
||||
|
||||
Positionals:
|
||||
query SQL query to execute [string]
|
||||
|
||||
@@ -318,7 +318,12 @@ function insertProject(id: ProjectV2.ID, worktree: string) {
|
||||
|
||||
function attachSessionToWorkspace(sessionID: SessionID, workspaceID: WorkspaceV2.ID) {
|
||||
return Database.Service.use(({ db }) =>
|
||||
db.update(SessionTable).set({ workspace_id: workspaceID }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie),
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ workspace_id: workspaceID })
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -329,7 +334,10 @@ function sessionSequence(sessionID: SessionID) {
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie, Effect.map((row) => row?.seq)),
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => row?.seq),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -340,7 +348,10 @@ function sessionSequenceOwner(sessionID: SessionID) {
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie, Effect.map((row) => row?.ownerID)),
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => row?.ownerID),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -842,14 +853,12 @@ describe("workspace CRUD", () => {
|
||||
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
(
|
||||
yield* db
|
||||
.select({ workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, session.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
)?.workspaceID,
|
||||
(yield* db
|
||||
.select({ workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, session.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie))?.workspaceID,
|
||||
).toBe(target.id)
|
||||
expect(yield* sessionSequenceOwner(session.id)).toBe(target.id)
|
||||
})
|
||||
@@ -911,14 +920,12 @@ describe("workspace CRUD", () => {
|
||||
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
(
|
||||
yield* db
|
||||
.select({ workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, session.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
)?.workspaceID,
|
||||
(yield* db
|
||||
.select({ workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, session.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie))?.workspaceID,
|
||||
).toBeNull()
|
||||
expect(yield* sessionSequenceOwner(session.id)).toBe(instance.project.id)
|
||||
})
|
||||
@@ -955,14 +962,12 @@ describe("workspace CRUD", () => {
|
||||
|
||||
const { db } = yield* Database.Service
|
||||
expect(
|
||||
(
|
||||
yield* db
|
||||
.select({ workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, session.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
)?.workspaceID,
|
||||
(yield* db
|
||||
.select({ workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, session.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie))?.workspaceID,
|
||||
).toBeNull()
|
||||
expect(yield* sessionSequenceOwner(session.id)).toBe(projectID)
|
||||
expect(yield* sessionSequenceOwner(session.id)).not.toBe(workspaceProjectID)
|
||||
@@ -973,6 +978,7 @@ describe("workspace CRUD", () => {
|
||||
it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => {
|
||||
const calls: FetchCall[] = []
|
||||
let historySessionID: SessionID | undefined
|
||||
let historySession: SessionNs.Info | undefined
|
||||
let historyNextSeq = 0
|
||||
return Effect.gen(function* () {
|
||||
yield* HttpServer.serveEffect()(
|
||||
@@ -994,7 +1000,7 @@ describe("workspace CRUD", () => {
|
||||
aggregate_id: historySessionID!,
|
||||
seq: historyNextSeq,
|
||||
type: "session.updated.1",
|
||||
data: { sessionID: historySessionID!, info: { title: "from source history" } },
|
||||
data: { sessionID: historySessionID!, info: historySession! },
|
||||
},
|
||||
])
|
||||
}
|
||||
@@ -1025,6 +1031,7 @@ describe("workspace CRUD", () => {
|
||||
const session = yield* sessionSvc.create({})
|
||||
yield* attachSessionToWorkspace(session.id, previous.id)
|
||||
historySessionID = session.id
|
||||
historySession = { ...session, workspaceID: previous.id, title: "from source history" }
|
||||
historyNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1
|
||||
|
||||
yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true })
|
||||
@@ -1339,6 +1346,7 @@ describe("workspace sync state", () => {
|
||||
it.live("sync history sends the local sequence fence and replays returned events in workspace context", () => {
|
||||
const historyBodies: unknown[] = []
|
||||
let historySessionID: SessionID | undefined
|
||||
let historySession: SessionNs.Info | undefined
|
||||
let historyNextSeq = 0
|
||||
return Effect.gen(function* () {
|
||||
yield* HttpServer.serveEffect()(
|
||||
@@ -1356,7 +1364,7 @@ describe("workspace sync state", () => {
|
||||
aggregate_id: historySessionID!,
|
||||
seq: historyNextSeq,
|
||||
type: "session.updated.1",
|
||||
data: { sessionID: historySessionID!, info: { title: "from history" } },
|
||||
data: { sessionID: historySessionID!, info: historySession! },
|
||||
},
|
||||
]),
|
||||
)
|
||||
@@ -1380,6 +1388,7 @@ describe("workspace sync state", () => {
|
||||
const session = yield* sessionSvc.create({ title: "before history" })
|
||||
yield* attachSessionToWorkspace(session.id, info.id)
|
||||
historySessionID = session.id
|
||||
historySession = { ...session, workspaceID: info.id, title: "from history" }
|
||||
historyNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1
|
||||
|
||||
yield* workspace.startWorkspaceSyncing(instance.project.id)
|
||||
@@ -1394,8 +1403,9 @@ describe("workspace sync state", () => {
|
||||
captured.events.some(
|
||||
(event) =>
|
||||
event.workspace === info.id &&
|
||||
event.payload.type === "sync" &&
|
||||
event.payload.syncEvent.seq === historyNextSeq,
|
||||
event.payload.type === "session.updated" &&
|
||||
event.payload.properties.sessionID === session.id &&
|
||||
event.payload.properties.info.title === "from history",
|
||||
),
|
||||
).toBe(true)
|
||||
yield* workspace.remove(info.id)
|
||||
@@ -1482,6 +1492,7 @@ describe("workspace sync state", () => {
|
||||
|
||||
it.live("SSE sync events are replayed and forwarded", () => {
|
||||
let sseSessionID: SessionID | undefined
|
||||
let sseSession: SessionNs.Info | undefined
|
||||
let sseNextSeq = 0
|
||||
return Effect.gen(function* () {
|
||||
yield* HttpServer.serveEffect()(
|
||||
@@ -1502,7 +1513,7 @@ describe("workspace sync state", () => {
|
||||
aggregateID: sseSessionID!,
|
||||
seq: sseNextSeq,
|
||||
type: "session.updated.1",
|
||||
data: { sessionID: sseSessionID!, info: { title: "from sse" } },
|
||||
data: { sessionID: sseSessionID!, info: sseSession! },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1530,6 +1541,7 @@ describe("workspace sync state", () => {
|
||||
const session = yield* sessionSvc.create({ title: "before sse" })
|
||||
yield* attachSessionToWorkspace(session.id, info.id)
|
||||
sseSessionID = session.id
|
||||
sseSession = { ...session, workspaceID: info.id, title: "from sse" }
|
||||
sseNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1
|
||||
|
||||
yield* workspace.startWorkspaceSyncing(instance.project.id)
|
||||
@@ -1578,7 +1590,9 @@ describe("workspace waitForSync", () => {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.insert(EventSequenceTable).values({ aggregate_id: sessionID, seq: 4 }).run().pipe(Effect.orDie)
|
||||
|
||||
expect(yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_done"), { [sessionID]: 4 })).toBeUndefined()
|
||||
expect(
|
||||
yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_done"), { [sessionID]: 4 }),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
yield* workspace.waitForSync(WorkspaceV2.ID.ascending("wrk_wait_done_2"), { [sessionID]: 3 }),
|
||||
).toBeUndefined()
|
||||
|
||||
@@ -9,6 +9,7 @@ import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
|
||||
import { Config } from "@/config/config"
|
||||
import { FileWatcher } from "../../src/file/watcher"
|
||||
import { Git } from "../../src/git"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
|
||||
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
||||
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||
@@ -27,6 +28,7 @@ const watcherConfigLayer = ConfigProvider.layer(
|
||||
const watcherLayer = FileWatcher.layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(watcherConfigLayer),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { $ } from "bun"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
import * as fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect, Context, Layer, ManagedRuntime } from "effect"
|
||||
import { Effect, Context, Layer } from "effect"
|
||||
import type * as PlatformError from "effect/PlatformError"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
@@ -18,35 +17,31 @@ import { TestLLMServer } from "../lib/llm-server"
|
||||
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
export const testInstanceStoreLayer = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))
|
||||
const testInstanceRuntime = ManagedRuntime.make(testInstanceStoreLayer.pipe(Layer.provideMerge(Observability.layer)))
|
||||
|
||||
const runTestInstanceStore = <A>(fn: (store: InstanceStore.Interface) => Effect.Effect<A>) =>
|
||||
testInstanceRuntime.runPromise(InstanceStore.Service.use(fn))
|
||||
|
||||
export async function provideTestInstance<R>(input: {
|
||||
directory: string
|
||||
init?: Effect.Effect<void>
|
||||
fn: (ctx: InstanceContext) => R
|
||||
}) {
|
||||
const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory }))
|
||||
const ctx = await InstanceRuntime.load({ directory: input.directory })
|
||||
try {
|
||||
if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
if (input.init) await Effect.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx)))
|
||||
return await input.fn(ctx)
|
||||
} finally {
|
||||
await runTestInstanceStore((store) => store.dispose(ctx))
|
||||
await InstanceRuntime.disposeInstance(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
export async function withTestInstance<R>(input: { directory: string; fn: (ctx: InstanceContext) => R }) {
|
||||
return input.fn(await runTestInstanceStore((store) => store.load({ directory: input.directory })))
|
||||
return input.fn(await InstanceRuntime.load({ directory: input.directory }))
|
||||
}
|
||||
|
||||
export async function reloadTestInstance(input: { directory: string }) {
|
||||
return runTestInstanceStore((store) => store.reload(input))
|
||||
return InstanceRuntime.reloadInstance(input)
|
||||
}
|
||||
|
||||
export async function disposeAllInstances() {
|
||||
await Promise.all([InstanceRuntime.disposeAllInstances(), runTestInstanceStore((store) => store.disposeAll())])
|
||||
await InstanceRuntime.disposeAllInstances()
|
||||
}
|
||||
|
||||
// Strip null bytes from paths (defensive fix for CI environment issues)
|
||||
@@ -119,9 +114,10 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
|
||||
}
|
||||
|
||||
/** Effectful scoped tmpdir. Cleaned up when the scope closes. Make sure these stay in sync */
|
||||
export function tmpdirScoped(options?: {
|
||||
export function tmpdirScoped<E = never, R = never>(options?: {
|
||||
git?: boolean
|
||||
config?: Partial<Config.Info> | (() => Partial<Config.Info>)
|
||||
init?: (directory: string) => Effect.Effect<void, E, R>
|
||||
}) {
|
||||
return Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
@@ -158,6 +154,8 @@ export function tmpdirScoped(options?: {
|
||||
)
|
||||
}
|
||||
|
||||
if (options?.init) yield* options.init(dir)
|
||||
|
||||
return dir
|
||||
})
|
||||
}
|
||||
@@ -183,21 +181,8 @@ export function provideTmpdirInstance<A, E, R>(
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const path = yield* tmpdirScoped(options)
|
||||
let provided = false
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
provided
|
||||
? Effect.promise(() =>
|
||||
runTestInstanceStore((store) =>
|
||||
store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx))),
|
||||
),
|
||||
).pipe(Effect.ignore)
|
||||
: Effect.void,
|
||||
)
|
||||
|
||||
provided = true
|
||||
return yield* self(path).pipe(provideInstance(path))
|
||||
})
|
||||
}).pipe(Effect.provide(testInstanceStoreLayer))
|
||||
}
|
||||
|
||||
export class TestInstance extends Context.Service<TestInstance, { readonly directory: string }>()("@test/Instance") {}
|
||||
@@ -209,7 +194,11 @@ export const requireInstance = Effect.gen(function* () {
|
||||
})
|
||||
|
||||
export const withTmpdirInstance =
|
||||
(options?: { git?: boolean; config?: Partial<Config.Info> | (() => Partial<Config.Info>) }) =>
|
||||
<E2 = never, R2 = never>(options?: {
|
||||
git?: boolean
|
||||
config?: Partial<Config.Info> | (() => Partial<Config.Info>)
|
||||
init?: (directory: string) => Effect.Effect<void, E2, R2>
|
||||
}) =>
|
||||
<A, E, R>(self: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped(options)
|
||||
@@ -222,7 +211,7 @@ export function provideTmpdirServer<A, E, R>(
|
||||
): Effect.Effect<
|
||||
A,
|
||||
E | PlatformError.PlatformError,
|
||||
R | TestLLMServer | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope | InstanceStore.Service
|
||||
R | TestLLMServer | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
|
||||
> {
|
||||
return Effect.gen(function* () {
|
||||
const llm = yield* TestLLMServer
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { provideTmpdirInstance, testInstanceStoreLayer, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Format } from "../../src/format"
|
||||
@@ -10,141 +10,102 @@ import * as Formatter from "../../src/format/formatter"
|
||||
const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
|
||||
|
||||
describe("Format", () => {
|
||||
it.live("status() returns empty list when no formatters are configured", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
it.instance("status() returns empty list when no formatters are configured", () =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* fmt.status()).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"status() returns built-in formatters when formatter is true",
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* fmt.status()).toEqual([])
|
||||
const statuses = yield* fmt.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
expect(gofmt).toBeDefined()
|
||||
expect(gofmt!.extensions).toContain(".go")
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ config: { formatter: true } },
|
||||
)
|
||||
|
||||
it.live("status() returns built-in formatters when formatter is true", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* fmt.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
expect(gofmt).toBeDefined()
|
||||
expect(gofmt!.extensions).toContain(".go")
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
formatter: true,
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() keeps built-in formatters when config object is provided", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* fmt.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
const mix = statuses.find((item) => item.name === "mix")
|
||||
expect(gofmt).toBeDefined()
|
||||
expect(gofmt!.extensions).toContain(".go")
|
||||
expect(mix).toBeDefined()
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
formatter: {
|
||||
gofmt: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() excludes formatters marked as disabled in config", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* fmt.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
const mix = statuses.find((item) => item.name === "mix")
|
||||
expect(gofmt).toBeUndefined()
|
||||
expect(mix).toBeDefined()
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
formatter: {
|
||||
gofmt: { disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() excludes uv when ruff is disabled", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* fmt.status()
|
||||
expect(statuses.find((item) => item.name === "ruff")).toBeUndefined()
|
||||
expect(statuses.find((item) => item.name === "uv")).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
formatter: {
|
||||
ruff: { disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() excludes ruff when uv is disabled", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* fmt.status()
|
||||
expect(statuses.find((item) => item.name === "ruff")).toBeUndefined()
|
||||
expect(statuses.find((item) => item.name === "uv")).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
formatter: {
|
||||
uv: { disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void)))
|
||||
|
||||
it.live("file() returns false when no formatter runs", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
it.instance(
|
||||
"status() keeps built-in formatters when config object is provided",
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
const file = `${dir}/test.txt`
|
||||
yield* Effect.promise(() => Bun.write(file, "x"))
|
||||
|
||||
const formatted = yield* Format.use.file(file)
|
||||
expect(formatted).toBe(false)
|
||||
const statuses = yield* fmt.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
const mix = statuses.find((item) => item.name === "mix")
|
||||
expect(gofmt).toBeDefined()
|
||||
expect(gofmt!.extensions).toContain(".go")
|
||||
expect(mix).toBeDefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
formatter: false,
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
{ config: { formatter: { gofmt: {} } } },
|
||||
)
|
||||
|
||||
it.live("status() initializes formatter state per directory", () =>
|
||||
it.instance(
|
||||
"status() excludes formatters marked as disabled in config",
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* fmt.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
const mix = statuses.find((item) => item.name === "mix")
|
||||
expect(gofmt).toBeUndefined()
|
||||
expect(mix).toBeDefined()
|
||||
}),
|
||||
),
|
||||
{ config: { formatter: { gofmt: { disabled: true } } } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"status() excludes uv when ruff is disabled",
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* fmt.status()
|
||||
expect(statuses.find((item) => item.name === "ruff")).toBeUndefined()
|
||||
expect(statuses.find((item) => item.name === "uv")).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
{ config: { formatter: { ruff: { disabled: true } } } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"status() excludes ruff when uv is disabled",
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* fmt.status()
|
||||
expect(statuses.find((item) => item.name === "ruff")).toBeUndefined()
|
||||
expect(statuses.find((item) => item.name === "uv")).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
{ config: { formatter: { uv: { disabled: true } } } },
|
||||
)
|
||||
|
||||
it.instance("service initializes without error", () => Format.Service.use(() => Effect.void))
|
||||
|
||||
it.instance(
|
||||
"file() returns false when no formatter runs",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = `${test.directory}/test.txt`
|
||||
yield* Effect.promise(() => Bun.write(file, "x"))
|
||||
|
||||
const formatted = yield* Format.use.file(file)
|
||||
expect(formatted).toBe(false)
|
||||
}),
|
||||
{ config: { formatter: false } },
|
||||
)
|
||||
|
||||
testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer)).live("status() initializes formatter state per directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const a = yield* provideTmpdirInstance(() => Format.use.status(), {
|
||||
config: { formatter: false },
|
||||
@@ -160,113 +121,106 @@ describe("Format", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("runs enabled checks for matching formatters in parallel", () =>
|
||||
provideTmpdirInstance(
|
||||
(path) =>
|
||||
Effect.gen(function* () {
|
||||
const file = `${path}/test.parallel`
|
||||
yield* Effect.promise(() => Bun.write(file, "x"))
|
||||
it.instance(
|
||||
"runs enabled checks for matching formatters in parallel",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = `${test.directory}/test.parallel`
|
||||
yield* Effect.promise(() => Bun.write(file, "x"))
|
||||
|
||||
const one = {
|
||||
extensions: Formatter.gofmt.extensions,
|
||||
enabled: Formatter.gofmt.enabled,
|
||||
}
|
||||
const two = {
|
||||
extensions: Formatter.mix.extensions,
|
||||
enabled: Formatter.mix.enabled,
|
||||
}
|
||||
const one = {
|
||||
extensions: Formatter.gofmt.extensions,
|
||||
enabled: Formatter.gofmt.enabled,
|
||||
}
|
||||
const two = {
|
||||
extensions: Formatter.mix.extensions,
|
||||
enabled: Formatter.mix.enabled,
|
||||
}
|
||||
|
||||
let active = 0
|
||||
let max = 0
|
||||
let active = 0
|
||||
let max = 0
|
||||
|
||||
yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
Formatter.gofmt.extensions = [".parallel"]
|
||||
Formatter.mix.extensions = [".parallel"]
|
||||
Formatter.gofmt.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Promise.resolve()
|
||||
active--
|
||||
return ["sh", "-c", "true"]
|
||||
}
|
||||
Formatter.mix.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Promise.resolve()
|
||||
active--
|
||||
return ["sh", "-c", "true"]
|
||||
}
|
||||
}),
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fmt.init()
|
||||
yield* fmt.file(file)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
Formatter.gofmt.extensions = one.extensions
|
||||
Formatter.gofmt.enabled = one.enabled
|
||||
Formatter.mix.extensions = two.extensions
|
||||
Formatter.mix.enabled = two.enabled
|
||||
yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
Formatter.gofmt.extensions = [".parallel"]
|
||||
Formatter.mix.extensions = [".parallel"]
|
||||
Formatter.gofmt.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Promise.resolve()
|
||||
active--
|
||||
return ["sh", "-c", "true"]
|
||||
}
|
||||
Formatter.mix.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Promise.resolve()
|
||||
active--
|
||||
return ["sh", "-c", "true"]
|
||||
}
|
||||
}),
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fmt.init()
|
||||
yield* fmt.file(file)
|
||||
}),
|
||||
)
|
||||
),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
Formatter.gofmt.extensions = one.extensions
|
||||
Formatter.gofmt.enabled = one.enabled
|
||||
Formatter.mix.extensions = two.extensions
|
||||
Formatter.mix.enabled = two.enabled
|
||||
}),
|
||||
)
|
||||
|
||||
expect(max).toBe(2)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
formatter: {
|
||||
gofmt: {},
|
||||
mix: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
expect(max).toBe(2)
|
||||
}),
|
||||
{ config: { formatter: { gofmt: {}, mix: {} } } },
|
||||
)
|
||||
|
||||
it.live("runs matching formatters sequentially for the same file", () =>
|
||||
provideTmpdirInstance(
|
||||
(path) =>
|
||||
Effect.gen(function* () {
|
||||
const file = `${path}/test.seq`
|
||||
yield* Effect.promise(() => Bun.write(file, "x"))
|
||||
it.instance(
|
||||
"runs matching formatters sequentially for the same file",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = `${test.directory}/test.seq`
|
||||
yield* Effect.promise(() => Bun.write(file, "x"))
|
||||
|
||||
yield* Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fmt.init()
|
||||
expect(yield* fmt.file(file)).toBe(true)
|
||||
}),
|
||||
)
|
||||
yield* Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fmt.init()
|
||||
expect(yield* fmt.file(file)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(yield* Effect.promise(() => Bun.file(file).text())).toBe("xAB")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
formatter: {
|
||||
first: {
|
||||
command: [
|
||||
"node",
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'A')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
second: {
|
||||
command: [
|
||||
"node",
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'B')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
expect(yield* Effect.promise(() => Bun.file(file).text())).toBe("xAB")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
formatter: {
|
||||
first: {
|
||||
command: [
|
||||
"node",
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'A')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
second: {
|
||||
command: [
|
||||
"node",
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'B')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,20 +5,26 @@ import * as TestClock from "effect/testing/TestClock"
|
||||
import * as TestConsole from "effect/testing/TestConsole"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import type { Config } from "@/config/config"
|
||||
import { TestInstance, testInstanceStoreLayer, withTmpdirInstance } from "../fixture/fixture"
|
||||
import { TestInstance, withTmpdirInstance } from "../fixture/fixture"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
|
||||
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
|
||||
type InstanceOptions = { git?: boolean; config?: Partial<Config.Info> | (() => Partial<Config.Info>) }
|
||||
|
||||
function isInstanceOptions(options: InstanceOptions | number | TestOptions | undefined): options is InstanceOptions {
|
||||
return !!options && typeof options === "object" && ("git" in options || "config" in options)
|
||||
type InstanceOptions<E, R> = {
|
||||
git?: boolean
|
||||
config?: Partial<Config.Info> | (() => Partial<Config.Info>)
|
||||
init?: (directory: string) => Effect.Effect<void, E, R>
|
||||
}
|
||||
|
||||
function instanceArgs(
|
||||
options?: InstanceOptions | number | TestOptions,
|
||||
function isInstanceOptions<E, R>(
|
||||
options: InstanceOptions<E, R> | number | TestOptions | undefined,
|
||||
): options is InstanceOptions<E, R> {
|
||||
return !!options && typeof options === "object" && ("git" in options || "config" in options || "init" in options)
|
||||
}
|
||||
|
||||
function instanceArgs<E, R>(
|
||||
options?: InstanceOptions<E, R> | number | TestOptions,
|
||||
testOptions?: number | TestOptions,
|
||||
): { instanceOptions: InstanceOptions | undefined; testOptions: number | TestOptions | undefined } {
|
||||
): { instanceOptions: InstanceOptions<E, R> | undefined; testOptions: number | TestOptions | undefined } {
|
||||
if (typeof options === "number") return { instanceOptions: undefined, testOptions: options }
|
||||
if (isInstanceOptions(options)) return { instanceOptions: options, testOptions }
|
||||
return { instanceOptions: undefined, testOptions: options }
|
||||
@@ -76,10 +82,10 @@ const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>,
|
||||
live.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||
test.skip(name, () => run(value, liveLayer), opts)
|
||||
|
||||
const instance = <A, E2>(
|
||||
const instance = <A, E2, E3 = never>(
|
||||
name: string,
|
||||
value: Body<A, E2, R | TestInstance | Scope.Scope>,
|
||||
options?: InstanceOptions | number | TestOptions,
|
||||
value: Body<A, E2, R | InstanceStore.Service | TestInstance | Scope.Scope>,
|
||||
options?: InstanceOptions<E3, R | Scope.Scope> | number | TestOptions,
|
||||
opts?: number | TestOptions,
|
||||
) => {
|
||||
const args = instanceArgs(options, opts)
|
||||
@@ -90,10 +96,10 @@ const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>,
|
||||
)
|
||||
}
|
||||
|
||||
instance.only = <A, E2>(
|
||||
instance.only = <A, E2, E3 = never>(
|
||||
name: string,
|
||||
value: Body<A, E2, R | TestInstance | Scope.Scope>,
|
||||
options?: InstanceOptions | number | TestOptions,
|
||||
value: Body<A, E2, R | InstanceStore.Service | TestInstance | Scope.Scope>,
|
||||
options?: InstanceOptions<E3, R | Scope.Scope> | number | TestOptions,
|
||||
opts?: number | TestOptions,
|
||||
) => {
|
||||
const args = instanceArgs(options, opts)
|
||||
@@ -104,10 +110,10 @@ const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>,
|
||||
)
|
||||
}
|
||||
|
||||
instance.skip = <A, E2>(
|
||||
instance.skip = <A, E2, E3 = never>(
|
||||
name: string,
|
||||
value: Body<A, E2, R | TestInstance | Scope.Scope>,
|
||||
options?: InstanceOptions | number | TestOptions,
|
||||
value: Body<A, E2, R | InstanceStore.Service | TestInstance | Scope.Scope>,
|
||||
options?: InstanceOptions<E3, R | Scope.Scope> | number | TestOptions,
|
||||
opts?: number | TestOptions,
|
||||
) => {
|
||||
const args = instanceArgs(options, opts)
|
||||
@@ -122,22 +128,22 @@ const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>,
|
||||
}
|
||||
|
||||
// Test environment with TestClock and TestConsole
|
||||
const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer(), testInstanceStoreLayer)
|
||||
const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer())
|
||||
|
||||
// Live environment - uses real clock, but keeps TestConsole for output capture
|
||||
const liveEnv = Layer.mergeAll(TestConsole.layer, testInstanceStoreLayer)
|
||||
const liveEnv = TestConsole.layer
|
||||
|
||||
export const it = make<InstanceStore.Service, never>(testEnv, liveEnv)
|
||||
export const it = make<never, never>(testEnv, liveEnv)
|
||||
|
||||
export const testEffect = <R, E>(layer: Layer.Layer<R, E>) =>
|
||||
make<R | InstanceStore.Service, E>(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))
|
||||
make<R, E>(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))
|
||||
|
||||
// Variant of `testEffect` that builds the test layer through the shared
|
||||
// process-wide memoMap so services like Bus/Session resolve to the same
|
||||
// instances Server.Default uses. Use when a test needs pub/sub identity with
|
||||
// an in-process HTTP server — most tests should stick with `testEffect`.
|
||||
export const testEffectShared = <R, E>(layer: Layer.Layer<R, E>) =>
|
||||
make<R | InstanceStore.Service, E>(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv), sharedRun)
|
||||
make<R, E>(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv), sharedRun)
|
||||
|
||||
export const awaitWithTimeout = <A, E, R>(
|
||||
self: Effect.Effect<A, E, R>,
|
||||
|
||||
@@ -1,36 +1,44 @@
|
||||
import { describe, expect, spyOn } from "bun:test"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Config } from "@/config/config"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import * as LSPServer from "@/lsp/server"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
const lspLayer = (flags: Parameters<typeof RuntimeFlags.layer>[0] = {}) =>
|
||||
LSP.layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer(flags)),
|
||||
Layer.provideMerge(EventV2Bridge.defaultLayer),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(lspLayer(), CrossSpawnSpawner.defaultLayer))
|
||||
const experimentalTyIt = testEffect(
|
||||
Layer.mergeAll(
|
||||
LSP.layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalLspTy: true }))),
|
||||
lspLayer({ experimentalLspTy: true }),
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
),
|
||||
)
|
||||
const fakeServerPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
|
||||
const disabledDownloadIt = testEffect(
|
||||
Layer.mergeAll(
|
||||
LSP.layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.layer({ disableLspDownload: true }))),
|
||||
lspLayer({ disableLspDownload: true }),
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
describe("lsp.spawn", () => {
|
||||
it.live("does not spawn builtin LSP for files outside instance", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
it.instance(
|
||||
"does not spawn builtin LSP for files outside instance",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
@@ -46,14 +54,13 @@ describe("lsp.spawn", () => {
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
)
|
||||
|
||||
it.live("does not spawn builtin LSP for files inside instance when LSP is unset", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
it.instance("does not spawn builtin LSP for files inside instance when LSP is unset", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
@@ -68,14 +75,14 @@ describe("lsp.spawn", () => {
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("would spawn builtin LSP for files inside instance when lsp is true", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
it.instance(
|
||||
"would spawn builtin LSP for files inside instance when lsp is true",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
@@ -90,44 +97,46 @@ describe("lsp.spawn", () => {
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
)
|
||||
|
||||
it.live("publishes lsp.updated after custom LSP initialization", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
it.instance(
|
||||
"publishes lsp.updated after custom LSP initialization",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const lsp = yield* LSP.Service
|
||||
const updated = yield* Deferred.make<void>()
|
||||
const unsubscribe = Bus.subscribe(LSP.Event.Updated, () =>
|
||||
Effect.runSync(Deferred.succeed(updated, undefined)),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const unsubscribe = yield* events.listen((event) => {
|
||||
if (event.type === LSP.Event.Updated.type) Deferred.doneUnsafe(updated, Effect.void)
|
||||
return Effect.void
|
||||
})
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
const file = path.join(dir, "sample.repro")
|
||||
yield* Effect.promise(() => Bun.write(file, "sample\n"))
|
||||
yield* lsp.touchFile(file)
|
||||
yield* awaitWithTimeout(Deferred.await(updated), "lsp.updated event was not published")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
lsp: {
|
||||
fake: {
|
||||
command: [process.execPath, fakeServerPath],
|
||||
extensions: [".repro"],
|
||||
},
|
||||
{
|
||||
config: {
|
||||
lsp: {
|
||||
fake: {
|
||||
command: [process.execPath, fakeServerPath],
|
||||
extensions: [".repro"],
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
it.live("would spawn builtin LSP for files inside instance when config object is provided", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
it.instance(
|
||||
"would spawn builtin LSP for files inside instance when config object is provided",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
@@ -142,21 +151,21 @@ describe("lsp.spawn", () => {
|
||||
}
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
lsp: {
|
||||
eslint: { disabled: true },
|
||||
},
|
||||
{
|
||||
config: {
|
||||
lsp: {
|
||||
eslint: { disabled: true },
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
it.live("uses pyright instead of ty by default", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
it.instance(
|
||||
"uses pyright instead of ty by default",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const ty = spyOn(LSPServer.Ty, "spawn").mockResolvedValue(undefined)
|
||||
const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
@@ -174,15 +183,15 @@ describe("lsp.spawn", () => {
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
)
|
||||
|
||||
experimentalTyIt.live("uses ty instead of pyright when experimentalLspTy is enabled", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
experimentalTyIt.instance(
|
||||
"uses ty instead of pyright when experimentalLspTy is enabled",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const ty = spyOn(LSPServer.Ty, "spawn").mockResolvedValue(undefined)
|
||||
const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
@@ -200,15 +209,15 @@ describe("lsp.spawn", () => {
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
)
|
||||
|
||||
disabledDownloadIt.live("passes disableLspDownload to builtin LSP spawn", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
disabledDownloadIt.instance(
|
||||
"passes disableLspDownload to builtin LSP spawn",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const pyright = spyOn(LSPServer.Pyright, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
@@ -224,7 +233,6 @@ describe("lsp.spawn", () => {
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Effect, Layer } from "effect"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import * as LSPServer from "@/lsp/server"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
@@ -20,137 +20,113 @@ describe("LSP service lifecycle", () => {
|
||||
spawnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it.live("init() completes without error", () => provideTmpdirInstance(() => LSP.Service.use((lsp) => lsp.init())))
|
||||
it.instance("init() completes without error", () => LSP.Service.use((lsp) => lsp.init()))
|
||||
|
||||
it.live("status() returns empty array initially", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.status()
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result.length).toBe(0)
|
||||
}),
|
||||
),
|
||||
it.instance("status() returns empty array initially", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.status()
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result.length).toBe(0)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("diagnostics() returns empty object initially", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.diagnostics()
|
||||
expect(typeof result).toBe("object")
|
||||
expect(Object.keys(result).length).toBe(0)
|
||||
}),
|
||||
),
|
||||
it.instance("diagnostics() returns empty object initially", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.diagnostics()
|
||||
expect(typeof result).toBe("object")
|
||||
expect(Object.keys(result).length).toBe(0)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("hasClients() returns false for .ts files in instance when LSP is unset", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
it.instance("hasClients() returns false for .ts files in instance when LSP is unset", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
|
||||
const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts"))
|
||||
expect(result).toBe(false)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("hasClients() returns true for .ts files in instance when lsp is true", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
|
||||
expect(result).toBe(true)
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("hasClients() keeps built-in LSPs when config object is provided", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
|
||||
expect(result).toBe(true)
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
lsp: {
|
||||
eslint: { disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("hasClients() returns false for files outside instance", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
it.instance(
|
||||
"hasClients() returns true for .ts files in instance when lsp is true",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join(dir, "..", "outside.ts"))
|
||||
expect(typeof result).toBe("boolean")
|
||||
const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts"))
|
||||
expect(result).toBe(true)
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"hasClients() keeps built-in LSPs when config object is provided",
|
||||
() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "test.ts"))
|
||||
expect(result).toBe(true)
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: { eslint: { disabled: true } } } },
|
||||
)
|
||||
|
||||
it.instance("hasClients() returns false for files outside instance", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join((yield* TestInstance).directory, "..", "outside.ts"))
|
||||
expect(typeof result).toBe("boolean")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("workspaceSymbol() returns empty array with no clients", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.workspaceSymbol("test")
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result.length).toBe(0)
|
||||
}),
|
||||
),
|
||||
it.instance("workspaceSymbol() returns empty array with no clients", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.workspaceSymbol("test")
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect(result.length).toBe(0)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("definition() returns empty array for unknown file", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.definition({
|
||||
file: path.join(dir, "nonexistent.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
}),
|
||||
),
|
||||
it.instance("definition() returns empty array for unknown file", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.definition({
|
||||
file: path.join((yield* TestInstance).directory, "nonexistent.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("references() returns empty array for unknown file", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.references({
|
||||
file: path.join(dir, "nonexistent.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
}),
|
||||
),
|
||||
it.instance("references() returns empty array for unknown file", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.references({
|
||||
file: path.join((yield* TestInstance).directory, "nonexistent.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("multiple init() calls are idempotent", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lsp.init()
|
||||
yield* lsp.init()
|
||||
yield* lsp.init()
|
||||
}),
|
||||
),
|
||||
it.instance("multiple init() calls are idempotent", () =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lsp.init()
|
||||
yield* lsp.init()
|
||||
yield* lsp.init()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -112,7 +112,7 @@ beforeEach(() => {
|
||||
|
||||
// Import modules after mocking
|
||||
const { MCP } = await import("../../src/mcp/index")
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { EventV2Bridge } = await import("../../src/event-v2-bridge")
|
||||
const { Config } = await import("../../src/config/config")
|
||||
const { McpAuth } = await import("../../src/mcp/auth")
|
||||
const { McpOAuthProvider } = await import("../../src/mcp/oauth-provider")
|
||||
@@ -123,7 +123,7 @@ const mcpTest = testEffect(
|
||||
Layer.mergeAll(
|
||||
MCP.layer.pipe(
|
||||
Layer.provide(McpAuth.defaultLayer),
|
||||
Layer.provideMerge(Bus.layer),
|
||||
Layer.provideMerge(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
|
||||
@@ -106,7 +106,7 @@ beforeEach(() => {
|
||||
|
||||
// Import modules after mocking
|
||||
const { MCP } = await import("../../src/mcp/index")
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { EventV2Bridge } = await import("../../src/event-v2-bridge")
|
||||
const { Config } = await import("../../src/config/config")
|
||||
const { McpAuth } = await import("../../src/mcp/auth")
|
||||
const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
|
||||
@@ -115,7 +115,7 @@ const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawne
|
||||
const mcpTest = testEffect(
|
||||
MCP.layer.pipe(
|
||||
Layer.provide(McpAuth.defaultLayer),
|
||||
Layer.provideMerge(Bus.layer),
|
||||
Layer.provideMerge(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
@@ -142,12 +142,14 @@ const trackBrowserOpen = Effect.gen(function* () {
|
||||
})
|
||||
|
||||
const trackBrowserOpenFailed = Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const event = yield* Deferred.make<{ mcpName: string; url: string }>()
|
||||
const unsubscribe = yield* bus.subscribeCallback(MCP.BrowserOpenFailed, (evt) => {
|
||||
Effect.runSync(Deferred.succeed(event, evt.properties).pipe(Effect.ignore))
|
||||
const unsubscribe = yield* events.listen((evt) => {
|
||||
if (evt.type === MCP.BrowserOpenFailed.type)
|
||||
Deferred.doneUnsafe(event, Effect.succeed(evt.data as { mcpName: string; url: string }))
|
||||
return Effect.void
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
return event
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import os from "os"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Permission } from "../../src/permission"
|
||||
@@ -12,11 +12,11 @@ import { TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
|
||||
const bus = Bus.layer
|
||||
const events = EventV2Bridge.defaultLayer
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const env = Layer.mergeAll(
|
||||
Permission.layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(bus)),
|
||||
bus,
|
||||
Permission.layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(events)),
|
||||
events,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)),
|
||||
)
|
||||
@@ -654,12 +654,13 @@ it.instance(
|
||||
"ask - publishes asked event",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const seen = yield* Deferred.make<Permission.Request>()
|
||||
const unsub = yield* bus.subscribeCallback(Permission.Event.Asked, (event) => {
|
||||
Deferred.doneUnsafe(seen, Effect.succeed(event.properties))
|
||||
const unsub = yield* events.listen((event) => {
|
||||
if (event.type === Permission.Event.Asked.type) Deferred.doneUnsafe(seen, Effect.succeed(event.data as Permission.Request))
|
||||
return Effect.void
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unsub))
|
||||
yield* Effect.addFinalizer(() => unsub)
|
||||
|
||||
const fiber = yield* ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
@@ -914,7 +915,7 @@ it.instance(
|
||||
"reply - publishes replied event",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const seen = yield* Deferred.make<{ sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }>()
|
||||
|
||||
const fiber = yield* ask({
|
||||
@@ -929,10 +930,12 @@ it.instance(
|
||||
|
||||
yield* waitForPending(1)
|
||||
|
||||
const unsub = yield* bus.subscribeCallback(Permission.Event.Replied, (event) => {
|
||||
Deferred.doneUnsafe(seen, Effect.succeed(event.properties))
|
||||
const unsub = yield* events.listen((event) => {
|
||||
if (event.type === Permission.Event.Replied.type)
|
||||
Deferred.doneUnsafe(seen, Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }))
|
||||
return Effect.void
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(unsub))
|
||||
yield* Effect.addFinalizer(() => unsub)
|
||||
|
||||
yield* reply({ requestID: PermissionID.make("per_test7"), reply: "once" })
|
||||
yield* Fiber.join(fiber)
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ProviderAuth } from "@/provider/auth"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Auth } from "@/auth"
|
||||
import { Bus } from "@/bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
@@ -22,7 +22,7 @@ function layer(directory: string, plugins: string[]) {
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(
|
||||
Plugin.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer()),
|
||||
Layer.provide(
|
||||
TestConfig.layer({
|
||||
|
||||
@@ -5,13 +5,13 @@ import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const { Plugin } = await import("../../src/plugin/index")
|
||||
const { PluginLoader } = await import("../../src/plugin/loader")
|
||||
const { readPackageThemes } = await import("../../src/plugin/shared")
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { EventV2Bridge } = await import("../../src/event-v2-bridge")
|
||||
const { Npm } = await import("@opencode-ai/core/npm")
|
||||
const { TestConfig } = await import("../fixture/config")
|
||||
const { RuntimeFlags } = await import("../../src/effect/runtime-flags")
|
||||
@@ -20,7 +20,7 @@ afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, testInstanceStoreLayer))
|
||||
|
||||
function withTmp<T, A, E, R>(
|
||||
init: (dir: string) => Promise<T>,
|
||||
@@ -46,7 +46,7 @@ function load(dir: string, flags?: Parameters<typeof RuntimeFlags.layer>[0]) {
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Plugin.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })),
|
||||
Layer.provide(
|
||||
TestConfig.layer({
|
||||
|
||||
@@ -6,13 +6,13 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Env } from "../../src/env"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
import { Plugin } from "../../src/plugin/index"
|
||||
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { AccountTest } from "../fake/account"
|
||||
import { AuthTest } from "../fake/auth"
|
||||
@@ -31,7 +31,7 @@ const configLayer = Config.layer.pipe(
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Plugin.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
|
||||
),
|
||||
@@ -41,31 +41,30 @@ const it = testEffect(
|
||||
const systemHook = "experimental.chat.system.transform"
|
||||
|
||||
function withProject<A, E, R>(source: string, self: Effect.Effect<A, E, R>) {
|
||||
return provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "plugin.ts")
|
||||
yield* Effect.all(
|
||||
[
|
||||
Effect.promise(() => Bun.write(file, source)),
|
||||
Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugin: [pathToFileURL(file).href],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
return Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "plugin.ts")
|
||||
yield* Effect.all(
|
||||
[
|
||||
Effect.promise(() => Bun.write(file, source)),
|
||||
Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(test.directory, "opencode.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugin: [pathToFileURL(file).href],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
),
|
||||
],
|
||||
{ discard: true, concurrency: 2 },
|
||||
)
|
||||
return yield* self
|
||||
}),
|
||||
)
|
||||
),
|
||||
],
|
||||
{ discard: true, concurrency: 2 },
|
||||
)
|
||||
return yield* self
|
||||
})
|
||||
}
|
||||
|
||||
const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransform")(function* () {
|
||||
@@ -85,7 +84,7 @@ const triggerSystemTransform = Effect.fn("PluginTriggerTest.triggerSystemTransfo
|
||||
})
|
||||
|
||||
describe("plugin.trigger", () => {
|
||||
it.live("runs synchronous hooks without crashing", () =>
|
||||
it.instance("runs synchronous hooks without crashing", () =>
|
||||
withProject(
|
||||
[
|
||||
"export default async () => ({",
|
||||
@@ -101,7 +100,7 @@ describe("plugin.trigger", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("awaits asynchronous hooks", () =>
|
||||
it.instance("awaits asynchronous hooks", () =>
|
||||
withProject(
|
||||
[
|
||||
"export default async () => ({",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Env } from "../../src/env"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
@@ -21,12 +21,11 @@ import { Vcs } from "../../src/project/vcs"
|
||||
import { InstanceState } from "../../src/effect/instance-state"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { AccountTest } from "../fake/account"
|
||||
import { AuthTest } from "../fake/auth"
|
||||
import { NpmTest } from "../fake/npm"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
|
||||
const configLayer = Config.layer.pipe(
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
@@ -38,7 +37,7 @@ const configLayer = Config.layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
const pluginLayer = Plugin.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
|
||||
)
|
||||
@@ -63,9 +62,9 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("plugin.workspace", () => {
|
||||
it.live("plugin can install a workspace adapter", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
it.instance("plugin can install a workspace adapter", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const type = `plug-${Math.random().toString(36).slice(2)}`
|
||||
const file = path.join(dir, "plugin.ts")
|
||||
const mark = path.join(dir, "created.json")
|
||||
@@ -133,7 +132,6 @@ describe("plugin.workspace", () => {
|
||||
directory: space,
|
||||
extra: { key: "value" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -73,6 +73,8 @@ delete process.env["CEREBRAS_API_KEY"]
|
||||
delete process.env["SAMBANOVA_API_KEY"]
|
||||
delete process.env["OPENCODE_SERVER_PASSWORD"]
|
||||
delete process.env["OPENCODE_SERVER_USERNAME"]
|
||||
delete process.env["OPENCODE_EXPERIMENTAL"]
|
||||
delete process.env["OPENCODE_ENABLE_EXPERIMENTAL_MODELS"]
|
||||
delete process.env["OTEL_EXPORTER_OTLP_ENDPOINT"]
|
||||
delete process.env["OTEL_EXPORTER_OTLP_HEADERS"]
|
||||
delete process.env["OTEL_RESOURCE_ATTRIBUTES"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Bus } from "@/bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Project } from "@/project/project"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { $ } from "bun"
|
||||
@@ -75,7 +75,7 @@ function projectLayerWithFailure(failArg: string) {
|
||||
Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))),
|
||||
Layer.provide(mockGitFailure(failArg)),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(Bus.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
@@ -85,7 +85,7 @@ function projectLayerWithFailure(failArg: string) {
|
||||
|
||||
function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.layer>[0]) {
|
||||
return Project.layer.pipe(
|
||||
Layer.provide(Bus.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Deferred, Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { disposeAllInstances, provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
import { FileWatcher } from "../../src/file/watcher"
|
||||
import { Git } from "../../src/git"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
@@ -19,11 +19,12 @@ import { testEffect } from "../lib/effect"
|
||||
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
|
||||
|
||||
const layer = Layer.mergeAll(
|
||||
Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(Bus.layer)),
|
||||
Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(EventV2Bridge.defaultLayer)),
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
)
|
||||
const it = testEffect(layer)
|
||||
const worktreeIt = testEffect(Layer.mergeAll(layer, testInstanceStoreLayer))
|
||||
|
||||
const git = Effect.fn("VcsTest.git")(function* (cwd: string, args: string[]) {
|
||||
const result = yield* Git.Service.use((git) => git.run(args, { cwd }))
|
||||
@@ -47,13 +48,15 @@ const init = Effect.fn("VcsTest.init")(function* () {
|
||||
})
|
||||
|
||||
const nextBranchUpdate = Effect.fn("VcsTest.nextBranchUpdate")(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const updated = yield* Deferred.make<string | undefined>()
|
||||
|
||||
const off = yield* bus.subscribeCallback(Vcs.Event.BranchUpdated, (evt) => {
|
||||
Effect.runSync(Deferred.succeed(updated, evt.properties.branch))
|
||||
const off = yield* events.listen((event) => {
|
||||
if (event.type === Vcs.Event.BranchUpdated.type)
|
||||
Deferred.doneUnsafe(updated, Effect.succeed((event.data as typeof Vcs.Event.BranchUpdated.data.Type).branch))
|
||||
return Effect.void
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||
yield* Effect.addFinalizer(() => off)
|
||||
|
||||
return updated
|
||||
})
|
||||
@@ -62,9 +65,9 @@ const publishHeadChangeUntil = Effect.fn("VcsTest.publishHeadChangeUntil")(funct
|
||||
pending: Deferred.Deferred<string | undefined>,
|
||||
head: string,
|
||||
) {
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
for (let i = 0; i < 50; i++) {
|
||||
yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" })
|
||||
yield* events.publish(FileWatcher.Event.Updated, { file: head, event: "change" })
|
||||
if (yield* Deferred.isDone(pending)) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
@@ -183,7 +186,7 @@ describe("Vcs diff", () => {
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("detects current branch from the active worktree", () =>
|
||||
worktreeIt.live("detects current branch from the active worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const wt = yield* tmpdirScoped()
|
||||
|
||||
@@ -5,17 +5,18 @@ import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Worktree } from "../../src/worktree"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
const wintest = process.platform === "win32" ? it.live : it.live.skip
|
||||
const wintest = process.platform === "win32" ? it.instance : it.instance.skip
|
||||
|
||||
describe("Worktree.remove", () => {
|
||||
it.live("continues when git remove exits non-zero after detaching", () =>
|
||||
provideTmpdirInstance(
|
||||
(root) =>
|
||||
Effect.gen(function* () {
|
||||
it.instance(
|
||||
"continues when git remove exits non-zero after detaching",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const root = (yield* TestInstance).directory
|
||||
const svc = yield* Worktree.Service
|
||||
const name = `remove-regression-${Date.now().toString(36)}`
|
||||
const branch = `opencode/${name}`
|
||||
@@ -79,15 +80,15 @@ describe("Worktree.remove", () => {
|
||||
$`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(),
|
||||
)
|
||||
expect(ref.exitCode).not.toBe(0)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
wintest("stops fsmonitor before removing a worktree", () =>
|
||||
provideTmpdirInstance(
|
||||
(root) =>
|
||||
Effect.gen(function* () {
|
||||
wintest(
|
||||
"stops fsmonitor before removing a worktree",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const root = (yield* TestInstance).directory
|
||||
const svc = yield* Worktree.Service
|
||||
const name = `remove-fsmonitor-${Date.now().toString(36)}`
|
||||
const branch = `opencode/${name}`
|
||||
@@ -119,8 +120,7 @@ describe("Worktree.remove", () => {
|
||||
$`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow(),
|
||||
)
|
||||
expect(ref.exitCode).not.toBe(0)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { Pty } from "../../src/pty"
|
||||
@@ -10,7 +10,7 @@ type Socket = Parameters<Pty.Interface["connect"]>[1]
|
||||
|
||||
const it = testEffect(
|
||||
Pty.layer.pipe(
|
||||
Layer.provideMerge(Bus.layer),
|
||||
Layer.provideMerge(EventV2Bridge.defaultLayer),
|
||||
Layer.provideMerge(Config.defaultLayer),
|
||||
Layer.provideMerge(Plugin.defaultLayer),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { Pty } from "../../src/pty"
|
||||
@@ -11,7 +11,7 @@ type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID }
|
||||
|
||||
const it = testEffect(
|
||||
Pty.layer.pipe(
|
||||
Layer.provideMerge(Bus.layer),
|
||||
Layer.provideMerge(EventV2Bridge.defaultLayer),
|
||||
Layer.provideMerge(Config.defaultLayer),
|
||||
Layer.provideMerge(Plugin.defaultLayer),
|
||||
),
|
||||
@@ -19,27 +19,19 @@ const it = testEffect(
|
||||
const ptyTest = process.platform === "win32" ? it.instance.skip : it.instance
|
||||
|
||||
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const source = yield* EventV2Bridge.Service
|
||||
const events = yield* Queue.unbounded<PtyEvent>()
|
||||
|
||||
const subscribe = <A>(effect: Effect.Effect<() => void, never, A>) =>
|
||||
Effect.acquireRelease(effect, (off) => Effect.sync(off))
|
||||
|
||||
yield* subscribe(
|
||||
bus.subscribeCallback(Pty.Event.Created, (evt) => {
|
||||
Queue.offerUnsafe(events, { type: "created", id: evt.properties.info.id })
|
||||
}),
|
||||
)
|
||||
yield* subscribe(
|
||||
bus.subscribeCallback(Pty.Event.Exited, (evt) => {
|
||||
Queue.offerUnsafe(events, { type: "exited", id: evt.properties.id })
|
||||
}),
|
||||
)
|
||||
yield* subscribe(
|
||||
bus.subscribeCallback(Pty.Event.Deleted, (evt) => {
|
||||
Queue.offerUnsafe(events, { type: "deleted", id: evt.properties.id })
|
||||
}),
|
||||
)
|
||||
const unsubscribe = yield* source.listen((event) => {
|
||||
if (event.type === Pty.Event.Created.type)
|
||||
Queue.offerUnsafe(events, { type: "created", id: (event.data as typeof Pty.Event.Created.data.Type).info.id })
|
||||
if (event.type === Pty.Event.Exited.type)
|
||||
Queue.offerUnsafe(events, { type: "exited", id: (event.data as typeof Pty.Event.Exited.data.Type).id })
|
||||
if (event.type === Pty.Event.Deleted.type)
|
||||
Queue.offerUnsafe(events, { type: "deleted", id: (event.data as typeof Pty.Event.Deleted.data.Type).id })
|
||||
return Effect.void
|
||||
})
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
return events
|
||||
})
|
||||
|
||||
@@ -2,16 +2,19 @@ import { afterEach, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Fiber, Layer, Queue } from "effect"
|
||||
import { Question } from "../../src/question"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { QuestionID } from "../../src/question/schema"
|
||||
import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(Bus.layer)), CrossSpawnSpawner.defaultLayer),
|
||||
Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
const lifecycle = testEffect(
|
||||
Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer),
|
||||
)
|
||||
|
||||
const askEffect = Effect.fn("QuestionTest.ask")(function* (input: {
|
||||
@@ -49,10 +52,13 @@ const rejectAll = Effect.gen(function* () {
|
||||
|
||||
const waitForPending = Effect.fn("QuestionTest.waitForPending")(function* (count: number) {
|
||||
const question = yield* Question.Service
|
||||
const bus = yield* Bus.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const asked = yield* Queue.unbounded<void>()
|
||||
const off = yield* bus.subscribeCallback(Question.Event.Asked, () => Queue.offerUnsafe(asked, undefined))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||
const off = yield* events.listen((event) => {
|
||||
if (event.type === Question.Event.Asked.type) Queue.offerUnsafe(asked, undefined)
|
||||
return Effect.void
|
||||
})
|
||||
yield* Effect.addFinalizer(() => off)
|
||||
|
||||
for (;;) {
|
||||
const pending = yield* question.list()
|
||||
@@ -361,7 +367,7 @@ it.instance(
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("questions stay isolated by directory", () =>
|
||||
lifecycle.live("questions stay isolated by directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const one = yield* tmpdirScoped({ git: true })
|
||||
const two = yield* tmpdirScoped({ git: true })
|
||||
@@ -404,7 +410,7 @@ it.live("questions stay isolated by directory", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("pending question rejects on instance dispose", () =>
|
||||
lifecycle.live("pending question rejects on instance dispose", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const fiber = yield* askEffect({
|
||||
@@ -423,7 +429,7 @@ it.live("pending question rejects on instance dispose", () =>
|
||||
return yield* InstanceRef
|
||||
}).pipe(provideInstance(dir))
|
||||
if (!ctx) return yield* Effect.die(new Error("missing test instance"))
|
||||
yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx))
|
||||
yield* InstanceStore.Service.use((store) => store.dispose(ctx))
|
||||
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
@@ -431,7 +437,7 @@ it.live("pending question rejects on instance dispose", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("pending question rejects on instance reload", () =>
|
||||
lifecycle.live("pending question rejects on instance reload", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const fiber = yield* askEffect({
|
||||
@@ -446,7 +452,7 @@ it.live("pending question rejects on instance reload", () =>
|
||||
}).pipe(provideInstance(dir), Effect.forkScoped)
|
||||
|
||||
expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
|
||||
yield* Effect.promise(() => reloadTestInstance({ directory: dir }))
|
||||
yield* InstanceStore.Service.use((store) => store.reload({ directory: dir }))
|
||||
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("session.listGlobal", () => {
|
||||
const firstSession = yield* withSession({ title: "first-session" })
|
||||
const secondSession = yield* withSession({ title: "second-session" }).pipe(provideInstance(second))
|
||||
|
||||
const sessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200 })])
|
||||
const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 }))
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(firstSession.id)
|
||||
@@ -56,12 +56,14 @@ describe("session.listGlobal", () => {
|
||||
|
||||
yield* SessionNs.Service.use((session) => session.setArchived({ sessionID: archived.id, time: Date.now() }))
|
||||
|
||||
const sessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200 })])
|
||||
const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 }))
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).not.toContain(archived.id)
|
||||
|
||||
const allSessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200, archived: true })])
|
||||
const allSessions = yield* SessionNs.Service.use((session) =>
|
||||
session.listGlobal({ limit: 200, archived: true }),
|
||||
)
|
||||
const allIds = allSessions.map((session) => session.id)
|
||||
|
||||
expect(allIds).toContain(archived.id)
|
||||
@@ -86,13 +88,15 @@ describe("session.listGlobal", () => {
|
||||
)
|
||||
const second = yield* withSession({ title: "page-two" })
|
||||
|
||||
const page = yield* Effect.sync(() => [...SessionNs.listGlobal({ directory: test.directory, limit: 1 })])
|
||||
const page = yield* SessionNs.Service.use((session) =>
|
||||
session.listGlobal({ directory: test.directory, limit: 1 }),
|
||||
)
|
||||
expect(page.length).toBe(1)
|
||||
expect(page[0].id).toBe(second.id)
|
||||
|
||||
const next = yield* Effect.sync(() => [
|
||||
...SessionNs.listGlobal({ directory: test.directory, limit: 10, cursor: page[0].time.updated }),
|
||||
])
|
||||
const next = yield* SessionNs.Service.use((session) =>
|
||||
session.listGlobal({ directory: test.directory, limit: 10, cursor: page[0].time.updated }),
|
||||
)
|
||||
const ids = next.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(first.id)
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Layer, Queue, Schema, Stream } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Event as ServerEvent } from "../../src/server/event"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffectShared } from "../lib/effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -17,28 +15,25 @@ const EventData = Schema.Struct({
|
||||
properties: Schema.Record(Schema.String, Schema.Any),
|
||||
})
|
||||
|
||||
const readEvent = (reader: ReadableStreamDefaultReader<Uint8Array>) =>
|
||||
const readEvent = (reader: Queue.Dequeue<Uint8Array>) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* Effect.promise(() => reader.read()).pipe(
|
||||
const value = yield* Queue.take(reader).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () => Effect.fail(new Error("timed out waiting for event")),
|
||||
}),
|
||||
)
|
||||
if (result.done || !result.value) return yield* Effect.fail(new Error("event stream closed"))
|
||||
return Schema.decodeUnknownSync(EventData)(
|
||||
JSON.parse(new TextDecoder().decode(result.value).replace(/^data: /, "")),
|
||||
)
|
||||
return Schema.decodeUnknownSync(EventData)(JSON.parse(new TextDecoder().decode(value).replace(/^data: /, "")))
|
||||
})
|
||||
|
||||
const openEventStream = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(async () =>
|
||||
Server.Default().app.request(EventPaths.event, { headers: { "x-opencode-directory": directory } }),
|
||||
const response = yield* requestInDirectory(EventPaths.event, directory)
|
||||
const reader = yield* Queue.unbounded<Uint8Array>()
|
||||
yield* response.stream.pipe(
|
||||
Stream.runForEach((value) => Queue.offer(reader, value)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
if (!response.body) return yield* Effect.die("missing SSE response body")
|
||||
const reader = response.body.getReader()
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => undefined)))
|
||||
return { response, reader }
|
||||
})
|
||||
|
||||
@@ -47,7 +42,7 @@ afterEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const it = testEffectShared(Bus.defaultLayer)
|
||||
const it = testEffect(httpApiLayer)
|
||||
|
||||
describe("event HttpApi", () => {
|
||||
it.instance(
|
||||
@@ -58,10 +53,10 @@ describe("event HttpApi", () => {
|
||||
const { response, reader } = yield* openEventStream(directory)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("content-type")).toContain("text/event-stream")
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache, no-transform")
|
||||
expect(response.headers.get("x-accel-buffering")).toBe("no")
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff")
|
||||
expect(response.headers["content-type"]).toContain("text/event-stream")
|
||||
expect(response.headers["cache-control"]).toBe("no-cache, no-transform")
|
||||
expect(response.headers["x-accel-buffering"]).toBe("no")
|
||||
expect(response.headers["x-content-type-options"]).toBe("nosniff")
|
||||
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
@@ -76,8 +71,8 @@ describe("event HttpApi", () => {
|
||||
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
|
||||
// If no second event arrives within 250ms, the stream is still open.
|
||||
const status = yield* Effect.promise(() => reader.read()).pipe(
|
||||
Effect.map((result) => (result.done ? ("closed" as const) : ("event" as const))),
|
||||
const status = yield* Queue.take(reader).pipe(
|
||||
Effect.as("event" as const),
|
||||
Effect.timeoutOrElse({ duration: "250 millis", orElse: () => Effect.succeed("open" as const) }),
|
||||
)
|
||||
expect(status).toBe("open")
|
||||
@@ -86,16 +81,18 @@ describe("event HttpApi", () => {
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"delivers instance bus events after the initial event",
|
||||
"delivers instance events after the initial event",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const { reader } = yield* openEventStream(directory)
|
||||
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
|
||||
yield* Bus.use.publish(ServerEvent.Connected, {})
|
||||
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
const created = yield* requestInDirectory("/session", directory, { method: "POST" })
|
||||
expect(created.status).toBe(200)
|
||||
expect(yield* readEvent(reader)).toMatchObject({ type: "session.created" })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
@@ -14,30 +14,23 @@ import { Worktree } from "../../src/worktree"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
|
||||
const testWorktreeMutations = process.platform === "win32" ? it.instance.skip : it.instance
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function request(path: string, directory: string, init: RequestInit = {}) {
|
||||
return Effect.promise(() => {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return Promise.resolve(app().request(path, { ...init, headers }))
|
||||
})
|
||||
return requestInDirectory(path, directory, init)
|
||||
}
|
||||
|
||||
function createSession(input?: Session.CreateInput) {
|
||||
return Session.use.create(input)
|
||||
}
|
||||
|
||||
function json<T>(response: Response) {
|
||||
return Effect.promise(() => response.json() as Promise<T>)
|
||||
function json<T>(response: HttpClientResponse.HttpClientResponse) {
|
||||
return response.json.pipe(Effect.map((value) => value as T))
|
||||
}
|
||||
|
||||
function waitReady(input: { directory?: string; name?: string }) {
|
||||
@@ -83,7 +76,11 @@ function insertAccount() {
|
||||
}),
|
||||
(id) =>
|
||||
Database.Service.use(({ db }) =>
|
||||
db.delete(AccountTable).where(eq(AccountTable.id, AccountV2.ID.make(id))).run().pipe(Effect.orDie),
|
||||
db
|
||||
.delete(AccountTable)
|
||||
.where(eq(AccountTable.id, AccountV2.ID.make(id)))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -91,11 +88,19 @@ function insertAccount() {
|
||||
function setSessionUpdated(session: Session.Info, updated: number) {
|
||||
return Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.update(SessionTable).set({ time_updated: updated }).where(eq(SessionTable.id, session.id)).run().pipe(Effect.orDie)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_updated: updated })
|
||||
.where(eq(SessionTable.id, session.id))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
}
|
||||
|
||||
function withCreatedWorktree(directory: string, use: (info: Worktree.Info) => Effect.Effect<void, unknown, never>) {
|
||||
function withCreatedWorktree(
|
||||
directory: string,
|
||||
use: (info: Worktree.Info) => Effect.Effect<void, unknown, HttpClient.HttpClient>,
|
||||
) {
|
||||
const name = "api-test"
|
||||
const headers = { "content-type": "application/json" }
|
||||
return Effect.acquireUseRelease(
|
||||
@@ -244,7 +249,7 @@ describe("experimental HttpApi", () => {
|
||||
tmp.directory,
|
||||
)
|
||||
expect(page.status).toBe(200)
|
||||
expect(page.headers.get("x-next-cursor")).toBeTruthy()
|
||||
expect(page.headers["x-next-cursor"]).toBeTruthy()
|
||||
|
||||
const body = yield* json<Session.GlobalInfo[]>(page)
|
||||
expect(body.map((session) => session.id)).toEqual([second.id])
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { Config, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
|
||||
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
|
||||
HttpApiApp.routes,
|
||||
{
|
||||
disableListenLog: true,
|
||||
disableLogger: true,
|
||||
},
|
||||
)
|
||||
|
||||
export const httpApiLayer = servedRoutes.pipe(
|
||||
Layer.provide(layerWebSocketConstructorGlobal),
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provideMerge(NodeServices.layer),
|
||||
)
|
||||
|
||||
export function request(path: string, init?: RequestInit) {
|
||||
const url = new URL(path, "http://localhost")
|
||||
return HttpClientRequest.fromWeb(new Request(url, init)).pipe(
|
||||
HttpClientRequest.setUrl(url.pathname),
|
||||
HttpClient.execute,
|
||||
)
|
||||
}
|
||||
|
||||
export function requestInDirectory(path: string, directory: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return request(path, { ...init, headers })
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { describe, expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { markPluginDependenciesReady } from "../fixture/plugin"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, request } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -18,16 +18,12 @@ const testStateLayer = Layer.effectDiscard(
|
||||
),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer, httpApiLayer))
|
||||
const projectOptions = { config: { formatter: false, lsp: false } }
|
||||
const providerID = "test-oauth-parity"
|
||||
const oauthURL = "https://example.com/oauth"
|
||||
const oauthInstructions = "Finish OAuth"
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function providerListHasFetch(list: unknown) {
|
||||
if (!Array.isArray(list)) return false
|
||||
return list.some((item: unknown) => {
|
||||
@@ -77,41 +73,34 @@ function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id:
|
||||
}
|
||||
|
||||
function requestAuthorize(input: {
|
||||
app: ReturnType<typeof app>
|
||||
providerID: string
|
||||
method: number
|
||||
headers: HeadersInit
|
||||
inputs?: Record<string, string>
|
||||
}) {
|
||||
return Effect.promise(async () => {
|
||||
const response = await input.app.request(`/provider/${input.providerID}/oauth/authorize`, {
|
||||
return Effect.gen(function* () {
|
||||
const response = yield* request(`/provider/${input.providerID}/oauth/authorize`, {
|
||||
method: "POST",
|
||||
headers: input.headers,
|
||||
body: JSON.stringify({ method: input.method, ...(input.inputs ? { inputs: input.inputs } : {}) }),
|
||||
})
|
||||
return {
|
||||
status: response.status,
|
||||
body: await response.text(),
|
||||
body: yield* response.text,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function requestCallback(input: {
|
||||
app: ReturnType<typeof app>
|
||||
providerID: string
|
||||
method: number
|
||||
headers: HeadersInit
|
||||
code?: string
|
||||
}) {
|
||||
return Effect.promise(async () => {
|
||||
const response = await input.app.request(`/provider/${input.providerID}/oauth/callback`, {
|
||||
function requestCallback(input: { providerID: string; method: number; headers: HeadersInit; code?: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const response = yield* request(`/provider/${input.providerID}/oauth/callback`, {
|
||||
method: "POST",
|
||||
headers: input.headers,
|
||||
body: JSON.stringify({ method: input.method, ...(input.code ? { code: input.code } : {}) }),
|
||||
})
|
||||
return {
|
||||
status: response.status,
|
||||
body: await response.text(),
|
||||
body: yield* response.text,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -277,15 +266,13 @@ describe("provider HttpApi", () => {
|
||||
it.instance.skip(
|
||||
"returns public v2 provider not found errors",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request("/api/provider/missing", { headers: { "x-opencode-directory": instance.directory } }),
|
||||
),
|
||||
)
|
||||
const directory = (yield* TestInstance).directory
|
||||
const response = yield* request("/api/provider/missing", {
|
||||
headers: { "x-opencode-directory": directory },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({
|
||||
expect(yield* response.json).toEqual({
|
||||
_tag: "ProviderNotFoundError",
|
||||
providerID: "missing",
|
||||
message: "Provider not found: missing",
|
||||
@@ -297,13 +284,9 @@ describe("provider HttpApi", () => {
|
||||
it.instance(
|
||||
"serves OAuth authorize response shapes",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
yield* writeProviderAuthPlugin(instance.directory)
|
||||
const headers = { "x-opencode-directory": instance.directory, "content-type": "application/json" }
|
||||
const server = app()
|
||||
|
||||
const directory = (yield* TestInstance).directory
|
||||
const headers = { "x-opencode-directory": directory, "content-type": "application/json" }
|
||||
const api = yield* requestAuthorize({
|
||||
app: server,
|
||||
providerID,
|
||||
method: 0,
|
||||
headers,
|
||||
@@ -315,7 +298,6 @@ describe("provider HttpApi", () => {
|
||||
expect(api).toEqual({ status: 200, body: "null" })
|
||||
|
||||
const oauth = yield* requestAuthorize({
|
||||
app: server,
|
||||
providerID,
|
||||
method: 1,
|
||||
headers,
|
||||
@@ -326,21 +308,19 @@ describe("provider HttpApi", () => {
|
||||
instructions: oauthInstructions,
|
||||
})
|
||||
}),
|
||||
projectOptions,
|
||||
{ ...projectOptions, init: writeProviderAuthPlugin },
|
||||
30000,
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns declared provider auth validation errors",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
yield* writeProviderAuthValidationPlugin(instance.directory)
|
||||
const directory = (yield* TestInstance).directory
|
||||
const response = yield* requestAuthorize({
|
||||
app: app(),
|
||||
providerID: "test-oauth-validation",
|
||||
method: 0,
|
||||
inputs: { token: "nope" },
|
||||
headers: { "x-opencode-directory": instance.directory, "content-type": "application/json" },
|
||||
headers: { "x-opencode-directory": directory, "content-type": "application/json" },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
@@ -349,19 +329,18 @@ describe("provider HttpApi", () => {
|
||||
data: { field: "token", message: "Token must be ok" },
|
||||
})
|
||||
}),
|
||||
projectOptions,
|
||||
{ ...projectOptions, init: writeProviderAuthValidationPlugin },
|
||||
30000,
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns declared provider auth callback errors",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
const directory = (yield* TestInstance).directory
|
||||
const response = yield* requestCallback({
|
||||
app: app(),
|
||||
providerID,
|
||||
method: 0,
|
||||
headers: { "x-opencode-directory": instance.directory, "content-type": "application/json" },
|
||||
headers: { "x-opencode-directory": directory, "content-type": "application/json" },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
@@ -377,54 +356,48 @@ describe("provider HttpApi", () => {
|
||||
it.instance(
|
||||
"serves provider lists when auth loaders add runtime fetch options",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
yield* writeFunctionOptionsPlugin(instance.directory)
|
||||
const directory = (yield* TestInstance).directory
|
||||
yield* setEnvScoped(
|
||||
"OPENCODE_AUTH_CONTENT",
|
||||
JSON.stringify({
|
||||
google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 },
|
||||
}),
|
||||
)
|
||||
const headers = { "x-opencode-directory": instance.directory }
|
||||
const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers })))
|
||||
const configResponse = yield* Effect.promise(() =>
|
||||
Promise.resolve(app().request("/config/providers", { headers })),
|
||||
)
|
||||
const headers = { "x-opencode-directory": directory }
|
||||
const providerResponse = yield* request("/provider", { headers })
|
||||
const configResponse = yield* request("/config/providers", { headers })
|
||||
|
||||
expect(providerResponse.status).toBe(200)
|
||||
expect(configResponse.status).toBe(200)
|
||||
|
||||
const providerBody = yield* Effect.promise(() => providerResponse.json())
|
||||
const configBody = yield* Effect.promise(() => configResponse.json())
|
||||
const providerBody = yield* providerResponse.json
|
||||
const configBody = yield* configResponse.json
|
||||
expect(hasProviderWithFetch(providerBody, "all")).toBe(false)
|
||||
expect(hasProviderWithFetch(configBody, "providers")).toBe(false)
|
||||
expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
|
||||
expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true)
|
||||
}),
|
||||
projectOptions,
|
||||
{ ...projectOptions, init: writeFunctionOptionsPlugin },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"keeps provider.models hook input mutations out of provider state",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
yield* writeProviderModelsMutationPlugin(instance.directory)
|
||||
const directory = (yield* TestInstance).directory
|
||||
|
||||
const headers = { "x-opencode-directory": instance.directory }
|
||||
const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers })))
|
||||
const configResponse = yield* Effect.promise(() =>
|
||||
Promise.resolve(app().request("/config/providers", { headers })),
|
||||
)
|
||||
const headers = { "x-opencode-directory": directory }
|
||||
const providerResponse = yield* request("/provider", { headers })
|
||||
const configResponse = yield* request("/config/providers", { headers })
|
||||
|
||||
expect(providerResponse.status).toBe(200)
|
||||
expect(configResponse.status).toBe(200)
|
||||
|
||||
const providerBody = yield* Effect.promise(() => providerResponse.json())
|
||||
const configBody = yield* Effect.promise(() => configResponse.json())
|
||||
const providerBody = yield* providerResponse.json
|
||||
const configBody = yield* configResponse.json
|
||||
expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false)
|
||||
expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false)
|
||||
expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
|
||||
}),
|
||||
projectOptions,
|
||||
{ ...projectOptions, init: writeProviderModelsMutationPlugin },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientResponse } from "effect/unstable/http"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
|
||||
@@ -13,8 +13,11 @@ import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
|
||||
|
||||
const text = (response: HttpClientResponse.HttpClientResponse) => response.text
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
@@ -67,16 +70,14 @@ describe("schema-rejection wire shape", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const res = yield* Effect.promise(async () =>
|
||||
Server.Default().app.request(SyncPaths.history, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: -1 }),
|
||||
}),
|
||||
)
|
||||
const body = yield* Effect.promise(async () => res.text())
|
||||
const res = yield* requestInDirectory(SyncPaths.history, test.directory, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: -1 }),
|
||||
})
|
||||
const body = yield* text(res)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.headers.get("content-type") ?? "").toContain("application/json")
|
||||
expect(res.headers["content-type"] ?? "").toContain("application/json")
|
||||
const parsed = JSON.parse(body)
|
||||
expect(parsed).toMatchObject({
|
||||
name: "BadRequest",
|
||||
@@ -95,8 +96,8 @@ describe("schema-rejection wire shape", () => {
|
||||
const test = yield* TestInstance
|
||||
// /find/file?limit=999999 violates the limit constraint check.
|
||||
const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}`
|
||||
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
|
||||
const body = yield* Effect.promise(async () => res.text())
|
||||
const res = yield* requestInDirectory(url, test.directory)
|
||||
const body = yield* text(res)
|
||||
expect(res.status).toBe(400)
|
||||
const parsed = JSON.parse(body)
|
||||
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } })
|
||||
@@ -109,12 +110,8 @@ describe("schema-rejection wire shape", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const res = yield* Effect.promise(async () =>
|
||||
Server.Default().app.request("/api/session?limit=0", {
|
||||
headers: { "x-opencode-directory": test.directory },
|
||||
}),
|
||||
)
|
||||
const parsed = JSON.parse(yield* Effect.promise(async () => res.text()))
|
||||
const res = yield* requestInDirectory("/api/session?limit=0", test.directory)
|
||||
const parsed = JSON.parse(yield* text(res))
|
||||
expect(res.status).toBe(400)
|
||||
expect(parsed).toMatchObject({ _tag: "InvalidRequestError", kind: "Query" })
|
||||
expect(parsed.message).toEqual(expect.any(String))
|
||||
@@ -131,14 +128,12 @@ describe("schema-rejection wire shape", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const huge = "X".repeat(50_000)
|
||||
const res = yield* Effect.promise(async () =>
|
||||
Server.Default().app.request(SyncPaths.history, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: huge }),
|
||||
}),
|
||||
)
|
||||
const body = yield* Effect.promise(async () => res.text())
|
||||
const res = yield* requestInDirectory(SyncPaths.history, test.directory, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: huge }),
|
||||
})
|
||||
const body = yield* text(res)
|
||||
expect(res.status).toBe(400)
|
||||
// 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB.
|
||||
expect(body.length).toBeLessThan(2 * 1024)
|
||||
@@ -155,10 +150,10 @@ describe("schema-rejection wire shape", () => {
|
||||
const test = yield* TestInstance
|
||||
const sessionID = yield* seedCorruptStepFinishPart
|
||||
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
|
||||
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
|
||||
const body = yield* Effect.promise(async () => res.text())
|
||||
const res = yield* requestInDirectory(url, test.directory)
|
||||
const body = yield* text(res)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.headers.get("content-type") ?? "").toContain("application/json")
|
||||
expect(res.headers["content-type"] ?? "").toContain("application/json")
|
||||
const parsed = JSON.parse(body)
|
||||
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } })
|
||||
// Field path in data.message — what made this PR worth shipping.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { ConfigProvider, Deferred, Effect, Layer } from "effect"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
@@ -11,8 +11,6 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { validateSession } from "../../src/cli/cmd/tui/validate-session"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
|
||||
@@ -26,6 +24,8 @@ import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixt
|
||||
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { httpApiLayer } from "./httpapi-layer"
|
||||
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const it = testEffect(
|
||||
@@ -33,6 +33,8 @@ const it = testEffect(
|
||||
AppFileSystem.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)),
|
||||
Database.defaultLayer,
|
||||
httpApiLayer,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -47,55 +49,47 @@ type SdkResult = { response: Response; data?: unknown; error?: unknown }
|
||||
type Captured = { status: number; data?: unknown; error?: unknown }
|
||||
type ProjectFixture = { sdk: Sdk; directory: string }
|
||||
type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] }
|
||||
type TestServices = AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | InstanceStore.Service
|
||||
type TestServices =
|
||||
| AppFileSystem.Service
|
||||
| ChildProcessSpawner.ChildProcessSpawner
|
||||
| InstanceStore.Service
|
||||
| HttpServer.HttpServer
|
||||
type TestScope = Scope.Scope | TestServices
|
||||
|
||||
function app(serverPath: ServerPath, input?: { password?: string; username?: string }) {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
if (serverPath === "default") return Server.Default().app
|
||||
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
HttpApiApp.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_SERVER_PASSWORD: input?.password,
|
||||
OPENCODE_SERVER_USERNAME: input?.username,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
return {
|
||||
fetch: (request: Request) => handler(request, HttpApiApp.context),
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function client(
|
||||
serverPath: ServerPath,
|
||||
directory?: string,
|
||||
input?: { password?: string; username?: string; headers?: Record<string, string> },
|
||||
) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: "http://localhost",
|
||||
directory,
|
||||
headers: input?.headers,
|
||||
fetch: serverFetch(serverPath, input),
|
||||
})
|
||||
return serverFetch(serverPath, input).pipe(
|
||||
Effect.map((fetch) =>
|
||||
createOpencodeClient({
|
||||
baseUrl: "http://localhost",
|
||||
directory,
|
||||
headers: input?.headers,
|
||||
fetch,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function serverFetch(serverPath: ServerPath, input?: { password?: string; username?: string }) {
|
||||
const serverApp = app(serverPath, input)
|
||||
return Object.assign(
|
||||
async (request: RequestInfo | URL, init?: RequestInit) =>
|
||||
await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
) satisfies typeof globalThis.fetch
|
||||
return HttpServer.HttpServer.use((server) =>
|
||||
Effect.sync(() => {
|
||||
void serverPath
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
const baseUrl = HttpServer.formatAddress(server.address)
|
||||
return Object.assign(
|
||||
async (request: RequestInfo | URL, init?: RequestInit) => {
|
||||
const source = request instanceof Request ? request : new Request(request, init)
|
||||
const url = new URL(source.url)
|
||||
return globalThis.fetch(new Request(new URL(`${url.pathname}${url.search}`, baseUrl), source))
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
) satisfies typeof globalThis.fetch
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function authorization(username: string, password: string) {
|
||||
@@ -206,22 +200,14 @@ function httpapiInstance<A, E>(
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
yield* options.setup?.(instance.directory) ?? Effect.void
|
||||
return yield* run({ sdk: client(options.serverPath, instance.directory), directory: instance.directory })
|
||||
return yield* run({ sdk: yield* client(options.serverPath, instance.directory), directory: instance.directory })
|
||||
}),
|
||||
{ git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } },
|
||||
)
|
||||
}
|
||||
|
||||
function serverPathParity<A, E>(name: string, scenario: (serverPath: ServerPath) => Effect.Effect<A, E, TestScope>) {
|
||||
it.live(
|
||||
name,
|
||||
Effect.gen(function* () {
|
||||
const standard = yield* scenario("default")
|
||||
yield* resetState()
|
||||
const raw = yield* scenario("raw")
|
||||
expect(raw).toEqual(standard)
|
||||
}),
|
||||
)
|
||||
it.live(name, scenario("raw"))
|
||||
}
|
||||
|
||||
function withProject<A, E, E2 = never>(
|
||||
@@ -239,7 +225,7 @@ function withProject<A, E, E2 = never>(
|
||||
config: { formatter: false, lsp: false, ...options.config },
|
||||
})
|
||||
yield* options.setup?.(directory) ?? Effect.void
|
||||
return yield* run({ sdk: client(serverPath, directory), directory })
|
||||
return yield* run({ sdk: yield* client(serverPath, directory), directory })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -340,7 +326,7 @@ describe("HttpApi SDK", () => {
|
||||
httpapi(
|
||||
"uses the generated SDK for global and control routes",
|
||||
Effect.gen(function* () {
|
||||
const sdk = client("raw")
|
||||
const sdk = yield* client("raw")
|
||||
const health = yield* call(() => sdk.global.health())
|
||||
const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" }))
|
||||
|
||||
@@ -382,7 +368,7 @@ describe("HttpApi SDK", () => {
|
||||
|
||||
serverPathParity("matches generated SDK global and control behavior", (serverPath) =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = client(serverPath)
|
||||
const sdk = yield* client(serverPath)
|
||||
const health = yield* capture(() => sdk.global.health())
|
||||
const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" }))
|
||||
const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" }))
|
||||
@@ -396,9 +382,11 @@ describe("HttpApi SDK", () => {
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK global event stream", (serverPath) =>
|
||||
firstEvent((signal) => client(serverPath).global.event({ signal })).pipe(
|
||||
Effect.map((event) => ({ type: record(record(event).payload).type })),
|
||||
),
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* client(serverPath)
|
||||
const event = yield* firstEvent((signal) => sdk.global.event({ signal }))
|
||||
return { type: record(record(event).payload).type }
|
||||
}),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK instance event stream", (serverPath) =>
|
||||
@@ -443,12 +431,13 @@ describe("HttpApi SDK", () => {
|
||||
withStandardProject(serverPath, ({ directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T"
|
||||
const fetch = yield* serverFetch(serverPath)
|
||||
const thrown = yield* captureThrown(() =>
|
||||
validateSession({
|
||||
url: "http://localhost",
|
||||
directory,
|
||||
sessionID,
|
||||
fetch: serverFetch(serverPath),
|
||||
fetch,
|
||||
}),
|
||||
)
|
||||
expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`)
|
||||
@@ -462,21 +451,18 @@ describe("HttpApi SDK", () => {
|
||||
{ serverPath: "raw", setup: writeStandardFiles },
|
||||
({ directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const missing = yield* capture(() =>
|
||||
client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }),
|
||||
)
|
||||
const bad = yield* capture(() =>
|
||||
client("raw", directory, {
|
||||
password: "secret",
|
||||
headers: { authorization: authorization("opencode", "wrong") },
|
||||
}).file.read({ path: "hello.txt" }),
|
||||
)
|
||||
const good = yield* capture(() =>
|
||||
client("raw", directory, {
|
||||
password: "secret",
|
||||
headers: { authorization: authorization("opencode", "secret") },
|
||||
}).file.read({ path: "hello.txt" }),
|
||||
)
|
||||
const missingSdk = yield* client("raw", directory, { password: "secret" })
|
||||
const missing = yield* capture(() => missingSdk.file.read({ path: "hello.txt" }))
|
||||
const badSdk = yield* client("raw", directory, {
|
||||
password: "secret",
|
||||
headers: { authorization: authorization("opencode", "wrong") },
|
||||
})
|
||||
const bad = yield* capture(() => badSdk.file.read({ path: "hello.txt" }))
|
||||
const goodSdk = yield* client("raw", directory, {
|
||||
password: "secret",
|
||||
headers: { authorization: authorization("opencode", "secret") },
|
||||
})
|
||||
const good = yield* capture(() => goodSdk.file.read({ path: "hello.txt" }))
|
||||
|
||||
return {
|
||||
statuses: statuses({ missing, bad, good }),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { Cause, Config, Effect, Exit, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
@@ -13,7 +16,7 @@ import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||
import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import * as HttpSessionError from "../../src/server/routes/instance/httpapi/handlers/session-errors"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
@@ -43,13 +46,28 @@ const instanceStoreLayer = InstanceStore.defaultLayer.pipe(
|
||||
Layer.succeed(InstanceBootstrapService.Service, InstanceBootstrapService.Service.of({ run: Effect.void })),
|
||||
),
|
||||
)
|
||||
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
|
||||
HttpApiApp.routes,
|
||||
{
|
||||
disableListenLog: true,
|
||||
disableLogger: true,
|
||||
},
|
||||
)
|
||||
const httpApiLayer = servedRoutes.pipe(
|
||||
Layer.provide(layerWebSocketConstructorGlobal),
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provideMerge(NodeServices.layer),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(instanceStoreLayer, Project.defaultLayer, Session.defaultLayer, workspaceLayer, Database.defaultLayer),
|
||||
Layer.mergeAll(
|
||||
instanceStoreLayer,
|
||||
Project.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
workspaceLayer,
|
||||
Database.defaultLayer,
|
||||
httpApiLayer,
|
||||
),
|
||||
)
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function pathFor(path: string, params: Record<string, string>) {
|
||||
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
|
||||
@@ -180,7 +198,12 @@ const setLegacySummaryDiff = (sessionID: SessionIDType) =>
|
||||
const getWorkspaceID = (sessionID: SessionIDType) =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
return yield* db.select({ workspaceID: SessionTable.workspace_id }).from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
|
||||
return yield* db
|
||||
.select({ workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const clearSessionPath = (sessionID: SessionIDType) =>
|
||||
@@ -190,18 +213,20 @@ const clearSessionPath = (sessionID: SessionIDType) =>
|
||||
})
|
||||
|
||||
function request(path: string, init?: RequestInit) {
|
||||
return Effect.promise(async () => app().request(path, init))
|
||||
const url = new URL(path, "http://localhost")
|
||||
return HttpClientRequest.fromWeb(new Request(url, init)).pipe(
|
||||
HttpClientRequest.setUrl(url.pathname),
|
||||
HttpClient.execute,
|
||||
)
|
||||
}
|
||||
|
||||
function json<T>(response: Response) {
|
||||
return Effect.promise(async () => {
|
||||
if (response.status !== 200) throw new Error(await response.text())
|
||||
return (await response.json()) as T
|
||||
})
|
||||
function json<T>(response: HttpClientResponse.HttpClientResponse) {
|
||||
if (response.status !== 200) return response.text.pipe(Effect.flatMap((text) => Effect.die(new Error(text))))
|
||||
return response.json.pipe(Effect.map((value) => value as T))
|
||||
}
|
||||
|
||||
function responseJson(response: Response) {
|
||||
return Effect.promise(() => response.json())
|
||||
function responseJson(response: HttpClientResponse.HttpClientResponse) {
|
||||
return response.json
|
||||
}
|
||||
|
||||
function requestJson<T>(path: string, init?: RequestInit) {
|
||||
@@ -335,7 +360,7 @@ describe("session HttpApi", () => {
|
||||
headers,
|
||||
})
|
||||
const messagePage = yield* json<SessionLegacy.WithParts[]>(messages)
|
||||
const nextCursor = messages.headers.get("x-next-cursor")
|
||||
const nextCursor = messages.headers["x-next-cursor"]
|
||||
expect(nextCursor).toBeTruthy()
|
||||
expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" })
|
||||
|
||||
@@ -745,9 +770,9 @@ describe("session HttpApi", () => {
|
||||
|
||||
const response = yield* request(route, { headers })
|
||||
|
||||
expect(response.headers.get("x-next-cursor")).toBeTruthy()
|
||||
expect(response.headers.get("link")).toContain("limit=1")
|
||||
expect(response.headers.get("access-control-expose-headers")?.toLowerCase()).toContain("x-next-cursor")
|
||||
expect(response.headers["x-next-cursor"]).toBeTruthy()
|
||||
expect(response.headers["link"]).toContain("limit=1")
|
||||
expect(response.headers["access-control-expose-headers"]?.toLowerCase()).toContain("x-next-cursor")
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, describe, expect, mock, spyOn } from "bun:test"
|
||||
import { Context, Effect } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Session } from "@/session/session"
|
||||
@@ -9,16 +8,13 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
const it = testEffect(Session.defaultLayer)
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer))
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
@@ -38,23 +34,17 @@ describe("sync HttpApi", () => {
|
||||
const info = spyOn(Log.create({ service: "server.sync" }), "info")
|
||||
const session = yield* Session.use.create({ title: "sync" })
|
||||
|
||||
const started = yield* Effect.promise(() =>
|
||||
Promise.resolve(app().request(SyncPaths.start, { method: "POST", headers })),
|
||||
)
|
||||
const started = yield* requestInDirectory(SyncPaths.start, tmp.directory, { method: "POST", headers })
|
||||
expect(started.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => started.json())).toBe(true)
|
||||
expect(yield* started.json).toBe(true)
|
||||
|
||||
const history = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request(SyncPaths.history, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const history = yield* requestInDirectory(SyncPaths.history, tmp.directory, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(history.status).toBe(200)
|
||||
const rows = (yield* Effect.promise(() => history.json())) as Array<{
|
||||
const rows = (yield* history.json) as Array<{
|
||||
id: string
|
||||
aggregate_id: string
|
||||
seq: number
|
||||
@@ -63,28 +53,24 @@ describe("sync HttpApi", () => {
|
||||
}>
|
||||
expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
|
||||
|
||||
const replayed = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request(SyncPaths.replay, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
directory: tmp.directory,
|
||||
events: rows
|
||||
.filter((row) => row.aggregate_id === session.id)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
aggregateID: row.aggregate_id,
|
||||
seq: row.seq,
|
||||
type: row.type,
|
||||
data: row.data,
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const replayed = yield* requestInDirectory(SyncPaths.replay, tmp.directory, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
directory: tmp.directory,
|
||||
events: rows
|
||||
.filter((row) => row.aggregate_id === session.id)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
aggregateID: row.aggregate_id,
|
||||
seq: row.seq,
|
||||
type: row.type,
|
||||
data: row.data,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
expect(replayed.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => replayed.json())).toEqual({ sessionID: session.id })
|
||||
expect(yield* replayed.json).toEqual({ sessionID: session.id })
|
||||
expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
|
||||
expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
|
||||
}),
|
||||
@@ -123,15 +109,11 @@ describe("sync HttpApi", () => {
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request(item.path, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(item.body),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const response = yield* requestInDirectory(item.path, tmp.directory, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(item.body),
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
}
|
||||
}),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user