mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 03:18:31 +00:00
chore(observability): merge v2
This commit is contained in:
commit
18da3deee7
16 changed files with 489 additions and 697 deletions
|
|
@ -5,29 +5,22 @@ import { Catalog } from "./catalog"
|
|||
import { CommandV2 } from "./command"
|
||||
import { Config } from "./config"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeLocationNode, Node } from "./effect/app-node"
|
||||
import { httpClient } from "./effect/app-node-platform"
|
||||
import { Node } from "./effect/app-node"
|
||||
import { EventV2 } from "./event"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Generate } from "./generate"
|
||||
import { Form } from "./form"
|
||||
import { Global } from "./global"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher"
|
||||
import { Image } from "./image"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher"
|
||||
import { Integration } from "./integration"
|
||||
import { Location } from "./location"
|
||||
import { LocationMutation } from "./location-mutation"
|
||||
import { LocationServiceMap } from "./location-service-map"
|
||||
import { MCP } from "./mcp/index"
|
||||
import { ModelsDev } from "./models-dev"
|
||||
import { Npm } from "./npm"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { PluginRuntime } from "./plugin/runtime"
|
||||
import { SdkPlugins } from "./plugin/sdk"
|
||||
import { PluginSupervisor } from "./plugin/supervisor"
|
||||
import { ProjectCopy } from "./project/copy"
|
||||
import { Pty } from "./pty"
|
||||
|
|
@ -35,7 +28,6 @@ import { QuestionV2 } from "./question"
|
|||
import { Shell } from "./shell"
|
||||
import { Reference } from "./reference"
|
||||
import { ReferenceGuidance } from "./reference/guidance"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SessionCompaction } from "./session/compaction"
|
||||
|
|
@ -51,49 +43,11 @@ import { SessionInstructions } from "./session/instructions"
|
|||
import { McpTool } from "./tool/mcp"
|
||||
import { ReadToolFileSystem } from "./tool/read-filesystem"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { WebSearchTool } from "./tool/websearch"
|
||||
import { ToolOutputStore } from "./tool-output-store"
|
||||
import { Vcs } from "./vcs"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map"
|
||||
|
||||
const pluginSupervisorNode = makeLocationNode({
|
||||
service: PluginSupervisor.Service,
|
||||
layer: PluginSupervisor.layer,
|
||||
deps: [
|
||||
PluginV2.node,
|
||||
SdkPlugins.node,
|
||||
AgentV2.node,
|
||||
Catalog.node,
|
||||
CommandV2.node,
|
||||
Config.node,
|
||||
EventV2.node,
|
||||
FileMutation.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
Image.node,
|
||||
Integration.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
PermissionV2.node,
|
||||
PluginRuntime.node,
|
||||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
SessionTodo.node,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
WebSearchTool.configNode,
|
||||
],
|
||||
})
|
||||
|
||||
const locationServiceNodes = [
|
||||
Location.node,
|
||||
Config.node,
|
||||
|
|
@ -104,7 +58,7 @@ const locationServiceNodes = [
|
|||
Catalog.node,
|
||||
AISDK.node,
|
||||
PluginV2.node,
|
||||
pluginSupervisorNode,
|
||||
PluginSupervisor.node,
|
||||
ProjectCopy.node,
|
||||
ProjectCopy.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
|
|
|
|||
|
|
@ -2,18 +2,42 @@ export * as PluginSupervisor from "./supervisor"
|
|||
|
||||
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { Context, Deferred, Effect, Layer, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { CommandV2 } from "../command"
|
||||
import { Config } from "../config"
|
||||
import { ConfigPlugin } from "../config/plugin"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { httpClient } from "../effect/app-node-platform"
|
||||
import { EventV2 } from "../event"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Form } from "../form"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
import { Image } from "../image"
|
||||
import { Integration } from "../integration"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "../npm"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { PluginPromise } from "../plugin/promise"
|
||||
import { Reference } from "../reference"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { SessionInstructions } from "../session/instructions"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
import { Shell } from "../shell"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { ReadToolFileSystem } from "../tool/read-filesystem"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { WebSearchTool } from "../tool/websearch"
|
||||
import { PluginInternal } from "./internal"
|
||||
import { PluginRuntime } from "./runtime"
|
||||
import { SdkPlugins } from "./sdk"
|
||||
|
||||
const PluginModule = Schema.Struct({
|
||||
|
|
@ -246,7 +270,8 @@ const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interfa
|
|||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly ready: Effect.Effect<void>
|
||||
/** Wait for the initial plugin generation and startup updates to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
|
||||
|
|
@ -259,35 +284,96 @@ const layer = Layer.effect(
|
|||
const config = yield* Config.Service
|
||||
const events = yield* EventV2.Service
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const reload = Effect.fn("PluginSupervisor.reload")(() =>
|
||||
lock.withPermit(
|
||||
const ready = yield* Deferred.make<void>()
|
||||
let observed = 0
|
||||
let applied = -1
|
||||
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* (target: number) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (applied >= target) return
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
const internal = yield* PluginInternal.list()
|
||||
// Combine internal plugins with host-contributed SDK plugins in boot order.
|
||||
const pre = [...internal.pre, ...sdk.all()]
|
||||
// Read the current layered config before resolving plugin directives and packages.
|
||||
const entries = yield* config.entries()
|
||||
const operations = yield* scan(entries)
|
||||
const operations = yield* scan(yield* config.entries())
|
||||
// Apply config operations and load enabled package plugins into one ordered generation.
|
||||
const plugins = yield* resolve(pre, internal.post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(plugins)
|
||||
applied = target
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* events.subscribe([Event.Updated, SdkPlugins.Updated]).pipe(
|
||||
Stream.runForEach(() =>
|
||||
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
||||
)
|
||||
})
|
||||
const updates = yield* events
|
||||
.subscribe([Event.Updated, SdkPlugins.Updated])
|
||||
.pipe(Stream.toQueue({ capacity: 1, strategy: "sliding" }))
|
||||
const signals = yield* Stream.concat(
|
||||
Stream.succeed(0),
|
||||
Stream.fromQueue(updates).pipe(Stream.mapEffect(() => Effect.sync(() => ++observed))),
|
||||
).pipe(Stream.broadcast({ capacity: 1, strategy: "sliding", replay: 1 }))
|
||||
const attempt = (target: number) =>
|
||||
activate(target).pipe(
|
||||
Effect.map(() => observed === target),
|
||||
Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }).pipe(Effect.as(false))),
|
||||
)
|
||||
|
||||
yield* signals.pipe(
|
||||
Stream.runForEach((target) =>
|
||||
activate(target).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const fiber = yield* reload().pipe(
|
||||
Effect.withSpan("PluginSupervisor.boot"),
|
||||
yield* signals.pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.mapEffect(attempt),
|
||||
Stream.filter((settled) => settled),
|
||||
Stream.take(1),
|
||||
Stream.runDrain,
|
||||
Effect.andThen(Deferred.succeed(ready, undefined)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ ready: Fiber.join(fiber) })
|
||||
return Service.of({ flush: Deferred.await(ready) })
|
||||
}),
|
||||
)
|
||||
|
||||
const nodeLayer = layer as Layer.Layer<Service, never, PluginInternal.Requirements>
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: nodeLayer,
|
||||
deps: [
|
||||
PluginV2.node,
|
||||
SdkPlugins.node,
|
||||
AgentV2.node,
|
||||
Catalog.node,
|
||||
CommandV2.node,
|
||||
Config.node,
|
||||
EventV2.node,
|
||||
FileMutation.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
httpClient,
|
||||
Image.node,
|
||||
Integration.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Npm.node,
|
||||
PermissionV2.node,
|
||||
PluginRuntime.node,
|
||||
Form.node,
|
||||
ReadToolFileSystem.node,
|
||||
Reference.node,
|
||||
Ripgrep.node,
|
||||
SessionInstructions.node,
|
||||
SessionTodo.node,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
ToolRegistry.toolsNode,
|
||||
WebSearchTool.configNode,
|
||||
],
|
||||
})
|
||||
|
||||
export { layer }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { SessionMessage } from "./message"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
|
|
@ -12,6 +13,15 @@ export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeErr
|
|||
}
|
||||
}
|
||||
|
||||
export class AgentNotFoundError extends Schema.TaggedErrorClass<AgentNotFoundError>()("Session.AgentNotFoundError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
agent: Agent.ID,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Agent not found: "${this.agent}"`
|
||||
}
|
||||
}
|
||||
|
||||
export class StepFailedError extends Schema.TaggedErrorClass<StepFailedError>()("Session.StepFailedError", {
|
||||
error: SessionError.Error,
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export * as SessionRunner from "./index"
|
|||
import type { LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect } from "effect"
|
||||
import { SessionSchema } from "../schema"
|
||||
import type { MessageDecodeError, StepFailedError, UserInterruptedError } from "../error"
|
||||
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { Instructions } from "../../instructions/index"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
|
|
@ -12,6 +12,7 @@ export type RunError =
|
|||
| LLMError
|
||||
| SessionRunnerModel.Error
|
||||
| MessageDecodeError
|
||||
| AgentNotFoundError
|
||||
| StepFailedError
|
||||
| UserInterruptedError
|
||||
| Instructions.InitializationBlocked
|
||||
|
|
|
|||
|
|
@ -48,12 +48,13 @@ import { SessionRunnerSystemPrompt } from "./system-prompt"
|
|||
import { Snapshot } from "../../snapshot"
|
||||
import { makeLocationNode } from "../../effect/app-node"
|
||||
import { llmClient } from "../../effect/app-node-platform"
|
||||
import { StepFailedError, UserInterruptedError } from "../error"
|
||||
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "../error"
|
||||
import { toSessionError } from "../to-session-error"
|
||||
import { SessionRunnerRetry } from "./retry"
|
||||
import { AgentTelemetry } from "../../observability/agent"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
|
||||
import { PluginHooks } from "../../plugin/hooks"
|
||||
import { PluginSupervisor } from "../../plugin/supervisor"
|
||||
|
||||
type StepTokens = {
|
||||
readonly input: number
|
||||
|
|
@ -150,6 +151,7 @@ const layer = Layer.effect(
|
|||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
// Title generation is a side effect of the first step; it must not delay step continuation.
|
||||
// Tracked per process so repeated wakes before the second user message arrives don't
|
||||
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
||||
|
|
@ -212,17 +214,21 @@ const layer = Layer.effect(
|
|||
const session = yield* AgentTelemetry.stage("session", getSession(sessionID))
|
||||
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
|
||||
return yield* Effect.interrupt
|
||||
yield* AgentTelemetry.stage("plugin_readiness", plugins.flush)
|
||||
const agent = yield* AgentTelemetry.stage("agent", agents.select(session.agent))
|
||||
yield* AgentTelemetry.identify({ agent: agent.id, parentSessionID: session.parentID })
|
||||
if (!agent.info)
|
||||
return yield* AgentTelemetry.stage(
|
||||
"agent",
|
||||
new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id }),
|
||||
)
|
||||
const agentInfo = agent.info
|
||||
// Establish what the model knows before admitting what the user said, so
|
||||
// a blocked first step leaves pending inputs untouched.
|
||||
const checkpoint = yield* AgentTelemetry.stage(
|
||||
"instructions",
|
||||
InstructionCheckpoint.prepare(db, events, loadInstructions(agent, session.id), session.id),
|
||||
)
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error | UserInterruptedError>()
|
||||
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error | UserInterruptedError>> = []
|
||||
let needsContinuation = false
|
||||
let currentStep = step
|
||||
let currentTrigger = trigger
|
||||
let inputDelivery: SessionInput.Delivery | undefined
|
||||
|
|
@ -248,21 +254,18 @@ const layer = Layer.effect(
|
|||
SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq),
|
||||
)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||
const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps
|
||||
const toolMaterialization = isLastStep
|
||||
? undefined
|
||||
: yield* AgentTelemetry.stage(
|
||||
"tool_materialization",
|
||||
tools.materialize({ permissions: agent.info?.permissions, model }),
|
||||
tools.materialize({ permissions: agentInfo.permissions, model }),
|
||||
)
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const request = LLM.request({
|
||||
model,
|
||||
providerOptions: { openai: { promptCacheKey } },
|
||||
system: [
|
||||
agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model),
|
||||
checkpoint.baseline,
|
||||
]
|
||||
system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), checkpoint.baseline]
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: [
|
||||
|
|
@ -272,6 +275,9 @@ const layer = Layer.effect(
|
|||
tools: toolMaterialization?.definitions ?? [],
|
||||
toolChoice: isLastStep ? "none" : undefined,
|
||||
})
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error | UserInterruptedError>()
|
||||
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error | UserInterruptedError>> = []
|
||||
let needsContinuation = false
|
||||
const availableTools = new Map(request.tools.map((tool) => [tool.name, tool]))
|
||||
const requestEvent: SessionHooks["request"] = {
|
||||
sessionID: session.id,
|
||||
|
|
@ -477,7 +483,7 @@ const layer = Layer.effect(
|
|||
const error = toSessionError(llmFailure)
|
||||
const retryable = SessionRunnerRetry.isRetryable(llmFailure)
|
||||
const hasOutput = publisher.hasRetryEvidence()
|
||||
const stepLimited = agent.info?.steps !== undefined && currentStep >= agent.info.steps
|
||||
const stepLimited = agentInfo.steps !== undefined && currentStep >= agentInfo.steps
|
||||
if (retryable && !hasOutput && !stepLimited) {
|
||||
return yield* new SessionRunnerRetry.RetryableFailure({
|
||||
cause: llmFailure,
|
||||
|
|
@ -801,5 +807,6 @@ export const node = makeLocationNode({
|
|||
Config.node,
|
||||
Snapshot.node,
|
||||
Database.node,
|
||||
PluginSupervisor.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { PermissionV2 } from "../permission"
|
|||
import { QuestionV2 } from "../question"
|
||||
import { Integration } from "../integration"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { StepFailedError, UserInterruptedError } from "./error"
|
||||
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
|
||||
export function toSessionError(cause: unknown): SessionError.Error {
|
||||
|
|
@ -41,6 +41,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
|||
if (cause instanceof ToolFailure)
|
||||
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
|
||||
if (cause instanceof StepFailedError) return cause.error
|
||||
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
|
||||
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
|
||||
if (
|
||||
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ describe("PluginSupervisor config", () => {
|
|||
|
||||
const ready = Effect.fnUntraced(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.ready
|
||||
yield* supervisor.flush
|
||||
})
|
||||
|
||||
function withLocation<A, E, R>(
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
|
|||
import { Config } from "@opencode-ai/schema/config"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect"
|
||||
import { Context, DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
|
|
@ -54,7 +54,7 @@ describe("LocationServiceMap", () => {
|
|||
const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
|
||||
const read = Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.ready
|
||||
yield* supervisor.flush
|
||||
const agents = yield* AgentV2.Service
|
||||
return yield* agents.get(id)
|
||||
})
|
||||
|
|
@ -67,6 +67,268 @@ describe("LocationServiceMap", () => {
|
|||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("waits for explorer activation to complete", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(
|
||||
EffectPlugin.define({
|
||||
id: "blocked-initial-activation",
|
||||
effect: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))),
|
||||
}),
|
||||
)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(started)
|
||||
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild,
|
||||
)
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(flushFiber)
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.timeout("1 second"),
|
||||
)
|
||||
|
||||
const explorer = yield* Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
return yield* agents.resolve("explore")
|
||||
}).pipe(Effect.provide(context))
|
||||
|
||||
expect(explorer).toBeDefined()
|
||||
expect(explorer?.permissions.length).toBeGreaterThan(0)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("reruns activation for SDK plugins registered during startup", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
const releaseSecond = yield* Deferred.make<void>()
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(
|
||||
EffectPlugin.define({
|
||||
id: "fixed-target-first-plugin",
|
||||
effect: () =>
|
||||
Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
|
||||
}),
|
||||
)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(firstStarted)
|
||||
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* sdk.register(
|
||||
EffectPlugin.define({
|
||||
id: "fixed-target-second-plugin",
|
||||
effect: () =>
|
||||
Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
|
||||
yield* Deferred.succeed(releaseSecond, undefined)
|
||||
yield* Fiber.join(flushFiber)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("reruns activation for Config updates during startup", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const activations = { count: 0 }
|
||||
const file = path.join(dir.path, "opencode.json")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "{}"))
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
const releaseSecond = yield* Deferred.make<void>()
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(
|
||||
EffectPlugin.define({
|
||||
id: "blocked-config-reload",
|
||||
effect: () =>
|
||||
Effect.sync(() => ++activations.count).pipe(
|
||||
Effect.flatMap((activation) =>
|
||||
activation === 1
|
||||
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst)))
|
||||
: Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(firstStarted)
|
||||
|
||||
const events = yield* EventV2.Service
|
||||
const updated = yield* events.subscribe(Config.Event.Updated).pipe(
|
||||
Stream.filter((event) => event.location?.directory === dir.path),
|
||||
Stream.runHead,
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
file,
|
||||
JSON.stringify({ plugins: [path.join(import.meta.dir, "plugin/fixtures/config-effect-plugin.ts")] }),
|
||||
),
|
||||
)
|
||||
yield* Fiber.join(updated)
|
||||
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild,
|
||||
)
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
yield* Deferred.await(secondStarted)
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(releaseSecond, undefined)
|
||||
yield* Fiber.join(flushFiber)
|
||||
expect(activations.count).toBe(2)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("keeps flush pending while startup updates continue", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
yield* Effect.forEach(
|
||||
Array.from({ length: 5 }),
|
||||
() => events.publish(SdkPlugins.Updated, {}).pipe(Effect.andThen(Effect.sleep("50 millis"))),
|
||||
{ discard: true },
|
||||
)
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
yield* Fiber.join(flushFiber)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("keeps flush open while later hot reload runs", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
|
||||
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const completed = yield* Deferred.make<void>()
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(
|
||||
EffectPlugin.define({
|
||||
id: "post-ready-plugin",
|
||||
effect: () =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.andThen(Deferred.succeed(completed, undefined)),
|
||||
),
|
||||
}),
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.timeout("1 second"),
|
||||
)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Deferred.await(completed)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
itWithSdk.live("does not cancel activation when a flush waiter is interrupted", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const completed = yield* Deferred.make<void>()
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(
|
||||
EffectPlugin.define({
|
||||
id: "interrupted-waiter-plugin",
|
||||
effect: () =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(release)),
|
||||
Effect.andThen(Deferred.succeed(completed, undefined)),
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
|
||||
yield* Deferred.await(started)
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Fiber.interrupt(flushFiber)
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Deferred.await(completed)
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.timeout("500 millis"),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("applies ordered plugin config operations during boot", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
@ -79,7 +341,7 @@ describe("LocationServiceMap", () => {
|
|||
)
|
||||
const plugins = yield* Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
yield* (yield* PluginSupervisor.Service).ready
|
||||
yield* (yield* PluginSupervisor.Service).flush
|
||||
return yield* plugins.list()
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
|
|
@ -106,7 +368,7 @@ describe("LocationServiceMap", () => {
|
|||
yield* Effect.gen(function* () {
|
||||
const registry = yield* PluginV2.Service
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.ready
|
||||
yield* supervisor.flush
|
||||
expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
|||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
||||
import { SessionTelemetry } from "@opencode-ai/core/observability/session"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer, References, Tracer } from "effect"
|
||||
|
|
@ -83,6 +84,7 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.suc
|
|||
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
|
||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
|
|
@ -96,6 +98,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
|||
[Config.node, config],
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
])
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
|
|
@ -162,6 +165,7 @@ const it = testEffect(
|
|||
[ReferenceGuidance.node, referenceGuidance],
|
||||
[Config.node, config],
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
[SessionExecution.node, execution],
|
||||
],
|
||||
),
|
||||
|
|
@ -172,6 +176,12 @@ describe("SessionRunnerLLM recorded", () => {
|
|||
it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () =>
|
||||
Effect.gen(function* () {
|
||||
spans.length = 0
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(AgentV2.ID.make("build"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
}),
|
||||
)
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
|||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { QuestionTool } from "@opencode-ai/core/tool/question"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
|
|
@ -344,6 +345,13 @@ const config = Layer.succeed(
|
|||
]),
|
||||
}),
|
||||
)
|
||||
let pluginFlushHook = Effect.void
|
||||
const pluginSupervisor = Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
PluginSupervisor.Service.of({
|
||||
flush: Effect.suspend(() => pluginFlushHook),
|
||||
}),
|
||||
)
|
||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
|
|
@ -357,6 +365,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
|||
[Config.node, config],
|
||||
[McpGuidance.node, mcpGuidance],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
])
|
||||
const spans: Tracer.NativeSpan[] = []
|
||||
const tracer = Tracer.make({
|
||||
|
|
@ -429,6 +438,7 @@ const it = testEffect(
|
|||
[SessionExecution.node, execution],
|
||||
[Config.node, config],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
|
@ -466,6 +476,7 @@ const setup = Effect.gen(function* () {
|
|||
systemUnavailable = false
|
||||
systemLoadHook = Effect.void
|
||||
modelResolveHook = Effect.void
|
||||
pluginFlushHook = Effect.void
|
||||
currentModel = model
|
||||
skillBaselines.clear()
|
||||
responses = undefined
|
||||
|
|
@ -479,6 +490,12 @@ const setup = Effect.gen(function* () {
|
|||
activeToolExecutions = 0
|
||||
maxActiveToolExecutions = 0
|
||||
spans.length = 0
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(AgentV2.ID.make("build"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
}),
|
||||
)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
|
|
@ -1307,10 +1324,64 @@ describe("SessionRunnerLLM", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("fails before the model request when the selected agent is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ agent: "explore" })
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Inspect files" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
const failure = yield* session.resume(sessionID).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "Session.AgentNotFoundError",
|
||||
sessionID,
|
||||
agent: "explore",
|
||||
})
|
||||
expect(requests).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("waits for initial plugin readiness before constructing the model request", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const release = yield* Deferred.make<void>()
|
||||
pluginFlushHook = Deferred.await(release)
|
||||
const session = yield* SessionV2.Service
|
||||
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Wait for plugins" }), resume: false })
|
||||
|
||||
requests.length = 0
|
||||
response = []
|
||||
const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(requests).toHaveLength(0)
|
||||
expect(running.pollUnsafe()).toBeUndefined()
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(running)
|
||||
expect(requests).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("updates selected-agent skill guidance after an agent switch", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const events = yield* EventV2.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
yield* agents.transform((draft) =>
|
||||
draft.update(AgentV2.ID.make("reviewer"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
}),
|
||||
)
|
||||
skillBaselines.set(AgentV2.ID.make("build"), "Build skills")
|
||||
yield* admit(session, "First")
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
|
|||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { SubagentTool } from "@opencode-ai/core/tool/subagent"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
|
|
@ -125,6 +126,7 @@ const it = testEffect(layer)
|
|||
const withSubagent = (location: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(locations.get(location)))
|
||||
yield* AgentV2.Service.use((agents) =>
|
||||
agents.transform((draft) => {
|
||||
// The caller identity used by executeTool; subagent permission asserts against it.
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ const layer = Layer.effect(
|
|||
const skillDirs = yield* skill.dirs()
|
||||
const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length
|
||||
? yield* Effect.gen(function* () {
|
||||
yield* (yield* PluginSupervisor.Service).ready
|
||||
yield* (yield* PluginSupervisor.Service).flush
|
||||
return (yield* (yield* Reference.Service).list()).map((reference) => reference.path)
|
||||
}).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) }))))
|
||||
: []
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers)
|
|||
"model.default",
|
||||
Effect.fn(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.ready.pipe(
|
||||
yield* plugins.flush.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () =>
|
||||
|
|
|
|||
|
|
@ -1,390 +0,0 @@
|
|||
import { Effect, FileSystem, Layer, Option, Stream } from "effect"
|
||||
import { systemError, type PlatformError, type SystemErrorTag } from "effect/PlatformError"
|
||||
import nodeFs from "fs"
|
||||
import path from "path"
|
||||
|
||||
/**
|
||||
* In-memory simulated `FileSystem.FileSystem`.
|
||||
*
|
||||
* Replaces the `NodeFileSystem` platform node when the server runs in
|
||||
* simulation mode. Backed by a flat map of absolute paths to entries and
|
||||
* rooted at a single directory (the simulation anchor): paths that resolve
|
||||
* outside the root fail with `PermissionDenied` so host filesystem escapes
|
||||
* are loud. Only the operations the app actually uses are implemented;
|
||||
* everything else dies with a clear defect.
|
||||
*
|
||||
* Inspired by the V1 prototype on `jlongster/simulation-rebase`, rewritten
|
||||
* for the V2 platform node shape without the `just-bash` dependency.
|
||||
*/
|
||||
|
||||
export interface Options {
|
||||
readonly root: string
|
||||
readonly files?: Record<string, string | Uint8Array>
|
||||
}
|
||||
|
||||
interface FileEntry {
|
||||
readonly type: "File"
|
||||
content: Uint8Array
|
||||
mode: number
|
||||
mtime: Date
|
||||
}
|
||||
|
||||
interface DirectoryEntry {
|
||||
readonly type: "Directory"
|
||||
mode: number
|
||||
mtime: Date
|
||||
}
|
||||
|
||||
type Entry = FileEntry | DirectoryEntry
|
||||
|
||||
export function make(options: Options): FileSystem.FileSystem {
|
||||
const root = path.resolve(options.root)
|
||||
const store = new Map<string, Entry>()
|
||||
const temp = { value: 0 }
|
||||
const encoder = new TextEncoder()
|
||||
store.set(root, makeDirectoryEntry())
|
||||
|
||||
const within = (resolved: string) => resolved === root || resolved.startsWith(withSep(root))
|
||||
|
||||
const childrenOf = (resolved: string) => [...store.keys()].filter((key) => key.startsWith(withSep(resolved)))
|
||||
|
||||
const fail = (
|
||||
tag: SystemErrorTag,
|
||||
method: string,
|
||||
file: string,
|
||||
description?: string,
|
||||
): Effect.Effect<never, PlatformError> =>
|
||||
Effect.fail(
|
||||
systemError({ _tag: tag, module: "SimulationFileSystem", method, description, pathOrDescriptor: file }),
|
||||
)
|
||||
|
||||
const locate = (method: string, file: string): Effect.Effect<string, PlatformError> => {
|
||||
const resolved = path.resolve(root, file)
|
||||
if (within(resolved)) return Effect.succeed(resolved)
|
||||
return fail("PermissionDenied", method, file, "path escapes the simulated filesystem root")
|
||||
}
|
||||
|
||||
const requireEntry = (method: string, file: string): Effect.Effect<readonly [string, Entry], PlatformError> =>
|
||||
locate(method, file).pipe(
|
||||
Effect.flatMap((resolved) => {
|
||||
const entry = store.get(resolved)
|
||||
if (!entry) return fail("NotFound", method, file)
|
||||
return Effect.succeed([resolved, entry] as const)
|
||||
}),
|
||||
)
|
||||
|
||||
const requireParentDirectory = (
|
||||
method: string,
|
||||
resolved: string,
|
||||
file: string,
|
||||
): Effect.Effect<void, PlatformError> => {
|
||||
const parent = store.get(path.dirname(resolved))
|
||||
if (parent?.type === "Directory") return Effect.void
|
||||
return fail("NotFound", method, file, "parent directory does not exist")
|
||||
}
|
||||
|
||||
// Creates every missing directory between root and resolved (inclusive).
|
||||
const ensureDirectories = (method: string, file: string, resolved: string): Effect.Effect<void, PlatformError> =>
|
||||
Effect.suspend(() => {
|
||||
const segments = path.relative(root, resolved).split(path.sep).filter(Boolean)
|
||||
const conflict = segments.reduce<string | Effect.Effect<never, PlatformError>>((current, segment) => {
|
||||
if (typeof current !== "string") return current
|
||||
const next = path.join(current, segment)
|
||||
const entry = store.get(next)
|
||||
if (entry && entry.type !== "Directory")
|
||||
return fail("AlreadyExists", method, file, "path component is not a directory")
|
||||
if (!entry) store.set(next, makeDirectoryEntry())
|
||||
return next
|
||||
}, root)
|
||||
return typeof conflict === "string" ? Effect.void : conflict
|
||||
})
|
||||
|
||||
// Seed initial files, creating parents as needed. Entries outside the root are ignored.
|
||||
for (const [file, content] of Object.entries(options.files ?? {})) {
|
||||
const resolved = path.resolve(root, file)
|
||||
if (!within(resolved)) continue
|
||||
Effect.runSync(ensureDirectories("seed", file, path.dirname(resolved)))
|
||||
store.set(resolved, {
|
||||
type: "File",
|
||||
content: typeof content === "string" ? encoder.encode(content) : content.slice(),
|
||||
mode: 0o644,
|
||||
mtime: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
// Probe operations report NotFound outside the root instead of
|
||||
// PermissionDenied: walk-up loops (project discovery, findUp, globUp)
|
||||
// legitimately probe ancestor directories of the anchor and must observe
|
||||
// "nothing there". Content access and mutation outside the root stay loud.
|
||||
const probe = (method: string, file: string): Effect.Effect<Entry, PlatformError> =>
|
||||
Effect.suspend(() => {
|
||||
const resolved = path.resolve(root, file)
|
||||
const entry = within(resolved) ? store.get(resolved) : undefined
|
||||
if (!entry) return fail("NotFound", method, file)
|
||||
return Effect.succeed(entry)
|
||||
})
|
||||
|
||||
const stat: FileSystem.FileSystem["stat"] = (file) => probe("stat", file).pipe(Effect.map(toInfo))
|
||||
|
||||
const access: FileSystem.FileSystem["access"] = (file) => probe("access", file).pipe(Effect.asVoid)
|
||||
|
||||
const chmod: FileSystem.FileSystem["chmod"] = (file, mode) =>
|
||||
requireEntry("chmod", file).pipe(
|
||||
Effect.map(([, entry]) => {
|
||||
entry.mode = mode
|
||||
}),
|
||||
)
|
||||
|
||||
const realPath: FileSystem.FileSystem["realPath"] = (file) =>
|
||||
requireEntry("realPath", file).pipe(Effect.map(([resolved]) => resolved))
|
||||
|
||||
const readFile: FileSystem.FileSystem["readFile"] = (file) =>
|
||||
requireEntry("readFile", file).pipe(
|
||||
Effect.flatMap(([, entry]) => {
|
||||
if (entry.type !== "File") return fail("BadResource", "readFile", file, "path is a directory")
|
||||
return Effect.succeed(entry.content.slice())
|
||||
}),
|
||||
)
|
||||
|
||||
const writeFile: FileSystem.FileSystem["writeFile"] = (file, data, writeOptions) =>
|
||||
locate("writeFile", file).pipe(
|
||||
Effect.flatMap((resolved) => {
|
||||
const existing = store.get(resolved)
|
||||
if (existing?.type === "Directory") return fail("BadResource", "writeFile", file, "path is a directory")
|
||||
return requireParentDirectory("writeFile", resolved, file).pipe(
|
||||
Effect.map(() => {
|
||||
store.set(resolved, {
|
||||
type: "File",
|
||||
content: data.slice(),
|
||||
mode: writeOptions?.mode ?? existing?.mode ?? 0o644,
|
||||
mtime: new Date(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const makeDirectory: FileSystem.FileSystem["makeDirectory"] = (file, dirOptions) =>
|
||||
locate("makeDirectory", file).pipe(
|
||||
Effect.flatMap((resolved) => {
|
||||
if (dirOptions?.recursive) return ensureDirectories("makeDirectory", file, resolved)
|
||||
if (store.has(resolved)) return fail("AlreadyExists", "makeDirectory", file)
|
||||
return requireParentDirectory("makeDirectory", resolved, file).pipe(
|
||||
Effect.map(() => {
|
||||
store.set(resolved, { type: "Directory", mode: dirOptions?.mode ?? 0o755, mtime: new Date() })
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const readDirectory: FileSystem.FileSystem["readDirectory"] = (file, readOptions) =>
|
||||
requireEntry("readDirectory", file).pipe(
|
||||
Effect.flatMap(([resolved, entry]) => {
|
||||
if (entry.type !== "Directory") return fail("BadResource", "readDirectory", file, "path is not a directory")
|
||||
const children = childrenOf(resolved)
|
||||
const names = readOptions?.recursive
|
||||
? children.map((key) => path.relative(resolved, key))
|
||||
: children.filter((key) => path.dirname(key) === resolved).map((key) => path.basename(key))
|
||||
return Effect.succeed(names.sort((a, b) => a.localeCompare(b)))
|
||||
}),
|
||||
)
|
||||
|
||||
const remove: FileSystem.FileSystem["remove"] = (file, removeOptions) =>
|
||||
locate("remove", file).pipe(
|
||||
Effect.flatMap((resolved) => {
|
||||
const entry = store.get(resolved)
|
||||
if (!entry) return removeOptions?.force ? Effect.void : fail("NotFound", "remove", file)
|
||||
const children = childrenOf(resolved)
|
||||
if (entry.type === "Directory" && children.length > 0 && !removeOptions?.recursive)
|
||||
return fail("Unknown", "remove", file, "directory is not empty")
|
||||
for (const key of children) store.delete(key)
|
||||
store.delete(resolved)
|
||||
// The root itself must always exist.
|
||||
if (resolved === root) store.set(root, makeDirectoryEntry())
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
|
||||
const rename: FileSystem.FileSystem["rename"] = (oldPath, newPath) =>
|
||||
Effect.all([locate("rename", oldPath), locate("rename", newPath)]).pipe(
|
||||
Effect.flatMap(([from, to]) => {
|
||||
const entry = store.get(from)
|
||||
if (!entry) return fail("NotFound", "rename", oldPath)
|
||||
return requireParentDirectory("rename", to, newPath).pipe(
|
||||
Effect.map(() => {
|
||||
const moved = [from, ...childrenOf(from)].map((key) => [key, store.get(key)!] as const)
|
||||
for (const [key] of moved) store.delete(key)
|
||||
for (const key of [to, ...childrenOf(to)]) store.delete(key)
|
||||
for (const [key, value] of moved) store.set(key === from ? to : to + key.slice(from.length), value)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const copy: FileSystem.FileSystem["copy"] = (fromPath, toPath) =>
|
||||
Effect.all([locate("copy", fromPath), locate("copy", toPath)]).pipe(
|
||||
Effect.flatMap(([from, to]) => {
|
||||
const entry = store.get(from)
|
||||
if (!entry) return fail("NotFound", "copy", fromPath)
|
||||
return requireParentDirectory("copy", to, toPath).pipe(
|
||||
Effect.map(() => {
|
||||
for (const key of [from, ...childrenOf(from)]) {
|
||||
const source = store.get(key)!
|
||||
const target = key === from ? to : to + key.slice(from.length)
|
||||
store.set(
|
||||
target,
|
||||
source.type === "File"
|
||||
? { ...source, content: source.content.slice(), mtime: new Date() }
|
||||
: { ...source, mtime: new Date() },
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const copyFile: FileSystem.FileSystem["copyFile"] = (fromPath, toPath) =>
|
||||
readFile(fromPath).pipe(Effect.flatMap((content) => writeFile(toPath, content)))
|
||||
|
||||
const makeTempDirectory: FileSystem.FileSystem["makeTempDirectory"] = (tempOptions) =>
|
||||
Effect.suspend(() => {
|
||||
const directory = tempOptions?.directory ?? path.join(root, ".simulation-tmp")
|
||||
const file = path.join(directory, `${tempOptions?.prefix ?? "tmp-"}${++temp.value}`)
|
||||
return makeDirectory(file, { recursive: true }).pipe(Effect.map(() => file))
|
||||
})
|
||||
|
||||
const makeTempDirectoryScoped: FileSystem.FileSystem["makeTempDirectoryScoped"] = (tempOptions) =>
|
||||
Effect.acquireRelease(makeTempDirectory(tempOptions), (directory) =>
|
||||
remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
// Read-only file handle: enough for the read tool's stat/seek/readAlloc use.
|
||||
const open: FileSystem.FileSystem["open"] = (file) =>
|
||||
requireEntry("open", file).pipe(
|
||||
Effect.map(([resolved]) => {
|
||||
const position = { value: 0 }
|
||||
const contentOf = () => {
|
||||
const current = store.get(resolved)
|
||||
return current?.type === "File" ? current.content : new Uint8Array()
|
||||
}
|
||||
return {
|
||||
[FileSystem.FileTypeId]: FileSystem.FileTypeId,
|
||||
fd: FileSystem.FileDescriptor(0),
|
||||
stat: Effect.suspend(() => stat(resolved)),
|
||||
seek: (offset, from) =>
|
||||
Effect.sync(() => {
|
||||
position.value = from === "start" ? Number(offset) : position.value + Number(offset)
|
||||
}),
|
||||
sync: Effect.void,
|
||||
read: (buffer) =>
|
||||
Effect.sync(() => {
|
||||
const chunk = contentOf().subarray(position.value, position.value + buffer.length)
|
||||
buffer.set(chunk)
|
||||
position.value += chunk.length
|
||||
return FileSystem.Size(chunk.length)
|
||||
}),
|
||||
readAlloc: (size) =>
|
||||
Effect.sync(() => {
|
||||
const chunk = contentOf().slice(position.value, position.value + Number(size))
|
||||
position.value += chunk.length
|
||||
return chunk.length === 0 ? Option.none() : Option.some(chunk)
|
||||
}),
|
||||
truncate: () => unimplemented("File.truncate"),
|
||||
write: () => unimplemented("File.write"),
|
||||
writeAll: () => unimplemented("File.writeAll"),
|
||||
} satisfies FileSystem.File
|
||||
}),
|
||||
)
|
||||
|
||||
return FileSystem.make({
|
||||
access,
|
||||
chmod,
|
||||
chown: () => unimplemented("chown"),
|
||||
copy,
|
||||
copyFile,
|
||||
link: () => unimplemented("link"),
|
||||
makeDirectory,
|
||||
makeTempDirectory,
|
||||
makeTempDirectoryScoped,
|
||||
makeTempFile: () => unimplemented("makeTempFile"),
|
||||
makeTempFileScoped: () => unimplemented("makeTempFileScoped"),
|
||||
open,
|
||||
readDirectory,
|
||||
readFile,
|
||||
readLink: () => unimplemented("readLink"),
|
||||
realPath,
|
||||
remove,
|
||||
rename,
|
||||
stat,
|
||||
symlink: () => unimplemented("symlink"),
|
||||
truncate: () => unimplemented("truncate"),
|
||||
utimes: () => unimplemented("utimes"),
|
||||
watch: () => Stream.die(new Error("SimulationFileSystem.watch is not implemented in simulation")),
|
||||
writeFile,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily constructed layer so the root defaults to `process.cwd()` at
|
||||
* layer-build time (the simulation anchor directory), not at import time.
|
||||
*
|
||||
* When `OPENCODE_SIMULATE_STATE` points at a snapshot directory, its
|
||||
* `files/` contents are read from the host once at build time and seeded
|
||||
* into the in-memory tree, joined onto the anchor root.
|
||||
*/
|
||||
export const layer = (options?: Partial<Options>) =>
|
||||
Layer.sync(FileSystem.FileSystem)(() =>
|
||||
make({
|
||||
root: options?.root ?? process.cwd(),
|
||||
files: { ...loadSnapshotFiles(process.env.OPENCODE_SIMULATE_STATE), ...options?.files },
|
||||
}),
|
||||
)
|
||||
|
||||
function loadSnapshotFiles(stateDirectory: string | undefined) {
|
||||
if (!stateDirectory) return {}
|
||||
const snapshot = path.join(stateDirectory, "files")
|
||||
if (!nodeFs.existsSync(snapshot)) return {}
|
||||
const files: Record<string, Uint8Array> = {}
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const file = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) walk(file)
|
||||
if (entry.isFile()) files[path.relative(snapshot, file)] = new Uint8Array(nodeFs.readFileSync(file))
|
||||
}
|
||||
}
|
||||
walk(snapshot)
|
||||
return files
|
||||
}
|
||||
|
||||
function makeDirectoryEntry(): Entry {
|
||||
return { type: "Directory", mode: 0o755, mtime: new Date() }
|
||||
}
|
||||
|
||||
function withSep(dir: string) {
|
||||
return dir.endsWith(path.sep) ? dir : dir + path.sep
|
||||
}
|
||||
|
||||
function toInfo(entry: Entry): FileSystem.File.Info {
|
||||
return {
|
||||
type: entry.type,
|
||||
mtime: Option.some(entry.mtime),
|
||||
atime: Option.some(entry.mtime),
|
||||
birthtime: Option.some(entry.mtime),
|
||||
dev: 0,
|
||||
ino: Option.none(),
|
||||
mode: entry.mode,
|
||||
nlink: Option.none(),
|
||||
uid: Option.none(),
|
||||
gid: Option.none(),
|
||||
rdev: Option.none(),
|
||||
size: FileSystem.Size(entry.type === "File" ? entry.content.length : 0),
|
||||
blksize: Option.none(),
|
||||
blocks: Option.none(),
|
||||
}
|
||||
}
|
||||
|
||||
function unimplemented(method: string) {
|
||||
return Effect.die(new Error(`SimulationFileSystem.${method} is not implemented in simulation`))
|
||||
}
|
||||
|
||||
export * as SimulationFileSystem from "./filesystem"
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
import { Effect, FileSystem, Layer } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { filesystem } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import path from "path"
|
||||
|
||||
/**
|
||||
* Simulation replacement for `FSUtil`.
|
||||
*
|
||||
* This implementation is intentionally self-contained and only uses the
|
||||
* injected simulated `FileSystem.FileSystem`. The default FSUtil layer has a
|
||||
* few helpers that reach host-node APIs directly; depending on it here makes it
|
||||
* easy for mutation paths to escape or miss the in-memory project tree.
|
||||
*/
|
||||
|
||||
const layer = Layer.effect(
|
||||
FSUtil.Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
|
||||
const existsSafe = Effect.fn("SimulationFSUtil.existsSafe")(function* (file: string) {
|
||||
return yield* fs.exists(file).pipe(Effect.orElseSucceed(() => false))
|
||||
})
|
||||
|
||||
const isDir = Effect.fn("SimulationFSUtil.isDir")(function* (file: string) {
|
||||
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
return info?.type === "Directory"
|
||||
})
|
||||
|
||||
const isFile = Effect.fn("SimulationFSUtil.isFile")(function* (file: string) {
|
||||
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
return info?.type === "File"
|
||||
})
|
||||
|
||||
const realPath = Effect.fn("SimulationFSUtil.realPath")(function* (file: string) {
|
||||
return yield* fs.realPath(file)
|
||||
})
|
||||
|
||||
const stat = Effect.fn("SimulationFSUtil.stat")(function* (file: string) {
|
||||
return yield* fs.stat(file)
|
||||
})
|
||||
|
||||
const readFile = Effect.fn("SimulationFSUtil.readFile")(function* (file: string) {
|
||||
return yield* fs.readFile(file)
|
||||
})
|
||||
|
||||
const readFileString = Effect.fn("SimulationFSUtil.readFileString")(function* (file: string) {
|
||||
return yield* fs.readFileString(file)
|
||||
})
|
||||
|
||||
const readFileStringSafe = Effect.fn("SimulationFSUtil.readFileStringSafe")(function* (file: string) {
|
||||
return yield* fs
|
||||
.readFileString(file)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
})
|
||||
|
||||
const readJson = Effect.fn("SimulationFSUtil.readJson")(function* (file: string) {
|
||||
const text = yield* readFileString(file)
|
||||
return JSON.parse(text) as unknown
|
||||
})
|
||||
|
||||
const writeFile = Effect.fn("SimulationFSUtil.writeFile")(function* (
|
||||
file: string,
|
||||
data: Uint8Array,
|
||||
options?: Parameters<typeof fs.writeFile>[2],
|
||||
) {
|
||||
return yield* fs.writeFile(file, data, options)
|
||||
})
|
||||
|
||||
const writeFileString = Effect.fn("SimulationFSUtil.writeFileString")(function* (
|
||||
file: string,
|
||||
data: string,
|
||||
options?: Parameters<typeof fs.writeFileString>[2],
|
||||
) {
|
||||
return yield* fs.writeFileString(file, data, options)
|
||||
})
|
||||
|
||||
const makeDirectory: FileSystem.FileSystem["makeDirectory"] = (file, options) => fs.makeDirectory(file, options)
|
||||
|
||||
const ensureDir = Effect.fn("SimulationFSUtil.ensureDir")(function* (file: string) {
|
||||
yield* fs.makeDirectory(file, { recursive: true })
|
||||
})
|
||||
|
||||
const writeWithDirs = Effect.fn("SimulationFSUtil.writeWithDirs")(function* (
|
||||
file: string,
|
||||
content: string | Uint8Array,
|
||||
mode?: number,
|
||||
) {
|
||||
const write =
|
||||
typeof content === "string"
|
||||
? fs.writeFileString(file, content)
|
||||
: fs.writeFile(file, content)
|
||||
yield* write.pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
fs.makeDirectory(path.dirname(file), { recursive: true }).pipe(Effect.andThen(write)),
|
||||
),
|
||||
)
|
||||
if (mode !== undefined) yield* fs.chmod(file, mode)
|
||||
})
|
||||
|
||||
const writeJson = Effect.fn("SimulationFSUtil.writeJson")(function* (file: string, data: unknown, mode?: number) {
|
||||
yield* writeFileString(file, JSON.stringify(data, null, 2))
|
||||
if (mode !== undefined) yield* fs.chmod(file, mode)
|
||||
})
|
||||
|
||||
const readDirectoryEntries = Effect.fn("SimulationFSUtil.readDirectoryEntries")(function* (dirPath: string) {
|
||||
const names = yield* fs.readDirectory(dirPath)
|
||||
return yield* Effect.forEach(names, (name) =>
|
||||
fs.stat(path.join(dirPath, name)).pipe(
|
||||
Effect.map(
|
||||
(info): FSUtil.DirEntry => ({
|
||||
name,
|
||||
type:
|
||||
info.type === "Directory"
|
||||
? "directory"
|
||||
: info.type === "File"
|
||||
? "file"
|
||||
: info.type === "SymbolicLink"
|
||||
? "symlink"
|
||||
: "other",
|
||||
}),
|
||||
),
|
||||
Effect.orElseSucceed((): FSUtil.DirEntry => ({ name, type: "other" })),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("SimulationFSUtil.resolve")(function* (input: string) {
|
||||
return path.resolve(input)
|
||||
})
|
||||
|
||||
const glob = Effect.fn("SimulationFSUtil.glob")(function* (pattern: string, options?: Glob.Options) {
|
||||
const cwd = path.resolve(options?.cwd ?? process.cwd())
|
||||
const entries = yield* fs
|
||||
.readDirectory(cwd, { recursive: true })
|
||||
.pipe(Effect.orElseSucceed(() => [] as string[]))
|
||||
const matches = yield* Effect.forEach(entries, (entry) =>
|
||||
fs.stat(path.join(cwd, entry)).pipe(
|
||||
Effect.map((info) => ({ entry, type: info.type })),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
),
|
||||
)
|
||||
return matches
|
||||
.filter((item) => item !== undefined)
|
||||
.filter((item) => options?.include === "all" || item.type === "File")
|
||||
.filter((item) => Glob.match(pattern, item.entry))
|
||||
.map((item) => (options?.absolute ? path.join(cwd, item.entry) : item.entry))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
})
|
||||
|
||||
const globUp = Effect.fn("SimulationFSUtil.globUp")(function* (pattern: string, start: string, stop?: string) {
|
||||
const result: string[] = []
|
||||
let current = path.resolve(start)
|
||||
while (true) {
|
||||
result.push(...(yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true })))
|
||||
if (stop === current) break
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const up = Effect.fn("SimulationFSUtil.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
|
||||
const result: string[] = []
|
||||
let current = path.resolve(options.start)
|
||||
while (true) {
|
||||
for (const target of options.targets) {
|
||||
const search = path.join(current, target)
|
||||
if (yield* fs.exists(search)) result.push(search)
|
||||
}
|
||||
if (options.stop === current) break
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const findUp = Effect.fn("SimulationFSUtil.findUp")(function* (target: string, start: string, stop?: string) {
|
||||
return yield* up({ targets: [target], start, stop })
|
||||
})
|
||||
|
||||
return FSUtil.Service.of({
|
||||
...fs,
|
||||
realPath,
|
||||
stat,
|
||||
readFile,
|
||||
readFileString,
|
||||
writeFile,
|
||||
writeFileString,
|
||||
makeDirectory,
|
||||
isDir,
|
||||
isFile,
|
||||
existsSafe,
|
||||
readFileStringSafe,
|
||||
readJson,
|
||||
writeJson,
|
||||
ensureDir,
|
||||
writeWithDirs,
|
||||
readDirectoryEntries,
|
||||
resolve,
|
||||
findUp,
|
||||
up,
|
||||
globUp,
|
||||
glob,
|
||||
globMatch: Glob.match,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: FSUtil.Service, layer, deps: [filesystem] })
|
||||
|
||||
export * as SimulationFSUtil from "./fs-util"
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { filesystem, httpClient } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { DriveManifest } from "../manifest"
|
||||
import { SimulationControl } from "./control"
|
||||
import { SimulationFileSystem } from "./filesystem"
|
||||
import { SimulationFSUtil } from "./fs-util"
|
||||
import { SimulationNetwork } from "./network"
|
||||
import { SimulationOpenAI } from "./openai"
|
||||
|
||||
|
|
@ -14,8 +11,6 @@ import { SimulationOpenAI } from "./openai"
|
|||
* The server merges these into the app node build when `OPENCODE_SIMULATE`
|
||||
* is enabled, via a dynamic import so this module is never loaded eagerly.
|
||||
*
|
||||
* - Filesystem: in-memory tree rooted at the current working directory.
|
||||
* Everything under the root lives in memory; paths outside it fail loudly.
|
||||
* - Network: all outbound HTTP resolves against the simulated route table;
|
||||
* unknown destinations are denied. The driver-answered OpenAI endpoint is
|
||||
* registered here as the first route.
|
||||
|
|
@ -32,8 +27,6 @@ export function startDriveServer() {
|
|||
}
|
||||
|
||||
export const simulationReplacements: LayerNode.Replacements = [
|
||||
[filesystem, SimulationFileSystem.layer()],
|
||||
[FSUtil.node, SimulationFSUtil.node],
|
||||
[httpClient, SimulationNetwork.layer],
|
||||
]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue