Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d116655c4 | ||
|
|
8eae3a287e |
@@ -329,7 +329,6 @@ export const SettingsGeneral: Component = () => {
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
if (option.value === currentShell()) return
|
||||
globalSync.updateConfig({ shell: option.value })
|
||||
}}
|
||||
variant="secondary"
|
||||
|
||||
@@ -245,7 +245,10 @@ export const ExportCommand = cmd({
|
||||
output: process.stderr,
|
||||
})
|
||||
|
||||
const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list()))
|
||||
const sessions = []
|
||||
for await (const session of Session.list()) {
|
||||
sessions.push(session)
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
prompts.log.error("No sessions found", {
|
||||
|
||||
@@ -91,9 +91,7 @@ export const SessionListCommand = cmd({
|
||||
},
|
||||
handler: async (args) => {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const sessions = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })),
|
||||
)
|
||||
const sessions = [...Session.list({ roots: true, limit: args.maxCount })]
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return
|
||||
|
||||
@@ -189,20 +189,13 @@ export function resolveZedDbPath() {
|
||||
path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
|
||||
].filter((item): item is string => Boolean(item))
|
||||
|
||||
return candidates.find((item) => isFile(item))
|
||||
}
|
||||
|
||||
function isFile(item: string) {
|
||||
try {
|
||||
return Filesystem.stat(item)?.isFile() === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return candidates.find((item) => Filesystem.stat(item)?.isFile())
|
||||
}
|
||||
|
||||
function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
|
||||
return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
|
||||
if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length)
|
||||
if (pathContains(item, cwd)) return Math.max(score, 2)
|
||||
if (pathContains(cwd, item)) return Math.max(score, 1)
|
||||
return score
|
||||
}, 0)
|
||||
}
|
||||
|
||||
@@ -759,23 +759,18 @@ export const layer = Layer.effect(
|
||||
const patch = writableGlobal(config)
|
||||
|
||||
let next: Info
|
||||
let changed: boolean
|
||||
if (!file.endsWith(".jsonc")) {
|
||||
const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file)
|
||||
const merged = mergeDeep(writable(existing), patch)
|
||||
const serialized = JSON.stringify(merged, null, 2)
|
||||
changed = serialized !== before
|
||||
if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
||||
next = merged
|
||||
} else {
|
||||
const updated = patchJsonc(before, patch)
|
||||
next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
|
||||
changed = updated !== before
|
||||
if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
// Only tear down running instances if the config actually changed.
|
||||
if (changed) yield* invalidate()
|
||||
yield* invalidate()
|
||||
return next
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Git } from "@/git"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Config } from "@/config/config"
|
||||
import { FileIgnore } from "./ignore"
|
||||
@@ -75,27 +76,25 @@ export const layer = Layer.effect(
|
||||
function* () {
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
|
||||
|
||||
const ctx = yield* InstanceState.context
|
||||
|
||||
log.info("init", { directory: ctx.directory })
|
||||
log.info("init", { directory: Instance.directory })
|
||||
|
||||
const backend = getBackend()
|
||||
if (!backend) {
|
||||
log.error("watcher backend not supported", { directory: ctx.directory, platform: process.platform })
|
||||
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
|
||||
return
|
||||
}
|
||||
|
||||
const w = watcher()
|
||||
if (!w) return
|
||||
|
||||
log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend })
|
||||
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
|
||||
|
||||
const subs: ParcelWatcher.AsyncSubscription[] = []
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
|
||||
)
|
||||
|
||||
const cb: ParcelWatcher.SubscribeCallback = InstanceState.bind((err, evts) => {
|
||||
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||
@@ -123,14 +122,19 @@ export const layer = Layer.effect(
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)])
|
||||
yield* subscribe(Instance.directory, [
|
||||
...FileIgnore.PATTERNS,
|
||||
...cfgIgnores,
|
||||
...protecteds(Instance.directory),
|
||||
])
|
||||
}
|
||||
|
||||
if (ctx.project.vcs === "git") {
|
||||
if (Instance.project.vcs === "git") {
|
||||
const result = yield* git.run(["rev-parse", "--git-dir"], {
|
||||
cwd: ctx.worktree,
|
||||
cwd: Instance.project.worktree,
|
||||
})
|
||||
const vcsDir = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
|
||||
const vcsDir =
|
||||
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||
(entry) => entry !== "HEAD",
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as Project from "./project"
|
||||
import * as Vcs from "./vcs"
|
||||
import { Bus } from "../bus"
|
||||
import { Command } from "../command"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Instance } from "./instance"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
@@ -15,8 +15,7 @@ import * as Effect from "effect/Effect"
|
||||
import { Config } from "@/config/config"
|
||||
|
||||
export const InstanceBootstrap = Effect.gen(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
Log.Default.info("bootstrapping", { directory: ctx.directory })
|
||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||
// everything depends on config so eager load it for nice traces
|
||||
yield* Config.Service.use((svc) => svc.get())
|
||||
// Plugin can mutate config so it has to be initialized before anything else.
|
||||
@@ -33,11 +32,10 @@ export const InstanceBootstrap = Effect.gen(function* () {
|
||||
].map((s) => Effect.forkDetach(s.use((i) => i.init()))),
|
||||
).pipe(Effect.withSpan("InstanceBootstrap.init"))
|
||||
|
||||
const projectID = ctx.project.id
|
||||
yield* Bus.Service.use((svc) =>
|
||||
svc.subscribeCallback(Command.Event.Executed, async (payload) => {
|
||||
if (payload.properties.name === Command.Default.INIT) {
|
||||
Project.setInitialized(projectID)
|
||||
Project.setInitialized(Instance.project.id)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@ import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
|
||||
const log = Log.create({ service: "project" })
|
||||
@@ -24,13 +24,13 @@ const log = Log.create({ service: "project" })
|
||||
const ProjectVcs = Schema.Literal("git")
|
||||
|
||||
const ProjectIcon = Schema.Struct({
|
||||
url: optionalOmitUndefined(Schema.String),
|
||||
override: optionalOmitUndefined(Schema.String),
|
||||
color: optionalOmitUndefined(Schema.String),
|
||||
url: Schema.optional(Schema.String),
|
||||
override: Schema.optional(Schema.String),
|
||||
color: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const ProjectCommands = Schema.Struct({
|
||||
start: optionalOmitUndefined(
|
||||
start: Schema.optional(
|
||||
Schema.String.annotate({ description: "Startup script to run when creating a new workspace (worktree)" }),
|
||||
),
|
||||
})
|
||||
@@ -38,16 +38,16 @@ const ProjectCommands = Schema.Struct({
|
||||
const ProjectTime = Schema.Struct({
|
||||
created: NonNegativeInt,
|
||||
updated: NonNegativeInt,
|
||||
initialized: optionalOmitUndefined(NonNegativeInt),
|
||||
initialized: Schema.optional(NonNegativeInt),
|
||||
})
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
id: ProjectID,
|
||||
worktree: Schema.String,
|
||||
vcs: optionalOmitUndefined(ProjectVcs),
|
||||
name: optionalOmitUndefined(Schema.String),
|
||||
icon: optionalOmitUndefined(ProjectIcon),
|
||||
commands: optionalOmitUndefined(ProjectCommands),
|
||||
vcs: Schema.optional(ProjectVcs),
|
||||
name: Schema.optional(Schema.String),
|
||||
icon: Schema.optional(ProjectIcon),
|
||||
commands: Schema.optional(ProjectCommands),
|
||||
time: ProjectTime,
|
||||
sandboxes: Schema.Array(Schema.String),
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Auth } from "@/auth"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { namedSchemaError } from "@/util/named-schema-error"
|
||||
import { optionalOmitUndefined, withStatics } from "@/util/schema"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { Plugin } from "../plugin"
|
||||
import { ProviderID } from "./schema"
|
||||
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
|
||||
@@ -18,14 +18,14 @@ const TextPrompt = Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
placeholder: optionalOmitUndefined(Schema.String),
|
||||
when: optionalOmitUndefined(When),
|
||||
placeholder: Schema.optional(Schema.String),
|
||||
when: Schema.optional(When),
|
||||
})
|
||||
|
||||
const SelectOption = Schema.Struct({
|
||||
label: Schema.String,
|
||||
value: Schema.String,
|
||||
hint: optionalOmitUndefined(Schema.String),
|
||||
hint: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const SelectPrompt = Schema.Struct({
|
||||
@@ -33,7 +33,7 @@ const SelectPrompt = Schema.Struct({
|
||||
key: Schema.String,
|
||||
message: Schema.String,
|
||||
options: Schema.Array(SelectOption),
|
||||
when: optionalOmitUndefined(When),
|
||||
when: Schema.optional(When),
|
||||
})
|
||||
|
||||
const Prompt = Schema.Union([TextPrompt, SelectPrompt])
|
||||
@@ -41,7 +41,7 @@ const Prompt = Schema.Union([TextPrompt, SelectPrompt])
|
||||
export class Method extends Schema.Class<Method>("ProviderAuthMethod")({
|
||||
type: Schema.Literals(["oauth", "api"]),
|
||||
label: Schema.String,
|
||||
prompts: optionalOmitUndefined(Schema.Array(Prompt)),
|
||||
prompts: Schema.optional(Schema.Array(Prompt)),
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
@@ -135,25 +135,23 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
|
||||
item.methods.map((method) => ({
|
||||
type: method.type,
|
||||
label: method.label,
|
||||
...(method.prompts && {
|
||||
prompts: method.prompts.map((prompt) => {
|
||||
if (prompt.type === "select") {
|
||||
return {
|
||||
type: "select" as const,
|
||||
key: prompt.key,
|
||||
message: prompt.message,
|
||||
options: prompt.options,
|
||||
...(prompt.when && { when: prompt.when }),
|
||||
}
|
||||
}
|
||||
prompts: method.prompts?.map((prompt) => {
|
||||
if (prompt.type === "select") {
|
||||
return {
|
||||
type: "text" as const,
|
||||
type: "select" as const,
|
||||
key: prompt.key,
|
||||
message: prompt.message,
|
||||
...(prompt.placeholder && { placeholder: prompt.placeholder }),
|
||||
...(prompt.when && { when: prompt.when }),
|
||||
options: prompt.options,
|
||||
when: prompt.when,
|
||||
}
|
||||
}),
|
||||
}
|
||||
return {
|
||||
type: "text" as const,
|
||||
key: prompt.key,
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder,
|
||||
when: prompt.when,
|
||||
}
|
||||
}),
|
||||
})),
|
||||
),
|
||||
|
||||
@@ -24,7 +24,7 @@ import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { optionalOmitUndefined, withStatics } from "@/util/schema"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
import * as ProviderTransform from "./transform"
|
||||
import { ModelID, ProviderID } from "./schema"
|
||||
@@ -875,7 +875,7 @@ const ProviderCost = Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache: ProviderCacheCost,
|
||||
experimentalOver200K: optionalOmitUndefined(
|
||||
experimentalOver200K: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
@@ -886,7 +886,7 @@ const ProviderCost = Schema.Struct({
|
||||
|
||||
const ProviderLimit = Schema.Struct({
|
||||
context: Schema.Finite,
|
||||
input: optionalOmitUndefined(Schema.Finite),
|
||||
input: Schema.optional(Schema.Finite),
|
||||
output: Schema.Finite,
|
||||
})
|
||||
|
||||
@@ -895,7 +895,7 @@ export const Model = Schema.Struct({
|
||||
providerID: ProviderID,
|
||||
api: ProviderApiInfo,
|
||||
name: Schema.String,
|
||||
family: optionalOmitUndefined(Schema.String),
|
||||
family: Schema.optional(Schema.String),
|
||||
capabilities: ProviderCapabilities,
|
||||
cost: ProviderCost,
|
||||
limit: ProviderLimit,
|
||||
@@ -903,7 +903,7 @@ export const Model = Schema.Struct({
|
||||
options: Schema.Record(Schema.String, Schema.Any),
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
release_date: Schema.String,
|
||||
variants: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
|
||||
variants: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any))),
|
||||
})
|
||||
.annotate({ identifier: "Model" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
@@ -914,7 +914,7 @@ export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
source: Schema.Literals(["env", "config", "custom", "api"]),
|
||||
env: Schema.Array(Schema.String),
|
||||
key: optionalOmitUndefined(Schema.String),
|
||||
key: Schema.optional(Schema.String),
|
||||
options: Schema.Record(Schema.String, Schema.Any),
|
||||
models: Schema.Record(Schema.String, Model),
|
||||
})
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/
|
||||
|
||||
export type CorsOptions = { readonly cors?: ReadonlyArray<string> }
|
||||
|
||||
export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) {
|
||||
export function isAllowedCorsOrigin(input: string | undefined, opts?: { cors?: string[] }) {
|
||||
if (!input) return true
|
||||
if (input.startsWith("http://localhost:")) return true
|
||||
if (input.startsWith("http://127.0.0.1:")) return true
|
||||
|
||||
@@ -11,7 +11,7 @@ import { basicAuth } from "hono/basic-auth"
|
||||
import { cors } from "hono/cors"
|
||||
import { compress } from "hono/compress"
|
||||
import * as ServerBackend from "./backend"
|
||||
import { isAllowedCorsOrigin, type CorsOptions } from "./cors"
|
||||
import { isAllowedCorsOrigin } from "./cors"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -67,7 +67,7 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M
|
||||
}
|
||||
}
|
||||
|
||||
export function CorsMiddleware(opts?: CorsOptions): MiddlewareHandler {
|
||||
export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
|
||||
return cors({
|
||||
maxAge: 86_400,
|
||||
origin(input) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { Command } from "@/command"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
@@ -18,7 +19,7 @@ import { Todo } from "@/session/todo"
|
||||
import { MessageID, PartID, SessionID } from "@/session/schema"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Cause, Effect, Schema, Scope } from "effect"
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
@@ -60,18 +61,22 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
const todoSvc = yield* Todo.Service
|
||||
const summary = yield* SessionSummary.Service
|
||||
const bus = yield* Bus.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
|
||||
return yield* session.list({
|
||||
directory: ctx.query.scope === "project" ? undefined : ctx.query.directory,
|
||||
scope: ctx.query.scope,
|
||||
path: ctx.query.path,
|
||||
roots: ctx.query.roots,
|
||||
start: ctx.query.start,
|
||||
search: ctx.query.search,
|
||||
limit: ctx.query.limit,
|
||||
})
|
||||
const instance = yield* InstanceState.context
|
||||
return Instance.restore(instance, () =>
|
||||
Array.from(
|
||||
Session.list({
|
||||
directory: ctx.query.scope === "project" ? undefined : ctx.query.directory,
|
||||
scope: ctx.query.scope,
|
||||
path: ctx.query.path,
|
||||
roots: ctx.query.roots,
|
||||
start: ctx.query.start,
|
||||
search: ctx.query.search,
|
||||
limit: ctx.query.limit,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const status = Effect.fn("SessionHttpApi.status")(function* () {
|
||||
@@ -254,16 +259,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof PromptPayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
const workspace = yield* InstanceState.workspaceID
|
||||
const bridge = yield* EffectBridge.make()
|
||||
return HttpServerResponse.stream(
|
||||
Stream.fromEffect(
|
||||
promptSvc
|
||||
.prompt({
|
||||
bridge.run(
|
||||
promptSvc.prompt({
|
||||
...ctx.payload,
|
||||
sessionID: ctx.params.sessionID,
|
||||
})
|
||||
.pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)),
|
||||
}),
|
||||
),
|
||||
).pipe(
|
||||
Stream.map((message) => JSON.stringify(message)),
|
||||
Stream.encodeText,
|
||||
@@ -276,18 +280,22 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof PromptPayload.Type
|
||||
}) {
|
||||
yield* promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause })
|
||||
yield* bus.publish(Session.Event.Error, {
|
||||
sessionID: ctx.params.sessionID,
|
||||
error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
const bridge = yield* EffectBridge.make()
|
||||
yield* Effect.sync(() => {
|
||||
bridge.fork(
|
||||
promptSvc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID }).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.logError("prompt_async failed", { sessionID: ctx.params.sessionID, cause })
|
||||
yield* bus.publish(Session.Event.Error, {
|
||||
sessionID: ctx.params.sessionID,
|
||||
error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
return HttpApiSchema.NoContent.make()
|
||||
})
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ import { lazy } from "@/util/lazy"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
||||
import { isAllowedCorsOrigin } from "@/server/cors"
|
||||
import { serveUIEffect } from "@/server/routes/ui"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
|
||||
@@ -77,14 +77,13 @@ const runtime = HttpRouter.middleware()(
|
||||
),
|
||||
).layer
|
||||
|
||||
const cors = (corsOptions?: CorsOptions) =>
|
||||
HttpRouter.middleware(
|
||||
HttpMiddleware.cors({
|
||||
allowedOrigins: (origin) => isAllowedCorsOrigin(origin, corsOptions),
|
||||
maxAge: 86_400,
|
||||
}),
|
||||
{ global: true },
|
||||
)
|
||||
const cors = HttpRouter.middleware(
|
||||
HttpMiddleware.cors({
|
||||
allowedOrigins: isAllowedCorsOrigin,
|
||||
maxAge: 86_400,
|
||||
}),
|
||||
{ global: true },
|
||||
)
|
||||
|
||||
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers]))
|
||||
const instanceRouterLayer = authorizationRouterMiddleware
|
||||
@@ -131,68 +130,55 @@ const uiRoute = HttpRouter.use((router) =>
|
||||
}),
|
||||
).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))))
|
||||
|
||||
export function createRoutes(corsOptions?: CorsOptions) {
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
|
||||
Layer.provide([
|
||||
cors(corsOptions),
|
||||
runtime,
|
||||
Account.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
Command.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
File.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
Installation.defaultLayer,
|
||||
MCP.defaultLayer,
|
||||
Permission.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
ProviderAuth.defaultLayer,
|
||||
Provider.defaultLayer,
|
||||
Pty.defaultLayer,
|
||||
Question.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
SessionCompaction.defaultLayer,
|
||||
SessionPrompt.defaultLayer,
|
||||
SessionRevert.defaultLayer,
|
||||
SessionShare.defaultLayer,
|
||||
SessionRunState.defaultLayer,
|
||||
SessionStatus.defaultLayer,
|
||||
SessionSummary.defaultLayer,
|
||||
SyncEvent.defaultLayer,
|
||||
Skill.defaultLayer,
|
||||
Todo.defaultLayer,
|
||||
ToolRegistry.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
Worktree.defaultLayer,
|
||||
Bus.layer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FetchHttpClient.layer,
|
||||
HttpServer.layerServices,
|
||||
]),
|
||||
Layer.provideMerge(Observability.layer),
|
||||
)
|
||||
}
|
||||
export const routes = Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
|
||||
Layer.provide([
|
||||
cors,
|
||||
runtime,
|
||||
Account.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
Command.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
File.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
Installation.defaultLayer,
|
||||
MCP.defaultLayer,
|
||||
Permission.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
ProviderAuth.defaultLayer,
|
||||
Provider.defaultLayer,
|
||||
Pty.defaultLayer,
|
||||
Question.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
SessionCompaction.defaultLayer,
|
||||
SessionPrompt.defaultLayer,
|
||||
SessionRevert.defaultLayer,
|
||||
SessionShare.defaultLayer,
|
||||
SessionRunState.defaultLayer,
|
||||
SessionStatus.defaultLayer,
|
||||
SessionSummary.defaultLayer,
|
||||
SyncEvent.defaultLayer,
|
||||
Skill.defaultLayer,
|
||||
Todo.defaultLayer,
|
||||
ToolRegistry.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
Worktree.defaultLayer,
|
||||
Bus.layer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FetchHttpClient.layer,
|
||||
HttpServer.layerServices,
|
||||
]),
|
||||
Layer.provideMerge(Observability.layer),
|
||||
)
|
||||
|
||||
export const routes = createRoutes()
|
||||
|
||||
const defaultWebHandler = lazy(() =>
|
||||
export const webHandler = lazy(() =>
|
||||
HttpRouter.toWebHandler(routes, {
|
||||
memoMap,
|
||||
middleware: disposeMiddleware,
|
||||
}),
|
||||
)
|
||||
|
||||
export function webHandler(corsOptions?: CorsOptions) {
|
||||
if (!corsOptions?.cors?.length) return defaultWebHandler()
|
||||
return HttpRouter.toWebHandler(createRoutes(corsOptions), {
|
||||
// Server-level CORS options are dynamic; don't reuse the default route layer memoized without them.
|
||||
memoMap: Layer.makeMemoMapUnsafe(),
|
||||
middleware: disposeMiddleware,
|
||||
})
|
||||
}
|
||||
|
||||
export * as ExperimentalHttpApiServer from "./server"
|
||||
|
||||
@@ -78,22 +78,18 @@ export const SessionRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
return c.json(
|
||||
await runRequest(
|
||||
"SessionRoutes.list",
|
||||
c,
|
||||
Session.Service.use((svc) =>
|
||||
svc.list({
|
||||
directory: query.scope === "project" ? undefined : query.directory,
|
||||
path: query.path,
|
||||
roots: queryBoolean(query.roots),
|
||||
start: query.start,
|
||||
search: query.search,
|
||||
limit: query.limit,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const sessions: Session.Info[] = []
|
||||
for await (const session of Session.list({
|
||||
directory: query.scope === "project" ? undefined : query.directory,
|
||||
path: query.path,
|
||||
roots: queryBoolean(query.roots),
|
||||
start: query.start,
|
||||
search: query.search,
|
||||
limit: query.limit,
|
||||
})) {
|
||||
sessions.push(session)
|
||||
}
|
||||
return c.json(sessions)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
|
||||
@@ -18,7 +18,6 @@ import { InstanceMiddleware } from "./routes/instance/middleware"
|
||||
import { WorkspaceRoutes } from "./routes/control/workspace"
|
||||
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
|
||||
import * as ServerBackend from "./backend"
|
||||
import type { CorsOptions } from "./cors"
|
||||
|
||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -39,13 +38,6 @@ type ServerApp = {
|
||||
request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
|
||||
}
|
||||
|
||||
type ListenOptions = CorsOptions & {
|
||||
port: number
|
||||
hostname: string
|
||||
mdns?: boolean
|
||||
mdnsDomain?: string
|
||||
}
|
||||
|
||||
const DefaultHono = lazy(() =>
|
||||
withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })),
|
||||
)
|
||||
@@ -62,14 +54,14 @@ export const Default = () => {
|
||||
return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono()
|
||||
}
|
||||
|
||||
function create(opts: ListenOptions) {
|
||||
function create(opts: { cors?: string[] }) {
|
||||
const selected = select()
|
||||
return selected.backend === "effect-httpapi"
|
||||
? withBackend(selected, createHttpApi(opts))
|
||||
? withBackend(selected, createHttpApi())
|
||||
: withBackend(selected, createHono(opts, selected))
|
||||
}
|
||||
|
||||
export function Legacy(opts: CorsOptions = {}) {
|
||||
export function Legacy(opts: { cors?: string[] } = {}) {
|
||||
return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" }))
|
||||
}
|
||||
|
||||
@@ -82,8 +74,8 @@ function withBackend<T extends { app: ServerApp; runtime: unknown }>(selection:
|
||||
return built
|
||||
}
|
||||
|
||||
function createHttpApi(corsOptions?: CorsOptions) {
|
||||
const handler = ExperimentalHttpApiServer.webHandler(corsOptions).handler
|
||||
function createHttpApi() {
|
||||
const handler = ExperimentalHttpApiServer.webHandler().handler
|
||||
const app: ServerApp = {
|
||||
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
|
||||
request(input, init) {
|
||||
@@ -96,7 +88,10 @@ function createHttpApi(corsOptions?: CorsOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function createHono(opts: CorsOptions, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono")) {
|
||||
function createHono(
|
||||
opts: { cors?: string[] },
|
||||
selection: ServerBackend.Selection = ServerBackend.force(select(), "hono"),
|
||||
) {
|
||||
const backendAttributes = ServerBackend.attributes(selection)
|
||||
const app = new Hono()
|
||||
.onError(ErrorMiddleware)
|
||||
@@ -156,7 +151,13 @@ export async function openapi() {
|
||||
|
||||
export let url: URL
|
||||
|
||||
export async function listen(opts: ListenOptions): Promise<Listener> {
|
||||
export async function listen(opts: {
|
||||
port: number
|
||||
hostname: string
|
||||
mdns?: boolean
|
||||
mdnsDomain?: string
|
||||
cors?: string[]
|
||||
}): Promise<Listener> {
|
||||
const built = create(opts)
|
||||
const server = await built.runtime.listen(opts)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { SessionID } from "@/session/schema"
|
||||
import { Auth } from "@/auth"
|
||||
import { Installation } from "@/installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import * as Option from "effect/Option"
|
||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
|
||||
@@ -266,8 +266,7 @@ const live: Layer.Layer<
|
||||
return !match || match.action !== "ask"
|
||||
})
|
||||
|
||||
const instance = yield* InstanceState.context
|
||||
const workspace = yield* InstanceState.workspaceID
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const approvedToolsForSession = new Set<string>()
|
||||
workflowModel.approvalHandler = InstanceState.bind(async (approvalTools) => {
|
||||
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
|
||||
@@ -293,21 +292,16 @@ const live: Layer.Layer<
|
||||
}
|
||||
})
|
||||
const uniquePatterns = [...new Set(toolPatterns)] as string[]
|
||||
await Effect.runPromise(
|
||||
perm
|
||||
.ask({
|
||||
id,
|
||||
sessionID: SessionID.make(input.sessionID),
|
||||
permission: "workflow_tool_approval",
|
||||
patterns: uniquePatterns,
|
||||
metadata: { tools: approvalTools },
|
||||
always: uniquePatterns,
|
||||
ruleset: [],
|
||||
})
|
||||
.pipe(
|
||||
Effect.provideService(InstanceRef, instance),
|
||||
Effect.provideService(WorkspaceRef, workspace),
|
||||
),
|
||||
await bridge.promise(
|
||||
perm.ask({
|
||||
id,
|
||||
sessionID: SessionID.make(input.sessionID),
|
||||
permission: "workflow_tool_approval",
|
||||
patterns: uniquePatterns,
|
||||
metadata: { tools: approvalTools },
|
||||
always: uniquePatterns,
|
||||
ruleset: [],
|
||||
}),
|
||||
)
|
||||
for (const name of uniqueNames) approvedToolsForSession.add(name)
|
||||
workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
|
||||
|
||||
@@ -1443,7 +1443,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
|
||||
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
|
||||
sys.skills(agent),
|
||||
sys.environment(model),
|
||||
Effect.sync(() => sys.environment(model)),
|
||||
instruction.system().pipe(Effect.orDie),
|
||||
MessageV2.toModelMessagesEffect(msgs, model),
|
||||
])
|
||||
|
||||
@@ -26,7 +26,7 @@ import { ProjectTable } from "../project/project.sql"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import type { InstanceContext } from "../project/instance"
|
||||
import { Instance, type InstanceContext } from "../project/instance"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { ProjectID } from "../project/schema"
|
||||
@@ -234,16 +234,6 @@ export const MessagesInput = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
limit: Schema.optional(NonNegativeInt),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type ListInput = {
|
||||
directory?: string
|
||||
scope?: "project"
|
||||
path?: string
|
||||
workspaceID?: WorkspaceID
|
||||
roots?: boolean
|
||||
start?: number
|
||||
search?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
const CreatedEventSchema = Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
@@ -400,7 +390,6 @@ export class BusyError extends Error {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
||||
readonly create: (input?: {
|
||||
parentID?: SessionID
|
||||
title?: string
|
||||
@@ -509,11 +498,6 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
||||
return fromRow(row)
|
||||
})
|
||||
|
||||
const list = Effect.fn("Session.list")(function* (input?: ListInput) {
|
||||
const ctx = yield* InstanceState.context
|
||||
return Array.from(listByProject({ projectID: ctx.project.id, ...(input ?? {}) }))
|
||||
})
|
||||
|
||||
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
|
||||
const rows = yield* db((d) =>
|
||||
d
|
||||
@@ -747,7 +731,6 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
list,
|
||||
create,
|
||||
fork,
|
||||
touch,
|
||||
@@ -779,17 +762,23 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(SyncEvent.defaultLayer),
|
||||
)
|
||||
|
||||
function* listByProject(
|
||||
input: ListInput & {
|
||||
projectID: ProjectID
|
||||
},
|
||||
) {
|
||||
const conditions = [eq(SessionTable.project_id, input.projectID)]
|
||||
export function* list(input?: {
|
||||
directory?: string
|
||||
scope?: "project"
|
||||
path?: string
|
||||
workspaceID?: WorkspaceID
|
||||
roots?: boolean
|
||||
start?: number
|
||||
search?: string
|
||||
limit?: number
|
||||
}) {
|
||||
const project = Instance.project
|
||||
const conditions = [eq(SessionTable.project_id, project.id)]
|
||||
|
||||
if (input.workspaceID) {
|
||||
if (input?.workspaceID) {
|
||||
conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
}
|
||||
if (input.path !== undefined) {
|
||||
if (input?.path !== undefined) {
|
||||
if (input.path) {
|
||||
const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)]
|
||||
|
||||
@@ -799,22 +788,22 @@ function* listByProject(
|
||||
: or(...conds)!,
|
||||
)
|
||||
}
|
||||
} else if (input.scope !== "project" && !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
if (input.directory) {
|
||||
} else if (input?.scope !== "project" && !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
if (input?.directory) {
|
||||
conditions.push(eq(SessionTable.directory, input.directory))
|
||||
}
|
||||
}
|
||||
if (input.roots) {
|
||||
if (input?.roots) {
|
||||
conditions.push(isNull(SessionTable.parent_id))
|
||||
}
|
||||
if (input.start) {
|
||||
if (input?.start) {
|
||||
conditions.push(gte(SessionTable.time_updated, input.start))
|
||||
}
|
||||
if (input.search) {
|
||||
if (input?.search) {
|
||||
conditions.push(like(SessionTable.title, `%${input.search}%`))
|
||||
}
|
||||
|
||||
const limit = input.limit ?? 100
|
||||
const limit = input?.limit ?? 100
|
||||
|
||||
const rows = Database.use((db) =>
|
||||
db
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
|
||||
import PROMPT_DEFAULT from "./prompt/default.txt"
|
||||
@@ -33,7 +33,7 @@ export function provider(model: Provider.Model) {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly environment: (model: Provider.Model) => Effect.Effect<string[]>
|
||||
readonly environment: (model: Provider.Model) => string[]
|
||||
readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
@@ -45,22 +45,22 @@ export const layer = Layer.effect(
|
||||
const skill = yield* Skill.Service
|
||||
|
||||
return Service.of({
|
||||
environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) {
|
||||
const ctx = yield* InstanceState.context
|
||||
environment(model) {
|
||||
const project = Instance.project
|
||||
return [
|
||||
[
|
||||
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
|
||||
`Here is some useful information about the environment you are running in:`,
|
||||
`<env>`,
|
||||
` Working directory: ${ctx.directory}`,
|
||||
` Workspace root folder: ${ctx.worktree}`,
|
||||
` Is directory a git repo: ${ctx.project.vcs === "git" ? "yes" : "no"}`,
|
||||
` Working directory: ${Instance.directory}`,
|
||||
` Workspace root folder: ${Instance.worktree}`,
|
||||
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
|
||||
` Platform: ${process.platform}`,
|
||||
` Today's date: ${new Date().toDateString()}`,
|
||||
`</env>`,
|
||||
].join("\n"),
|
||||
]
|
||||
}),
|
||||
},
|
||||
|
||||
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
|
||||
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
|
||||
|
||||
@@ -4,9 +4,9 @@ import { eq } from "drizzle-orm"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Bus as ProjectBus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { EventSequenceTable, EventTable } from "./event.sql"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { EventID } from "./schema"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
|
||||
@@ -14,7 +14,6 @@ import { zodObject } from "@/util/effect-zod"
|
||||
import type { DeepMutable } from "@/util/schema"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { serviceUse } from "@/effect/service-use"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
// Keep `Event["data"]` mutable because projectors mutate the persisted shape
|
||||
// when writing to the database. Bus payloads (`Properties`) stay readonly —
|
||||
@@ -48,10 +47,6 @@ export type SerializedEvent<Def extends Definition = Definition> = Event<Def> &
|
||||
|
||||
type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void
|
||||
type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown>
|
||||
type PublishContext = {
|
||||
instance?: InstanceContext
|
||||
workspace?: WorkspaceID
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly run: <Def extends Definition>(
|
||||
@@ -92,14 +87,7 @@ export const layer = Layer.effect(Service)(
|
||||
)
|
||||
}
|
||||
|
||||
const publish = !!options?.publish
|
||||
const context = publish
|
||||
? {
|
||||
instance: yield* InstanceState.context,
|
||||
workspace: yield* InstanceState.workspaceID,
|
||||
}
|
||||
: undefined
|
||||
process(def, event, { publish, context })
|
||||
process(def, event, { publish: !!options?.publish })
|
||||
})
|
||||
|
||||
const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) {
|
||||
@@ -134,12 +122,6 @@ export const layer = Layer.effect(Service)(
|
||||
}
|
||||
|
||||
const { publish = true } = options || {}
|
||||
const context = publish
|
||||
? {
|
||||
instance: yield* InstanceState.context,
|
||||
workspace: yield* InstanceState.workspaceID,
|
||||
}
|
||||
: undefined
|
||||
|
||||
// Note that this is an "immediate" transaction which is critical.
|
||||
// We need to make sure we can safely read and write with nothing
|
||||
@@ -155,7 +137,7 @@ export const layer = Layer.effect(Service)(
|
||||
const seq = row?.seq != null ? row.seq + 1 : 0
|
||||
|
||||
const event = { id, seq, aggregateID: agg, data }
|
||||
process(def, event, { publish, context })
|
||||
process(def, event, { publish })
|
||||
},
|
||||
{
|
||||
behavior: "immediate",
|
||||
@@ -260,11 +242,7 @@ export function project<Def extends Definition>(
|
||||
return [def, func as ProjectorFunc]
|
||||
}
|
||||
|
||||
function process<Def extends Definition>(
|
||||
def: Def,
|
||||
event: Event<Def>,
|
||||
options: { publish: boolean; context?: PublishContext },
|
||||
) {
|
||||
function process<Def extends Definition>(def: Def, event: Event<Def>, options: { publish: boolean }) {
|
||||
if (projectors == null) {
|
||||
throw new Error("No projectors available. Call `SyncEvent.init` to install projectors")
|
||||
}
|
||||
@@ -303,10 +281,6 @@ function process<Def extends Definition>(
|
||||
|
||||
Database.effect(() => {
|
||||
if (options?.publish) {
|
||||
if (!options.context?.instance) {
|
||||
throw new Error("SyncEvent.process: publish requires instance context")
|
||||
}
|
||||
|
||||
const result = convertEvent(def.type, event.data)
|
||||
const publish = (data: unknown) => ProjectBus.publish(def, data as Properties<Def>)
|
||||
if (result instanceof Promise) {
|
||||
@@ -316,9 +290,9 @@ function process<Def extends Definition>(
|
||||
}
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory: options.context.instance.directory,
|
||||
project: options.context.instance.project.id,
|
||||
workspace: options.context.workspace,
|
||||
directory: Instance.directory,
|
||||
project: Instance.project.id,
|
||||
workspace: WorkspaceContext.workspaceID,
|
||||
payload: {
|
||||
type: "sync",
|
||||
syncEvent: {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { mkdir, symlink } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { expect, spyOn, test } from "bun:test"
|
||||
import { offsetToPosition, resolveZedDbPath, resolveZedSelection } from "../../../src/cli/cmd/tui/context/editor-zed"
|
||||
import { expect, test } from "bun:test"
|
||||
import { offsetToPosition, resolveZedSelection } from "../../../src/cli/cmd/tui/context/editor-zed"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
|
||||
type ZedFixtureOptions = {
|
||||
@@ -68,23 +66,6 @@ test("offsetToPosition converts Zed offsets to 1-based editor positions", () =>
|
||||
})
|
||||
})
|
||||
|
||||
test("resolveZedDbPath skips candidates that cannot be stated", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const loop = path.join(tmp.path, "loop")
|
||||
await symlink(loop, loop)
|
||||
const home = spyOn(os, "homedir").mockImplementation(() => tmp.path)
|
||||
const previous = process.env.OPENCODE_ZED_DB
|
||||
process.env.OPENCODE_ZED_DB = loop
|
||||
|
||||
try {
|
||||
expect(resolveZedDbPath()).toBeUndefined()
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.OPENCODE_ZED_DB
|
||||
else process.env.OPENCODE_ZED_DB = previous
|
||||
home.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("resolveZedSelection returns active editor selection", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await writeZedFixture(tmp.path)
|
||||
@@ -270,71 +251,6 @@ test("resolveZedSelection returns empty when no workspace matches", async () =>
|
||||
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" })
|
||||
})
|
||||
|
||||
test("resolveZedSelection matches a Zed workspace that contains the session directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await writeZedFixture(tmp.path)
|
||||
|
||||
expect(await resolveZedSelection(fixture.dbPath, path.join(tmp.path, "packages", "app"))).toEqual({
|
||||
type: "selection",
|
||||
selection: {
|
||||
filePath: fixture.filePath,
|
||||
source: "zed",
|
||||
ranges: [
|
||||
{
|
||||
text: "two",
|
||||
selection: {
|
||||
start: { line: 2, character: 1 },
|
||||
end: { line: 2, character: 4 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("resolveZedSelection prefers the most specific containing Zed workspace", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await writeZedFixture(tmp.path)
|
||||
const child = path.join(tmp.path, "packages")
|
||||
const childFile = path.join(child, "child.ts")
|
||||
await mkdir(child, { recursive: true })
|
||||
await Bun.write(childFile, "child")
|
||||
|
||||
const db = new Database(fixture.dbPath)
|
||||
db.run("insert into workspaces values (2, ?, ?)", [JSON.stringify([child]), "2026-01-01"])
|
||||
db.run("insert into panes values (2, 2, 1)")
|
||||
db.run("insert into items values (2, 2, 2, 1, ?)", ["Editor"])
|
||||
db.run("insert into editors values (2, 2, ?, ?)", [childFile, "child"])
|
||||
db.run("insert into editor_selections values (2, 2, 0, 5)")
|
||||
db.close()
|
||||
|
||||
expect(await resolveZedSelection(fixture.dbPath, path.join(child, "app"))).toEqual({
|
||||
type: "selection",
|
||||
selection: {
|
||||
filePath: childFile,
|
||||
source: "zed",
|
||||
ranges: [
|
||||
{
|
||||
text: "child",
|
||||
selection: {
|
||||
start: { line: 1, character: 1 },
|
||||
end: { line: 1, character: 6 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("resolveZedSelection ignores a Zed workspace nested inside the session directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const child = path.join(tmp.path, "effect-lab")
|
||||
await mkdir(child, { recursive: true })
|
||||
const fixture = await writeZedFixture(child)
|
||||
|
||||
expect(await resolveZedSelection(fixture.dbPath, tmp.path)).toEqual({ type: "empty" })
|
||||
})
|
||||
|
||||
test("resolveZedSelection returns unavailable when a Zed terminal is active", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await writeZedFixture(tmp.path, { itemKind: "Terminal", editor: false })
|
||||
|
||||
@@ -4,7 +4,6 @@ import { describe, expect } from "bun:test"
|
||||
import { Config, Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
@@ -62,28 +61,4 @@ describe("HttpApi CORS", () => {
|
||||
expect(response.headers["access-control-allow-headers"]).toBe("authorization")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("uses custom CORS origins passed to the server", () =>
|
||||
Effect.gen(function* () {
|
||||
const listener = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => Server.listen({ hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] })),
|
||||
(listener) => Effect.promise(() => listener.stop(true)),
|
||||
)
|
||||
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL(InstancePaths.path, listener.url), {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin: "https://custom.example",
|
||||
"access-control-request-method": "GET",
|
||||
"access-control-request-headers": "authorization",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe("https://custom.example")
|
||||
expect(response.headers.get("access-control-allow-headers")).toBe("authorization")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,11 +5,6 @@ import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
|
||||
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { Session } from "@/session/session"
|
||||
@@ -94,89 +89,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("HttpApi JSON parity", () => {
|
||||
it.live(
|
||||
"matches legacy JSON shape for safe GET endpoints",
|
||||
withTmp(
|
||||
{
|
||||
git: true,
|
||||
config: {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
mcp: {
|
||||
demo: {
|
||||
type: "local",
|
||||
command: ["echo", "demo"],
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n"))
|
||||
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const legacy = app(false)
|
||||
const httpapi = app(true)
|
||||
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
{ label: "global.health", path: GlobalPaths.health, headers: {} },
|
||||
{ label: "global.config", path: GlobalPaths.config, headers: {} },
|
||||
{ label: "instance.path", path: InstancePaths.path, headers },
|
||||
{ label: "instance.vcs", path: InstancePaths.vcs, headers },
|
||||
{ label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers },
|
||||
{ label: "instance.command", path: InstancePaths.command, headers },
|
||||
{ label: "instance.agent", path: InstancePaths.agent, headers },
|
||||
{ label: "instance.skill", path: InstancePaths.skill, headers },
|
||||
{ label: "instance.lsp", path: InstancePaths.lsp, headers },
|
||||
{ label: "instance.formatter", path: InstancePaths.formatter, headers },
|
||||
{ label: "config.get", path: "/config", headers },
|
||||
{ label: "config.providers", path: "/config/providers", headers },
|
||||
{ label: "project.list", path: "/project", headers },
|
||||
{ label: "project.current", path: "/project/current", headers },
|
||||
{ label: "provider.list", path: "/provider", headers },
|
||||
{ label: "provider.auth", path: "/provider/auth", headers },
|
||||
{ label: "permission.list", path: "/permission", headers },
|
||||
{ label: "question.list", path: "/question", headers },
|
||||
{ label: "mcp.status", path: McpPaths.status, headers },
|
||||
{ label: "pty.shells", path: PtyPaths.shells, headers },
|
||||
{ label: "pty.list", path: PtyPaths.list, headers },
|
||||
{ label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers },
|
||||
{
|
||||
label: "file.content",
|
||||
path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`,
|
||||
headers,
|
||||
},
|
||||
{ label: "file.status", path: FilePaths.status, headers },
|
||||
{
|
||||
label: "find.file",
|
||||
path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`,
|
||||
headers,
|
||||
},
|
||||
{
|
||||
label: "find.text",
|
||||
path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`,
|
||||
headers,
|
||||
},
|
||||
{
|
||||
label: "find.symbol",
|
||||
path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`,
|
||||
headers,
|
||||
},
|
||||
{ label: "experimental.console", path: ExperimentalPaths.console, headers },
|
||||
{ label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers },
|
||||
{ label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers },
|
||||
{ label: "experimental.worktree", path: ExperimentalPaths.worktree, headers },
|
||||
{ label: "experimental.resource", path: ExperimentalPaths.resource, headers },
|
||||
],
|
||||
(input) => expectJsonParity({ ...input, legacy, httpapi }),
|
||||
{ concurrency: 1 },
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"matches legacy JSON shape for session read endpoints",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
|
||||
@@ -23,9 +23,6 @@ const svc = {
|
||||
create(input?: SessionNs.CreateInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
},
|
||||
list(input?: SessionNs.ListInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.list(input)))
|
||||
},
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -58,7 +55,7 @@ describe("session.list", () => {
|
||||
fn: async () => svc.create({ title: "sibling" }),
|
||||
})
|
||||
|
||||
const ids = (await svc.list()).map((s) => s.id)
|
||||
const ids = [...svc.list()].map((s) => s.id)
|
||||
expect(ids).toContain(root.id)
|
||||
expect(ids).toContain(parent.id)
|
||||
expect(ids).toContain(current.id)
|
||||
@@ -91,7 +88,7 @@ describe("session.list", () => {
|
||||
fn: async () => svc.create({ title: "sibling" }),
|
||||
})
|
||||
|
||||
const ids = (await svc.list({ directory: path.join(tmp.path, "packages", "opencode") })).map((s) => s.id)
|
||||
const ids = [...svc.list({ directory: path.join(tmp.path, "packages", "opencode") })].map((s) => s.id)
|
||||
expect(ids).not.toContain(root.id)
|
||||
expect(ids).not.toContain(parent.id)
|
||||
expect(ids).toContain(current.id)
|
||||
@@ -126,12 +123,9 @@ describe("session.list", () => {
|
||||
fn: async () => svc.create({ title: "sibling" }),
|
||||
})
|
||||
|
||||
const pathIDs = (
|
||||
await svc.list({
|
||||
directory: path.join(tmp.path, "packages", "app"),
|
||||
path: "packages/opencode/src",
|
||||
})
|
||||
).map((s) => s.id)
|
||||
const pathIDs = [
|
||||
...svc.list({ directory: path.join(tmp.path, "packages", "app"), path: "packages/opencode/src" }),
|
||||
].map((s) => s.id)
|
||||
expect(pathIDs).not.toContain(parent.id)
|
||||
expect(pathIDs).toContain(current.id)
|
||||
expect(pathIDs).toContain(deeper.id)
|
||||
@@ -161,12 +155,9 @@ describe("session.list", () => {
|
||||
Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run())
|
||||
Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run())
|
||||
|
||||
const pathIDs = (
|
||||
await svc.list({
|
||||
directory: path.join(tmp.path, "packages", "opencode", "src"),
|
||||
path: "packages/opencode/src",
|
||||
})
|
||||
).map((s) => s.id)
|
||||
const pathIDs = [
|
||||
...svc.list({ directory: path.join(tmp.path, "packages", "opencode", "src"), path: "packages/opencode/src" }),
|
||||
].map((s) => s.id)
|
||||
expect(pathIDs).toContain(current.id)
|
||||
expect(pathIDs).not.toContain(sibling.id)
|
||||
},
|
||||
@@ -181,7 +172,7 @@ describe("session.list", () => {
|
||||
const root = await svc.create({ title: "root-session" })
|
||||
const child = await svc.create({ title: "child-session", parentID: root.id })
|
||||
|
||||
const sessions = await svc.list({ roots: true })
|
||||
const sessions = [...svc.list({ roots: true })]
|
||||
const ids = sessions.map((s) => s.id)
|
||||
|
||||
expect(ids).toContain(root.id)
|
||||
@@ -198,7 +189,7 @@ describe("session.list", () => {
|
||||
await svc.create({ title: "new-session" })
|
||||
const futureStart = Date.now() + 86400000
|
||||
|
||||
const sessions = await svc.list({ start: futureStart })
|
||||
const sessions = [...svc.list({ start: futureStart })]
|
||||
expect(sessions.length).toBe(0)
|
||||
},
|
||||
})
|
||||
@@ -212,7 +203,7 @@ describe("session.list", () => {
|
||||
await svc.create({ title: "unique-search-term-abc" })
|
||||
await svc.create({ title: "other-session-xyz" })
|
||||
|
||||
const sessions = await svc.list({ search: "unique-search" })
|
||||
const sessions = [...svc.list({ search: "unique-search" })]
|
||||
const titles = sessions.map((s) => s.title)
|
||||
|
||||
expect(titles).toContain("unique-search-term-abc")
|
||||
@@ -230,7 +221,7 @@ describe("session.list", () => {
|
||||
await svc.create({ title: "session-2" })
|
||||
await svc.create({ title: "session-3" })
|
||||
|
||||
const sessions = await svc.list({ limit: 2 })
|
||||
const sessions = [...svc.list({ limit: 2 })]
|
||||
expect(sessions.length).toBe(2)
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user