mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 481f8667a4 | |||
| d9252a09dd | |||
| 74373f85c7 | |||
| 1b146ad094 | |||
| 8a63cbe79c | |||
| c565bd54e2 | |||
| f0136f947b | |||
| f1470c1a88 | |||
| f5398e7e1e |
@@ -0,0 +1,10 @@
|
|||||||
|
import { LocalContext } from "@/util/local-context"
|
||||||
|
import type * as Project from "./project"
|
||||||
|
|
||||||
|
export interface InstanceContext {
|
||||||
|
directory: string
|
||||||
|
worktree: string
|
||||||
|
project: Project.Info
|
||||||
|
}
|
||||||
|
|
||||||
|
export const context = LocalContext.create<InstanceContext>("instance")
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { GlobalBus } from "@/bus/global"
|
||||||
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
|
import { disposeInstance } from "@/effect/instance-registry"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
|
||||||
|
import { context, type InstanceContext } from "./instance-context"
|
||||||
|
import * as Project from "./project"
|
||||||
|
|
||||||
|
export interface LoadInput {
|
||||||
|
directory: string
|
||||||
|
init?: () => Promise<unknown>
|
||||||
|
worktree?: string
|
||||||
|
project?: Project.Info
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
|
||||||
|
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
|
||||||
|
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
|
||||||
|
readonly disposeAll: () => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
readonly deferred: Deferred.Deferred<InstanceContext>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const project = yield* Project.Service
|
||||||
|
const scope = yield* Scope.Scope
|
||||||
|
const cache = new Map<string, Entry>()
|
||||||
|
|
||||||
|
const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) {
|
||||||
|
const ctx =
|
||||||
|
input.project && input.worktree
|
||||||
|
? {
|
||||||
|
directory: input.directory,
|
||||||
|
worktree: input.worktree,
|
||||||
|
project: input.project,
|
||||||
|
}
|
||||||
|
: yield* project.fromDirectory(input.directory).pipe(
|
||||||
|
Effect.map((result) => ({
|
||||||
|
directory: input.directory,
|
||||||
|
worktree: result.sandbox,
|
||||||
|
project: result.project,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const init = input.init
|
||||||
|
if (init) yield* Effect.promise(() => context.provide(ctx, init))
|
||||||
|
return ctx
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeEntry = (directory: string, entry: Entry) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
if (cache.get(directory) !== entry) return false
|
||||||
|
cache.delete(directory)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const completeLoad = Effect.fnUntraced(function* (directory: string, input: LoadInput, entry: Entry) {
|
||||||
|
const exit = yield* Effect.exit(boot({ ...input, directory }))
|
||||||
|
if (Exit.isFailure(exit)) yield* removeEntry(directory, entry)
|
||||||
|
yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid)
|
||||||
|
})
|
||||||
|
|
||||||
|
const emitDisposed = (input: { directory: string; project?: string }) =>
|
||||||
|
Effect.sync(() =>
|
||||||
|
GlobalBus.emit("event", {
|
||||||
|
directory: input.directory,
|
||||||
|
project: input.project,
|
||||||
|
workspace: WorkspaceContext.workspaceID,
|
||||||
|
payload: {
|
||||||
|
type: "server.instance.disposed",
|
||||||
|
properties: {
|
||||||
|
directory: input.directory,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) {
|
||||||
|
yield* Effect.logInfo("disposing instance", { directory: ctx.directory })
|
||||||
|
yield* Effect.promise(() => disposeInstance(ctx.directory))
|
||||||
|
yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id })
|
||||||
|
})
|
||||||
|
|
||||||
|
const disposeEntry = Effect.fnUntraced(function* (directory: string, entry: Entry, ctx: InstanceContext) {
|
||||||
|
if (cache.get(directory) !== entry) return false
|
||||||
|
yield* disposeContext(ctx)
|
||||||
|
if (cache.get(directory) !== entry) return false
|
||||||
|
cache.delete(directory)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) {
|
||||||
|
const directory = AppFileSystem.resolve(input.directory)
|
||||||
|
return yield* Effect.uninterruptibleMask((restore) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const existing = cache.get(directory)
|
||||||
|
if (existing) return yield* restore(Deferred.await(existing.deferred))
|
||||||
|
|
||||||
|
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
|
||||||
|
cache.set(directory, entry)
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
yield* Effect.logInfo("creating instance", { directory })
|
||||||
|
yield* completeLoad(directory, input, entry)
|
||||||
|
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||||
|
return yield* restore(Deferred.await(entry.deferred))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) {
|
||||||
|
const directory = AppFileSystem.resolve(input.directory)
|
||||||
|
return yield* Effect.uninterruptibleMask((restore) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const previous = cache.get(directory)
|
||||||
|
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
|
||||||
|
cache.set(directory, entry)
|
||||||
|
yield* Effect.gen(function* () {
|
||||||
|
yield* Effect.logInfo("reloading instance", { directory })
|
||||||
|
if (previous) {
|
||||||
|
yield* Deferred.await(previous.deferred).pipe(Effect.ignore)
|
||||||
|
yield* Effect.promise(() => disposeInstance(directory))
|
||||||
|
yield* emitDisposed({ directory, project: input.project?.id })
|
||||||
|
}
|
||||||
|
yield* completeLoad(directory, input, entry)
|
||||||
|
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||||
|
return yield* restore(Deferred.await(entry.deferred))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) {
|
||||||
|
const entry = cache.get(ctx.directory)
|
||||||
|
if (!entry) return yield* disposeContext(ctx)
|
||||||
|
|
||||||
|
const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit)
|
||||||
|
if (Exit.isFailure(exit)) return yield* removeEntry(ctx.directory, entry).pipe(Effect.asVoid)
|
||||||
|
if (exit.value !== ctx) return
|
||||||
|
yield* disposeEntry(ctx.directory, entry, ctx).pipe(Effect.asVoid)
|
||||||
|
})
|
||||||
|
|
||||||
|
const disposeAllOnce = Effect.fnUntraced(function* () {
|
||||||
|
yield* Effect.logInfo("disposing all instances")
|
||||||
|
yield* Effect.forEach(
|
||||||
|
[...cache.entries()],
|
||||||
|
(item) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit)
|
||||||
|
if (Exit.isFailure(exit)) {
|
||||||
|
yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause })
|
||||||
|
yield* removeEntry(item[0], item[1])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
yield* disposeEntry(item[0], item[1], exit.value)
|
||||||
|
}),
|
||||||
|
{ discard: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const cachedDisposeAll = yield* Effect.cachedWithTTL(disposeAllOnce(), Duration.zero)
|
||||||
|
const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () {
|
||||||
|
return yield* cachedDisposeAll
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
load,
|
||||||
|
reload,
|
||||||
|
dispose,
|
||||||
|
disposeAll,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))
|
||||||
|
|
||||||
|
export const runtime = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
export * as InstanceStore from "./instance-store"
|
||||||
@@ -1,77 +1,20 @@
|
|||||||
import { GlobalBus } from "@/bus/global"
|
|
||||||
import { disposeInstance } from "@/effect/instance-registry"
|
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { iife } from "@/util/iife"
|
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
|
||||||
import { LocalContext } from "@/util/local-context"
|
|
||||||
import * as Project from "./project"
|
import * as Project from "./project"
|
||||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
import { context, type InstanceContext } from "./instance-context"
|
||||||
|
import { InstanceStore } from "./instance-store"
|
||||||
|
|
||||||
export interface InstanceContext {
|
export type { InstanceContext } from "./instance-context"
|
||||||
directory: string
|
export type { LoadInput } from "./instance-store"
|
||||||
worktree: string
|
|
||||||
project: Project.Info
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = LocalContext.create<InstanceContext>("instance")
|
|
||||||
const cache = new Map<string, Promise<InstanceContext>>()
|
|
||||||
const project = makeRuntime(Project.Service, Project.defaultLayer)
|
|
||||||
|
|
||||||
const disposal = {
|
|
||||||
all: undefined as Promise<void> | undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
|
|
||||||
return iife(async () => {
|
|
||||||
const ctx =
|
|
||||||
input.project && input.worktree
|
|
||||||
? {
|
|
||||||
directory: input.directory,
|
|
||||||
worktree: input.worktree,
|
|
||||||
project: input.project,
|
|
||||||
}
|
|
||||||
: await project
|
|
||||||
.runPromise((svc) => svc.fromDirectory(input.directory))
|
|
||||||
.then(({ project, sandbox }) => ({
|
|
||||||
directory: input.directory,
|
|
||||||
worktree: sandbox,
|
|
||||||
project,
|
|
||||||
}))
|
|
||||||
await context.provide(ctx, async () => {
|
|
||||||
await input.init?.()
|
|
||||||
})
|
|
||||||
return ctx
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function track(directory: string, next: Promise<InstanceContext>) {
|
|
||||||
const task = next.catch((error) => {
|
|
||||||
if (cache.get(directory) === task) cache.delete(directory)
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
cache.set(directory, task)
|
|
||||||
return task
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Instance = {
|
export const Instance = {
|
||||||
|
load(input: InstanceStore.LoadInput): Promise<InstanceContext> {
|
||||||
|
return InstanceStore.runtime.runPromise((store) => store.load(input))
|
||||||
|
},
|
||||||
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
|
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
|
||||||
const directory = AppFileSystem.resolve(input.directory)
|
return context.provide(
|
||||||
let existing = cache.get(directory)
|
await Instance.load({ directory: input.directory, init: input.init }),
|
||||||
if (!existing) {
|
async () => input.fn(),
|
||||||
Log.Default.info("creating instance", { directory })
|
)
|
||||||
existing = track(
|
|
||||||
directory,
|
|
||||||
boot({
|
|
||||||
directory,
|
|
||||||
init: input.init,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const ctx = await existing
|
|
||||||
return context.provide(ctx, async () => {
|
|
||||||
return input.fn()
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
get current() {
|
get current() {
|
||||||
return context.use()
|
return context.use()
|
||||||
@@ -117,74 +60,12 @@ export const Instance = {
|
|||||||
return context.provide(ctx, fn)
|
return context.provide(ctx, fn)
|
||||||
},
|
},
|
||||||
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||||
const directory = AppFileSystem.resolve(input.directory)
|
return InstanceStore.runtime.runPromise((store) => store.reload(input))
|
||||||
Log.Default.info("reloading instance", { directory })
|
|
||||||
await disposeInstance(directory)
|
|
||||||
cache.delete(directory)
|
|
||||||
const next = track(directory, boot({ ...input, directory }))
|
|
||||||
|
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory,
|
|
||||||
project: input.project?.id,
|
|
||||||
workspace: WorkspaceContext.workspaceID,
|
|
||||||
payload: {
|
|
||||||
type: "server.instance.disposed",
|
|
||||||
properties: {
|
|
||||||
directory,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return await next
|
|
||||||
},
|
},
|
||||||
async dispose() {
|
async dispose() {
|
||||||
const directory = Instance.directory
|
return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current))
|
||||||
const project = Instance.project
|
|
||||||
Log.Default.info("disposing instance", { directory })
|
|
||||||
await disposeInstance(directory)
|
|
||||||
cache.delete(directory)
|
|
||||||
|
|
||||||
GlobalBus.emit("event", {
|
|
||||||
directory,
|
|
||||||
project: project.id,
|
|
||||||
workspace: WorkspaceContext.workspaceID,
|
|
||||||
payload: {
|
|
||||||
type: "server.instance.disposed",
|
|
||||||
properties: {
|
|
||||||
directory,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
async disposeAll() {
|
async disposeAll() {
|
||||||
if (disposal.all) return disposal.all
|
return InstanceStore.runtime.runPromise((store) => store.disposeAll())
|
||||||
|
|
||||||
disposal.all = iife(async () => {
|
|
||||||
Log.Default.info("disposing all instances")
|
|
||||||
const entries = [...cache.entries()]
|
|
||||||
for (const [key, value] of entries) {
|
|
||||||
if (cache.get(key) !== value) continue
|
|
||||||
|
|
||||||
const ctx = await value.catch((error) => {
|
|
||||||
Log.Default.warn("instance dispose failed", { key, error })
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!ctx) {
|
|
||||||
if (cache.get(key) === value) cache.delete(key)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cache.get(key) !== value) continue
|
|
||||||
|
|
||||||
await context.provide(ctx, async () => {
|
|
||||||
await Instance.dispose()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}).finally(() => {
|
|
||||||
disposal.all = undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
return disposal.all
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
|||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const root = "/experimental/workspace"
|
const root = "/experimental/workspace"
|
||||||
export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"]))
|
export const CreatePayload = Schema.Struct({
|
||||||
|
...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]),
|
||||||
|
extra: Schema.optional(Workspace.CreateInput.fields.extra),
|
||||||
|
})
|
||||||
export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]))
|
export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"]))
|
||||||
export const SessionRestoreResponse = Schema.Struct({
|
export const SessionRestoreResponse = Schema.Struct({
|
||||||
total: NonNegativeInt,
|
total: NonNegativeInt,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
|
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { Instance } from "@/project/instance"
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { Effect, Queue, Schema } from "effect"
|
import { Effect, Queue, Schema } from "effect"
|
||||||
@@ -68,6 +68,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const installation = yield* Installation.Service
|
const installation = yield* Installation.Service
|
||||||
|
const store = yield* InstanceStore.Service
|
||||||
|
|
||||||
const health = Effect.fn("GlobalHttpApi.health")(function* () {
|
const health = Effect.fn("GlobalHttpApi.health")(function* () {
|
||||||
return { healthy: true as const, version: InstallationVersion }
|
return { healthy: true as const, version: InstallationVersion }
|
||||||
@@ -86,7 +87,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
|
|||||||
})
|
})
|
||||||
|
|
||||||
const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
|
const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
|
||||||
yield* Effect.promise(() => Instance.disposeAll())
|
yield* store.disposeAll()
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: "global",
|
directory: "global",
|
||||||
payload: { type: "global.disposed", properties: {} },
|
payload: { type: "global.disposed", properties: {} },
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac
|
|||||||
return yield* workspace
|
return yield* workspace
|
||||||
.create({
|
.create({
|
||||||
...ctx.payload,
|
...ctx.payload,
|
||||||
|
extra: ctx.payload.extra ?? null,
|
||||||
projectID: instance.project.id,
|
projectID: instance.project.id,
|
||||||
})
|
})
|
||||||
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { WorkspaceID } from "@/control-plane/schema"
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
import type { InstanceContext } from "@/project/instance"
|
||||||
import { WorkspaceRef } from "@/effect/instance-ref"
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
import { Instance, type InstanceContext } from "@/project/instance"
|
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
|
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
|
||||||
|
|
||||||
type MarkedInstance = {
|
type MarkedInstance = {
|
||||||
ctx: InstanceContext
|
ctx: InstanceContext
|
||||||
workspaceID?: WorkspaceID
|
store: InstanceStore.Interface
|
||||||
|
bridge: EffectBridge.Shape
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disposal is requested by an endpoint handler, but must run from the outer
|
// Disposal is requested by an endpoint handler, but must run from the outer
|
||||||
@@ -17,20 +17,9 @@ const disposeAfterResponse = new WeakMap<object, MarkedInstance>()
|
|||||||
|
|
||||||
const mark = (ctx: InstanceContext) =>
|
const mark = (ctx: InstanceContext) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return { ctx, workspaceID: yield* WorkspaceRef }
|
return { ctx, store: yield* InstanceStore.Service, bridge: yield* EffectBridge.make() }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Instance.dispose/reload still publish events through legacy ALS helpers.
|
|
||||||
// Effect request handlers carry these values in services, so bridge them back
|
|
||||||
// into the legacy contexts only around the lifecycle operation.
|
|
||||||
const restoreMarked = <A>(marked: MarkedInstance, fn: () => A) =>
|
|
||||||
Effect.promise(() =>
|
|
||||||
WorkspaceContext.provide({
|
|
||||||
workspaceID: marked.workspaceID,
|
|
||||||
fn: () => Instance.restore(marked.ctx, fn),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const markInstanceForDisposal = (ctx: InstanceContext) =>
|
export const markInstanceForDisposal = (ctx: InstanceContext) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const marked = yield* mark(ctx)
|
const marked = yield* mark(ctx)
|
||||||
@@ -43,11 +32,11 @@ export const markInstanceForDisposal = (ctx: InstanceContext) =>
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
|
export const markInstanceForReload = (ctx: InstanceContext, next: InstanceStore.LoadInput) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const marked = yield* mark(ctx)
|
const marked = yield* mark(ctx)
|
||||||
return yield* HttpEffect.appendPreResponseHandler((_request, response) =>
|
return yield* HttpEffect.appendPreResponseHandler((_request, response) =>
|
||||||
Effect.as(Effect.uninterruptible(restoreMarked(marked, () => Instance.reload(next))), response),
|
Effect.as(Effect.uninterruptible(marked.bridge.run(marked.store.reload(next))), response),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -58,6 +47,6 @@ export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
|
|||||||
const marked = disposeAfterResponse.get(request.source)
|
const marked = disposeAfterResponse.get(request.source)
|
||||||
if (!marked) return response
|
if (!marked) return response
|
||||||
disposeAfterResponse.delete(request.source)
|
disposeAfterResponse.delete(request.source)
|
||||||
yield* Effect.uninterruptible(restoreMarked(marked, () => Instance.dispose()))
|
yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx)))
|
||||||
return response
|
return response
|
||||||
})
|
})
|
||||||
|
|||||||
+22
-14
@@ -1,9 +1,8 @@
|
|||||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
import { Instance } from "@/project/instance"
|
|
||||||
import type { InstanceContext } from "@/project/instance"
|
import type { InstanceContext } from "@/project/instance"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||||
@@ -24,22 +23,23 @@ function decode(input: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeInstanceContext(directory: string): Effect.Effect<InstanceContext> {
|
function makeInstanceContext(
|
||||||
return Effect.promise(() =>
|
store: InstanceStore.Interface,
|
||||||
Instance.provide({
|
directory: string,
|
||||||
directory: Filesystem.resolve(decode(directory)),
|
): Effect.Effect<InstanceContext> {
|
||||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
return store.load({
|
||||||
fn: () => Instance.current,
|
directory: decode(directory),
|
||||||
}),
|
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function provideInstanceContext<E>(
|
function provideInstanceContext<E>(
|
||||||
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E>,
|
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E>,
|
||||||
|
store: InstanceStore.Interface,
|
||||||
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
|
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const route = yield* WorkspaceRouteContext
|
const route = yield* WorkspaceRouteContext
|
||||||
const ctx = yield* makeInstanceContext(route.directory)
|
const ctx = yield* makeInstanceContext(store, route.directory)
|
||||||
return yield* effect.pipe(
|
return yield* effect.pipe(
|
||||||
Effect.provideService(InstanceRef, ctx),
|
Effect.provideService(InstanceRef, ctx),
|
||||||
Effect.provideService(WorkspaceRef, route.workspaceID),
|
Effect.provideService(WorkspaceRef, route.workspaceID),
|
||||||
@@ -47,9 +47,17 @@ function provideInstanceContext<E>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const instanceContextLayer = Layer.succeed(
|
export const instanceContextLayer = Layer.effect(
|
||||||
InstanceContextMiddleware,
|
InstanceContextMiddleware,
|
||||||
InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)),
|
Effect.gen(function* () {
|
||||||
|
const store = yield* InstanceStore.Service
|
||||||
|
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store))
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const instanceRouterMiddleware = HttpRouter.middleware()((effect) => provideInstanceContext(effect))
|
export const instanceRouterMiddleware = HttpRouter.middleware()(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const store = yield* InstanceStore.Service
|
||||||
|
return (effect) => provideInstanceContext(effect, store)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { LSP } from "@/lsp/lsp"
|
|||||||
import { MCP } from "@/mcp"
|
import { MCP } from "@/mcp"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
import { Project } from "@/project/project"
|
import { Project } from "@/project/project"
|
||||||
import { ProviderAuth } from "@/provider/auth"
|
import { ProviderAuth } from "@/provider/auth"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
@@ -145,6 +146,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
|
|||||||
Format.defaultLayer,
|
Format.defaultLayer,
|
||||||
LSP.defaultLayer,
|
LSP.defaultLayer,
|
||||||
Installation.defaultLayer,
|
Installation.defaultLayer,
|
||||||
|
InstanceStore.defaultLayer,
|
||||||
MCP.defaultLayer,
|
MCP.defaultLayer,
|
||||||
Permission.defaultLayer,
|
Permission.defaultLayer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { afterEach, describe, expect } from "bun:test"
|
||||||
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
|
import { Effect, Fiber, Layer } from "effect"
|
||||||
|
import { registerDisposer } from "../../src/effect/instance-registry"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { InstanceStore } from "../../src/project/instance-store"
|
||||||
|
import { tmpdirScoped } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Instance.disposeAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("InstanceStore", () => {
|
||||||
|
it.live("loads instance context without installing ALS for the caller", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const dir = yield* tmpdirScoped({ git: true })
|
||||||
|
const store = yield* InstanceStore.Service
|
||||||
|
const ctx = yield* store.load({ directory: dir })
|
||||||
|
|
||||||
|
expect(ctx.directory).toBe(dir)
|
||||||
|
expect(ctx.worktree).toBe(dir)
|
||||||
|
expect(() => Instance.current).toThrow()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("runs load init inside the loaded legacy instance context", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const dir = yield* tmpdirScoped({ git: true })
|
||||||
|
const store = yield* InstanceStore.Service
|
||||||
|
let initializedDirectory: string | undefined
|
||||||
|
|
||||||
|
yield* store.load({
|
||||||
|
directory: dir,
|
||||||
|
init: async () => {
|
||||||
|
initializedDirectory = Instance.directory
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(initializedDirectory).toBe(dir)
|
||||||
|
expect(() => Instance.current).toThrow()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("caches loaded instance context by directory", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const dir = yield* tmpdirScoped({ git: true })
|
||||||
|
const store = yield* InstanceStore.Service
|
||||||
|
let initialized = 0
|
||||||
|
|
||||||
|
const first = yield* store.load({
|
||||||
|
directory: dir,
|
||||||
|
init: async () => {
|
||||||
|
initialized++
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const second = yield* store.load({
|
||||||
|
directory: dir,
|
||||||
|
init: async () => {
|
||||||
|
initialized++
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(second).toBe(first)
|
||||||
|
expect(initialized).toBe(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("dedupes concurrent loads while init is in flight", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const dir = yield* tmpdirScoped({ git: true })
|
||||||
|
const store = yield* InstanceStore.Service
|
||||||
|
const started = Promise.withResolvers<void>()
|
||||||
|
const release = Promise.withResolvers<void>()
|
||||||
|
let initialized = 0
|
||||||
|
|
||||||
|
const first = yield* store
|
||||||
|
.load({
|
||||||
|
directory: dir,
|
||||||
|
init: async () => {
|
||||||
|
initialized++
|
||||||
|
started.resolve()
|
||||||
|
await release.promise
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* Effect.promise(() => started.promise)
|
||||||
|
|
||||||
|
const second = yield* store
|
||||||
|
.load({
|
||||||
|
directory: dir,
|
||||||
|
init: async () => {
|
||||||
|
initialized++
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
expect(initialized).toBe(1)
|
||||||
|
release.resolve()
|
||||||
|
|
||||||
|
const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)])
|
||||||
|
expect(secondCtx).toBe(firstCtx)
|
||||||
|
expect(initialized).toBe(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("removes failed loads from the cache", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const dir = yield* tmpdirScoped({ git: true })
|
||||||
|
const store = yield* InstanceStore.Service
|
||||||
|
let attempts = 0
|
||||||
|
|
||||||
|
const failed = yield* store
|
||||||
|
.load({
|
||||||
|
directory: dir,
|
||||||
|
init: async () => {
|
||||||
|
attempts++
|
||||||
|
throw new Error("init failed")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.pipe(
|
||||||
|
Effect.as(false),
|
||||||
|
Effect.catchCause(() => Effect.succeed(true)),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(failed).toBe(true)
|
||||||
|
|
||||||
|
const ctx = yield* store.load({
|
||||||
|
directory: dir,
|
||||||
|
init: async () => {
|
||||||
|
attempts++
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(ctx.directory).toBe(dir)
|
||||||
|
expect(attempts).toBe(2)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("reload replaces the cached context", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const dir = yield* tmpdirScoped({ git: true })
|
||||||
|
const store = yield* InstanceStore.Service
|
||||||
|
|
||||||
|
const first = yield* store.load({ directory: dir })
|
||||||
|
const second = yield* store.reload({ directory: dir })
|
||||||
|
const cached = yield* store.load({ directory: dir })
|
||||||
|
|
||||||
|
expect(second).not.toBe(first)
|
||||||
|
expect(cached).toBe(second)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("stale dispose does not delete an in-flight reload", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const dir = yield* tmpdirScoped({ git: true })
|
||||||
|
const store = yield* InstanceStore.Service
|
||||||
|
const reloading = Promise.withResolvers<void>()
|
||||||
|
const releaseReload = Promise.withResolvers<void>()
|
||||||
|
const disposed: Array<string> = []
|
||||||
|
const off = registerDisposer(async (directory) => {
|
||||||
|
disposed.push(directory)
|
||||||
|
})
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||||
|
|
||||||
|
const first = yield* store.load({ directory: dir })
|
||||||
|
const reload = yield* store
|
||||||
|
.reload({
|
||||||
|
directory: dir,
|
||||||
|
init: async () => {
|
||||||
|
reloading.resolve()
|
||||||
|
await releaseReload.promise
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* Effect.promise(() => reloading.promise)
|
||||||
|
const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped)
|
||||||
|
releaseReload.resolve()
|
||||||
|
|
||||||
|
const second = yield* Fiber.join(reload)
|
||||||
|
yield* Fiber.join(staleDispose)
|
||||||
|
|
||||||
|
expect(disposed).toEqual([dir])
|
||||||
|
expect(yield* store.load({ directory: dir })).toBe(second)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("dedupes concurrent disposeAll calls", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const dir = yield* tmpdirScoped({ git: true })
|
||||||
|
const store = yield* InstanceStore.Service
|
||||||
|
const disposing = Promise.withResolvers<void>()
|
||||||
|
const releaseDispose = Promise.withResolvers<void>()
|
||||||
|
const disposed: Array<string> = []
|
||||||
|
const off = registerDisposer(async (directory) => {
|
||||||
|
disposed.push(directory)
|
||||||
|
disposing.resolve()
|
||||||
|
await releaseDispose.promise
|
||||||
|
})
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||||
|
|
||||||
|
yield* store.load({ directory: dir })
|
||||||
|
const first = yield* store.disposeAll().pipe(Effect.forkScoped)
|
||||||
|
yield* Effect.promise(() => disposing.promise)
|
||||||
|
const second = yield* store.disposeAll().pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
expect(disposed).toEqual([dir])
|
||||||
|
releaseDispose.resolve()
|
||||||
|
yield* Effect.all([Fiber.join(first), Fiber.join(second)])
|
||||||
|
expect(disposed).toEqual([dir])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("re-arms disposeAll after completion", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const dir1 = yield* tmpdirScoped({ git: true })
|
||||||
|
const dir2 = yield* tmpdirScoped({ git: true })
|
||||||
|
const store = yield* InstanceStore.Service
|
||||||
|
const disposed: Array<string> = []
|
||||||
|
const off = registerDisposer(async (directory) => {
|
||||||
|
disposed.push(directory)
|
||||||
|
})
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||||
|
|
||||||
|
yield* store.load({ directory: dir1 })
|
||||||
|
yield* store.disposeAll()
|
||||||
|
expect(disposed).toEqual([dir1])
|
||||||
|
|
||||||
|
yield* store.load({ directory: dir2 })
|
||||||
|
yield* store.disposeAll()
|
||||||
|
expect(disposed).toEqual([dir1, dir2])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("keeps Instance.provide as the legacy ALS wrapper", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const dir = yield* tmpdirScoped({ git: true })
|
||||||
|
|
||||||
|
const directory = yield* Effect.promise(() =>
|
||||||
|
Instance.provide({
|
||||||
|
directory: dir,
|
||||||
|
fn: () => Instance.directory,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(directory).toBe(dir)
|
||||||
|
expect(() => Instance.current).toThrow()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -12,6 +12,7 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
|||||||
import { Workspace } from "../../src/control-plane/workspace"
|
import { Workspace } from "../../src/control-plane/workspace"
|
||||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { InstanceStore } from "../../src/project/instance-store"
|
||||||
import { Project } from "../../src/project/project"
|
import { Project } from "../../src/project/project"
|
||||||
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
|
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
|
||||||
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||||
@@ -40,6 +41,7 @@ const it = testEffect(
|
|||||||
testStateLayer,
|
testStateLayer,
|
||||||
NodeHttpServer.layerTest,
|
NodeHttpServer.layerTest,
|
||||||
NodeServices.layer,
|
NodeServices.layer,
|
||||||
|
InstanceStore.defaultLayer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
Workspace.defaultLayer,
|
Workspace.defaultLayer,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ const it = testEffect(
|
|||||||
Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer),
|
Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
function request(path: string, directory: string, init: RequestInit = {}) {
|
function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) {
|
||||||
return Effect.promise(() => {
|
return Effect.promise(() => {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpApi
|
||||||
const headers = new Headers(init.headers)
|
const headers = new Headers(init.headers)
|
||||||
headers.set("x-opencode-directory", directory)
|
headers.set("x-opencode-directory", directory)
|
||||||
return Promise.resolve(Server.Default().app.request(path, { ...init, headers }))
|
return Promise.resolve(Server.Default().app.request(path, { ...init, headers }))
|
||||||
@@ -195,6 +195,55 @@ describe("workspace HttpApi", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("creates workspace with the TUI payload shape", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||||
|
const dir = yield* tmpdirScoped({ git: true })
|
||||||
|
const project = yield* Project.use.fromDirectory(dir)
|
||||||
|
registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
|
||||||
|
|
||||||
|
const created = yield* request(WorkspacePaths.list, dir, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ type: "local-test", branch: null }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(created.status).toBe(200)
|
||||||
|
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
|
||||||
|
type: "local-test",
|
||||||
|
name: "local-test",
|
||||||
|
extra: null,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("documents legacy Hono accepting the TUI payload shape", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||||
|
const dir = yield* tmpdirScoped({ git: true })
|
||||||
|
const project = yield* Project.use.fromDirectory(dir)
|
||||||
|
registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
|
||||||
|
|
||||||
|
const created = yield* request(
|
||||||
|
WorkspacePaths.list,
|
||||||
|
dir,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ type: "local-test", branch: null }),
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(created.status).toBe(200)
|
||||||
|
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
|
||||||
|
type: "local-test",
|
||||||
|
name: "local-test",
|
||||||
|
extra: null,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("routes local workspace requests through the workspace target directory", () =>
|
it.live("routes local workspace requests through the workspace target directory", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||||
|
|||||||
Reference in New Issue
Block a user