mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 01:16:24 +00:00
Compare commits
3
Commits
v2
..
file-access
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31f9e53047 | ||
|
|
9be2bdb053 | ||
|
|
a197c0f28b |
@@ -432,7 +432,6 @@
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"app-builder-lib": "26.15.7",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
@@ -1037,9 +1036,12 @@
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"electron",
|
||||
"esbuild",
|
||||
"tree-sitter-powershell",
|
||||
"protobufjs",
|
||||
"electron",
|
||||
"web-tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-T1JUv8gUrXavDI1HdVGqNRal4y2Bh5QavpR4DV+bJdA=",
|
||||
"aarch64-linux": "sha256-wlbWdEUVWAafgxaZis0Q+SAas8k6hu3s0C39CnMZ2gw=",
|
||||
"aarch64-darwin": "sha256-74rRue8KQWKzgMMb540beqMzp1DUShlPte6qklYCVTs=",
|
||||
"x86_64-darwin": "sha256-ihr3DoLd/4Lw6WbyXzSQXMP4hrVGk/UiRkO2aoQn8vg="
|
||||
"x86_64-linux": "sha256-bWKV3fV8Yc+3ILaNRChIYajsRcvEG2OkydsD2KMUXCM=",
|
||||
"aarch64-linux": "sha256-zIavB09LW+BU3GOq0PO7Lqfc3MVchkiOUf3trnAjhRM=",
|
||||
"aarch64-darwin": "sha256-Jlvz7QsXsb8GS9Ty4TmUIfGOawiueZWWEYmyEPms3dc=",
|
||||
"x86_64-darwin": "sha256-TzTDtcDtj1c3+eP2WZAHOfkszxIS06J6QUwlnscLaUM="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,10 @@
|
||||
"esbuild",
|
||||
"node-pty",
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-powershell",
|
||||
"web-tree-sitter",
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
|
||||
@@ -415,7 +415,10 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
|
||||
// System prompts share the cache-point convention: emit the text block, then
|
||||
// optionally a positional `cachePoint` marker.
|
||||
const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArray<LLMRequest["system"][number]>) => {
|
||||
const lowerSystem = (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
system: ReadonlyArray<LLMRequest["system"][number]>,
|
||||
) => {
|
||||
const content = system
|
||||
.filter((part) => part.text.length > 0)
|
||||
.flatMap((part) => textWithCache(breakpoints, part.text, part.cache))
|
||||
@@ -428,7 +431,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
const generation = request.generation
|
||||
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
|
||||
// tools → system → messages order to favour the highest-impact prefixes.
|
||||
const breakpoints = BedrockCache.breakpoints(request.model.id)
|
||||
const breakpoints = BedrockCache.breakpoints()
|
||||
const toolConfig = (() => {
|
||||
if (flattened.tools.length === 0) return undefined
|
||||
return {
|
||||
|
||||
@@ -639,14 +639,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
: state.usage,
|
||||
}
|
||||
const candidate = event.candidates?.[0]
|
||||
if (candidate?.finishReason && mapFinishReason(candidate.finishReason, state.hasToolCalls) === "error")
|
||||
return Effect.fail(
|
||||
ProviderShared.eventError(
|
||||
state.route,
|
||||
`Gemini stopped with ${candidate.finishReason}`,
|
||||
ProviderShared.encodeJson(event),
|
||||
),
|
||||
)
|
||||
if (!candidate?.content)
|
||||
return Effect.succeed([
|
||||
{ ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason },
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
AIError,
|
||||
InvalidProviderOutputError,
|
||||
LLMEvent,
|
||||
ProviderInternalError,
|
||||
UnknownProviderError,
|
||||
Usage,
|
||||
type FinishReasonDetails,
|
||||
type LLMRequest,
|
||||
@@ -702,16 +700,6 @@ const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event:
|
||||
normalized: mapFinishReason(choice.finish_reason),
|
||||
raw: choice.finish_reason,
|
||||
}
|
||||
if (finishReason.normalized === "error") {
|
||||
const details = {
|
||||
message: `Mistral Chat stopped with ${finishReason.raw}`,
|
||||
body: ProviderShared.encodeJson(event),
|
||||
}
|
||||
return yield* new AIError({
|
||||
reason:
|
||||
finishReason.raw === "network_error" ? new ProviderInternalError(details) : new UnknownProviderError(details),
|
||||
})
|
||||
}
|
||||
const incomplete = finishReason.normalized === "length" || finishReason.normalized === "content-filter"
|
||||
if (!incomplete && Object.keys(withTools.pendingTools).length > 0)
|
||||
return yield* ProviderShared.eventError(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import type { CacheHint } from "../../schema/index.js"
|
||||
import { newBreakpoints, ttlBucket } from "./cache.js"
|
||||
import { newBreakpoints, ttlBucket, type Breakpoints } from "./cache.js"
|
||||
|
||||
// Bedrock cache markers are positional: emit a `cachePoint` block immediately
|
||||
// after the content the caller wants treated as a cacheable prefix. Bedrock
|
||||
@@ -13,46 +13,24 @@ export const CachePointBlock = Schema.Struct({
|
||||
})
|
||||
export type CachePointBlock = Schema.Schema.Type<typeof CachePointBlock>
|
||||
|
||||
const LEGACY_CLAUDE = ["anthropic.claude-instant", "anthropic.claude-v1", "anthropic.claude-v2", "anthropic.claude-3-"]
|
||||
|
||||
// These legacy Claude releases support explicit caching, but only for five minutes.
|
||||
const CLAUDE_5M = [
|
||||
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
"anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
"anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
"anthropic.claude-opus-4-20250514-v1:0",
|
||||
"anthropic.claude-opus-4-1-20250805-v1:0",
|
||||
]
|
||||
|
||||
// Callers share the four-breakpoint budget across system, messages, and tools.
|
||||
// Callers pass a shared counter through every `block()` call site so the
|
||||
// four-breakpoint budget is respected across `system`, `messages`, and `tools`.
|
||||
export const BEDROCK_BREAKPOINT_CAP = 4
|
||||
|
||||
export const breakpoints = (modelID: string) => {
|
||||
// Substring matching also handles regional prefixes and model-bearing ARNs.
|
||||
const short = CLAUDE_5M.some((id) => modelID.includes(id))
|
||||
return {
|
||||
...newBreakpoints(BEDROCK_BREAKPOINT_CAP),
|
||||
// Assume modern Claude releases retain caching support; older generations need an explicit exception.
|
||||
// Other model families use implicit caching where available.
|
||||
supported: modelID.includes("anthropic.claude-") && (short || !LEGACY_CLAUDE.some((id) => modelID.includes(id))),
|
||||
ttl1h: !short,
|
||||
}
|
||||
}
|
||||
export type Breakpoints = ReturnType<typeof breakpoints>
|
||||
export type { Breakpoints } from "./cache.js"
|
||||
export const breakpoints = () => newBreakpoints(BEDROCK_BREAKPOINT_CAP)
|
||||
|
||||
const DEFAULT_5M: CachePointBlock = { cachePoint: { type: "default" } }
|
||||
const DEFAULT_1H: CachePointBlock = { cachePoint: { type: "default", ttl: "1h" } }
|
||||
|
||||
export const block = (breakpoints: Breakpoints, cache: CacheHint | undefined): CachePointBlock | undefined => {
|
||||
if (!breakpoints.supported) return undefined
|
||||
if (cache?.type !== "ephemeral" && cache?.type !== "persistent") return undefined
|
||||
if (breakpoints.remaining <= 0) {
|
||||
breakpoints.dropped += 1
|
||||
return undefined
|
||||
}
|
||||
breakpoints.remaining -= 1
|
||||
return breakpoints.ttl1h && ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
|
||||
return ttlBucket(cache.ttlSeconds) === "1h" ? DEFAULT_1H : DEFAULT_5M
|
||||
}
|
||||
|
||||
export * as BedrockCache from "./bedrock-cache.js"
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message, ToolCallPart } from "../../src/index.js"
|
||||
import { AmazonBedrock } from "../../src/providers.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
|
||||
const bedrock = AmazonBedrock.configure({ apiKey: "fixture" })
|
||||
|
||||
describe("Bedrock Converse cache policy", () => {
|
||||
for (const id of [
|
||||
"deepseek.r1-v1:0",
|
||||
"meta.llama3-3-70b-instruct-v1:0",
|
||||
"mistral.mistral-large-2402-v1:0",
|
||||
"qwen.qwen3-coder-480b-a35b-v1:0",
|
||||
"openai.gpt-oss-120b-1:0",
|
||||
"cohere.command-r-v1:0",
|
||||
"anthropic.claude-instant-v1",
|
||||
"anthropic.claude-v1",
|
||||
"anthropic.claude-v2",
|
||||
"anthropic.claude-v2:1",
|
||||
"anthropic.claude-3-haiku-20240307-v1:0",
|
||||
"anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
"anthropic.claude-3-opus-20240229-v1:0",
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
"amazon.nova-lite-v1:0",
|
||||
"global.amazon.nova-2-lite-v1:0",
|
||||
"custom-model",
|
||||
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123",
|
||||
]) {
|
||||
for (const policy of [undefined, "auto", "none", { tools: true, system: true, messages: { tail: 3 } }] as const) {
|
||||
it.effect(`omits checkpoints for ${id} (${JSON.stringify(policy)})`, () =>
|
||||
Effect.gen(function* () {
|
||||
// Exercise both automatic placement and manual hints at every lowering site.
|
||||
const cache = policy === "none" ? new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) : undefined
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: bedrock.model(id),
|
||||
cache: policy,
|
||||
system: [{ type: "text", text: "System prefix", cache }],
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" }, cache }],
|
||||
messages: [
|
||||
Message.user([{ type: "text", text: "Question", cache }]),
|
||||
Message.system([{ type: "text", text: "Update", cache }]),
|
||||
Message.assistant([
|
||||
{ type: "text", text: "Answer", cache },
|
||||
{ type: "reasoning", text: "Unsigned reasoning", cache },
|
||||
ToolCallPart.make({ id: "call_1", name: "lookup", input: {} }),
|
||||
]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Result", cache }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(JSON.stringify(prepared.body)).not.toContain("cachePoint")
|
||||
expect(prepared.body).toMatchObject({
|
||||
modelId: id,
|
||||
system: [{ text: "System prefix" }],
|
||||
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }] },
|
||||
messages: [
|
||||
{ role: "user", content: [{ text: "Question" }, { text: "<system-update>\nUpdate\n</system-update>" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ text: "Answer" }, { text: "Unsigned reasoning" }, { toolUse: { name: "lookup" } }],
|
||||
},
|
||||
{ role: "user", content: [{ toolResult: { content: [{ json: "Result" }] } }] },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, ttl] of [
|
||||
["anthropic.claude-3-5-sonnet-20241022-v2:0", undefined],
|
||||
["us.anthropic.claude-3-5-haiku-20241022-v1:0", undefined],
|
||||
["eu.anthropic.claude-3-7-sonnet-20250219-v1:0", undefined],
|
||||
["apac.anthropic.claude-sonnet-4-20250514-v1:0", undefined],
|
||||
["anthropic.claude-opus-4-20250514-v1:0", undefined],
|
||||
["anthropic.claude-opus-4-1-20250805-v1:0", undefined],
|
||||
["anthropic.claude-sonnet-4-5-20250929-v1:0", "1h"],
|
||||
["global.anthropic.claude-sonnet-99", "1h"],
|
||||
["anthropic.claude-new-family-99", "1h"],
|
||||
["arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6", "1h"],
|
||||
["arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.anthropic.claude-sonnet-4-6", "1h"],
|
||||
] as const) {
|
||||
for (const ttlSeconds of [undefined, 3600]) {
|
||||
it.effect(`preserves Claude checkpoints for ${id} (TTL: ${ttlSeconds ?? "default"})`, () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: bedrock.model(id),
|
||||
system: [
|
||||
{ type: "text", text: "Agent" },
|
||||
{ type: "text", text: "Project" },
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object" } }],
|
||||
prompt: "Question",
|
||||
cache:
|
||||
ttlSeconds === undefined ? undefined : { tools: true, system: true, messages: { tail: 1 }, ttlSeconds },
|
||||
}),
|
||||
)
|
||||
const marker = {
|
||||
cachePoint: ttlSeconds === undefined || ttl === undefined ? { type: "default" } : { type: "default", ttl },
|
||||
}
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
modelId: id,
|
||||
toolConfig: { tools: [{ toolSpec: { name: "lookup" } }, marker] },
|
||||
system: [{ text: "Agent" }, marker, { text: "Project" }, marker],
|
||||
messages: [{ role: "user", content: [{ text: "Question" }, marker] }],
|
||||
})
|
||||
if (ttlSeconds === undefined || ttl === undefined)
|
||||
expect(JSON.stringify(prepared.body)).not.toContain('"ttl"')
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -136,7 +136,7 @@ const captureHeaders = (target: LanguageModel) =>
|
||||
const model = AmazonBedrock.configure({
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
apiKey: "test-bearer",
|
||||
}).model("anthropic.claude-sonnet-4-5-20250929-v1:0")
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
|
||||
const baseRequest = LLM.request({
|
||||
id: "req_1",
|
||||
@@ -155,7 +155,7 @@ describe("Bedrock Converse route", () => {
|
||||
const prepared = yield* compileRequest(baseRequest)
|
||||
|
||||
expect(prepared.body).toEqual({
|
||||
modelId: "anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
system: [{ text: "You are concise." }],
|
||||
messages: [{ role: "user", content: [{ text: "Say hello." }] }],
|
||||
inferenceConfig: { maxTokens: 64, temperature: 0 },
|
||||
|
||||
@@ -1643,14 +1643,19 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves blocking finishes and rejects invalid-output finish reasons", () =>
|
||||
it.effect("maps current blocking and invalid-output finish reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasons = [
|
||||
["MODEL_ARMOR", "content-filter"],
|
||||
["IMAGE_PROHIBITED_CONTENT", "content-filter"],
|
||||
["IMAGE_RECITATION", "content-filter"],
|
||||
["LANGUAGE", "content-filter"],
|
||||
["UNEXPECTED_TOOL_CALL", "error"],
|
||||
["NO_IMAGE", "error"],
|
||||
["IMAGE_OTHER", "unknown"],
|
||||
["TOO_MANY_TOOL_CALLS", "error"],
|
||||
["MISSING_THOUGHT_SIGNATURE", "error"],
|
||||
["MALFORMED_RESPONSE", "error"],
|
||||
] as const
|
||||
|
||||
for (const [raw, normalized] of reasons) {
|
||||
@@ -1661,26 +1666,6 @@ describe("Gemini route", () => {
|
||||
)
|
||||
expect(response.finishReason).toEqual({ normalized, raw })
|
||||
}
|
||||
|
||||
for (const raw of [
|
||||
"MALFORMED_FUNCTION_CALL",
|
||||
"UNEXPECTED_TOOL_CALL",
|
||||
"NO_IMAGE",
|
||||
"TOO_MANY_TOOL_CALLS",
|
||||
"MISSING_THOUGHT_SIGNATURE",
|
||||
"MALFORMED_RESPONSE",
|
||||
]) {
|
||||
const event = { candidates: [{ finishReason: raw, finishMessage: "Provider detail" }], responseId: "failure" }
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(event))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error).toMatchObject({
|
||||
_tag: "AI.Error",
|
||||
reason: { _tag: "InvalidProviderOutput", body: JSON.stringify(event), http: { status: 200 } },
|
||||
message: `Gemini stopped with ${raw}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -600,6 +600,7 @@ describe("Mistral Chat", () => {
|
||||
["stop", "stop"],
|
||||
["model_length", "length"],
|
||||
["tool_calls", "tool-calls"],
|
||||
["error", "error"],
|
||||
["future_reason", "unknown"],
|
||||
] as const) {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -608,22 +609,6 @@ describe("Mistral Chat", () => {
|
||||
expect(response.finishReason).toEqual({ normalized, raw })
|
||||
}
|
||||
|
||||
for (const [raw, tag] of [
|
||||
["error", "UnknownProvider"],
|
||||
["network_error", "ProviderInternal"],
|
||||
] as const) {
|
||||
const event = { ...chunk({}, raw), diagnostics: { trace: "failure" } }
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(event))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error).toMatchObject({
|
||||
_tag: "AI.Error",
|
||||
reason: { _tag: tag, body: JSON.stringify(event), http: { status: 200 } },
|
||||
message: `Mistral Chat stopped with ${raw}`,
|
||||
})
|
||||
}
|
||||
|
||||
const truncated = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRequestQueue, isSetupRequest, isSlowRequest } from "./request-queue"
|
||||
import { createRequestQueue, isSlowRequest } from "./request-queue"
|
||||
|
||||
function setup(input?: {
|
||||
limit?: number
|
||||
slowLimit?: number
|
||||
stallMs?: number
|
||||
headersTimeoutMs?: number
|
||||
setupHeadersTimeoutMs?: number
|
||||
}) {
|
||||
function setup(input?: { limit?: number; slowLimit?: number; stallMs?: number; headersTimeoutMs?: number }) {
|
||||
const pending: Array<{ url: string; signal: AbortSignal; resolve: () => void }> = []
|
||||
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
|
||||
let clock = 0
|
||||
@@ -16,13 +10,12 @@ function setup(input?: {
|
||||
slowLimit: input?.slowLimit,
|
||||
stallMs: input?.stallMs,
|
||||
headersTimeoutMs: input?.headersTimeoutMs,
|
||||
setupHeadersTimeoutMs: input?.setupHeadersTimeoutMs,
|
||||
now: () => clock,
|
||||
log: (message, data) => logs.push({ message, data }),
|
||||
fetch: Object.assign(
|
||||
(resource: RequestInfo | URL, init?: RequestInit) =>
|
||||
(resource: RequestInfo | URL) =>
|
||||
new Promise<Response>((resolve, reject) => {
|
||||
const request = new Request(resource, init)
|
||||
const request = new Request(resource)
|
||||
request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true })
|
||||
pending.push({ url: request.url, signal: request.signal, resolve: () => resolve(new Response("ok")) })
|
||||
}),
|
||||
@@ -50,12 +43,7 @@ describe("createRequestQueue", () => {
|
||||
|
||||
test("slow endpoints hold at most their share of slots so small reads go first", async () => {
|
||||
const input = setup({ limit: 4, slowLimit: 2 })
|
||||
const paths = [
|
||||
"/api/vcs?location[directory]=%2Fa",
|
||||
"/api/vcs/diff?location[directory]=%2Fa",
|
||||
"/api/worktree",
|
||||
"/api/session/ses_1",
|
||||
]
|
||||
const paths = ["/api/vcs?location[directory]=%2Fa", "/api/vcs/diff?location[directory]=%2Fa", "/api/worktree", "/api/session/ses_1"]
|
||||
const responses = paths.map((path) => input.queue.fetch(`http://server${path}`))
|
||||
await input.settle()
|
||||
const started = () => input.pending.map((item) => new URL(item.url).pathname)
|
||||
@@ -121,26 +109,6 @@ describe("createRequestQueue", () => {
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("worktree creation gets the setup deadline while worktree reads keep the normal one", async () => {
|
||||
const input = setup({ limit: 4, headersTimeoutMs: 10, setupHeadersTimeoutMs: 200 })
|
||||
const create = input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa", { method: "POST" })
|
||||
const list = input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa")
|
||||
const listError = await list.catch((cause: unknown) => cause)
|
||||
expect((listError as DOMException).name).toBe("TimeoutError")
|
||||
// Past the normal deadline, the create is still on the wire.
|
||||
expect(input.pending[0]!.signal.aborted).toBe(false)
|
||||
input.pending[0]!.resolve()
|
||||
await expect(create).resolves.toBeInstanceOf(Response)
|
||||
expect(input.queue.inflight()).toBe(0)
|
||||
})
|
||||
|
||||
test("only worktree creation counts as a setup request", () => {
|
||||
expect(isSetupRequest("POST", "/api/worktree")).toBe(true)
|
||||
expect(isSetupRequest("GET", "/api/worktree")).toBe(false)
|
||||
expect(isSetupRequest("POST", "/api/worktree/refresh")).toBe(false)
|
||||
expect(isSetupRequest("DELETE", "/api/worktree")).toBe(false)
|
||||
})
|
||||
|
||||
test("caller aborts still reach the underlying request", async () => {
|
||||
const input = setup({ limit: 1 })
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -19,26 +19,16 @@ export const requestStallMs = 2_000
|
||||
// instead of wedging every later API call; the body may still stream for as long as it needs.
|
||||
export const requestHeadersTimeoutMs = 60_000
|
||||
|
||||
// Creating a worktree runs the project's setup script (dependency installs, fetches) before the
|
||||
// server answers, so it needs a budget measured in minutes rather than seconds. Cutting it off
|
||||
// kills the script midway and leaves a registered but half-initialised worktree behind.
|
||||
export const setupRequestHeadersTimeoutMs = 10 * 60_000
|
||||
|
||||
export function isSlowRequest(pathname: string) {
|
||||
return slowRequestPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))
|
||||
}
|
||||
|
||||
export function isSetupRequest(method: string, pathname: string) {
|
||||
return method === "POST" && pathname === "/api/worktree"
|
||||
}
|
||||
|
||||
export function createRequestQueue(input: {
|
||||
fetch: typeof globalThis.fetch
|
||||
limit?: number
|
||||
slowLimit?: number
|
||||
stallMs?: number
|
||||
headersTimeoutMs?: number
|
||||
setupHeadersTimeoutMs?: number
|
||||
log?: (message: string, data: Record<string, unknown>) => void
|
||||
now?: () => number
|
||||
}) {
|
||||
@@ -46,7 +36,6 @@ export function createRequestQueue(input: {
|
||||
const slowLimit = input.slowLimit ?? requestQueueSlowLimit
|
||||
const stallMs = input.stallMs ?? requestStallMs
|
||||
const headersTimeoutMs = input.headersTimeoutMs ?? requestHeadersTimeoutMs
|
||||
const setupHeadersTimeoutMs = input.setupHeadersTimeoutMs ?? setupRequestHeadersTimeoutMs
|
||||
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
|
||||
const base = input.fetch
|
||||
const now = input.now ?? Date.now
|
||||
@@ -112,7 +101,7 @@ export function createRequestQueue(input: {
|
||||
request.signal.addEventListener("abort", () => controller.abort(request.signal.reason), { once: true })
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new DOMException("Timed out waiting for the server to respond", "TimeoutError")),
|
||||
isSetupRequest(request.method, pathname) ? setupHeadersTimeoutMs : headersTimeoutMs,
|
||||
headersTimeoutMs,
|
||||
)
|
||||
return base(new Request(request, { signal: controller.signal })).finally(() => {
|
||||
clearTimeout(timer)
|
||||
|
||||
@@ -605,10 +605,7 @@ export type SessionLogOutput =
|
||||
readonly type: "session.execution.interrupted"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "user" | "shutdown" | "superseded" | "inactivity"
|
||||
}
|
||||
readonly data: { readonly sessionID: Session.ID; readonly reason: "user" | "shutdown" | "superseded" }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -1429,7 +1426,6 @@ export type ProjectListOperation<E = never> = () => Effect.Effect<ProjectListOut
|
||||
|
||||
export type ProjectUpdateInput = {
|
||||
readonly projectID: Project.ID
|
||||
readonly canonical?: AbsolutePath | undefined
|
||||
readonly name?: string | undefined
|
||||
readonly icon?: Project.Icon | undefined
|
||||
readonly commands?: Project.Commands | undefined
|
||||
|
||||
@@ -1018,7 +1018,7 @@ const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: Proj
|
||||
preserveEffect<ProjectUpdateOutput>()(
|
||||
raw["project.update"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
payload: { canonical: input["canonical"], name: input["name"], icon: input["icon"], commands: input["commands"] },
|
||||
payload: { name: input["name"], icon: input["icon"], commands: input["commands"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1377,12 +1377,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "PATCH",
|
||||
path: `/api/project/${encodeURIComponent(input.projectID)}`,
|
||||
body: {
|
||||
canonical: input["canonical"],
|
||||
name: input["name"],
|
||||
icon: input["icon"],
|
||||
commands: input["commands"],
|
||||
},
|
||||
body: { name: input["name"], icon: input["icon"], commands: input["commands"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: false,
|
||||
|
||||
@@ -710,7 +710,7 @@ export type SessionExecutionInterrupted = {
|
||||
type: "session.execution.interrupted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; reason: "user" | "shutdown" | "superseded" | "inactivity" }
|
||||
data: { sessionID: string; reason: "user" | "shutdown" | "superseded" }
|
||||
}
|
||||
|
||||
export type SessionInstructionsUpdated = {
|
||||
@@ -4672,26 +4672,17 @@ export type ProjectListOutput = Array<Project>
|
||||
|
||||
export type ProjectUpdateInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly canonical?: {
|
||||
readonly canonical?: string
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
}["canonical"]
|
||||
readonly name?: {
|
||||
readonly canonical?: string
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
}["name"]
|
||||
readonly icon?: {
|
||||
readonly canonical?: string
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
}["icon"]
|
||||
readonly commands?: {
|
||||
readonly canonical?: string
|
||||
readonly name?: string
|
||||
readonly icon?: { readonly url?: string; readonly override?: string; readonly color?: string }
|
||||
readonly commands?: { readonly start?: string }
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ConfigAgentV1 } from "../../v1/config/agent.js"
|
||||
import { ConfigMigrateV1 } from "../../v1/config/migrate.js"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Permission } from "../../permission.js"
|
||||
import type { LocationMutation } from "../../location-mutation.js"
|
||||
import type { FileAccess } from "../../file-access.js"
|
||||
import type { ReadTool } from "../../tool/plugin/read.js"
|
||||
import type { EditTool } from "../../tool/plugin/edit.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
@@ -27,10 +27,7 @@ const sourceDirectories = ["agent", "agents", "mode", "modes"] as const
|
||||
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
|
||||
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
|
||||
const decodeConfig = Schema.decodeUnknownOption(Info)
|
||||
type PathAction =
|
||||
| LocationMutation.ExternalDirectoryAuthorization["action"]
|
||||
| typeof ReadTool.name
|
||||
| typeof EditTool.name
|
||||
type PathAction = FileAccess.ExternalDirectoryAuthorization["action"] | typeof ReadTool.name | typeof EditTool.name
|
||||
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
|
||||
const agentKeys = new Set(["variant", ...Object.keys(ConfigAgent.Info.fields)])
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
export * as Credential from "./credential.js"
|
||||
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { Cause, Context, Effect, Layer, Schema } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { CredentialTable } from "./credential/sql.js"
|
||||
import { ErrorSummary } from "./util/error-summary.js"
|
||||
|
||||
export const ID = Credential.ID
|
||||
export type ID = Credential.ID
|
||||
@@ -124,21 +123,7 @@ const layer = Layer.effect(
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logError("credential create failed", {
|
||||
credentialID: credential.id,
|
||||
integrationID: credential.integrationID,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
yield* Effect.logInfo("credential created", {
|
||||
credentialID: credential.id,
|
||||
integrationID: credential.integrationID,
|
||||
type: credential.value.type,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
yield* bus.publish(
|
||||
Event.Switched,
|
||||
@@ -169,19 +154,8 @@ const layer = Layer.effect(
|
||||
return credential.integration_id
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logError("credential activate failed", {
|
||||
credentialID: id,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
if (integrationID) {
|
||||
yield* Effect.logInfo("credential activated", { integrationID, credentialID: id })
|
||||
yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
|
||||
}
|
||||
.pipe(Effect.orDie)
|
||||
if (integrationID) yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
|
||||
}),
|
||||
update: Effect.fn("Credential.update")(function* (id, updates) {
|
||||
if (updates.label === undefined && updates.value === undefined) return
|
||||
@@ -190,46 +164,15 @@ const layer = Layer.effect(
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.id, id))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logError("credential update lookup failed", {
|
||||
credentialID: id,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
if (!credential?.integrationID) {
|
||||
yield* Effect.logWarning("credential update skipped", { credentialID: id, reason: "credential_missing" })
|
||||
return
|
||||
}
|
||||
.pipe(Effect.orDie)
|
||||
if (!credential?.integrationID) return
|
||||
if (updates.label === credential.label && updates.value === undefined) return
|
||||
const updated = yield* db
|
||||
yield* db
|
||||
.update(CredentialTable)
|
||||
.set({ label: updates.label, value: updates.value })
|
||||
.where(eq(CredentialTable.id, id))
|
||||
.returning({ id: CredentialTable.id })
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logError("credential update failed", {
|
||||
credentialID: id,
|
||||
integrationID: credential.integrationID,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
if (!updated) {
|
||||
yield* Effect.logWarning("credential update skipped", { credentialID: id, reason: "credential_removed" })
|
||||
return
|
||||
}
|
||||
yield* Effect.logInfo("credential updated", {
|
||||
credentialID: id,
|
||||
integrationID: credential.integrationID,
|
||||
valueChanged: updates.value !== undefined,
|
||||
labelChanged: updates.label !== undefined && updates.label !== credential.label,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
if (updates.label !== undefined && updates.label !== credential.label)
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
}),
|
||||
@@ -248,8 +191,7 @@ const layer = Layer.effect(
|
||||
.get()
|
||||
: undefined
|
||||
yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run()
|
||||
if (!credential.integration_id || active?.id !== id)
|
||||
return { switched: false as const, integrationID: credential.integration_id }
|
||||
if (!credential.integration_id || active?.id !== id) return { switched: false as const }
|
||||
const replacement = yield* tx
|
||||
.select({ id: CredentialTable.id })
|
||||
.from(CredentialTable)
|
||||
@@ -275,22 +217,8 @@ const layer = Layer.effect(
|
||||
}
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logError("credential remove failed", {
|
||||
credentialID: id,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (!removed) return
|
||||
yield* Effect.logInfo("credential removed", {
|
||||
credentialID: id,
|
||||
integrationID: removed.integrationID,
|
||||
active: removed.switched,
|
||||
...(removed.switched ? { replacementID: removed.credentialID } : {}),
|
||||
})
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
if (removed.switched)
|
||||
yield* bus.publish(
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
export * as FileAccess from "./file-access.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Array, Context, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Location } from "./location.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Project } from "./project.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import type { SessionErrors } from "./session/error.js"
|
||||
import type { Tool } from "./tool.js"
|
||||
|
||||
export const Kind = Schema.Literals(["file", "directory"])
|
||||
export type Kind = typeof Kind.Type
|
||||
|
||||
export const ResolveInput = Schema.Struct({
|
||||
path: Schema.String,
|
||||
/** Selects the external approval boundary; it does not validate the target type. */
|
||||
kind: Kind.pipe(Schema.optional),
|
||||
})
|
||||
export type ResolveInput = typeof ResolveInput.Type
|
||||
|
||||
export interface ExternalDirectoryAuthorization {
|
||||
readonly action: "external_directory"
|
||||
/** Lexical directory used as the external approval boundary. */
|
||||
readonly directory: AbsolutePath
|
||||
readonly resource: string
|
||||
readonly save: string
|
||||
}
|
||||
|
||||
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
|
||||
action: input.action,
|
||||
resources: [input.resource],
|
||||
save: [input.save],
|
||||
})
|
||||
|
||||
export interface Target {
|
||||
readonly absolute: AbsolutePath
|
||||
/** Location-relative for internal paths, absolute for external paths. */
|
||||
readonly resource: string
|
||||
readonly externalDirectory?: ExternalDirectoryAuthorization
|
||||
}
|
||||
|
||||
export type Invocation = Pick<Tool.Context, "sessionID" | "agent" | "messageID" | "id">
|
||||
|
||||
export interface ReadOptions {
|
||||
/** A target already authorized by this invocation, used for filename recovery. */
|
||||
readonly siblingOf: Target
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Resolve a lexical path and its permission resources, without requesting approval. */
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
|
||||
/** Approve external directories in one batch, preserving first-seen resource order. */
|
||||
readonly authorizeExternal: (
|
||||
targets: readonly Target[],
|
||||
context: Invocation,
|
||||
metadata?: Permission.AssertInput["metadata"],
|
||||
) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
|
||||
/** Resolve a read target and obtain external-directory approval before read approval. */
|
||||
readonly authorizeRead: (
|
||||
file: string,
|
||||
context: Invocation,
|
||||
options?: ReadOptions,
|
||||
) => Effect.Effect<Target, FSUtil.Error | Error | SessionErrors.NotFoundError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileAccess") {}
|
||||
|
||||
/** Expand a leading ~ and normalize Windows shell paths before lexical resolution. */
|
||||
export const resolvePath = (directory: string, input: string, home = Global.Path.home) => {
|
||||
const normalized = FSUtil.windowsPath(input)
|
||||
return path.resolve(
|
||||
directory,
|
||||
normalized === "~"
|
||||
? home
|
||||
: normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))
|
||||
? path.join(home, normalized.slice(2))
|
||||
: normalized,
|
||||
)
|
||||
}
|
||||
|
||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||
const invocation = (context: Invocation) => ({
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool" as const, messageID: context.messageID, id: context.id },
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
const resolve = Effect.fn("FileAccess.resolve")(function* (input: ResolveInput) {
|
||||
const absolute = AbsolutePath.make(resolvePath(location.directory, input.path))
|
||||
const worktree = path.resolve(location.project.directory)
|
||||
const internal =
|
||||
FSUtil.contains(location.directory, absolute) ||
|
||||
(worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute))
|
||||
if (internal) {
|
||||
return {
|
||||
absolute,
|
||||
resource: slash(path.relative(location.directory, absolute) || "."),
|
||||
} satisfies Target
|
||||
}
|
||||
const type =
|
||||
input.kind === "directory"
|
||||
? "Directory"
|
||||
: input.kind === "file"
|
||||
? "File"
|
||||
: (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)))
|
||||
?.type
|
||||
const directory = AbsolutePath.make(type === "Directory" ? absolute : path.dirname(absolute))
|
||||
return {
|
||||
absolute,
|
||||
resource: slash(absolute),
|
||||
externalDirectory: {
|
||||
action: "external_directory",
|
||||
directory,
|
||||
resource: slash(path.join(directory, "*")),
|
||||
save: slash(path.join((yield* Project.root(fs, directory)) ?? directory, "*")),
|
||||
},
|
||||
} satisfies Target
|
||||
})
|
||||
|
||||
const authorizeExternal = Effect.fn("FileAccess.authorizeExternal")(function* (
|
||||
targets: readonly Target[],
|
||||
context: Invocation,
|
||||
metadata?: Permission.AssertInput["metadata"],
|
||||
) {
|
||||
const external = Array.dedupeWith(
|
||||
targets.flatMap((target) => (target.externalDirectory ? [target.externalDirectory] : [])),
|
||||
(left, right) => left.resource === right.resource,
|
||||
)
|
||||
if (external.length === 0) return
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: external.map((item) => item.resource),
|
||||
save: external.map((item) => item.save),
|
||||
...(metadata === undefined ? {} : { metadata }),
|
||||
...invocation(context),
|
||||
})
|
||||
})
|
||||
|
||||
const authorizeRead = Effect.fn("FileAccess.authorizeRead")(function* (
|
||||
file: string,
|
||||
context: Invocation,
|
||||
options?: ReadOptions,
|
||||
) {
|
||||
const target = yield* resolve({ path: file, kind: options ? "file" : undefined })
|
||||
const sibling = options && path.dirname(target.absolute) === path.dirname(options.siblingOf.absolute)
|
||||
|
||||
// Filename recovery shares the directory approval, but checks the recovered file's own read rules.
|
||||
if (!sibling) yield* authorizeExternal([target], context)
|
||||
yield* permission.assert({
|
||||
action: "read",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
...invocation(context),
|
||||
})
|
||||
return target
|
||||
})
|
||||
|
||||
return Service.of({ resolve, authorizeExternal, authorizeRead })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Permission.node] })
|
||||
@@ -7,11 +7,9 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Environment } from "./environment/index.js"
|
||||
import type { Files } from "./environment/index.js"
|
||||
import type { FileAccess } from "./file-access.js"
|
||||
|
||||
export interface Target {
|
||||
readonly absolute: string
|
||||
readonly resource: string
|
||||
}
|
||||
export type Target = Pick<FileAccess.Target, "absolute" | "resource">
|
||||
|
||||
export interface WriteInput {
|
||||
readonly target: Target
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Image } from "./image.js"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationMutation } from "./location-mutation.js"
|
||||
import { FileAccess } from "./file-access.js"
|
||||
import { ModelResolver } from "./model-resolver.js"
|
||||
import { Mcp } from "./mcp/index.js"
|
||||
import { Permission } from "./permission.js"
|
||||
@@ -82,7 +82,7 @@ const nodes = [
|
||||
Skill.node,
|
||||
InstructionBuiltIns.node,
|
||||
InstructionDiscovery.node,
|
||||
LocationMutation.node,
|
||||
FileAccess.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
Mcp.node,
|
||||
|
||||
@@ -5,8 +5,6 @@ import { Bus } from "./bus.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { SessionEvent } from "./session/event.js"
|
||||
import { SessionExecution } from "./session/execution.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
||||
const isSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
@@ -20,11 +18,9 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
|
||||
const clock = yield* Clock.Clock
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const sessions = yield* SessionStore.Service
|
||||
const timeToLive = Duration.toMillis(options.timeToLive ?? "60 minutes")
|
||||
const entries = new Map<string, { readonly ref: Location.Ref; expiresAt: number }>()
|
||||
const key = (ref: Location.Ref) => `${LocationServiceMap.canonical(ref).directory}\0${ref.workspaceID ?? ""}`
|
||||
const key = (ref: Location.Ref) => `${ref.directory}\0${ref.workspaceID ?? ""}`
|
||||
const touch = (ref: Location.Ref) =>
|
||||
Effect.sync(() => {
|
||||
entries.set(key(ref), { ref, expiresAt: clock.currentTimeMillisUnsafe() + timeToLive })
|
||||
@@ -43,44 +39,26 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
|
||||
yield* Effect.sleep(options.sweepInterval ?? "1 minute")
|
||||
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
|
||||
const cached = new Set(refs.map(key))
|
||||
yield* Effect.forEach(refs, (ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)), { discard: true })
|
||||
yield* Effect.forEach(
|
||||
refs,
|
||||
(ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)),
|
||||
{ discard: true },
|
||||
)
|
||||
for (const id of entries.keys()) {
|
||||
if (!cached.has(id)) entries.delete(id)
|
||||
}
|
||||
const now = clock.currentTimeMillisUnsafe()
|
||||
const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now)
|
||||
if (expired.length === 0) return
|
||||
const active = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID))
|
||||
yield* Effect.forEach(
|
||||
expired,
|
||||
(entry) =>
|
||||
Effect.gen(function* () {
|
||||
const owners = active.flatMap((session) =>
|
||||
session && key(session.location) === key(entry.ref) ? [session] : [],
|
||||
)
|
||||
// Invalidation only detaches the cache entry; borrowers retain the old
|
||||
// graph. Stop its executions and settle tool cleanup before detaching it.
|
||||
yield* Effect.forEach(
|
||||
owners,
|
||||
(session) => execution.interrupt(session.id, { reason: "inactivity", awaitSettlement: true }),
|
||||
{
|
||||
discard: true,
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
)
|
||||
const remaining = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID))
|
||||
// New work admitted during cleanup may now own the cached graph.
|
||||
if (remaining.some((session) => session && key(session.location) === key(entry.ref))) {
|
||||
yield* touch(entry.ref)
|
||||
return
|
||||
}
|
||||
entries.delete(key(entry.ref))
|
||||
yield* Effect.logInfo("location services evicted", {
|
||||
directory: entry.ref.directory,
|
||||
workspaceID: entry.ref.workspaceID,
|
||||
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
|
||||
}),
|
||||
{ discard: true, concurrency: "unbounded" },
|
||||
(entry) => {
|
||||
entries.delete(key(entry.ref))
|
||||
return Effect.logInfo("location services evicted", {
|
||||
directory: entry.ref.directory,
|
||||
workspaceID: entry.ref.workspaceID,
|
||||
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
|
||||
},
|
||||
{ discard: true },
|
||||
)
|
||||
}).pipe(Effect.forever, Effect.forkScoped)
|
||||
|
||||
@@ -92,5 +70,5 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
deps: [Bus.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node],
|
||||
deps: [Bus.node, LocationServiceMap.node],
|
||||
})
|
||||
|
||||
@@ -1,130 +1,3 @@
|
||||
export * as LocationMutation from "./location-mutation.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "./location.js"
|
||||
import { Project } from "./project.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
|
||||
export const Kind = Schema.Literals(["file", "directory"])
|
||||
export type Kind = typeof Kind.Type
|
||||
|
||||
/**
|
||||
* Mutation paths do not accept project references. A leading `~` expands to
|
||||
* the home directory; other relative paths resolve from the active Location.
|
||||
* Paths outside it and its non-root project worktree require separate
|
||||
* `external_directory` approval.
|
||||
*/
|
||||
export const ResolveInput = Schema.Struct({
|
||||
path: Schema.String,
|
||||
/** Selects the external approval boundary; it does not validate the target type. */
|
||||
kind: Kind.pipe(Schema.optional),
|
||||
})
|
||||
export type ResolveInput = typeof ResolveInput.Type
|
||||
|
||||
export interface ExternalDirectoryAuthorization {
|
||||
readonly action: "external_directory"
|
||||
/** Lexical directory used as the external approval boundary. */
|
||||
readonly directory: string
|
||||
/** `external_directory` permission resource. */
|
||||
readonly resource: string
|
||||
readonly save: string
|
||||
}
|
||||
|
||||
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
|
||||
action: input.action,
|
||||
resources: [input.resource],
|
||||
save: [input.save],
|
||||
})
|
||||
|
||||
export interface Target {
|
||||
/** Absolute lexical path. */
|
||||
readonly absolute: string
|
||||
/** Permission resource: Location-relative for internal paths, absolute for external paths. */
|
||||
readonly resource: string
|
||||
readonly externalDirectory?: ExternalDirectoryAuthorization
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Resolve a path and derive its permission resources. A leading `~` expands
|
||||
* to the home directory; other relative paths resolve from the Location.
|
||||
* Paths outside it and its non-root project worktree require separate
|
||||
* `external_directory` approval. This does not approve the mutation.
|
||||
*/
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
|
||||
}
|
||||
|
||||
/** Lexical absolute path, normalizing Windows shell paths and expanding `~` before resolution. */
|
||||
export const resolvePath = (directory: string, input: string, home = Global.Path.home) => {
|
||||
const normalized = FSUtil.windowsPath(input)
|
||||
return path.resolve(
|
||||
directory,
|
||||
normalized === "~"
|
||||
? home
|
||||
: normalized.startsWith("~/") || (process.platform === "win32" && normalized.startsWith("~\\"))
|
||||
? path.join(home, normalized.slice(2))
|
||||
: normalized,
|
||||
)
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
|
||||
|
||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
|
||||
const absolute = resolvePath(location.directory, input.path)
|
||||
const worktree = path.resolve(location.project.directory)
|
||||
const internal =
|
||||
FSUtil.contains(location.directory, absolute) ||
|
||||
(worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute))
|
||||
if (internal) {
|
||||
return {
|
||||
absolute,
|
||||
resource: slash(path.relative(location.directory, absolute) || "."),
|
||||
} satisfies Target
|
||||
}
|
||||
const type =
|
||||
input.kind === "directory"
|
||||
? "Directory"
|
||||
: input.kind === "file"
|
||||
? "File"
|
||||
: (yield* fs.stat(absolute).pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)))
|
||||
?.type
|
||||
const externalDirectory = type === "Directory" ? absolute : path.dirname(absolute)
|
||||
const externalResource = slash(path.join(externalDirectory, "*"))
|
||||
return {
|
||||
absolute,
|
||||
resource: slash(absolute),
|
||||
externalDirectory: {
|
||||
action: "external_directory",
|
||||
directory: externalDirectory,
|
||||
resource: externalResource,
|
||||
save: slash(
|
||||
path.join(
|
||||
(yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
|
||||
"*",
|
||||
),
|
||||
),
|
||||
},
|
||||
} satisfies Target
|
||||
})
|
||||
|
||||
return Service.of({ resolve })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [FSUtil.node, Location.node],
|
||||
})
|
||||
/** @deprecated Use FileAccess for path resolution and authorization. */
|
||||
export { FileAccess as LocationMutation } from "./file-access.js"
|
||||
export * from "./file-access.js"
|
||||
|
||||
@@ -231,8 +231,6 @@ export const connect = Effect.fnUntraced(function* (
|
||||
}
|
||||
if (!URL.canParse(config.url))
|
||||
return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
|
||||
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
const fetch = yield* McpOAuth.loggedFetch({ server, directory })
|
||||
// Prefer raw tools for our Code Mode without changing the configured URL used for OAuth identity.
|
||||
const url = new URL(config.url)
|
||||
const addedCodemode = config.codemode !== false && !url.searchParams.has("codemode")
|
||||
@@ -242,7 +240,6 @@ export const connect = Effect.fnUntraced(function* (
|
||||
new StreamableHTTPClientTransport(url, {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
authProvider,
|
||||
fetch,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -259,41 +259,27 @@ export const layer = (options?: Options) =>
|
||||
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
|
||||
const remote = entry.config
|
||||
const oauth = remote.oauth || undefined
|
||||
const run = Effect.runPromiseWith(yield* Effect.context())
|
||||
const base = {
|
||||
redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback",
|
||||
scope: oauth?.scope,
|
||||
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
|
||||
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
|
||||
onRedirect: () => run(Effect.logInfo("mcp oauth authorization required")),
|
||||
onRedirect: () => {},
|
||||
}
|
||||
const found = (yield* credentials.list(entry.integrationID)).at(-1)
|
||||
if (!found || found.value.type !== "oauth") {
|
||||
if (!found || found.value.type !== "oauth")
|
||||
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
|
||||
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
|
||||
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
|
||||
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
|
||||
yield* Effect.logInfo("mcp oauth credential unavailable", {
|
||||
integrationID: entry.integrationID,
|
||||
reason: found ? "not_oauth" : "missing",
|
||||
})
|
||||
return McpOAuth.provider({ ...base, store: McpOAuth.memoryStore() })
|
||||
}
|
||||
const credentialID = found.id
|
||||
const methodID = found.value.methodID
|
||||
const fields = { credentialID, integrationID: entry.integrationID }
|
||||
yield* Effect.logInfo("mcp oauth credential loaded", {
|
||||
...fields,
|
||||
hasRefreshToken: Boolean(found.value.refresh),
|
||||
hasClientInformation: Boolean(McpOAuth.clientFromCredential(found.value)),
|
||||
expiresAt: found.value.expires,
|
||||
expired: found.value.expires !== 0 && found.value.expires <= Date.now(),
|
||||
})
|
||||
// Tracks the refresh token this provider last presented, so invalidate can tell whether the SDK
|
||||
// rejected the currently-stored credential or a snapshot another connection has already rotated past.
|
||||
let presented = found.value.refresh
|
||||
const readOAuthCredential = async () => {
|
||||
const stored = await run(credentials.get(credentialID))
|
||||
const stored = await Effect.runPromise(credentials.get(credentialID))
|
||||
return stored?.value.type === "oauth" ? stored.value : undefined
|
||||
}
|
||||
return McpOAuth.provider({
|
||||
@@ -304,25 +290,10 @@ export const layer = (options?: Options) =>
|
||||
// strand every connection in needs_auth until a manual re-auth. Credential deletion notifies all locations;
|
||||
// reconnects remain serialized by the server lock.
|
||||
invalidate: async (scope) => {
|
||||
if (scope === "verifier" || scope === "discovery") {
|
||||
await run(
|
||||
Effect.logDebug("mcp oauth invalidation skipped", { ...fields, scope, reason: "not_credentials" }),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (scope === "verifier" || scope === "discovery") return
|
||||
const oauth = await readOAuthCredential()
|
||||
if (!oauth || oauth.refresh !== presented) {
|
||||
await run(
|
||||
Effect.logInfo("mcp oauth invalidation skipped", {
|
||||
...fields,
|
||||
scope,
|
||||
reason: oauth ? "token_rotated" : "credential_missing",
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
await run(Effect.logWarning("mcp oauth credential invalidation requested", { ...fields, scope }))
|
||||
await run(credentials.remove(credentialID))
|
||||
if (!oauth || oauth.refresh !== presented) return
|
||||
await Effect.runPromise(credentials.remove(credentialID))
|
||||
},
|
||||
// Always read the latest stored tokens instead of caching at connect time: with refresh-token rotation,
|
||||
// a cached snapshot goes stale the moment another connection refreshes, and re-presenting the consumed
|
||||
@@ -343,16 +314,7 @@ export const layer = (options?: Options) =>
|
||||
client: previous ? McpOAuth.clientFromCredential(previous) : undefined,
|
||||
})
|
||||
presented = value.refresh
|
||||
await run(
|
||||
Effect.logInfo("mcp oauth tokens received", {
|
||||
...fields,
|
||||
credentialPresent: Boolean(previous),
|
||||
refreshRotated: Boolean(previous && previous.refresh !== value.refresh),
|
||||
hasRefreshToken: Boolean(value.refresh),
|
||||
expiresAt: value.expires,
|
||||
}),
|
||||
)
|
||||
await run(credentials.update(credentialID, { value }))
|
||||
await Effect.runPromise(credentials.update(credentialID, { value }))
|
||||
},
|
||||
clientInformation: async () => {
|
||||
const oauth = await readOAuthCredential()
|
||||
@@ -598,10 +560,7 @@ export const layer = (options?: Options) =>
|
||||
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
|
||||
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
}).pipe(
|
||||
Effect.ensuring(entry.startup.open),
|
||||
Effect.annotateLogs({ server: name, directory: location.directory, connectionID: crypto.randomUUID() }),
|
||||
)
|
||||
}).pipe(Effect.ensuring(entry.startup.open))
|
||||
|
||||
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||
const scope = entry.scope
|
||||
|
||||
@@ -1,63 +1,12 @@
|
||||
export * as McpOAuth from "./oauth.js"
|
||||
|
||||
import { auth, parseErrorResponse, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"
|
||||
import { Cause, Deferred, Effect } from "effect"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { OauthCallbackPage } from "../oauth/page.js"
|
||||
import type { Integration } from "../integration.js"
|
||||
import { ErrorSummary } from "../util/error-summary.js"
|
||||
|
||||
/** Observe OAuth failures before the SDK handles them by invalidating credentials or redirecting. */
|
||||
export const loggedFetch = (fields: { readonly server: string; readonly directory?: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const run = Effect.runPromiseWith(yield* Effect.context())
|
||||
const request: FetchLike = (url, init) => {
|
||||
const grant = init?.body instanceof URLSearchParams ? init.body.get("grant_type") : undefined
|
||||
const operation = grant === "refresh_token" ? "refresh" : grant === "authorization_code" ? "exchange" : undefined
|
||||
const started = Date.now()
|
||||
return run(
|
||||
Effect.gen(function* () {
|
||||
if (operation) yield* Effect.logInfo("mcp oauth request started")
|
||||
const response = yield* Effect.tryPromise({ try: () => fetch(url, init), catch: (error) => error })
|
||||
const result = { status: response.status, durationMs: Date.now() - started }
|
||||
if (operation && !response.ok) {
|
||||
// Only retain the SDK's standard error code. Descriptions and raw bodies can echo credentials.
|
||||
const error = yield* Effect.tryPromise(async () => parseErrorResponse(await response.clone().text())).pipe(
|
||||
Effect.map((error) => error.errorCode),
|
||||
Effect.orElseSucceed(() => "unreadable_response"),
|
||||
)
|
||||
yield* Effect.logWarning("mcp oauth request rejected", { ...result, error })
|
||||
}
|
||||
if (operation && response.ok) {
|
||||
yield* Effect.logInfo("mcp oauth request succeeded", result)
|
||||
}
|
||||
if (!operation && (response.status === 401 || response.status === 403)) {
|
||||
yield* Effect.logWarning("mcp http authentication rejected", result)
|
||||
}
|
||||
return response
|
||||
}).pipe(
|
||||
Effect.onError((cause) => {
|
||||
if (init?.signal?.aborted) return Effect.logDebug("mcp http request aborted")
|
||||
return Effect.logWarning("mcp http request failed", {
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
durationMs: Date.now() - started,
|
||||
})
|
||||
}),
|
||||
Effect.annotateLogs({
|
||||
...fields,
|
||||
requestID: crypto.randomUUID(),
|
||||
origin: new URL(url).origin,
|
||||
method: init?.method ?? "GET",
|
||||
...(operation ? { operation } : {}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
return request
|
||||
})
|
||||
|
||||
/** Persists the OAuth artifacts for one MCP server session: DCR client info, PKCE verifier, and tokens. */
|
||||
export interface Store {
|
||||
@@ -196,12 +145,6 @@ export const authorize = (input: {
|
||||
readonly methodID: Integration.MethodID
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const fields = { server: input.name, methodID: input.methodID, oauthAttemptID: crypto.randomUUID() }
|
||||
const context = yield* Effect.context()
|
||||
const run = Effect.runPromiseWith(context)
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const fetchFn = yield* loggedFetch({ server: input.name }).pipe(Effect.annotateLogs(fields))
|
||||
yield* Effect.logInfo("mcp oauth authorization started", fields)
|
||||
const oauth = input.config.oauth || undefined
|
||||
const store = memoryStore()
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
@@ -217,20 +160,19 @@ export const authorize = (input: {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const fail = (reason: string, failure: string) => {
|
||||
runFork(Effect.logWarning("mcp oauth callback rejected", { ...fields, reason: failure }))
|
||||
const fail = (reason: string) => {
|
||||
Effect.runFork(Deferred.fail(code, new Error(reason)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(reason, { provider: input.name }))
|
||||
}
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||
if (error) return fail(error, "authorization_error")
|
||||
if (error) return fail(error)
|
||||
// Reject a redirect whose state does not match what we issued: this is the CSRF defense the
|
||||
// state parameter exists for, so an attacker can't inject their own authorization code.
|
||||
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch", "state_mismatch")
|
||||
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch")
|
||||
const value = url.searchParams.get("code")
|
||||
if (!value) return fail("Missing authorization code", "missing_code")
|
||||
if (!value) return fail("Missing authorization code")
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
|
||||
})
|
||||
@@ -260,7 +202,6 @@ export const authorize = (input: {
|
||||
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
|
||||
onRedirect: (url) => {
|
||||
authorizationUrl = url
|
||||
return run(Effect.logInfo("mcp oauth awaiting authorization", fields))
|
||||
},
|
||||
store,
|
||||
})
|
||||
@@ -269,16 +210,11 @@ export const authorize = (input: {
|
||||
const tokens = yield* Effect.promise(() => store.tokens())
|
||||
if (!tokens) return yield* Effect.fail(new Error(`MCP server "${input.name}" did not return OAuth tokens`))
|
||||
const client = yield* Effect.promise(() => store.clientInformation())
|
||||
yield* Effect.logInfo("mcp oauth authorization completed", {
|
||||
...fields,
|
||||
hasRefreshToken: Boolean(tokens.refresh_token),
|
||||
expiresIn: tokens.expires_in,
|
||||
})
|
||||
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
|
||||
})
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope, fetchFn }),
|
||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
|
||||
@@ -293,28 +229,11 @@ export const authorize = (input: {
|
||||
Effect.flatMap((value) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
auth(oauthProvider, {
|
||||
serverUrl: input.config.url,
|
||||
authorizationCode: value,
|
||||
scope: oauth?.scope,
|
||||
fetchFn,
|
||||
}),
|
||||
auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}),
|
||||
),
|
||||
Effect.flatMap(() => finalize),
|
||||
Effect.onError((cause) =>
|
||||
Effect.logWarning("mcp oauth authorization failed", { errors: ErrorSummary.from(Cause.squash(cause)) }),
|
||||
),
|
||||
Effect.annotateLogs(fields),
|
||||
),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.onError((cause) =>
|
||||
Effect.logWarning("mcp oauth authorization setup failed", {
|
||||
server: input.name,
|
||||
methodID: input.methodID,
|
||||
errors: ErrorSummary.from(Cause.squash(cause)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ConfigWorktreePlugin } from "../config/plugin/worktree.js"
|
||||
import { Worktree } from "../worktree.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Environment } from "../environment/index.js"
|
||||
import { FileAccess } from "../file-access.js"
|
||||
import { FileMutation } from "../file-mutation.js"
|
||||
import { Formatter } from "../formatter.js"
|
||||
import { Form } from "../form.js"
|
||||
@@ -44,7 +45,6 @@ import { Integration } from "../integration.js"
|
||||
import { Job } from "../job.js"
|
||||
import { KV } from "../kv.js"
|
||||
import { Location } from "../location.js"
|
||||
import { LocationMutation } from "../location-mutation.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
@@ -102,6 +102,7 @@ const services = [
|
||||
Credential.Service,
|
||||
Bus.Service,
|
||||
Environment.Service,
|
||||
FileAccess.Service,
|
||||
FileMutation.Service,
|
||||
Formatter.Service,
|
||||
LocationWatcherPolicy.Service,
|
||||
@@ -115,7 +116,6 @@ const services = [
|
||||
Job.Service,
|
||||
KV.Service,
|
||||
Location.Service,
|
||||
LocationMutation.Service,
|
||||
ModelsDev.Service,
|
||||
Mcp.Service,
|
||||
Npm.Service,
|
||||
@@ -151,6 +151,7 @@ export const requirements = LayerNode.group([
|
||||
Credential.node,
|
||||
Bus.node,
|
||||
Environment.node,
|
||||
FileAccess.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
LocationWatcherPolicy.node,
|
||||
@@ -164,7 +165,6 @@ export const requirements = LayerNode.group([
|
||||
Job.node,
|
||||
KV.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
ModelsDev.node,
|
||||
Mcp.node,
|
||||
Npm.node,
|
||||
|
||||
@@ -52,7 +52,7 @@ export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fie
|
||||
}),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http)).execute(request)
|
||||
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
const body = yield* collectBoundedResponseBody(
|
||||
response,
|
||||
MAX_RESPONSE_BYTES,
|
||||
@@ -60,7 +60,6 @@ export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fie
|
||||
)
|
||||
return yield* parseResponse(body.toString("utf8"), schema.output)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
|
||||
|
||||
@@ -63,11 +63,10 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
max_results: 8,
|
||||
}),
|
||||
)
|
||||
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http))
|
||||
const response = yield* HttpClient.filterStatusOk(http)
|
||||
.execute(request)
|
||||
.pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(SearchResponse)),
|
||||
Effect.scoped,
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error("Tavily web search request timed out")),
|
||||
|
||||
@@ -212,7 +212,6 @@ const layer = Layer.effect(
|
||||
const row = yield* db
|
||||
.update(ProjectTable)
|
||||
.set({
|
||||
worktree: input.canonical,
|
||||
name: input.name === undefined ? undefined : input.name || null,
|
||||
icon_url_override: input.icon?.override === undefined ? undefined : input.icon.override || null,
|
||||
icon_color: input.icon?.color === undefined ? undefined : input.icon.color || null,
|
||||
|
||||
@@ -28,17 +28,9 @@ export interface Interface {
|
||||
* Interrupt active work owned by this process. Idle interruption is a no-op. Resolves once
|
||||
* the interruption is accepted; cleanup settles asynchronously in the execution fiber.
|
||||
* Returns whether an active execution was interrupted. Compose with `awaitIdle` when
|
||||
* settlement matters. `awaitSettlement` waits only for the interrupted execution,
|
||||
* rather than fresh work admitted during its cleanup.
|
||||
* settlement matters.
|
||||
*/
|
||||
readonly interrupt: (
|
||||
sessionID: SessionSchema.ID,
|
||||
options?: {
|
||||
readonly continue?: boolean
|
||||
readonly reason?: "user" | "inactivity"
|
||||
readonly awaitSettlement?: boolean
|
||||
},
|
||||
) => Effect.Effect<boolean>
|
||||
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<boolean>
|
||||
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
|
||||
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
}
|
||||
@@ -46,7 +38,7 @@ export interface Interface {
|
||||
/** Routes execution from a Session ID to its selected instance's runner. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionExecution") {}
|
||||
|
||||
type InterruptReason = "user" | "shutdown" | "inactivity"
|
||||
type InterruptReason = "user" | "shutdown"
|
||||
|
||||
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: InterruptReason) {
|
||||
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
|
||||
@@ -128,8 +120,9 @@ export const layer = Layer.effect(
|
||||
return
|
||||
}
|
||||
if (outcome.type === "interrupted") {
|
||||
// Deliberate stops release the claim; shutdown keeps it for restart continuity.
|
||||
if (outcome.reason !== "shutdown") yield* jobs.cancel(sessionID)
|
||||
// A user cancel releases the claim: the turn must not resurrect at the next
|
||||
// boot. Shutdown interruption keeps it for restart continuity.
|
||||
if (outcome.reason === "user") yield* jobs.cancel(sessionID)
|
||||
yield* bus.publish(
|
||||
SessionEvent.Execution.Interrupted,
|
||||
{ sessionID, reason: outcome.reason },
|
||||
@@ -154,7 +147,7 @@ export const layer = Layer.effect(
|
||||
isActive: coordinator.isActive,
|
||||
interrupt: (sessionID, options) =>
|
||||
Effect.gen(function* () {
|
||||
const interrupted = yield* coordinator.interrupt(sessionID, options?.reason ?? "user", options)
|
||||
const interrupted = yield* coordinator.interrupt(sessionID, "user")
|
||||
if (!options?.continue) return interrupted
|
||||
// Resume steering input and between-turn control work from the interrupted
|
||||
// intent. Queued next-turn prompts stay parked: a steer-scoped drain never
|
||||
|
||||
@@ -17,14 +17,9 @@ export interface Coordinator<Key, E, Reason = never> {
|
||||
* Stops the active execution and clears its doorbell. No-op when idle. Resolves once the
|
||||
* interruption is accepted, not when cleanup settles: the execution fiber finishes its
|
||||
* finalizers and settled hook on its own time. Returns whether an active execution was
|
||||
* interrupted. `awaitSettlement` waits for this execution's cleanup and settled hook,
|
||||
* without following fresh work admitted during cleanup. `awaitIdle` follows successors too.
|
||||
* interrupted. Compose with `awaitIdle` for settlement.
|
||||
*/
|
||||
readonly interrupt: (
|
||||
key: Key,
|
||||
reason?: Reason,
|
||||
options?: { readonly awaitSettlement?: boolean },
|
||||
) => Effect.Effect<boolean>
|
||||
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<boolean>
|
||||
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
|
||||
readonly awaitIdle: (key: Key) => Effect.Effect<void>
|
||||
}
|
||||
@@ -175,22 +170,5 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
return Deferred.await(execution.done).pipe(Effect.ignoreCause, Effect.andThen(awaitIdle(key)))
|
||||
})
|
||||
|
||||
return {
|
||||
active: Effect.sync(() => new Set(executions.keys())),
|
||||
isActive,
|
||||
run,
|
||||
wake,
|
||||
interrupt: (key, reason, options) =>
|
||||
Effect.suspend(() => {
|
||||
const execution = executions.get(key)
|
||||
return interrupt(key, reason).pipe(
|
||||
Effect.tap(() =>
|
||||
options?.awaitSettlement && execution
|
||||
? Deferred.await(execution.done).pipe(Effect.ignoreCause)
|
||||
: Effect.void,
|
||||
),
|
||||
)
|
||||
}),
|
||||
awaitIdle,
|
||||
}
|
||||
return { active: Effect.sync(() => new Set(executions.keys())), isActive, run, wake, interrupt, awaitIdle }
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Environment } from "../../environment/index.js"
|
||||
import { FileMutation } from "../../file-mutation.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { FileAccess } from "../../file-access.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { fileDiff } from "./file-diff.js"
|
||||
|
||||
@@ -109,7 +109,7 @@ const findLineOccurrences = (content: string, search: string) => {
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.edit",
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: Context) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
@@ -143,16 +143,8 @@ export const Plugin = {
|
||||
})
|
||||
}
|
||||
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external) {
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
})
|
||||
}
|
||||
const target = yield* access.resolve({ path: input.path, kind: "file" })
|
||||
yield* access.authorizeExternal([target], context)
|
||||
|
||||
const original = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () =>
|
||||
@@ -218,7 +210,7 @@ export const Plugin = {
|
||||
replacements,
|
||||
} satisfies Output
|
||||
}).pipe(
|
||||
fileMutation.withLock([LocationMutation.resolvePath(location.directory, input.path)]),
|
||||
fileMutation.withLock([FileAccess.resolvePath(location.directory, input.path)]),
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
content: `Edited ${output.files[0]?.file} (${output.replacements} replacement${output.replacements === 1 ? "" : "s"})`,
|
||||
|
||||
@@ -7,7 +7,7 @@ import path from "path"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { FileSystem } from "../../filesystem.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { FileAccess } from "../../file-access.js"
|
||||
import { Ripgrep } from "../../ripgrep.js"
|
||||
import { RelativePath } from "../../schema.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
@@ -48,7 +48,7 @@ export const Plugin = {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -63,15 +63,8 @@ export const Plugin = {
|
||||
Effect.gen(function* () {
|
||||
const searchPath = input.path === "undefined" || input.path === "null" ? undefined : input.path
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
const target = yield* mutation.resolve({ path: searchPath ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const target = yield* access.resolve({ path: searchPath ?? ".", kind: "directory" })
|
||||
yield* access.authorizeExternal([target], context)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
|
||||
@@ -7,7 +7,7 @@ import path from "path"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { FileSystem } from "../../filesystem.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { FileAccess } from "../../file-access.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { Ripgrep } from "../../ripgrep.js"
|
||||
import { RelativePath } from "../../schema.js"
|
||||
@@ -67,7 +67,7 @@ export const Plugin = {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -82,14 +82,8 @@ export const Plugin = {
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = { type: "tool" as const, messageID: context.messageID, id: context.id }
|
||||
const target = yield* mutation.resolve({ path: input.path ?? "." })
|
||||
if (target.externalDirectory)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const target = yield* access.resolve({ path: input.path ?? "." })
|
||||
yield* access.authorizeExternal([target], context)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Environment } from "../../environment/index.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
import { FileMutation } from "../../file-mutation.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { FileAccess } from "../../file-access.js"
|
||||
import { Patch } from "@opencode-ai/util/patch"
|
||||
import { Permission } from "../../permission.js"
|
||||
import DESCRIPTION from "../patch.txt"
|
||||
@@ -45,29 +45,29 @@ export const toModelContent = (output: Output) =>
|
||||
|
||||
type Prepared =
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly target: FileAccess.Target
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
| (Extract<Patch.Hunk, { readonly type: "delete" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly target: FileAccess.Target
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
})
|
||||
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
|
||||
readonly target: LocationMutation.Target
|
||||
readonly target: FileAccess.Target
|
||||
readonly content: string
|
||||
readonly before: string
|
||||
readonly after: string
|
||||
readonly moveTarget?: LocationMutation.Target
|
||||
readonly moveTarget?: FileAccess.Target
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: Context) {
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
@@ -86,9 +86,9 @@ export const Plugin = {
|
||||
const parsed = Patch.parse(input.patchText)
|
||||
const lockTargets = Result.isSuccess(parsed)
|
||||
? parsed.success.flatMap((hunk) => [
|
||||
LocationMutation.resolvePath(location.directory, hunk.path),
|
||||
FileAccess.resolvePath(location.directory, hunk.path),
|
||||
...(hunk.type === "update" && hunk.movePath
|
||||
? [LocationMutation.resolvePath(location.directory, hunk.movePath)]
|
||||
? [FileAccess.resolvePath(location.directory, hunk.movePath)]
|
||||
: []),
|
||||
])
|
||||
: []
|
||||
@@ -114,17 +114,11 @@ export const Plugin = {
|
||||
const prepared: Prepared[] = []
|
||||
const updates = new Map<string, string>()
|
||||
const resolveTarget = Effect.fnUntraced(function* (value: string) {
|
||||
const target = yield* mutation.resolve({ path: value, kind: "file" })
|
||||
const target = yield* access.resolve({ path: value, kind: "file" })
|
||||
if (!target.externalDirectory) return target
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
metadata: {
|
||||
filepath: target.absolute,
|
||||
parentDir: target.externalDirectory.directory,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
yield* access.authorizeExternal([target], context, {
|
||||
filepath: target.absolute,
|
||||
parentDir: target.externalDirectory.directory,
|
||||
})
|
||||
return target
|
||||
})
|
||||
|
||||
@@ -6,8 +6,7 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "../../location.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { FileAccess } from "../../file-access.js"
|
||||
import { SessionInstructions } from "../../session/instructions.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
import { ReadToolFileSystem } from "../read-filesystem.js"
|
||||
@@ -31,8 +30,7 @@ export const Plugin = {
|
||||
id: "opencode.tool.read",
|
||||
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: Context) {
|
||||
const reader = yield* ReadToolFileSystem.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const permission = yield* Permission.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const sessionInstructions = yield* SessionInstructions.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
@@ -48,37 +46,13 @@ export const Plugin = {
|
||||
output: Output,
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const authorize = (target: LocationMutation.Target, authorizeExternal = true) =>
|
||||
Effect.gen(function* () {
|
||||
if (target.externalDirectory && authorizeExternal)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
})
|
||||
const read = (target: LocationMutation.Target) =>
|
||||
reader.read(AbsolutePath.make(target.absolute), target.resource, {
|
||||
const read = (target: FileAccess.Target) =>
|
||||
reader.read(target.absolute, target.resource, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
|
||||
const requested = yield* mutation.resolve({ path: input.path })
|
||||
yield* authorize(requested)
|
||||
const requested = yield* access.authorizeRead(input.path, context)
|
||||
const result = yield* read(requested).pipe(
|
||||
Effect.map((content) => ({ content, target: requested, path: input.path })),
|
||||
Effect.catchIf(
|
||||
@@ -89,9 +63,7 @@ export const Plugin = {
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
if (!alternate) return yield* missing(input.path, requested.absolute)
|
||||
const target = yield* mutation.resolve({ path: alternate, kind: "file" })
|
||||
// The candidate is a sibling under the external directory already approved above.
|
||||
yield* authorize(target, false)
|
||||
const target = yield* access.authorizeRead(alternate, context, { siblingOf: requested })
|
||||
const content = yield* read(target).pipe(
|
||||
Effect.catchIf(
|
||||
(error) => error instanceof Environment.NotFound,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { Job } from "../../job.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { FileAccess } from "../../file-access.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { NonNegativeInt } from "../../schema.js"
|
||||
import { Session } from "../../session.js"
|
||||
@@ -104,7 +104,7 @@ export const Plugin = {
|
||||
const jobs = yield* Job.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const shell = yield* Shell.Service
|
||||
const shellSelect = yield* ShellSelect.Service
|
||||
const compatibleShell = shellSelect.resolve({ priority: "compat" })
|
||||
@@ -117,30 +117,18 @@ export const Plugin = {
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
const target = yield* access.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
invocation.cwd = target.absolute
|
||||
const timeout = invocation.timeout
|
||||
const portable = Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, { portable })
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({
|
||||
path: LocationMutation.resolvePath(target.absolute, directory),
|
||||
access.resolve({
|
||||
path: FileAccess.resolvePath(target.absolute, directory),
|
||||
kind: "directory",
|
||||
}),
|
||||
)
|
||||
const external = [target, ...directories]
|
||||
.map((item) => item.externalDirectory)
|
||||
.filter((item) => item !== undefined)
|
||||
.filter((item, index, items) => items.findIndex((other) => other.resource === item.resource) === index)
|
||||
if (external.length > 0)
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: external.map((item) => item.resource),
|
||||
save: external.map((item) => item.save),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* access.authorizeExternal([target, ...directories], context)
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
|
||||
@@ -11,10 +11,6 @@ import { WebSearch } from "../../websearch.js"
|
||||
export const name = "websearch"
|
||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||
const providerSelectionLock = Semaphore.makeUnsafe(1)
|
||||
const httpErrors = new Map([
|
||||
[429, "Web search rate limited (HTTP 429)"],
|
||||
[401, "Web search authentication failed (HTTP 401)"],
|
||||
])
|
||||
|
||||
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
|
||||
|
||||
@@ -54,100 +50,106 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.messageID, id: context.id },
|
||||
})
|
||||
const search = (providerID?: WebSearch.ID) =>
|
||||
websearch.query(
|
||||
{ ...input, providerID },
|
||||
{
|
||||
sessionID: context.sessionID,
|
||||
onProvider: (provider) => context.progress({ provider: provider.id }),
|
||||
},
|
||||
)
|
||||
const result = yield* search().pipe(
|
||||
Effect.catchTag("WebSearch.ProviderRequired", () => {
|
||||
return providerSelectionLock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (yield* websearch.default()) return
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
const defaultProvider = providers[0]
|
||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Web Search",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "choice",
|
||||
description: "Allow OpenCode to search the web for up-to-date information?",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
{
|
||||
value: "allow",
|
||||
label: `Allow search via ${providers.map((provider) => provider.name).join(", ")}`,
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose another provider",
|
||||
},
|
||||
{ value: "disable", label: "Disable web search" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
if (response.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
if (response.answer.choice === "disable") {
|
||||
yield* websearch.select(false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
const selection =
|
||||
response.answer.choice === "choose"
|
||||
? yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
const search = (): Effect.Effect<Effect.Success<ReturnType<typeof ctx.websearch.query>>, unknown> =>
|
||||
websearch.default().pipe(
|
||||
Effect.flatMap((provider) => {
|
||||
if (!provider) return ctx.websearch.query(input)
|
||||
return context
|
||||
.progress({ provider: provider.id })
|
||||
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID: provider.id })))
|
||||
}),
|
||||
Effect.catch((error) => {
|
||||
if (!Schema.is(WebSearch.ProviderRequiredError)(error)) return Effect.fail(error)
|
||||
return providerSelectionLock
|
||||
.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (yield* websearch.default()) return
|
||||
const providers = (yield* ctx.websearch.providers()).data
|
||||
const defaultProvider = providers[0]
|
||||
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
|
||||
const response = yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Web Search",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "choice",
|
||||
description: "Allow OpenCode to search the web for up-to-date information?",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: [
|
||||
{
|
||||
key: "provider",
|
||||
description: "Choose a provider for web search.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: providers.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.name,
|
||||
})),
|
||||
value: "allow",
|
||||
label: `Allow search via ${providers.map((provider) => provider.name).join(", ")}`,
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose another provider",
|
||||
},
|
||||
{ value: "disable", label: "Disable web search" },
|
||||
],
|
||||
})
|
||||
: undefined
|
||||
if (selection?.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const providerID = selection?.answer.provider ?? "random"
|
||||
if (providerID === "random") {
|
||||
yield* websearch.select("random")
|
||||
return
|
||||
}
|
||||
const provider = providers.find((provider) => provider.id === providerID)
|
||||
if (!provider) return yield* new WebSearch.ProviderRequiredError()
|
||||
yield* websearch.select(provider.id)
|
||||
return provider.id
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "1 minute",
|
||||
orElse: () => Effect.fail(new Error("Web search cancelled")),
|
||||
}),
|
||||
Effect.flatMap(search),
|
||||
)
|
||||
}),
|
||||
)
|
||||
},
|
||||
],
|
||||
})
|
||||
if (response.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
if (response.answer.choice === "disable") {
|
||||
yield* websearch.select(false)
|
||||
return yield* new WebSearch.DisabledError()
|
||||
}
|
||||
const selection =
|
||||
response.answer.choice === "choose"
|
||||
? yield* forms.ask({
|
||||
sessionID: context.sessionID,
|
||||
title: "Choose a web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [
|
||||
{
|
||||
key: "provider",
|
||||
description: "Choose a provider for web search.",
|
||||
type: "string",
|
||||
required: true,
|
||||
custom: false,
|
||||
options: providers.map((provider) => ({
|
||||
value: provider.id,
|
||||
label: provider.name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
})
|
||||
: undefined
|
||||
if (selection?.status === "cancelled")
|
||||
return yield* Effect.fail(new Error("Web search cancelled"))
|
||||
const providerID = selection?.answer.provider ?? "random"
|
||||
if (
|
||||
typeof providerID !== "string" ||
|
||||
(providerID !== "random" && !providers.some((provider) => provider.id === providerID))
|
||||
)
|
||||
return yield* new WebSearch.ProviderRequiredError()
|
||||
yield* websearch.select(providerID === "random" ? "random" : WebSearch.ID.make(providerID))
|
||||
if (providerID !== "random") return WebSearch.ID.make(providerID)
|
||||
return providers[Math.floor(Math.random() * providers.length)]?.id
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "1 minute",
|
||||
orElse: () => Effect.fail(new Error("Web search cancelled")),
|
||||
}),
|
||||
Effect.flatMap((providerID) => {
|
||||
if (!providerID) return Effect.suspend(search)
|
||||
return context
|
||||
.progress({ provider: providerID })
|
||||
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID })))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const result = yield* search()
|
||||
const output = {
|
||||
provider: result.providerID,
|
||||
results: result.results,
|
||||
provider: result.data.providerID,
|
||||
results: result.data.results,
|
||||
}
|
||||
const content = output.results.length
|
||||
? output.results
|
||||
@@ -166,14 +168,28 @@ export const Plugin = {
|
||||
const fallback = `Unable to search the web for ${input.query}`
|
||||
if (!Schema.is(WebSearch.RequestError)(error)) return new ToolFailure({ message: fallback, error })
|
||||
const status = HttpClientError.isHttpClientError(error.cause) ? error.cause.response?.status : undefined
|
||||
return new ToolFailure({
|
||||
message:
|
||||
status === undefined
|
||||
? fallback
|
||||
: (httpErrors.get(status) ?? `Web search request failed (HTTP ${status})`),
|
||||
error,
|
||||
metadata: { provider: error.providerID },
|
||||
})
|
||||
switch (status) {
|
||||
case 429:
|
||||
return new ToolFailure({
|
||||
message: "Web search rate limited (HTTP 429)",
|
||||
error,
|
||||
metadata: { provider: error.providerID },
|
||||
})
|
||||
case 401:
|
||||
return new ToolFailure({
|
||||
message: "Web search authentication failed (HTTP 401)",
|
||||
error,
|
||||
metadata: { provider: error.providerID },
|
||||
})
|
||||
case undefined:
|
||||
return new ToolFailure({ message: fallback, error, metadata: { provider: error.providerID } })
|
||||
default:
|
||||
return new ToolFailure({
|
||||
message: `Web search request failed (HTTP ${status})`,
|
||||
error,
|
||||
metadata: { provider: error.providerID },
|
||||
})
|
||||
}
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Bom } from "@opencode-ai/util/bom"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
import { FileMutation } from "../../file-mutation.js"
|
||||
import { Formatter } from "../../formatter.js"
|
||||
import { LocationMutation } from "../../location-mutation.js"
|
||||
import { FileAccess } from "../../file-access.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { fileDiff } from "./file-diff.js"
|
||||
|
||||
@@ -46,7 +46,7 @@ export const toModelContent = (output: Output) =>
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.write",
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: Context) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
@@ -68,15 +68,8 @@ export const Plugin = {
|
||||
messageID: context.messageID,
|
||||
id: context.id,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const target = yield* access.resolve({ path: input.path, kind: "file" })
|
||||
yield* access.authorizeExternal([target], context)
|
||||
const current = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||
Effect.catchTag("Environment.NotFound", () => Effect.undefined),
|
||||
)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
export * as ErrorSummary from "./error-summary.js"
|
||||
|
||||
import { Option, Schema } from "effect"
|
||||
|
||||
const decode = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
_tag: Schema.optional(Schema.String),
|
||||
code: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
|
||||
errno: Schema.optional(Schema.Number),
|
||||
cause: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
)
|
||||
|
||||
/** Error messages, stacks and SQL parameters may contain credentials. Retain only diagnostic classifications. */
|
||||
export function from(error: unknown) {
|
||||
const errors: { type: string; code?: string | number; errno?: number }[] = []
|
||||
const seen = new Set<unknown>()
|
||||
while (error && !seen.has(error) && errors.length < 8) {
|
||||
seen.add(error)
|
||||
const result = decode(error)
|
||||
if (Option.isNone(result)) break
|
||||
errors.push({
|
||||
type: result.value._tag ?? (error instanceof Error ? error.name : result.value.name) ?? "unknown",
|
||||
code: result.value.code,
|
||||
errno: result.value.errno,
|
||||
})
|
||||
error = error instanceof Error ? error.cause : result.value.cause
|
||||
}
|
||||
return errors
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
export * as WebSearch from "./websearch.js"
|
||||
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { Clock, Context, Effect, Layer, Option, Schema, Stream } from "effect"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus.js"
|
||||
import { KV } from "./kv.js"
|
||||
@@ -58,13 +55,7 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
readonly providers: () => Effect.Effect<readonly Provider[]>
|
||||
readonly default: () => Effect.Effect<Provider | undefined, DisabledError>
|
||||
readonly select: (selection: Selection) => Effect.Effect<void>
|
||||
readonly query: (
|
||||
input: Input,
|
||||
options?: {
|
||||
readonly sessionID?: Session.ID
|
||||
readonly onProvider?: (provider: Provider) => Effect.Effect<void>
|
||||
},
|
||||
) => Effect.Effect<Response, Error>
|
||||
readonly query: (input: Input) => Effect.Effect<Response, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/WebSearch") {}
|
||||
@@ -88,17 +79,6 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const kv = yield* KV.Service
|
||||
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
|
||||
const cooldowns = new Map<ID, { until: number; error: RequestError }>()
|
||||
const preferred = new Map<Session.ID | undefined, { provider?: ID }>()
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => preferred.clear()))
|
||||
yield* bus.subscribe([SessionEvent.Deleted, SessionEvent.Moved]).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.sync(() => {
|
||||
preferred.delete(event.data.sessionID)
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const state = State.create<Data, Editor>({
|
||||
initial: () => ({ providers: new Map() }),
|
||||
editor: (editor) => ({
|
||||
@@ -116,46 +96,26 @@ const layer = Layer.effect(
|
||||
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ providerID }))
|
||||
}
|
||||
|
||||
const selection = Effect.fn("WebSearch.selection")(function* () {
|
||||
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
|
||||
const data = state.get()
|
||||
if (
|
||||
data.selection === false ||
|
||||
data.selection === "random" ||
|
||||
(data.selection && data.providers.has(data.selection))
|
||||
)
|
||||
return data.selection
|
||||
const stored = yield* kv.get(ProviderKey)
|
||||
const stored = data.selection === undefined ? yield* kv.get(ProviderKey) : undefined
|
||||
const decoded = Schema.decodeUnknownOption(Selection)(stored)
|
||||
if (stored !== undefined && Option.isNone(decoded)) yield* kv.remove(ProviderKey)
|
||||
return Option.getOrUndefined(decoded)
|
||||
const selection = data.selection ?? Option.getOrUndefined(decoded)
|
||||
if (selection === false) return yield* new DisabledError()
|
||||
if (selection === "random") {
|
||||
const providers = Array.from(data.providers.values())
|
||||
return providers[Math.floor(Math.random() * providers.length)]
|
||||
}
|
||||
return selection ? data.providers.get(selection) : undefined
|
||||
})
|
||||
|
||||
const randomProvider = (now: number, affinity: { provider?: ID }, attempted?: Set<ID>) => {
|
||||
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
|
||||
const providers = state.get().providers
|
||||
cooldowns.forEach((cooldown, id) => {
|
||||
if (cooldown.until <= now || !providers.has(id)) cooldowns.delete(id)
|
||||
})
|
||||
const current = affinity.provider === undefined ? undefined : providers.get(affinity.provider)
|
||||
if (current && !cooldowns.has(current.id) && !attempted?.has(current.id)) return current
|
||||
const available = Array.from(providers.values()).filter(
|
||||
(provider) => !cooldowns.has(provider.id) && !attempted?.has(provider.id),
|
||||
)
|
||||
const provider = available[Math.floor(Math.random() * available.length)]
|
||||
if (provider) affinity.provider = provider.id
|
||||
if (input.providerID) return yield* requireProvider(providers, input.providerID)
|
||||
const provider = yield* defaultProvider()
|
||||
if (!provider) return yield* new ProviderRequiredError()
|
||||
return provider
|
||||
}
|
||||
|
||||
const defaultProvider = Effect.fn("WebSearch.default")(function* (choice: Selection | undefined) {
|
||||
if (choice === false) return yield* new DisabledError()
|
||||
if (choice === "random") {
|
||||
// Inspection must not select a provider or reopen consent when every provider is cooling down.
|
||||
const active = preferred.get(undefined)?.provider
|
||||
return (
|
||||
(active === undefined ? undefined : state.get().providers.get(active)) ??
|
||||
state.get().providers.values().next().value
|
||||
)
|
||||
}
|
||||
return choice ? state.get().providers.get(choice) : undefined
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
@@ -168,58 +128,24 @@ const layer = Layer.effect(
|
||||
})).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
default: Effect.fn("WebSearch.defaultInfo")(function* () {
|
||||
const provider = yield* defaultProvider(yield* selection())
|
||||
const provider = yield* defaultProvider()
|
||||
return provider && { id: provider.id, name: provider.name }
|
||||
}),
|
||||
select: Effect.fn("WebSearch.select")(function* (selection) {
|
||||
yield* kv.set(ProviderKey, selection)
|
||||
}),
|
||||
query: Effect.fn("WebSearch.query")(function* (input, options) {
|
||||
const choice = input.providerID ? undefined : yield* selection()
|
||||
let provider = input.providerID
|
||||
? yield* requireProvider(state.get().providers, input.providerID)
|
||||
: yield* defaultProvider(choice)
|
||||
if (!provider) return yield* new ProviderRequiredError()
|
||||
// Keep the cell for this query so deletion/movement cannot reinsert an in-flight session's entry.
|
||||
const affinity = preferred.get(options?.sessionID) ?? { provider: undefined }
|
||||
if (choice === "random") {
|
||||
preferred.set(options?.sessionID, affinity)
|
||||
provider = randomProvider(yield* Clock.currentTimeMillis, affinity) ?? provider
|
||||
}
|
||||
const attempted = new Set<ID>()
|
||||
while (true) {
|
||||
if (options?.onProvider) yield* options.onProvider({ id: provider.id, name: provider.name })
|
||||
let cooldown = choice === "random" ? cooldowns.get(provider.id) : undefined
|
||||
if (!cooldown || cooldown.until <= (yield* Clock.currentTimeMillis)) {
|
||||
attempted.add(provider.id)
|
||||
const result = yield* provider
|
||||
.execute({ query: input.query })
|
||||
.pipe(Effect.flatMap(decodeResults), Effect.result)
|
||||
if (result._tag === "Success") return new Response({ providerID: provider.id, results: result.success })
|
||||
const cause = result.failure
|
||||
const error = new RequestError({ providerID: provider.id, cause })
|
||||
if (choice !== "random" || !HttpClientError.isHttpClientError(cause) || cause.response?.status !== 429)
|
||||
return yield* error
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
cooldown = { until: now + cooldownMillis(cause.response.headers["retry-after"], now), error }
|
||||
cooldowns.set(provider.id, cooldown)
|
||||
}
|
||||
provider = randomProvider(yield* Clock.currentTimeMillis, affinity, attempted)
|
||||
if (!provider) return yield* cooldown.error
|
||||
}
|
||||
query: Effect.fn("WebSearch.query")(function* (input) {
|
||||
const provider = yield* resolve(input)
|
||||
const results = yield* provider.execute({ query: input.query }).pipe(
|
||||
Effect.flatMap(decodeResults),
|
||||
Effect.mapError((cause) => new RequestError({ providerID: provider.id, cause })),
|
||||
)
|
||||
return new Response({ providerID: provider.id, results })
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
function cooldownMillis(value: string | undefined, now: number) {
|
||||
if (!value?.trim()) return 60_000
|
||||
const seconds = Number(value)
|
||||
if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1000 : 60_000
|
||||
const date = Date.parse(value)
|
||||
return Number.isFinite(date) ? Math.max(0, date - now) : 60_000
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
|
||||
+65
-72
@@ -4,17 +4,20 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tmpdirScoped, withTempDir } from "./fixture/tmpdir"
|
||||
import { location } from "./fixture/location"
|
||||
import { it } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
|
||||
function provide(directory: string, projectDirectory = directory) {
|
||||
return Effect.provide(
|
||||
LayerNode.compile(LocationMutation.node, {
|
||||
LayerNode.compile(FileAccess.node, {
|
||||
replacements: [
|
||||
Permission.node.replace(permissionLayer()),
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -31,21 +34,14 @@ function provide(directory: string, projectDirectory = directory) {
|
||||
)
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
}
|
||||
|
||||
describe("LocationMutation", () => {
|
||||
describe("FileAccess.resolve", () => {
|
||||
it.live("resolves an active relative existing file target", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "hello.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "hello.txt" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: "hello.txt" })
|
||||
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
@@ -57,11 +53,11 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("resolves an active relative prospective file target", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: path.join("src", "new.txt") })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: path.join("src", "new.txt") })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "src", "new.txt"),
|
||||
resource: "src/new.txt",
|
||||
@@ -71,10 +67,10 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("requires external-directory authorization for a relative lexical escape", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "../outside.txt" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: "../outside.txt" })
|
||||
const root = path.dirname(directory)
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(root, "outside.txt"),
|
||||
@@ -89,11 +85,12 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("allows a relative path outside the Location but inside the project worktree", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const active = path.join(directory, "packages", "opencode")
|
||||
yield* Effect.promise(() => fs.mkdir(active, { recursive: true }))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../../README.md" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: "../../README.md" })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "README.md"),
|
||||
resource: "../../README.md",
|
||||
@@ -104,37 +101,34 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("does not treat a filesystem-root project sentinel as an internal boundary", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: "../outside.txt" })
|
||||
expect(target.externalDirectory).toBeDefined()
|
||||
}).pipe(provide(directory, path.parse(directory).root)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("resolves a prospective target below an external symlink lexically", () =>
|
||||
withTmp((directory) => {
|
||||
const outside = `${directory}-outside`
|
||||
return Effect.gen(function* () {
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(outside)
|
||||
await fs.symlink(outside, path.join(directory, "escape"))
|
||||
})
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: path.join("escape", "new.txt") })
|
||||
const outside = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() => fs.symlink(outside.path, path.join(directory, "escape")))
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: path.join("escape", "new.txt") })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(directory, "escape", "new.txt"),
|
||||
resource: "escape/new.txt",
|
||||
})
|
||||
expect(target.externalDirectory).toBeUndefined()
|
||||
yield* Effect.promise(() => fs.rm(outside, { recursive: true, force: true }))
|
||||
}).pipe(provide(directory))
|
||||
}),
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("follows an in-location symlink using ordinary filesystem semantics", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -142,8 +136,8 @@ describe("LocationMutation", () => {
|
||||
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
|
||||
})
|
||||
|
||||
const mutation = yield* LocationMutation.Service
|
||||
expect(yield* mutation.resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
const access = yield* FileAccess.Service
|
||||
expect(yield* access.resolve({ path: "linked/new.txt" })).toMatchObject({
|
||||
absolute: path.join(directory, "linked", "new.txt"),
|
||||
resource: "linked/new.txt",
|
||||
})
|
||||
@@ -152,11 +146,11 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("accepts an explicit absolute in-location target without external approval", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "new.txt")
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({
|
||||
absolute: targetPath,
|
||||
resource: "new.txt",
|
||||
@@ -167,12 +161,12 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("requires external-directory authorization for an explicit external absolute target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
withTempDir(({ path: outside }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new.txt")
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: targetPath })
|
||||
const root = outside
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.join(root, "new.txt"),
|
||||
@@ -188,26 +182,26 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("resolves an existing external file target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
withTempDir(({ path: outside }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: targetPath })
|
||||
expect(target).toMatchObject({ absolute: targetPath })
|
||||
expect(target.externalDirectory?.directory).toBe(outside)
|
||||
expect(target.externalDirectory?.directory).toBe(AbsolutePath.make(outside))
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("uses an explicit file kind without treating an existing directory as the target boundary", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
withTempDir(({ path: outside }) =>
|
||||
Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: outside, kind: "file" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: outside, kind: "file" })
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: path.dirname(outside),
|
||||
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
|
||||
@@ -218,12 +212,12 @@ describe("LocationMutation", () => {
|
||||
)
|
||||
|
||||
it.live("authorizes prospective external descendants at their lexical parent", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
withTempDir(({ path: outside }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "new", "nested", "file.txt")
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: targetPath })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: targetPath })
|
||||
const parent = path.dirname(targetPath)
|
||||
expect(target.externalDirectory).toMatchObject({
|
||||
directory: parent,
|
||||
@@ -234,19 +228,18 @@ describe("LocationMutation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
test("ignores unknown mutation input fields", () => {
|
||||
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
|
||||
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
|
||||
test("ignores unknown path input fields", () => {
|
||||
expect(Schema.decodeUnknownSync(FileAccess.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
|
||||
path: "README.md",
|
||||
})
|
||||
})
|
||||
|
||||
test("expands a leading tilde against the home directory", () => {
|
||||
const home = path.resolve("/Users/aiden")
|
||||
expect(LocationMutation.resolvePath("/project", "~", home)).toBe(home)
|
||||
expect(LocationMutation.resolvePath("/project", "~/notes.md", home)).toBe(path.resolve(home, "notes.md"))
|
||||
expect(LocationMutation.resolvePath("/project", "~draft.md", home)).toBe(path.resolve("/project", "~draft.md"))
|
||||
expect(LocationMutation.resolvePath("/project", "~\\notes.md", home)).toBe(
|
||||
expect(FileAccess.resolvePath("/project", "~", home)).toBe(home)
|
||||
expect(FileAccess.resolvePath("/project", "~/notes.md", home)).toBe(path.resolve(home, "notes.md"))
|
||||
expect(FileAccess.resolvePath("/project", "~draft.md", home)).toBe(path.resolve("/project", "~draft.md"))
|
||||
expect(FileAccess.resolvePath("/project", "~\\notes.md", home)).toBe(
|
||||
process.platform === "win32" ? path.resolve(home, "notes.md") : path.resolve("/project", "~\\notes.md"),
|
||||
)
|
||||
})
|
||||
@@ -257,16 +250,16 @@ describe("LocationMutation", () => {
|
||||
["/cygdrive/c/Users/aiden/notes.md", "C:/Users/aiden/notes.md"],
|
||||
["/mnt/c/Users/aiden/notes.md", "C:/Users/aiden/notes.md"],
|
||||
])("normalizes Windows shell drive path %s before resolution", (input, windows) => {
|
||||
expect(LocationMutation.resolvePath("/project", input)).toBe(
|
||||
expect(FileAccess.resolvePath("/project", input)).toBe(
|
||||
process.platform === "win32" ? path.resolve(windows) : path.resolve(input),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("resolves a tilde path as an external home target", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "~/notes.md" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: "~/notes.md" })
|
||||
const absolute = path.resolve(Global.Path.home, "notes.md")
|
||||
expect(target).toMatchObject({
|
||||
absolute,
|
||||
@@ -282,8 +275,8 @@ describe("LocationMutation", () => {
|
||||
|
||||
it.live("treats a tilde path as in-location when the location is home", () =>
|
||||
Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "~/notes.md" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: "~/notes.md" })
|
||||
expect(target).toMatchObject({
|
||||
absolute: path.resolve(Global.Path.home, "notes.md"),
|
||||
resource: "notes.md",
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity } from "./lib/tool"
|
||||
|
||||
const invocation = {
|
||||
...toolIdentity,
|
||||
sessionID: Session.ID.make("ses_file_access"),
|
||||
id: Tool.CallID.make("call-read"),
|
||||
}
|
||||
const slash = (file: string) => file.replaceAll("\\", "/")
|
||||
|
||||
function provide(requests: Permission.AssertInput[], denied?: string) {
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([FileAccess.node, Location.node]), [
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Permission.node.replace(
|
||||
permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.gen(function* () {
|
||||
requests.push(input)
|
||||
if (input.action === denied)
|
||||
yield* new Permission.BlockedError({
|
||||
rules: [],
|
||||
permission: input.action,
|
||||
resources: input.resources,
|
||||
})
|
||||
}),
|
||||
}),
|
||||
),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
describe("FileAccess.authorizeRead", () => {
|
||||
it.live("returns an absolute target and preserves invocation identity on the read assertion", () => {
|
||||
const requests: Permission.AssertInput[] = []
|
||||
return Effect.gen(function* () {
|
||||
const access = yield* FileAccess.Service
|
||||
const location = yield* Location.Service
|
||||
const target = yield* access.authorizeRead("src/../README.md", invocation)
|
||||
const absolute: AbsolutePath = target.absolute
|
||||
|
||||
expect(absolute).toBe(AbsolutePath.make(path.join(location.directory, "README.md")))
|
||||
expect(target.externalDirectory).toBeUndefined()
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
action: "read",
|
||||
resources: ["README.md"],
|
||||
save: ["*"],
|
||||
sessionID: invocation.sessionID,
|
||||
agent: invocation.agent,
|
||||
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
|
||||
},
|
||||
])
|
||||
}).pipe(provide(requests))
|
||||
})
|
||||
|
||||
it.live("authorizes an external directory before the file's read rules", () => {
|
||||
const requests: Permission.AssertInput[] = []
|
||||
return Effect.gen(function* () {
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.authorizeRead("../notes.txt", invocation)
|
||||
|
||||
expect(requests).toMatchObject([
|
||||
{ action: "external_directory", resources: [slash(path.join(path.dirname(target.absolute), "*"))] },
|
||||
{ action: "read", resources: [slash(target.absolute)] },
|
||||
])
|
||||
for (const request of requests) {
|
||||
expect(request).toMatchObject({
|
||||
sessionID: invocation.sessionID,
|
||||
agent: invocation.agent,
|
||||
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
|
||||
})
|
||||
}
|
||||
}).pipe(provide(requests))
|
||||
})
|
||||
|
||||
for (const action of ["external_directory", "read"]) {
|
||||
it.live(`propagates ${action} denial without continuing authorization`, () => {
|
||||
const requests: Permission.AssertInput[] = []
|
||||
return Effect.gen(function* () {
|
||||
const access = yield* FileAccess.Service
|
||||
const error = yield* access.authorizeRead("../notes.txt", invocation).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Permission.BlockedError)
|
||||
expect(requests.map((request) => request.action)).toEqual(
|
||||
action === "external_directory" ? ["external_directory"] : ["external_directory", "read"],
|
||||
)
|
||||
}).pipe(provide(requests, action))
|
||||
})
|
||||
}
|
||||
|
||||
it.live("reuses a sibling's directory approval only for the supplied recovery call", () => {
|
||||
const requests: Permission.AssertInput[] = []
|
||||
return Effect.gen(function* () {
|
||||
const access = yield* FileAccess.Service
|
||||
const requested = yield* access.authorizeRead("../report final.txt", invocation)
|
||||
const recovered = yield* access.authorizeRead("../report\u202ffinal.txt", invocation, { siblingOf: requested })
|
||||
yield* access.authorizeRead("../notes.txt", invocation)
|
||||
|
||||
expect(requests.map((request) => request.action)).toEqual([
|
||||
"external_directory",
|
||||
"read",
|
||||
"read",
|
||||
"external_directory",
|
||||
"read",
|
||||
])
|
||||
expect(requests[2].resources).toEqual([slash(recovered.absolute)])
|
||||
}).pipe(provide(requests))
|
||||
})
|
||||
|
||||
it.live("checks the external directory for a target that is not a sibling", () => {
|
||||
const requests: Permission.AssertInput[] = []
|
||||
return Effect.gen(function* () {
|
||||
const access = yield* FileAccess.Service
|
||||
const requested = yield* access.authorizeRead("README.md", invocation)
|
||||
yield* access.authorizeRead("../notes.txt", invocation, { siblingOf: requested })
|
||||
|
||||
expect(requests.map((request) => request.action)).toEqual(["read", "external_directory", "read"])
|
||||
}).pipe(provide(requests))
|
||||
})
|
||||
|
||||
it.live("batches external resources in first-seen order and preserves broader repository saves", () => {
|
||||
const requests: Permission.AssertInput[] = []
|
||||
return Effect.gen(function* () {
|
||||
const external = yield* tmpdirScoped()
|
||||
const git = path.join(external.path, "git")
|
||||
const hg = path.join(external.path, "hg")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(git, ".git"), { recursive: true })
|
||||
await fs.mkdir(path.join(git, "nested"))
|
||||
await fs.mkdir(path.join(hg, ".hg"), { recursive: true })
|
||||
await fs.mkdir(path.join(hg, "nested"))
|
||||
})
|
||||
const access = yield* FileAccess.Service
|
||||
const first = yield* access.resolve({ path: path.join(git, "nested", "a.txt"), kind: "file" })
|
||||
const second = yield* access.resolve({ path: path.join(git, "nested", "b.txt"), kind: "file" })
|
||||
const third = yield* access.resolve({ path: path.join(hg, "nested", "c.txt"), kind: "file" })
|
||||
const internal = yield* access.resolve({ path: "README.md" })
|
||||
const metadata = { filepath: first.absolute, parentDir: path.dirname(first.absolute) }
|
||||
|
||||
yield* access.authorizeExternal([first, internal, second, third, first], invocation, metadata)
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
action: "external_directory",
|
||||
resources: [slash(path.join(git, "nested", "*")), slash(path.join(hg, "nested", "*"))],
|
||||
save: [slash(path.join(git, "*")), slash(path.join(hg, "*"))],
|
||||
metadata,
|
||||
sessionID: invocation.sessionID,
|
||||
agent: invocation.agent,
|
||||
source: { type: "tool", messageID: invocation.messageID, id: invocation.id },
|
||||
},
|
||||
])
|
||||
|
||||
yield* access.authorizeExternal([internal], invocation)
|
||||
expect(requests).toHaveLength(1)
|
||||
yield* access.authorizeExternal([second], invocation)
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[1].resources).toEqual([slash(path.join(git, "nested", "*"))])
|
||||
expect(Object.hasOwn(requests[1], "metadata")).toBe(false)
|
||||
}).pipe(provide(requests))
|
||||
})
|
||||
})
|
||||
@@ -7,12 +7,14 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { type EnvironmentFilesTransform, transformEnvironmentFiles } from "./fixture/environment"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { withTempDir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
|
||||
function provide(directory: string, transformFiles: EnvironmentFilesTransform = () => ({})) {
|
||||
const activeLocation = Layer.succeed(
|
||||
@@ -20,27 +22,22 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform =
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([FileAccess.node, FileMutation.node]), [
|
||||
Location.node.replace(activeLocation),
|
||||
Permission.node.replace(permissionLayer()),
|
||||
Environment.node.replace(transformEnvironmentFiles(transformFiles)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
|
||||
}
|
||||
|
||||
describe("FileMutation", () => {
|
||||
it.live("writes an existing internal file and returns a stable result", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "hello.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: "hello.txt" })
|
||||
|
||||
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
|
||||
operation: "write",
|
||||
@@ -54,9 +51,10 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
it.live("writes a prospective internal file and creates parent directories", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({
|
||||
path: path.join("src", "nested", "hello.txt"),
|
||||
})
|
||||
const result = yield* (yield* FileMutation.Service).write({ target, content: "hello" })
|
||||
@@ -73,12 +71,13 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
it.live("preserves exactly one BOM for text writes and normalizes created text", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const preservedPath = path.join(directory, "preserved.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore"))
|
||||
const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" })
|
||||
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
|
||||
const access = yield* FileAccess.Service
|
||||
const preserved = yield* access.resolve({ path: "preserved.txt" })
|
||||
const created = yield* access.resolve({ path: "created.txt" })
|
||||
const files = yield* FileMutation.Service
|
||||
|
||||
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
|
||||
@@ -91,11 +90,12 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
it.live("writes an explicitly resolved external target", () =>
|
||||
withTmp((directory) =>
|
||||
withTmp((outside) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
withTempDir(({ path: outside }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(outside, "external.txt")
|
||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
||||
const access = yield* FileAccess.Service
|
||||
const target = yield* access.resolve({ path: targetPath })
|
||||
const result = yield* (yield* FileMutation.Service).write({ target, content: "external" })
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -111,7 +111,7 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
it.live("serializes concurrent writes to the same absolute target", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const targetPath = path.join(directory, "shared.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
|
||||
@@ -133,10 +133,10 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
|
||||
const firstPlan = yield* access.resolve({ path: "shared.txt" })
|
||||
const secondPlan = yield* access.resolve({ path: "shared.txt" })
|
||||
const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
|
||||
@@ -154,7 +154,7 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
it.live("shares transaction locks across Location service instances", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
@@ -183,7 +183,7 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
it.live("allows transaction locks for distinct resolved paths to proceed independently", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
@@ -205,7 +205,7 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
it.live("allows distinct absolute targets to proceed independently", () =>
|
||||
withTmp((directory) =>
|
||||
withTempDir(({ path: directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
@@ -222,10 +222,10 @@ describe("FileMutation", () => {
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const access = yield* FileAccess.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const firstPlan = yield* mutation.resolve({ path: "first.txt" })
|
||||
const secondPlan = yield* mutation.resolve({ path: "second.txt" })
|
||||
const firstPlan = yield* access.resolve({ path: "first.txt" })
|
||||
const secondPlan = yield* access.resolve({ path: "second.txt" })
|
||||
const first = yield* files.write({ target: firstPlan, content: "first" }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(firstStarted)
|
||||
const second = yield* files.write({ target: secondPlan, content: "second" }).pipe(Effect.forkChild)
|
||||
|
||||
@@ -1,36 +1,20 @@
|
||||
export * as TestWebSearch from "./websearch"
|
||||
|
||||
import { Context, Deferred, Effect, Layer } from "effect"
|
||||
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
export interface Interface extends WebSearch.Interface {
|
||||
readonly queries: readonly WebSearch.Input[]
|
||||
readonly sessionIDs: readonly (Session.ID | undefined)[]
|
||||
/** Waits for query arrivals, not provider execution or query completion. */
|
||||
readonly wait: (count: number) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("test/WebSearch") {}
|
||||
|
||||
export function httpError(status = 429, retryAfter?: string, url = "https://search.example.com") {
|
||||
const request = HttpClientRequest.post(url)
|
||||
return new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.StatusCodeError({
|
||||
request,
|
||||
response: HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(null, { status, headers: retryAfter === undefined ? {} : { "Retry-After": retryAfter } }),
|
||||
),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// No providers are installed: tests register local executors through transform.
|
||||
// The normal Bus and KV implementations use the default in-memory database.
|
||||
export const layer = Layer.effectContext(
|
||||
@@ -38,7 +22,6 @@ export const layer = Layer.effectContext(
|
||||
const context = yield* Layer.build(AppNodeBuilder.build(LayerNode.group([WebSearch.node, Bus.node, KV.node])))
|
||||
const websearch = Context.get(context, WebSearch.Service)
|
||||
const queries: WebSearch.Input[] = []
|
||||
const sessionIDs: (Session.ID | undefined)[] = []
|
||||
let started = yield* Deferred.make<void>()
|
||||
const wait = (count: number): Effect.Effect<void> =>
|
||||
Effect.suspend(() =>
|
||||
@@ -47,15 +30,13 @@ export const layer = Layer.effectContext(
|
||||
const test = Service.of({
|
||||
...websearch,
|
||||
queries,
|
||||
sessionIDs,
|
||||
wait,
|
||||
query: Effect.fnUntraced(function* (input, options) {
|
||||
query: Effect.fnUntraced(function* (input: WebSearch.Input) {
|
||||
queries.push({ ...input })
|
||||
sessionIDs.push(options?.sessionID)
|
||||
const previous = started
|
||||
started = yield* Deferred.make<void>()
|
||||
yield* Deferred.succeed(previous, undefined)
|
||||
return yield* websearch.query(input, options)
|
||||
return yield* websearch.query(input)
|
||||
}),
|
||||
})
|
||||
return Context.add(context, WebSearch.Service, test).pipe(Context.add(Service, test))
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Deferred, Duration, Effect, Fiber, Layer, LayerMap, RcMap, Schema } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationActivity } from "@opencode-ai/core/location-activity"
|
||||
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
// Keep real execution ownership, location caching, forms, and eviction. The fixture
|
||||
// runner waits on a form instead of making a model request before asking a question.
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const map = yield* LayerMap.make(
|
||||
(ref: Location.Ref) =>
|
||||
// The fixture only exercises these three Location services.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
Layer.merge(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
project: { id: Project.ID.global, directory: ref.directory, canonical: ref.directory },
|
||||
}),
|
||||
),
|
||||
Layer.effect(
|
||||
SessionRunner.Service,
|
||||
Effect.gen(function* () {
|
||||
const forms = yield* Form.Service
|
||||
return SessionRunner.Service.of({
|
||||
drain: ({ sessionID }) =>
|
||||
forms
|
||||
.ask({
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
fields: [{ key: "runtime", type: "string" }],
|
||||
})
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.as(SessionRunner.DrainResult.Complete()),
|
||||
Effect.onInterrupt(() => Effect.sleep("5 minutes")),
|
||||
),
|
||||
})
|
||||
}),
|
||||
),
|
||||
).pipe(
|
||||
Layer.provideMerge(Form.layer),
|
||||
Layer.provide(Layer.succeed(Bus.Service, bus)),
|
||||
Layer.fresh,
|
||||
) as unknown as Layer.Layer<LocationServices>,
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
)
|
||||
return {
|
||||
...map,
|
||||
get: (ref: Location.Ref) => map.get(LocationServiceMap.canonical(ref)),
|
||||
contextEffect: (ref: Location.Ref) => map.contextEffect(LocationServiceMap.canonical(ref)),
|
||||
contextEffectOption: (ref: Location.Ref) => map.contextEffectOption(LocationServiceMap.canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) => map.invalidate(LocationServiceMap.canonical(ref)),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionStore.node,
|
||||
LocationServiceMap.node,
|
||||
SessionExecution.node,
|
||||
LocationActivity.node,
|
||||
]),
|
||||
[
|
||||
LocationServiceMap.node.replace(
|
||||
makeGlobalNode({
|
||||
service: LocationServiceMap.Service,
|
||||
layer: locations,
|
||||
deps: [Bus.node],
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("LocationActivity eviction", () => {
|
||||
for (const [count, admission] of [
|
||||
[1, "none"],
|
||||
[2, "none"],
|
||||
[1, "other"],
|
||||
[1, "same"],
|
||||
] as const) {
|
||||
const newWork = admission !== "none"
|
||||
it.effect(
|
||||
`interrupts ${count} waiting executions before eviction (${admission} session admitted during cleanup)`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const map = yield* LocationServiceMap.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const sessionIDs = Array.from({ length: count }, (_, index) =>
|
||||
Session.ID.make(`ses_waiting_question_${index}`),
|
||||
)
|
||||
const newcomer = admission === "same" ? sessionIDs[0] : Session.ID.make("ses_new_question")
|
||||
const ref = LocationServiceMap.canonical({ directory: AbsolutePath.make("/project") })
|
||||
const idle = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_idle") })
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: ref.directory, sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values(
|
||||
Array.from(new Set([...sessionIDs, newcomer]), (sessionID) => ({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "question",
|
||||
directory: ref.directory,
|
||||
title: "Waiting question",
|
||||
version: "test",
|
||||
})),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const created = yield* Deferred.make<void>()
|
||||
const newCreated = yield* Deferred.make<void>()
|
||||
const pending: Form.Info[] = []
|
||||
const interrupted: SessionEvent.Execution.Interrupted["data"][] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.type === SessionEvent.Execution.Interrupted.type) {
|
||||
interrupted.push(Schema.decodeUnknownSync(SessionEvent.Execution.Interrupted.data)(event.data))
|
||||
}
|
||||
if (event.type !== Form.Event.Created.type) return
|
||||
pending.push(Schema.decodeUnknownSync(Form.Event.Created.data)(event.data).form)
|
||||
if (pending.length === count) yield* Deferred.succeed(created, undefined)
|
||||
if (pending.length > count) yield* Deferred.succeed(newCreated, undefined)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const running = yield* Effect.forEach(sessionIDs, (sessionID) =>
|
||||
execution.resume(sessionID).pipe(Effect.exit, Effect.forkScoped),
|
||||
)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach([...sessionIDs, newcomer], (sessionID) => execution.interrupt(sessionID)).pipe(
|
||||
Effect.andThen(TestClock.adjust("5 minutes")),
|
||||
),
|
||||
)
|
||||
yield* Deferred.await(created)
|
||||
const context = yield* map.contextEffect(ref).pipe(Effect.scoped)
|
||||
const forms = Context.get(context, Form.Service)
|
||||
expect((yield* store.listSuspended()).toSorted()).toEqual(sessionIDs.toSorted())
|
||||
yield* Location.Service.pipe(Effect.provide(map.get(idle)), Effect.scoped)
|
||||
|
||||
// Human input produces no durable activity while the question is pending.
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* TestClock.adjust("62 minutes")
|
||||
// Interruption has cancelled each question, but slow cleanup still owns the graph.
|
||||
expect(Array.from(yield* execution.active).toSorted()).toEqual(sessionIDs.toSorted())
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
|
||||
expect(yield* forms.list()).toEqual([])
|
||||
for (const form of pending) expect(yield* forms.state(form.id)).toEqual({ status: "cancelled" })
|
||||
|
||||
if (newWork) {
|
||||
yield* execution.wake(newcomer)
|
||||
if (admission === "other") yield* Deferred.await(newCreated)
|
||||
}
|
||||
yield* TestClock.adjust("5 minutes")
|
||||
if (newWork) yield* Deferred.await(newCreated)
|
||||
const results = yield* Effect.forEach(running, Fiber.join)
|
||||
expect(results.every((exit) => exit._tag === "Failure")).toBe(true)
|
||||
expect(Array.from(yield* execution.active)).toEqual(newWork ? [newcomer] : [])
|
||||
expect(yield* store.listSuspended()).toEqual(newWork ? [newcomer] : [])
|
||||
expect(interrupted.toSorted((a, b) => a.sessionID.localeCompare(b.sessionID))).toEqual(
|
||||
sessionIDs.map((sessionID) => ({ sessionID, reason: "inactivity" })),
|
||||
)
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual(newWork ? [ref] : [])
|
||||
if (newWork) {
|
||||
expect(yield* forms.list({ sessionID: newcomer })).toEqual([pending[count]])
|
||||
if (admission === "same") {
|
||||
const later = LocationServiceMap.canonical({ directory: AbsolutePath.make("/later") })
|
||||
yield* Location.Service.pipe(Effect.provide(map.get(later)), Effect.scoped)
|
||||
yield* TestClock.adjust("30 minutes")
|
||||
// Keep fresh work active while a different graph reaches its own deadline.
|
||||
yield* bus.publish(SessionEvent.Execution.Started, { sessionID: newcomer }, { location: ref })
|
||||
yield* TestClock.adjust("32 minutes")
|
||||
expect(Array.from(yield* execution.active)).toEqual([newcomer])
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
|
||||
}
|
||||
yield* execution.interrupt(newcomer)
|
||||
yield* TestClock.adjust("5 minutes")
|
||||
yield* execution.awaitIdle(newcomer)
|
||||
yield* TestClock.adjust("62 minutes")
|
||||
expect(yield* store.listSuspended()).toEqual([])
|
||||
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([])
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -17,29 +17,24 @@ interface WebSearchRequest {
|
||||
}
|
||||
|
||||
export const requests: WebSearchRequest[] = []
|
||||
export const signals: AbortSignal[] = []
|
||||
let responseBody = ""
|
||||
let responseStatus = 200
|
||||
|
||||
export function resetWebSearchFixture(body: string, status = 200) {
|
||||
export function resetWebSearchFixture(body: string) {
|
||||
requests.length = 0
|
||||
signals.length = 0
|
||||
responseBody = body
|
||||
responseStatus = status
|
||||
}
|
||||
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request, _url, signal) =>
|
||||
HttpClient.make((request) =>
|
||||
Effect.sync(() => {
|
||||
signals.push(signal)
|
||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: JSON.parse(new TextDecoder().decode(request.body.body)),
|
||||
})
|
||||
return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: responseStatus }))
|
||||
return HttpClientResponse.fromWeb(request, new Response(responseBody, { status: 200 }))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { WebSearchFirecrawl } from "@opencode-ai/core/plugin/websearch/firecrawl
|
||||
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
|
||||
import { WebSearchTavily } from "@opencode-ai/core/plugin/websearch/tavily"
|
||||
import { host, integrationHost, webSearchHost } from "./host"
|
||||
import { requests, signals, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
|
||||
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
|
||||
|
||||
beforeEach(() => {
|
||||
resetWebSearchFixture(
|
||||
@@ -30,25 +30,6 @@ beforeEach(() => {
|
||||
const it = webSearchIntegrationTest
|
||||
|
||||
describe("built-in web search providers", () => {
|
||||
;[WebSearchExa.Plugin, WebSearchParallel.Plugin, WebSearchFirecrawl.Plugin, WebSearchTavily.Plugin].forEach(
|
||||
(plugin) => {
|
||||
it.effect(`releases rate-limited HTTP requests for ${plugin.id} before caching their errors`, () =>
|
||||
Effect.gen(function* () {
|
||||
resetWebSearchFixture("Rate limited", 429)
|
||||
const integrations = yield* Integration.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* plugin.effect(
|
||||
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
|
||||
)
|
||||
yield* websearch.select("random")
|
||||
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
|
||||
expect(signals).toHaveLength(1)
|
||||
expect(signals[0]?.aborted).toBe(true)
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it.effect("registers a provider without an integration", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -12,7 +12,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
@@ -42,7 +42,7 @@ const readToolNode = makeLocationNode({
|
||||
deps: [
|
||||
Tool.node,
|
||||
ReadToolFileSystem.node,
|
||||
LocationMutation.node,
|
||||
FileAccess.node,
|
||||
Image.node,
|
||||
Permission.node,
|
||||
SessionInstructions.node,
|
||||
@@ -64,7 +64,7 @@ const testLayer = AppNodeBuilder.build(
|
||||
Session.node,
|
||||
Location.node,
|
||||
FSUtil.node,
|
||||
LocationMutation.node,
|
||||
FileAccess.node,
|
||||
ReadToolFileSystem.node,
|
||||
readToolNode,
|
||||
Tool.node,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -27,7 +27,7 @@ const editToolNode = makeLocationNode({
|
||||
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
||||
deps: [
|
||||
Tool.node,
|
||||
LocationMutation.node,
|
||||
FileAccess.node,
|
||||
FileMutation.node,
|
||||
Environment.node,
|
||||
Formatter.node,
|
||||
@@ -91,7 +91,7 @@ const withTool = <A, E, R>(
|
||||
return yield* body(registry)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, editToolNode]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, editToolNode]), [
|
||||
Environment.node.replace(
|
||||
transformEnvironmentFiles((files) => ({
|
||||
read: (target, range) =>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -27,7 +27,7 @@ const patchToolNode = makeLocationNode({
|
||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||
deps: [
|
||||
Tool.node,
|
||||
LocationMutation.node,
|
||||
FileAccess.node,
|
||||
FileMutation.node,
|
||||
Environment.node,
|
||||
Formatter.node,
|
||||
@@ -99,7 +99,7 @@ const withTool = <A, E, R>(
|
||||
return yield* body(yield* Tool.Service)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, patchToolNode]), [
|
||||
Environment.node.replace(
|
||||
transformEnvironmentFiles((files) => ({
|
||||
read: (target, range) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { Effect, Exit, Layer, Result } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -12,7 +12,7 @@ import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { location } from "./fixture/location"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
|
||||
@@ -30,7 +30,7 @@ const readToolNode = makeLocationNode({
|
||||
deps: [
|
||||
Tool.node,
|
||||
ReadToolFileSystem.node,
|
||||
LocationMutation.node,
|
||||
FileAccess.node,
|
||||
Image.node,
|
||||
Permission.node,
|
||||
SessionInstructions.node,
|
||||
@@ -47,7 +47,7 @@ const readCalls: {
|
||||
page: ReadToolFileSystem.PageInput
|
||||
}[] = []
|
||||
const listCalls: AbsolutePath[] = []
|
||||
let resolveFailure: unknown
|
||||
let readDefect: unknown
|
||||
let directoryEntries: string[] = []
|
||||
let directoryEntryDetails: Environment.DirEntry[] = []
|
||||
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage | ReadToolFileSystem.ListPage = {
|
||||
@@ -69,7 +69,7 @@ const reader = Layer.succeed(
|
||||
},
|
||||
read: (input, resource, page = {}) => {
|
||||
readCalls.push({ input, page })
|
||||
if (resolveFailure !== undefined) return Effect.die(resolveFailure)
|
||||
if (readDefect !== undefined) return Effect.die(readDefect)
|
||||
if (readOverride) return readOverride(input, resource, page)
|
||||
if (readFailure !== undefined) return Effect.fail(readFailure)
|
||||
return Effect.succeed(readResult)
|
||||
@@ -77,13 +77,14 @@ const reader = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
let allow = true
|
||||
let deniedResource: string | undefined
|
||||
const permission = permissionLayer({
|
||||
assert: (input) =>
|
||||
Effect.sync(() => {
|
||||
assertions.push(input)
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
allow
|
||||
allow && !input.resources.some((resource) => resource === deniedResource)
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new Permission.BlockedError({
|
||||
@@ -112,30 +113,6 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })),
|
||||
)
|
||||
const mutation = Layer.succeed(
|
||||
LocationMutation.Service,
|
||||
LocationMutation.Service.of({
|
||||
resolve: (input) => {
|
||||
const absolute = path.resolve(process.cwd(), input.path)
|
||||
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute)
|
||||
const resource = external ? absolute.replaceAll("\\", "/") : path.relative(process.cwd(), absolute) || "."
|
||||
const directory = path.dirname(absolute)
|
||||
const externalResource = path.join(directory, "*").replaceAll("\\", "/")
|
||||
return Effect.succeed({
|
||||
absolute,
|
||||
resource,
|
||||
externalDirectory: external
|
||||
? {
|
||||
action: "external_directory" as const,
|
||||
directory,
|
||||
resource: externalResource,
|
||||
save: externalResource,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
const unavailableImage = Layer.mock(Image.Service, {
|
||||
normalize: () => Effect.fail(new Image.ResizerUnavailableError()),
|
||||
})
|
||||
@@ -146,7 +123,6 @@ const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
|
||||
Permission.node.replace(permission),
|
||||
Config.node.replace(config),
|
||||
Image.node.replace(imageLayer),
|
||||
LocationMutation.node.replace(mutation),
|
||||
FSUtil.node.replace(testFileSystem),
|
||||
Location.node.replace(locationLayer),
|
||||
Global.node.replace(Global.layerWith({ data: Global.Path.data })),
|
||||
@@ -165,7 +141,8 @@ describe("ReadTool", () => {
|
||||
readCalls.length = 0
|
||||
listCalls.length = 0
|
||||
allow = true
|
||||
resolveFailure = undefined
|
||||
deniedResource = undefined
|
||||
readDefect = undefined
|
||||
directoryEntries = []
|
||||
directoryEntryDetails = []
|
||||
readResult = {
|
||||
@@ -620,18 +597,21 @@ describe("ReadTool", () => {
|
||||
|
||||
it.effect("preserves unexpected filesystem defects", () =>
|
||||
Effect.gen(function* () {
|
||||
resolveFailure = new Error("unexpected")
|
||||
readDefect = new Error("unexpected")
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } },
|
||||
}).pipe(Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
const exit = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-defect", name: "read", input: { path: "README.md" } },
|
||||
}).pipe(Effect.exit)
|
||||
expect(Result.getOrThrow(Exit.findDefect(exit))).toBe(readDefect)
|
||||
expect(readCalls).toEqual([
|
||||
{
|
||||
input: AbsolutePath.make(path.join(process.cwd(), "README.md")),
|
||||
page: { offset: undefined, limit: undefined },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -721,6 +701,57 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("recovers an external filename without repeating directory approval", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(path.parse(process.cwd()).root, "external-read")
|
||||
const requested = path.join(directory, "report final.txt")
|
||||
const recovered = path.join(directory, "report\u202ffinal.txt")
|
||||
directoryEntryDetails = [{ name: path.basename(recovered), type: "file" }]
|
||||
readOverride = (input) =>
|
||||
input === requested ? Effect.fail(new Environment.NotFound({ path: requested })) : Effect.succeed(readResult)
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-external-recovery", name: "read", input: { path: requested } },
|
||||
}),
|
||||
).toMatchObject({ status: "completed" })
|
||||
expect(assertions).toMatchObject([
|
||||
{ action: "external_directory", resources: [path.join(directory, "*").replaceAll("\\", "/")] },
|
||||
{ action: "read", resources: [requested.replaceAll("\\", "/")] },
|
||||
{ action: "read", resources: [recovered.replaceAll("\\", "/")] },
|
||||
])
|
||||
expect(readCalls.map((call) => call.input)).toEqual([AbsolutePath.make(requested), AbsolutePath.make(recovered)])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not read a recovered filename denied by its own read rules", () =>
|
||||
Effect.gen(function* () {
|
||||
const requested = path.join(process.cwd(), "report final.txt")
|
||||
const recovered = path.join(process.cwd(), "report\u202ffinal.txt")
|
||||
deniedResource = path.basename(recovered)
|
||||
directoryEntryDetails = [{ name: path.basename(recovered), type: "file" }]
|
||||
readOverride = (input) =>
|
||||
input === requested ? Effect.fail(new Environment.NotFound({ path: requested })) : Effect.succeed(readResult)
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-denied-recovery", name: "read", input: { path: requested } },
|
||||
}),
|
||||
).toMatchObject({ status: "error", error: { type: "permission.rejected" } })
|
||||
expect(assertions).toMatchObject([
|
||||
{ action: "read", resources: [path.basename(requested)] },
|
||||
{ action: "read", resources: [path.basename(recovered)] },
|
||||
])
|
||||
expect(readCalls.map((call) => call.input)).toEqual([AbsolutePath.make(requested)])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not recover ambiguous files", () =>
|
||||
Effect.gen(function* () {
|
||||
const requested = "report final.txt"
|
||||
@@ -860,30 +891,6 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves unexpected resolution defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
resolveFailure = new Error("missing")
|
||||
expect(
|
||||
Exit.isFailure(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-missing", name: "read", input: { path: "missing.txt" } },
|
||||
}).pipe(Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
expect(readCalls).toEqual([
|
||||
{
|
||||
input: AbsolutePath.make(path.join(process.cwd(), "missing.txt")),
|
||||
page: { offset: undefined, limit: undefined },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards pagination and returns bounded text pages with continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
readResult = new ReadToolFileSystem.TextPage({
|
||||
|
||||
@@ -8,7 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -25,12 +25,12 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
const globToolNode = makeLocationNode({
|
||||
name: "test/glob-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, FileAccess.node, Permission.node],
|
||||
})
|
||||
const grepToolNode = makeLocationNode({
|
||||
name: "test/grep-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
|
||||
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, FileAccess.node, Permission.node],
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_search_tool_test")
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
@@ -131,7 +131,7 @@ const shellPluginSupervisor = makeLocationNode({
|
||||
deps: [
|
||||
Config.node,
|
||||
Environment.node,
|
||||
LocationMutation.node,
|
||||
FileAccess.node,
|
||||
Permission.node,
|
||||
Session.node,
|
||||
Job.node,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import type { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
@@ -131,11 +131,10 @@ describe("WebSearchTool registration", () => {
|
||||
expect(fixture.websearch.queries).toEqual([
|
||||
{
|
||||
query: "effect typescript",
|
||||
providerID: undefined,
|
||||
providerID: WebSearch.ID.make("exa"),
|
||||
},
|
||||
])
|
||||
expect(fixture.events).toEqual(["permission", "query"])
|
||||
expect(fixture.websearch.sessionIDs).toEqual([sessionID])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -216,7 +215,7 @@ describe("WebSearchTool registration", () => {
|
||||
})
|
||||
expect(first.status).toBe("completed")
|
||||
expect(["exa", "parallel"]).toContain(first.metadata?.provider)
|
||||
expect(fixture.websearch.sessionIDs).toEqual([sessionID, sessionID])
|
||||
expect(first.metadata?.provider).toBe(fixture.websearch.queries[1]?.providerID)
|
||||
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
|
||||
expect(fixture.websearch.queries).toHaveLength(2)
|
||||
expect(fixture.formRequests).toEqual([
|
||||
@@ -254,31 +253,12 @@ describe("WebSearchTool registration", () => {
|
||||
})
|
||||
expect(second.status).toBe("completed")
|
||||
expect(["exa", "parallel"]).toContain(second.metadata?.provider)
|
||||
expect(second.metadata?.provider).toBe(first.metadata?.provider)
|
||||
expect(second.metadata?.provider).toBe(fixture.websearch.queries[2]?.providerID)
|
||||
expect(fixture.formRequests).toHaveLength(1)
|
||||
expect(fixture.websearch.queries).toHaveLength(3)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("honors automatic consent when the configured provider is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
yield* fixture.websearch.transform((editor) => editor.default.set(WebSearch.ID.make("missing")))
|
||||
fixture.formResponse = { status: "answered", answer: { choice: "allow" } }
|
||||
const result = yield* executeTool(fixture.registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-missing", name: "websearch", input: { query: "effect" } },
|
||||
})
|
||||
expect(result.status).toBe("completed")
|
||||
expect(yield* fixture.kv.get(WebSearch.ProviderKey)).toBe("random")
|
||||
expect(result.metadata?.provider).toBe(
|
||||
(yield* fixture.websearch.query({ query: "next" }, { sessionID })).providerID,
|
||||
)
|
||||
expect(fixture.formRequests).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("asks a second form when choosing another provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
@@ -367,67 +347,6 @@ describe("WebSearchTool registration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps provider progress, output, and metadata accurate across automatic failover", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
yield* fixture.websearch.select("random")
|
||||
const first = (yield* fixture.websearch.query({ query: "seed" }, { sessionID })).providerID
|
||||
yield* fixture.websearch.transform((editor) =>
|
||||
editor.add({
|
||||
id: first,
|
||||
name: first,
|
||||
execute: () => Effect.fail(TestWebSearch.httpError()),
|
||||
}),
|
||||
)
|
||||
const progress: Tool.Metadata[] = []
|
||||
const tools = yield* fixture.registry.snapshot()
|
||||
const result = yield* tools.execute({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-failover", name: "websearch", input: { query: "effect" } },
|
||||
progress: (metadata) =>
|
||||
Effect.sync(() => {
|
||||
progress.push(metadata)
|
||||
}),
|
||||
})
|
||||
const replacement = WebSearch.ID.make(first === "exa" ? "parallel" : "exa")
|
||||
expect(progress).toEqual([{ provider: first }, { provider: replacement }])
|
||||
expect(result).toMatchObject({
|
||||
output: { provider: replacement, results: fixture.results },
|
||||
metadata: { provider: replacement },
|
||||
})
|
||||
expect(fixture.formRequests).toEqual([])
|
||||
expect((yield* fixture.websearch.query({ query: "next" }, { sessionID })).providerID).toBe(replacement)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not reopen consent when all automatic providers are cooling down", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
yield* fixture.websearch.select("random")
|
||||
fixture.error = TestWebSearch.httpError()
|
||||
const tools = yield* fixture.registry.snapshot()
|
||||
yield* Effect.forEach(["first", "cooling"], (query) =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* tools
|
||||
.execute({
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: `call-${query}`, name: "websearch", input: { query } },
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
expect(toSessionError(error)).toEqual({
|
||||
type: "tool.execution",
|
||||
message: "Web search rate limited (HTTP 429)",
|
||||
})
|
||||
expect(error.metadata).toMatchObject({ provider: expect.stringMatching(/^(exa|parallel)$/) })
|
||||
}),
|
||||
)
|
||||
expect(fixture.events.filter((event) => event === "query")).toHaveLength(2)
|
||||
expect(fixture.formRequests).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports safe HTTP failures with the attempted provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup
|
||||
@@ -443,7 +362,14 @@ describe("WebSearchTool registration", () => {
|
||||
],
|
||||
({ status, message }, index) =>
|
||||
Effect.gen(function* () {
|
||||
fixture.error = TestWebSearch.httpError(status, undefined, "https://mcp.exa.ai/mcp?exaApiKey=secret")
|
||||
const request = HttpClientRequest.post("https://mcp.exa.ai/mcp?exaApiKey=secret")
|
||||
fixture.error = new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.StatusCodeError({
|
||||
request,
|
||||
response: HttpClientResponse.fromWeb(request, new Response(null, { status })),
|
||||
description: "non 2xx status code",
|
||||
}),
|
||||
})
|
||||
const progress: Tool.Metadata[] = []
|
||||
const error = yield* tools
|
||||
.execute({
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Environment } from "@opencode-ai/core/environment/index"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { FileAccess } from "@opencode-ai/core/file-access"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
@@ -25,7 +25,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const writeToolNode = makeLocationNode({
|
||||
name: "test/write-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
|
||||
deps: [Tool.node, FileAccess.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_write_tool_test")
|
||||
@@ -79,7 +79,7 @@ const withTool = <A, E, R>(
|
||||
return yield* body(registry)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]), [
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, FileAccess.node, FileMutation.node, writeToolNode]), [
|
||||
Environment.node.replace(
|
||||
transformEnvironmentFiles((files) => ({
|
||||
write: (target, content) =>
|
||||
|
||||
@@ -1,34 +1,24 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Effect, Exit, Scope } from "effect"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { TestWebSearch } from "./lib/websearch"
|
||||
|
||||
const it = testEffect(TestWebSearch.layer)
|
||||
const firstSession = Session.ID.make("ses_search_first")
|
||||
const secondSession = Session.ID.make("ses_search_second")
|
||||
|
||||
const register = (id: string) =>
|
||||
Effect.gen(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
const providerID = WebSearch.ID.make(id)
|
||||
const calls: WebSearch.ProviderInput[] = []
|
||||
const failure: { cause?: unknown } = {}
|
||||
const registration = yield* websearch.transform((editor) => {
|
||||
yield* websearch.transform((editor) => {
|
||||
editor.add({
|
||||
id: providerID,
|
||||
name: id.toUpperCase(),
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
Effect.sync(() => {
|
||||
calls.push(input)
|
||||
if (failure.cause !== undefined) return yield* Effect.fail(failure.cause)
|
||||
return [
|
||||
{
|
||||
url: `https://${id}.example.com`,
|
||||
@@ -40,7 +30,7 @@ const register = (id: string) =>
|
||||
}),
|
||||
})
|
||||
})
|
||||
return { providerID, calls, failure, dispose: registration.dispose }
|
||||
return { providerID, calls }
|
||||
})
|
||||
|
||||
describe("WebSearch", () => {
|
||||
@@ -147,445 +137,17 @@ describe("WebSearch", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the random provider across queries, default lookups, and reloads", () =>
|
||||
it.effect("chooses a registered provider for random selection", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((editor) => editor.default.set("random"))
|
||||
|
||||
const first = yield* websearch.query({ query: "first" })
|
||||
expect(["exa", "parallel"]).toContain(first.providerID)
|
||||
expect((yield* websearch.default())?.id).toBe(first.providerID)
|
||||
yield* websearch.reload()
|
||||
const results = yield* Effect.all(
|
||||
Array.from({ length: 10 }, () => websearch.query({ query: "next" })),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
expect(results.every((result) => result.providerID === first.providerID)).toBe(true)
|
||||
expect(["exa", "parallel"]).toContain((yield* websearch.query({ query: "random" })).providerID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves persisted random selection and keeps its provider", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const kv = yield* KV.Service
|
||||
yield* kv.set(WebSearch.ProviderKey, "random")
|
||||
const first = yield* websearch.query({ query: "legacy" })
|
||||
expect((yield* websearch.query({ query: "sticky" })).providerID).toBe(first.providerID)
|
||||
yield* websearch.select("random")
|
||||
expect(yield* kv.get(WebSearch.ProviderKey)).toBe("random")
|
||||
expect((yield* websearch.query({ query: "canonical" })).providerID).toBe(first.providerID)
|
||||
}),
|
||||
)
|
||||
it.effect("fails over on rate limits with random and keeps the replacement after cooldown", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.transform((editor) => editor.default.set("random"))
|
||||
const first = yield* websearch.query({ query: "first" })
|
||||
const limited = first.providerID === exa.providerID ? exa : parallel
|
||||
const replacement = first.providerID === exa.providerID ? parallel : exa
|
||||
limited.failure.cause = TestWebSearch.httpError()
|
||||
const progress: WebSearch.ID[] = []
|
||||
expect(
|
||||
(yield* websearch.query(
|
||||
{ query: "retry" },
|
||||
{
|
||||
onProvider: (provider) =>
|
||||
Effect.sync(() => {
|
||||
progress.push(provider.id)
|
||||
}),
|
||||
},
|
||||
)).providerID,
|
||||
).toBe(replacement.providerID)
|
||||
expect(progress).toEqual([limited.providerID, replacement.providerID])
|
||||
expect(limited.calls.at(-1)).toEqual({ query: "retry" })
|
||||
expect(replacement.calls).toEqual([{ query: "retry" }])
|
||||
|
||||
limited.failure.cause = undefined
|
||||
yield* TestClock.adjust("59 seconds")
|
||||
expect((yield* websearch.query({ query: "cooling" })).providerID).toBe(replacement.providerID)
|
||||
expect(limited.calls).toHaveLength(2)
|
||||
yield* TestClock.adjust("1 second")
|
||||
expect((yield* websearch.query({ query: "still sticky" })).providerID).toBe(replacement.providerID)
|
||||
replacement.failure.cause = TestWebSearch.httpError()
|
||||
expect((yield* websearch.query({ query: "recovered" })).providerID).toBe(limited.providerID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reselects when a concurrent query cools down the provider while progress is pending", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
yield* websearch.query({ query: "seed" })
|
||||
const first = yield* websearch.default()
|
||||
if (!first) return yield* Effect.die("Expected an automatic provider")
|
||||
const limited = first.id === exa.providerID ? exa : parallel
|
||||
const replacement = first.id === exa.providerID ? parallel : exa
|
||||
const paused = yield* Deferred.make<void>()
|
||||
const resume = yield* Deferred.make<void>()
|
||||
const progress: WebSearch.ID[] = []
|
||||
const pending = yield* websearch
|
||||
.query(
|
||||
{ query: "pending" },
|
||||
{
|
||||
onProvider: (provider) =>
|
||||
Effect.gen(function* () {
|
||||
progress.push(provider.id)
|
||||
if (provider.id !== first.id) return
|
||||
yield* Deferred.succeed(paused, undefined)
|
||||
yield* Deferred.await(resume)
|
||||
}),
|
||||
},
|
||||
)
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(paused)
|
||||
limited.failure.cause = TestWebSearch.httpError()
|
||||
expect((yield* websearch.query({ query: "trigger" })).providerID).toBe(replacement.providerID)
|
||||
yield* Deferred.succeed(resume, undefined)
|
||||
expect((yield* Fiber.join(pending)).providerID).toBe(replacement.providerID)
|
||||
expect(progress).toEqual([limited.providerID, replacement.providerID])
|
||||
expect(limited.calls).toEqual([{ query: "seed" }, { query: "trigger" }])
|
||||
expect(replacement.calls).toEqual([{ query: "trigger" }, { query: "pending" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails promptly when all providers are cooling down without asking for a provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = [yield* register("exa"), yield* register("parallel"), yield* register("tavily")]
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
providers.forEach((provider) => {
|
||||
provider.failure.cause = TestWebSearch.httpError()
|
||||
})
|
||||
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
|
||||
expect(providers.map((provider) => provider.calls.length)).toEqual([1, 1, 1])
|
||||
expect(yield* websearch.default()).toBeDefined()
|
||||
expect(yield* websearch.query({ query: "still limited" }).pipe(Effect.flip)).toBeInstanceOf(
|
||||
WebSearch.RequestError,
|
||||
)
|
||||
expect(providers.map((provider) => provider.calls.length)).toEqual([1, 1, 1])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("tries each provider only once per query even with a zero cooldown", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = [yield* register("exa"), yield* register("parallel")]
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
providers.forEach((provider) => {
|
||||
provider.failure.cause = TestWebSearch.httpError(429, "0")
|
||||
})
|
||||
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
|
||||
expect(providers.map((provider) => provider.calls.length)).toEqual([1, 1])
|
||||
}),
|
||||
)
|
||||
;[
|
||||
{ header: "120", millis: 120_000 },
|
||||
{ header: "Thu, 01 Jan 1970 00:02:00 GMT", millis: 120_000 },
|
||||
{ header: undefined, millis: 60_000 },
|
||||
{ header: "invalid", millis: 60_000 },
|
||||
{ header: "", millis: 60_000 },
|
||||
{ header: "-1", millis: 60_000 },
|
||||
].forEach(({ header, millis }) => {
|
||||
it.effect(`respects Retry-After ${JSON.stringify(header)} and recovers after cooldown`, () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
provider.failure.cause = TestWebSearch.httpError(429, header)
|
||||
expect(yield* websearch.query({ query: "limited" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
|
||||
provider.failure.cause = undefined
|
||||
yield* TestClock.adjust(millis - 1)
|
||||
expect(yield* websearch.query({ query: "early" }).pipe(Effect.flip)).toBeInstanceOf(WebSearch.RequestError)
|
||||
expect(provider.calls).toHaveLength(1)
|
||||
yield* TestClock.adjust(1)
|
||||
expect((yield* websearch.query({ query: "recovered" })).providerID).toBe(provider.providerID)
|
||||
expect(provider.calls).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("does not rotate or cool down providers for other failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
const first = yield* websearch.query({ query: "first" })
|
||||
const provider = first.providerID === exa.providerID ? exa : parallel
|
||||
yield* Effect.forEach(
|
||||
[TestWebSearch.httpError(401), TestWebSearch.httpError(500), new Error("timeout")],
|
||||
(cause) =>
|
||||
Effect.gen(function* () {
|
||||
provider.failure.cause = cause
|
||||
expect(yield* websearch.query({ query: "failure" }).pipe(Effect.flip)).toMatchObject({
|
||||
providerID: first.providerID,
|
||||
cause,
|
||||
})
|
||||
expect((yield* websearch.default())?.id).toBe(first.providerID)
|
||||
}),
|
||||
)
|
||||
provider.failure.cause = undefined
|
||||
expect((yield* websearch.query({ query: "recovered" })).providerID).toBe(first.providerID)
|
||||
expect((first.providerID === exa.providerID ? parallel : exa).calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not fail over fixed or explicitly requested providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
exa.failure.cause = TestWebSearch.httpError()
|
||||
yield* websearch.select(exa.providerID)
|
||||
expect(yield* websearch.query({ query: "fixed" }).pipe(Effect.flip)).toMatchObject({ providerID: exa.providerID })
|
||||
yield* websearch.select("random")
|
||||
expect(yield* websearch.query({ query: "explicit", providerID: exa.providerID }).pipe(Effect.flip)).toMatchObject(
|
||||
{
|
||||
providerID: exa.providerID,
|
||||
},
|
||||
)
|
||||
expect(exa.calls).toHaveLength(2)
|
||||
expect(parallel.calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reselects when the active provider is removed", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
expect((yield* websearch.query({ query: "first" })).providerID).toBe(exa.providerID)
|
||||
const parallel = yield* register("parallel")
|
||||
expect((yield* websearch.query({ query: "still sticky" })).providerID).toBe(exa.providerID)
|
||||
yield* exa.dispose
|
||||
expect((yield* websearch.query({ query: "removed" })).providerID).toBe(parallel.providerID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses updated registrations for the sticky provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
expect((yield* websearch.query({ query: "original" })).results).toHaveLength(1)
|
||||
const updated = yield* websearch.transform((editor) =>
|
||||
editor.add({
|
||||
id: exa.providerID,
|
||||
name: "Updated Exa",
|
||||
execute: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
expect(yield* websearch.default()).toEqual({ id: exa.providerID, name: "Updated Exa" })
|
||||
expect((yield* websearch.query({ query: "updated" })).results).toEqual([])
|
||||
yield* updated.dispose
|
||||
expect((yield* websearch.query({ query: "restored" })).results).toHaveLength(1)
|
||||
expect(exa.calls).toEqual([{ query: "original" }, { query: "restored" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps independent session affinities across parallel initial and subsequent searches", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
yield* register("parallel")
|
||||
yield* register("tavily")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
const results = yield* Effect.forEach(
|
||||
[firstSession, secondSession],
|
||||
(sessionID) =>
|
||||
Effect.all(
|
||||
Array.from({ length: 8 }, () => websearch.query({ query: "parallel" }, { sessionID })),
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
},
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
expect(results.map((group) => new Set(group.map((result) => result.providerID)).size)).toEqual([1, 1])
|
||||
expect((yield* websearch.query({ query: "later" }, { sessionID: firstSession })).providerID).toBe(
|
||||
results[0]?.[0]?.providerID,
|
||||
)
|
||||
expect((yield* websearch.query({ query: "later" }, { sessionID: secondSession })).providerID).toBe(
|
||||
results[1]?.[0]?.providerID,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not overwrite peer or Location affinity when a session switches providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
yield* websearch.query({ query: "location" })
|
||||
yield* websearch.query({ query: "first" }, { sessionID: firstSession })
|
||||
yield* websearch.query({ query: "second" }, { sessionID: secondSession })
|
||||
const parallel = yield* register("parallel")
|
||||
exa.failure.cause = TestWebSearch.httpError()
|
||||
expect((yield* websearch.query({ query: "switch" }, { sessionID: firstSession })).providerID).toBe(
|
||||
parallel.providerID,
|
||||
)
|
||||
// Even while Exa is cooling down, inspection must not reroute any caller.
|
||||
expect((yield* websearch.default())?.id).toBe(exa.providerID)
|
||||
exa.failure.cause = undefined
|
||||
yield* TestClock.adjust("1 minute")
|
||||
expect((yield* websearch.query({ query: "peer" }, { sessionID: secondSession })).providerID).toBe(exa.providerID)
|
||||
expect((yield* websearch.query({ query: "location" })).providerID).toBe(exa.providerID)
|
||||
expect((yield* websearch.query({ query: "sticky replacement" }, { sessionID: firstSession })).providerID).toBe(
|
||||
parallel.providerID,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("shares cooldowns without sending a peer back to the rate-limited provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
yield* websearch.select("random")
|
||||
yield* websearch.query({ query: "first" }, { sessionID: firstSession })
|
||||
yield* websearch.query({ query: "second" }, { sessionID: secondSession })
|
||||
const parallel = yield* register("parallel")
|
||||
exa.failure.cause = TestWebSearch.httpError(429, "120")
|
||||
yield* websearch.query({ query: "switch" }, { sessionID: firstSession })
|
||||
expect((yield* websearch.query({ query: "peer" }, { sessionID: secondSession })).providerID).toBe(
|
||||
parallel.providerID,
|
||||
)
|
||||
expect(exa.calls).toHaveLength(3)
|
||||
exa.failure.cause = undefined
|
||||
yield* TestClock.adjust("2 minutes")
|
||||
expect((yield* websearch.query({ query: "sticky peer" }, { sessionID: secondSession })).providerID).toBe(
|
||||
parallel.providerID,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("converges overlapping session failures and ignores a late success on the old provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const websearch = yield* WebSearch.Service
|
||||
const arrived = yield* Deferred.make<void>()
|
||||
const failures = yield* Deferred.make<void>()
|
||||
const lateStarted = yield* Deferred.make<void>()
|
||||
const lateRelease = yield* Deferred.make<void>()
|
||||
const calls: string[] = []
|
||||
yield* websearch.transform((editor) =>
|
||||
editor.add({
|
||||
id: WebSearch.ID.make("exa"),
|
||||
name: "Exa",
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
if (input.query === "seed") return []
|
||||
if (input.query === "late") {
|
||||
yield* Deferred.succeed(lateStarted, undefined)
|
||||
yield* Deferred.await(lateRelease)
|
||||
return []
|
||||
}
|
||||
calls.push(input.query)
|
||||
if (calls.length === 3) yield* Deferred.succeed(arrived, undefined)
|
||||
yield* Deferred.await(failures)
|
||||
return yield* TestWebSearch.httpError()
|
||||
}),
|
||||
}),
|
||||
)
|
||||
yield* websearch.select("random")
|
||||
yield* websearch.query({ query: "seed" }, { sessionID: firstSession })
|
||||
const late = yield* websearch.query({ query: "late" }, { sessionID: firstSession }).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(lateStarted)
|
||||
yield* register("parallel")
|
||||
yield* register("tavily")
|
||||
const pending = yield* Effect.all(
|
||||
Array.from({ length: 3 }, (_, index) =>
|
||||
websearch.query({ query: `fail-${index}` }, { sessionID: firstSession }),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(arrived)
|
||||
yield* Deferred.succeed(failures, undefined)
|
||||
const results = yield* Fiber.join(pending)
|
||||
expect(new Set(results.map((result) => result.providerID)).size).toBe(1)
|
||||
expect(results[0]?.providerID).not.toBe(WebSearch.ID.make("exa"))
|
||||
yield* Deferred.succeed(lateRelease, undefined)
|
||||
expect((yield* Fiber.join(late)).providerID).toBe(WebSearch.ID.make("exa"))
|
||||
expect((yield* websearch.query({ query: "later" }, { sessionID: firstSession })).providerID).toBe(
|
||||
results[0]?.providerID,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps fixed and explicit providers pinned with session context", () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const parallel = yield* register("parallel")
|
||||
const websearch = yield* WebSearch.Service
|
||||
exa.failure.cause = TestWebSearch.httpError()
|
||||
yield* websearch.select(exa.providerID)
|
||||
expect(yield* websearch.query({ query: "fixed" }, { sessionID: firstSession }).pipe(Effect.flip)).toMatchObject({
|
||||
providerID: exa.providerID,
|
||||
})
|
||||
yield* websearch.select("random")
|
||||
expect(
|
||||
yield* websearch
|
||||
.query({ query: "explicit", providerID: exa.providerID }, { sessionID: firstSession })
|
||||
.pipe(Effect.flip),
|
||||
).toMatchObject({ providerID: exa.providerID })
|
||||
expect(parallel.calls).toEqual([])
|
||||
}),
|
||||
)
|
||||
;["delete", "move"].forEach((operation) => {
|
||||
it.effect(`forgets affinity on session ${operation} without retaining it through an in-flight query`, () =>
|
||||
Effect.gen(function* () {
|
||||
const exa = yield* register("exa")
|
||||
const websearch = yield* WebSearch.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* websearch.select("random")
|
||||
yield* websearch.query({ query: "seed" }, { sessionID: firstSession })
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const pending = yield* websearch
|
||||
.query(
|
||||
{ query: "pending" },
|
||||
{
|
||||
sessionID: firstSession,
|
||||
onProvider: (provider) =>
|
||||
provider.id === exa.providerID
|
||||
? Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)))
|
||||
: Effect.void,
|
||||
},
|
||||
)
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(started)
|
||||
yield* operation === "delete"
|
||||
? bus.publish(SessionEvent.Deleted, { sessionID: firstSession })
|
||||
: bus.publish(SessionEvent.Moved, {
|
||||
sessionID: firstSession,
|
||||
location: { directory: AbsolutePath.make("/moved") },
|
||||
projectID: Project.ID.global,
|
||||
})
|
||||
yield* Effect.yieldNow
|
||||
yield* exa.dispose
|
||||
const parallel = yield* register("parallel")
|
||||
expect((yield* websearch.query({ query: "new affinity" }, { sessionID: firstSession })).providerID).toBe(
|
||||
parallel.providerID,
|
||||
)
|
||||
yield* parallel.dispose
|
||||
const tavily = yield* register("tavily")
|
||||
exa.failure.cause = TestWebSearch.httpError()
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
expect((yield* Fiber.join(pending)).providerID).toBe(tavily.providerID)
|
||||
yield* register("parallel")
|
||||
expect((yield* websearch.query({ query: "still new affinity" }, { sessionID: firstSession })).providerID).toBe(
|
||||
parallel.providerID,
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("fails when web search is explicitly disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* register("exa")
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
- Follow Solid best practices, leave a comment when violating this: https://www.brenelz.com/posts/solid-js-best-practices/
|
||||
- Renderer process should only call `window.api` from `src/preload`.
|
||||
- Main process should register IPC handlers in `src/main/ipc.ts`.
|
||||
- Avoid FS operations where possible. For any desktop persistence prefer sqlite in most cases, as performance and EPERM and many other things, especially on windows can be quite painful. Using anything other than sqlite should come with strong reasons.
|
||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for native menus, picker titles, dialogs, buttons, accessible labels, and displayed errors.
|
||||
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
||||
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "drizzle-kit"
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
schema: "./src/main/storage/schema.ts",
|
||||
out: "./src/main/storage/migration",
|
||||
})
|
||||
@@ -78,10 +78,7 @@ const require = __cjs_mod__.createRequire(import.meta.url);
|
||||
input: { index: "src/preload/index.ts" },
|
||||
output: {
|
||||
format: "cjs",
|
||||
// The package is "type": "module". Under --no-sandbox Electron loads the preload
|
||||
// through Node's module loader, which treats a .js file as ESM and fails on
|
||||
// require("electron"). The sandboxed path ignores the extension.
|
||||
entryFileNames: "[name].cjs",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"typecheck": "tsgo -b",
|
||||
"test": "bun test --timeout 30000",
|
||||
"dev": "bun ./scripts/dev.ts",
|
||||
"migration": "bun ./scripts/migration.ts",
|
||||
"prebuild": "bun ./scripts/prebuild.ts",
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
@@ -47,7 +46,6 @@
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"app-builder-lib": "26.15.7",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
// Renders the drizzle-kit output under src/main/storage/migration into migration.gen.ts so the
|
||||
// main bundle and Bun tests import plain TypeScript instead of reading SQL files at runtime.
|
||||
//
|
||||
// bun run migration --name <change> generate a migration from schema.ts, then render
|
||||
// bun run migration render only
|
||||
// bun run migration --check fail when the rendered registry is stale
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "node:path"
|
||||
import { parseArgs } from "node:util"
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "..")
|
||||
const directory = path.join(root, "src/main/storage/migration")
|
||||
const output = path.join(root, "src/main/storage/migration.gen.ts")
|
||||
const args = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
check: { type: "boolean" },
|
||||
name: { type: "string" },
|
||||
},
|
||||
})
|
||||
|
||||
if (args.values.name) await $`bun drizzle-kit generate --name ${args.values.name}`.cwd(root)
|
||||
|
||||
const rendered = await render()
|
||||
if (!args.values.check) {
|
||||
await Bun.write(output, rendered)
|
||||
process.exit(0)
|
||||
}
|
||||
if ((await Bun.file(output).text()) !== rendered) {
|
||||
throw new Error("Desktop storage migrations are stale. Run `bun run migration` from packages/desktop.")
|
||||
}
|
||||
|
||||
async function render() {
|
||||
const ids = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: directory })))
|
||||
.map((file) => path.dirname(file))
|
||||
.sort()
|
||||
const migrations = await Promise.all(
|
||||
ids.map(async (id) => ({
|
||||
id,
|
||||
// Normalize so a CRLF checkout renders the same registry as an LF one.
|
||||
statements: (await Bun.file(path.join(directory, id, "migration.sql")).text())
|
||||
.replaceAll("\r\n", "\n")
|
||||
.split("--> statement-breakpoint")
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0),
|
||||
})),
|
||||
)
|
||||
const source = `// Generated by scripts/migration.ts from src/main/storage/migration. Do not edit.
|
||||
|
||||
export const migrations = ${JSON.stringify(migrations, null, 2)}
|
||||
`
|
||||
const prettier = await import("prettier")
|
||||
return prettier.format(source, { parser: "typescript", semi: false, printWidth: 120 })
|
||||
}
|
||||
@@ -6,17 +6,24 @@ export const storageHandlers = StorageRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const storage = yield* DesktopStorage.Service
|
||||
return StorageRpcs.of({
|
||||
StorageGet: ({ name, key }) => Effect.sync(() => storage.state.get(name, key)),
|
||||
StorageSet: ({ name, key, value }) => Effect.sync(() => storage.state.set(name, key, value)),
|
||||
StorageDelete: ({ name, key }) => Effect.sync(() => storage.state.delete(name, key)),
|
||||
StorageClear: ({ name }) => Effect.sync(() => storage.state.clear(name)),
|
||||
StorageKeys: ({ name }) => Effect.sync(() => storage.state.keys(name)),
|
||||
StorageLength: ({ name }) => Effect.sync(() => storage.state.length(name)),
|
||||
StorageGet: ({ name, key }) => Effect.sync(() => storage.get(name, key)),
|
||||
StorageSet: ({ name, key, value }) => Effect.sync(() => storage.set(name, key, value)),
|
||||
StorageDelete: ({ name, key }) => storage.deleteValue(name, key).pipe(Effect.orDie),
|
||||
StorageClear: ({ name }) => storage.clear(name).pipe(Effect.orDie),
|
||||
StorageKeys: ({ name }) => Effect.sync(() => storage.keys(name)),
|
||||
StorageLength: ({ name }) => Effect.sync(() => storage.length(name)),
|
||||
DraftsGet: ({ key }) => Effect.sync(() => storage.drafts.get(key)),
|
||||
DraftsSet: ({ key, value }) => Effect.sync(() => storage.drafts.set(key, value)),
|
||||
DraftsDelete: ({ key }) => Effect.sync(() => storage.drafts.set(key, null)),
|
||||
DraftsPutBlob: ({ data }) => Effect.sync(() => storage.drafts.putBlob(data)),
|
||||
DraftsGetBlob: ({ id }) => Effect.sync(() => storage.drafts.getBlob(id)),
|
||||
DraftsPutBlob: ({ data }) =>
|
||||
Effect.sync(() =>
|
||||
storage.drafts.putBlob(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer),
|
||||
),
|
||||
DraftsGetBlob: ({ id }) =>
|
||||
Effect.sync(() => {
|
||||
const data = storage.drafts.getBlob(id)
|
||||
return data ? new Uint8Array(data) : null
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -19,11 +19,12 @@ import { ApplicationLifecycle } from "./lifecycle"
|
||||
import { showCliInstaller } from "./native/install-cli"
|
||||
import { createMenu, sendMenuCommand } from "./native/menu"
|
||||
import { DesktopCli } from "./service/desktop-cli"
|
||||
import { DesktopStorage } from "./storage"
|
||||
import { Updater } from "./updater"
|
||||
import { getLastFocusedWindow } from "./windows"
|
||||
import { Wsl } from "./wsl/start"
|
||||
|
||||
const services = Layer.mergeAll(DesktopFiles.layer, Wsl.layer)
|
||||
const services = Layer.mergeAll(DesktopFiles.layer, DesktopStorage.layer, Wsl.layer)
|
||||
const handlers = Layer.mergeAll(
|
||||
appHandlers,
|
||||
storageHandlers,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
prepareApplicationEnvironment,
|
||||
prepareDesktop,
|
||||
} from "./environment"
|
||||
import { initializeFirstLaunchOnboarding } from "./onboarding"
|
||||
|
||||
export interface Interface {
|
||||
readonly version: string
|
||||
@@ -22,6 +23,7 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const logging = yield* DesktopLogging.Service
|
||||
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
|
||||
yield* prepareApplicationEnvironment
|
||||
yield* preferApplicationEnvironment
|
||||
yield* loadProxyEnvironment
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Effect, FileSystem, Path } from "effect"
|
||||
import { CHANNEL } from "../constants"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { getUserShell, loadShellEnv } from "../service/shell-env"
|
||||
import { cleanupStoreFiles } from "../storage/cleanup"
|
||||
import { registerRendererProtocol, setDockIcon } from "../windows"
|
||||
|
||||
const appNames: Record<string, string> = {
|
||||
@@ -73,6 +74,14 @@ export const preferApplicationEnvironment = Effect.gen(function* () {
|
||||
export const prepareDesktop = Effect.gen(function* () {
|
||||
const path = yield* Path.Path
|
||||
const paths = yield* DesktopPaths.resolve
|
||||
yield* cleanupStoreFiles(app.getPath("userData")).pipe(
|
||||
Effect.tap((result) =>
|
||||
result.deleted.length === 0
|
||||
? Effect.void
|
||||
: Effect.logInfo("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned }),
|
||||
),
|
||||
Effect.catch((error) => Effect.logWarning("failed to clean scoped store files", { error })),
|
||||
)
|
||||
if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1")
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
yield* registerRendererProtocol()
|
||||
|
||||
@@ -6,11 +6,9 @@ import { Context, Effect, Layer } from "effect"
|
||||
import { DeepLinksOpened } from "../../shared/ipc-rpc/events"
|
||||
import { emitIpcEvent } from "../ipc-events"
|
||||
import { DesktopLogging, scoped } from "../native/logging"
|
||||
import { DesktopStorage } from "../storage"
|
||||
import { safeWebContentsURL } from "../windows/state"
|
||||
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
|
||||
import { acquireApplicationLock, configureApplication } from "./environment"
|
||||
import { initializeFirstLaunchOnboarding } from "./onboarding"
|
||||
import { Shutdown } from "./shutdown"
|
||||
|
||||
export interface Interface {
|
||||
@@ -145,22 +143,13 @@ const runtime = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
// Storage opens after configureApplication has set userData and before windows exist, so window
|
||||
// teardown can clear a window's persisted state and every renderer request finds it ready.
|
||||
const platform = Layer.mergeAll(
|
||||
DesktopLogging.layer,
|
||||
Shutdown.layer,
|
||||
DesktopStorage.layer.pipe(Layer.provide(DesktopLogging.layer)),
|
||||
)
|
||||
const platform = Layer.merge(DesktopLogging.layer, Shutdown.layer)
|
||||
|
||||
export const layer = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
// Electron scopes the single-instance lock to userData.
|
||||
yield* configureApplication()
|
||||
if (!acquireApplicationLock()) return yield* Effect.interrupt
|
||||
// Decide first-launch state before the storage layer creates drafts.sqlite, which would
|
||||
// otherwise read as evidence of an earlier launch on a fresh install.
|
||||
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
|
||||
return runtime.pipe(Layer.provideMerge(platform))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as DesktopLogging from "./logging"
|
||||
|
||||
import log from "electron-log/main.js"
|
||||
import { app, crashReporter, netLog, shell } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Logger, Option, Path, References, Stream } from "effect"
|
||||
import { Context, Effect, FileSystem, Layer, Logger, Option, Path, References } from "effect"
|
||||
import { homedir } from "node:os"
|
||||
import { VERSION } from "../constants"
|
||||
|
||||
@@ -134,15 +134,11 @@ function exportDebugLogsEffect(fs: FileSystem.FileSystem, path: Path.Path) {
|
||||
const output = path.join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* Effect.logInfo("exporting debug logs", { output })
|
||||
const files = [
|
||||
yield* writeZip(fs, output, [
|
||||
{ name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(path), null, 2)) },
|
||||
...(yield* collect(fs, path, root, "desktop")),
|
||||
...(yield* Effect.forEach(serverLogRoots(path), (dir, i) => collect(fs, path, dir, `server-${i + 1}`))).flat(),
|
||||
...(yield* collect(fs, path, app.getPath("crashDumps"), "crashpad")),
|
||||
]
|
||||
const truncated = files.filter((file) => file.offset > 0).map((file) => file.name)
|
||||
yield* writeZip(fs, output, [
|
||||
{ name: "manifest.json", data: Buffer.from(JSON.stringify({ ...manifest(path), truncated }, null, 2)) },
|
||||
...files,
|
||||
])
|
||||
yield* Effect.sync(() => shell.showItemInFolder(output))
|
||||
return output
|
||||
@@ -233,7 +229,7 @@ function serverLogRoots(path: Path.Path) {
|
||||
]
|
||||
}
|
||||
|
||||
type Entry = { name: string; path: string; offset: number } | { name: string; data: Uint8Array }
|
||||
type Entry = { name: string; path: string } | { name: string; data: Uint8Array }
|
||||
|
||||
function collect(fs: FileSystem.FileSystem, path: Path.Path, dir: string, prefix: string) {
|
||||
return Effect.gen(function* () {
|
||||
@@ -246,11 +242,9 @@ function collect(fs: FileSystem.FileSystem, path: Path.Path, dir: string, prefix
|
||||
const info = yield* fs.stat(file)
|
||||
if (info.type === "Directory") return null
|
||||
if (Option.getOrElse(info.mtime, () => new Date(0)).getTime() < cutoff) return null
|
||||
if (info.size > FileSystem.Size(MAX_EXPORT_FILE_SIZE)) return null
|
||||
if (file.endsWith(".heapsnapshot")) return null
|
||||
// Server logs append forever without rotation, so the active log is often the largest
|
||||
// file. Export its tail rather than dropping the most relevant file from the bundle.
|
||||
const offset = Math.max(0, Number(info.size) - MAX_EXPORT_FILE_SIZE)
|
||||
return { name: path.join(prefix, entry).replace(/\\/g, "/"), path: file, offset }
|
||||
return { name: path.join(prefix, entry).replace(/\\/g, "/"), path: file }
|
||||
}),
|
||||
)).filter((entry) => entry !== null)
|
||||
})
|
||||
@@ -264,12 +258,7 @@ function writeZip(fs: FileSystem.FileSystem, output: string, entries: Entry[]) {
|
||||
entries,
|
||||
(entry) =>
|
||||
Effect.gen(function* () {
|
||||
const data =
|
||||
"data" in entry
|
||||
? entry.data
|
||||
: entry.offset === 0
|
||||
? yield* fs.readFile(entry.path)
|
||||
: Buffer.concat(yield* Stream.runCollect(fs.stream(entry.path, { offset: entry.offset })))
|
||||
const data = "data" in entry ? entry.data : yield* fs.readFile(entry.path)
|
||||
yield* Effect.tryPromise(() => writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(data)]))))
|
||||
}),
|
||||
{ concurrency: 1, discard: true },
|
||||
|
||||
@@ -13,7 +13,7 @@ export const resolve = Effect.gen(function* () {
|
||||
const root = path.dirname(yield* path.fromFileUrl(new URL(import.meta.url)))
|
||||
return {
|
||||
developmentResourcesRoot: path.join(root, "../../resources"),
|
||||
preloadPath: path.join(root, "../preload/index.cjs"),
|
||||
preloadPath: path.join(root, "../preload/index.js"),
|
||||
rendererRoot: path.join(root, "../renderer"),
|
||||
} satisfies Resolved
|
||||
}).pipe(Effect.orDie)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
|
||||
import * as NodePath from "@effect/platform-node/NodePath"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { tmpdir } from "node:os"
|
||||
import { Effect, FileSystem, Layer, Path } from "effect"
|
||||
import { cleanupStoreFiles, deleteStoreFileIfEmpty } from "./cleanup"
|
||||
|
||||
const roots: string[] = []
|
||||
const platform = Layer.merge(NodeFileSystem.layer, NodePath.layer)
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(platform)))
|
||||
|
||||
const tempRoot = Effect.fn("StorageTest.tempRoot")(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = yield* fs.makeTempDirectory({ directory: tmpdir(), prefix: "opencode-store-cleanup-" })
|
||||
roots.push(root)
|
||||
return root
|
||||
})
|
||||
|
||||
const writeStore = Effect.fn("StorageTest.writeStore")(function* (
|
||||
root: string,
|
||||
name: string,
|
||||
value: string,
|
||||
modified: Date,
|
||||
) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
yield* fs.writeFileString(path.join(root, name), value)
|
||||
yield* fs.utimes(path.join(root, name), modified, modified)
|
||||
})
|
||||
|
||||
afterEach(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
yield* Effect.forEach(roots.splice(0), (root) => fs.remove(root, { recursive: true, force: true }), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
describe("store cleanup", () => {
|
||||
test("removes empty scoped stores and leaves global stores alone", () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = yield* tempRoot()
|
||||
const now = new Date("2026-07-01T00:00:00.000Z")
|
||||
yield* writeStore(root, "opencode.draft.empty.dat", "{}", now)
|
||||
yield* writeStore(root, "opencode.workspace.empty.dat", "{\n}", now)
|
||||
yield* writeStore(root, "opencode.global.dat", "{}", now)
|
||||
yield* writeStore(root, "opencode.workspace.empty.dat.json", "{}", now)
|
||||
|
||||
const result = yield* cleanupStoreFiles(root, now.getTime())
|
||||
|
||||
expect(result.deleted.sort()).toEqual(["opencode.draft.empty.dat", "opencode.workspace.empty.dat"])
|
||||
expect((yield* fs.readDirectory(root)).sort()).toEqual([
|
||||
"opencode.global.dat",
|
||||
"opencode.workspace.empty.dat.json",
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("removes stale drafts by age without removing non-empty workspace stores", () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = yield* tempRoot()
|
||||
const now = new Date("2026-07-01T00:00:00.000Z")
|
||||
yield* writeStore(
|
||||
root,
|
||||
"opencode.draft.old.dat",
|
||||
'{"draft:prompt":"hello"}',
|
||||
new Date("2026-05-01T00:00:00.000Z"),
|
||||
)
|
||||
yield* writeStore(root, "opencode.draft.recent.dat", '{"draft:prompt":"hello"}', now)
|
||||
yield* writeStore(
|
||||
root,
|
||||
"opencode.workspace.old.dat",
|
||||
'{"workspace:layout":"wide"}',
|
||||
new Date("2025-01-01T00:00:00.000Z"),
|
||||
)
|
||||
yield* writeStore(root, "opencode.workspace.recent.dat", '{"workspace:layout":"wide"}', now)
|
||||
|
||||
const result = yield* cleanupStoreFiles(root, now.getTime())
|
||||
|
||||
expect(result.deleted).toEqual(["opencode.draft.old.dat"])
|
||||
expect((yield* fs.readDirectory(root)).sort()).toEqual([
|
||||
"opencode.draft.recent.dat",
|
||||
"opencode.workspace.old.dat",
|
||||
"opencode.workspace.recent.dat",
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("caps scoped stores by recency", () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = yield* tempRoot()
|
||||
const now = new Date("2026-07-01T00:00:00.000Z")
|
||||
yield* Effect.forEach(
|
||||
Array.from({ length: 102 }, (_, index) => index),
|
||||
(index) =>
|
||||
writeStore(
|
||||
root,
|
||||
`opencode.draft.${index}.dat`,
|
||||
'{"draft:prompt":"hello"}',
|
||||
new Date(now.getTime() - index * 1000),
|
||||
),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
|
||||
const result = yield* cleanupStoreFiles(root, now.getTime())
|
||||
const remaining = yield* fs.readDirectory(root)
|
||||
|
||||
expect(result.deleted.sort()).toEqual(["opencode.draft.100.dat", "opencode.draft.101.dat"])
|
||||
expect(remaining).toHaveLength(100)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("removes a scoped store immediately when it becomes empty", () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = yield* tempRoot()
|
||||
yield* writeStore(root, "opencode.draft.empty.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
|
||||
yield* writeStore(root, "opencode.global.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
|
||||
|
||||
expect(yield* deleteStoreFileIfEmpty(root, "opencode.draft.empty.dat")).toBe(true)
|
||||
expect(yield* deleteStoreFileIfEmpty(root, "opencode.global.dat")).toBe(false)
|
||||
expect(yield* fs.readDirectory(root)).toEqual(["opencode.global.dat"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Effect, FileSystem, Option, Path } from "effect"
|
||||
|
||||
const EMPTY_STORE_MAX_BYTES = 128
|
||||
const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
||||
const DRAFT_KEEP_RECENT = 100
|
||||
|
||||
type StoreKind = "draft" | "workspace"
|
||||
type StoreCandidate = {
|
||||
name: string
|
||||
path: string
|
||||
kind: StoreKind
|
||||
modified: number
|
||||
empty: boolean
|
||||
}
|
||||
|
||||
export const cleanupStoreFiles = Effect.fn("Storage.cleanupStoreFiles")(function* (
|
||||
userDataPath: string,
|
||||
now = Date.now(),
|
||||
) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const entries = yield* fs.readDirectory(userDataPath).pipe(Effect.orElseSucceed(() => []))
|
||||
const candidates = (yield* Effect.forEach(
|
||||
entries,
|
||||
Effect.fnUntraced(function* (entry) {
|
||||
const kind = storeKind(entry)
|
||||
if (!kind) return
|
||||
|
||||
const file = path.join(userDataPath, entry)
|
||||
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (stats?.type !== "File") return
|
||||
|
||||
return {
|
||||
name: entry,
|
||||
path: file,
|
||||
kind,
|
||||
modified: Option.getOrElse(stats.mtime, () => new Date(0)).getTime(),
|
||||
empty: yield* isEmptyStore(file, stats.size),
|
||||
}
|
||||
}),
|
||||
{ concurrency: 5 },
|
||||
)).filter((candidate) => !!candidate)
|
||||
|
||||
const stale = new Set<StoreCandidate>()
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.empty) stale.add(candidate)
|
||||
if (candidate.kind === "draft" && now - candidate.modified > DRAFT_RETENTION_MS) stale.add(candidate)
|
||||
}
|
||||
|
||||
candidates
|
||||
.filter((candidate) => candidate.kind === "draft" && !candidate.empty)
|
||||
.sort((a, b) => b.modified - a.modified)
|
||||
.slice(DRAFT_KEEP_RECENT)
|
||||
.forEach((candidate) => stale.add(candidate))
|
||||
|
||||
const deleted = yield* Effect.forEach(
|
||||
stale,
|
||||
Effect.fnUntraced(function* (candidate) {
|
||||
yield* fs.remove(candidate.path, { force: true })
|
||||
return candidate.name
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
return { scanned: candidates.length, deleted }
|
||||
})
|
||||
|
||||
export const deleteStoreFileIfEmpty = Effect.fn("Storage.deleteStoreFileIfEmpty")(function* (
|
||||
userDataPath: string,
|
||||
name: string,
|
||||
) {
|
||||
if (!storeKind(name)) return false
|
||||
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const file = path.join(userDataPath, name)
|
||||
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (stats?.type !== "File") return false
|
||||
if (!(yield* isEmptyStore(file, stats.size))) return false
|
||||
|
||||
yield* fs.remove(file, { force: true })
|
||||
return true
|
||||
})
|
||||
|
||||
function storeKind(name: string): StoreKind | undefined {
|
||||
if (/^opencode\.draft\..+\.dat$/.test(name)) return "draft"
|
||||
if (/^opencode\.workspace\..+\.dat$/.test(name)) return "workspace"
|
||||
}
|
||||
|
||||
const isEmptyStore = Effect.fn("Storage.isEmptyStore")(function* (file: string, size: FileSystem.Size) {
|
||||
if (size > FileSystem.Size(EMPTY_STORE_MAX_BYTES)) return false
|
||||
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const raw = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (raw === undefined) return false
|
||||
if (raw.trim() === "") return true
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { DatabaseSync } from "node:sqlite"
|
||||
import path from "node:path"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { drizzle } from "drizzle-orm/node-sqlite"
|
||||
import { migrate, openDatabase } from "./database"
|
||||
import { migrations } from "./migration.gen"
|
||||
|
||||
const tables = (db: ReturnType<typeof drizzle>) =>
|
||||
db
|
||||
.all<{ name: string }>(sql`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
|
||||
.map((row) => row.name)
|
||||
|
||||
describe("database", () => {
|
||||
test("bootstraps every table on a fresh database and is idempotent", () => {
|
||||
const database = openDatabase(":memory:")
|
||||
expect(tables(database.db)).toEqual(["blob", "document", "migration", "state"])
|
||||
expect(migrate(database.db)).toEqual([])
|
||||
database.close()
|
||||
})
|
||||
|
||||
test("adopts a drafts.sqlite created before the journal existed", () => {
|
||||
const native = new DatabaseSync(":memory:")
|
||||
native.exec(
|
||||
"CREATE TABLE document (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE blob (id TEXT PRIMARY KEY, data BLOB NOT NULL); INSERT INTO document VALUES ('k', 'v')",
|
||||
)
|
||||
const db = drizzle({ client: native })
|
||||
expect(migrate(db)).toEqual(migrations.map((migration) => migration.id))
|
||||
expect(tables(db)).toEqual(["blob", "document", "migration", "state"])
|
||||
expect(db.all<{ value: string }>(sql`SELECT value FROM document`)).toEqual([{ value: "v" }])
|
||||
expect(migrate(db)).toEqual([])
|
||||
})
|
||||
|
||||
test("rendered registry matches the drizzle-kit output on disk", async () => {
|
||||
const directory = path.join(import.meta.dirname, "migration")
|
||||
const ids = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: directory })))
|
||||
.map((file) => path.dirname(file))
|
||||
.sort()
|
||||
expect(migrations.map((migration) => migration.id)).toEqual(ids)
|
||||
for (const migration of migrations) {
|
||||
const source = (await Bun.file(path.join(directory, migration.id, "migration.sql")).text()).replaceAll(
|
||||
"\r\n",
|
||||
"\n",
|
||||
)
|
||||
for (const statement of migration.statements) expect(source).toContain(statement)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { DatabaseSync } from "node:sqlite"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { drizzle } from "drizzle-orm/node-sqlite"
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { migrations } from "./migration.gen"
|
||||
|
||||
export type Database = ReturnType<typeof drizzle>
|
||||
|
||||
// Owned by the runner rather than schema.ts so drizzle-kit never tries to migrate the journal itself.
|
||||
const journal = sqliteTable("migration", {
|
||||
id: text().primaryKey(),
|
||||
time_completed: integer().notNull(),
|
||||
})
|
||||
|
||||
export function openDatabase(filename: string) {
|
||||
const native = new DatabaseSync(filename)
|
||||
// WAL keeps readers off the writer. NORMAL fsyncs at checkpoints only, which survives an app
|
||||
// crash but not power loss; the right trade for UI state and far cheaper on Windows.
|
||||
native.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA temp_store=MEMORY")
|
||||
const db = drizzle({ client: native })
|
||||
migrate(db)
|
||||
return { db, close: () => native.close() }
|
||||
}
|
||||
|
||||
export function migrate(db: Database) {
|
||||
db.run(sql`CREATE TABLE IF NOT EXISTS ${journal} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
|
||||
const applied = new Set(
|
||||
db
|
||||
.select({ id: journal.id })
|
||||
.from(journal)
|
||||
.all()
|
||||
.map((row) => row.id),
|
||||
)
|
||||
// drafts.sqlite predates the journal: its tables were created by hand, so the migration that
|
||||
// would create them is recorded as applied instead of run.
|
||||
const legacy =
|
||||
applied.size === 0 &&
|
||||
db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'document'`) !== undefined
|
||||
const pending = migrations.filter((migration) => !applied.has(migration.id))
|
||||
if (pending.length === 0) return []
|
||||
db.transaction((tx) => {
|
||||
pending.forEach((migration, index) => {
|
||||
if (!(legacy && index === 0)) migration.statements.forEach((statement) => tx.run(sql.raw(statement)))
|
||||
tx.insert(journal).values({ id: migration.id, time_completed: Date.now() }).run()
|
||||
})
|
||||
})
|
||||
return pending.map((migration) => migration.id)
|
||||
}
|
||||
@@ -1,32 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { openDatabase } from "./database"
|
||||
import { createDraftStore } from "./drafts"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createDesktopDraftStore } from "./drafts"
|
||||
|
||||
describe("draft store", () => {
|
||||
test("queues documents and reads them back before and after flush", () => {
|
||||
const database = openDatabase(":memory:")
|
||||
const drafts = createDraftStore(database.db, { delay: 1_000 })
|
||||
drafts.set("a:draft:prompt", "{}")
|
||||
expect(drafts.get("a:draft:prompt")).toBe("{}")
|
||||
drafts.flush()
|
||||
expect(drafts.get("a:draft:prompt")).toBe("{}")
|
||||
drafts.set("a:draft:prompt", null)
|
||||
expect(drafts.get("a:draft:prompt")).toBeNull()
|
||||
drafts.flush()
|
||||
expect(database.db.all(sql`SELECT key FROM document`)).toEqual([])
|
||||
})
|
||||
test("flushes the latest buffered draft and stores blobs", () => {
|
||||
const store = createDesktopDraftStore(":memory:")
|
||||
store.set("prompt", "first")
|
||||
store.set("prompt", "latest")
|
||||
expect(store.get("prompt")).toBe("latest")
|
||||
store.flush()
|
||||
expect(store.get("prompt")).toBe("latest")
|
||||
|
||||
test("stores blobs by content hash and collects unreferenced ones on open", () => {
|
||||
const database = openDatabase(":memory:")
|
||||
const first = createDraftStore(database.db, { delay: 1_000 })
|
||||
const used = first.putBlob(new Uint8Array([1, 2, 3]))
|
||||
const unused = first.putBlob(new Uint8Array([4, 5, 6]))
|
||||
expect(first.putBlob(new Uint8Array([1, 2, 3]))).toBe(used)
|
||||
first.set("doc", JSON.stringify({ parts: [{ blob: { id: used } }] }))
|
||||
first.flush()
|
||||
const second = createDraftStore(database.db, { delay: 1_000 })
|
||||
expect(second.getBlob(used)).toEqual(new Uint8Array([1, 2, 3]))
|
||||
expect(second.getBlob(unused)).toBeNull()
|
||||
})
|
||||
const bytes = new TextEncoder().encode("image")
|
||||
const id = store.putBlob(bytes)
|
||||
expect(store.getBlob(id)).toEqual(bytes)
|
||||
store.close()
|
||||
})
|
||||
|
||||
test("allows repeated flushes until closing", () => {
|
||||
const store = createDesktopDraftStore(":memory:")
|
||||
store.set("prompt", "first")
|
||||
store.flush()
|
||||
store.set("prompt", "draft")
|
||||
store.flush()
|
||||
expect(store.get("prompt")).toBe("draft")
|
||||
store.close()
|
||||
|
||||
expect(() => store.flush()).not.toThrow()
|
||||
expect(() => store.close()).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -1,39 +1,72 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import type { Database } from "./database"
|
||||
import { blobs, document } from "./schema"
|
||||
import { createWriteBehind } from "./write-behind"
|
||||
import { DatabaseSync } from "node:sqlite"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { drizzle } from "drizzle-orm/node-sqlite"
|
||||
import { blob, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
|
||||
export type DraftStore = ReturnType<typeof createDraftStore>
|
||||
const documents = sqliteTable("document", {
|
||||
key: text().primaryKey(),
|
||||
value: text().notNull(),
|
||||
})
|
||||
const blobs = sqliteTable("blob", {
|
||||
id: text().primaryKey(),
|
||||
data: blob({ mode: "buffer" }).notNull(),
|
||||
})
|
||||
|
||||
export function createDraftStore(db: Database, input: { delay?: number; onError?: (error: unknown) => void } = {}) {
|
||||
collectBlobs(db)
|
||||
const byKey = eq(document.key, sql.placeholder("key"))
|
||||
const read = db.select({ value: document.value }).from(document).where(byKey).prepare()
|
||||
const remove = db.delete(document).where(byKey).prepare()
|
||||
const upsert = db
|
||||
.insert(document)
|
||||
.values({ key: sql.placeholder("key"), value: sql.placeholder("value") })
|
||||
.onConflictDoUpdate({ target: document.key, set: { value: sql.placeholder("value") } })
|
||||
.prepare()
|
||||
const writer = createWriteBehind<string | null>({
|
||||
delay: input.delay ?? 500,
|
||||
onError: input.onError,
|
||||
write: (batch) =>
|
||||
db.transaction(() => {
|
||||
for (const [key, value] of batch) {
|
||||
if (value === null) remove.run({ key })
|
||||
else upsert.run({ key, value })
|
||||
}
|
||||
export function createDesktopDraftStore(filename: string) {
|
||||
const native = new DatabaseSync(filename)
|
||||
native.exec(
|
||||
"PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS document (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS blob (id TEXT PRIMARY KEY, data BLOB NOT NULL);",
|
||||
)
|
||||
const db = drizzle({ client: native })
|
||||
const used = new Set<string>()
|
||||
db.select({ value: documents.value })
|
||||
.from(documents)
|
||||
.all()
|
||||
.forEach(({ value }) =>
|
||||
JSON.parse(value, (_key, item) => {
|
||||
if (item?.blob && typeof item.blob.id === "string") used.add(item.blob.id)
|
||||
return item
|
||||
}),
|
||||
})
|
||||
|
||||
)
|
||||
db.select({ id: blobs.id })
|
||||
.from(blobs)
|
||||
.all()
|
||||
.filter(({ id }) => !used.has(id))
|
||||
.forEach(({ id }) => db.delete(blobs).where(eq(blobs.id, id)).run())
|
||||
const pending = new Map<string, string | null>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let closed = false
|
||||
const flush = () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
if (closed) return
|
||||
const writes = [...pending]
|
||||
pending.clear()
|
||||
if (!writes.length) return
|
||||
db.transaction((tx) => {
|
||||
writes.forEach(([key, value]) => {
|
||||
if (value === null) tx.delete(documents).where(eq(documents.key, key)).run()
|
||||
else
|
||||
tx.insert(documents)
|
||||
.values({ key, value })
|
||||
.onConflictDoUpdate({ target: documents.key, set: { value } })
|
||||
.run()
|
||||
})
|
||||
})
|
||||
}
|
||||
const schedule = () => {
|
||||
if (!timer) timer = setTimeout(flush, 500)
|
||||
}
|
||||
return {
|
||||
get(key: string) {
|
||||
if (writer.has(key)) return writer.get(key) ?? null
|
||||
return read.get({ key })?.value ?? null
|
||||
get: (key: string) =>
|
||||
pending.has(key)
|
||||
? (pending.get(key) ?? null)
|
||||
: (db.select({ value: documents.value }).from(documents).where(eq(documents.key, key)).get()?.value ?? null),
|
||||
set(key: string, value: string | null) {
|
||||
pending.set(key, value)
|
||||
schedule()
|
||||
},
|
||||
set: (key: string, value: string | null) => writer.set(key, value),
|
||||
putBlob(data: Uint8Array) {
|
||||
const id = createHash("sha256").update(data).digest("hex")
|
||||
db.insert(blobs)
|
||||
@@ -42,22 +75,13 @@ export function createDraftStore(db: Database, input: { delay?: number; onError?
|
||||
.run()
|
||||
return id
|
||||
},
|
||||
getBlob(id: string): Uint8Array | null {
|
||||
return db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null
|
||||
getBlob: (id: string) => db.select({ data: blobs.data }).from(blobs).where(eq(blobs.id, id)).get()?.data ?? null,
|
||||
flush,
|
||||
close() {
|
||||
if (closed) return
|
||||
flush()
|
||||
closed = true
|
||||
native.close()
|
||||
},
|
||||
flush: writer.flush,
|
||||
close: writer.close,
|
||||
}
|
||||
}
|
||||
|
||||
// Blobs are content-addressed and shared; drop the ones no document references anymore. SQLite
|
||||
// walks the JSON itself, so startup does not parse every draft and history entry in JavaScript.
|
||||
function collectBlobs(db: Database) {
|
||||
db.run(sql`
|
||||
DELETE FROM ${blobs} WHERE ${blobs.id} NOT IN (
|
||||
SELECT json_extract(node.value, '$.id')
|
||||
FROM ${document}, json_tree(${document.value}) AS node
|
||||
WHERE json_valid(${document.value}) AND node.key = 'blob' AND node.type = 'object'
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,8 @@ export * as DesktopStorage from "./index"
|
||||
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import { Context, Effect, Layer, Path } from "effect"
|
||||
import { openDatabase } from "./database"
|
||||
import { createDraftStore } from "./drafts"
|
||||
import { importLegacyStores } from "./legacy"
|
||||
import { createStateStore } from "./state"
|
||||
import { createDesktopDraftStore } from "./drafts"
|
||||
import { getStore, removeStoreFileIfEmpty } from "./store"
|
||||
|
||||
export type Interface = ReturnType<typeof make>
|
||||
|
||||
@@ -15,52 +13,60 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const path = yield* Path.Path
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
const userData = app.getPath("userData")
|
||||
const storage = make(path.join(userData, "drafts.sqlite"), (error) =>
|
||||
runFork(Effect.logError("storage flush failed", { error })),
|
||||
)
|
||||
yield* importLegacyStores(storage.db, userData).pipe(
|
||||
Effect.tap((result) =>
|
||||
result.removed.length === 0
|
||||
? Effect.void
|
||||
: Effect.logInfo("imported legacy store files", { imported: result.imported, files: result.removed }),
|
||||
),
|
||||
Effect.catch((error) => Effect.logWarning("failed to import legacy store files", { error })),
|
||||
)
|
||||
const wire = (_event: Electron.Event, win: BrowserWindow) => win.on("session-end", storage.flush)
|
||||
app.on("before-quit", storage.flush)
|
||||
const storage = make(path.join(app.getPath("userData"), "drafts.sqlite"))
|
||||
const flush = () => storage.drafts.flush()
|
||||
const wire = (_event: Electron.Event, win: BrowserWindow) => win.on("session-end", flush)
|
||||
app.on("before-quit", flush)
|
||||
app.on("browser-window-created", wire)
|
||||
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
app.off("before-quit", storage.flush)
|
||||
app.off("before-quit", flush)
|
||||
app.off("browser-window-created", wire)
|
||||
BrowserWindow.getAllWindows().forEach((win) => win.off("session-end", storage.flush))
|
||||
storage.close()
|
||||
BrowserWindow.getAllWindows().forEach((win) => win.off("session-end", flush))
|
||||
storage.drafts.close()
|
||||
}),
|
||||
)
|
||||
return Service.of(storage)
|
||||
}),
|
||||
)
|
||||
|
||||
// The file keeps its historical name; renaming it would mean moving the drafts it already holds.
|
||||
export function make(filename: string, onError?: (error: unknown) => void) {
|
||||
const database = openDatabase(filename)
|
||||
const state = createStateStore(database.db, { onError })
|
||||
const drafts = createDraftStore(database.db, { onError })
|
||||
function make(draftFile: string) {
|
||||
const drafts = createDesktopDraftStore(draftFile)
|
||||
const deleteValue = Effect.fn("DesktopStorage.delete")(function* (name: string, key: string) {
|
||||
getStore(name).delete(key)
|
||||
yield* removeStoreFileIfEmpty(name).pipe(Effect.ignore)
|
||||
})
|
||||
const clear = Effect.fn("DesktopStorage.clear")(function* (name: string) {
|
||||
getStore(name).clear()
|
||||
yield* removeStoreFileIfEmpty(name).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
return {
|
||||
db: database.db,
|
||||
state,
|
||||
drafts,
|
||||
flush() {
|
||||
state.flush()
|
||||
drafts.flush()
|
||||
get(name: string, key: string) {
|
||||
try {
|
||||
const value = getStore(name).get(key)
|
||||
if (value === undefined || value === null) return null
|
||||
return typeof value === "string" ? value : JSON.stringify(value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
close() {
|
||||
state.close()
|
||||
drafts.close()
|
||||
database.close()
|
||||
set: (name: string, key: string, value: string) => getStore(name).set(key, value),
|
||||
deleteValue,
|
||||
clear,
|
||||
keys: (name: string) => Object.keys(getStore(name).store),
|
||||
length: (name: string) => Object.keys(getStore(name).store).length,
|
||||
drafts: {
|
||||
get: (key: string) => drafts.get(key),
|
||||
set: (key: string, value: string | null) => drafts.set(key, value),
|
||||
putBlob: (data: ArrayBuffer) => drafts.putBlob(new Uint8Array(data)),
|
||||
getBlob(id: string) {
|
||||
const data = drafts.getBlob(id)
|
||||
return data ? new Uint8Array(data).buffer : null
|
||||
},
|
||||
flush: drafts.flush,
|
||||
close: drafts.close,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ describe("hasExistingAppState", () => {
|
||||
test("recognizes state written by an earlier OpenCode launch", () => {
|
||||
expect(hasExistingAppState([file("opencode.settings")])).toBe(true)
|
||||
expect(hasExistingAppState([file("opencode.global.dat")])).toBe(true)
|
||||
expect(hasExistingAppState([file("drafts.sqlite")])).toBe(true)
|
||||
expect(hasExistingAppState([file("window-state-abc.json")])).toBe(true)
|
||||
expect(hasExistingAppState([directory("opencode")])).toBe(true)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ export function hasExistingAppState(entries: Array<{ name: string; directory: bo
|
||||
return entries.some((entry) => {
|
||||
if (entry.name === "opencode.settings") return true
|
||||
if (entry.name.endsWith(".dat")) return true
|
||||
if (entry.name === "drafts.sqlite") return true
|
||||
if (/^window-state-.+\.json$/.test(entry.name)) return true
|
||||
return entry.directory && entry.name === "opencode"
|
||||
})
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
|
||||
import * as NodePath from "@effect/platform-node/NodePath"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { tmpdir } from "node:os"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect, FileSystem, Layer, Path } from "effect"
|
||||
import { openDatabase } from "./database"
|
||||
import { importLegacyStores } from "./legacy"
|
||||
|
||||
const roots: string[] = []
|
||||
const platform = Layer.merge(NodeFileSystem.layer, NodePath.layer)
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(platform)))
|
||||
const now = new Date("2026-07-01T00:00:00.000Z")
|
||||
const day = 24 * 60 * 60 * 1000
|
||||
|
||||
const tempRoot = Effect.fn("LegacyTest.tempRoot")(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = yield* fs.makeTempDirectory({ directory: tmpdir(), prefix: "opencode-legacy-store-" })
|
||||
roots.push(root)
|
||||
return root
|
||||
})
|
||||
|
||||
const writeStore = Effect.fn("LegacyTest.writeStore")(function* (
|
||||
root: string,
|
||||
name: string,
|
||||
value: string,
|
||||
modified = now,
|
||||
) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
yield* fs.writeFileString(path.join(root, name), value)
|
||||
yield* fs.utimes(path.join(root, name), modified, modified)
|
||||
})
|
||||
|
||||
const listing = Effect.fn("LegacyTest.listing")(function* (root: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
return (yield* fs.readDirectory(root)).sort()
|
||||
})
|
||||
|
||||
const rows = (db: ReturnType<typeof openDatabase>["db"]) =>
|
||||
db.all<{ name: string; key: string; value: string }>(sql`SELECT name, key, value FROM state ORDER BY name, key`)
|
||||
|
||||
afterEach(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
yield* Effect.forEach(roots.splice(0), (root) => fs.remove(root, { recursive: true, force: true }), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
describe("legacy store import", () => {
|
||||
test("copies every namespace into state and removes the files", () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const root = yield* tempRoot()
|
||||
const database = openDatabase(":memory:")
|
||||
yield* writeStore(root, "opencode.global.dat", JSON.stringify({ model: "m", layout: { sidebar: 1 } }))
|
||||
yield* writeStore(root, "opencode.window.w1.dat", JSON.stringify({ tabs: "[]" }))
|
||||
yield* writeStore(root, "default.dat", JSON.stringify({ "settings.v3": "{}" }))
|
||||
yield* writeStore(root, "opencode.settings", JSON.stringify({ keep: true }))
|
||||
yield* writeStore(root, "unrelated.txt", "x")
|
||||
|
||||
const result = yield* importLegacyStores(database.db, root, now.getTime())
|
||||
|
||||
expect(result.imported).toBe(4)
|
||||
expect(rows(database.db)).toEqual([
|
||||
{ name: "default.dat", key: "settings.v3", value: "{}" },
|
||||
{ name: "opencode.global.dat", key: "layout", value: '{"sidebar":1}' },
|
||||
{ name: "opencode.global.dat", key: "model", value: "m" },
|
||||
{ name: "opencode.window.w1.dat", key: "tabs", value: "[]" },
|
||||
])
|
||||
expect(yield* listing(root)).toEqual(["opencode.settings", "unrelated.txt"])
|
||||
}),
|
||||
))
|
||||
|
||||
test("does not overwrite state that already exists", () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const root = yield* tempRoot()
|
||||
const database = openDatabase(":memory:")
|
||||
database.db.run(sql`INSERT INTO state VALUES ('opencode.global.dat', 'model', 'new', 0)`)
|
||||
yield* writeStore(root, "opencode.global.dat", JSON.stringify({ model: "old" }))
|
||||
|
||||
yield* importLegacyStores(database.db, root, now.getTime())
|
||||
|
||||
expect(rows(database.db)).toEqual([{ name: "opencode.global.dat", key: "model", value: "new" }])
|
||||
expect(yield* listing(root)).toEqual([])
|
||||
}),
|
||||
))
|
||||
|
||||
test("leaves unreadable files in place", () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const root = yield* tempRoot()
|
||||
const database = openDatabase(":memory:")
|
||||
yield* writeStore(root, "opencode.global.dat", "{not json")
|
||||
yield* writeStore(root, "opencode.workspace.x.dat", JSON.stringify({ terminal: "{}" }))
|
||||
|
||||
const result = yield* importLegacyStores(database.db, root, now.getTime())
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(yield* listing(root)).toEqual(["opencode.global.dat"])
|
||||
}),
|
||||
))
|
||||
|
||||
test("applies draft retention: skips empty, stale, and excess draft files", () =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const root = yield* tempRoot()
|
||||
const database = openDatabase(":memory:")
|
||||
yield* writeStore(root, "opencode.draft.empty.dat", "{}")
|
||||
yield* writeStore(
|
||||
root,
|
||||
"opencode.draft.stale.dat",
|
||||
JSON.stringify({ "draft:prompt": "old" }),
|
||||
new Date(now.getTime() - 31 * day),
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
Array.from({ length: 102 }, (_, index) => index),
|
||||
(index) =>
|
||||
writeStore(
|
||||
root,
|
||||
`opencode.draft.${index}.dat`,
|
||||
JSON.stringify({ "draft:prompt": `${index}` }),
|
||||
new Date(now.getTime() - index * 1_000),
|
||||
),
|
||||
{ concurrency: 5 },
|
||||
)
|
||||
|
||||
const result = yield* importLegacyStores(database.db, root, now.getTime())
|
||||
|
||||
expect(result.imported).toBe(100)
|
||||
const names = rows(database.db).map((row) => row.name)
|
||||
expect(names).toContain("opencode.draft.0.dat")
|
||||
expect(names).toContain("opencode.draft.99.dat")
|
||||
expect(names).not.toContain("opencode.draft.100.dat")
|
||||
expect(names).not.toContain("opencode.draft.101.dat")
|
||||
expect(names).not.toContain("opencode.draft.stale.dat")
|
||||
expect(names).not.toContain("opencode.draft.empty.dat")
|
||||
expect(yield* listing(root)).toEqual([])
|
||||
}),
|
||||
))
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import { Effect, FileSystem, Option, Path, Schema } from "effect"
|
||||
import type { Database } from "./database"
|
||||
import { state } from "./schema"
|
||||
|
||||
const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
|
||||
const DRAFT_KEEP_RECENT = 100
|
||||
|
||||
const Entries = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const decode = Schema.decodeUnknownOption(Entries)
|
||||
|
||||
type Candidate = { name: string; path: string; modified: number; entries: Record<string, unknown> }
|
||||
|
||||
// Before the state table existed, every namespace the renderer persisted was an electron-store
|
||||
// JSON file in userData. Copy them into SQLite once and remove them; this is the only place the
|
||||
// storage layer touches the filesystem, and it runs before the first renderer request.
|
||||
export const importLegacyStores = Effect.fn("DesktopStorage.importLegacyStores")(function* (
|
||||
db: Database,
|
||||
userData: string,
|
||||
now = Date.now(),
|
||||
) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const names = (yield* fs.readDirectory(userData).pipe(Effect.orElseSucceed(() => []))).filter(isStoreFile)
|
||||
const files = yield* Effect.forEach(
|
||||
names,
|
||||
Effect.fnUntraced(function* (name) {
|
||||
const file = path.join(userData, name)
|
||||
const stats = yield* fs.stat(file).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (stats?.type !== "File") return
|
||||
const raw = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (raw === undefined) return
|
||||
const entries = decode(raw)
|
||||
if (Option.isNone(entries)) {
|
||||
yield* Effect.logWarning("legacy store is not readable, leaving it in place", { name })
|
||||
return
|
||||
}
|
||||
return {
|
||||
name,
|
||||
path: file,
|
||||
modified: Option.getOrElse(stats.mtime, () => new Date(0)).getTime(),
|
||||
entries: entries.value,
|
||||
} satisfies Candidate
|
||||
}),
|
||||
{ concurrency: 5 },
|
||||
)
|
||||
const candidates = files.filter((file) => !!file)
|
||||
const kept = new Set(
|
||||
candidates
|
||||
.filter((file) => isDraft(file.name) && Object.keys(file.entries).length > 0)
|
||||
.filter((file) => now - file.modified <= DRAFT_RETENTION_MS)
|
||||
.sort((a, b) => b.modified - a.modified)
|
||||
.slice(0, DRAFT_KEEP_RECENT)
|
||||
.map((file) => file.name),
|
||||
)
|
||||
const rows = candidates
|
||||
.filter((file) => !isDraft(file.name) || kept.has(file.name))
|
||||
.flatMap((file) =>
|
||||
Object.entries(file.entries).map(([key, value]) => ({
|
||||
name: file.name,
|
||||
key,
|
||||
value: typeof value === "string" ? value : JSON.stringify(value),
|
||||
updated_at: file.modified,
|
||||
})),
|
||||
)
|
||||
// Existing rows win: a file left behind by an interrupted import must not overwrite newer state.
|
||||
if (rows.length > 0) {
|
||||
db.transaction((tx) => {
|
||||
rows.forEach((row) => tx.insert(state).values(row).onConflictDoNothing().run())
|
||||
})
|
||||
}
|
||||
yield* Effect.forEach(candidates, (file) => fs.remove(file.path, { force: true }), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
return { imported: rows.length, removed: candidates.map((file) => file.name) }
|
||||
})
|
||||
|
||||
function isStoreFile(name: string) {
|
||||
return name === "default.dat" || /^opencode\..+\.dat$/.test(name)
|
||||
}
|
||||
|
||||
function isDraft(name: string) {
|
||||
return name.startsWith("opencode.draft.")
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
// Generated by scripts/migration.ts from src/main/storage/migration. Do not edit.
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
id: "20260906234153_drafts",
|
||||
statements: [
|
||||
"CREATE TABLE `blob` (\n\t`id` text PRIMARY KEY,\n\t`data` blob NOT NULL\n);",
|
||||
"CREATE TABLE `document` (\n\t`key` text PRIMARY KEY,\n\t`value` text NOT NULL\n);",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "20260906234220_state",
|
||||
statements: [
|
||||
"CREATE TABLE `state` (\n\t`name` text NOT NULL,\n\t`key` text NOT NULL,\n\t`value` text NOT NULL,\n\t`updated_at` integer NOT NULL,\n\tCONSTRAINT `state_pk` PRIMARY KEY(`name`, `key`)\n);",
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
CREATE TABLE `blob` (
|
||||
`id` text PRIMARY KEY,
|
||||
`data` blob NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `document` (
|
||||
`key` text PRIMARY KEY,
|
||||
`value` text NOT NULL
|
||||
);
|
||||
@@ -1,77 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "463be56f-5f29-4831-9d00-1b9c6bd957e3",
|
||||
"prevIds": [
|
||||
"00000000-0000-0000-0000-000000000000"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "blob",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "document",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "blob"
|
||||
},
|
||||
{
|
||||
"type": "blob",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "blob"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "key",
|
||||
"entityType": "columns",
|
||||
"table": "document"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "value",
|
||||
"entityType": "columns",
|
||||
"table": "document"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "blob_pk",
|
||||
"table": "blob",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"key"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "document_pk",
|
||||
"table": "document",
|
||||
"entityType": "pks"
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
CREATE TABLE `state` (
|
||||
`name` text NOT NULL,
|
||||
`key` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
CONSTRAINT `state_pk` PRIMARY KEY(`name`, `key`)
|
||||
);
|
||||
@@ -1,131 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "5a2f8b7e-2765-4a0b-8877-a74ee2f29b20",
|
||||
"prevIds": [
|
||||
"463be56f-5f29-4831-9d00-1b9c6bd957e3"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "blob",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "document",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "state",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "blob"
|
||||
},
|
||||
{
|
||||
"type": "blob",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "blob"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "key",
|
||||
"entityType": "columns",
|
||||
"table": "document"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "value",
|
||||
"entityType": "columns",
|
||||
"table": "document"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "name",
|
||||
"entityType": "columns",
|
||||
"table": "state"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "key",
|
||||
"entityType": "columns",
|
||||
"table": "state"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "value",
|
||||
"entityType": "columns",
|
||||
"table": "state"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "updated_at",
|
||||
"entityType": "columns",
|
||||
"table": "state"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"name",
|
||||
"key"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "state_pk",
|
||||
"entityType": "pks",
|
||||
"table": "state"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "blob_pk",
|
||||
"table": "blob",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"key"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "document_pk",
|
||||
"table": "document",
|
||||
"entityType": "pks"
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { blob, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
|
||||
// Prompt drafts and history. `key` is `${storage}:${key}` as the renderer draft store writes it.
|
||||
export const document = sqliteTable("document", {
|
||||
key: text().primaryKey(),
|
||||
value: text().notNull(),
|
||||
})
|
||||
|
||||
// Images referenced from documents by content hash.
|
||||
export const blobs = sqliteTable("blob", {
|
||||
id: text().primaryKey(),
|
||||
data: blob({ mode: "buffer" }).notNull(),
|
||||
})
|
||||
|
||||
// Everything the renderer persists through `platform.storage(name)`. `name` is the storage
|
||||
// namespace the app chooses (still spelled like the `.dat` file it used to be) and `key` the
|
||||
// entry within it, so the app's relocation and alias logic keeps working unchanged.
|
||||
export const state = sqliteTable(
|
||||
"state",
|
||||
{
|
||||
name: text().notNull(),
|
||||
key: text().notNull(),
|
||||
value: text().notNull(),
|
||||
updated_at: integer().notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.name, table.key] })],
|
||||
)
|
||||
@@ -1,111 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { openDatabase } from "./database"
|
||||
import { createStateStore } from "./state"
|
||||
|
||||
const roots: string[] = []
|
||||
// Bun's node:sqlite shim keeps prepared statements alive after close(), which pins the WAL files
|
||||
// on Windows. Node (and so Electron) finalizes them; tolerate the leftover here only.
|
||||
afterEach(() =>
|
||||
Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }).catch(() => undefined))),
|
||||
)
|
||||
|
||||
const open = () => {
|
||||
const database = openDatabase(":memory:")
|
||||
return { db: database.db, store: createStateStore(database.db, { delay: 1_000 }) }
|
||||
}
|
||||
const rows = (db: ReturnType<typeof openDatabase>["db"]) =>
|
||||
db.all<{ name: string; key: string; value: string }>(sql`SELECT name, key, value FROM state ORDER BY name, key`)
|
||||
|
||||
describe("state store", () => {
|
||||
test("reads its own queued writes before they reach the database", () => {
|
||||
const { db, store } = open()
|
||||
store.set("global", "model", "a")
|
||||
expect(store.get("global", "model")).toBe("a")
|
||||
expect(rows(db)).toEqual([])
|
||||
store.flush()
|
||||
expect(rows(db)).toEqual([{ name: "global", key: "model", value: "a" }])
|
||||
expect(store.get("global", "model")).toBe("a")
|
||||
})
|
||||
|
||||
test("delete is visible immediately and removes the row on flush", () => {
|
||||
const { db, store } = open()
|
||||
store.set("global", "model", "a")
|
||||
store.flush()
|
||||
store.delete("global", "model")
|
||||
expect(store.get("global", "model")).toBeNull()
|
||||
expect(store.keys("global")).toEqual([])
|
||||
store.flush()
|
||||
expect(rows(db)).toEqual([])
|
||||
})
|
||||
|
||||
test("keys and length merge stored rows with queued changes", () => {
|
||||
const { store } = open()
|
||||
store.set("w", "tabs", "[]")
|
||||
store.set("w", "recent", "{}")
|
||||
store.flush()
|
||||
store.set("w", "info", "{}")
|
||||
store.delete("w", "recent")
|
||||
expect(store.keys("w").sort()).toEqual(["info", "tabs"])
|
||||
expect(store.length("w")).toBe(2)
|
||||
expect(store.keys("other")).toEqual([])
|
||||
})
|
||||
|
||||
test("clear drops a namespace including queued writes and leaves others alone", () => {
|
||||
const { db, store } = open()
|
||||
store.set("w1", "tabs", "[]")
|
||||
store.set("w2", "tabs", "[]")
|
||||
store.flush()
|
||||
store.set("w1", "recent", "{}")
|
||||
store.clear("w1")
|
||||
expect(store.get("w1", "recent")).toBeNull()
|
||||
store.flush()
|
||||
expect(rows(db)).toEqual([{ name: "w2", key: "tabs", value: "[]" }])
|
||||
})
|
||||
|
||||
test("a failed flush keeps every acknowledged write until a later flush succeeds", () => {
|
||||
const database = openDatabase(":memory:")
|
||||
const errors: unknown[] = []
|
||||
const store = createStateStore(database.db, { delay: 1_000, onError: (error) => errors.push(error) })
|
||||
store.set("w", "tabs", "[1]")
|
||||
store.set("w", "recent", "{}")
|
||||
database.db.run(sql`DROP TABLE state`)
|
||||
store.flush()
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(store.get("w", "tabs")).toBe("[1]")
|
||||
database.db.run(
|
||||
sql`CREATE TABLE state (name TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (name, key))`,
|
||||
)
|
||||
store.set("w", "info", "{}")
|
||||
store.flush()
|
||||
expect(rows(database.db)).toEqual([
|
||||
{ name: "w", key: "info", value: "{}" },
|
||||
{ name: "w", key: "recent", value: "{}" },
|
||||
{ name: "w", key: "tabs", value: "[1]" },
|
||||
])
|
||||
})
|
||||
|
||||
test("survives close and reopen on disk", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "opencode-state-"))
|
||||
roots.push(root)
|
||||
const file = path.join(root, "drafts.sqlite")
|
||||
const first = openDatabase(file)
|
||||
const store = createStateStore(first.db, { delay: 1_000 })
|
||||
store.set("global", "model", "a")
|
||||
store.close()
|
||||
first.close()
|
||||
const second = openDatabase(file)
|
||||
expect(createStateStore(second.db).get("global", "model")).toBe("a")
|
||||
second.close()
|
||||
})
|
||||
|
||||
test("a burst of writes to one key lands as a single upsert", () => {
|
||||
const { db, store } = open()
|
||||
for (let index = 0; index < 100; index++) store.set("global", "layout", `${index}`)
|
||||
store.flush()
|
||||
expect(rows(db)).toEqual([{ name: "global", key: "layout", value: "99" }])
|
||||
})
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
import { and, eq, sql } from "drizzle-orm"
|
||||
import type { Database } from "./database"
|
||||
import { state } from "./schema"
|
||||
import { createWriteBehind } from "./write-behind"
|
||||
|
||||
export type StateStore = ReturnType<typeof createStateStore>
|
||||
|
||||
type Row = { name: string; key: string; value: string | null }
|
||||
|
||||
// Reads hit SQLite directly: they happen at mount time and a point lookup on the primary key
|
||||
// costs microseconds, so a second in-memory copy would only duplicate the renderer's cache.
|
||||
export function createStateStore(db: Database, input: { delay?: number; onError?: (error: unknown) => void } = {}) {
|
||||
// Prepared once; the flush loop then only binds values instead of rebuilding SQL per row.
|
||||
const byKey = and(eq(state.name, sql.placeholder("name")), eq(state.key, sql.placeholder("key")))
|
||||
const read = db.select({ value: state.value }).from(state).where(byKey).prepare()
|
||||
const remove = db.delete(state).where(byKey).prepare()
|
||||
const upsert = db
|
||||
.insert(state)
|
||||
.values({
|
||||
name: sql.placeholder("name"),
|
||||
key: sql.placeholder("key"),
|
||||
value: sql.placeholder("value"),
|
||||
updated_at: sql.placeholder("updated_at"),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [state.name, state.key],
|
||||
set: { value: sql.placeholder("value"), updated_at: sql.placeholder("updated_at") },
|
||||
})
|
||||
.prepare()
|
||||
const writer = createWriteBehind<Row>({
|
||||
delay: input.delay ?? 250,
|
||||
onError: input.onError,
|
||||
write: (batch) =>
|
||||
db.transaction(() => {
|
||||
const updated_at = Date.now()
|
||||
for (const row of batch.values()) {
|
||||
if (row.value === null) remove.run({ name: row.name, key: row.key })
|
||||
else upsert.run({ name: row.name, key: row.key, value: row.value, updated_at })
|
||||
}
|
||||
}),
|
||||
})
|
||||
const id = (name: string, key: string) => `${name}\0${key}`
|
||||
const keys = (name: string) => {
|
||||
const result = new Set(
|
||||
db
|
||||
.select({ key: state.key })
|
||||
.from(state)
|
||||
.where(eq(state.name, name))
|
||||
.all()
|
||||
.map((row) => row.key),
|
||||
)
|
||||
for (const row of writer.entries()) {
|
||||
if (row.name !== name) continue
|
||||
if (row.value === null) result.delete(row.key)
|
||||
else result.add(row.key)
|
||||
}
|
||||
return [...result]
|
||||
}
|
||||
|
||||
return {
|
||||
get(name: string, key: string) {
|
||||
const queued = writer.get(id(name, key))
|
||||
if (queued) return queued.value
|
||||
return read.get({ name, key })?.value ?? null
|
||||
},
|
||||
set: (name: string, key: string, value: string) => writer.set(id(name, key), { name, key, value }),
|
||||
delete: (name: string, key: string) => writer.set(id(name, key), { name, key, value: null }),
|
||||
keys,
|
||||
length: (name: string) => keys(name).length,
|
||||
// Rare (window closed for good, explicit clear) so it goes straight to the database.
|
||||
clear(name: string) {
|
||||
writer.drop((row) => row.name === name)
|
||||
db.delete(state).where(eq(state.name, name)).run()
|
||||
},
|
||||
flush: writer.flush,
|
||||
close: writer.close,
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
import Store from "electron-store"
|
||||
import electron from "electron"
|
||||
import { Effect } from "effect"
|
||||
|
||||
import { deleteStoreFileIfEmpty } from "./cleanup"
|
||||
import { SETTINGS_STORE } from "./keys"
|
||||
|
||||
const cache = new Map<string, Store>()
|
||||
|
||||
// Main-process settings only (onboarding, default server, window list, appearance, updater).
|
||||
// These are read synchronously before the storage layer exists and written on user action, so
|
||||
// electron-store's synchronous file write is acceptable here. Renderer state goes through
|
||||
// DesktopStorage instead.
|
||||
//
|
||||
// We cannot instantiate the electron-store at module load time because
|
||||
// module import hoisting causes this to run before app.setPath("userData", ...)
|
||||
// in index.ts has executed, which would result in files being written to the default directory
|
||||
@@ -26,3 +23,11 @@ export function getStore(name = SETTINGS_STORE) {
|
||||
cache.set(name, next)
|
||||
return next
|
||||
}
|
||||
|
||||
export const removeStoreFileIfEmpty = Effect.fn("DesktopStorage.removeStoreFileIfEmpty")(function* (name: string) {
|
||||
if (yield* deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
|
||||
})
|
||||
|
||||
export function forgetStore(name: string) {
|
||||
cache.delete(name)
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createWriteBehind } from "./write-behind"
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
describe("write-behind", () => {
|
||||
test("coalesces a burst into one batch with the latest value per key", async () => {
|
||||
const batches: Map<string, number>[] = []
|
||||
const writer = createWriteBehind<number>({ delay: 10, write: (batch) => batches.push(batch) })
|
||||
writer.set("a", 1)
|
||||
writer.set("b", 1)
|
||||
writer.set("a", 2)
|
||||
expect(writer.get("a")).toBe(2)
|
||||
expect(batches).toHaveLength(0)
|
||||
await wait(30)
|
||||
expect(batches).toHaveLength(1)
|
||||
expect([...batches[0]!]).toEqual([
|
||||
["a", 2],
|
||||
["b", 1],
|
||||
])
|
||||
expect(writer.has("a")).toBe(false)
|
||||
})
|
||||
|
||||
test("flush writes immediately and close stops accepting writes", () => {
|
||||
const batches: Map<string, number>[] = []
|
||||
const writer = createWriteBehind<number>({ delay: 1_000, write: (batch) => batches.push(batch) })
|
||||
writer.set("a", 1)
|
||||
writer.flush()
|
||||
expect(batches).toHaveLength(1)
|
||||
writer.set("b", 2)
|
||||
writer.close()
|
||||
expect(batches).toHaveLength(2)
|
||||
writer.set("c", 3)
|
||||
writer.flush()
|
||||
expect(batches).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("drop removes queued entries matching a predicate", () => {
|
||||
const batches: Map<string, number>[] = []
|
||||
const writer = createWriteBehind<number>({ delay: 1_000, write: (batch) => batches.push(batch) })
|
||||
writer.set("a", 1)
|
||||
writer.set("b", 2)
|
||||
writer.drop((value) => value === 1)
|
||||
writer.flush()
|
||||
expect([...batches[0]!]).toEqual([["b", 2]])
|
||||
})
|
||||
|
||||
test("keeps a failed batch queued and retries it on the next flush", () => {
|
||||
const errors: unknown[] = []
|
||||
let fail = true
|
||||
const batches: Map<string, number>[] = []
|
||||
const writer = createWriteBehind<number>({
|
||||
delay: 1_000,
|
||||
onError: (error) => errors.push(error),
|
||||
write: (batch) => {
|
||||
if (fail) throw new Error("disk full")
|
||||
batches.push(batch)
|
||||
},
|
||||
})
|
||||
writer.set("a", 1)
|
||||
writer.set("b", 1)
|
||||
writer.flush()
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(writer.get("a")).toBe(1)
|
||||
fail = false
|
||||
writer.set("a", 2)
|
||||
writer.flush()
|
||||
expect([...batches[0]!].sort()).toEqual([
|
||||
["a", 2],
|
||||
["b", 1],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
// Coalesces a burst of writes into one batch. The latest value per key wins, so a store that
|
||||
// re-serializes on every mutation costs one row write per flush instead of one per change.
|
||||
export function createWriteBehind<T>(input: {
|
||||
delay: number
|
||||
write: (batch: Map<string, T>) => void
|
||||
onError?: (error: unknown) => void
|
||||
}) {
|
||||
const pending = new Map<string, T>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let closed = false
|
||||
|
||||
const flush = () => {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
if (closed || pending.size === 0) return
|
||||
const batch = new Map(pending)
|
||||
pending.clear()
|
||||
try {
|
||||
input.write(batch)
|
||||
} catch (error) {
|
||||
// The renderer already saw these writes succeed. Keep them queued so the next flush retries
|
||||
// them; anything written for the same key since then takes precedence.
|
||||
for (const [key, value] of batch) if (!pending.has(key)) pending.set(key, value)
|
||||
if (!input.onError) throw error
|
||||
input.onError(error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get: (key: string) => pending.get(key),
|
||||
has: (key: string) => pending.has(key),
|
||||
entries: () => pending.values(),
|
||||
set(key: string, value: T) {
|
||||
if (closed) return
|
||||
pending.set(key, value)
|
||||
timer ??= setTimeout(flush, input.delay)
|
||||
},
|
||||
drop(predicate: (value: T) => boolean) {
|
||||
for (const [key, value] of pending) if (predicate(value)) pending.delete(key)
|
||||
},
|
||||
flush,
|
||||
close() {
|
||||
flush()
|
||||
closed = true
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import { Effect, FileSystem, Path } from "effect"
|
||||
import { openExternalURL } from "../files"
|
||||
import { scoped } from "../native/logging"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { DesktopStorage } from "../storage"
|
||||
import { getStore } from "../storage/store"
|
||||
import { forgetStore, getStore } from "../storage/store"
|
||||
import { WINDOW_IDS_KEY } from "../storage/keys"
|
||||
import { windowIDArgument } from "../../shared/window-bootstrap"
|
||||
import {
|
||||
@@ -78,7 +77,6 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const paths = yield* DesktopPaths.resolve
|
||||
const storage = yield* DesktopStorage.Service
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
const wireWindowRecovery = yield* makeWindowRecovery
|
||||
|
||||
@@ -143,12 +141,14 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
win.on("session-end", () => registry.setQuitting())
|
||||
win.on("closed", () => {
|
||||
if (!registry.closed(id)) return
|
||||
const data = windowDataFile(id)
|
||||
runFork(
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.try(() => storage.state.clear(windowDataFile(id)))
|
||||
yield* fs.remove(path.join(app.getPath("userData"), windowStateFile(id)), { force: true })
|
||||
yield* fs.remove(path.join(app.getPath("userData"), data), { force: true })
|
||||
}).pipe(
|
||||
Effect.catch((error) => scoped("window", Effect.logError("failed to clean window state", { id, error }))),
|
||||
Effect.tap(() => Effect.sync(() => forgetStore(data))),
|
||||
Effect.catch((error) => scoped("window", Effect.logError("failed to clean window files", { id, error }))),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -161,8 +161,7 @@ function windowStateFile(id: string) {
|
||||
return `window-state-${safeWindowID(id)}.json`
|
||||
}
|
||||
|
||||
// Mirrors windowStorage() in packages/app/src/runtime/persistence/storage.ts; it is the state
|
||||
// namespace the renderer persists this window's tabs under.
|
||||
// Mirrors windowStorage() in packages/app/src/utils/persist.ts.
|
||||
function windowDataFile(id: string) {
|
||||
return `opencode.window.${safeWindowID(id)}.dat`
|
||||
}
|
||||
|
||||
@@ -7407,7 +7407,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Update the project canonical directory, display metadata, and workspace commands.",
|
||||
"description": "Update project display metadata and workspace commands.",
|
||||
"summary": "Update project",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -7415,9 +7415,6 @@
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"canonical": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -14592,8 +14589,7 @@
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["random"],
|
||||
"description": "Reuse a randomly selected provider until it is rate limited, then switch to another available provider."
|
||||
"enum": ["random"]
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
|
||||
@@ -29,7 +29,7 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.project.update",
|
||||
summary: "Update project",
|
||||
description: "Update the project canonical directory, display metadata, and workspace commands.",
|
||||
description: "Update project display metadata and workspace commands.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,13 +4,7 @@ import { Schema } from "effect"
|
||||
import { WebSearch } from "../websearch.js"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigWebSearch.Info")({
|
||||
provider: Schema.Union([
|
||||
Schema.Literal("random").annotate({
|
||||
description:
|
||||
"Reuse a randomly selected provider until it is rate limited, then switch to another available provider.",
|
||||
}),
|
||||
WebSearch.ID,
|
||||
]),
|
||||
provider: Schema.Union([Schema.Literal("random"), WebSearch.ID]),
|
||||
}) {}
|
||||
|
||||
export const Selection = Schema.Union([Schema.Literal(false), Info])
|
||||
|
||||
@@ -50,7 +50,6 @@ export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const UpdateInput = Schema.Struct({
|
||||
projectID: ID,
|
||||
canonical: optional(AbsolutePath),
|
||||
name: optional(Schema.String),
|
||||
icon: optional(Icon),
|
||||
commands: optional(Commands),
|
||||
|
||||
@@ -233,7 +233,7 @@ export namespace Execution {
|
||||
export const Interrupted = Event.durable({
|
||||
type: "session.execution.interrupted",
|
||||
...options,
|
||||
schema: { ...Base, reason: Schema.Literals(["user", "shutdown", "superseded", "inactivity"]) },
|
||||
schema: { ...Base, reason: Schema.Literals(["user", "shutdown", "superseded"]) },
|
||||
})
|
||||
export type Interrupted = typeof Interrupted.Type
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import {
|
||||
CliRenderer,
|
||||
RGBA,
|
||||
RootTextNodeRenderable,
|
||||
TextNodeRenderable,
|
||||
type Renderable,
|
||||
type RenderContext,
|
||||
type TextNodeOptions,
|
||||
} from "@opentui/core"
|
||||
import { extend } from "@opentui/solid"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { smootherstep } from "./tab-pulse"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { webSearchProviderName } from "../util/tool-display"
|
||||
|
||||
type Value = { id: string; provider: string; running: boolean }
|
||||
type Options = TextNodeOptions & { value?: Value; enabled?: boolean }
|
||||
|
||||
const FADE = 80
|
||||
const RESIZE = 60
|
||||
const DURATION = FADE * 2 + RESIZE
|
||||
|
||||
/** An inline span: keep native wrapping, selection, and the surrounding tool's styles. */
|
||||
export class RetryProviderRenderable extends TextNodeRenderable {
|
||||
private readonly ctx: CliRenderer
|
||||
private current?: Value
|
||||
private enabledValue: boolean
|
||||
private painted = false
|
||||
private disposed = false
|
||||
private elapsed: number | undefined
|
||||
private fresh = false
|
||||
private source = ""
|
||||
private target = ""
|
||||
private displayed = ""
|
||||
private opacity = 1
|
||||
private fromOpacity = 1
|
||||
|
||||
constructor(ctx: RenderContext, options: Options) {
|
||||
super(options)
|
||||
if (!(ctx instanceof CliRenderer)) throw new Error("RetryProvider requires a renderer frame clock")
|
||||
this.ctx = ctx
|
||||
this.enabledValue = options.enabled ?? true
|
||||
if (options.value) this.value = options.value
|
||||
}
|
||||
|
||||
private markPainted = () => {
|
||||
if (!this.onScreen()) return
|
||||
this.painted = true
|
||||
this.ctx.off("frame", this.markPainted)
|
||||
}
|
||||
|
||||
private onScreen() {
|
||||
let node: TextNodeRenderable | null = this
|
||||
while (node && !(node instanceof RootTextNodeRenderable)) {
|
||||
if (!node.visible) return false
|
||||
node = node.parent
|
||||
}
|
||||
if (!node) return false
|
||||
const text = node.textParent
|
||||
if (
|
||||
text.width <= 0 ||
|
||||
text.height <= 0 ||
|
||||
text.screenX < 0 ||
|
||||
text.screenY < 0 ||
|
||||
text.screenX + text.width > this.ctx.width ||
|
||||
text.screenY + text.height > this.ctx.height
|
||||
)
|
||||
return false
|
||||
// Be conservative with clipped rows: do not start a transition as history enters the viewport.
|
||||
for (let parent: Renderable | null = text; parent; parent = parent.parent) {
|
||||
if (!parent.visible) return false
|
||||
if (parent.overflow === "visible") continue
|
||||
if (
|
||||
text.screenX < parent.screenX ||
|
||||
text.screenY < parent.screenY ||
|
||||
text.screenX + text.width > parent.screenX + parent.width ||
|
||||
text.screenY + text.height > parent.screenY + parent.height
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
set value(value: Value) {
|
||||
if (this.disposed) return
|
||||
const previous = this.current
|
||||
this.current = value
|
||||
if (!value.running) this.ctx.off("frame", this.markPainted)
|
||||
if (previous?.id === value.id && previous.provider === value.provider) return
|
||||
this.target = webSearchProviderName(value.provider)
|
||||
const retry =
|
||||
this.enabledValue &&
|
||||
this.painted &&
|
||||
previous?.id === value.id &&
|
||||
previous.running &&
|
||||
value.running &&
|
||||
this.onScreen()
|
||||
if (!retry) {
|
||||
this.finish()
|
||||
this.painted = false
|
||||
this.ctx.off("frame", this.markPainted)
|
||||
if (value.running) this.ctx.on("frame", this.markPainted)
|
||||
return
|
||||
}
|
||||
// During fade-out, replace the destination without restarting or queuing transitions.
|
||||
if (this.elapsed !== undefined && this.elapsed < FADE) return
|
||||
this.source = this.displayed
|
||||
this.fromOpacity = this.opacity
|
||||
const start = this.elapsed === undefined
|
||||
this.elapsed = 0
|
||||
this.fresh = true
|
||||
if (start) {
|
||||
this.ctx.setFrameCallback(this.tick)
|
||||
this.ctx.requestLive()
|
||||
}
|
||||
}
|
||||
|
||||
set enabled(value: boolean) {
|
||||
if (value === this.enabledValue) return
|
||||
this.enabledValue = value
|
||||
if (!value) this.finish()
|
||||
}
|
||||
|
||||
private tick = async (delta: number) => {
|
||||
if (this.elapsed === undefined) return
|
||||
if (!this.onScreen()) {
|
||||
this.finish()
|
||||
this.painted = false
|
||||
if (this.current?.running) this.ctx.on("frame", this.markPainted)
|
||||
return
|
||||
}
|
||||
// Ignore time the renderer spent idle before this transition started.
|
||||
this.elapsed += this.fresh ? 0 : delta
|
||||
this.fresh = false
|
||||
if (this.elapsed >= DURATION) return this.finish()
|
||||
if (this.elapsed < FADE) {
|
||||
return this.show(this.source, this.fromOpacity * (1 - smootherstep(this.elapsed / FADE)))
|
||||
}
|
||||
if (this.elapsed < FADE + RESIZE) {
|
||||
// Move the query only while the name is invisible; never reveal partial provider glyphs.
|
||||
const from = stringWidth(this.source)
|
||||
const to = stringWidth(this.target)
|
||||
return this.show(" ".repeat(Math.round(from + (to - from) * smootherstep((this.elapsed - FADE) / RESIZE))), 0)
|
||||
}
|
||||
this.show(this.target, smootherstep((this.elapsed - FADE - RESIZE) / FADE))
|
||||
}
|
||||
|
||||
private show(text: string, opacity: number) {
|
||||
if (text === this.displayed && opacity === this.opacity) return
|
||||
this.displayed = text
|
||||
this.opacity = opacity
|
||||
this.children = [text]
|
||||
}
|
||||
|
||||
private stop() {
|
||||
if (this.elapsed !== undefined) {
|
||||
this.ctx.removeFrameCallback(this.tick)
|
||||
this.ctx.dropLive()
|
||||
}
|
||||
this.elapsed = undefined
|
||||
}
|
||||
|
||||
private finish() {
|
||||
this.stop()
|
||||
this.show(this.target, 1)
|
||||
}
|
||||
|
||||
override gatherWithInheritedStyle(style?: Parameters<TextNodeRenderable["gatherWithInheritedStyle"]>[0]) {
|
||||
const chunks = super.gatherWithInheritedStyle(style)
|
||||
if (this.opacity === 1) return chunks
|
||||
return chunks.map((chunk) => {
|
||||
const fg = RGBA.clone(chunk.fg ?? RGBA.defaultForeground())
|
||||
fg.a *= this.opacity
|
||||
return { ...chunk, fg }
|
||||
})
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.ctx.off("frame", this.markPainted)
|
||||
this.stop()
|
||||
super.destroy()
|
||||
}
|
||||
|
||||
override destroyRecursively() {
|
||||
this.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
extend({ retry_provider: RetryProviderRenderable })
|
||||
|
||||
export function RetryProvider(props: { value: Value; enabled: boolean }) {
|
||||
// Solid's text-node reconciler only applies inline styles; control the custom span through its ref.
|
||||
return (
|
||||
<retry_provider
|
||||
ref={(node) => {
|
||||
onCleanup(() => node.destroy())
|
||||
createEffect(() => {
|
||||
node.enabled = props.enabled
|
||||
node.value = props.value
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
retry_provider: typeof RetryProviderRenderable
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user