chore: merge dev
This commit is contained in:
commit
c830f67ec5
860 changed files with 60540 additions and 23213 deletions
|
|
@ -0,0 +1,8 @@
|
|||
CREATE TABLE `project_directory` (
|
||||
`project_id` text NOT NULL,
|
||||
`directory` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`time_created` integer NOT NULL,
|
||||
CONSTRAINT `project_directory_pk` PRIMARY KEY(`project_id`, `directory`),
|
||||
CONSTRAINT `fk_project_directory_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
1664
packages/core/migration/20260602182828_add_project_directories/snapshot.json
generated
Normal file
1664
packages/core/migration/20260602182828_add_project_directories/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,5 @@
|
|||
DROP INDEX IF EXISTS `session_message_session_idx`;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `session_message_session_type_idx`;--> statement-breakpoint
|
||||
CREATE INDEX `event_aggregate_seq_idx` ON `event` (`aggregate_id`,`seq`);--> statement-breakpoint
|
||||
CREATE INDEX `session_message_session_time_created_id_idx` ON `session_message` (`session_id`,`time_created`,`id`);--> statement-breakpoint
|
||||
CREATE INDEX `session_message_session_type_time_created_id_idx` ON `session_message` (`session_id`,`type`,`time_created`,`id`);
|
||||
1636
packages/core/migration/20260603001617_session_message_projection_indexes/snapshot.json
generated
Normal file
1636
packages/core/migration/20260603001617_session_message_projection_indexes/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,6 @@
|
|||
DELETE FROM `session_message`;--> statement-breakpoint
|
||||
ALTER TABLE `session_message` ADD `seq` integer NOT NULL;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `session_message_session_time_created_id_idx`;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS `session_message_session_type_time_created_id_idx`;--> statement-breakpoint
|
||||
CREATE INDEX `session_message_session_seq_idx` ON `session_message` (`session_id`,`seq`);--> statement-breakpoint
|
||||
CREATE INDEX `session_message_session_type_seq_idx` ON `session_message` (`session_id`,`type`,`seq`);
|
||||
1638
packages/core/migration/20260603040000_session_message_projection_order/snapshot.json
generated
Normal file
1638
packages/core/migration/20260603040000_session_message_projection_order/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,12 @@
|
|||
CREATE TABLE `session_input` (
|
||||
`seq` integer PRIMARY KEY AUTOINCREMENT,
|
||||
`id` text NOT NULL UNIQUE,
|
||||
`session_id` text NOT NULL,
|
||||
`prompt` text NOT NULL,
|
||||
`delivery` text NOT NULL,
|
||||
`promoted_seq` integer,
|
||||
`time_created` integer NOT NULL,
|
||||
CONSTRAINT `fk_session_input_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `session_input_session_pending_seq_idx` ON `session_input` (`session_id`,`promoted_seq`,`seq`);
|
||||
1759
packages/core/migration/20260603141458_session_input_inbox/snapshot.json
generated
Normal file
1759
packages/core/migration/20260603141458_session_input_inbox/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,4 @@
|
|||
DROP INDEX IF EXISTS `session_input_session_pending_seq_idx`;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS `event_aggregate_type_seq_idx` ON `event` (`aggregate_id`,`type`,`seq`);--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS `session_input_session_pending_delivery_seq_idx` ON `session_input` (`session_id`,`promoted_seq`,`delivery`,`seq`);--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS `session_message_session_time_created_id_idx` ON `session_message` (`session_id`,`time_created`,`id`);
|
||||
1869
packages/core/migration/20260603160727_jittery_ezekiel_stane/snapshot.json
generated
Normal file
1869
packages/core/migration/20260603160727_jittery_ezekiel_stane/snapshot.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,7 @@
|
|||
"scripts": {
|
||||
"db": "bun drizzle-kit",
|
||||
"migration": "bun run script/migration.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"test": "bun test",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
|
|
@ -16,6 +17,7 @@
|
|||
"opencode": "./bin/opencode"
|
||||
},
|
||||
"exports": {
|
||||
"./session/runner": "./src/session/runner/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"imports": {
|
||||
|
|
@ -23,20 +25,37 @@
|
|||
"bun": "./src/database/sqlite.bun.ts",
|
||||
"node": "./src/database/sqlite.node.ts",
|
||||
"default": "./src/database/sqlite.bun.ts"
|
||||
},
|
||||
"#pty": {
|
||||
"bun": "./src/pty/pty.bun.ts",
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
"default": "./src/pty/pty.bun.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/cross-spawn": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "catalog:",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-musl": "2.5.1",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.1",
|
||||
"@parcel/watcher-linux-x64-musl": "2.5.1",
|
||||
"@parcel/watcher-win32-arm64": "2.5.1",
|
||||
"@parcel/watcher-win32-x64": "2.5.1",
|
||||
"@opencode-ai/http-recorder": "workspace:*",
|
||||
"drizzle-kit": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.107",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
|
|
@ -55,34 +74,43 @@
|
|||
"@ai-sdk/togetherai": "2.0.41",
|
||||
"@ai-sdk/vercel": "2.0.39",
|
||||
"@ai-sdk/xai": "3.0.82",
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.1",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@openrouter/ai-sdk-provider": "2.8.1",
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bun-pty": "0.4.8",
|
||||
"cross-spawn": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.8.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"htmlparser2": "8.0.2",
|
||||
"immer": "11.1.4",
|
||||
"ignore": "7.0.5",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"semver": "^7.6.3",
|
||||
"turndown": "7.2.0",
|
||||
"venice-ai-sdk-provider": "2.0.2",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
|
|
|||
28
packages/core/script/fix-node-pty.ts
Executable file
28
packages/core/script/fix-node-pty.ts
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const root = path.join(dir, "node_modules", "node-pty", "prebuilds")
|
||||
const dirs = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
|
||||
const files = dirs.filter((x) => x.isDirectory()).map((x) => path.join(root, x.name, "spawn-helper"))
|
||||
const result = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const stat = await fs.stat(file).catch(() => undefined)
|
||||
if (!stat) return
|
||||
if ((stat.mode & 0o111) === 0o111) return
|
||||
await fs.chmod(file, stat.mode | 0o755)
|
||||
return file
|
||||
}),
|
||||
)
|
||||
const fixed = result.filter(Boolean)
|
||||
if (fixed.length) {
|
||||
console.log(`fixed node-pty permissions for ${fixed.length} helper${fixed.length === 1 ? "" : "s"}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,18 +5,28 @@ import fs from "fs/promises"
|
|||
import os from "os"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { parseArgs } from "util"
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..")
|
||||
const sqlDir = path.join(root, "packages/core/migration")
|
||||
const tsDir = path.join(root, "packages/core/src/database/migration")
|
||||
const registry = path.join(root, "packages/core/src/database/migration.gen.ts")
|
||||
const args = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
check: { type: "boolean" },
|
||||
name: { type: "string" },
|
||||
},
|
||||
})
|
||||
|
||||
if (Bun.argv.includes("--check")) {
|
||||
if (args.values.check) {
|
||||
await check()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await $`bun drizzle-kit generate`.cwd(path.join(root, "packages/core"))
|
||||
await $`bun drizzle-kit generate ${args.values.name ? ["--name", args.values.name] : []}`.cwd(
|
||||
path.join(root, "packages/core"),
|
||||
)
|
||||
|
||||
const sqlMigrations = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: sqlDir })))
|
||||
.map((file) => file.split("/")[0])
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export const Color = Schema.Union([
|
|||
export class Info extends Schema.Class<Info>("AgentV2.Info")({
|
||||
id: ID,
|
||||
model: ModelV2.Ref.pipe(Schema.optional),
|
||||
options: ProviderV2.Options,
|
||||
request: ProviderV2.Request,
|
||||
system: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mode: Schema.Literals(["subagent", "primary", "all"]),
|
||||
|
|
@ -31,13 +31,9 @@ export class Info extends Schema.Class<Info>("AgentV2.Info")({
|
|||
static empty(id: ID) {
|
||||
return new Info({
|
||||
id,
|
||||
options: {
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: {},
|
||||
request: {},
|
||||
},
|
||||
},
|
||||
mode: "all",
|
||||
hidden: false,
|
||||
|
|
|
|||
|
|
@ -58,8 +58,12 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
|||
}
|
||||
|
||||
function prepareOptions(model: ModelV2.Info, pkg: string) {
|
||||
const options: Record<string, any> = { name: model.providerID, ...model.options.aisdk.provider }
|
||||
if (model.endpoint.type === "aisdk" && model.endpoint.url) options.baseURL = model.endpoint.url
|
||||
const options: Record<string, any> = {
|
||||
name: model.providerID,
|
||||
...(model.api.type === "aisdk" ? (model.api.settings ?? {}) : {}),
|
||||
...model.request.body,
|
||||
}
|
||||
if (model.api.type === "aisdk" && model.api.url) options.baseURL = model.api.url
|
||||
|
||||
const customFetch = options.fetch
|
||||
const chunkTimeout = options.chunkTimeout
|
||||
|
|
@ -78,7 +82,11 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
|
|||
if (abortSignals.length === 1) opts.signal = abortSignals[0]
|
||||
if (abortSignals.length > 1) opts.signal = AbortSignal.any(abortSignals)
|
||||
|
||||
if ((pkg === "@ai-sdk/openai" || pkg === "@ai-sdk/azure") && opts.body && opts.method === "POST") {
|
||||
if (
|
||||
(pkg === "@ai-sdk/openai" || pkg === "@ai-sdk/azure" || pkg === "@ai-sdk/amazon-bedrock/mantle") &&
|
||||
opts.body &&
|
||||
opts.method === "POST"
|
||||
) {
|
||||
const body = JSON.parse(opts.body as string)
|
||||
if (body.store !== true && Array.isArray(body.input)) {
|
||||
for (const item of body.input) {
|
||||
|
|
@ -123,25 +131,25 @@ export const layer = Layer.effect(
|
|||
|
||||
return Service.of({
|
||||
language: Effect.fn("AISDK.language")(function* (model) {
|
||||
const key = `${model.providerID}/${model.id}/${model.options.variant ?? "default"}`
|
||||
const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}`
|
||||
const existing = languages.get(key)
|
||||
if (existing) return existing
|
||||
if (model.endpoint.type !== "aisdk")
|
||||
if (model.api.type !== "aisdk")
|
||||
return yield* new InitError({
|
||||
providerID: model.providerID,
|
||||
cause: new Error(`Unsupported endpoint ${model.endpoint.type}`),
|
||||
cause: new Error(`Unsupported api ${model.api.type}`),
|
||||
})
|
||||
|
||||
const options = prepareOptions(model, model.endpoint.package)
|
||||
const options = prepareOptions(model, model.api.package)
|
||||
const sdkKey = JSON.stringify({
|
||||
providerID: model.providerID,
|
||||
endpoint: model.endpoint,
|
||||
api: model.api,
|
||||
options,
|
||||
})
|
||||
const sdk =
|
||||
sdks.get(sdkKey) ??
|
||||
(yield* plugin
|
||||
.trigger("aisdk.sdk", { model, package: model.endpoint.package, options }, {})
|
||||
.trigger("aisdk.sdk", { model, package: model.api.package, options }, {})
|
||||
.pipe(initError(model.providerID))).sdk
|
||||
if (!sdk)
|
||||
return yield* new InitError({
|
||||
|
|
@ -160,7 +168,7 @@ export const layer = Layer.effect(
|
|||
{},
|
||||
)
|
||||
.pipe(initError(model.providerID))
|
||||
const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.apiID)).pipe(
|
||||
const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe(
|
||||
initError(model.providerID),
|
||||
)
|
||||
languages.set(key, language)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
|
|||
import { Identifier } from "./util/identifier"
|
||||
import { NonNegativeInt, withStatics } from "./schema"
|
||||
import { Global } from "./global"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { EventV2 } from "./event"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
|
|
@ -131,7 +131,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const fsys = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const events = yield* EventV2.Service
|
||||
const file = path.join(global.data, "account.json")
|
||||
|
|
@ -334,7 +334,7 @@ export const layer = Layer.effect(
|
|||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Global.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
)
|
||||
|
|
|
|||
284
packages/core/src/background-job.ts
Normal file
284
packages/core/src/background-job.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
export * as BackgroundJob from "./background-job"
|
||||
|
||||
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
|
||||
import { Identifier } from "./id/id"
|
||||
|
||||
export type Status = "running" | "completed" | "error" | "cancelled"
|
||||
|
||||
export type Info = {
|
||||
id: string
|
||||
type: string
|
||||
title?: string
|
||||
status: Status
|
||||
started_at: number
|
||||
completed_at?: number
|
||||
output?: string
|
||||
error?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type Active = {
|
||||
info: Info
|
||||
done: Deferred.Deferred<Info>
|
||||
scope: Scope.Closeable
|
||||
token: object
|
||||
pending: number
|
||||
next: number
|
||||
output?: { sequence: number; text: string }
|
||||
}
|
||||
|
||||
type State = {
|
||||
jobs: SynchronizedRef.SynchronizedRef<Map<string, Active>>
|
||||
scope: Scope.Scope
|
||||
}
|
||||
|
||||
type FinishResult = {
|
||||
info?: Info
|
||||
done?: Deferred.Deferred<Info>
|
||||
scope?: Scope.Closeable
|
||||
}
|
||||
|
||||
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
|
||||
|
||||
type ExtendResult = { extended: false } | { extended: true; scope: Scope.Closeable; token: object; sequence: number }
|
||||
|
||||
export type StartInput = {
|
||||
id?: string
|
||||
type: string
|
||||
title?: string
|
||||
metadata?: Record<string, unknown>
|
||||
run: Effect.Effect<string, unknown>
|
||||
}
|
||||
|
||||
export type ExtendInput = {
|
||||
id: string
|
||||
run: Effect.Effect<string, unknown>
|
||||
}
|
||||
|
||||
export type WaitInput = {
|
||||
id: string
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export type WaitResult = {
|
||||
info?: Info
|
||||
timedOut: boolean
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly get: (id: string) => Effect.Effect<Info | undefined>
|
||||
readonly start: (input: StartInput) => Effect.Effect<Info>
|
||||
readonly extend: (input: ExtendInput) => Effect.Effect<boolean>
|
||||
readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
|
||||
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/BackgroundJob") {}
|
||||
|
||||
function snapshot(job: Active): Info {
|
||||
return {
|
||||
...job.info,
|
||||
...(job.info.metadata ? { metadata: { ...job.info.metadata } } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes one scoped, process-local registry. Entries are intentionally not
|
||||
* durable: process restart or owner-scope closure loses status and interrupts
|
||||
* live work. Persisted observation, restart recovery, and remote workers need a
|
||||
* separate durable ownership slice rather than pretending this registry has
|
||||
* those semantics.
|
||||
*/
|
||||
export const make = Effect.gen(function* () {
|
||||
const state: State = {
|
||||
jobs: yield* SynchronizedRef.make(new Map()),
|
||||
scope: yield* Scope.Scope,
|
||||
}
|
||||
|
||||
const settle = Effect.fn("BackgroundJob.settle")(function* (
|
||||
id: string,
|
||||
token: object,
|
||||
sequence: number,
|
||||
exit: Exit.Exit<string, unknown>,
|
||||
) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.token !== token) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const pending = job.pending - 1
|
||||
const output =
|
||||
Exit.isSuccess(exit) && (!job.output || sequence > job.output.sequence)
|
||||
? { sequence, text: exit.value }
|
||||
: job.output
|
||||
if (Exit.isSuccess(exit) && pending > 0) {
|
||||
return [{}, new Map(jobs).set(id, { ...job, pending, output })]
|
||||
}
|
||||
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
|
||||
? "completed"
|
||||
: Cause.hasInterruptsOnly(exit.cause)
|
||||
? "cancelled"
|
||||
: "error"
|
||||
const next = {
|
||||
...job,
|
||||
pending: 0,
|
||||
output,
|
||||
info: {
|
||||
...job.info,
|
||||
status,
|
||||
completed_at,
|
||||
...(output ? { output: output.text } : {}),
|
||||
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
|
||||
},
|
||||
}
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
})
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.scope) {
|
||||
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
|
||||
}
|
||||
return result.info
|
||||
})
|
||||
|
||||
const fork = Effect.fn("BackgroundJob.fork")(function* (
|
||||
scope: Scope.Scope,
|
||||
id: string,
|
||||
token: object,
|
||||
sequence: number,
|
||||
run: Effect.Effect<string, unknown>,
|
||||
) {
|
||||
return yield* run.pipe(
|
||||
Effect.matchCauseEffect({
|
||||
onSuccess: (output) => settle(id, token, sequence, Exit.succeed(output)),
|
||||
onFailure: (cause) => settle(id, token, sequence, Exit.failCause(cause)),
|
||||
}),
|
||||
Effect.asVoid,
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
const list: Interface["list"] = Effect.fn("BackgroundJob.list")(function* () {
|
||||
return Array.from((yield* SynchronizedRef.get(state.jobs)).values())
|
||||
.map(snapshot)
|
||||
.toSorted((a, b) => a.started_at - b.started_at)
|
||||
})
|
||||
|
||||
const get: Interface["get"] = Effect.fn("BackgroundJob.get")(function* (id) {
|
||||
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
|
||||
if (!job) return
|
||||
return snapshot(job)
|
||||
})
|
||||
|
||||
const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const id = input.id ?? Identifier.ascending("job")
|
||||
const started_at = yield* Clock.currentTimeMillis
|
||||
const done = yield* Deferred.make<Info>()
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs) {
|
||||
const existing = jobs.get(id)
|
||||
if (existing?.info.status === "running") {
|
||||
return [{ info: snapshot(existing) }, jobs] as readonly [StartResult, Map<string, Active>]
|
||||
}
|
||||
const scope = yield* Scope.fork(state.scope, "parallel")
|
||||
const token = {}
|
||||
const job = {
|
||||
info: {
|
||||
id,
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
status: "running" as const,
|
||||
started_at,
|
||||
metadata: input.metadata,
|
||||
},
|
||||
done,
|
||||
scope,
|
||||
token,
|
||||
pending: 1,
|
||||
next: 1,
|
||||
}
|
||||
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
|
||||
StartResult,
|
||||
Map<string, Active>,
|
||||
]
|
||||
}),
|
||||
)
|
||||
if ("scope" in result) yield* fork(result.scope, id, result.token, 0, restore(input.run))
|
||||
return result.info
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const extend: Interface["extend"] = Effect.fn("BackgroundJob.extend")(function* (input) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* SynchronizedRef.modify(
|
||||
state.jobs,
|
||||
(jobs): readonly [ExtendResult, Map<string, Active>] => {
|
||||
const job = jobs.get(input.id)
|
||||
if (!job || job.info.status !== "running") return [{ extended: false }, jobs]
|
||||
return [
|
||||
{ extended: true, scope: job.scope, token: job.token, sequence: job.next },
|
||||
new Map(jobs).set(input.id, {
|
||||
...job,
|
||||
pending: job.pending + 1,
|
||||
next: job.next + 1,
|
||||
}),
|
||||
]
|
||||
},
|
||||
)
|
||||
if (!result.extended) return false
|
||||
yield* fork(result.scope, input.id, result.token, result.sequence, restore(input.run))
|
||||
return true
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const wait: Interface["wait"] = Effect.fn("BackgroundJob.wait")(function* (input) {
|
||||
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
|
||||
if (!job) return { timedOut: false }
|
||||
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
|
||||
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
|
||||
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
|
||||
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
|
||||
if (info._tag === "Some") return { info: info.value, timedOut: false }
|
||||
return { info: snapshot(job), timedOut: true }
|
||||
})
|
||||
|
||||
const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const next = {
|
||||
...job,
|
||||
pending: 0,
|
||||
info: {
|
||||
...job.info,
|
||||
status: "cancelled" as const,
|
||||
completed_at,
|
||||
},
|
||||
}
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
})
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.scope) yield* Scope.close(result.scope, Exit.void)
|
||||
return result.info
|
||||
})
|
||||
|
||||
return Service.of({ list, get, start, extend, wait, cancel })
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
|
@ -97,34 +97,29 @@ export const layer = Layer.effect(
|
|||
|
||||
const resolve = (model: ModelV2.Info) => {
|
||||
const provider = state.get().providers.get(model.providerID)!.provider
|
||||
const endpoint =
|
||||
model.endpoint.type === "unknown"
|
||||
? provider.endpoint
|
||||
: model.endpoint.type === "aisdk" && provider.endpoint.type === "aisdk" && !model.endpoint.url
|
||||
? { ...model.endpoint, url: provider.endpoint.url }
|
||||
: model.endpoint
|
||||
const options = {
|
||||
const api =
|
||||
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
|
||||
? { ...provider.api, id: model.api.id }
|
||||
: model.api.type === "aisdk" && provider.api.type === "aisdk" && !model.api.url
|
||||
? { ...model.api, url: provider.api.url, settings: { ...provider.api.settings, ...model.api.settings } }
|
||||
: model.api.type === "aisdk" && provider.api.type === "aisdk"
|
||||
? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } }
|
||||
: model.api
|
||||
const request = {
|
||||
headers: {
|
||||
...provider.options.headers,
|
||||
...model.options.headers,
|
||||
...provider.request.headers,
|
||||
...model.request.headers,
|
||||
},
|
||||
body: {
|
||||
...provider.options.body,
|
||||
...model.options.body,
|
||||
...provider.request.body,
|
||||
...model.request.body,
|
||||
},
|
||||
aisdk: {
|
||||
provider: {
|
||||
...provider.options.aisdk.provider,
|
||||
...model.options.aisdk.provider,
|
||||
},
|
||||
request: model.options.aisdk.request,
|
||||
},
|
||||
variant: model.options.variant,
|
||||
variant: model.request.variant,
|
||||
}
|
||||
return new ModelV2.Info({
|
||||
...model,
|
||||
endpoint,
|
||||
options,
|
||||
api,
|
||||
request,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -134,10 +129,10 @@ export const layer = Layer.effect(
|
|||
return match
|
||||
}
|
||||
|
||||
const normalizeEndpoint = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
|
||||
if (item.endpoint.type !== "aisdk" || typeof item.options.aisdk.provider.baseURL !== "string") return
|
||||
item.endpoint.url = item.options.aisdk.provider.baseURL
|
||||
delete item.options.aisdk.provider.baseURL
|
||||
const normalizeApi = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
|
||||
if (typeof item.request.body.baseURL !== "string") return
|
||||
item.api.url = item.request.body.baseURL
|
||||
delete item.request.body.baseURL
|
||||
}
|
||||
|
||||
const state = State.create<Data, Editor>({
|
||||
|
|
@ -157,7 +152,7 @@ export const layer = Layer.effect(
|
|||
draft.providers.set(providerID, current)
|
||||
}
|
||||
fn(current.provider)
|
||||
normalizeEndpoint(current.provider)
|
||||
normalizeApi(current.provider)
|
||||
},
|
||||
remove: (providerID) => {
|
||||
draft.providers.delete(providerID)
|
||||
|
|
@ -179,7 +174,7 @@ export const layer = Layer.effect(
|
|||
fn(model)
|
||||
model.id = modelID
|
||||
model.providerID = providerID
|
||||
normalizeEndpoint(model)
|
||||
normalizeApi(model)
|
||||
},
|
||||
remove: (providerID, modelID) => {
|
||||
draft.providers.get(providerID)?.models.delete(modelID)
|
||||
|
|
|
|||
68
packages/core/src/command.ts
Normal file
68
packages/core/src/command.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
export * as CommandV2 from "./command"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { castDraft, type Draft } from "immer"
|
||||
import { ModelV2 } from "./model"
|
||||
import { State } from "./state"
|
||||
|
||||
export class Info extends Schema.Class<Info>("CommandV2.Info")({
|
||||
name: Schema.String,
|
||||
template: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
model: ModelV2.Ref.pipe(Schema.optional),
|
||||
subtask: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export type Data = {
|
||||
commands: Map<string, Info>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
list: () => readonly Info[]
|
||||
get: (name: string) => Info | undefined
|
||||
update: (name: string, update: (command: Draft<Info>) => void) => void
|
||||
remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Command") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.sync(() => {
|
||||
const state = State.create<Data, Editor>({
|
||||
initial: () => ({ commands: new Map() }),
|
||||
editor: (draft) => ({
|
||||
list: () => Array.from(draft.commands.values()) as Info[],
|
||||
get: (name) => draft.commands.get(name),
|
||||
update: (name, update) => {
|
||||
const current = draft.commands.get(name) ?? castDraft(new Info({ name, template: "" }))
|
||||
if (!draft.commands.has(name)) draft.commands.set(name, current)
|
||||
update(current)
|
||||
current.name = name
|
||||
},
|
||||
remove: (name) => {
|
||||
draft.commands.delete(name)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
get: Effect.fn("CommandV2.get")(function* (name) {
|
||||
return state.get().commands.get(name)
|
||||
}),
|
||||
list: Effect.fn("CommandV2.list")(function* () {
|
||||
return Array.from(state.get().commands.values())
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
|
@ -3,15 +3,16 @@ export * as Config from "./config"
|
|||
import path from "path"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { PermissionSchema } from "./permission/schema"
|
||||
import { Policy } from "./policy"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { ConfigAgent } from "./config/agent"
|
||||
import { ConfigAttachments } from "./config/attachments"
|
||||
import { ConfigCompaction } from "./config/compaction"
|
||||
import { ConfigCommand } from "./config/command"
|
||||
import { ConfigExperimental } from "./config/experimental"
|
||||
import { ConfigFormatter } from "./config/formatter"
|
||||
import { ConfigLSP } from "./config/lsp"
|
||||
|
|
@ -21,6 +22,8 @@ import { ConfigProvider } from "./config/provider"
|
|||
import { ConfigReference } from "./config/reference"
|
||||
import { ConfigToolOutput } from "./config/tool-output"
|
||||
import { ConfigWatcher } from "./config/watcher"
|
||||
import { ConfigV1 } from "./v1/config/config"
|
||||
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
$schema: Schema.optional(Schema.String).annotate({
|
||||
|
|
@ -50,7 +53,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
|||
username: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Username displayed in conversations and used for telemetry identity",
|
||||
}),
|
||||
permissions: PermissionV2.Ruleset.pipe(Schema.optional).annotate({
|
||||
permissions: PermissionSchema.Ruleset.pipe(Schema.optional).annotate({
|
||||
description: "Ordered tool permission rules applied to agent tool use",
|
||||
}),
|
||||
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
|
||||
|
|
@ -83,6 +86,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
|||
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Additional paths or URLs to discover skills from",
|
||||
}),
|
||||
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
|
||||
description: "Named slash command definitions",
|
||||
}),
|
||||
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
|
||||
description: "Additional paths or URLs supplying ambient instructions",
|
||||
}),
|
||||
|
|
@ -96,30 +102,22 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
|||
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const FileSource = Schema.Struct({
|
||||
type: Schema.Literal("file"),
|
||||
path: Schema.String,
|
||||
}).annotate({ identifier: "Config.FileSource" })
|
||||
export type FileSource = typeof FileSource.Type
|
||||
|
||||
export const MemorySource = Schema.Struct({
|
||||
type: Schema.Literal("memory"),
|
||||
}).annotate({ identifier: "Config.MemorySource" })
|
||||
export type MemorySource = typeof MemorySource.Type
|
||||
|
||||
export const Source = Schema.Union([FileSource, MemorySource]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Source = typeof Source.Type
|
||||
|
||||
export class Loaded extends Schema.Class<Loaded>("Config.Loaded")({
|
||||
source: Source,
|
||||
export class Document extends Schema.Class<Document>("Config.Document")({
|
||||
type: Schema.Literal("document"),
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
info: Info,
|
||||
}) {}
|
||||
|
||||
export class Directory extends Schema.Class<Directory>("Config.Directory")({
|
||||
type: Schema.Literal("directory"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export type Entry = Document | Directory
|
||||
|
||||
export interface Interface {
|
||||
/** Returns supplemental config directories from lowest to highest priority. */
|
||||
readonly directories: () => Effect.Effect<AbsolutePath[]>
|
||||
/** Loads location config files from lowest to highest priority. */
|
||||
readonly get: () => Effect.Effect<Loaded[]>
|
||||
/** Returns location config documents and supplemental directories from lowest to highest priority. */
|
||||
readonly entries: () => Effect.Effect<Entry[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Config") {}
|
||||
|
|
@ -127,7 +125,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const policy = yield* Policy.Service
|
||||
|
|
@ -141,45 +139,61 @@ export const layer = Layer.effect(
|
|||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return
|
||||
|
||||
// Accept legacy fields while v2 is migrated incrementally; recognized
|
||||
// fields still have to satisfy the v2 schema.
|
||||
const decoded = ConfigMigrateV1.isV1(input)
|
||||
? Option.map(
|
||||
Schema.decodeUnknownOption(ConfigV1.Info)(input, {
|
||||
errors: "all",
|
||||
onExcessProperty: "ignore",
|
||||
propertyOrder: "original",
|
||||
}),
|
||||
ConfigMigrateV1.migrate,
|
||||
)
|
||||
: Option.some(input)
|
||||
const info = Option.getOrUndefined(
|
||||
Schema.decodeUnknownOption(Info)(input, { errors: "all", onExcessProperty: "ignore" }),
|
||||
Option.flatMap(
|
||||
decoded,
|
||||
Schema.decodeUnknownOption(Info, { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" }),
|
||||
),
|
||||
)
|
||||
if (!info) return
|
||||
return new Loaded({ source: { type: "file", path: filepath }, info })
|
||||
return new Document({ type: "document", path: filepath, info })
|
||||
})
|
||||
|
||||
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
|
||||
return yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
|
||||
Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)),
|
||||
)
|
||||
return [
|
||||
...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
|
||||
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
|
||||
)),
|
||||
new Directory({ type: "directory", path: directory }),
|
||||
]
|
||||
})
|
||||
|
||||
const globalDirectory = AbsolutePath.make(global.config)
|
||||
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
|
||||
// Read configuration once when this location opens. Later calls reuse these
|
||||
// values until the location is reopened.
|
||||
const directories = locationIsGlobal
|
||||
? [globalDirectory]
|
||||
: [
|
||||
globalDirectory,
|
||||
...(yield* fs
|
||||
.up({ targets: [".opencode"], start: location.directory, stop: location.project.directory })
|
||||
.pipe(Effect.orDie))
|
||||
.toReversed()
|
||||
.map((directory) => AbsolutePath.make(directory)),
|
||||
]
|
||||
const discovered = locationIsGlobal
|
||||
? []
|
||||
: yield* fs
|
||||
.up({
|
||||
targets: [".opencode", ...names.toReversed()],
|
||||
start: location.directory,
|
||||
stop: location.project.directory,
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
const directories = [
|
||||
globalDirectory,
|
||||
...discovered
|
||||
.filter((item) => path.basename(item) === ".opencode")
|
||||
.toReversed()
|
||||
.map((directory) => AbsolutePath.make(directory)),
|
||||
]
|
||||
// A config closer to the opened directory should win over one higher up.
|
||||
// Search starts nearby, so reverse the results before applying them.
|
||||
const directPaths = locationIsGlobal
|
||||
? []
|
||||
: (yield* fs
|
||||
.up({ targets: names.toReversed(), start: location.directory, stop: location.project.directory })
|
||||
.pipe(Effect.orDie)).toReversed()
|
||||
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed()
|
||||
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((configs) => configs.filter((config): config is Loaded => config !== undefined)),
|
||||
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
|
||||
)
|
||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||
// Apply general settings first and more specific settings last:
|
||||
|
|
@ -187,13 +201,15 @@ export const layer = Layer.effect(
|
|||
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
|
||||
// Rules use the opposite order so a user-global rule can override a
|
||||
// repository rule. Statement order inside each file stays unchanged.
|
||||
yield* policy.load(configs.toReversed().flatMap((config) => config.info.experimental?.policies ?? []))
|
||||
yield* policy.load(
|
||||
configs
|
||||
.filter((config): config is Document => config.type === "document")
|
||||
.toReversed()
|
||||
.flatMap((config) => config.info.experimental?.policies ?? []),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
directories: Effect.fn("Config.directories")(function* () {
|
||||
return directories
|
||||
}),
|
||||
get: Effect.fn("Config.get")(function* () {
|
||||
entries: Effect.fn("Config.entries")(function* () {
|
||||
return configs
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as ConfigAgent from "./agent"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { PermissionSchema } from "../permission/schema"
|
||||
import { ConfigProvider } from "./provider"
|
||||
import { PositiveInt } from "../schema"
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ export const Color = Schema.Union([
|
|||
export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
|
||||
model: Schema.String.pipe(Schema.optional),
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
options: ConfigProvider.Options.pipe(Schema.optional),
|
||||
request: ConfigProvider.Request.pipe(Schema.optional),
|
||||
system: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
|
||||
|
|
@ -21,5 +21,5 @@ export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
|
|||
color: Color.pipe(Schema.optional),
|
||||
steps: PositiveInt.pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
permissions: PermissionV2.Ruleset.pipe(Schema.optional),
|
||||
permissions: PermissionSchema.Ruleset.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
|
|
|||
12
packages/core/src/config/command.ts
Normal file
12
packages/core/src/config/command.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export * as ConfigCommand from "./command"
|
||||
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class Info extends Schema.Class<Info>("ConfigV2.Command")({
|
||||
template: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
agent: Schema.String.pipe(Schema.optional),
|
||||
model: Schema.String.pipe(Schema.optional),
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
subtask: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
36
packages/core/src/config/markdown.ts
Normal file
36
packages/core/src/config/markdown.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
export * as ConfigMarkdown from "./markdown"
|
||||
|
||||
import matter from "gray-matter"
|
||||
export function parse(content: string) {
|
||||
try {
|
||||
return matter(content)
|
||||
} catch {
|
||||
return matter(sanitize(content))
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOption(content: string) {
|
||||
try {
|
||||
return parse(content)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Other coding agents accept unquoted colons in frontmatter values. Retry
|
||||
// those values as YAML block scalars so existing config files keep working.
|
||||
export function sanitize(content: string) {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
||||
if (!match) return content
|
||||
const frontmatter = match[1]
|
||||
const result = frontmatter.split(/\r?\n/).flatMap((line) => {
|
||||
if (line.trim().startsWith("#") || line.trim() === "" || /^\s+/.test(line)) return [line]
|
||||
const entry = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*)$/)
|
||||
if (!entry) return [line]
|
||||
const value = entry[2].trim()
|
||||
if (value === "" || value === ">" || value === "|" || value.startsWith('"') || value.startsWith("'")) return [line]
|
||||
if (!value.includes(":")) return [line]
|
||||
return [`${entry[1]}: |-`, ` ${value}`]
|
||||
})
|
||||
return content.replace(frontmatter, () => result.join("\n"))
|
||||
}
|
||||
|
|
@ -1,103 +1,140 @@
|
|||
export * as ConfigAgentPlugin from "./agent"
|
||||
|
||||
import path from "path"
|
||||
import matter from "gray-matter"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigAgent } from "../agent"
|
||||
import { AppFileSystem } from "../../filesystem"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
import { FSUtil } from "../../fs-util"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PermissionV2 } from "../../permission"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ConfigAgentV1 } from "../../v1/config/agent"
|
||||
import { ConfigMigrateV1 } from "../../v1/config/migrate"
|
||||
|
||||
const legacySources = [
|
||||
{ pattern: "{agent,agents}/**/*.md", primary: false },
|
||||
{ pattern: "{mode,modes}/*.md", primary: true },
|
||||
] as const
|
||||
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
|
||||
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
|
||||
const decodeConfig = Schema.decodeUnknownOption(Config.Info)
|
||||
const agentKeys = new Set([
|
||||
"model",
|
||||
"variant",
|
||||
"request",
|
||||
"system",
|
||||
"description",
|
||||
"mode",
|
||||
"hidden",
|
||||
"color",
|
||||
"steps",
|
||||
"disabled",
|
||||
"permissions",
|
||||
])
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-agent"),
|
||||
effect: Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const documents = yield* config.get()
|
||||
const loadFile = Effect.fnUntraced(function* (directory: string, filepath: string) {
|
||||
const text = yield* fs.readFileString(filepath).pipe(Effect.orDie)
|
||||
const document = yield* Effect.try({ try: () => matter(text), catch: () => undefined }).pipe(
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
if (!document) return
|
||||
|
||||
const info = Option.getOrUndefined(
|
||||
Schema.decodeUnknownOption(ConfigAgent.Info)(
|
||||
{ ...document.data, system: document.content.trim() },
|
||||
{ errors: "all", onExcessProperty: "ignore" },
|
||||
),
|
||||
)
|
||||
if (!info) return
|
||||
|
||||
const relative = path.relative(directory, filepath).split(path.sep).slice(1).join("/")
|
||||
return {
|
||||
id: AgentV2.ID.make(relative.slice(0, -path.extname(relative).length)),
|
||||
info,
|
||||
}
|
||||
})
|
||||
const files = (yield* Effect.forEach(yield* config.directories(), (directory) =>
|
||||
fs.glob("{agent,agents}/**/*.md", { cwd: directory, absolute: true, dot: true, symlink: true }).pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((items) => Effect.forEach(items, (item) => loadFile(directory, item))),
|
||||
Effect.map((items) => items.filter((item): item is NonNullable<typeof item> => item !== undefined)),
|
||||
),
|
||||
)).flat()
|
||||
const fs = yield* FSUtil.Service
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([entry])
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* discover(fs, entry.path)
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => content && decode(file, content)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) =>
|
||||
documents.filter((document): document is Config.Document => document !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
|
||||
yield* agent.update((editor) => {
|
||||
const permissions = new Map<AgentV2.ID, PermissionV2.Ruleset>()
|
||||
|
||||
function update(agentID: AgentV2.ID, item: ConfigAgent.Info, disabled: boolean, rules: PermissionV2.Ruleset) {
|
||||
if (disabled) {
|
||||
editor.remove(agentID)
|
||||
permissions.delete(agentID)
|
||||
return
|
||||
}
|
||||
|
||||
editor.update(agentID, (agent) => {
|
||||
if (item.model !== undefined) {
|
||||
const model = ModelV2.parse(item.model)
|
||||
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
|
||||
}
|
||||
if (item.variant !== undefined && agent.model !== undefined) {
|
||||
agent.model.variant = ModelV2.VariantID.make(item.variant)
|
||||
}
|
||||
if (item.options !== undefined) {
|
||||
Object.assign(agent.options.headers, item.options.headers ?? {})
|
||||
Object.assign(agent.options.body, item.options.body ?? {})
|
||||
Object.assign(agent.options.aisdk.provider, item.options.aisdk?.provider ?? {})
|
||||
Object.assign(agent.options.aisdk.request, item.options.aisdk?.request ?? {})
|
||||
}
|
||||
if (item.system !== undefined) agent.system = item.system
|
||||
if (item.description !== undefined) agent.description = item.description
|
||||
if (item.mode !== undefined) agent.mode = item.mode
|
||||
if (item.hidden !== undefined) agent.hidden = item.hidden
|
||||
if (item.color !== undefined) agent.color = item.color
|
||||
if (item.steps !== undefined) agent.steps = item.steps
|
||||
})
|
||||
|
||||
if (rules.length) permissions.set(agentID, [...(permissions.get(agentID) ?? []), ...rules])
|
||||
}
|
||||
|
||||
for (const file of documents) {
|
||||
for (const [id, item] of Object.entries(file.info.agents ?? {})) {
|
||||
update(AgentV2.ID.make(id), item, item.disabled ?? false, item.permissions ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
update(file.id, file.info, file.info.disabled ?? false, file.info.permissions ?? [])
|
||||
}
|
||||
|
||||
const global = documents.flatMap((file) => file.info.permissions ?? [])
|
||||
const global = documents.flatMap((document) => document.info.permissions ?? [])
|
||||
for (const current of editor.list()) {
|
||||
editor.update(current.id, (agent) => {
|
||||
agent.permissions.push(...global, ...(permissions.get(current.id) ?? []))
|
||||
})
|
||||
editor.update(current.id, (agent) => agent.permissions.push(...global))
|
||||
}
|
||||
|
||||
for (const document of documents) {
|
||||
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
|
||||
const agentID = AgentV2.ID.make(id)
|
||||
if (item.disabled) {
|
||||
editor.remove(agentID)
|
||||
continue
|
||||
}
|
||||
|
||||
const exists = editor.get(agentID) !== undefined
|
||||
editor.update(agentID, (agent) => {
|
||||
if (!exists) agent.permissions.push(...global)
|
||||
if (item.model !== undefined) {
|
||||
const model = ModelV2.parse(item.model)
|
||||
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
|
||||
}
|
||||
if (item.variant !== undefined && agent.model !== undefined) {
|
||||
agent.model.variant = ModelV2.VariantID.make(item.variant)
|
||||
}
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(agent.request.headers, item.request.headers ?? {})
|
||||
Object.assign(agent.request.body, item.request.body ?? {})
|
||||
}
|
||||
if (item.system !== undefined) agent.system = item.system
|
||||
if (item.description !== undefined) agent.description = item.description
|
||||
if (item.mode !== undefined) agent.mode = item.mode
|
||||
if (item.hidden !== undefined) agent.hidden = item.hidden
|
||||
if (item.color !== undefined) agent.color = item.color
|
||||
if (item.steps !== undefined) agent.steps = item.steps
|
||||
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
function discover(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.forEach(legacySources, (source) =>
|
||||
fs
|
||||
.glob(source.pattern, { cwd: directory, absolute: true, dot: true, symlink: true })
|
||||
.pipe(
|
||||
Effect.map((files) => files.toSorted().map((filepath) => ({ directory, filepath, primary: source.primary }))),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((files) => files.flat()),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
)
|
||||
}
|
||||
|
||||
function decode(file: { directory: string; filepath: string; primary: boolean }, content: string) {
|
||||
const markdown = ConfigMarkdown.parseOption(content)
|
||||
if (!markdown) return
|
||||
const name = path
|
||||
.relative(file.directory, file.filepath)
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/^(agent|agents|mode|modes)\//, "")
|
||||
.replace(/\.md$/, "")
|
||||
const body = markdown.content.trim()
|
||||
const legacy = Object.keys(markdown.data).some((key) => !agentKeys.has(key))
|
||||
const agent = Option.getOrUndefined(
|
||||
legacy
|
||||
? Option.map(
|
||||
decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all", propertyOrder: "original" }),
|
||||
ConfigMigrateV1.migrateAgent,
|
||||
)
|
||||
: decodeAgent({ ...markdown.data, system: body }, { errors: "all", propertyOrder: "original" }),
|
||||
)
|
||||
if (!agent) return
|
||||
const info = Option.getOrUndefined(
|
||||
decodeConfig({
|
||||
agents: { [name]: file.primary ? { ...agent, mode: "primary" } : agent },
|
||||
}),
|
||||
)
|
||||
if (!info) return
|
||||
return new Config.Document({ type: "document", path: file.filepath, info })
|
||||
}
|
||||
|
|
|
|||
84
packages/core/src/config/plugin/command.ts
Normal file
84
packages/core/src/config/plugin/command.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
export * as ConfigCommandPlugin from "./command"
|
||||
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { CommandV2 } from "../../command"
|
||||
import { Config } from "../../config"
|
||||
import { FSUtil } from "../../fs-util"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ConfigCommand } from "../command"
|
||||
import { ConfigMarkdown } from "../markdown"
|
||||
|
||||
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-command"),
|
||||
effect: Effect.gen(function* () {
|
||||
const command = yield* CommandV2.Service
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const transform = yield* command.transform()
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
|
||||
return loadDirectory(fs, entry.path).pipe(
|
||||
Effect.map((commands) => [
|
||||
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
|
||||
]),
|
||||
)
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
|
||||
yield* transform((editor) => {
|
||||
for (const document of documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
editor.update(name, (item) => {
|
||||
item.template = command.template
|
||||
if (command.description !== undefined) item.description = command.description
|
||||
if (command.agent !== undefined) item.agent = command.agent
|
||||
if (command.model !== undefined) {
|
||||
const model = ModelV2.parse(command.model)
|
||||
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
|
||||
}
|
||||
if (command.variant !== undefined && item.model !== undefined) {
|
||||
item.model.variant = ModelV2.VariantID.make(command.variant)
|
||||
}
|
||||
if (command.subtask !== undefined) item.subtask = command.subtask
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
function loadDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
.glob("{command,commands}/**/*.md", { cwd: directory, absolute: true, dot: true, symlink: true })
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
return yield* Effect.forEach(files.toSorted(), (filepath) =>
|
||||
fs.readFileStringSafe(filepath).pipe(
|
||||
Effect.map((content) => (content === undefined ? undefined : decode(directory, filepath, content))),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((commands) =>
|
||||
commands.filter((command): command is { name: string; info: ConfigCommand.Info } => command !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function decode(directory: string, filepath: string, content: string) {
|
||||
const markdown = ConfigMarkdown.parseOption(content)
|
||||
if (!markdown) return
|
||||
const info = Option.getOrUndefined(decodeCommand({ ...markdown.data, template: markdown.content.trim() }))
|
||||
if (!info) return
|
||||
return {
|
||||
name: path
|
||||
.relative(directory, filepath)
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/^(command|commands)\//, "")
|
||||
.replace(/\.md$/, ""),
|
||||
info,
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ export const Plugin = PluginV2.define({
|
|||
const catalog = yield* Catalog.Service
|
||||
const config = yield* Config.Service
|
||||
const transform = yield* catalog.transform()
|
||||
const files = yield* config.get()
|
||||
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
|
||||
|
||||
yield* transform((catalog) => {
|
||||
for (const file of files) {
|
||||
|
|
@ -23,21 +23,18 @@ export const Plugin = PluginV2.define({
|
|||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.env !== undefined) provider.env = [...item.env]
|
||||
provider.enabled = { via: "custom", data: {} }
|
||||
if (item.endpoint !== undefined) provider.endpoint = { ...item.endpoint }
|
||||
if (item.options !== undefined) {
|
||||
Object.assign(provider.options.headers, item.options.headers ?? {})
|
||||
Object.assign(provider.options.body, item.options.body ?? {})
|
||||
Object.assign(provider.options.aisdk.provider, item.options.aisdk?.provider ?? {})
|
||||
Object.assign(provider.options.aisdk.request, item.options.aisdk?.request ?? {})
|
||||
if (item.api !== undefined) provider.api = { ...item.api }
|
||||
if (item.request !== undefined) {
|
||||
Object.assign(provider.request.headers, item.request.headers ?? {})
|
||||
Object.assign(provider.request.body, item.request.body ?? {})
|
||||
}
|
||||
})
|
||||
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
catalog.model.update(providerID, ModelV2.ID.make(id), (model) => {
|
||||
if (config.api_id !== undefined) model.apiID = config.api_id
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.endpoint !== undefined) model.endpoint = { ...config.endpoint }
|
||||
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
|
|
@ -45,12 +42,10 @@ export const Plugin = PluginV2.define({
|
|||
output: [...config.capabilities.output],
|
||||
}
|
||||
}
|
||||
if (config.options !== undefined) {
|
||||
Object.assign(model.options.headers, config.options.headers ?? {})
|
||||
Object.assign(model.options.body, config.options.body ?? {})
|
||||
Object.assign(model.options.aisdk.provider, config.options.aisdk?.provider ?? {})
|
||||
Object.assign(model.options.aisdk.request, config.options.aisdk?.request ?? {})
|
||||
if (config.options.variant !== undefined) model.options.variant = config.options.variant
|
||||
if (config.request !== undefined) {
|
||||
Object.assign(model.request.headers, config.request.headers ?? {})
|
||||
Object.assign(model.request.body, config.request.body ?? {})
|
||||
if (config.request.variant !== undefined) model.request.variant = config.request.variant
|
||||
}
|
||||
if (config.variants !== undefined) {
|
||||
for (const variant of config.variants) {
|
||||
|
|
@ -60,17 +55,11 @@ export const Plugin = PluginV2.define({
|
|||
id: variant.id,
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: {},
|
||||
request: {},
|
||||
},
|
||||
}
|
||||
model.variants.push(existing)
|
||||
}
|
||||
Object.assign(existing.headers, variant.headers ?? {})
|
||||
Object.assign(existing.body, variant.body ?? {})
|
||||
Object.assign(existing.aisdk.provider, variant.aisdk?.provider ?? {})
|
||||
Object.assign(existing.aisdk.request, variant.aisdk?.request ?? {})
|
||||
}
|
||||
}
|
||||
if (config.cost !== undefined) {
|
||||
|
|
|
|||
48
packages/core/src/config/plugin/skill.ts
Normal file
48
packages/core/src/config/plugin/skill.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
export * as ConfigSkillPlugin from "./skill"
|
||||
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { Global } from "../../global"
|
||||
import { Location } from "../../location"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { SkillV2 } from "../../skill"
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("config-skill"),
|
||||
effect: Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const transform = yield* skill.transform()
|
||||
const entries = yield* config.entries()
|
||||
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
|
||||
yield* transform((editor) => {
|
||||
for (const directory of directories) {
|
||||
editor.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
)
|
||||
editor.source(
|
||||
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
|
||||
)
|
||||
}
|
||||
for (const item of items) {
|
||||
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
|
||||
editor.source(new SkillV2.UrlSource({ type: "url", url: item }))
|
||||
continue
|
||||
}
|
||||
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
||||
editor.source(
|
||||
new SkillV2.DirectorySource({
|
||||
type: "directory",
|
||||
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
|
@ -4,13 +4,9 @@ import { Schema } from "effect"
|
|||
import { ProviderV2 } from "../provider"
|
||||
import { ModelV2 } from "../model"
|
||||
|
||||
export class Options extends Schema.Class<Options>("ConfigV2.Provider.Options")({
|
||||
export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")({
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||
body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
aisdk: Schema.Struct({
|
||||
provider: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
request: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
class Cache extends Schema.Class<Cache>("ConfigV2.Model.Cost.Cache")({
|
||||
|
|
@ -34,19 +30,32 @@ class Limit extends Schema.Class<Limit>("ConfigV2.Model.Limit")({
|
|||
output: Schema.Int.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
const ModelApi = Schema.Union([
|
||||
Schema.Struct({
|
||||
id: ModelV2.ID.pipe(Schema.optional),
|
||||
...ProviderV2.AISDK.fields,
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: ModelV2.ID.pipe(Schema.optional),
|
||||
...ProviderV2.Native.fields,
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: ModelV2.ID,
|
||||
}),
|
||||
])
|
||||
|
||||
class Model extends Schema.Class<Model>("ConfigV2.Model")({
|
||||
api_id: ModelV2.ID.pipe(Schema.optional),
|
||||
family: ModelV2.Family.pipe(Schema.optional),
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
endpoint: ProviderV2.Endpoint.pipe(Schema.optional),
|
||||
api: ModelApi.pipe(Schema.optional),
|
||||
capabilities: ModelV2.Capabilities.pipe(Schema.optional),
|
||||
options: Schema.Struct({
|
||||
...Options.fields,
|
||||
request: Schema.Struct({
|
||||
...Request.fields,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}).pipe(Schema.optional),
|
||||
variants: Schema.Struct({
|
||||
id: ModelV2.VariantID,
|
||||
...Options.fields,
|
||||
...Request.fields,
|
||||
}).pipe(Schema.Array, Schema.optional),
|
||||
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
|
||||
disabled: Schema.Boolean.pipe(Schema.optional),
|
||||
|
|
@ -56,7 +65,7 @@ class Model extends Schema.Class<Model>("ConfigV2.Model")({
|
|||
export class Info extends Schema.Class<Info>("ConfigV2.Provider")({
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
env: Schema.String.pipe(Schema.Array, Schema.optional),
|
||||
endpoint: ProviderV2.Endpoint.pipe(Schema.optional),
|
||||
options: Options.pipe(Schema.optional),
|
||||
api: ProviderV2.Api.pipe(Schema.optional),
|
||||
request: Request.pipe(Schema.optional),
|
||||
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -15,3 +15,34 @@ export const Entry = Schema.Union([Schema.String, Git, Local])
|
|||
export type Entry = typeof Entry.Type
|
||||
|
||||
export const Info = Schema.Record(Schema.String, Entry)
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export type NormalizedEntry =
|
||||
| { readonly kind: "local"; readonly path: string }
|
||||
| { readonly kind: "git"; readonly repository: string; readonly branch?: string }
|
||||
| { readonly kind: "invalid"; readonly message: string }
|
||||
|
||||
export type NormalizedInfo = Record<string, NormalizedEntry>
|
||||
|
||||
export function validateAlias(name: string) {
|
||||
if (name.length === 0) return "Reference alias must not be empty"
|
||||
if (/[\/\s`,]/.test(name)) return "Reference alias must not contain /, whitespace, comma, or backtick"
|
||||
}
|
||||
|
||||
export function normalizeEntry(entry: Entry): NormalizedEntry {
|
||||
if (typeof entry === "string") {
|
||||
if (entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")) return { kind: "local", path: entry }
|
||||
return { kind: "git", repository: entry }
|
||||
}
|
||||
if ("path" in entry) return { kind: "local", path: entry.path }
|
||||
return { kind: "git", repository: entry.repository, branch: entry.branch }
|
||||
}
|
||||
|
||||
export function normalize(info: Info): NormalizedInfo {
|
||||
return Object.fromEntries(
|
||||
Object.entries(info).map(([name, entry]) => {
|
||||
const message = validateAlias(name)
|
||||
return [name, message ? { kind: "invalid" as const, message } : normalizeEntry(entry)]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
5
packages/core/src/database/migration.gen.ts
generated
5
packages/core/src/database/migration.gen.ts
generated
|
|
@ -26,5 +26,10 @@ export const migrations = (
|
|||
import("./migration/20260601010001_normalize_storage_paths"),
|
||||
import("./migration/20260601202201_amazing_prowler"),
|
||||
import("./migration/20260602002951_lowly_union_jack"),
|
||||
import("./migration/20260602182828_add_project_directories"),
|
||||
import("./migration/20260603001617_session_message_projection_indexes"),
|
||||
import("./migration/20260603040000_session_message_projection_order"),
|
||||
import("./migration/20260603141458_session_input_inbox"),
|
||||
import("./migration/20260603160727_jittery_ezekiel_stane"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
export * as DatabaseMigration from "./migration"
|
||||
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Semaphore } from "effect"
|
||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { migrations } from "./migration.gen"
|
||||
|
||||
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
export type Migration = {
|
||||
id: string
|
||||
|
|
@ -14,7 +15,7 @@ export type Migration = {
|
|||
}
|
||||
|
||||
export function apply(db: Database) {
|
||||
return applyOnly(db, migrations)
|
||||
return lock.withPermit(applyOnly(db, migrations))
|
||||
}
|
||||
|
||||
export function applyOnly(db: Database, input: Migration[]) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260602182828_add_project_directories",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`project_directory\` (
|
||||
\`project_id\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`project_directory_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
|
||||
CONSTRAINT \`fk_project_directory_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260603001617_session_message_projection_indexes",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_idx\`;`)
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_type_idx\`;`)
|
||||
yield* tx.run(`CREATE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_session_type_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`time_created\`,\`id\`);`,
|
||||
)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260603040000_session_message_projection_order",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
// Pre-launch Session projections were written before durable event persistence
|
||||
// became unconditional, so they cannot be assigned truthful aggregate order.
|
||||
yield* tx.run(`DELETE FROM \`session_message\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`session_message\` ADD COLUMN \`seq\` integer NOT NULL;`)
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_message_session_type_time_created_id_idx\`;`)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
|
||||
)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260603141458_session_input_inbox",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`session_input\` (
|
||||
\`seq\` integer PRIMARY KEY AUTOINCREMENT,
|
||||
\`id\` text NOT NULL UNIQUE,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`prompt\` text NOT NULL,
|
||||
\`delivery\` text NOT NULL,
|
||||
\`promoted_seq\` integer,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_input_session_pending_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`seq\`);`,
|
||||
)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260603160727_jittery_ezekiel_stane",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`DROP INDEX IF EXISTS \`session_input_session_pending_seq_idx\`;`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX IF NOT EXISTS \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX IF NOT EXISTS \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX IF NOT EXISTS \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
|
||||
)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
|
|
@ -175,8 +175,9 @@ const drizzleLayer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const layer = (config: Config) =>
|
||||
Layer.merge(
|
||||
nativeLayer(config),
|
||||
Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(nativeLayer(config))),
|
||||
).pipe(Layer.provide(Reactivity.layer))
|
||||
export const layer = (config: Config) => {
|
||||
const native = nativeLayer(config)
|
||||
return Layer.merge(native, Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
|
||||
Layer.provide(Reactivity.layer),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,8 +170,9 @@ const drizzleLayer = Layer.effect(
|
|||
}),
|
||||
)
|
||||
|
||||
export const layer = (config: Config) =>
|
||||
Layer.merge(
|
||||
nativeLayer(config),
|
||||
Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(nativeLayer(config))),
|
||||
).pipe(Layer.provide(Reactivity.layer))
|
||||
export const layer = (config: Config) => {
|
||||
const native = nativeLayer(config)
|
||||
return Layer.merge(native, Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
|
||||
Layer.provide(Reactivity.layer),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
45
packages/core/src/effect/keyed-mutex.ts
Normal file
45
packages/core/src/effect/keyed-mutex.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
export * as KeyedMutex from "./keyed-mutex"
|
||||
|
||||
import { Effect, Semaphore } from "effect"
|
||||
|
||||
export interface KeyedMutex<in Key> {
|
||||
readonly size: Effect.Effect<number>
|
||||
readonly withLock: (key: Key) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an in-memory mutex with one lock per key. Entries are removed when no
|
||||
* holder or waiter remains.
|
||||
*
|
||||
* same key -> queue
|
||||
* different key -> run independently
|
||||
*
|
||||
* `users` counts holders and waiters so an entry is not removed while a waiter
|
||||
* will reuse it.
|
||||
*/
|
||||
export const makeUnsafe = <Key>(): KeyedMutex<Key> => {
|
||||
const locks = new Map<Key, { readonly semaphore: Semaphore.Semaphore; users: number }>()
|
||||
|
||||
const withLock =
|
||||
(key: Key) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.suspend(() => {
|
||||
const current = locks.get(key)
|
||||
const entry = current ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }
|
||||
if (!current) locks.set(key, entry)
|
||||
entry.users++
|
||||
return entry.semaphore.withPermit(effect).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
entry.users--
|
||||
if (entry.users === 0) locks.delete(key)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return { size: Effect.sync(() => locks.size), withLock }
|
||||
}
|
||||
|
||||
/** Creates an in-memory keyed mutex inside an Effect workflow. */
|
||||
export const make = <Key>(): Effect.Effect<KeyedMutex<Key>> => Effect.sync(makeUnsafe<Key>)
|
||||
|
|
@ -1,19 +1,29 @@
|
|||
export * as EventV2 from "./event"
|
||||
|
||||
import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { Database } from "./database/database"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||
import { Location } from "./location"
|
||||
import { withStatics } from "./schema"
|
||||
import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Event.ID"),
|
||||
withStatics((schema) => ({ create: () => schema.make("evt_" + Identifier.ascending()) })),
|
||||
withStatics((schema) => ({
|
||||
create: () => schema.make("evt_" + Identifier.ascending()),
|
||||
fromExternal: (input: ExternalID) => schema.make(externalID("evt", input)),
|
||||
})),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
/**
|
||||
* Durable aggregate continuation position for embedded replay streams.
|
||||
* TODO: Decide whether a future HTTP / SDK surface should expose an opaque cursor instead.
|
||||
*/
|
||||
export const Cursor = NonNegativeInt.pipe(Schema.brand("EventV2.Cursor"))
|
||||
export type Cursor = typeof Cursor.Type
|
||||
|
||||
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
|
||||
readonly type: Type
|
||||
readonly sync?: {
|
||||
|
|
@ -29,6 +39,8 @@ export type Payload<D extends Definition = Definition> = {
|
|||
readonly id: ID
|
||||
readonly type: D["type"]
|
||||
readonly data: Data<D>
|
||||
/** Durable aggregate order, populated while synchronized events are projected. */
|
||||
readonly seq?: number
|
||||
readonly version?: number
|
||||
readonly location?: Location.Ref
|
||||
readonly metadata?: Record<string, unknown>
|
||||
|
|
@ -36,6 +48,7 @@ export type Payload<D extends Definition = Definition> = {
|
|||
|
||||
export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
||||
type AnyProjector = (event: Payload) => Effect.Effect<void>
|
||||
export type CommitGuard = (event: Payload) => Effect.Effect<void>
|
||||
export type Listener = (event: Payload) => Effect.Effect<void>
|
||||
export type Sync = (event: Payload) => Effect.Effect<void>
|
||||
export type Unsubscribe = Effect.Effect<void>
|
||||
|
|
@ -48,6 +61,11 @@ export type SerializedEvent = {
|
|||
readonly data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type CursorEvent<E extends Payload = Payload> = {
|
||||
readonly cursor: Cursor
|
||||
readonly event: E
|
||||
}
|
||||
|
||||
export class InvalidSyncEventError extends Schema.TaggedErrorClass<InvalidSyncEventError>()(
|
||||
"EventV2.InvalidSyncEvent",
|
||||
{
|
||||
|
|
@ -61,7 +79,15 @@ export function versionedType(type: string, version: number) {
|
|||
}
|
||||
|
||||
export const registry = new Map<string, Definition>()
|
||||
const syncRegistry = new Map<string, Definition & { readonly sync: NonNullable<Definition["sync"]> }>()
|
||||
type SyncDefinition = Definition & {
|
||||
readonly sync: NonNullable<Definition["sync"]>
|
||||
readonly encode: (data: unknown) => unknown
|
||||
readonly decode: (data: unknown) => unknown
|
||||
}
|
||||
const syncRegistry = new Map<string, SyncDefinition>()
|
||||
|
||||
// Synchronized events cross a JSON boundary, so their data schemas must encode and decode without services.
|
||||
const syncCodec = (definition: Definition) => definition.data as Schema.Codec<unknown, unknown, never, never>
|
||||
|
||||
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
|
||||
readonly type: Type
|
||||
|
|
@ -93,7 +119,10 @@ export function define<const Type extends string, Fields extends Schema.Struct.F
|
|||
if (input.sync)
|
||||
syncRegistry.set(
|
||||
versionedType(input.type, input.sync.version),
|
||||
definition as Definition & { readonly sync: NonNullable<Definition["sync"]> },
|
||||
Object.assign(definition, {
|
||||
encode: Schema.encodeUnknownSync(syncCodec(definition)),
|
||||
decode: Schema.decodeUnknownSync(syncCodec(definition)),
|
||||
}) as SyncDefinition,
|
||||
)
|
||||
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
|
||||
Definition<Type, Schema.Struct<Fields>>
|
||||
|
|
@ -117,16 +146,21 @@ export interface Interface {
|
|||
) => Effect.Effect<Payload<D>>
|
||||
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
|
||||
readonly all: () => Stream.Stream<Payload>
|
||||
readonly aggregateEvents: (input: {
|
||||
readonly aggregateID: string
|
||||
readonly after?: Cursor
|
||||
}) => Stream.Stream<CursorEvent>
|
||||
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
|
||||
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
|
||||
readonly beforeCommit: (guard: CommitGuard) => Effect.Effect<void>
|
||||
readonly project: <D extends Definition>(definition: D, projector: Projector<D>) => Effect.Effect<void>
|
||||
readonly replay: (
|
||||
event: SerializedEvent,
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string },
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||
) => Effect.Effect<void>
|
||||
readonly replayAll: (
|
||||
events: SerializedEvent[],
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string },
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||
) => Effect.Effect<string | undefined>
|
||||
readonly remove: (aggregateID: string) => Effect.Effect<void>
|
||||
readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect<void>
|
||||
|
|
@ -134,261 +168,439 @@ export interface Interface {
|
|||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const all = yield* PubSub.unbounded<Payload>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
const projectors = new Map<string, AnyProjector[]>()
|
||||
const listeners = new Array<Listener>()
|
||||
const syncHandlers = new Array<Sync>()
|
||||
const { db } = yield* Database.Service
|
||||
export interface LayerOptions {
|
||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
const getOrCreate = (definition: Definition) =>
|
||||
Effect.gen(function* () {
|
||||
const existing = typed.get(definition.type)
|
||||
if (existing) return existing
|
||||
const pubsub = yield* PubSub.unbounded<Payload>()
|
||||
typed.set(definition.type, pubsub)
|
||||
return pubsub
|
||||
})
|
||||
export const layerWith = (options?: LayerOptions) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const all = yield* PubSub.unbounded<Payload>()
|
||||
const synchronized = new Map<string, Set<PubSub.PubSub<void>>>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
const projectors = new Map<string, AnyProjector[]>()
|
||||
const commitGuards = new Array<CommitGuard>()
|
||||
const listeners = new Array<Listener>()
|
||||
const syncHandlers = new Array<Sync>()
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
yield* PubSub.shutdown(all)
|
||||
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
|
||||
}),
|
||||
)
|
||||
const getOrCreate = (definition: Definition) =>
|
||||
Effect.gen(function* () {
|
||||
const existing = typed.get(definition.type)
|
||||
if (existing) return existing
|
||||
const pubsub = yield* PubSub.unbounded<Payload>()
|
||||
typed.set(definition.type, pubsub)
|
||||
return pubsub
|
||||
})
|
||||
|
||||
function commitSyncEvent(
|
||||
event: Payload,
|
||||
input?: { readonly seq: number; readonly aggregateID: string; readonly ownerID?: string },
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const definition = registry.get(event.type)
|
||||
const sync = definition?.sync
|
||||
if (sync) {
|
||||
if (event.version !== sync.version) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Expected event version ${sync.version}, got ${event.version}`,
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
yield* PubSub.shutdown(all)
|
||||
yield* Effect.forEach(
|
||||
synchronized.values(),
|
||||
(pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }),
|
||||
{ discard: true },
|
||||
)
|
||||
yield* Effect.forEach(typed.values(), PubSub.shutdown, { discard: true })
|
||||
}),
|
||||
)
|
||||
|
||||
function commitSyncEvent(
|
||||
event: Payload,
|
||||
input?: {
|
||||
readonly seq: number
|
||||
readonly aggregateID: string
|
||||
readonly ownerID?: string
|
||||
readonly strictOwner?: boolean
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const definition = registry.get(event.type)
|
||||
const sync = definition?.sync
|
||||
if (sync) {
|
||||
if (event.version !== sync.version) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Expected event version ${sync.version}, got ${event.version}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
|
||||
if (typeof aggregateID !== "string") {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Expected string aggregate field ${sync.aggregate}`,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
if (input && input.aggregateID !== aggregateID) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const list = projectors.get(event.type) ?? []
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const committed = yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const latest = row?.seq ?? -1
|
||||
if (input && input.seq <= latest) return
|
||||
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
if (input.strictOwner) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
const seq = input?.seq ?? latest + 1
|
||||
if (input && seq !== latest + 1) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const guard of commitGuards) {
|
||||
yield* guard(event)
|
||||
}
|
||||
for (const projector of list) {
|
||||
yield* projector({ ...event, seq } as Payload)
|
||||
}
|
||||
const encoded = syncRegistry
|
||||
.get(versionedType(definition.type, sync.version))!
|
||||
.encode(event.data)
|
||||
yield* db
|
||||
.insert(EventSequenceTable)
|
||||
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: {
|
||||
seq,
|
||||
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(EventTable)
|
||||
.values([
|
||||
{
|
||||
id: event.id,
|
||||
aggregate_id: aggregateID,
|
||||
seq,
|
||||
type: versionedType(definition.type, sync.version),
|
||||
data: encoded as Record<string, unknown>,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return { aggregateID, seq }
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (committed) {
|
||||
yield* Effect.forEach(
|
||||
synchronized.get(committed.aggregateID) ?? [],
|
||||
(pubsub) => PubSub.publish(pubsub, undefined),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
return committed
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
|
||||
if (typeof aggregateID !== "string") {
|
||||
})
|
||||
}
|
||||
|
||||
function publishEvent<D extends Definition>(event: Payload<D>) {
|
||||
return Effect.gen(function* () {
|
||||
const durable = registry.get(event.type)?.sync !== undefined
|
||||
if (durable) {
|
||||
for (const sync of syncHandlers) {
|
||||
yield* sync(event as Payload)
|
||||
}
|
||||
const committed = yield* commitSyncEvent(event as Payload)
|
||||
if (committed) event = { ...event, seq: committed.seq }
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
yield* listener(event as Payload)
|
||||
}
|
||||
const pubsub = typed.get(event.type)
|
||||
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
|
||||
yield* PubSub.publish(all, event as Payload)
|
||||
return event
|
||||
})
|
||||
}
|
||||
|
||||
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
|
||||
return Effect.gen(function* () {
|
||||
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
|
||||
const location =
|
||||
options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined)
|
||||
return yield* publishEvent({
|
||||
id: options?.id ?? ID.create(),
|
||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||
type: definition.type,
|
||||
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
|
||||
...(location ? { location } : {}),
|
||||
data,
|
||||
} as Payload<D>)
|
||||
})
|
||||
}
|
||||
|
||||
function replay(
|
||||
event: SerializedEvent,
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const definition = syncRegistry.get(event.type)
|
||||
if (!definition) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Expected string aggregate field ${sync.aggregate}`,
|
||||
}),
|
||||
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
|
||||
)
|
||||
} else {
|
||||
const list = projectors.get(event.type) ?? []
|
||||
yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const latest = row?.seq ?? -1
|
||||
if (input && input.seq <= latest) return
|
||||
if (input && row?.ownerID && row.ownerID !== input.ownerID) return
|
||||
const seq = input?.seq ?? latest + 1
|
||||
if (input && seq !== latest + 1) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
for (const projector of list) {
|
||||
yield* projector(event as Payload)
|
||||
}
|
||||
yield* db
|
||||
.insert(EventSequenceTable)
|
||||
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq },
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(EventTable)
|
||||
.values([
|
||||
{
|
||||
id: event.id,
|
||||
aggregate_id: aggregateID,
|
||||
seq,
|
||||
type: versionedType(definition.type, sync.version),
|
||||
data: event.data as Record<string, unknown>,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const payload = {
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
version: definition.sync.version,
|
||||
data: definition.decode(event.data),
|
||||
} as Payload
|
||||
const committed = yield* commitSyncEvent(payload, {
|
||||
seq: event.seq,
|
||||
aggregateID: event.aggregateID,
|
||||
ownerID: options?.ownerID,
|
||||
strictOwner: options?.strictOwner,
|
||||
})
|
||||
if (committed && options?.publish) {
|
||||
const published = { ...payload, seq: committed.seq }
|
||||
for (const listener of listeners) {
|
||||
yield* listener(published)
|
||||
}
|
||||
const pubsub = typed.get(payload.type)
|
||||
if (pubsub) yield* PubSub.publish(pubsub, published)
|
||||
yield* PubSub.publish(all, published)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function publishEvent<D extends Definition>(event: Payload<D>) {
|
||||
return Effect.gen(function* () {
|
||||
for (const sync of syncHandlers) {
|
||||
yield* sync(event as Payload)
|
||||
}
|
||||
yield* commitSyncEvent(event as Payload)
|
||||
for (const listener of listeners) {
|
||||
yield* listener(event as Payload)
|
||||
}
|
||||
const pubsub = typed.get(event.type)
|
||||
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
|
||||
yield* PubSub.publish(all, event as Payload)
|
||||
return event
|
||||
})
|
||||
}
|
||||
function replayAll(
|
||||
events: SerializedEvent[],
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const source = events[0]?.aggregateID
|
||||
if (!source) return undefined
|
||||
if (events.some((event) => event.aggregateID !== source)) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: events[0]?.type ?? "unknown",
|
||||
message: "Replay events must belong to the same aggregate",
|
||||
}),
|
||||
)
|
||||
}
|
||||
const start = events[0]?.seq ?? 0
|
||||
for (const [index, event] of events.entries()) {
|
||||
const seq = start + index
|
||||
if (event.seq !== seq) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: event.type,
|
||||
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const event of events) {
|
||||
yield* replay(event, options)
|
||||
}
|
||||
return source
|
||||
})
|
||||
}
|
||||
|
||||
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
|
||||
return Effect.gen(function* () {
|
||||
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
|
||||
const location =
|
||||
options?.location ??
|
||||
(serviceLocation
|
||||
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
|
||||
: undefined)
|
||||
return yield* publishEvent({
|
||||
id: options?.id ?? ID.create(),
|
||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||
type: definition.type,
|
||||
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
|
||||
...(location ? { location } : {}),
|
||||
data,
|
||||
} as Payload<D>)
|
||||
})
|
||||
}
|
||||
function remove(aggregateID: string) {
|
||||
return db
|
||||
.transaction(() =>
|
||||
Effect.gen(function* () {
|
||||
yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
|
||||
yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
function replay(event: SerializedEvent, options?: { readonly publish?: boolean; readonly ownerID?: string }) {
|
||||
return Effect.gen(function* () {
|
||||
function claim(aggregateID: string, ownerID: string) {
|
||||
return db
|
||||
.update(EventSequenceTable)
|
||||
.set({ owner_id: ownerID })
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
|
||||
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
|
||||
Stream.map((event) => event as Payload<D>),
|
||||
)
|
||||
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
|
||||
|
||||
const decodeSerializedEvent = (event: SerializedEvent): CursorEvent => {
|
||||
const definition = syncRegistry.get(event.type)
|
||||
if (!definition) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
|
||||
)
|
||||
} else {
|
||||
const payload = {
|
||||
throw new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` })
|
||||
}
|
||||
return {
|
||||
cursor: Cursor.make(event.seq),
|
||||
event: {
|
||||
id: event.id,
|
||||
type: definition.type,
|
||||
version: definition.sync.version,
|
||||
data: event.data,
|
||||
} as Payload
|
||||
yield* commitSyncEvent(payload, { seq: event.seq, aggregateID: event.aggregateID, ownerID: options?.ownerID })
|
||||
if (options?.publish) {
|
||||
for (const listener of listeners) {
|
||||
yield* listener(payload)
|
||||
}
|
||||
const pubsub = typed.get(payload.type)
|
||||
if (pubsub) yield* PubSub.publish(pubsub, payload)
|
||||
yield* PubSub.publish(all, payload)
|
||||
}
|
||||
seq: event.seq,
|
||||
data: definition.decode(event.data),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function replayAll(events: SerializedEvent[], options?: { readonly publish?: boolean; readonly ownerID?: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const source = events[0]?.aggregateID
|
||||
if (!source) return undefined
|
||||
if (events.some((event) => event.aggregateID !== source)) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
type: events[0]?.type ?? "unknown",
|
||||
message: "Replay events must belong to the same aggregate",
|
||||
}),
|
||||
)
|
||||
}
|
||||
const start = events[0]?.seq ?? 0
|
||||
for (const [index, event] of events.entries()) {
|
||||
const seq = start + index
|
||||
if (event.seq !== seq) {
|
||||
yield* Effect.die(
|
||||
new InvalidSyncEventError({
|
||||
const readAfter = (aggregateID: string, after: number) =>
|
||||
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
||||
Effect.andThen(
|
||||
db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, aggregateID), gt(EventTable.seq, after)))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all(),
|
||||
),
|
||||
Effect.orDie,
|
||||
Effect.map((rows) =>
|
||||
rows.map((event) =>
|
||||
decodeSerializedEvent({
|
||||
id: event.id,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
|
||||
data: event.data,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const event of events) {
|
||||
yield* replay(event, options)
|
||||
}
|
||||
return source
|
||||
})
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
function remove(aggregateID: string) {
|
||||
return db
|
||||
.transaction(() =>
|
||||
const subscribeSynchronized = (aggregateID: string) =>
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub.sliding<void>(1)
|
||||
const subscription = yield* PubSub.subscribe(pubsub)
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const pubsubs = synchronized.get(aggregateID) ?? new Set()
|
||||
pubsubs.add(pubsub)
|
||||
synchronized.set(aggregateID, pubsubs)
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
const pubsubs = synchronized.get(aggregateID)
|
||||
pubsubs?.delete(pubsub)
|
||||
if (pubsubs?.size === 0) synchronized.delete(aggregateID)
|
||||
}).pipe(Effect.andThen(PubSub.shutdown(pubsub))),
|
||||
)
|
||||
return subscription
|
||||
})
|
||||
|
||||
const streamEvents = (input: {
|
||||
readonly aggregateID: string
|
||||
readonly after?: Cursor
|
||||
}): Stream.Stream<CursorEvent> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
|
||||
yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
|
||||
const synchronized = yield* subscribeSynchronized(input.aggregateID)
|
||||
let cursor = input.after ?? -1
|
||||
const read = Effect.suspend(() => readAfter(input.aggregateID, cursor)).pipe(
|
||||
Effect.tap((events) =>
|
||||
Effect.sync(() => {
|
||||
cursor = events.at(-1)?.cursor ?? cursor
|
||||
}),
|
||||
),
|
||||
)
|
||||
const historical = yield* read
|
||||
const live = Stream.fromSubscription(synchronized).pipe(
|
||||
Stream.mapEffect(() => read),
|
||||
Stream.flattenIterable,
|
||||
)
|
||||
return Stream.concat(Stream.fromIterable(historical), live)
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
function claim(aggregateID: string, ownerID: string) {
|
||||
return db
|
||||
.update(EventSequenceTable)
|
||||
.set({ owner_id: ownerID })
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
|
||||
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
|
||||
Stream.map((event) => event as Payload<D>),
|
||||
)
|
||||
|
||||
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
|
||||
|
||||
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
|
||||
Effect.sync(() => {
|
||||
listeners.push(listener)
|
||||
return Effect.sync(() => {
|
||||
const index = listeners.indexOf(listener)
|
||||
if (index >= 0) listeners.splice(index, 1)
|
||||
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
|
||||
Effect.sync(() => {
|
||||
listeners.push(listener)
|
||||
return Effect.sync(() => {
|
||||
const index = listeners.indexOf(listener)
|
||||
if (index >= 0) listeners.splice(index, 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
|
||||
Effect.sync(() => {
|
||||
syncHandlers.push(handler)
|
||||
return Effect.sync(() => {
|
||||
const index = syncHandlers.indexOf(handler)
|
||||
if (index >= 0) syncHandlers.splice(index, 1)
|
||||
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
|
||||
Effect.sync(() => {
|
||||
syncHandlers.push(handler)
|
||||
return Effect.sync(() => {
|
||||
const index = syncHandlers.indexOf(handler)
|
||||
if (index >= 0) syncHandlers.splice(index, 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
const list = projectors.get(definition.type) ?? []
|
||||
list.push((event) => projector(event as Payload<D>))
|
||||
projectors.set(definition.type, list)
|
||||
})
|
||||
const beforeCommit = (guard: CommitGuard): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
commitGuards.push(guard)
|
||||
})
|
||||
|
||||
return Service.of({ publish, subscribe, all: streamAll, sync, listen, project, replay, replayAll, remove, claim })
|
||||
}),
|
||||
)
|
||||
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
const list = projectors.get(definition.type) ?? []
|
||||
list.push((event) => projector(event as Payload<D>))
|
||||
projectors.set(definition.type, list)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
publish,
|
||||
subscribe,
|
||||
all: streamAll,
|
||||
aggregateEvents: streamEvents,
|
||||
sync,
|
||||
listen,
|
||||
beforeCommit,
|
||||
project,
|
||||
replay,
|
||||
replayAll,
|
||||
remove,
|
||||
claim,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = layerWith()
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
|
||||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"
|
||||
import type { EventV2 } from "../event"
|
||||
|
||||
export const EventSequenceTable = sqliteTable("event_sequence", {
|
||||
|
|
@ -7,12 +7,19 @@ export const EventSequenceTable = sqliteTable("event_sequence", {
|
|||
owner_id: text(),
|
||||
})
|
||||
|
||||
export const EventTable = sqliteTable("event", {
|
||||
id: text().$type<EventV2.ID>().primaryKey(),
|
||||
aggregate_id: text()
|
||||
.notNull()
|
||||
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
|
||||
seq: integer().notNull(),
|
||||
type: text().notNull(),
|
||||
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
|
||||
})
|
||||
export const EventTable = sqliteTable(
|
||||
"event",
|
||||
{
|
||||
id: text().$type<EventV2.ID>().primaryKey(),
|
||||
aggregate_id: text()
|
||||
.notNull()
|
||||
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
|
||||
seq: integer().notNull(),
|
||||
type: text().notNull(),
|
||||
data: text({ mode: "json" }).$type<Record<string, unknown>>().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("event_aggregate_seq_idx").on(table.aggregate_id, table.seq),
|
||||
index("event_aggregate_type_seq_idx").on(table.aggregate_id, table.type, table.seq),
|
||||
],
|
||||
)
|
||||
|
|
|
|||
212
packages/core/src/file-mutation.ts
Normal file
212
packages/core/src/file-mutation.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
export * as FileMutation from "./file-mutation"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { dirname } from "path"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { LocationMutation } from "./location-mutation"
|
||||
|
||||
export interface WriteInput {
|
||||
readonly plan: LocationMutation.Plan
|
||||
readonly content: string | Uint8Array
|
||||
}
|
||||
|
||||
export interface TextWriteInput {
|
||||
readonly plan: LocationMutation.Plan
|
||||
readonly content: string
|
||||
}
|
||||
|
||||
export interface ConditionalWriteInput extends WriteInput {
|
||||
readonly expected: Uint8Array
|
||||
}
|
||||
|
||||
export interface RemoveInput {
|
||||
readonly plan: LocationMutation.Plan
|
||||
}
|
||||
|
||||
export class StaleContentError extends Schema.TaggedErrorClass<StaleContentError>()("FileMutation.StaleContentError", {
|
||||
path: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class TargetExistsError extends Schema.TaggedErrorClass<TargetExistsError>()("FileMutation.TargetExistsError", {
|
||||
path: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface WriteResult {
|
||||
readonly operation: "write"
|
||||
/** Canonical target actually passed to the filesystem mutation. */
|
||||
readonly target: string
|
||||
/** Permission resource captured during planning. */
|
||||
readonly resource: string
|
||||
readonly existed: boolean
|
||||
}
|
||||
|
||||
export interface RemoveResult {
|
||||
readonly operation: "remove"
|
||||
/** Canonical target actually passed to the filesystem mutation. */
|
||||
readonly target: string
|
||||
/** Permission resource captured during planning. */
|
||||
readonly resource: string
|
||||
readonly existed: boolean
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Create only while the planned target remains absent. */
|
||||
readonly create: (
|
||||
input: WriteInput,
|
||||
) => Effect.Effect<WriteResult, TargetExistsError | LocationMutation.RevalidationError | FSUtil.Error>
|
||||
/** Write after immediately revalidating the planned target. */
|
||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
|
||||
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
||||
readonly writeTextPreservingBom: (
|
||||
input: TextWriteInput,
|
||||
) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
|
||||
/** Commit only if an existing target still has the expected bytes. */
|
||||
readonly writeIfUnchanged: (
|
||||
input: ConditionalWriteInput,
|
||||
) => Effect.Effect<WriteResult, StaleContentError | LocationMutation.RevalidationError | FSUtil.Error>
|
||||
/** Remove after immediately revalidating the planned target. */
|
||||
readonly remove: (
|
||||
input: RemoveInput,
|
||||
) => Effect.Effect<RemoveResult, LocationMutation.RevalidationError | FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileMutation") {}
|
||||
|
||||
/**
|
||||
* Commit planned file changes.
|
||||
*
|
||||
* resolve(path) -> approve -> lock target -> revalidate(plan) -> mutate
|
||||
*
|
||||
* The caller approves the plan first. This service locks the canonical target,
|
||||
* revalidates the plan immediately before the filesystem operation, then mutates.
|
||||
*
|
||||
* `writeIfUnchanged` compares and writes while holding the same in-memory lock,
|
||||
* so cooperating calls in this process cannot overwrite from the same stale
|
||||
* content. Locks apply only within this service layer and only to identical
|
||||
* canonical targets.
|
||||
*
|
||||
* Revalidation reduces the race window but is not atomic with the next
|
||||
* path-based filesystem operation. A hostile local process can still race it.
|
||||
*
|
||||
* TODO: Use descriptor-relative no-follow operations where supported to close
|
||||
* the final race.
|
||||
*/
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const locks = KeyedMutex.makeUnsafe<string>()
|
||||
const withTargetLock =
|
||||
(target: string) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
locks.withLock(target)(Effect.uninterruptible(effect))
|
||||
|
||||
const withValidatedTarget =
|
||||
(plan: LocationMutation.Plan) =>
|
||||
<A, E, R>(commit: (target: LocationMutation.Target) => Effect.Effect<A, E, R>) =>
|
||||
withTargetLock(plan.target.canonical)(mutation.revalidate(plan).pipe(Effect.flatMap(commit)))
|
||||
|
||||
const writeResult = (target: LocationMutation.Target, existed = target.exists): WriteResult => ({
|
||||
operation: "write",
|
||||
target: target.canonical,
|
||||
resource: target.resource,
|
||||
existed,
|
||||
})
|
||||
|
||||
const removeResult = (target: LocationMutation.Target): RemoveResult => ({
|
||||
operation: "remove",
|
||||
target: target.canonical,
|
||||
resource: target.resource,
|
||||
existed: target.exists,
|
||||
})
|
||||
|
||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||
withValidatedTarget(input.plan)((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fs.writeWithDirs(target.canonical, input.content)
|
||||
return writeResult(target)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
|
||||
withValidatedTarget(input.plan)((target) =>
|
||||
Effect.gen(function* () {
|
||||
const next = splitBom(input.content)
|
||||
const preserveBom = target.exists && hasUtf8Bom(yield* fs.readFile(target.canonical))
|
||||
yield* fs.writeWithDirs(target.canonical, joinBom(next.text, preserveBom || next.bom))
|
||||
return writeResult(target)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const create = Effect.fn("FileMutation.create")((input: WriteInput) =>
|
||||
withValidatedTarget(input.plan)((target) =>
|
||||
Effect.gen(function* () {
|
||||
if (target.exists) return yield* new TargetExistsError({ path: target.canonical })
|
||||
yield* fs.ensureDir(dirname(target.canonical))
|
||||
if (typeof input.content === "string")
|
||||
yield* fs.writeFileString(target.canonical, input.content, { flag: "wx" })
|
||||
else yield* fs.writeFile(target.canonical, input.content, { flag: "wx" })
|
||||
return writeResult(target, false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const writeIfUnchanged = Effect.fn("FileMutation.writeIfUnchanged")((input: ConditionalWriteInput) =>
|
||||
withValidatedTarget(input.plan)((target) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* fs.readFile(target.canonical)
|
||||
if (!sameBytes(current, input.expected)) return yield* new StaleContentError({ path: target.canonical })
|
||||
yield* fs.writeWithDirs(target.canonical, input.content)
|
||||
return writeResult(target)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const remove = Effect.fn("FileMutation.remove")((input: RemoveInput) =>
|
||||
withValidatedTarget(input.plan)((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fs.remove(target.canonical)
|
||||
return removeResult(target)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({ create, write, writeTextPreservingBom, writeIfUnchanged, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
function splitBom(text: string) {
|
||||
const stripped = text.replace(/^\uFEFF+/, "")
|
||||
return { bom: stripped.length !== text.length, text: stripped }
|
||||
}
|
||||
|
||||
function joinBom(text: string, bom: boolean) {
|
||||
const stripped = splitBom(text).text
|
||||
return bom ? `\uFEFF${stripped}` : stripped
|
||||
}
|
||||
|
||||
function hasUtf8Bom(content: Uint8Array) {
|
||||
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
||||
}
|
||||
|
||||
function sameBytes(left: Uint8Array, right: Uint8Array) {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((byte, index) => byte === right[index])
|
||||
}
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
/**
|
||||
* Deferred until the corresponding V2 integrations exist.
|
||||
*/
|
||||
// TODO: Add formatter integration after V2 formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
|
||||
// TODO: Add snapshots / undo after V2 snapshot design exists.
|
||||
// TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists.
|
||||
// TODO: Design multi-file transactions / rollback if apply_patch needs atomic edits.
|
||||
// Until then, edits are sequential and report partial application.
|
||||
// TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement.
|
||||
|
|
@ -1,247 +1,573 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||
import { realpathSync } from "fs"
|
||||
import * as NFS from "fs/promises"
|
||||
import { lookup } from "mime-types"
|
||||
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { Glob } from "./util/glob"
|
||||
import { serviceUse } from "./effect/service-use"
|
||||
export * as FileSystem from "./filesystem"
|
||||
|
||||
export namespace AppFileSystem {
|
||||
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||
method: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import ignore from "ignore"
|
||||
import { Context, Effect, Layer, Option, Schema, Stream } from "effect"
|
||||
import { EventV2 } from "./event"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { ProjectReference } from "./project-reference"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||
import { Protected } from "./filesystem/protected"
|
||||
import { Ripgrep } from "./filesystem/ripgrep"
|
||||
|
||||
export type Error = PlatformError | FileSystemError
|
||||
export const ReadInput = Schema.Struct({
|
||||
path: RelativePath,
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
})
|
||||
export type ReadInput = typeof ReadInput.Type
|
||||
|
||||
export interface DirEntry {
|
||||
readonly name: string
|
||||
readonly type: "file" | "directory" | "symlink" | "other"
|
||||
}
|
||||
export const MAX_READ_LINES = 2_000
|
||||
export const MAX_READ_BYTES = 50 * 1024
|
||||
const MAX_LINE_LENGTH = 2_000
|
||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||
|
||||
export interface Interface extends FileSystem.FileSystem {
|
||||
readonly isDir: (path: string) => Effect.Effect<boolean>
|
||||
readonly isFile: (path: string) => Effect.Effect<boolean>
|
||||
readonly existsSafe: (path: string) => Effect.Effect<boolean>
|
||||
readonly readFileStringSafe: (path: string) => Effect.Effect<string | undefined, Error>
|
||||
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
|
||||
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
|
||||
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
|
||||
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
|
||||
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
|
||||
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
|
||||
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||
readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
|
||||
readonly globMatch: (pattern: string, filepath: string) => boolean
|
||||
}
|
||||
export class TextContent extends Schema.Class<TextContent>("FileSystem.TextContent")({
|
||||
type: Schema.Literal("text"),
|
||||
content: Schema.String,
|
||||
mime: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
||||
export class BinaryContent extends Schema.Class<BinaryContent>("FileSystem.BinaryContent")({
|
||||
type: Schema.Literal("binary"),
|
||||
content: Schema.String,
|
||||
encoding: Schema.Literal("base64"),
|
||||
mime: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Content = typeof Content.Type
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
export const TextPageInput = Schema.Struct({
|
||||
offset: PositiveInt.pipe(Schema.optional),
|
||||
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional),
|
||||
})
|
||||
export type TextPageInput = typeof TextPageInput.Type
|
||||
|
||||
const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
|
||||
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
|
||||
})
|
||||
export class TextPage extends Schema.Class<TextPage>("FileSystem.TextPage")({
|
||||
type: Schema.Literal("text-page"),
|
||||
content: Schema.String,
|
||||
mime: Schema.String,
|
||||
offset: PositiveInt,
|
||||
truncated: Schema.Boolean,
|
||||
next: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
|
||||
return yield* fs
|
||||
.readFileString(path)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
})
|
||||
export class ReadTarget extends Schema.Class<ReadTarget>("FileSystem.ReadTarget")({
|
||||
real: Schema.String,
|
||||
resource: Schema.String,
|
||||
size: NonNegativeInt,
|
||||
dev: Schema.Number,
|
||||
ino: Schema.Number.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
|
||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||
return info?.type === "Directory"
|
||||
})
|
||||
export const ListInput = Schema.Struct({
|
||||
path: RelativePath.pipe(Schema.optional),
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
})
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
|
||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||
return info?.type === "File"
|
||||
})
|
||||
export const ListPageInput = Schema.Struct({
|
||||
...ListInput.fields,
|
||||
offset: PositiveInt.pipe(Schema.optional),
|
||||
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(2_000)).pipe(Schema.optional),
|
||||
})
|
||||
export type ListPageInput = typeof ListPageInput.Type
|
||||
|
||||
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
|
||||
return entries.map(
|
||||
(e): DirEntry => ({
|
||||
name: e.name,
|
||||
type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
|
||||
}),
|
||||
)
|
||||
},
|
||||
catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
|
||||
})
|
||||
})
|
||||
export class ListTarget extends Schema.Class<ListTarget>("FileSystem.ListTarget")({
|
||||
absolute: Schema.String,
|
||||
real: Schema.String,
|
||||
directory: Schema.String,
|
||||
root: Schema.String,
|
||||
resource: Schema.String,
|
||||
}) {}
|
||||
|
||||
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
|
||||
const text = yield* fs.readFileString(path)
|
||||
return JSON.parse(text)
|
||||
})
|
||||
/** Canonical read authority for Location-scoped search and metadata leaves. */
|
||||
export class RootTarget extends Schema.Class<RootTarget>("FileSystem.RootTarget")({
|
||||
absolute: Schema.String,
|
||||
real: Schema.String,
|
||||
directory: Schema.String,
|
||||
root: Schema.String,
|
||||
resource: Schema.String,
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
type: Schema.Literals(["file", "directory"]),
|
||||
dev: Schema.Number,
|
||||
ino: Schema.Number.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
|
||||
const content = JSON.stringify(data, null, 2)
|
||||
yield* fs.writeFileString(path, content)
|
||||
if (mode) yield* fs.chmod(path, mode)
|
||||
})
|
||||
export type ReadPathTarget =
|
||||
| { readonly type: "file"; readonly target: ReadTarget }
|
||||
| { readonly type: "directory"; readonly target: ListTarget }
|
||||
|
||||
const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
|
||||
yield* fs.makeDirectory(path, { recursive: true })
|
||||
})
|
||||
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
|
||||
path: RelativePath,
|
||||
uri: Schema.String,
|
||||
type: Schema.Literals(["file", "directory"]),
|
||||
mime: Schema.String,
|
||||
}) {}
|
||||
|
||||
const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
|
||||
path: string,
|
||||
content: string | Uint8Array,
|
||||
mode?: number,
|
||||
) {
|
||||
const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
|
||||
export class ListPage extends Schema.Class<ListPage>("FileSystem.ListPage")({
|
||||
entries: Schema.Array(Entry),
|
||||
truncated: Schema.Boolean,
|
||||
next: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
yield* write.pipe(
|
||||
Effect.catchIf(
|
||||
(e) => e.reason._tag === "NotFound",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* fs.makeDirectory(dirname(path), { recursive: true })
|
||||
yield* write
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (mode) yield* fs.chmod(path, mode)
|
||||
})
|
||||
export const FindInput = Schema.Struct({
|
||||
query: Schema.String,
|
||||
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
|
||||
limit: PositiveInt.pipe(Schema.optional),
|
||||
})
|
||||
export type FindInput = typeof FindInput.Type
|
||||
|
||||
const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => Glob.scan(pattern, options),
|
||||
catch: (cause) => new FileSystemError({ method: "glob", cause }),
|
||||
})
|
||||
})
|
||||
export const GrepInput = Schema.Struct({
|
||||
pattern: Schema.String,
|
||||
include: Schema.String.pipe(Schema.optional),
|
||||
limit: PositiveInt.pipe(Schema.optional),
|
||||
})
|
||||
export type GrepInput = typeof GrepInput.Type
|
||||
|
||||
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
|
||||
const result: string[] = []
|
||||
let current = start
|
||||
while (true) {
|
||||
const search = join(current, target)
|
||||
if (yield* fs.exists(search)) result.push(search)
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
|
||||
const result: string[] = []
|
||||
let current = options.start
|
||||
while (true) {
|
||||
for (const target of options.targets) {
|
||||
const search = join(current, target)
|
||||
if (yield* fs.exists(search)) result.push(search)
|
||||
}
|
||||
if (options.stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
|
||||
const result: string[] = []
|
||||
let current = start
|
||||
while (true) {
|
||||
const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
|
||||
Effect.catch(() => Effect.succeed([] as string[])),
|
||||
)
|
||||
result.push(...matches)
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
...fs,
|
||||
existsSafe,
|
||||
readFileStringSafe,
|
||||
isDir,
|
||||
isFile,
|
||||
readDirectoryEntries,
|
||||
readJson,
|
||||
writeJson,
|
||||
ensureDir,
|
||||
writeWithDirs,
|
||||
findUp,
|
||||
up,
|
||||
globUp,
|
||||
glob,
|
||||
globMatch: Glob.match,
|
||||
})
|
||||
export class GrepMatch extends Schema.Class<GrepMatch>("FileSystem.GrepMatch")({
|
||||
path: RelativePath,
|
||||
lines: Schema.String,
|
||||
line: PositiveInt,
|
||||
offset: NonNegativeInt,
|
||||
submatches: Schema.Array(
|
||||
Schema.Struct({
|
||||
text: Schema.String,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}),
|
||||
)
|
||||
),
|
||||
}) {}
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
||||
|
||||
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
||||
export function mimeType(p: string): string {
|
||||
return lookup(p) || "application/octet-stream"
|
||||
}
|
||||
|
||||
export function normalizePath(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
const resolved = pathResolve(windowsPath(p))
|
||||
try {
|
||||
return realpathSync.native(resolved)
|
||||
} catch {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePathPattern(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
if (p === "*") return p
|
||||
const match = p.match(/^(.*)[\\/]\*$/)
|
||||
if (!match) return normalizePath(p)
|
||||
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
|
||||
return join(normalizePath(dir), "*")
|
||||
}
|
||||
|
||||
export function resolve(p: string): string {
|
||||
const resolved = pathResolve(windowsPath(p))
|
||||
try {
|
||||
return normalizePath(realpathSync(resolved))
|
||||
} catch (e: any) {
|
||||
if (e?.code === "ENOENT") return normalizePath(resolved)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export function windowsPath(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
return p
|
||||
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
}
|
||||
|
||||
export function overlaps(a: string, b: string) {
|
||||
const relA = relative(a, b)
|
||||
const relB = relative(b, a)
|
||||
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
|
||||
}
|
||||
|
||||
export function contains(parent: string, child: string) {
|
||||
return !relative(parent, child).startsWith("..")
|
||||
}
|
||||
export const Event = {
|
||||
Edited: EventV2.define({
|
||||
type: "file.edited",
|
||||
schema: {
|
||||
file: Schema.String,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly read: (input: ReadInput) => Effect.Effect<Content>
|
||||
readonly resolveReadPath: (input: ReadInput) => Effect.Effect<ReadPathTarget>
|
||||
readonly resolveRead: (input: ReadInput) => Effect.Effect<ReadTarget>
|
||||
readonly readResolved: (target: ReadTarget, maximumBytes?: number) => Effect.Effect<Content>
|
||||
readonly readTextPageResolved: (target: ReadTarget, page?: TextPageInput) => Effect.Effect<TextPage>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||
/** Select a contained canonical read root without asserting leaf policy. */
|
||||
readonly resolveRoot: (input?: ListInput) => Effect.Effect<RootTarget>
|
||||
readonly revalidateRoot: (target: RootTarget) => Effect.Effect<RootTarget>
|
||||
readonly resolveList: (input?: ListInput) => Effect.Effect<ListTarget>
|
||||
readonly listResolved: (target: ListTarget) => Effect.Effect<Entry[]>
|
||||
readonly listPage: (input?: ListPageInput) => Effect.Effect<ListPage>
|
||||
readonly listPageResolved: (
|
||||
target: ListTarget,
|
||||
page?: Pick<ListPageInput, "offset" | "limit">,
|
||||
) => Effect.Effect<ListPage>
|
||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
|
||||
readonly isIgnored: (path: RelativePath, type: "file" | "directory") => boolean
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const references = yield* ProjectReference.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
|
||||
const ignored = ignore()
|
||||
const gitignore = yield* fs
|
||||
.readFileString(path.join(location.project.directory, ".gitignore"))
|
||||
.pipe(Effect.catch(() => Effect.succeed("")))
|
||||
if (gitignore) ignored.add(gitignore)
|
||||
const ignorefile = yield* fs
|
||||
.readFileString(path.join(location.project.directory, ".ignore"))
|
||||
.pipe(Effect.catch(() => Effect.succeed("")))
|
||||
if (ignorefile) ignored.add(ignorefile)
|
||||
const select = Effect.fnUntraced(function* (reference?: string) {
|
||||
if (!reference) return { directory: location.directory, root }
|
||||
const resolved = yield* references.get(reference)
|
||||
if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`))
|
||||
if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message))
|
||||
if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
|
||||
return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
|
||||
})
|
||||
const resolve = Effect.fnUntraced(function* (input?: RelativePath, reference?: string) {
|
||||
if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location"))
|
||||
const selected = yield* select(reference)
|
||||
const absolute = path.resolve(selected.directory, input ?? ".")
|
||||
if (!FSUtil.contains(selected.directory, absolute))
|
||||
return yield* Effect.die(new Error("Path escapes the location"))
|
||||
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
|
||||
if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
|
||||
return { absolute, real, ...selected }
|
||||
})
|
||||
const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) {
|
||||
const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
|
||||
if (!real) return
|
||||
if (!FSUtil.contains(selected.root, real)) return
|
||||
const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
|
||||
if (!info) return
|
||||
const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined
|
||||
if (!type) return
|
||||
return new Entry({
|
||||
path: RelativePath.make(path.relative(selected.directory, absolute)),
|
||||
uri: pathToFileURL(real).href,
|
||||
type,
|
||||
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(real),
|
||||
})
|
||||
})
|
||||
|
||||
const scan = Effect.fnUntraced(function* () {
|
||||
if (location.directory === Global.Path.home && location.project.id === "global") {
|
||||
const protectedNames = Protected.names()
|
||||
const nested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||
return (yield* Effect.forEach(
|
||||
yield* fs.readDirectoryEntries(location.directory).pipe(Effect.orElseSucceed(() => [])),
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
if (item.type !== "directory" || item.name.startsWith(".") || protectedNames.has(item.name)) return []
|
||||
const directory = path.join(location.directory, item.name)
|
||||
return [
|
||||
item.name + "/",
|
||||
...(yield* fs.readDirectoryEntries(directory).pipe(Effect.orElseSucceed(() => []))).flatMap((child) =>
|
||||
child.type === "directory" && !child.name.startsWith(".") && !nested.has(child.name)
|
||||
? [`${item.name}/${child.name}/`]
|
||||
: [],
|
||||
),
|
||||
]
|
||||
}),
|
||||
)).flat()
|
||||
}
|
||||
|
||||
const files = Array.from(yield* ripgrep.files({ cwd: location.directory }).pipe(Stream.runCollect, Effect.orDie))
|
||||
const dirs = new Set<string>()
|
||||
for (const file of files) {
|
||||
let current = file
|
||||
while (true) {
|
||||
const directory = path.dirname(current)
|
||||
if (directory === "." || directory === current) break
|
||||
current = directory
|
||||
dirs.add(directory + "/")
|
||||
}
|
||||
}
|
||||
return [...files, ...dirs]
|
||||
})
|
||||
|
||||
const resolveReadPath = Effect.fn("FileSystem.resolveReadPath")(function* (input: ReadInput) {
|
||||
const file = yield* resolve(input.path, input.reference)
|
||||
const info = yield* fs.stat(file.real).pipe(Effect.orDie)
|
||||
const relative = path.relative(file.root, file.real).replaceAll("\\", "/")
|
||||
const resource = input.reference === undefined ? relative || "." : `${input.reference}:${relative || "."}`
|
||||
if (info.type === "File") {
|
||||
return {
|
||||
type: "file" as const,
|
||||
target: new ReadTarget({
|
||||
real: file.real,
|
||||
resource,
|
||||
size: Number(info.size),
|
||||
dev: info.dev,
|
||||
ino: Option.getOrUndefined(info.ino),
|
||||
}),
|
||||
}
|
||||
}
|
||||
if (info.type === "Directory") {
|
||||
return { type: "directory" as const, target: new ListTarget({ ...file, resource }) }
|
||||
}
|
||||
return yield* Effect.die(new Error("Path is not a file or directory"))
|
||||
})
|
||||
const resolveRead = Effect.fn("FileSystem.resolveRead")(function* (input: ReadInput) {
|
||||
const resolved = yield* resolveReadPath(input)
|
||||
if (resolved.type !== "file") return yield* Effect.die(new Error("Path is not a file"))
|
||||
return resolved.target
|
||||
})
|
||||
const content = (target: ReadTarget, bytes: Uint8Array) =>
|
||||
Effect.gen(function* () {
|
||||
const mime = FSUtil.mimeType(target.real)
|
||||
if (!bytes.includes(0)) {
|
||||
const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
|
||||
Effect.option,
|
||||
)
|
||||
if (content._tag === "Some") return new TextContent({ type: "text", content: content.value, mime })
|
||||
}
|
||||
return new BinaryContent({
|
||||
type: "binary",
|
||||
content: Buffer.from(bytes).toString("base64"),
|
||||
encoding: "base64",
|
||||
mime,
|
||||
})
|
||||
})
|
||||
const readResolved = Effect.fn("FileSystem.readResolved")(function* (target: ReadTarget, maximumBytes?: number) {
|
||||
if (maximumBytes === undefined) return yield* content(target, yield* fs.readFile(target.real).pipe(Effect.orDie))
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie)
|
||||
const info = yield* file.stat.pipe(Effect.orDie)
|
||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||
if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
|
||||
return yield* Effect.die(new Error("File changed after permission approval"))
|
||||
if (info.size > maximumBytes)
|
||||
return yield* Effect.die(new Error(`File exceeds ${maximumBytes} byte read limit`))
|
||||
const bytes = yield* file.readAlloc(maximumBytes + 1).pipe(Effect.orDie)
|
||||
if (bytes._tag === "Some" && bytes.value.length > maximumBytes)
|
||||
return yield* Effect.die(new Error(`File exceeds ${maximumBytes} byte read limit`))
|
||||
return yield* content(target, bytes._tag === "Some" ? bytes.value : new Uint8Array())
|
||||
}),
|
||||
)
|
||||
})
|
||||
const readTextPageResolved = Effect.fn("FileSystem.readTextPageResolved")(function* (
|
||||
target: ReadTarget,
|
||||
page: TextPageInput = {},
|
||||
) {
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie)
|
||||
const info = yield* file.stat.pipe(Effect.orDie)
|
||||
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
|
||||
if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
|
||||
return yield* Effect.die(new Error("File changed after permission approval"))
|
||||
|
||||
const offset = page.offset ?? 1
|
||||
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
|
||||
const lines: string[] = []
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true })
|
||||
let pending = ""
|
||||
let discard = false
|
||||
let line = 1
|
||||
let bytes = 0
|
||||
let found = false
|
||||
let truncated = false
|
||||
let next: number | undefined
|
||||
|
||||
const append = (input: string) => {
|
||||
if (line < offset) {
|
||||
line++
|
||||
return true
|
||||
}
|
||||
if (lines.length >= limit) {
|
||||
truncated = true
|
||||
next = line
|
||||
return false
|
||||
}
|
||||
found = true
|
||||
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
|
||||
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
|
||||
if (bytes + size > MAX_READ_BYTES) {
|
||||
truncated = true
|
||||
next = line
|
||||
return false
|
||||
}
|
||||
lines.push(text)
|
||||
bytes += size
|
||||
line++
|
||||
return true
|
||||
}
|
||||
|
||||
let done = false
|
||||
while (!done) {
|
||||
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
|
||||
if (Option.isNone(chunk)) break
|
||||
if (chunk.value.includes(0)) return yield* Effect.die(new Error("Cannot page binary file"))
|
||||
let text = decoder.decode(chunk.value, { stream: true })
|
||||
while (true) {
|
||||
const index = text.indexOf("\n")
|
||||
if (index === -1) {
|
||||
if (!discard) {
|
||||
pending += text
|
||||
if (pending.length > MAX_LINE_LENGTH) {
|
||||
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
|
||||
discard = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
const current = pending + (discard ? "" : text.slice(0, index))
|
||||
pending = ""
|
||||
discard = false
|
||||
text = text.slice(index + 1)
|
||||
if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) {
|
||||
done = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!done) {
|
||||
const tail = decoder.decode()
|
||||
if (!discard) pending += tail
|
||||
if (pending && !append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)) done = true
|
||||
}
|
||||
if (!done && !found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`))
|
||||
|
||||
return new TextPage({
|
||||
type: "text-page",
|
||||
content: lines.join("\n"),
|
||||
mime: FSUtil.mimeType(target.real),
|
||||
offset,
|
||||
truncated,
|
||||
...(next === undefined ? {} : { next }),
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
const resolveList = Effect.fn("FileSystem.resolveList")(function* (input: ListInput = {}) {
|
||||
const directory = yield* resolve(input.path, input.reference)
|
||||
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
|
||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||
const relative = path.relative(directory.root, directory.real).replaceAll("\\", "/") || "."
|
||||
return new ListTarget({
|
||||
...directory,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
})
|
||||
})
|
||||
const resolveRoot = Effect.fn("FileSystem.resolveRoot")(function* (input: ListInput = {}) {
|
||||
const target = yield* resolve(input.path, input.reference)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
|
||||
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
|
||||
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
|
||||
return new RootTarget({
|
||||
...target,
|
||||
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
|
||||
reference: input.reference,
|
||||
type,
|
||||
dev: info.dev,
|
||||
ino: Option.getOrUndefined(info.ino),
|
||||
})
|
||||
})
|
||||
const revalidateRoot = Effect.fn("FileSystem.revalidateRoot")(function* (target: RootTarget) {
|
||||
const canonical = yield* fs.realPath(target.absolute).pipe(Effect.orDie)
|
||||
if (canonical !== target.real) return yield* Effect.die(new Error("Search root changed after approval"))
|
||||
const info = yield* fs.stat(canonical).pipe(Effect.orDie)
|
||||
if (
|
||||
info.type !== (target.type === "file" ? "File" : "Directory") ||
|
||||
info.dev !== target.dev ||
|
||||
Option.getOrUndefined(info.ino) !== target.ino
|
||||
)
|
||||
return yield* Effect.die(new Error("Search root identity changed after approval"))
|
||||
return target
|
||||
})
|
||||
const listResolved = Effect.fn("FileSystem.listResolved")(function* (directory: ListTarget) {
|
||||
return yield* fs.readDirectoryEntries(directory.real).pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((items) =>
|
||||
Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), {
|
||||
concurrency: "unbounded",
|
||||
}),
|
||||
),
|
||||
Effect.map((items) =>
|
||||
items
|
||||
.filter((item): item is Entry => item !== undefined)
|
||||
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
|
||||
),
|
||||
)
|
||||
})
|
||||
const listPageResolved = Effect.fn("FileSystem.listPageResolved")(function* (
|
||||
target: ListTarget,
|
||||
page: Pick<ListPageInput, "offset" | "limit"> = {},
|
||||
) {
|
||||
type Candidate = Entry | { readonly name: string; readonly type: "file" | "directory" }
|
||||
const offset = page.offset ?? 1
|
||||
const limit = Math.min(page.limit ?? 2_000, 2_000)
|
||||
const items = yield* fs.readDirectoryEntries(target.real).pipe(Effect.orDie)
|
||||
const candidates = yield* Effect.forEach(
|
||||
items,
|
||||
(item): Effect.Effect<Candidate | undefined> => {
|
||||
if (item.type === "other") return Effect.succeed(undefined)
|
||||
if (item.type === "symlink") return entry(path.join(target.absolute, item.name), target)
|
||||
return Effect.succeed({ name: item.name, type: item.type } as const)
|
||||
},
|
||||
{ concurrency: 16 },
|
||||
).pipe(Effect.map((items) => items.filter((item): item is Candidate => item !== undefined)))
|
||||
candidates.sort((a, b) => {
|
||||
return a.type === b.type
|
||||
? (a instanceof Entry ? a.path : a.name).localeCompare(b instanceof Entry ? b.path : b.name)
|
||||
: a.type === "directory"
|
||||
? -1
|
||||
: 1
|
||||
})
|
||||
const selected = candidates.slice(offset - 1, offset - 1 + limit)
|
||||
const entries = yield* Effect.forEach(
|
||||
selected,
|
||||
(item) => (item instanceof Entry ? Effect.succeed(item) : entry(path.join(target.absolute, item.name), target)),
|
||||
{
|
||||
concurrency: 16,
|
||||
},
|
||||
).pipe(Effect.map((items) => items.filter((item): item is Entry => item !== undefined)))
|
||||
const truncated = offset - 1 + selected.length < candidates.length
|
||||
return new ListPage({ entries, truncated, ...(truncated ? { next: offset + selected.length } : {}) })
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
read: Effect.fn("FileSystem.read")(function* (input) {
|
||||
return yield* readResolved(yield* resolveRead(input))
|
||||
}),
|
||||
resolveReadPath,
|
||||
resolveRead,
|
||||
readResolved,
|
||||
readTextPageResolved,
|
||||
list: Effect.fn("FileSystem.list")(function* (input) {
|
||||
return yield* listResolved(yield* resolveList(input))
|
||||
}),
|
||||
resolveRoot,
|
||||
revalidateRoot,
|
||||
resolveList,
|
||||
listResolved,
|
||||
listPage: Effect.fn("FileSystem.listPage")(function* (input) {
|
||||
return yield* listPageResolved(yield* resolveList(input), input)
|
||||
}),
|
||||
listPageResolved,
|
||||
find: Effect.fn("FileSystem.find")(function* (input) {
|
||||
const items = (yield* scan()).filter((item) => input.type !== "file" || !item.endsWith("/"))
|
||||
const filtered = items.filter((item) => input.type !== "directory" || item.endsWith("/"))
|
||||
const sorted = input.query.trim()
|
||||
? fuzzysort.go(input.query.trim(), filtered, { limit: input.limit ?? 100 }).map((item) => item.target)
|
||||
: filtered.slice(0, input.limit)
|
||||
return yield* Effect.forEach(sorted, (item) => entry(path.join(location.directory, item))).pipe(
|
||||
Effect.map((items) => items.filter((item): item is Entry => item !== undefined)),
|
||||
)
|
||||
}),
|
||||
grep: Effect.fn("FileSystem.grep")(function* (input) {
|
||||
return (yield* ripgrep
|
||||
.search({
|
||||
cwd: location.directory,
|
||||
pattern: input.pattern,
|
||||
glob: input.include ? [input.include] : undefined,
|
||||
limit: input.limit,
|
||||
})
|
||||
.pipe(Effect.orDie)).items.map(
|
||||
(item) =>
|
||||
new GrepMatch({
|
||||
path: RelativePath.make(item.path.text),
|
||||
lines: item.lines.text,
|
||||
line: item.line_number,
|
||||
offset: item.absolute_offset,
|
||||
submatches: item.submatches.map((submatch) => ({
|
||||
text: submatch.match.text,
|
||||
start: submatch.start,
|
||||
end: submatch.end,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
isIgnored: (input, type) =>
|
||||
ignored.ignores(
|
||||
path.relative(location.project.directory, path.join(location.directory, input)) +
|
||||
(type === "directory" ? "/" : ""),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provideMerge(ProjectReference.locationLayer),
|
||||
)
|
||||
|
|
|
|||
67
packages/core/src/filesystem/ignore.ts
Normal file
67
packages/core/src/filesystem/ignore.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { Glob } from "../util/glob"
|
||||
|
||||
const FOLDERS = new Set([
|
||||
"node_modules",
|
||||
"bower_components",
|
||||
".pnpm-store",
|
||||
"vendor",
|
||||
".npm",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
".next",
|
||||
"target",
|
||||
"bin",
|
||||
"obj",
|
||||
".git",
|
||||
".svn",
|
||||
".hg",
|
||||
".vscode",
|
||||
".idea",
|
||||
".turbo",
|
||||
".output",
|
||||
"desktop",
|
||||
".sst",
|
||||
".cache",
|
||||
".webkit-cache",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
"mypy_cache",
|
||||
".history",
|
||||
".gradle",
|
||||
])
|
||||
|
||||
const FILES = [
|
||||
"**/*.swp",
|
||||
"**/*.swo",
|
||||
"**/*.pyc",
|
||||
"**/.DS_Store",
|
||||
"**/Thumbs.db",
|
||||
"**/logs/**",
|
||||
"**/tmp/**",
|
||||
"**/temp/**",
|
||||
"**/*.log",
|
||||
"**/coverage/**",
|
||||
"**/.nyc_output/**",
|
||||
]
|
||||
|
||||
export const PATTERNS = [...FILES, ...FOLDERS]
|
||||
|
||||
export function match(filepath: string, opts?: { extra?: string[]; whitelist?: string[] }) {
|
||||
for (const pattern of opts?.whitelist || []) {
|
||||
if (Glob.match(pattern, filepath)) return false
|
||||
}
|
||||
|
||||
const parts = filepath.split(/[/\\]/)
|
||||
for (const part of parts) {
|
||||
if (FOLDERS.has(part)) return true
|
||||
}
|
||||
|
||||
for (const pattern of [...FILES, ...(opts?.extra || [])]) {
|
||||
if (Glob.match(pattern, filepath)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export * as Ignore from "./ignore"
|
||||
53
packages/core/src/filesystem/protected.ts
Normal file
53
packages/core/src/filesystem/protected.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
const home = os.homedir()
|
||||
|
||||
const DARWIN_HOME = [
|
||||
"Music",
|
||||
"Pictures",
|
||||
"Movies",
|
||||
"Downloads",
|
||||
"Desktop",
|
||||
"Documents",
|
||||
"Public",
|
||||
"Applications",
|
||||
"Library",
|
||||
]
|
||||
|
||||
const DARWIN_LIBRARY = [
|
||||
"Application Support/AddressBook",
|
||||
"Calendars",
|
||||
"Mail",
|
||||
"Messages",
|
||||
"Safari",
|
||||
"Cookies",
|
||||
"Application Support/com.apple.TCC",
|
||||
"PersonalizationPortrait",
|
||||
"Metadata/CoreSpotlight",
|
||||
"Suggestions",
|
||||
]
|
||||
|
||||
const DARWIN_ROOT = ["/.DocumentRevisions-V100", "/.Spotlight-V100", "/.Trashes", "/.fseventsd"]
|
||||
const WIN32_HOME = ["AppData", "Downloads", "Desktop", "Documents", "Pictures", "Music", "Videos", "OneDrive"]
|
||||
|
||||
/** Directory basenames to skip when scanning the home directory. */
|
||||
export function names(): ReadonlySet<string> {
|
||||
if (process.platform === "darwin") return new Set(DARWIN_HOME)
|
||||
if (process.platform === "win32") return new Set(WIN32_HOME)
|
||||
return new Set()
|
||||
}
|
||||
|
||||
/** Absolute paths that should never be watched, stated, or scanned. */
|
||||
export function paths(): string[] {
|
||||
if (process.platform === "darwin")
|
||||
return [
|
||||
...DARWIN_HOME.map((name) => path.join(home, name)),
|
||||
...DARWIN_LIBRARY.map((name) => path.join(home, "Library", name)),
|
||||
...DARWIN_ROOT,
|
||||
]
|
||||
if (process.platform === "win32") return WIN32_HOME.map((name) => path.join(home, name))
|
||||
return []
|
||||
}
|
||||
|
||||
export * as Protected from "./protected"
|
||||
485
packages/core/src/filesystem/ripgrep.ts
Normal file
485
packages/core/src/filesystem/ripgrep.ts
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
import path from "path"
|
||||
import { serviceUse } from "../effect/service-use"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
|
||||
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
|
||||
import { Global } from "../global"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
import * as Log from "../util/log"
|
||||
import { sanitizedProcessEnv } from "../util/opencode-process"
|
||||
import { which } from "../util/which"
|
||||
|
||||
const log = Log.create({ service: "ripgrep" })
|
||||
const VERSION = "15.1.0"
|
||||
const PLATFORM = {
|
||||
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
|
||||
"arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
|
||||
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
|
||||
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
|
||||
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
|
||||
"ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" },
|
||||
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
|
||||
} as const
|
||||
|
||||
const TimeStats = Schema.Struct({
|
||||
secs: NonNegativeInt,
|
||||
nanos: NonNegativeInt,
|
||||
human: Schema.String,
|
||||
})
|
||||
|
||||
const Stats = Schema.Struct({
|
||||
elapsed: TimeStats,
|
||||
searches: NonNegativeInt,
|
||||
searches_with_match: NonNegativeInt,
|
||||
bytes_searched: NonNegativeInt,
|
||||
bytes_printed: NonNegativeInt,
|
||||
matched_lines: NonNegativeInt,
|
||||
matches: NonNegativeInt,
|
||||
})
|
||||
|
||||
const PathText = Schema.Struct({
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
const Begin = Schema.Struct({
|
||||
type: Schema.Literal("begin"),
|
||||
data: Schema.Struct({
|
||||
path: PathText,
|
||||
}),
|
||||
})
|
||||
|
||||
export const SearchMatch = Schema.Struct({
|
||||
path: PathText,
|
||||
lines: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
line_number: NonNegativeInt,
|
||||
absolute_offset: NonNegativeInt,
|
||||
submatches: Schema.Array(
|
||||
Schema.Struct({
|
||||
match: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const Match = Schema.Struct({
|
||||
type: Schema.Literal("match"),
|
||||
data: SearchMatch,
|
||||
})
|
||||
|
||||
const End = Schema.Struct({
|
||||
type: Schema.Literal("end"),
|
||||
data: Schema.Struct({
|
||||
path: PathText,
|
||||
binary_offset: Schema.NullOr(NonNegativeInt),
|
||||
stats: Stats,
|
||||
}),
|
||||
})
|
||||
|
||||
const Summary = Schema.Struct({
|
||||
type: Schema.Literal("summary"),
|
||||
data: Schema.Struct({
|
||||
elapsed_total: TimeStats,
|
||||
stats: Stats,
|
||||
}),
|
||||
})
|
||||
|
||||
const Result = Schema.Union([Begin, Match, End, Summary])
|
||||
const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result))
|
||||
|
||||
export type Result = Schema.Schema.Type<typeof Result>
|
||||
export type Match = Schema.Schema.Type<typeof Match>
|
||||
export type Item = Match["data"]
|
||||
export type Begin = Schema.Schema.Type<typeof Begin>
|
||||
export type End = Schema.Schema.Type<typeof End>
|
||||
export type Summary = Schema.Schema.Type<typeof Summary>
|
||||
export type Row = Match["data"]
|
||||
|
||||
export interface SearchResult {
|
||||
items: Item[]
|
||||
partial: boolean
|
||||
}
|
||||
|
||||
export interface FilesInput {
|
||||
cwd: string
|
||||
glob?: string[]
|
||||
hidden?: boolean
|
||||
follow?: boolean
|
||||
maxDepth?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface SearchInput {
|
||||
cwd: string
|
||||
pattern: string
|
||||
glob?: string[]
|
||||
limit?: number
|
||||
follow?: boolean
|
||||
file?: string[]
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface TreeInput {
|
||||
cwd: string
|
||||
limit?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly filepath: Effect.Effect<string, Error>
|
||||
readonly files: (input: FilesInput) => Stream.Stream<string, PlatformError | Error>
|
||||
readonly tree: (input: TreeInput) => Effect.Effect<string, PlatformError | Error>
|
||||
readonly search: (input: SearchInput) => Effect.Effect<SearchResult, PlatformError | Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
function env() {
|
||||
const env = sanitizedProcessEnv()
|
||||
delete env.RIPGREP_CONFIG_PATH
|
||||
return env
|
||||
}
|
||||
|
||||
function aborted(signal?: AbortSignal) {
|
||||
const err = signal?.reason
|
||||
if (err instanceof Error) return err
|
||||
const out = new Error("Aborted")
|
||||
out.name = "AbortError"
|
||||
return out
|
||||
}
|
||||
|
||||
function waitForAbort(signal?: AbortSignal) {
|
||||
if (!signal) return Effect.never
|
||||
if (signal.aborted) return Effect.fail(aborted(signal))
|
||||
return Effect.callback<never, Error>((resume) => {
|
||||
const onabort = () => resume(Effect.fail(aborted(signal)))
|
||||
signal.addEventListener("abort", onabort, { once: true })
|
||||
return Effect.sync(() => signal.removeEventListener("abort", onabort))
|
||||
})
|
||||
}
|
||||
|
||||
function error(stderr: string, code: number) {
|
||||
const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`)
|
||||
err.name = "RipgrepError"
|
||||
return err
|
||||
}
|
||||
|
||||
function clean(file: string) {
|
||||
return path.normalize(file.replace(/^\.[\\/]/, ""))
|
||||
}
|
||||
|
||||
function row(data: Row): Row {
|
||||
return {
|
||||
...data,
|
||||
path: {
|
||||
...data.path,
|
||||
text: clean(data.path.text),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function parse(line: string) {
|
||||
return decodeResult(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause })))
|
||||
}
|
||||
|
||||
function fail(queue: Queue.Queue<string, PlatformError | Error | Cause.Done>, err: PlatformError | Error) {
|
||||
Queue.failCauseUnsafe(queue, Cause.fail(err))
|
||||
}
|
||||
|
||||
function filesArgs(input: FilesInput) {
|
||||
const args = ["--no-config", "--files", "--glob=!.git/*"]
|
||||
if (input.follow) args.push("--follow")
|
||||
if (input.hidden !== false) args.push("--hidden")
|
||||
if (input.hidden === false) args.push("--glob=!.*")
|
||||
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
|
||||
if (input.glob) {
|
||||
for (const glob of input.glob) args.push(`--glob=${glob}`)
|
||||
}
|
||||
args.push(".")
|
||||
return args
|
||||
}
|
||||
|
||||
function searchArgs(input: SearchInput) {
|
||||
const args = ["--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"]
|
||||
if (input.follow) args.push("--follow")
|
||||
if (input.glob) {
|
||||
for (const glob of input.glob) args.push(`--glob=${glob}`)
|
||||
}
|
||||
if (input.limit) args.push(`--max-count=${input.limit}`)
|
||||
args.push("--", input.pattern, ...(input.file ?? ["."]))
|
||||
return args
|
||||
}
|
||||
|
||||
function raceAbort<A, E, R>(effect: Effect.Effect<A, E, R>, signal?: AbortSignal) {
|
||||
return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect
|
||||
}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpawner | HttpClient.HttpClient> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string, args: string[], opts?: { cwd?: string }) {
|
||||
const handle = yield* spawner.spawn(
|
||||
ChildProcess.make(command, args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
|
||||
)
|
||||
const [stdout, stderr, code] = yield* Effect.all(
|
||||
[
|
||||
Stream.mkString(Stream.decodeText(handle.stdout)),
|
||||
Stream.mkString(Stream.decodeText(handle.stderr)),
|
||||
handle.exitCode,
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return { stdout, stderr, code }
|
||||
}, Effect.scoped)
|
||||
|
||||
const extract = Effect.fnUntraced(function* (
|
||||
archive: string,
|
||||
config: (typeof PLATFORM)[keyof typeof PLATFORM],
|
||||
target: string,
|
||||
) {
|
||||
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
|
||||
|
||||
if (config.extension === "zip") {
|
||||
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
|
||||
const result = yield* run(shell, [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
`$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`,
|
||||
])
|
||||
if (result.code !== 0) {
|
||||
return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
|
||||
}
|
||||
}
|
||||
|
||||
if (config.extension === "tar.gz") {
|
||||
const result = yield* run("tar", ["-xzf", archive, "-C", dir])
|
||||
if (result.code !== 0) {
|
||||
return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
|
||||
}
|
||||
}
|
||||
|
||||
const extracted = path.join(
|
||||
dir,
|
||||
`ripgrep-${VERSION}-${config.platform}`,
|
||||
process.platform === "win32" ? "rg.exe" : "rg",
|
||||
)
|
||||
if (!(yield* fs.isFile(extracted))) {
|
||||
return yield* Effect.fail(new Error(`ripgrep archive did not contain executable: ${extracted}`))
|
||||
}
|
||||
|
||||
yield* fs.copyFile(extracted, target)
|
||||
if (process.platform === "win32") return
|
||||
yield* fs.chmod(target, 0o755)
|
||||
}, Effect.scoped)
|
||||
|
||||
const filepath = yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
|
||||
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
|
||||
|
||||
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
|
||||
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
|
||||
|
||||
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
||||
const config = PLATFORM[platformKey]
|
||||
if (!config) {
|
||||
return yield* Effect.fail(new Error(`unsupported platform for ripgrep: ${platformKey}`))
|
||||
}
|
||||
|
||||
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
|
||||
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
|
||||
const archive = path.join(Global.Path.bin, filename)
|
||||
|
||||
log.info("downloading ripgrep", { url })
|
||||
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
|
||||
|
||||
const bytes = yield* HttpClientRequest.get(url).pipe(
|
||||
http.execute,
|
||||
Effect.flatMap((response) => response.arrayBuffer),
|
||||
Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
|
||||
)
|
||||
if (bytes.byteLength === 0) {
|
||||
return yield* Effect.fail(new Error(`failed to download ripgrep from ${url}`))
|
||||
}
|
||||
|
||||
yield* fs.writeWithDirs(archive, new Uint8Array(bytes))
|
||||
yield* extract(archive, config, target)
|
||||
yield* fs.remove(archive, { force: true }).pipe(Effect.ignore)
|
||||
return target
|
||||
}),
|
||||
)
|
||||
|
||||
const check = Effect.fnUntraced(function* (cwd: string) {
|
||||
if (yield* fs.isDir(cwd).pipe(Effect.orDie)) return
|
||||
return yield* Effect.fail(
|
||||
Object.assign(new Error(`No such file or directory: '${cwd}'`), {
|
||||
code: "ENOENT",
|
||||
errno: -2,
|
||||
path: cwd,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const command = Effect.fnUntraced(function* (cwd: string, args: string[]) {
|
||||
const binary = yield* filepath
|
||||
return ChildProcess.make(binary, args, {
|
||||
cwd,
|
||||
env: env(),
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
})
|
||||
})
|
||||
|
||||
const files: Interface["files"] = (input) =>
|
||||
Stream.callback<string, PlatformError | Error>((queue) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.forkScoped(
|
||||
Effect.gen(function* () {
|
||||
yield* check(input.cwd)
|
||||
const handle = yield* spawner.spawn(yield* command(input.cwd, filesArgs(input)))
|
||||
const stderr = yield* Stream.mkString(Stream.decodeText(handle.stderr)).pipe(Effect.forkScoped)
|
||||
const stdout = yield* Stream.decodeText(handle.stdout).pipe(
|
||||
Stream.splitLines,
|
||||
Stream.filter((line) => line.length > 0),
|
||||
Stream.runForEach((line) => Effect.sync(() => Queue.offerUnsafe(queue, clean(line)))),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const code = yield* raceAbort(handle.exitCode, input.signal)
|
||||
yield* Fiber.join(stdout)
|
||||
if (code === 0 || code === 1) {
|
||||
Queue.endUnsafe(queue)
|
||||
return
|
||||
}
|
||||
fail(queue, error(yield* Fiber.join(stderr), code))
|
||||
}).pipe(
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
fail(queue, err)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
|
||||
yield* check(input.cwd)
|
||||
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(yield* command(input.cwd, searchArgs(input)))
|
||||
|
||||
const [items, stderr, code] = yield* Effect.all(
|
||||
[
|
||||
Stream.decodeText(handle.stdout).pipe(
|
||||
Stream.splitLines,
|
||||
Stream.filter((line) => line.length > 0),
|
||||
Stream.mapEffect(parse),
|
||||
Stream.filter((item): item is Match => item.type === "match"),
|
||||
Stream.map((item) => row(item.data)),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
),
|
||||
Stream.mkString(Stream.decodeText(handle.stderr)),
|
||||
handle.exitCode,
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
if (code !== 0 && code !== 1 && code !== 2) {
|
||||
return yield* Effect.fail(error(stderr, code))
|
||||
}
|
||||
|
||||
return {
|
||||
items: code === 1 ? [] : items,
|
||||
partial: code === 2,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return yield* raceAbort(program, input.signal)
|
||||
})
|
||||
|
||||
const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
|
||||
log.info("tree", input)
|
||||
const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
|
||||
|
||||
interface Node {
|
||||
name: string
|
||||
children: Map<string, Node>
|
||||
}
|
||||
|
||||
function child(node: Node, name: string) {
|
||||
const item = node.children.get(name)
|
||||
if (item) return item
|
||||
const next = { name, children: new Map() }
|
||||
node.children.set(name, next)
|
||||
return next
|
||||
}
|
||||
|
||||
function count(node: Node): number {
|
||||
return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
|
||||
}
|
||||
|
||||
const root: Node = { name: "", children: new Map() }
|
||||
for (const file of list) {
|
||||
if (file.includes(".opencode")) continue
|
||||
const parts = file.split(path.sep)
|
||||
if (parts.length < 2) continue
|
||||
let node = root
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
node = child(node, part)
|
||||
}
|
||||
}
|
||||
|
||||
const total = count(root)
|
||||
const limit = input.limit ?? total
|
||||
const lines: string[] = []
|
||||
const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values())
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((node) => ({ node, path: node.name }))
|
||||
|
||||
let used = 0
|
||||
for (let i = 0; i < queue.length && used < limit; i++) {
|
||||
const item = queue[i]
|
||||
lines.push(item.path)
|
||||
used++
|
||||
queue.push(
|
||||
...Array.from(item.node.children.values())
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((node) => ({ node, path: `${item.path}/${node.name}` })),
|
||||
)
|
||||
}
|
||||
|
||||
if (total > used) lines.push(`[${total - used} truncated]`)
|
||||
return lines.join("\n")
|
||||
})
|
||||
|
||||
return Service.of({ filepath, files, tree, search })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
export * as Ripgrep from "./ripgrep"
|
||||
142
packages/core/src/filesystem/watcher.ts
Normal file
142
packages/core/src/filesystem/watcher.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
export * as Watcher from "./watcher"
|
||||
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { Cause, Context, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Config } from "../config"
|
||||
import { EventV2 } from "../event"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
import { lazy } from "../util/lazy"
|
||||
import * as Log from "../util/log"
|
||||
import { Ignore } from "./ignore"
|
||||
import { Protected } from "./protected"
|
||||
|
||||
declare const OPENCODE_LIBC: string | undefined
|
||||
|
||||
const log = Log.create({ service: "file.watcher" })
|
||||
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
||||
|
||||
export const Event = {
|
||||
Updated: EventV2.define({
|
||||
type: "file.watcher.updated",
|
||||
schema: {
|
||||
file: Schema.String,
|
||||
event: Schema.Literals(["add", "change", "unlink"]),
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
try {
|
||||
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
|
||||
const binding = require(
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
|
||||
)
|
||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||
} catch (error) {
|
||||
log.error("failed to load watcher binding", { error })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
function getBackend() {
|
||||
if (process.platform === "win32") return "windows"
|
||||
if (process.platform === "darwin") return "fs-events"
|
||||
if (process.platform === "linux") return "inotify"
|
||||
}
|
||||
|
||||
function protecteds(dir: string) {
|
||||
return Protected.paths().filter((item) => {
|
||||
const relative = path.relative(dir, item)
|
||||
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
|
||||
})
|
||||
}
|
||||
|
||||
export const hasNativeBinding = () => !!watcher()
|
||||
|
||||
export interface Interface {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileWatcher") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
|
||||
|
||||
const backend = getBackend()
|
||||
const location = yield* Location.Service
|
||||
if (!backend) {
|
||||
log.error("watcher backend not supported", { directory: location.directory, platform: process.platform })
|
||||
return Service.of({})
|
||||
}
|
||||
|
||||
const w = watcher()
|
||||
if (!w) return Service.of({})
|
||||
|
||||
log.info("watcher backend", { directory: location.directory, platform: process.platform, backend })
|
||||
const events = yield* EventV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const subscriptions: ParcelWatcher.AsyncSubscription[] = []
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))),
|
||||
)
|
||||
|
||||
const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => {
|
||||
for (const update of updates) {
|
||||
if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" }))
|
||||
if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" }))
|
||||
if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" }))
|
||||
}
|
||||
}
|
||||
|
||||
const subscribe = (directory: string, ignore: string[]) => {
|
||||
const pending = w.subscribe(directory, callback, { ignore, backend })
|
||||
return Effect.promise(() => pending).pipe(
|
||||
Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))),
|
||||
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to subscribe", { directory, cause: Cause.pretty(cause) })
|
||||
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const config = (yield* (yield* Config.Service).entries())
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
yield* Effect.forkScoped(
|
||||
subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]),
|
||||
)
|
||||
}
|
||||
|
||||
if (location.vcs?.type === "git") {
|
||||
const resolved = yield* git.dir(location.directory)
|
||||
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
|
||||
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
|
||||
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
|
||||
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
|
||||
)
|
||||
yield* Effect.forkScoped(subscribe(vcs, ignore))
|
||||
}
|
||||
}
|
||||
|
||||
return Service.of({})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
||||
return Effect.succeed(Service.of({}))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
import { Config } from "effect"
|
||||
|
||||
function truthy(key: string) {
|
||||
export function truthy(key: string) {
|
||||
const value = process.env[key]?.toLowerCase()
|
||||
return value === "true" || value === "1"
|
||||
}
|
||||
|
||||
const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
|
||||
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
|
||||
|
||||
function enabledByExperimental(key: string) {
|
||||
return process.env[key] === undefined ? OPENCODE_EXPERIMENTAL : truthy(key)
|
||||
return process.env[key] === undefined ? truthy("OPENCODE_EXPERIMENTAL") : truthy(key)
|
||||
}
|
||||
|
||||
export const Flag = {
|
||||
|
|
@ -54,6 +53,9 @@ export const Flag = {
|
|||
get OPENCODE_DISABLE_PROJECT_CONFIG() {
|
||||
return truthy("OPENCODE_DISABLE_PROJECT_CONFIG")
|
||||
},
|
||||
get OPENCODE_EXPERIMENTAL_REFERENCES() {
|
||||
return enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES")
|
||||
},
|
||||
get OPENCODE_TUI_CONFIG() {
|
||||
return process.env["OPENCODE_TUI_CONFIG"]
|
||||
},
|
||||
|
|
|
|||
246
packages/core/src/fs-util.ts
Normal file
246
packages/core/src/fs-util.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { dirname, isAbsolute, join, relative, resolve as pathResolve, sep } from "path"
|
||||
import { realpathSync } from "fs"
|
||||
import * as NFS from "fs/promises"
|
||||
import { lookup } from "mime-types"
|
||||
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { Glob } from "./util/glob"
|
||||
import { serviceUse } from "./effect/service-use"
|
||||
|
||||
export namespace FSUtil {
|
||||
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||
method: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export type Error = PlatformError | FileSystemError
|
||||
|
||||
export interface DirEntry {
|
||||
readonly name: string
|
||||
readonly type: "file" | "directory" | "symlink" | "other"
|
||||
}
|
||||
|
||||
export interface Interface extends FileSystem.FileSystem {
|
||||
readonly isDir: (path: string) => Effect.Effect<boolean>
|
||||
readonly isFile: (path: string) => Effect.Effect<boolean>
|
||||
readonly existsSafe: (path: string) => Effect.Effect<boolean>
|
||||
readonly readFileStringSafe: (path: string) => Effect.Effect<string | undefined, Error>
|
||||
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
|
||||
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
|
||||
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
|
||||
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
|
||||
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
|
||||
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
|
||||
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||
readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect<string[], Error>
|
||||
readonly globMatch: (pattern: string, filepath: string) => boolean
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
|
||||
const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
|
||||
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
|
||||
})
|
||||
|
||||
const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
|
||||
return yield* fs
|
||||
.readFileString(path)
|
||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
})
|
||||
|
||||
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
|
||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||
return info?.type === "Directory"
|
||||
})
|
||||
|
||||
const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
|
||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||
return info?.type === "File"
|
||||
})
|
||||
|
||||
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
|
||||
return entries.map(
|
||||
(e): DirEntry => ({
|
||||
name: e.name,
|
||||
type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
|
||||
}),
|
||||
)
|
||||
},
|
||||
catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
|
||||
const text = yield* fs.readFileString(path)
|
||||
return JSON.parse(text)
|
||||
})
|
||||
|
||||
const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
|
||||
const content = JSON.stringify(data, null, 2)
|
||||
yield* fs.writeFileString(path, content)
|
||||
if (mode) yield* fs.chmod(path, mode)
|
||||
})
|
||||
|
||||
const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
|
||||
yield* fs.makeDirectory(path, { recursive: true })
|
||||
})
|
||||
|
||||
const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
|
||||
path: string,
|
||||
content: string | Uint8Array,
|
||||
mode?: number,
|
||||
) {
|
||||
const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
|
||||
|
||||
yield* write.pipe(
|
||||
Effect.catchIf(
|
||||
(e) => e.reason._tag === "NotFound",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* fs.makeDirectory(dirname(path), { recursive: true })
|
||||
yield* write
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (mode) yield* fs.chmod(path, mode)
|
||||
})
|
||||
|
||||
const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => Glob.scan(pattern, options),
|
||||
catch: (cause) => new FileSystemError({ method: "glob", cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
|
||||
const result: string[] = []
|
||||
let current = start
|
||||
while (true) {
|
||||
const search = join(current, target)
|
||||
if (yield* fs.exists(search)) result.push(search)
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
|
||||
const result: string[] = []
|
||||
let current = options.start
|
||||
while (true) {
|
||||
for (const target of options.targets) {
|
||||
const search = join(current, target)
|
||||
if (yield* fs.exists(search)) result.push(search)
|
||||
}
|
||||
if (options.stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
|
||||
const result: string[] = []
|
||||
let current = start
|
||||
while (true) {
|
||||
const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
|
||||
Effect.catch(() => Effect.succeed([] as string[])),
|
||||
)
|
||||
result.push(...matches)
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
...fs,
|
||||
existsSafe,
|
||||
readFileStringSafe,
|
||||
isDir,
|
||||
isFile,
|
||||
readDirectoryEntries,
|
||||
readJson,
|
||||
writeJson,
|
||||
ensureDir,
|
||||
writeWithDirs,
|
||||
findUp,
|
||||
up,
|
||||
globUp,
|
||||
glob,
|
||||
globMatch: Glob.match,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
||||
|
||||
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
||||
export function mimeType(p: string): string {
|
||||
return lookup(p) || "application/octet-stream"
|
||||
}
|
||||
|
||||
export function normalizePath(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
const resolved = pathResolve(windowsPath(p))
|
||||
try {
|
||||
return realpathSync.native(resolved)
|
||||
} catch {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePathPattern(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
if (p === "*") return p
|
||||
const match = p.match(/^(.*)[\\/]\*$/)
|
||||
if (!match) return normalizePath(p)
|
||||
const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1]
|
||||
return join(normalizePath(dir), "*")
|
||||
}
|
||||
|
||||
export function resolve(p: string): string {
|
||||
const resolved = pathResolve(windowsPath(p))
|
||||
try {
|
||||
return normalizePath(realpathSync(resolved))
|
||||
} catch (e: any) {
|
||||
if (e?.code === "ENOENT") return normalizePath(resolved)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export function windowsPath(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
return p
|
||||
.replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
.replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
.replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
.replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
|
||||
}
|
||||
|
||||
export function overlaps(a: string, b: string) {
|
||||
return contains(a, b) || contains(b, a)
|
||||
}
|
||||
|
||||
export function contains(parent: string, child: string) {
|
||||
const result = relative(parent, child)
|
||||
return result === "" || (!isAbsolute(result) && result !== ".." && !result.startsWith(`..${sep}`))
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
export * as Git from "./git"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { AppProcess } from "./process"
|
||||
|
||||
export interface Repo {
|
||||
|
|
@ -26,10 +26,35 @@ export interface Repo {
|
|||
readonly store: AbsolutePath
|
||||
}
|
||||
|
||||
export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git.WorktreeError", {
|
||||
operation: Schema.Literals(["create", "remove", "list"]),
|
||||
message: Schema.String,
|
||||
directory: Schema.optional(AbsolutePath),
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
|
||||
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
|
||||
readonly roots: (repo: Repo) => Effect.Effect<string[]>
|
||||
readonly origin: (directory: string) => Effect.Effect<string | undefined>
|
||||
readonly head: (directory: string) => Effect.Effect<string | undefined>
|
||||
readonly dir: (directory: string) => Effect.Effect<string | undefined>
|
||||
readonly branch: (directory: string) => Effect.Effect<string | undefined>
|
||||
readonly remoteHead: (directory: string) => Effect.Effect<string | undefined>
|
||||
readonly clone: (input: {
|
||||
remote: string
|
||||
target: string
|
||||
branch?: string
|
||||
depth?: number
|
||||
}) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly fetch: (directory: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly checkout: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly reset: (directory: string, target: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
|
||||
readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
|
||||
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
|
||||
|
|
@ -37,7 +62,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Gi
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const proc = yield* AppProcess.Service
|
||||
|
||||
const find = Effect.fn("Git.find")(function* (input: AbsolutePath) {
|
||||
|
|
@ -75,21 +100,143 @@ export const layer = Layer.effect(
|
|||
.toSorted()
|
||||
})
|
||||
|
||||
return Service.of({ find, remote, roots })
|
||||
const origin = Effect.fn("Git.origin")(function* (directory: string) {
|
||||
const result = yield* run(directory, proc)(["config", "--get", "remote.origin.url"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const head = Effect.fn("Git.head")(function* (directory: string) {
|
||||
const result = yield* run(directory, proc)(["rev-parse", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const dir = Effect.fn("Git.dir")(function* (directory: string) {
|
||||
const result = yield* run(directory, proc)(["rev-parse", "--git-dir"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return AbsolutePath.make(resolvePath(directory, result.text))
|
||||
})
|
||||
|
||||
const branch = Effect.fn("Git.branch")(function* (directory: string) {
|
||||
const result = yield* run(directory, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const remoteHead = Effect.fn("Git.remoteHead")(function* (directory: string) {
|
||||
const result = yield* run(directory, proc)(["symbolic-ref", "refs/remotes/origin/HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim().replace(/^refs\/remotes\//, "") || undefined
|
||||
})
|
||||
|
||||
const clone = Effect.fn("Git.clone")((input: { remote: string; target: string; branch?: string; depth?: number }) =>
|
||||
execute(
|
||||
path.dirname(input.target),
|
||||
proc,
|
||||
)([
|
||||
"clone",
|
||||
"--depth",
|
||||
String(input.depth ?? 100),
|
||||
...(input.branch ? ["--branch", input.branch] : []),
|
||||
"--",
|
||||
input.remote,
|
||||
input.target,
|
||||
]),
|
||||
)
|
||||
|
||||
const fetch = Effect.fn("Git.fetch")((directory: string) => execute(directory, proc)(["fetch", "--all", "--prune"]))
|
||||
|
||||
const fetchBranch = Effect.fn("Git.fetchBranch")((directory: string, branch: string) =>
|
||||
execute(directory, proc)(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]),
|
||||
)
|
||||
|
||||
const checkout = Effect.fn("Git.checkout")((directory: string, branch: string) =>
|
||||
execute(directory, proc)(["checkout", "-B", branch, `origin/${branch}`]),
|
||||
)
|
||||
|
||||
const reset = Effect.fn("Git.reset")((directory: string, target: string) =>
|
||||
execute(directory, proc)(["reset", "--hard", target]),
|
||||
)
|
||||
|
||||
const worktree = Effect.fnUntraced(function* (
|
||||
operation: "create" | "remove" | "list",
|
||||
repo: Repo,
|
||||
args: string[],
|
||||
worktreeDirectory?: AbsolutePath,
|
||||
cwd = repo.directory,
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (result.exitCode === 0) return result.stdout.toString("utf8")
|
||||
return yield* new WorktreeError({
|
||||
operation,
|
||||
directory: worktreeDirectory,
|
||||
message: result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed",
|
||||
})
|
||||
})
|
||||
|
||||
const worktreeCreate = Effect.fn("Git.worktreeCreate")(function* (input: { repo: Repo; directory: AbsolutePath }) {
|
||||
yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.directory, "HEAD"], input.directory)
|
||||
})
|
||||
|
||||
const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: { repo: Repo; directory: AbsolutePath }) {
|
||||
yield* worktree(
|
||||
"remove",
|
||||
input.repo,
|
||||
["worktree", "remove", "--force", input.directory],
|
||||
input.directory,
|
||||
input.repo.store,
|
||||
)
|
||||
})
|
||||
|
||||
const worktreeList = Effect.fn("Git.worktreeList")(function* (repo: Repo) {
|
||||
return (yield* worktree("list", repo, ["worktree", "list", "--porcelain"]))
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("worktree "))
|
||||
.map((line) => AbsolutePath.make(resolvePath(repo.directory, line.slice("worktree ".length).trim())))
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
find,
|
||||
remote,
|
||||
roots,
|
||||
origin,
|
||||
head,
|
||||
dir,
|
||||
branch,
|
||||
remoteHead,
|
||||
clone,
|
||||
fetch,
|
||||
fetchBranch,
|
||||
checkout,
|
||||
reset,
|
||||
worktreeCreate,
|
||||
worktreeRemove,
|
||||
worktreeList,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
|
||||
|
||||
interface Result {
|
||||
export interface Result {
|
||||
readonly exitCode: number
|
||||
readonly text: string
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
function run(cwd: string, proc: AppProcess.Interface) {
|
||||
return (args: string[]) =>
|
||||
execute(cwd, proc)(args).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, text: "", stderr: "" })))
|
||||
}
|
||||
|
||||
function execute(cwd: string, proc: AppProcess.Interface) {
|
||||
return (args: string[]) =>
|
||||
proc
|
||||
.run(
|
||||
|
|
@ -100,15 +247,21 @@ function run(cwd: string, proc: AppProcess.Interface) {
|
|||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => ({ exitCode: result.exitCode, text: result.stdout.toString("utf8") }) satisfies Result),
|
||||
Effect.catch(() => Effect.succeed({ exitCode: 1, text: "" } satisfies Result)),
|
||||
Effect.map(
|
||||
(result) =>
|
||||
({
|
||||
exitCode: result.exitCode,
|
||||
text: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
}) satisfies Result,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, value: string) {
|
||||
const trimmed = value.replace(/[\r\n]+$/, "")
|
||||
if (!trimmed) return cwd
|
||||
const normalized = AppFileSystem.windowsPath(trimmed)
|
||||
const normalized = FSUtil.windowsPath(trimmed)
|
||||
if (path.isAbsolute(normalized)) return path.normalize(normalized)
|
||||
return path.resolve(cwd, normalized)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Policy } from "./policy"
|
|||
import { Config } from "./config"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { Catalog } from "./catalog"
|
||||
import { CommandV2 } from "./command"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { PluginBoot } from "./plugin/boot"
|
||||
import { Project } from "./project"
|
||||
|
|
@ -11,26 +12,84 @@ import { EventV2 } from "./event"
|
|||
import { Auth } from "./auth"
|
||||
import { Npm } from "./npm"
|
||||
import { ModelsDev } from "./models-dev"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { Database } from "./database/database"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { PermissionSaved } from "./permission/saved"
|
||||
import { SessionV2 } from "./session"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { LocationMutation } from "./location-mutation"
|
||||
import { LocationSearch } from "./location-search"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { ProjectReference } from "./project-reference"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
import { Pty } from "./pty"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { BuiltInTools } from "./tool/builtins"
|
||||
import { ToolRegistry } from "./tool-registry"
|
||||
import { ToolOutputStore } from "./tool-output-store"
|
||||
import { AppProcess } from "./process"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionTodo } from "./session/todo"
|
||||
import { QuestionV2 } from "./question"
|
||||
import { LLMClient } from "@opencode-ai/llm"
|
||||
import { RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SessionRunCoordinator } from "./session/run-coordinator"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
|
||||
lookup: (ref: Location.Ref) => {
|
||||
const location = Location.layer(ref)
|
||||
return Layer.mergeAll(
|
||||
const permissionsAndTools = ToolRegistry.layer.pipe(Layer.provideMerge(PermissionV2.locationLayer))
|
||||
const services = Layer.mergeAll(
|
||||
location,
|
||||
Policy.locationLayer,
|
||||
Config.locationLayer,
|
||||
ProjectReference.locationLayer,
|
||||
PluginV2.locationLayer,
|
||||
Catalog.locationLayer,
|
||||
CommandV2.locationLayer,
|
||||
AgentV2.locationLayer,
|
||||
PluginBoot.locationLayer,
|
||||
PermissionV2.locationLayer,
|
||||
).pipe(Layer.provideMerge(location), Layer.fresh)
|
||||
FileSystem.locationLayer,
|
||||
Watcher.locationLayer,
|
||||
Pty.locationLayer,
|
||||
SkillV2.locationLayer,
|
||||
permissionsAndTools,
|
||||
LocationMutation.locationLayer.pipe(Layer.orDie),
|
||||
).pipe(Layer.provideMerge(location))
|
||||
const commits = FileMutation.locationLayer.pipe(Layer.provide(services))
|
||||
const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
|
||||
const resources = ToolOutputStore.layer.pipe(Layer.provide(services))
|
||||
const todos = SessionTodo.layer.pipe(Layer.provide(services))
|
||||
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
|
||||
const builtInTools = BuiltInTools.locationLayer.pipe(
|
||||
Layer.provide(services),
|
||||
Layer.provide(commits),
|
||||
Layer.provide(searches),
|
||||
Layer.provide(resources),
|
||||
Layer.provide(todos),
|
||||
Layer.provide(questions),
|
||||
)
|
||||
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
|
||||
const runner = SessionRunnerLLM.defaultLayer.pipe(Layer.provide(services), Layer.provide(model))
|
||||
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
|
||||
return Layer.mergeAll(
|
||||
services,
|
||||
commits,
|
||||
searches,
|
||||
resources,
|
||||
todos,
|
||||
questions,
|
||||
model,
|
||||
runner,
|
||||
coordinator,
|
||||
builtInTools,
|
||||
).pipe(Layer.fresh)
|
||||
},
|
||||
idleTimeToLive: "60 minutes",
|
||||
dependencies: [
|
||||
|
|
@ -39,10 +98,15 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
|||
Auth.defaultLayer,
|
||||
Npm.defaultLayer,
|
||||
ModelsDev.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
AppProcess.defaultLayer,
|
||||
Global.defaultLayer,
|
||||
Database.defaultLayer,
|
||||
SessionV2.defaultLayer,
|
||||
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
|
||||
PermissionSaved.defaultLayer,
|
||||
RepositoryCache.defaultLayer,
|
||||
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
|
||||
FetchHttpClient.layer,
|
||||
ToolOutputStore.defaultCleanupLayer,
|
||||
],
|
||||
}) {}
|
||||
|
|
|
|||
311
packages/core/src/location-mutation.ts
Normal file
311
packages/core/src/location-mutation.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
export * as LocationMutation from "./location-mutation"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Location } from "./location"
|
||||
|
||||
export const Kind = Schema.Literals(["file", "directory"])
|
||||
export type Kind = typeof Kind.Type
|
||||
|
||||
/**
|
||||
* Mutation paths do not accept project references. Relative paths must stay
|
||||
* inside the active Location. Absolute paths outside it 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 class PathError extends Schema.TaggedErrorClass<PathError>()("LocationMutation.PathError", {
|
||||
path: Schema.String,
|
||||
reason: Schema.Literals([
|
||||
"relative_escape",
|
||||
"location_escape",
|
||||
"non_directory_ancestor",
|
||||
"unresolved_symlink",
|
||||
"location_identity_changed",
|
||||
]),
|
||||
}) {}
|
||||
|
||||
export class RevalidationError extends Schema.TaggedErrorClass<RevalidationError>()(
|
||||
"LocationMutation.RevalidationError",
|
||||
{
|
||||
path: Schema.String,
|
||||
reason: Schema.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export interface Identity {
|
||||
/** Canonical path for this saved filesystem identity. */
|
||||
readonly canonical: string
|
||||
readonly dev: number
|
||||
readonly ino?: number
|
||||
}
|
||||
|
||||
export interface ExternalDirectoryAuthorization {
|
||||
readonly action: "external_directory"
|
||||
/** Canonical existing directory used as the external approval boundary. */
|
||||
readonly directory: string
|
||||
/** `external_directory` permission resource. */
|
||||
readonly resource: string
|
||||
readonly save: string
|
||||
/** Saved identity checked again after approval to detect swaps. */
|
||||
readonly authority: Identity
|
||||
}
|
||||
|
||||
/** Build the `external_directory` permission request. */
|
||||
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
|
||||
action: input.action,
|
||||
resources: [input.resource],
|
||||
save: [input.save],
|
||||
})
|
||||
|
||||
export interface Target {
|
||||
/** Canonical existing path, or missing path below a canonical directory. */
|
||||
readonly canonical: string
|
||||
readonly exists: boolean
|
||||
readonly type?:
|
||||
| "File"
|
||||
| "Directory"
|
||||
| "SymbolicLink"
|
||||
| "BlockDevice"
|
||||
| "CharacterDevice"
|
||||
| "FIFO"
|
||||
| "Socket"
|
||||
| "Unknown"
|
||||
/** Permission resource: Location-relative for internal paths, canonical for external paths. */
|
||||
readonly resource: string
|
||||
readonly externalDirectory?: ExternalDirectoryAuthorization
|
||||
}
|
||||
|
||||
/**
|
||||
* A path checked before permission approval.
|
||||
*
|
||||
* resolve(path) -> Plan -> approve -> revalidate(plan) -> mutate immediately
|
||||
*
|
||||
* Tools must approve `target.externalDirectory`, when present, and their normal
|
||||
* mutation action before calling `revalidate`. Revalidation rejects escapes,
|
||||
* symlinks in missing suffixes, and changes made while approval is pending. It
|
||||
* cannot be atomic with the next filesystem call, so mutate immediately afterward.
|
||||
*/
|
||||
export interface Plan {
|
||||
readonly input: ResolveInput
|
||||
readonly target: Target
|
||||
/** Saved identity of the existing target or nearest existing ancestor. */
|
||||
readonly authority: Identity
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Check a path before approval and derive its permission resources. Relative
|
||||
* paths must stay inside the Location. Absolute paths outside it require
|
||||
* separate `external_directory` approval. This does not approve the tool's
|
||||
* mutation action.
|
||||
*/
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Plan, PathError | FSUtil.Error>
|
||||
/**
|
||||
* Check the plan again immediately before mutation. Reject changes to the
|
||||
* target, its saved identity, or approval resources. Mutate the returned
|
||||
* target immediately.
|
||||
*/
|
||||
readonly revalidate: (plan: Plan) => Effect.Effect<Target, RevalidationError | FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationMutation") {}
|
||||
|
||||
interface ResolvedPath {
|
||||
readonly canonical: string
|
||||
readonly exists: boolean
|
||||
readonly type?: Target["type"]
|
||||
readonly authority: Identity
|
||||
}
|
||||
|
||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const locationRoot = yield* fs.realPath(location.directory)
|
||||
const locationAuthority = yield* identity(locationRoot)
|
||||
|
||||
function identityFrom(canonical: string, info: Effect.Success<ReturnType<typeof fs.stat>>): Identity {
|
||||
return {
|
||||
canonical,
|
||||
dev: info.dev,
|
||||
ino: Option.getOrUndefined(info.ino),
|
||||
}
|
||||
}
|
||||
|
||||
function identity(canonical: string) {
|
||||
return fs.stat(canonical).pipe(Effect.map((info) => identityFrom(canonical, info)))
|
||||
}
|
||||
|
||||
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
|
||||
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||
}
|
||||
|
||||
function sameIdentity(left: Identity, right: Identity) {
|
||||
return left.canonical === right.canonical && left.dev === right.dev && left.ino === right.ino
|
||||
}
|
||||
|
||||
/** Check whether a saved path still points to the same filesystem object. */
|
||||
const assertIdentity = Effect.fnUntraced(function* (expected: Identity) {
|
||||
const canonical = yield* notFound(fs.realPath(expected.canonical))
|
||||
if (canonical === undefined) return false
|
||||
const actual = yield* notFound(identity(canonical))
|
||||
if (actual === undefined) return false
|
||||
return canonical === expected.canonical && sameIdentity(expected, actual)
|
||||
})
|
||||
|
||||
const assertLocationIdentity = Effect.fnUntraced(function* (requested: string) {
|
||||
if (yield* assertIdentity(locationAuthority)) return
|
||||
return yield* new PathError({ path: requested, reason: "location_identity_changed" })
|
||||
})
|
||||
|
||||
const hasUnresolvedSymlink = Effect.fnUntraced(function* (anchor: string, suffix: string) {
|
||||
let current = anchor
|
||||
for (const part of suffix.split(path.sep)) {
|
||||
if (!part) continue
|
||||
current = path.join(current, part)
|
||||
if (
|
||||
yield* fs.readLink(current).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
/**
|
||||
* Resolve a path to a canonical target and save an existing filesystem
|
||||
* identity for later revalidation.
|
||||
*
|
||||
* existing path -> save target identity
|
||||
* missing path -> save nearest existing directory identity
|
||||
*
|
||||
* Missing suffixes must not contain symlinks.
|
||||
*/
|
||||
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
|
||||
const existing = yield* notFound(fs.realPath(absolute))
|
||||
if (existing !== undefined) {
|
||||
const info = yield* fs.stat(existing)
|
||||
return {
|
||||
canonical: existing,
|
||||
exists: true,
|
||||
type: info.type,
|
||||
authority: identityFrom(existing, info),
|
||||
} satisfies ResolvedPath
|
||||
}
|
||||
|
||||
let anchor = path.dirname(absolute)
|
||||
while (true) {
|
||||
const canonical = yield* notFound(fs.realPath(anchor))
|
||||
if (canonical !== undefined) {
|
||||
const info = yield* fs.stat(canonical)
|
||||
if (info.type !== "Directory")
|
||||
return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
||||
const suffix = path.relative(anchor, absolute)
|
||||
if (yield* hasUnresolvedSymlink(anchor, suffix)) {
|
||||
return yield* new PathError({ path: absolute, reason: "unresolved_symlink" })
|
||||
}
|
||||
return {
|
||||
canonical: path.resolve(canonical, suffix),
|
||||
exists: false,
|
||||
authority: identityFrom(canonical, info),
|
||||
} satisfies ResolvedPath
|
||||
}
|
||||
const parent = path.dirname(anchor)
|
||||
if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
||||
anchor = parent
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Choose the existing directory used for separate external approval.
|
||||
*
|
||||
* existing directory target -> "<target>/*"
|
||||
* file or missing target -> "<nearest existing parent>/*"
|
||||
*/
|
||||
const externalDirectory = Effect.fnUntraced(function* (resolved: ResolvedPath, kind: Kind) {
|
||||
const candidate =
|
||||
kind === "directory" && resolved.type === "Directory" ? resolved.canonical : path.dirname(resolved.canonical)
|
||||
const boundary = yield* resolvePath(candidate)
|
||||
const directory =
|
||||
boundary.exists && boundary.type === "Directory" ? boundary.canonical : boundary.authority.canonical
|
||||
const resource = slash(path.join(directory, "*"))
|
||||
return {
|
||||
action: "external_directory" as const,
|
||||
directory,
|
||||
resource,
|
||||
save: resource,
|
||||
authority: boundary.authority,
|
||||
}
|
||||
})
|
||||
|
||||
const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
|
||||
yield* assertLocationIdentity(input.path)
|
||||
const relative = !path.isAbsolute(input.path)
|
||||
const absolute = path.resolve(location.directory, input.path)
|
||||
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
||||
if (relative && !lexicallyInternal) return yield* new PathError({ path: input.path, reason: "relative_escape" })
|
||||
|
||||
const resolved = yield* resolvePath(absolute)
|
||||
if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) {
|
||||
return yield* new PathError({ path: input.path, reason: "location_escape" })
|
||||
}
|
||||
|
||||
const external = !lexicallyInternal
|
||||
const resource = external
|
||||
? slash(resolved.canonical)
|
||||
: slash(path.relative(locationRoot, resolved.canonical) || ".")
|
||||
const target: Target = {
|
||||
canonical: resolved.canonical,
|
||||
exists: resolved.exists,
|
||||
type: resolved.type,
|
||||
resource,
|
||||
externalDirectory: external ? yield* externalDirectory(resolved, input.kind ?? "file") : undefined,
|
||||
}
|
||||
return { input, target, authority: resolved.authority } satisfies Plan
|
||||
})
|
||||
|
||||
/**
|
||||
* Re-resolve a plan immediately before mutation and reject any changed
|
||||
* identity, target, or approval resource. This reduces the race window but
|
||||
* cannot make the next filesystem call atomic.
|
||||
*/
|
||||
const revalidate = Effect.fn("LocationMutation.revalidate")(function* (plan: Plan) {
|
||||
const invalid = (reason: string) => new RevalidationError({ path: plan.input.path, reason })
|
||||
const fresh = yield* resolve(plan.input).pipe(
|
||||
Effect.mapError((error) => (error instanceof PathError ? invalid(error.reason) : error)),
|
||||
)
|
||||
if (!sameIdentity(fresh.authority, plan.authority)) return yield* invalid("mutation authority changed")
|
||||
if (fresh.target.canonical !== plan.target.canonical) return yield* invalid("canonical mutation target changed")
|
||||
if (fresh.target.resource !== plan.target.resource) return yield* invalid("mutation resource changed")
|
||||
if (Boolean(fresh.target.externalDirectory) !== Boolean(plan.target.externalDirectory)) {
|
||||
return yield* invalid("external directory authority changed")
|
||||
}
|
||||
if (
|
||||
fresh.target.externalDirectory &&
|
||||
plan.target.externalDirectory &&
|
||||
(fresh.target.externalDirectory.directory !== plan.target.externalDirectory.directory ||
|
||||
fresh.target.externalDirectory.resource !== plan.target.externalDirectory.resource ||
|
||||
!sameIdentity(fresh.target.externalDirectory.authority, plan.target.externalDirectory.authority))
|
||||
) {
|
||||
return yield* invalid("external directory authority changed")
|
||||
}
|
||||
return fresh.target
|
||||
})
|
||||
|
||||
return Service.of({ resolve, revalidate })
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
198
packages/core/src/location-search.ts
Normal file
198
packages/core/src/location-search.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
export * as LocationSearch from "./location-search"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
|
||||
|
||||
/**
|
||||
* Location-scoped raw search substrate. Search authority is selected only by
|
||||
* FileSystem, preserving Location-relative paths and named read
|
||||
* references. Model formatting, leaf-tool permissions, and HTTP transport stay
|
||||
* outside this service so future GlobTool, GrepTool, and HTTP consumers can
|
||||
* share the same bounded filesystem behavior.
|
||||
*
|
||||
* TODO: Expose this substrate through HTTP fs.search/fs.grep endpoints.
|
||||
* TODO: Reuse this substrate for instruction and skill discovery where suitable.
|
||||
*/
|
||||
|
||||
export const DEFAULT_RESULT_LIMIT = 100
|
||||
export const MAX_RESULT_LIMIT = 100
|
||||
export const MAX_LINE_PREVIEW_LENGTH = 2_000
|
||||
|
||||
export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESULT_LIMIT))
|
||||
|
||||
const RootInput = {
|
||||
path: RelativePath.pipe(Schema.optional),
|
||||
reference: Schema.NonEmptyString.pipe(Schema.optional),
|
||||
}
|
||||
|
||||
export const FilesInput = Schema.Struct({
|
||||
pattern: Schema.String,
|
||||
...RootInput,
|
||||
limit: ResultLimit.pipe(Schema.optional),
|
||||
})
|
||||
export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSignal }
|
||||
|
||||
export const GrepInput = Schema.Struct({
|
||||
pattern: Schema.String,
|
||||
include: Schema.String.pipe(Schema.optional),
|
||||
...RootInput,
|
||||
limit: ResultLimit.pipe(Schema.optional),
|
||||
})
|
||||
export type GrepInput = typeof GrepInput.Type & { readonly signal?: AbortSignal }
|
||||
|
||||
export class File extends Schema.Class<File>("LocationSearch.File")({
|
||||
path: RelativePath,
|
||||
canonical: Schema.String,
|
||||
resource: Schema.String,
|
||||
mtime: Schema.Number,
|
||||
}) {}
|
||||
|
||||
export class Submatch extends Schema.Class<Submatch>("LocationSearch.Submatch")({
|
||||
text: Schema.String,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}) {}
|
||||
|
||||
export class Match extends Schema.Class<Match>("LocationSearch.Match")({
|
||||
path: RelativePath,
|
||||
canonical: Schema.String,
|
||||
resource: Schema.String,
|
||||
lines: Schema.String,
|
||||
linePreviewTruncated: Schema.Boolean,
|
||||
line: PositiveInt,
|
||||
offset: NonNegativeInt,
|
||||
submatches: Schema.Array(Submatch),
|
||||
mtime: Schema.Number,
|
||||
}) {}
|
||||
|
||||
export class FilesResult extends Schema.Class<FilesResult>("LocationSearch.FilesResult")({
|
||||
items: Schema.Array(File),
|
||||
truncated: Schema.Boolean,
|
||||
partial: Schema.Boolean,
|
||||
}) {}
|
||||
|
||||
export class GrepResult extends Schema.Class<GrepResult>("LocationSearch.GrepResult")({
|
||||
items: Schema.Array(Match),
|
||||
truncated: Schema.Boolean,
|
||||
partial: Schema.Boolean,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly files: (input: FilesInput, root?: FileSystem.RootTarget) => Effect.Effect<FilesResult, Ripgrep.Error>
|
||||
readonly grep: (
|
||||
input: GrepInput,
|
||||
root?: FileSystem.RootTarget,
|
||||
) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationSearch") {}
|
||||
|
||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||
const cap = (limit?: number) => Math.min(limit ?? DEFAULT_RESULT_LIMIT, MAX_RESULT_LIMIT)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
|
||||
const candidate = Effect.fnUntraced(function* (root: FileSystem.RootTarget, cwd: string, value: string) {
|
||||
const absolute = path.resolve(cwd, value)
|
||||
const lexicallyContained =
|
||||
root.type === "directory" ? FSUtil.contains(root.real, absolute) : absolute === root.real
|
||||
if (!lexicallyContained) return
|
||||
const canonical = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
|
||||
if (!canonical || !FSUtil.contains(root.root, canonical)) return
|
||||
const info = yield* fs.stat(canonical).pipe(Effect.catch(() => Effect.void))
|
||||
if (!info || info.type !== "File") return
|
||||
const relative = slash(path.relative(root.root, canonical))
|
||||
return {
|
||||
path: RelativePath.make(relative),
|
||||
canonical,
|
||||
resource: root.reference === undefined ? relative : `${root.reference}:${relative}`,
|
||||
mtime: info.mtime.pipe(
|
||||
Option.map((date) => date.getTime()),
|
||||
Option.getOrElse(() => 0),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
files: Effect.fn("LocationSearch.files")(function* (input, approvedRoot) {
|
||||
const root = yield* filesystem.revalidateRoot(approvedRoot ?? (yield* filesystem.resolveRoot(input)))
|
||||
if (root.type !== "directory")
|
||||
return yield* Effect.die(new globalThis.Error("Files search path must be a directory"))
|
||||
const result = yield* ripgrep.files({
|
||||
cwd: root.real,
|
||||
pattern: input.pattern,
|
||||
limit: cap(input.limit),
|
||||
signal: input.signal,
|
||||
})
|
||||
const mapped = yield* Effect.forEach(result.items, (item) => candidate(root, root.real, item), {
|
||||
concurrency: 16,
|
||||
})
|
||||
const items = mapped.filter((item): item is File => item !== undefined).map((item) => new File(item))
|
||||
// TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering.
|
||||
// TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical.
|
||||
return new FilesResult({
|
||||
items,
|
||||
truncated: result.truncated,
|
||||
partial: result.partial || items.length !== result.items.length,
|
||||
})
|
||||
}),
|
||||
grep: Effect.fn("LocationSearch.grep")(function* (input, approvedRoot) {
|
||||
const root = yield* filesystem.revalidateRoot(approvedRoot ?? (yield* filesystem.resolveRoot(input)))
|
||||
const cwd = root.type === "directory" ? root.real : path.dirname(root.real)
|
||||
const result = yield* ripgrep.grep({
|
||||
cwd,
|
||||
pattern: input.pattern,
|
||||
include: input.include,
|
||||
file: root.type === "file" ? path.basename(root.real) : undefined,
|
||||
limit: cap(input.limit),
|
||||
signal: input.signal,
|
||||
})
|
||||
const candidates = new Map<string, ReturnType<typeof candidate>>()
|
||||
for (const item of result.items) {
|
||||
if (!candidates.has(item.path.text)) {
|
||||
candidates.set(item.path.text, yield* Effect.cached(candidate(root, cwd, item.path.text)))
|
||||
}
|
||||
}
|
||||
const mapped = yield* Effect.forEach(
|
||||
result.items,
|
||||
(item) =>
|
||||
candidates.get(item.path.text)!.pipe(
|
||||
Effect.map(
|
||||
(file) =>
|
||||
file &&
|
||||
new Match({
|
||||
...file,
|
||||
lines: item.lines.text.slice(0, MAX_LINE_PREVIEW_LENGTH),
|
||||
linePreviewTruncated: item.lines.text.length > MAX_LINE_PREVIEW_LENGTH,
|
||||
line: item.line_number,
|
||||
offset: item.absolute_offset,
|
||||
submatches: item.submatches.map(
|
||||
(submatch) =>
|
||||
new Submatch({ text: submatch.match.text, start: submatch.start, end: submatch.end }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ concurrency: 16 },
|
||||
)
|
||||
const items = mapped.filter((item): item is Match => item !== undefined)
|
||||
// TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering.
|
||||
// TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical.
|
||||
return new GrepResult({
|
||||
items,
|
||||
truncated: result.truncated,
|
||||
partial: result.partial || items.length !== result.items.length,
|
||||
})
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -1,25 +1,33 @@
|
|||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Project } from "./project"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { WorkspaceV2 } from "./workspace"
|
||||
|
||||
export * as Location from "./location"
|
||||
|
||||
export const Ref = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
workspaceID: Schema.optional(Schema.String),
|
||||
workspaceID: Schema.optional(WorkspaceV2.ID),
|
||||
}).annotate({ identifier: "Location.Ref" })
|
||||
export type Ref = typeof Ref.Type
|
||||
|
||||
export interface Interface {
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: string
|
||||
readonly project: {
|
||||
readonly id: Project.ID
|
||||
readonly directory: AbsolutePath
|
||||
}
|
||||
export class Info extends Schema.Class<Info>("Location.Info")({
|
||||
directory: AbsolutePath,
|
||||
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
|
||||
project: Schema.Struct({
|
||||
id: Project.ID,
|
||||
directory: AbsolutePath,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export interface Interface extends Info {
|
||||
readonly vcs?: Project.Vcs
|
||||
}
|
||||
|
||||
export function response<S extends Schema.Top>(data: S) {
|
||||
return Schema.Struct({ location: Info, data })
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
||||
|
||||
export const layer = (ref: Ref) =>
|
||||
|
|
|
|||
|
|
@ -40,21 +40,32 @@ export const Ref = Schema.Struct({
|
|||
})
|
||||
export type Ref = typeof Ref.Type
|
||||
|
||||
export const Api = Schema.Union([
|
||||
Schema.Struct({
|
||||
id: ID,
|
||||
...ProviderV2.AISDK.fields,
|
||||
}),
|
||||
Schema.Struct({
|
||||
id: ID,
|
||||
...ProviderV2.Native.fields,
|
||||
}),
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Api = typeof Api.Type
|
||||
|
||||
export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
||||
id: ID,
|
||||
apiID: ID,
|
||||
providerID: ProviderV2.ID,
|
||||
family: Family.pipe(Schema.optional),
|
||||
name: Schema.String,
|
||||
endpoint: ProviderV2.Endpoint,
|
||||
api: Api,
|
||||
capabilities: Capabilities,
|
||||
options: Schema.Struct({
|
||||
...ProviderV2.Options.fields,
|
||||
request: Schema.Struct({
|
||||
...ProviderV2.Request.fields,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
variants: Schema.Struct({
|
||||
id: VariantID,
|
||||
...ProviderV2.Options.fields,
|
||||
...ProviderV2.Request.fields,
|
||||
}).pipe(Schema.Array),
|
||||
time: Schema.Struct({
|
||||
released: DateTimeUtcFromMillis,
|
||||
|
|
@ -69,26 +80,23 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
|||
}),
|
||||
}) {
|
||||
static empty(providerID: ProviderV2.ID, modelID: ID): Info {
|
||||
return {
|
||||
return new Info({
|
||||
id: modelID,
|
||||
apiID: modelID,
|
||||
providerID,
|
||||
name: modelID,
|
||||
endpoint: {
|
||||
type: "unknown",
|
||||
api: {
|
||||
id: modelID,
|
||||
type: "native",
|
||||
settings: {},
|
||||
},
|
||||
capabilities: {
|
||||
tools: false,
|
||||
input: [],
|
||||
output: [],
|
||||
},
|
||||
options: {
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: {},
|
||||
request: {},
|
||||
},
|
||||
},
|
||||
variants: [],
|
||||
time: {
|
||||
|
|
@ -101,7 +109,7 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
|
|||
context: 0,
|
||||
output: 0,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Global } from "./global"
|
|||
import { Flag } from "./flag/flag"
|
||||
import { Flock } from "./util/flock"
|
||||
import { Hash } from "./util/hash"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { InstallationChannel, InstallationVersion } from "./installation/version"
|
||||
import { EventV2 } from "./event"
|
||||
|
||||
|
|
@ -125,7 +125,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Mo
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const events = yield* EventV2.Service
|
||||
const http = HttpClient.filterStatusOk(
|
||||
(yield* HttpClient.HttpClient).pipe(
|
||||
|
|
@ -227,7 +227,7 @@ export const layer = Layer.effect(
|
|||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import path from "path"
|
|||
import npa from "npm-package-arg"
|
||||
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
import { EffectFlock } from "./util/effect-flock"
|
||||
import { makeRuntime } from "./effect/runtime"
|
||||
|
|
@ -70,7 +70,7 @@ interface ArboristTree {
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const afs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const flock = yield* EffectFlock.Service
|
||||
|
|
@ -246,7 +246,7 @@ export const layer = Layer.effect(
|
|||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(EffectFlock.layer),
|
||||
Layer.provide(AppFileSystem.layer),
|
||||
Layer.provide(FSUtil.layer),
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
)
|
||||
|
|
|
|||
39
packages/core/src/opencode.ts
Normal file
39
packages/core/src/opencode.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
export * as OpenCode from "./opencode"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Database } from "./database/database"
|
||||
import { EventV2 } from "./event"
|
||||
import { LocationServiceMap } from "./location-layer"
|
||||
import { ProjectV2 } from "./project"
|
||||
import { SessionV2 } from "./session"
|
||||
import { SessionProjector } from "./session/projector"
|
||||
import * as SessionExecutionLocal from "./session/execution/local"
|
||||
import { SessionStore } from "./session/store"
|
||||
|
||||
export interface Interface {
|
||||
readonly sessions: SessionV2.Interface
|
||||
}
|
||||
|
||||
/** Public embedded OpenCode API for Effect-native applications. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/OpenCode") {}
|
||||
|
||||
const DefaultSessions = SessionV2.layer.pipe(
|
||||
Layer.provide(SessionProjector.layer),
|
||||
Layer.provide(SessionExecutionLocal.layer),
|
||||
Layer.provide(LocationServiceMap.layer),
|
||||
Layer.provide(SessionStore.layer),
|
||||
Layer.provide(EventV2.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.orDie,
|
||||
)
|
||||
|
||||
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
return Service.of({ sessions: yield* SessionV2.Service })
|
||||
}),
|
||||
).pipe(Layer.provide(DefaultSessions))
|
||||
|
||||
// TODO: Add OpenCode.create(...) as the Promise facade over the same embedded API semantics.
|
||||
197
packages/core/src/patch.ts
Normal file
197
packages/core/src/patch.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
export * as Patch from "./patch"
|
||||
|
||||
export type Hunk =
|
||||
| { readonly type: "add"; readonly path: string; readonly contents: string }
|
||||
| { readonly type: "delete"; readonly path: string }
|
||||
| {
|
||||
readonly type: "update"
|
||||
readonly path: string
|
||||
readonly movePath?: string
|
||||
readonly chunks: ReadonlyArray<UpdateFileChunk>
|
||||
}
|
||||
|
||||
export interface UpdateFileChunk {
|
||||
readonly oldLines: ReadonlyArray<string>
|
||||
readonly newLines: ReadonlyArray<string>
|
||||
readonly changeContext?: string
|
||||
readonly endOfFile?: boolean
|
||||
}
|
||||
|
||||
export interface FileUpdate {
|
||||
readonly content: string
|
||||
readonly bom: boolean
|
||||
}
|
||||
|
||||
export function parse(patchText: string): ReadonlyArray<Hunk> {
|
||||
const lines = stripHeredoc(patchText.trim()).split("\n")
|
||||
const begin = lines.findIndex((line) => line.trim() === "*** Begin Patch")
|
||||
const end = lines.findIndex((line) => line.trim() === "*** End Patch")
|
||||
if (begin === -1 || end === -1 || begin >= end) throw new Error("Invalid patch format: missing Begin/End markers")
|
||||
|
||||
const hunks: Hunk[] = []
|
||||
let index = begin + 1
|
||||
while (index < end) {
|
||||
const line = lines[index]!
|
||||
if (line.startsWith("*** Add File:")) {
|
||||
const path = line.slice("*** Add File:".length).trim()
|
||||
if (!path) throw new Error("Invalid add file path")
|
||||
const parsed = parseAdd(lines, index + 1)
|
||||
hunks.push({ type: "add", path, contents: parsed.content })
|
||||
index = parsed.next
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("*** Delete File:")) {
|
||||
const path = line.slice("*** Delete File:".length).trim()
|
||||
if (!path) throw new Error("Invalid delete file path")
|
||||
hunks.push({ type: "delete", path })
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (line.startsWith("*** Update File:")) {
|
||||
const path = line.slice("*** Update File:".length).trim()
|
||||
if (!path) throw new Error("Invalid update file path")
|
||||
let next = index + 1
|
||||
let movePath: string | undefined
|
||||
if (lines[next]?.startsWith("*** Move to:")) {
|
||||
movePath = lines[next]!.slice("*** Move to:".length).trim()
|
||||
if (!movePath) throw new Error("Invalid move file path")
|
||||
next++
|
||||
}
|
||||
const parsed = parseUpdate(lines, next)
|
||||
if (parsed.chunks.length === 0) throw new Error(`Invalid update hunk for ${path}: expected at least one @@ chunk`)
|
||||
hunks.push({ type: "update", path, movePath, chunks: parsed.chunks })
|
||||
index = parsed.next
|
||||
continue
|
||||
}
|
||||
throw new Error(`Invalid patch line: ${line}`)
|
||||
}
|
||||
return hunks
|
||||
}
|
||||
|
||||
export function derive(path: string, chunks: ReadonlyArray<UpdateFileChunk>, original: string): FileUpdate {
|
||||
const source = splitBom(original)
|
||||
const lines = source.text.split("\n")
|
||||
if (lines.at(-1) === "") lines.pop()
|
||||
const replacements = computeReplacements(lines, path, chunks)
|
||||
const updated = [...lines]
|
||||
for (const [start, remove, insert] of replacements.toReversed()) updated.splice(start, remove, ...insert)
|
||||
if (updated.at(-1) !== "") updated.push("")
|
||||
const next = splitBom(updated.join("\n"))
|
||||
return { content: next.text, bom: source.bom || next.bom }
|
||||
}
|
||||
|
||||
export function joinBom(text: string, bom: boolean) {
|
||||
const stripped = splitBom(text).text
|
||||
return bom ? `\uFEFF${stripped}` : stripped
|
||||
}
|
||||
|
||||
function parseAdd(lines: ReadonlyArray<string>, start: number) {
|
||||
const content: string[] = []
|
||||
let index = start
|
||||
while (index < lines.length && !lines[index]!.startsWith("***")) {
|
||||
if (!lines[index]!.startsWith("+")) throw new Error(`Invalid add file line: ${lines[index]}`)
|
||||
content.push(lines[index]!.slice(1))
|
||||
index++
|
||||
}
|
||||
return { content: content.join("\n"), next: index }
|
||||
}
|
||||
|
||||
function parseUpdate(lines: ReadonlyArray<string>, start: number) {
|
||||
const chunks: UpdateFileChunk[] = []
|
||||
let index = start
|
||||
while (index < lines.length && !lines[index]!.startsWith("***")) {
|
||||
if (!lines[index]!.startsWith("@@")) {
|
||||
throw new Error(`Invalid update file line: ${lines[index]}`)
|
||||
}
|
||||
const changeContext = lines[index]!.slice(2).trim() || undefined
|
||||
const oldLines: string[] = []
|
||||
const newLines: string[] = []
|
||||
let endOfFile = false
|
||||
index++
|
||||
while (index < lines.length && !lines[index]!.startsWith("@@")) {
|
||||
const line = lines[index]!
|
||||
if (line === "*** End of File") {
|
||||
endOfFile = true
|
||||
index++
|
||||
break
|
||||
}
|
||||
if (line.startsWith("***")) break
|
||||
if (line.startsWith(" ")) {
|
||||
oldLines.push(line.slice(1))
|
||||
newLines.push(line.slice(1))
|
||||
} else if (line.startsWith("-")) oldLines.push(line.slice(1))
|
||||
else if (line.startsWith("+")) newLines.push(line.slice(1))
|
||||
else throw new Error(`Invalid update chunk line: ${line}`)
|
||||
index++
|
||||
}
|
||||
chunks.push({ oldLines, newLines, changeContext, endOfFile: endOfFile || undefined })
|
||||
}
|
||||
return { chunks, next: index }
|
||||
}
|
||||
|
||||
function computeReplacements(lines: ReadonlyArray<string>, path: string, chunks: ReadonlyArray<UpdateFileChunk>) {
|
||||
const replacements: Array<readonly [start: number, remove: number, insert: ReadonlyArray<string>]> = []
|
||||
let lineIndex = 0
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.changeContext) {
|
||||
const context = seek(lines, [chunk.changeContext], lineIndex)
|
||||
if (context === -1) throw new Error(`Failed to find context '${chunk.changeContext}' in ${path}`)
|
||||
lineIndex = context + 1
|
||||
}
|
||||
if (chunk.oldLines.length === 0) {
|
||||
replacements.push([lines.length, 0, chunk.newLines])
|
||||
continue
|
||||
}
|
||||
let oldLines = chunk.oldLines
|
||||
let newLines = chunk.newLines
|
||||
let found = seek(lines, oldLines, lineIndex, chunk.endOfFile)
|
||||
if (found === -1 && oldLines.at(-1) === "") {
|
||||
oldLines = oldLines.slice(0, -1)
|
||||
if (newLines.at(-1) === "") newLines = newLines.slice(0, -1)
|
||||
found = seek(lines, oldLines, lineIndex, chunk.endOfFile)
|
||||
}
|
||||
if (found === -1) throw new Error(`Failed to find expected lines in ${path}:\n${chunk.oldLines.join("\n")}`)
|
||||
replacements.push([found, oldLines.length, newLines])
|
||||
lineIndex = found + oldLines.length
|
||||
}
|
||||
return replacements.toSorted((left, right) => left[0] - right[0])
|
||||
}
|
||||
|
||||
function seek(lines: ReadonlyArray<string>, pattern: ReadonlyArray<string>, start: number, eof = false) {
|
||||
if (pattern.length === 0) return -1
|
||||
for (const compare of [exact, rstrip, trim, normalized]) {
|
||||
if (eof) {
|
||||
const offset = lines.length - pattern.length
|
||||
if (offset >= start && matches(lines, pattern, offset, compare)) return offset
|
||||
}
|
||||
for (let offset = start; offset <= lines.length - pattern.length; offset++) {
|
||||
if (matches(lines, pattern, offset, compare)) return offset
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function matches(
|
||||
lines: ReadonlyArray<string>,
|
||||
pattern: ReadonlyArray<string>,
|
||||
offset: number,
|
||||
compare: (left: string, right: string) => boolean,
|
||||
) {
|
||||
return pattern.every((line, index) => compare(lines[offset + index]!, line))
|
||||
}
|
||||
|
||||
const exact = (left: string, right: string) => left === right
|
||||
const rstrip = (left: string, right: string) => left.trimEnd() === right.trimEnd()
|
||||
const trim = (left: string, right: string) => left.trim() === right.trim()
|
||||
const normalized = (left: string, right: string) => normalize(left.trim()) === normalize(right.trim())
|
||||
const normalize = (value: string) =>
|
||||
value
|
||||
.replace(/[‘’‚‛]/g, "'")
|
||||
.replace(/[“”„‟]/g, '"')
|
||||
.replace(/[‐‑‒–—―]/g, "-")
|
||||
.replace(/…/g, "...")
|
||||
.replace(/ /g, " ")
|
||||
const splitBom = (text: string) =>
|
||||
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
|
||||
const stripHeredoc = (input: string) =>
|
||||
input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)?.[2] ?? input
|
||||
|
|
@ -5,6 +5,7 @@ import { EventV2 } from "./event"
|
|||
import { Location } from "./location"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { SessionV2 } from "./session"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { withStatics } from "./schema"
|
||||
import { Identifier } from "./util/identifier"
|
||||
import { Wildcard } from "./util/wildcard"
|
||||
|
|
@ -135,7 +136,7 @@ export const layer = Layer.effect(
|
|||
const events = yield* EventV2.Service
|
||||
const location = yield* Location.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
const sessions = yield* SessionV2.Service
|
||||
const sessions = yield* SessionStore.Service
|
||||
const saved = yield* PermissionSaved.Service
|
||||
const pending = new Map<ID, Pending>()
|
||||
|
||||
|
|
@ -159,8 +160,8 @@ export const layer = Layer.effect(
|
|||
|
||||
const configured = EffectRuntime.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID) {
|
||||
const session = yield* sessions.get(sessionID)
|
||||
if (!session.agent) return []
|
||||
return (yield* agents.get(AgentV2.ID.make(session.agent)))?.permissions ?? []
|
||||
if (!session) return yield* new SessionV2.NotFoundError({ sessionID })
|
||||
return (yield* agents.get(AgentV2.ID.make(session.agent ?? "build")))?.permissions ?? []
|
||||
})
|
||||
|
||||
function denied(input: AssertInput, rules: Ruleset) {
|
||||
|
|
@ -192,13 +193,19 @@ export const layer = Layer.effect(
|
|||
}
|
||||
}
|
||||
|
||||
const create = EffectRuntime.fnUntraced(function* (request: Request) {
|
||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||
const item = { request, deferred }
|
||||
pending.set(request.id, item)
|
||||
yield* events.publish(Event.Asked, request)
|
||||
return item
|
||||
})
|
||||
const create = (request: Request) =>
|
||||
EffectRuntime.uninterruptible(
|
||||
EffectRuntime.gen(function* () {
|
||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||
const item = { request, deferred }
|
||||
if (pending.has(request.id)) return yield* EffectRuntime.die(`Duplicate pending permission ID: ${request.id}`)
|
||||
pending.set(request.id, item)
|
||||
yield* events
|
||||
.publish(Event.Asked, request)
|
||||
.pipe(EffectRuntime.onError(() => EffectRuntime.sync(() => pending.delete(request.id))))
|
||||
return item
|
||||
}),
|
||||
)
|
||||
|
||||
const ask = EffectRuntime.fn("PermissionV2.ask")(function* (input: AssertInput) {
|
||||
const result = yield* evaluateInput(input)
|
||||
|
|
@ -207,86 +214,95 @@ export const layer = Layer.effect(
|
|||
return { id: value.id, effect: result.effect }
|
||||
})
|
||||
|
||||
const assert = EffectRuntime.fn("PermissionV2.assert")(function* (input: AssertInput) {
|
||||
const result = yield* evaluateInput(input)
|
||||
if (result.effect === "deny") {
|
||||
return yield* new DeniedError({
|
||||
rules: relevant(input, result.rules),
|
||||
})
|
||||
}
|
||||
if (result.effect === "allow") return
|
||||
const item = yield* create(request(input))
|
||||
return yield* Deferred.await(item.deferred).pipe(
|
||||
EffectRuntime.ensuring(
|
||||
EffectRuntime.sync(() => {
|
||||
pending.delete(item.request.id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const reply = EffectRuntime.fn("PermissionV2.reply")(function* (input: ReplyInput) {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
|
||||
pending.delete(input.requestID)
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: existing.request.sessionID,
|
||||
requestID: existing.request.id,
|
||||
reply: input.reply,
|
||||
})
|
||||
|
||||
if (input.reply === "reject") {
|
||||
yield* Deferred.fail(
|
||||
existing.deferred,
|
||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||
)
|
||||
for (const [id, item] of pending) {
|
||||
if (item.request.sessionID !== existing.request.sessionID) continue
|
||||
pending.delete(id)
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: item.request.sessionID,
|
||||
requestID: item.request.id,
|
||||
reply: "reject",
|
||||
})
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (input.reply === "always" && existing.request.save?.length) {
|
||||
yield* saved.add({
|
||||
projectID: location.project.id,
|
||||
action: existing.request.action,
|
||||
resources: existing.request.save,
|
||||
})
|
||||
}
|
||||
yield* Deferred.succeed(existing.deferred, undefined)
|
||||
if (input.reply !== "always" || !existing.request.save?.length) return
|
||||
|
||||
const rememberedRules = yield* savedRules()
|
||||
for (const [id, item] of pending) {
|
||||
const input = { ...item.request }
|
||||
const rules = yield* configured(item.request.sessionID).pipe(
|
||||
EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)),
|
||||
)
|
||||
if (!rules) continue
|
||||
if (denied(input, rules)) continue
|
||||
const effective = [...rules, ...rememberedRules]
|
||||
if (
|
||||
!item.request.resources.every(
|
||||
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
|
||||
const assert = EffectRuntime.fn("PermissionV2.assert")((input: AssertInput) =>
|
||||
EffectRuntime.uninterruptibleMask((restore) =>
|
||||
EffectRuntime.gen(function* () {
|
||||
const result = yield* evaluateInput(input)
|
||||
if (result.effect === "deny") {
|
||||
return yield* new DeniedError({
|
||||
rules: relevant(input, result.rules),
|
||||
})
|
||||
}
|
||||
if (result.effect === "allow") return
|
||||
const item = yield* create(request(input))
|
||||
return yield* restore(Deferred.await(item.deferred)).pipe(
|
||||
EffectRuntime.ensuring(
|
||||
EffectRuntime.sync(() => {
|
||||
pending.delete(item.request.id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
pending.delete(id)
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: item.request.sessionID,
|
||||
requestID: item.request.id,
|
||||
reply: "always",
|
||||
})
|
||||
yield* Deferred.succeed(item.deferred, undefined)
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const reply = EffectRuntime.fn("PermissionV2.reply")((input: ReplyInput) =>
|
||||
EffectRuntime.uninterruptible(
|
||||
EffectRuntime.gen(function* () {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: existing.request.sessionID,
|
||||
requestID: existing.request.id,
|
||||
reply: input.reply,
|
||||
})
|
||||
|
||||
if (input.reply === "reject") {
|
||||
yield* Deferred.fail(
|
||||
existing.deferred,
|
||||
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
|
||||
)
|
||||
pending.delete(input.requestID)
|
||||
for (const [id, item] of pending) {
|
||||
if (item.request.sessionID !== existing.request.sessionID) continue
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: item.request.sessionID,
|
||||
requestID: item.request.id,
|
||||
reply: "reject",
|
||||
})
|
||||
yield* Deferred.fail(item.deferred, new RejectedError())
|
||||
pending.delete(id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (input.reply === "always" && existing.request.save?.length) {
|
||||
yield* saved.add({
|
||||
projectID: location.project.id,
|
||||
action: existing.request.action,
|
||||
resources: existing.request.save,
|
||||
})
|
||||
}
|
||||
yield* Deferred.succeed(existing.deferred, undefined)
|
||||
pending.delete(input.requestID)
|
||||
if (input.reply !== "always" || !existing.request.save?.length) return
|
||||
|
||||
const rememberedRules = yield* savedRules()
|
||||
for (const [id, item] of pending) {
|
||||
const input = { ...item.request }
|
||||
const rules = yield* configured(item.request.sessionID).pipe(
|
||||
EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)),
|
||||
)
|
||||
if (!rules) continue
|
||||
if (denied(input, rules)) continue
|
||||
const effective = [...rules, ...rememberedRules]
|
||||
if (
|
||||
!item.request.resources.every(
|
||||
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
|
||||
)
|
||||
)
|
||||
continue
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: item.request.sessionID,
|
||||
requestID: item.request.id,
|
||||
reply: "always",
|
||||
})
|
||||
yield* Deferred.succeed(item.deferred, undefined)
|
||||
pending.delete(id)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const list = EffectRuntime.fn("PermissionV2.list")(function* () {
|
||||
return Array.from(pending.values(), (item) => item.request)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Context, Effect, Exit, Layer, Schema, Scope } from "effect"
|
|||
import type { ModelV2 } from "./model"
|
||||
import type { Catalog } from "./catalog"
|
||||
import { EventV2 } from "./event"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
|
||||
export type ID = typeof ID.Type
|
||||
|
|
@ -105,29 +106,36 @@ export const layer = Layer.effect(
|
|||
scope: Scope.Closeable
|
||||
}[] = []
|
||||
const events = yield* EventV2.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const locks = KeyedMutex.makeUnsafe<ID>()
|
||||
|
||||
const svc = Service.of({
|
||||
add: Effect.fn("Plugin.add")(function* (input) {
|
||||
const existing = hooks.find((item) => item.id === input.id)
|
||||
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
|
||||
const scope = yield* Scope.make()
|
||||
const result = yield* input.effect.pipe(
|
||||
Scope.provide(scope),
|
||||
Effect.withSpan("Plugin.load", {
|
||||
attributes: {
|
||||
"plugin.id": input.id,
|
||||
},
|
||||
yield* locks.withLock(input.id)(
|
||||
Effect.gen(function* () {
|
||||
const existing = hooks.find((item) => item.id === input.id)
|
||||
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
|
||||
const childScope = yield* Scope.fork(scope)
|
||||
const result = yield* input.effect.pipe(
|
||||
Scope.provide(childScope),
|
||||
Effect.withSpan("Plugin.load", {
|
||||
attributes: {
|
||||
"plugin.id": input.id,
|
||||
},
|
||||
}),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)),
|
||||
)
|
||||
hooks = [
|
||||
...hooks.filter((item) => item.id !== input.id),
|
||||
{
|
||||
id: input.id,
|
||||
hooks: result ?? {},
|
||||
scope: childScope,
|
||||
},
|
||||
]
|
||||
yield* events.publish(Event.Added, { id: input.id })
|
||||
}),
|
||||
)
|
||||
hooks = [
|
||||
...hooks.filter((item) => item.id !== input.id),
|
||||
{
|
||||
id: input.id,
|
||||
hooks: result ?? {},
|
||||
scope,
|
||||
},
|
||||
]
|
||||
yield* events.publish(Event.Added, { id: input.id })
|
||||
}),
|
||||
trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) {
|
||||
return yield* svc.triggerFor(ID.make("*"), name, input, output)
|
||||
|
|
@ -167,9 +175,13 @@ export const layer = Layer.effect(
|
|||
return event as any
|
||||
}),
|
||||
remove: Effect.fn("Plugin.remove")(function* (id) {
|
||||
const existing = hooks.find((item) => item.id === id)
|
||||
hooks = hooks.filter((item) => item.id !== id)
|
||||
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
|
||||
yield* locks.withLock(id)(
|
||||
Effect.gen(function* () {
|
||||
const existing = hooks.find((item) => item.id === id)
|
||||
hooks = hooks.filter((item) => item.id !== id)
|
||||
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
return svc
|
||||
|
|
|
|||
|
|
@ -32,10 +32,10 @@ export const AccountPlugin = PluginV2.define({
|
|||
service: account.serviceID,
|
||||
}
|
||||
if (account.credential.type === "api") {
|
||||
provider.options.aisdk.provider.apiKey = account.credential.key
|
||||
Object.assign(provider.options.aisdk.provider, account.credential.metadata ?? {})
|
||||
provider.request.body.apiKey = account.credential.key
|
||||
Object.assign(provider.request.body, account.credential.metadata ?? {})
|
||||
}
|
||||
if (account.credential.type === "oauth") provider.options.aisdk.provider.apiKey = account.credential.access
|
||||
if (account.credential.type === "oauth") provider.request.body.apiKey = account.credential.access
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ Guidelines:
|
|||
- Use Glob for broad file pattern matching
|
||||
- Use Grep for searching file contents with regex
|
||||
- Use Read when you know the specific file path you need to read
|
||||
- Use Bash for file operations like copying, moving, or listing directory contents
|
||||
- Adapt your search approach based on the thoroughness level specified by the caller
|
||||
- Return file paths as absolute paths in your final response
|
||||
- For clear communication, avoid using emojis
|
||||
|
|
@ -115,8 +114,6 @@ export const Plugin = PluginV2.define({
|
|||
{ action: "question", resource: "*", effect: "deny" },
|
||||
{ action: "plan_enter", resource: "*", effect: "deny" },
|
||||
{ action: "plan_exit", resource: "*", effect: "deny" },
|
||||
{ action: "repo_clone", resource: "*", effect: "deny" },
|
||||
{ action: "repo_overview", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*.env", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.*", effect: "ask" },
|
||||
|
|
@ -173,8 +170,6 @@ export const Plugin = PluginV2.define({
|
|||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "grep", resource: "*", effect: "allow" },
|
||||
{ action: "glob", resource: "*", effect: "allow" },
|
||||
{ action: "list", resource: "*", effect: "allow" },
|
||||
{ action: "bash", resource: "*", effect: "allow" },
|
||||
{ action: "webfetch", resource: "*", effect: "allow" },
|
||||
{ action: "websearch", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
|
|
|
|||
|
|
@ -4,34 +4,44 @@ import { Context, Deferred, Effect, Layer } from "effect"
|
|||
import { Auth } from "../auth"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Catalog } from "../catalog"
|
||||
import { CommandV2 } from "../command"
|
||||
import { Config } from "../config"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command"
|
||||
import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { EventV2 } from "../event"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Global } from "../global"
|
||||
import { Location } from "../location"
|
||||
import { ModelsDev } from "../models-dev"
|
||||
import { Npm } from "../npm"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { AccountPlugin } from "./account"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { SkillPlugin } from "./skill"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider"
|
||||
import { EnvPlugin } from "./env"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { SkillV2 } from "../skill"
|
||||
|
||||
type Plugin = {
|
||||
id: PluginV2.ID
|
||||
effect: PluginV2.Effect<
|
||||
| Catalog.Service
|
||||
| CommandV2.Service
|
||||
| Auth.Service
|
||||
| AgentV2.Service
|
||||
| Npm.Service
|
||||
| EventV2.Service
|
||||
| AppFileSystem.Service
|
||||
| FSUtil.Service
|
||||
| Global.Service
|
||||
| Location.Service
|
||||
| PluginV2.Service
|
||||
| Config.Service
|
||||
| ModelsDev.Service
|
||||
| SkillV2.Service
|
||||
>
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +55,7 @@ export const layer = Layer.effect(
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const accounts = yield* Auth.Service
|
||||
const agents = yield* AgentV2.Service
|
||||
|
|
@ -53,7 +64,9 @@ export const layer = Layer.effect(
|
|||
const modelsDev = yield* ModelsDev.Service
|
||||
const npm = yield* Npm.Service
|
||||
const events = yield* EventV2.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const skill = yield* SkillV2.Service
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
|
||||
|
|
@ -61,6 +74,7 @@ export const layer = Layer.effect(
|
|||
id: input.id,
|
||||
effect: input.effect.pipe(
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(CommandV2.Service, commands),
|
||||
Effect.provideService(Auth.Service, accounts),
|
||||
Effect.provideService(AgentV2.Service, agents),
|
||||
Effect.provideService(Config.Service, config),
|
||||
|
|
@ -68,7 +82,9 @@ export const layer = Layer.effect(
|
|||
Effect.provideService(ModelsDev.Service, modelsDev),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(AppFileSystem.Service, fs),
|
||||
Effect.provideService(FSUtil.Service, fs),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(SkillV2.Service, skill),
|
||||
Effect.provideService(PluginV2.Service, plugin),
|
||||
),
|
||||
})
|
||||
|
|
@ -78,12 +94,16 @@ export const layer = Layer.effect(
|
|||
yield* add(EnvPlugin)
|
||||
yield* add(AccountPlugin)
|
||||
yield* add(AgentPlugin.Plugin)
|
||||
yield* add(CommandPlugin.Plugin)
|
||||
yield* add(SkillPlugin.Plugin)
|
||||
for (const item of ProviderPlugins) {
|
||||
yield* add(item)
|
||||
}
|
||||
yield* add(ModelsDevPlugin)
|
||||
yield* add(ConfigProviderPlugin.Plugin)
|
||||
yield* add(ConfigAgentPlugin.Plugin)
|
||||
yield* add(ConfigCommandPlugin.Plugin)
|
||||
yield* add(ConfigSkillPlugin.Plugin)
|
||||
}).pipe(Effect.withSpan("PluginBoot.boot"))
|
||||
|
||||
yield* boot.pipe(
|
||||
|
|
@ -100,6 +120,8 @@ export const layer = Layer.effect(
|
|||
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Catalog.locationLayer),
|
||||
Layer.provideMerge(CommandV2.locationLayer),
|
||||
Layer.provideMerge(Config.locationLayer),
|
||||
Layer.provideMerge(AgentV2.locationLayer),
|
||||
Layer.provideMerge(SkillV2.locationLayer),
|
||||
)
|
||||
|
|
|
|||
29
packages/core/src/plugin/command.ts
Normal file
29
packages/core/src/plugin/command.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export * as CommandPlugin from "./command"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { CommandV2 } from "../command"
|
||||
import { Location } from "../location"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import PROMPT_INITIALIZE from "./command/initialize.txt"
|
||||
import PROMPT_REVIEW from "./command/review.txt"
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("command"),
|
||||
effect: Effect.gen(function* () {
|
||||
const command = yield* CommandV2.Service
|
||||
const location = yield* Location.Service
|
||||
const transform = yield* command.transform()
|
||||
|
||||
yield* transform((editor) => {
|
||||
editor.update("init", (command) => {
|
||||
command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory)
|
||||
command.description = "guided AGENTS.md setup"
|
||||
})
|
||||
editor.update("review", (command) => {
|
||||
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
|
||||
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
|
||||
command.subtask = true
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
65
packages/core/src/plugin/command/initialize.txt
Normal file
65
packages/core/src/plugin/command/initialize.txt
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
Create or update `AGENTS.md` for this repository.
|
||||
|
||||
The goal is a compact instruction file that helps future OpenCode sessions avoid mistakes and ramp up quickly. Every line should answer: "Would an agent likely miss this without help?" If not, leave it out.
|
||||
|
||||
User-provided focus or constraints (honor these):
|
||||
$ARGUMENTS
|
||||
|
||||
## How to investigate
|
||||
|
||||
Read the highest-value sources first:
|
||||
- `README*`, root manifests, workspace config, lockfiles
|
||||
- build, test, lint, formatter, typecheck, and codegen config
|
||||
- CI workflows and pre-commit / task runner config
|
||||
- existing instruction files (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules/`, `.cursorrules`, `.github/copilot-instructions.md`)
|
||||
- repo-local OpenCode config such as `opencode.json`
|
||||
|
||||
If architecture is still unclear after reading config and docs, inspect a small number of representative code files to find the real entrypoints, package boundaries, and execution flow. Prefer reading the files that explain how the system is wired together over random leaf files.
|
||||
|
||||
Prefer executable sources of truth over prose. If docs conflict with config or scripts, trust the executable source and only keep what you can verify.
|
||||
|
||||
## What to extract
|
||||
|
||||
Look for the highest-signal facts for an agent working in this repo:
|
||||
- exact developer commands, especially non-obvious ones
|
||||
- how to run a single test, a single package, or a focused verification step
|
||||
- required command order when it matters, such as `lint -> typecheck -> test`
|
||||
- monorepo or multi-package boundaries, ownership of major directories, and the real app/library entrypoints
|
||||
- framework or toolchain quirks: generated code, migrations, codegen, build artifacts, special env loading, dev servers, infra deploy flow
|
||||
- testing quirks: fixtures, integration test prerequisites, snapshot workflows, required services, flaky or expensive suites
|
||||
- important constraints from existing instruction files worth preserving
|
||||
|
||||
Good `AGENTS.md` content is usually hard-earned context that took reading multiple files to infer.
|
||||
|
||||
## Questions
|
||||
|
||||
Only ask the user questions if the repo cannot answer something important. Use the `question` tool for one short batch at most.
|
||||
|
||||
Good questions:
|
||||
- undocumented team conventions
|
||||
- branch / PR / release expectations
|
||||
- missing setup or test prerequisites that are known but not written down
|
||||
|
||||
Do not ask about anything the repo already makes clear.
|
||||
|
||||
## Writing rules
|
||||
|
||||
Include only high-signal, repo-specific guidance such as:
|
||||
- exact commands and shortcuts the agent would otherwise guess wrong
|
||||
- architecture notes that are not obvious from filenames
|
||||
- conventions that differ from language or framework defaults
|
||||
- setup requirements, environment quirks, and operational gotchas
|
||||
- references to existing instruction sources that matter
|
||||
|
||||
Exclude:
|
||||
- generic software advice
|
||||
- long tutorials or exhaustive file trees
|
||||
- obvious language conventions
|
||||
- speculative claims or anything you could not verify
|
||||
- content better stored in another file referenced via `opencode.json` `instructions`
|
||||
|
||||
When in doubt, omit.
|
||||
|
||||
Prefer short sections and bullets. If the repo is simple, keep the file simple. If the repo is large, summarize the few structural facts that actually change how an agent should work.
|
||||
|
||||
If `AGENTS.md` already exists at `${path}`, improve it in place rather than rewriting blindly. Preserve verified useful guidance, delete fluff or stale claims, and reconcile it with the current codebase.
|
||||
100
packages/core/src/plugin/command/review.txt
Normal file
100
packages/core/src/plugin/command/review.txt
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
You are a code reviewer. Your job is to review code changes and provide actionable feedback.
|
||||
|
||||
---
|
||||
|
||||
Input: $ARGUMENTS
|
||||
|
||||
---
|
||||
|
||||
## Determining What to Review
|
||||
|
||||
Based on the input provided, determine which type of review to perform:
|
||||
|
||||
1. **No arguments (default)**: Review all uncommitted changes
|
||||
- Run: `git diff` for unstaged changes
|
||||
- Run: `git diff --cached` for staged changes
|
||||
- Run: `git status --short` to identify untracked (net new) files
|
||||
|
||||
2. **Commit hash** (40-char SHA or short hash): Review that specific commit
|
||||
- Run: `git show $ARGUMENTS`
|
||||
|
||||
3. **Branch name**: Compare current branch to the specified branch
|
||||
- Run: `git diff $ARGUMENTS...HEAD`
|
||||
|
||||
4. **PR URL or number** (contains "github.com" or "pull" or looks like a PR number): Review the pull request
|
||||
- Run: `gh pr view $ARGUMENTS` to get PR context
|
||||
- Run: `gh pr diff $ARGUMENTS` to get the diff
|
||||
|
||||
Use best judgement when processing input.
|
||||
|
||||
---
|
||||
|
||||
## Gathering Context
|
||||
|
||||
**Diffs alone are not enough.** After getting the diff, read the entire file(s) being modified to understand the full context. Code that looks wrong in isolation may be correct given surrounding logic—and vice versa.
|
||||
|
||||
- Use the diff to identify which files changed
|
||||
- Use `git status --short` to identify untracked files, then read their full contents
|
||||
- Read the full file to understand existing patterns, control flow, and error handling
|
||||
- Check for existing style guide or conventions files (CONVENTIONS.md, AGENTS.md, .editorconfig, etc.)
|
||||
|
||||
---
|
||||
|
||||
## What to Look For
|
||||
|
||||
**Bugs** - Your primary focus.
|
||||
- Logic errors, off-by-one mistakes, incorrect conditionals
|
||||
- If-else guards: missing guards, incorrect branching, unreachable code paths
|
||||
- Edge cases: null/empty/undefined inputs, error conditions, race conditions
|
||||
- Security issues: injection, auth bypass, data exposure
|
||||
- Broken error handling that swallows failures, throws unexpectedly or returns error types that are not caught.
|
||||
|
||||
**Structure** - Does the code fit the codebase?
|
||||
- Does it follow existing patterns and conventions?
|
||||
- Are there established abstractions it should use but doesn't?
|
||||
- Excessive nesting that could be flattened with early returns or extraction
|
||||
|
||||
**Performance** - Only flag if obviously problematic.
|
||||
- O(n²) on unbounded data, N+1 queries, blocking I/O on hot paths
|
||||
|
||||
**Behavior Changes** - If a behavioral change is introduced, raise it (especially if it's possibly unintentional).
|
||||
|
||||
---
|
||||
|
||||
## Before You Flag Something
|
||||
|
||||
**Be certain.** If you're going to call something a bug, you need to be confident it actually is one.
|
||||
|
||||
- Only review the changes - do not review pre-existing code that wasn't modified
|
||||
- Don't flag something as a bug if you're unsure - investigate first
|
||||
- Don't invent hypothetical problems - if an edge case matters, explain the realistic scenario where it breaks
|
||||
- If you need more context to be sure, use the tools below to get it
|
||||
|
||||
**Don't be a zealot about style.** When checking code against conventions:
|
||||
|
||||
- Verify the code is *actually* in violation. Don't complain about else statements if early returns are already being used correctly.
|
||||
- Some "violations" are acceptable when they're the simplest option. A `let` statement is fine if the alternative is convoluted.
|
||||
- Excessive nesting is a legitimate concern regardless of other style choices.
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
Use these to inform your review:
|
||||
|
||||
- **Explore agent** - Find how existing code handles similar problems. Check patterns, conventions, and prior art before claiming something doesn't fit.
|
||||
- **Exa Code Context** - Verify correct usage of libraries/APIs before flagging something as wrong.
|
||||
- **Web Search** - Research best practices if you're unsure about a pattern.
|
||||
|
||||
If you're uncertain about something and can't verify it with these tools, say "I'm not sure about X" rather than flagging it as a definite issue.
|
||||
|
||||
---
|
||||
|
||||
## Output
|
||||
|
||||
1. If there is a bug, be direct and clear about why it is a bug.
|
||||
2. Clearly communicate severity of issues. Do not overstate severity.
|
||||
3. Critiques should clearly and explicitly communicate the scenarios, environments, or inputs that are necessary for the bug to arise. The comment should immediately indicate that the issue's severity depends on these factors.
|
||||
4. Your tone should be matter-of-fact and not accusatory or overly positive. It should read as a helpful AI assistant suggestion without sounding too much like a human reviewer.
|
||||
5. Write so the reader can quickly understand the issue without reading too closely.
|
||||
6. AVOID flattery, do not give any comments that are not helpful to the reader.
|
||||
|
|
@ -43,10 +43,6 @@ function variants(model: ModelsDev.Model) {
|
|||
id: ModelV2.VariantID.make(id),
|
||||
headers: { ...(item.provider?.headers ?? {}) },
|
||||
body: { ...(item.provider?.body ?? {}) },
|
||||
aisdk: {
|
||||
provider: {},
|
||||
request: {},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -66,14 +62,16 @@ export const ModelsDevPlugin = PluginV2.define({
|
|||
catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = item.name
|
||||
provider.env = [...item.env]
|
||||
provider.endpoint = item.npm
|
||||
provider.api = item.npm
|
||||
? {
|
||||
type: "aisdk",
|
||||
package: item.npm,
|
||||
url: item.api,
|
||||
}
|
||||
: {
|
||||
type: "unknown",
|
||||
type: "native",
|
||||
url: item.api,
|
||||
settings: {},
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -82,14 +80,18 @@ export const ModelsDevPlugin = PluginV2.define({
|
|||
catalog.model.update(providerID, modelID, (draft) => {
|
||||
draft.name = model.name
|
||||
draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
|
||||
draft.endpoint = model.provider?.npm
|
||||
draft.api = model.provider?.npm
|
||||
? {
|
||||
id: draft.api.id,
|
||||
type: "aisdk",
|
||||
package: model.provider?.npm,
|
||||
url: model.provider.api,
|
||||
}
|
||||
: {
|
||||
type: "unknown",
|
||||
id: draft.api.id,
|
||||
type: "native",
|
||||
url: model.provider?.api,
|
||||
settings: {},
|
||||
}
|
||||
draft.capabilities = {
|
||||
tools: model.tool_call,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import { Effect } from "effect"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { PluginV2 } from "../../plugin"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
|
||||
type MantleSDK = {
|
||||
languageModel: (modelID: string) => LanguageModelV3
|
||||
chat: (modelID: string) => LanguageModelV3
|
||||
responses: (modelID: string) => LanguageModelV3
|
||||
}
|
||||
|
||||
// Bedrock cross-region inference profiles require regional prefixes only for
|
||||
// specific model/region combinations. Keep the mapping narrow and avoid
|
||||
// double-prefixing model IDs that models.dev already marks as global/us/eu/etc.
|
||||
|
|
@ -46,26 +53,32 @@ function resolveModelID(modelID: string, region: string | undefined) {
|
|||
: modelID
|
||||
}
|
||||
|
||||
function selectMantleModel(sdk: MantleSDK, modelID: string) {
|
||||
if (modelID === "openai.gpt-oss-safeguard-20b" || modelID === "openai.gpt-oss-safeguard-120b")
|
||||
return sdk.chat(modelID)
|
||||
return sdk.responses(modelID)
|
||||
}
|
||||
|
||||
export const AmazonBedrockPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("amazon-bedrock"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/amazon-bedrock") continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (provider.endpoint.type !== "aisdk") return
|
||||
if (typeof provider.options.aisdk.provider.endpoint !== "string") return
|
||||
if (provider.api.type !== "aisdk") return
|
||||
if (typeof provider.request.body.endpoint !== "string") return
|
||||
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
|
||||
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
|
||||
provider.endpoint.url = provider.options.aisdk.provider.endpoint
|
||||
delete provider.options.aisdk.provider.endpoint
|
||||
provider.api.url = provider.request.body.endpoint
|
||||
delete provider.request.body.endpoint
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/amazon-bedrock") return
|
||||
if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return
|
||||
const options = { ...evt.options }
|
||||
const profile = typeof options.profile === "string" ? options.profile : process.env.AWS_PROFILE
|
||||
const region = typeof options.region === "string" ? options.region : (process.env.AWS_REGION ?? "us-east-1")
|
||||
|
|
@ -86,13 +99,23 @@ export const AmazonBedrockPlugin = PluginV2.define({
|
|||
options.credentialProvider = fromNodeProviderChain(profile ? { profile } : {})
|
||||
}
|
||||
|
||||
if (evt.package === "@ai-sdk/amazon-bedrock/mantle") {
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock/mantle"))
|
||||
evt.sdk = mod.createBedrockMantle(options)
|
||||
return
|
||||
}
|
||||
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/amazon-bedrock"))
|
||||
evt.sdk = mod.createAmazonBedrock(options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return
|
||||
if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") {
|
||||
evt.language = selectMantleModel(evt.sdk, evt.model.api.id)
|
||||
return
|
||||
}
|
||||
const region = typeof evt.options.region === "string" ? evt.options.region : process.env.AWS_REGION
|
||||
evt.language = evt.sdk.languageModel(resolveModelID(evt.model.apiID, region))
|
||||
evt.language = evt.sdk.languageModel(resolveModelID(evt.model.api.id, region))
|
||||
}),
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ export const AnthropicPlugin = PluginV2.define({
|
|||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/anthropic") continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/anthropic") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.options.headers["anthropic-beta"] =
|
||||
provider.request.headers["anthropic-beta"] =
|
||||
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,14 +16,14 @@ export const AzurePlugin = PluginV2.define({
|
|||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/azure") continue
|
||||
const configured = item.provider.options.aisdk.provider.resourceName
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/azure") continue
|
||||
const configured = item.provider.request.body.resourceName
|
||||
const resourceName =
|
||||
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
|
||||
if (!resourceName) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.options.aisdk.provider.resourceName = resourceName
|
||||
provider.request.body.resourceName = resourceName
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
|
@ -33,7 +33,7 @@ export const AzurePlugin = PluginV2.define({
|
|||
if (
|
||||
!evt.options.resourceName &&
|
||||
!evt.options.baseURL &&
|
||||
(evt.model.endpoint.type !== "aisdk" || !evt.model.endpoint.url)
|
||||
(evt.model.api.type !== "aisdk" || !evt.model.api.url)
|
||||
) {
|
||||
throw new Error(
|
||||
"AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it",
|
||||
|
|
@ -45,7 +45,7 @@ export const AzurePlugin = PluginV2.define({
|
|||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.azure) return
|
||||
evt.language = selectLanguage(evt.sdk, evt.model.apiID, Boolean(evt.options.useCompletionUrls))
|
||||
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))
|
||||
}),
|
||||
}
|
||||
}),
|
||||
|
|
@ -59,17 +59,17 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({
|
|||
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
|
||||
if (!resourceName) return
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (!item.provider.id.includes("azure-cognitive-services")) continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
|
||||
provider.request.body.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
|
||||
})
|
||||
}
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
|
||||
evt.language = selectLanguage(evt.sdk, evt.model.apiID, Boolean(evt.options.useCompletionUrls))
|
||||
evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls))
|
||||
}),
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ export const CerebrasPlugin = PluginV2.define({
|
|||
return {
|
||||
"catalog.transform": Effect.fn(function* (ctx) {
|
||||
for (const item of ctx.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/cerebras") continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/cerebras") continue
|
||||
ctx.provider.update(item.provider.id, (provider) => {
|
||||
provider.options.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
|
||||
provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -14,23 +14,23 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
|
|||
const item = evt.provider.get(providerID)
|
||||
if (!item) return
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (provider.endpoint.type !== "aisdk") return
|
||||
if (provider.endpoint.url) return
|
||||
const accountId = resolveAccountId(provider.options.aisdk.provider)
|
||||
if (accountId) provider.endpoint.url = workersEndpoint(accountId)
|
||||
if (provider.api.type !== "aisdk") return
|
||||
if (provider.api.url) return
|
||||
const accountId = resolveAccountId(provider.request.body)
|
||||
if (accountId) provider.api.url = workersEndpoint(accountId)
|
||||
})
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
if (evt.package !== "@ai-sdk/openai-compatible") return
|
||||
|
||||
if (!hasWorkersEndpoint(evt.model.endpoint)) return
|
||||
if (!hasWorkersEndpoint(evt.model.api)) return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
|
||||
evt.sdk = mod.createOpenAICompatible(sdkOptions(evt.options) as any)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== providerID) return
|
||||
evt.language = evt.sdk.languageModel(evt.model.apiID)
|
||||
evt.language = evt.sdk.languageModel(evt.model.api.id)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
|
|
@ -44,8 +44,8 @@ function workersEndpoint(accountId: string) {
|
|||
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`
|
||||
}
|
||||
|
||||
function hasWorkersEndpoint(endpoint: ProviderV2.Endpoint) {
|
||||
return endpoint.type === "aisdk" && Boolean(endpoint.url)
|
||||
function hasWorkersEndpoint(api: ProviderV2.Api) {
|
||||
return api.type === "aisdk" && Boolean(api.url)
|
||||
}
|
||||
|
||||
function sdkOptions(options: Record<string, any>) {
|
||||
|
|
|
|||
|
|
@ -23,12 +23,12 @@ export const GithubCopilotPlugin = PluginV2.define({
|
|||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
|
||||
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {
|
||||
evt.language = evt.sdk.languageModel(evt.model.apiID)
|
||||
evt.language = evt.sdk.languageModel(evt.model.api.id)
|
||||
return
|
||||
}
|
||||
evt.language = shouldUseResponses(evt.model.apiID)
|
||||
? evt.sdk.responses(evt.model.apiID)
|
||||
: evt.sdk.chat(evt.model.apiID)
|
||||
evt.language = shouldUseResponses(evt.model.api.id)
|
||||
? evt.sdk.responses(evt.model.api.id)
|
||||
: evt.sdk.chat(evt.model.api.id)
|
||||
}),
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
||||
|
|
|
|||
|
|
@ -34,18 +34,16 @@ export const GitLabPlugin = PluginV2.define({
|
|||
if (evt.model.providerID !== ProviderV2.ID.gitlab) return
|
||||
const featureFlags =
|
||||
typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {}
|
||||
if (evt.model.apiID.startsWith("duo-workflow-")) {
|
||||
if (evt.model.api.id.startsWith("duo-workflow-")) {
|
||||
const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider")).pipe(Effect.orDie)
|
||||
const workflowRef =
|
||||
typeof evt.model.options.aisdk.request.workflowRef === "string"
|
||||
? evt.model.options.aisdk.request.workflowRef
|
||||
: undefined
|
||||
typeof evt.model.request.body.workflowRef === "string" ? evt.model.request.body.workflowRef : undefined
|
||||
const workflowDefinition =
|
||||
typeof evt.model.options.aisdk.request.workflowDefinition === "string"
|
||||
? evt.model.options.aisdk.request.workflowDefinition
|
||||
typeof evt.model.request.body.workflowDefinition === "string"
|
||||
? evt.model.request.body.workflowDefinition
|
||||
: undefined
|
||||
const language = evt.sdk.workflowChat(
|
||||
gitlab.isWorkflowModel(evt.model.apiID) ? evt.model.apiID : "duo-workflow",
|
||||
gitlab.isWorkflowModel(evt.model.api.id) ? evt.model.api.id : "duo-workflow",
|
||||
{
|
||||
featureFlags,
|
||||
workflowDefinition,
|
||||
|
|
@ -55,7 +53,7 @@ export const GitLabPlugin = PluginV2.define({
|
|||
evt.language = language
|
||||
return
|
||||
}
|
||||
evt.language = evt.sdk.agenticChat(evt.model.apiID, {
|
||||
evt.language = evt.sdk.agenticChat(evt.model.api.id, {
|
||||
aiGatewayHeaders: evt.options.aiGatewayHeaders,
|
||||
featureFlags,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -60,22 +60,22 @@ export const GoogleVertexPlugin = PluginV2.define({
|
|||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (
|
||||
item.provider.endpoint.package !== "@ai-sdk/google-vertex" &&
|
||||
!item.provider.endpoint.package.includes("@ai-sdk/openai-compatible")
|
||||
item.provider.api.package !== "@ai-sdk/google-vertex" &&
|
||||
!item.provider.api.package.includes("@ai-sdk/openai-compatible")
|
||||
)
|
||||
continue
|
||||
const project = resolveProject(item.provider.options.aisdk.provider)
|
||||
const location = String(resolveLocation(item.provider.options.aisdk.provider))
|
||||
const project = resolveProject(item.provider.request.body)
|
||||
const location = String(resolveLocation(item.provider.request.body))
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (project) provider.options.aisdk.provider.project = project
|
||||
provider.options.aisdk.provider.location = location
|
||||
if (provider.endpoint.type === "aisdk" && provider.endpoint.url) {
|
||||
provider.endpoint.url = replaceVertexVars(provider.endpoint.url, project, location)
|
||||
if (project) provider.request.body.project = project
|
||||
provider.request.body.location = location
|
||||
if (provider.api.type === "aisdk" && provider.api.url) {
|
||||
provider.api.url = replaceVertexVars(provider.api.url, project, location)
|
||||
}
|
||||
if (provider.endpoint.type === "aisdk" && provider.endpoint.package.includes("@ai-sdk/openai-compatible")) {
|
||||
provider.options.aisdk.provider.fetch = authFetch(provider.options.aisdk.provider.fetch)
|
||||
if (provider.api.type === "aisdk" && provider.api.package.includes("@ai-sdk/openai-compatible")) {
|
||||
provider.request.body.fetch = authFetch(provider.request.body.fetch)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -99,7 +99,7 @@ export const GoogleVertexPlugin = PluginV2.define({
|
|||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.googleVertex) return
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.apiID).trim())
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())
|
||||
}),
|
||||
}
|
||||
}),
|
||||
|
|
@ -111,21 +111,21 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
|
|||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/google-vertex/anthropic") continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue
|
||||
const project =
|
||||
item.provider.options.aisdk.provider.project ??
|
||||
item.provider.request.body.project ??
|
||||
process.env.GOOGLE_CLOUD_PROJECT ??
|
||||
process.env.GCP_PROJECT ??
|
||||
process.env.GCLOUD_PROJECT
|
||||
const location =
|
||||
item.provider.options.aisdk.provider.location ??
|
||||
item.provider.request.body.location ??
|
||||
process.env.GOOGLE_CLOUD_LOCATION ??
|
||||
process.env.VERTEX_LOCATION ??
|
||||
"global"
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (project) provider.options.aisdk.provider.project = project
|
||||
provider.options.aisdk.provider.location = location
|
||||
if (project) provider.request.body.project = project
|
||||
provider.request.body.location = location
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
|
@ -155,7 +155,7 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
|
|||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.apiID).trim())
|
||||
evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim())
|
||||
}),
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ export const KiloPlugin = PluginV2.define({
|
|||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.endpoint.url !== "https://api.kilo.ai/api/gateway") continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.options.headers["X-Title"] = "opencode"
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ export const LLMGatewayPlugin = PluginV2.define({
|
|||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.enabled === false) continue
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.endpoint.url !== "https://api.llmgateway.io/v1") continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.options.headers["X-Title"] = "opencode"
|
||||
provider.options.headers["X-Source"] = "opencode"
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
provider.request.headers["X-Source"] = "opencode"
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ export const NvidiaPlugin = PluginV2.define({
|
|||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.endpoint.url !== "https://integrate.api.nvidia.com/v1") continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.options.headers["X-Title"] = "opencode"
|
||||
provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ export const OpenAIPlugin = PluginV2.define({
|
|||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.openai) return
|
||||
evt.language = evt.sdk.responses(evt.model.apiID)
|
||||
evt.language = evt.sdk.responses(evt.model.api.id)
|
||||
}),
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/openai") continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai") continue
|
||||
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
|
||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ export const OpencodePlugin = PluginV2.define({
|
|||
hasKey = Boolean(
|
||||
process.env.OPENCODE_API_KEY ||
|
||||
item.provider.env.some((env) => process.env[env]) ||
|
||||
item.provider.options.aisdk.provider.apiKey ||
|
||||
item.provider.request.body.apiKey ||
|
||||
(item.provider.enabled && item.provider.enabled.via === "account"),
|
||||
)
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
if (!hasKey) provider.options.aisdk.provider.apiKey = "public"
|
||||
if (!hasKey) provider.request.body.apiKey = "public"
|
||||
})
|
||||
if (hasKey) return
|
||||
for (const model of item.models.values()) {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ export const OpenRouterPlugin = PluginV2.define({
|
|||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@openrouter/ai-sdk-provider") continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.options.headers["X-Title"] = "opencode"
|
||||
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] = "opencode"
|
||||
})
|
||||
for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) {
|
||||
if (!item.models.has(modelID)) continue
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export const SapAICorePlugin = PluginV2.define({
|
|||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
|
||||
evt.language = evt.sdk(evt.model.apiID)
|
||||
evt.language = evt.sdk(evt.model.api.id)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ export const VercelPlugin = PluginV2.define({
|
|||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/vercel") continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/vercel") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.options.headers["http-referer"] = "https://opencode.ai/"
|
||||
provider.options.headers["x-title"] = "opencode"
|
||||
provider.request.headers["http-referer"] = "https://opencode.ai/"
|
||||
provider.request.headers["x-title"] = "opencode"
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export const XAIPlugin = PluginV2.define({
|
|||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.make("xai")) return
|
||||
evt.language = evt.sdk.responses(evt.model.apiID)
|
||||
evt.language = evt.sdk.responses(evt.model.api.id)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ export const ZenmuxPlugin = PluginV2.define({
|
|||
return {
|
||||
"catalog.transform": Effect.fn(function* (evt) {
|
||||
for (const item of evt.provider.list()) {
|
||||
if (item.provider.endpoint.type !== "aisdk") continue
|
||||
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.endpoint.url !== "https://zenmux.ai/api/v1") continue
|
||||
if (item.provider.api.type !== "aisdk") continue
|
||||
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
|
||||
if (item.provider.api.url !== "https://zenmux.ai/api/v1") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/"
|
||||
provider.options.headers["X-Title"] ??= "opencode"
|
||||
provider.request.headers["HTTP-Referer"] ??= "https://opencode.ai/"
|
||||
provider.request.headers["X-Title"] ??= "opencode"
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
32
packages/core/src/plugin/skill.ts
Normal file
32
packages/core/src/plugin/skill.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export * as SkillPlugin from "./skill"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { PluginV2 } from "../plugin"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { SkillV2 } from "../skill"
|
||||
|
||||
export const Plugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("skill"),
|
||||
effect: Effect.gen(function* () {
|
||||
const skill = yield* SkillV2.Service
|
||||
const transform = yield* skill.transform()
|
||||
const content = yield* Effect.promise(() =>
|
||||
Bun.file(new URL("./skill/customize-opencode.md", import.meta.url)).text(),
|
||||
)
|
||||
|
||||
yield* transform((editor) => {
|
||||
editor.source(
|
||||
new SkillV2.EmbeddedSource({
|
||||
type: "embedded",
|
||||
skill: new SkillV2.Info({
|
||||
name: "customize-opencode",
|
||||
description:
|
||||
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
|
||||
location: AbsolutePath.make("/builtin/customize-opencode.md"),
|
||||
content,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
})
|
||||
376
packages/core/src/plugin/skill/customize-opencode.md
Normal file
376
packages/core/src/plugin/skill/customize-opencode.md
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
<!--
|
||||
Built-in skill. Name and description are registered in code at
|
||||
packages/core/src/plugin/skill.ts
|
||||
and CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION). The body below becomes the
|
||||
skill's content.
|
||||
-->
|
||||
|
||||
# Customizing opencode
|
||||
|
||||
opencode validates its own config strictly and refuses to start when a field
|
||||
is wrong. The shapes below cover the common surface area, but they are a
|
||||
**summary, not the source of truth**.
|
||||
|
||||
## Full schema reference
|
||||
|
||||
The authoritative list of every config option — with field types, enums,
|
||||
defaults, and descriptions — lives in the published JSON Schema:
|
||||
|
||||
**<https://opencode.ai/config.json>**
|
||||
|
||||
If a field is not documented in this skill, or you need to confirm an exact
|
||||
shape before writing config, **fetch that URL and read the schema directly**
|
||||
rather than guessing. opencode hard-fails on invalid config, so the cost of a
|
||||
wrong shape is a broken startup.
|
||||
|
||||
Independently, every `opencode.json` should declare
|
||||
`"$schema": "https://opencode.ai/config.json"` so the user's editor catches
|
||||
mistakes as they type.
|
||||
|
||||
## Applying changes
|
||||
|
||||
Config is loaded once when opencode starts and is not hot-reloaded. After
|
||||
saving changes to `opencode.json`, an agent file, a skill, a plugin, or any
|
||||
other config-time file, **tell the user to quit and restart opencode** for
|
||||
the changes to take effect. The running session will keep using the
|
||||
already-loaded config until then.
|
||||
|
||||
## Where files live
|
||||
|
||||
| Scope | Path |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) |
|
||||
| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) |
|
||||
| Project agents | `.opencode/agent/<name>.md` or `.opencode/agents/<name>.md` |
|
||||
| Global agents | `~/.config/opencode/agent(s)/<name>.md` |
|
||||
| Project skills | `.opencode/skill(s)/<name>/SKILL.md` |
|
||||
| Global skills | `~/.config/opencode/skill(s)/<name>/SKILL.md` |
|
||||
| External skills (auto-loaded) | `~/.claude/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` |
|
||||
|
||||
Configs from each scope are deep-merged. Project overrides global. Unknown
|
||||
top-level keys in `opencode.json` are rejected with `ConfigInvalidError`.
|
||||
|
||||
## opencode.json
|
||||
|
||||
Every field is optional.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"username": "string",
|
||||
"model": "provider/model-id",
|
||||
"small_model": "provider/model-id",
|
||||
"default_agent": "agent-name",
|
||||
"shell": "/bin/zsh",
|
||||
"logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR",
|
||||
"share": "manual" | "auto" | "disabled",
|
||||
"autoupdate": true | false | "notify",
|
||||
"snapshot": true,
|
||||
"instructions": ["AGENTS.md", "docs/style.md"],
|
||||
|
||||
"skills": {
|
||||
"paths": [".opencode/skills", "/abs/path/to/skills"],
|
||||
"urls": ["https://example.com/.well-known/skills/"]
|
||||
},
|
||||
|
||||
"agent": {
|
||||
"my-agent": {
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"mode": "subagent",
|
||||
"description": "...",
|
||||
"permission": { "edit": "deny" }
|
||||
}
|
||||
},
|
||||
|
||||
"command": {
|
||||
"deploy": { "description": "...", "prompt": "..." }
|
||||
},
|
||||
|
||||
"provider": {
|
||||
"anthropic": { "options": { "apiKey": "..." } }
|
||||
},
|
||||
"disabled_providers": ["openai"],
|
||||
"enabled_providers": ["anthropic"],
|
||||
|
||||
"mcp": {
|
||||
"playwright": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "@playwright/mcp"],
|
||||
"enabled": true,
|
||||
"env": {}
|
||||
},
|
||||
"remote-thing": {
|
||||
"type": "remote",
|
||||
"url": "https://...",
|
||||
"headers": { "Authorization": "Bearer ..." }
|
||||
}
|
||||
},
|
||||
|
||||
"plugin": [
|
||||
"opencode-gemini-auth",
|
||||
"opencode-foo@1.2.3",
|
||||
"./local-plugin.ts",
|
||||
["opencode-bar", { "option": "value" }]
|
||||
],
|
||||
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": { "git *": "allow", "*": "ask" }
|
||||
},
|
||||
|
||||
"formatter": false,
|
||||
"lsp": false,
|
||||
|
||||
"experimental": {
|
||||
"primary_tools": ["edit"],
|
||||
"mcp_timeout": 30000
|
||||
},
|
||||
|
||||
"tool_output": { "max_lines": 200, "max_bytes": 8192 },
|
||||
|
||||
"compaction": { "auto": true, "tail_turns": 15 }
|
||||
}
|
||||
```
|
||||
|
||||
Shape notes worth being explicit about:
|
||||
|
||||
- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`.
|
||||
- `skills` is an object with `paths` and/or `urls`, not an array.
|
||||
- `agent` is an object keyed by agent name, not an array.
|
||||
- `plugin` is an array of strings or `[name, options]` tuples, not an object.
|
||||
- `mcp[name].command` is an array of strings, never a single string. `type` is required.
|
||||
- `permission` is either a string action or an object keyed by tool name.
|
||||
|
||||
## Skills
|
||||
|
||||
opencode's skill loader scans for `**/SKILL.md` inside skill directories. The
|
||||
file is named `SKILL.md` exactly, and lives in its own folder named after the
|
||||
skill:
|
||||
|
||||
```
|
||||
.opencode/skills/my-skill/SKILL.md
|
||||
```
|
||||
|
||||
Frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: One sentence covering what this skill does AND when to trigger it. Front-load the literal keywords or filenames the user is likely to say.
|
||||
---
|
||||
|
||||
# My Skill
|
||||
|
||||
(skill body in markdown: instructions, examples, references)
|
||||
```
|
||||
|
||||
- `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name.
|
||||
- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both _what_ the skill does and _when_ to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics.
|
||||
- Optional: `license`, `compatibility`, `metadata` (string-string map).
|
||||
|
||||
Register skills from non-default locations via `skills.paths` (scanned
|
||||
recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of
|
||||
skills).
|
||||
|
||||
## Agents
|
||||
|
||||
Two ways to define an agent. Use the file form for anything non-trivial.
|
||||
|
||||
### Inline (in `opencode.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"my-reviewer": {
|
||||
"description": "Reviews PRs for style violations.",
|
||||
"mode": "subagent",
|
||||
"model": "anthropic/claude-sonnet-4-6",
|
||||
"permission": { "edit": "deny", "bash": "ask" },
|
||||
"prompt": "You are a strict PR reviewer..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File
|
||||
|
||||
```
|
||||
.opencode/agent/my-reviewer.md OR .opencode/agents/my-reviewer.md
|
||||
```
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Reviews PRs for style violations.
|
||||
mode: subagent
|
||||
model: anthropic/claude-sonnet-4-6
|
||||
permission:
|
||||
edit: deny
|
||||
bash: ask
|
||||
---
|
||||
|
||||
You are a strict PR reviewer. Focus on...
|
||||
```
|
||||
|
||||
The file body becomes the agent's `prompt`. Do not also put `prompt:` in the
|
||||
frontmatter.
|
||||
|
||||
`mode` is one of `"primary"`, `"subagent"`, `"all"`.
|
||||
|
||||
Allowed top-level frontmatter fields: `name, model, variant, description, mode,
|
||||
hidden, color, steps, options, permission, disable, temperature, top_p`. Any
|
||||
unknown field is silently routed into `options`.
|
||||
|
||||
To disable a built-in agent: `agent: { build: { disable: true } }`, or in a
|
||||
file, `disable: true` in frontmatter.
|
||||
|
||||
`default_agent` must point to a non-hidden, primary-mode agent.
|
||||
|
||||
### Built-in agents
|
||||
|
||||
opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agents:
|
||||
`compaction`, `title`, `summary`. To override a built-in's fields, define the
|
||||
same key in `agent: { <name>: { ... } }`.
|
||||
|
||||
## Plugins
|
||||
|
||||
`plugin:` is an array. Each entry is one of:
|
||||
|
||||
```json
|
||||
"plugin": [
|
||||
"opencode-gemini-auth", // npm spec, latest
|
||||
"opencode-foo@1.2.3", // npm spec, pinned
|
||||
"./local-plugin.ts", // file path, relative to the declaring config
|
||||
"file:///abs/path/plugin.js", // file URL
|
||||
["opencode-bar", { "key": "val" }] // tuple form with options
|
||||
]
|
||||
```
|
||||
|
||||
Auto-discovered plugins (no config entry needed): any `*.ts` or `*.js` file in
|
||||
`.opencode/plugin/` or `.opencode/plugins/`.
|
||||
|
||||
A plugin module exports `default` (or any named export) of type
|
||||
`Plugin = (input: PluginInput, options?) => Promise<Hooks>`. The export is a
|
||||
function, not a plain object literal, and the function returns an object
|
||||
(return `{}` if there is nothing to register).
|
||||
|
||||
```ts
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
|
||||
export default (async ({ client, project, directory, $ }) => {
|
||||
return {
|
||||
config: (cfg) => {
|
||||
// cfg is the live merged config; mutate fields here.
|
||||
},
|
||||
"tool.execute.before": async (input, output) => {
|
||||
// mutate output.args before the tool runs
|
||||
},
|
||||
}
|
||||
}) satisfies Plugin
|
||||
```
|
||||
|
||||
Hook surface (mutate `output` in place; return `void`):
|
||||
|
||||
- `event(input)`: every bus event
|
||||
- `config(cfg)`: once on init with the merged config
|
||||
- `chat.message`, `chat.params`, `chat.headers`
|
||||
- `tool.execute.before`, `tool.execute.after`
|
||||
- `tool.definition`
|
||||
- `command.execute.before`
|
||||
- `shell.env`
|
||||
- `permission.ask`
|
||||
- `experimental.chat.messages.transform`, `experimental.chat.system.transform`,
|
||||
`experimental.session.compacting`, `experimental.compaction.autocontinue`,
|
||||
`experimental.text.complete`
|
||||
|
||||
Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`,
|
||||
`auth: { ... }`, `provider: { ... }`.
|
||||
|
||||
## MCP servers
|
||||
|
||||
`mcp:` is an object keyed by server name. Each server is discriminated by
|
||||
`type`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"playwright": {
|
||||
"type": "local",
|
||||
"command": ["npx", "-y", "@playwright/mcp"],
|
||||
"enabled": true,
|
||||
"env": { "BROWSER": "chromium" }
|
||||
},
|
||||
"github": {
|
||||
"type": "remote",
|
||||
"url": "https://...",
|
||||
"enabled": true,
|
||||
"headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" }
|
||||
},
|
||||
"old-server": { "enabled": false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`command` is an array of strings. `type` is required. Use `enabled: false` to
|
||||
disable a server inherited from a parent config.
|
||||
|
||||
## Permissions
|
||||
|
||||
```json
|
||||
"permission": {
|
||||
"edit": "deny",
|
||||
"bash": { "git *": "allow", "rm *": "deny", "*": "ask" },
|
||||
"external_directory": { "~/secrets/**": "deny", "*": "allow" }
|
||||
}
|
||||
```
|
||||
|
||||
Actions: `"allow"`, `"ask"`, `"deny"`.
|
||||
|
||||
Per-tool value forms: `"allow"` shorthand (treated as `{"*": "allow"}`), or an
|
||||
object `{ pattern: action }`. Within an object, **insertion order matters**.
|
||||
opencode evaluates the LAST matching rule, so put broad rules first and narrow
|
||||
rules last.
|
||||
|
||||
`permission: "allow"` (a string at the top level) is shorthand for "allow
|
||||
everything" and is rarely what the user wants.
|
||||
|
||||
Known permission keys: `read, edit, glob, grep, list, bash, task,
|
||||
external_directory, todowrite, question, webfetch, websearch, lsp, doom_loop,
|
||||
skill`. Some of these (`todowrite,
|
||||
question, webfetch, websearch, doom_loop`) only accept a flat
|
||||
action, not a per-pattern object.
|
||||
|
||||
`external_directory` patterns are filesystem paths (use `~/`, absolute paths,
|
||||
or globs like `~/projects/**`).
|
||||
|
||||
Per-agent `permission:` overrides top-level `permission:`. Plan Mode lives on
|
||||
the `plan` agent's permission ruleset (`edit: deny *`).
|
||||
|
||||
## Escape hatches
|
||||
|
||||
When a user's config is broken and opencode won't start, these env vars help:
|
||||
|
||||
- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json`
|
||||
and start from globals only. Run from the project directory, opencode loads,
|
||||
the user edits the broken file, then they restart without the flag.
|
||||
- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config.
|
||||
- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`:
|
||||
inject inline JSON as a final local-scope merge.
|
||||
- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins.
|
||||
- `OPENCODE_PURE=1`: skip external plugins entirely.
|
||||
- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`,
|
||||
`OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under
|
||||
`~/.claude/` and `~/.agents/`.
|
||||
|
||||
## When proposing edits
|
||||
|
||||
- Validate against the schema before writing. If you are unsure of a field's
|
||||
exact shape, or the field is not covered in this skill, fetch
|
||||
`https://opencode.ai/config.json` and read the schema rather than guessing.
|
||||
- Preserve `$schema` and any existing fields the user did not ask to change.
|
||||
- For agent, skill, and plugin definitions, prefer creating new files in the
|
||||
correct location over inlining everything in `opencode.json`.
|
||||
- If the user's existing config is malformed, point them at the env-var escape
|
||||
hatches above so they can edit from inside opencode without breaking their
|
||||
session.
|
||||
- After saving any config change, remind the user to quit and restart opencode
|
||||
— running sessions keep using the already-loaded config.
|
||||
|
|
@ -79,7 +79,7 @@ const describeCommand = (command: ChildProcess.Command): string => {
|
|||
const wrapError = (description: string, cause: unknown): AppProcessError =>
|
||||
cause instanceof AppProcessError ? cause : new AppProcessError({ command: description, cause })
|
||||
|
||||
const abortError = (signal: AbortSignal): Error => {
|
||||
export const abortError = (signal: AbortSignal): Error => {
|
||||
const reason = signal.reason
|
||||
if (reason instanceof Error) return reason
|
||||
const err = new Error("Aborted")
|
||||
|
|
@ -87,7 +87,7 @@ const abortError = (signal: AbortSignal): Error => {
|
|||
return err
|
||||
}
|
||||
|
||||
const waitForAbort = (signal: AbortSignal) =>
|
||||
export const waitForAbort = (signal: AbortSignal) =>
|
||||
Effect.callback<never, Error>((resume) => {
|
||||
if (signal.aborted) {
|
||||
resume(Effect.fail(abortError(signal)))
|
||||
|
|
@ -107,7 +107,7 @@ const normalizeStdin = (
|
|||
? Stream.make(input)
|
||||
: input
|
||||
|
||||
const collectStream = (stream: Stream.Stream<Uint8Array, PlatformError>, maxOutputBytes: number | undefined) =>
|
||||
export const collectStream = (stream: Stream.Stream<Uint8Array, PlatformError>, maxOutputBytes: number | undefined) =>
|
||||
Stream.runFold(
|
||||
stream,
|
||||
() => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }),
|
||||
|
|
|
|||
241
packages/core/src/project-reference.ts
Normal file
241
packages/core/src/project-reference.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
export * as ProjectReference from "./project-reference"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { ConfigReference } from "./config/reference"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Flag } from "./flag/flag"
|
||||
import { Global } from "./global"
|
||||
import { Location } from "./location"
|
||||
import { Repository } from "./repository"
|
||||
import { RepositoryCache } from "./repository-cache"
|
||||
|
||||
export type Resolved =
|
||||
| { readonly name: string; readonly kind: "local"; readonly path: string }
|
||||
| {
|
||||
readonly name: string
|
||||
readonly kind: "git"
|
||||
readonly repository: string
|
||||
readonly reference: Repository.RemoteReference
|
||||
readonly path: string
|
||||
readonly branch?: string
|
||||
}
|
||||
| { readonly name: string; readonly kind: "invalid"; readonly repository?: string; readonly message: string }
|
||||
|
||||
type Valid = Exclude<Resolved, { kind: "invalid" }>
|
||||
|
||||
export type Mention =
|
||||
| {
|
||||
readonly name: string
|
||||
readonly kind: "reference"
|
||||
readonly reference: Valid
|
||||
readonly target?: string
|
||||
readonly path: string
|
||||
}
|
||||
| { readonly name: string; readonly kind: "invalid"; readonly target?: string; readonly message: string }
|
||||
| {
|
||||
readonly name: string
|
||||
readonly kind: "missing"
|
||||
readonly target: string
|
||||
readonly path: string
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<Resolved[]>
|
||||
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
|
||||
readonly resolveMention: (value: string) => Effect.Effect<Mention | undefined, RepositoryCache.Error>
|
||||
readonly ensurePath: (target?: string) => Effect.Effect<void, RepositoryCache.Error>
|
||||
readonly containsManagedPath: (target?: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectReference") {}
|
||||
|
||||
type Materializer = {
|
||||
readonly name: string
|
||||
readonly repository: string
|
||||
readonly path: string
|
||||
readonly run: Effect.Effect<void, RepositoryCache.Error>
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_REFERENCES) return Service.of(inert)
|
||||
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const references = resolveAll({
|
||||
references: ConfigReference.normalize(
|
||||
Object.assign(
|
||||
{},
|
||||
...(yield* config.entries())
|
||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
.map((document) => document.info.references ?? {}),
|
||||
),
|
||||
),
|
||||
directory: location.project.directory,
|
||||
home: global.home,
|
||||
repos: global.repos,
|
||||
})
|
||||
const materializers = yield* Effect.forEach(
|
||||
uniqueGitReferences(references),
|
||||
Effect.fnUntraced(function* (reference) {
|
||||
return {
|
||||
name: reference.name,
|
||||
repository: reference.repository,
|
||||
path: reference.path,
|
||||
run: yield* Effect.cached(
|
||||
cache
|
||||
.ensure({ reference: reference.reference, branch: reference.branch, refresh: true })
|
||||
.pipe(Effect.asVoid),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
yield* Effect.forEach(
|
||||
materializers,
|
||||
(materializer) =>
|
||||
materializer.run.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize project reference").pipe(
|
||||
Effect.annotateLogs({ name: materializer.name, repository: materializer.repository, cause }),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ concurrency: 4, discard: true },
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
const ensurePath = Effect.fn("ProjectReference.ensurePath")(function* (target?: string) {
|
||||
const normalized = normalizePath(target)
|
||||
if (!normalized)
|
||||
return yield* Effect.forEach(materializers, (materializer) => materializer.run, { discard: true })
|
||||
yield* materializers.find((materializer) => contains(materializer.path, normalized))?.run ?? Effect.void
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
list: Effect.fn("ProjectReference.list")(function* () {
|
||||
return references
|
||||
}),
|
||||
get: Effect.fn("ProjectReference.get")(function* (name: string) {
|
||||
return references.find((reference) => reference.name === name)
|
||||
}),
|
||||
ensurePath,
|
||||
containsManagedPath: Effect.fn("ProjectReference.containsManagedPath")(function* (target?: string) {
|
||||
const normalized = normalizePath(target)
|
||||
return normalized
|
||||
? references.some((reference) => reference.kind === "git" && contains(reference.path, normalized))
|
||||
: false
|
||||
}),
|
||||
resolveMention: Effect.fn("ProjectReference.resolveMention")(function* (value: string) {
|
||||
const [name, ...rest] = value.split("/")
|
||||
const target = rest.length ? rest.join("/") : undefined
|
||||
const reference = references.find((reference) => reference.name === name)
|
||||
if (!reference) return
|
||||
if (reference.kind === "invalid") return { name, kind: "invalid", target, message: reference.message }
|
||||
if (reference.kind === "git") yield* ensurePath(reference.path)
|
||||
if (!target) return { name, kind: "reference", reference, path: reference.path }
|
||||
|
||||
const resolved = path.resolve(reference.path, target)
|
||||
if (!FSUtil.contains(reference.path, resolved))
|
||||
return { name, kind: "invalid", target, message: "Reference target escapes its root" }
|
||||
if (!(yield* fs.existsSafe(resolved)))
|
||||
return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" }
|
||||
return { name, kind: "reference", reference, target, path: resolved }
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
|
||||
|
||||
const inert: Interface = {
|
||||
list: () => Effect.succeed([]),
|
||||
get: () => Effect.succeed(undefined),
|
||||
resolveMention: () => Effect.succeed(undefined),
|
||||
ensurePath: () => Effect.void,
|
||||
containsManagedPath: () => Effect.succeed(false),
|
||||
}
|
||||
|
||||
export function resolveAll(input: {
|
||||
references: ConfigReference.NormalizedInfo
|
||||
directory: string
|
||||
home: string
|
||||
repos: string
|
||||
}) {
|
||||
const seen = new Map<string, { name: string; branch?: string }>()
|
||||
return Object.entries(input.references).map(([name, reference]): Resolved => {
|
||||
const resolved = resolve({ name, reference, directory: input.directory, home: input.home, repos: input.repos })
|
||||
if (resolved.kind !== "git") return resolved
|
||||
const existing = seen.get(resolved.path)
|
||||
if (!existing) {
|
||||
seen.set(resolved.path, { name, branch: resolved.branch })
|
||||
return resolved
|
||||
}
|
||||
if (existing.branch === resolved.branch) return resolved
|
||||
return {
|
||||
name,
|
||||
kind: "invalid",
|
||||
repository: resolved.repository,
|
||||
message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${existing.branch ?? "default branch"} and @${name} requests ${resolved.branch ?? "default branch"}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function resolve(input: {
|
||||
name: string
|
||||
reference: ConfigReference.NormalizedEntry
|
||||
directory: string
|
||||
home: string
|
||||
repos: string
|
||||
}): Resolved {
|
||||
if (input.reference.kind === "invalid") return { name: input.name, kind: "invalid", message: input.reference.message }
|
||||
if (input.reference.kind === "local") {
|
||||
return { name: input.name, kind: "local", path: localPath(input.directory, input.home, input.reference.path) }
|
||||
}
|
||||
const reference = Repository.parse(input.reference.repository)
|
||||
if (!reference || !Repository.isRemote(reference)) {
|
||||
return {
|
||||
name: input.name,
|
||||
kind: "invalid",
|
||||
repository: input.reference.repository,
|
||||
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: input.name,
|
||||
kind: "git",
|
||||
repository: input.reference.repository,
|
||||
reference,
|
||||
path: Repository.cachePath(input.repos, reference),
|
||||
branch: input.reference.branch,
|
||||
}
|
||||
}
|
||||
|
||||
function localPath(directory: string, home: string, value: string) {
|
||||
if (value.startsWith("~/")) return path.join(home, value.slice(2))
|
||||
return path.isAbsolute(value) ? value : path.resolve(directory, value)
|
||||
}
|
||||
|
||||
function uniqueGitReferences(references: Resolved[]) {
|
||||
const seen = new Set<string>()
|
||||
return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
|
||||
if (reference.kind !== "git" || seen.has(reference.path)) return false
|
||||
seen.add(reference.path)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function normalizePath(target?: string) {
|
||||
if (!target) return
|
||||
return process.platform === "win32" ? FSUtil.normalizePath(target) : target
|
||||
}
|
||||
|
||||
function contains(parent: string, child: string) {
|
||||
return FSUtil.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child)
|
||||
}
|
||||
|
|
@ -2,11 +2,14 @@ export * as ProjectV2 from "./project"
|
|||
export * as Project from "./project"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath, withStatics } from "./schema"
|
||||
import { AppFileSystem } from "./filesystem"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Database } from "./database/database"
|
||||
import { Git } from "./git"
|
||||
import { Hash } from "./util/hash"
|
||||
import { ProjectDirectoryTable } from "./project/sql"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Project.ID"),
|
||||
|
|
@ -28,7 +31,16 @@ export class Info extends Schema.Class<Info>("Project.Info")({
|
|||
id: ID,
|
||||
}) {}
|
||||
|
||||
export const DirectoriesInput = Schema.Struct({
|
||||
projectID: ID,
|
||||
}).annotate({ identifier: "Project.DirectoriesInput" })
|
||||
export type DirectoriesInput = typeof DirectoriesInput.Type
|
||||
|
||||
export const Directories = Schema.Array(AbsolutePath).annotate({ identifier: "Project.Directories" })
|
||||
export type Directories = typeof Directories.Type
|
||||
|
||||
export interface Interface {
|
||||
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
|
||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<
|
||||
{
|
||||
previous?: ID
|
||||
|
|
@ -55,9 +67,22 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pr
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
|
||||
const rows = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, input.projectID))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows
|
||||
.toSorted((a, b) => a.directory.localeCompare(b.directory))
|
||||
.map((row) => AbsolutePath.make(row.directory))
|
||||
})
|
||||
|
||||
const cached = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
|
||||
Effect.map((value) => value.trim()),
|
||||
|
|
@ -109,7 +134,6 @@ export const layer = Layer.effect(
|
|||
|
||||
const previous = yield* cached(repo.store)
|
||||
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
|
||||
|
||||
return {
|
||||
previous,
|
||||
id: id ?? ID.global,
|
||||
|
|
@ -122,8 +146,12 @@ export const layer = Layer.effect(
|
|||
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
return Service.of({ resolve, commit })
|
||||
return Service.of({ directories, resolve, commit })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
)
|
||||
|
|
|
|||
41
packages/core/src/project/copy-strategies.ts
Normal file
41
packages/core/src/project/copy-strategies.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Git } from "../git"
|
||||
import { DirectoryUnavailableError, type Copy, type Strategy, type StrategyID } from "./copy"
|
||||
|
||||
export function makeStrategies(input: {
|
||||
git: Git.Interface
|
||||
fs: FSUtil.Interface
|
||||
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
|
||||
}) {
|
||||
const repo = (sourceDirectory: AbsolutePath) =>
|
||||
({ directory: sourceDirectory, store: sourceDirectory }) satisfies Git.Repo
|
||||
|
||||
const gitWorktree: Strategy = {
|
||||
id: "git_worktree",
|
||||
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
|
||||
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
|
||||
return { directory: yield* input.canonical(options.directory) }
|
||||
}),
|
||||
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (directory) {
|
||||
const found = yield* input.git.find(directory)
|
||||
if (!found) return yield* new DirectoryUnavailableError({ directory })
|
||||
yield* input.git.worktreeRemove({ repo: found, directory })
|
||||
}),
|
||||
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
|
||||
const entries = yield* input.git.worktreeList(repo(directory))
|
||||
return yield* Effect.forEach(entries, (entry) =>
|
||||
entry === directory
|
||||
? Effect.succeed(undefined)
|
||||
: input.canonical(entry).pipe(Effect.map((directory) => ({ directory }))),
|
||||
).pipe(Effect.map((items) => items.filter((item): item is Copy => item !== undefined)))
|
||||
}),
|
||||
detect: Effect.fn("ProjectCopy.GitWorktree.detect")(function* (inputDirectory) {
|
||||
return yield* input.fs.isFile(path.join(inputDirectory, ".git"))
|
||||
}),
|
||||
}
|
||||
|
||||
return new Map<StrategyID, Strategy>([[gitWorktree.id, gitWorktree]])
|
||||
}
|
||||
261
packages/core/src/project/copy.ts
Normal file
261
packages/core/src/project/copy.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
export * as ProjectCopy from "./copy"
|
||||
|
||||
import { and, eq, inArray } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Git } from "../git"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { Project } from "../project"
|
||||
import { ProjectDirectoryTable } from "./sql"
|
||||
import { makeStrategies } from "./copy-strategies"
|
||||
|
||||
export const StrategyID = Schema.Literal("git_worktree")
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
|
||||
export const DetectInput = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
}).annotate({ identifier: "ProjectCopy.DetectInput" })
|
||||
export type DetectInput = typeof DetectInput.Type
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
projectID: Project.ID,
|
||||
strategy: StrategyID,
|
||||
sourceDirectory: AbsolutePath,
|
||||
directory: AbsolutePath,
|
||||
}).annotate({ identifier: "ProjectCopy.CreateInput" })
|
||||
export type CreateInput = typeof CreateInput.Type
|
||||
|
||||
export const RemoveInput = Schema.Struct({
|
||||
projectID: Project.ID,
|
||||
directory: AbsolutePath,
|
||||
}).annotate({ identifier: "ProjectCopy.RemoveInput" })
|
||||
export type RemoveInput = typeof RemoveInput.Type
|
||||
|
||||
export const RefreshInput = Schema.Struct({
|
||||
projectID: Project.ID,
|
||||
}).annotate({ identifier: "ProjectCopy.RefreshInput" })
|
||||
export type RefreshInput = typeof RefreshInput.Type
|
||||
|
||||
export const Copy = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
}).annotate({ identifier: "ProjectCopy.Copy" })
|
||||
export type Copy = typeof Copy.Type
|
||||
|
||||
export type DirectoryType = "main" | "root" | StrategyID
|
||||
|
||||
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
|
||||
"ProjectCopy.SourceDirectoryNotFoundError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
|
||||
"ProjectCopy.DestinationExistsError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
|
||||
"ProjectCopy.DirectoryUnavailableError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class StrategyNotFoundError extends Schema.TaggedErrorClass<StrategyNotFoundError>()(
|
||||
"ProjectCopy.StrategyNotFoundError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| SourceDirectoryNotFoundError
|
||||
| DestinationExistsError
|
||||
| DirectoryUnavailableError
|
||||
| StrategyNotFoundError
|
||||
| Git.WorktreeError
|
||||
|
||||
export interface Strategy {
|
||||
readonly id: StrategyID
|
||||
readonly create: (input: {
|
||||
sourceDirectory: AbsolutePath
|
||||
directory: AbsolutePath
|
||||
}) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly remove: (directory: AbsolutePath) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<Copy[], Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly detect: (directory: AbsolutePath) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export const Event = {
|
||||
Updated: EventV2.define({
|
||||
type: "project.directories.updated",
|
||||
schema: { projectID: Project.ID },
|
||||
}),
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly detect: (input: DetectInput) => Effect.Effect<StrategyID | undefined>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
||||
readonly refresh: (input: RefreshInput) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const events = yield* EventV2.Service
|
||||
const db = (yield* Database.Service).db
|
||||
|
||||
const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
|
||||
const resolved = AbsolutePath.make(FSUtil.resolve(input))
|
||||
if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
|
||||
return resolved
|
||||
})
|
||||
|
||||
const registry = makeStrategies({ git, fs, canonical })
|
||||
|
||||
const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
|
||||
const sourceDirectory = yield* canonical(input)
|
||||
const row = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(
|
||||
and(eq(ProjectDirectoryTable.project_id, projectID), eq(ProjectDirectoryTable.directory, sourceDirectory)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
|
||||
return sourceDirectory
|
||||
})
|
||||
|
||||
const insert = Effect.fnUntraced(function* (projectID: Project.ID, copyDirectory: AbsolutePath, type: StrategyID) {
|
||||
return yield* db
|
||||
.transaction(
|
||||
(tx) =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* tx
|
||||
.select({ directory: ProjectDirectoryTable.directory })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ProjectDirectoryTable.project_id, projectID),
|
||||
eq(ProjectDirectoryTable.directory, copyDirectory),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
if (row) return false
|
||||
yield* tx
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: projectID, directory: copyDirectory, type })
|
||||
.run()
|
||||
return true
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const removeStored = Effect.fnUntraced(function* (projectID: Project.ID, copyDirectory: AbsolutePath) {
|
||||
return (
|
||||
(yield* db
|
||||
.delete(ProjectDirectoryTable)
|
||||
.where(
|
||||
and(eq(ProjectDirectoryTable.project_id, projectID), eq(ProjectDirectoryTable.directory, copyDirectory)),
|
||||
)
|
||||
.returning({ directory: ProjectDirectoryTable.directory })
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
|
||||
if (update) yield* events.publish(Event.Updated, { projectID })
|
||||
})
|
||||
|
||||
const strategy = (id: StrategyID) => registry.get(id) as Strategy
|
||||
|
||||
const detect = Effect.fn("ProjectCopy.detect")(function* (input: DetectInput) {
|
||||
for (const strategy of registry.values()) {
|
||||
if (yield* strategy.detect(input.directory)) return strategy.id
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
|
||||
if (yield* fs.existsSafe(input.directory))
|
||||
return yield* new DestinationExistsError({ directory: input.directory })
|
||||
const result = yield* strategy(input.strategy).create({
|
||||
directory: input.directory,
|
||||
sourceDirectory: yield* source(input.sourceDirectory, input.projectID),
|
||||
})
|
||||
yield* changed(input.projectID, yield* insert(input.projectID, result.directory, input.strategy))
|
||||
return result
|
||||
})
|
||||
|
||||
const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
|
||||
const copyDirectory = yield* canonical(input.directory)
|
||||
const id = yield* detect({ directory: copyDirectory })
|
||||
if (!id) return yield* new StrategyNotFoundError({ directory: copyDirectory })
|
||||
yield* strategy(id).remove(copyDirectory)
|
||||
yield* changed(input.projectID, yield* removeStored(input.projectID, copyDirectory))
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) {
|
||||
const roots = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ProjectDirectoryTable.project_id, input.projectID),
|
||||
inArray(ProjectDirectoryTable.type, ["main", "root"]),
|
||||
),
|
||||
)
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const sourceDirectories = yield* Effect.forEach(roots, (item) => canonical(AbsolutePath.make(item.directory)), {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const discovered = yield* Effect.forEach(
|
||||
sourceDirectories,
|
||||
(sourceDirectory) =>
|
||||
Effect.forEach(registry.values(), (strategy) =>
|
||||
strategy
|
||||
.list(sourceDirectory)
|
||||
.pipe(Effect.map((items) => items.map((item) => ({ ...item, type: strategy.id })))),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(
|
||||
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
|
||||
)
|
||||
const stored = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, input.projectID))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const inserted = yield* Effect.forEach(discovered, (item) =>
|
||||
insert(input.projectID, item.directory, item.type),
|
||||
).pipe(Effect.map((items) => items.some(Boolean)))
|
||||
const removed = yield* Effect.forEach(stored, (item) =>
|
||||
fs
|
||||
.isDir(item.directory)
|
||||
.pipe(
|
||||
Effect.flatMap((exists) =>
|
||||
exists ? Effect.succeed(false) : removeStored(input.projectID, AbsolutePath.make(item.directory)),
|
||||
),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.some(Boolean)))
|
||||
yield* changed(input.projectID, inserted || removed)
|
||||
})
|
||||
|
||||
return Service.of({ detect, create, remove, refresh })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
|
||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
import * as DatabasePath from "../database/path"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import { ProjectV2 } from "../project"
|
||||
|
|
@ -16,3 +16,19 @@ export const ProjectTable = sqliteTable("project", {
|
|||
sandboxes: DatabasePath.absoluteArrayColumn().notNull(),
|
||||
commands: text({ mode: "json" }).$type<{ start?: string }>(),
|
||||
})
|
||||
|
||||
export const ProjectDirectoryTable = sqliteTable(
|
||||
"project_directory",
|
||||
{
|
||||
project_id: text()
|
||||
.$type<ProjectV2.ID>()
|
||||
.notNull()
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
directory: text().notNull(),
|
||||
type: text().$type<"main" | "root" | "git_worktree">().notNull(),
|
||||
time_created: integer()
|
||||
.notNull()
|
||||
.$default(() => Date.now()),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.project_id, table.directory] })],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -22,62 +22,27 @@ export const ID = Schema.String.pipe(
|
|||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const ModelID = Schema.String.pipe(Schema.brand("ModelID"))
|
||||
export type ModelID = typeof ModelID.Type
|
||||
|
||||
const OpenAIResponses = Schema.Struct({
|
||||
type: Schema.Literal("openai/responses"),
|
||||
url: Schema.String,
|
||||
websocket: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
const OpenAICompletions = Schema.Struct({
|
||||
type: Schema.Literal("openai/completions"),
|
||||
url: Schema.String,
|
||||
reasoning: Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("reasoning_content"),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("reasoning_details"),
|
||||
}),
|
||||
]).pipe(Schema.optional),
|
||||
})
|
||||
export type OpenAICompletions = typeof OpenAICompletions.Type
|
||||
|
||||
const AISDK = Schema.Struct({
|
||||
export const AISDK = Schema.Struct({
|
||||
type: Schema.Literal("aisdk"),
|
||||
package: Schema.String,
|
||||
url: Schema.String.pipe(Schema.optional),
|
||||
settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const AnthropicMessages = Schema.Struct({
|
||||
type: Schema.Literal("anthropic/messages"),
|
||||
url: Schema.String,
|
||||
export const Native = Schema.Struct({
|
||||
type: Schema.Literal("native"),
|
||||
url: Schema.String.pipe(Schema.optional),
|
||||
settings: Schema.Record(Schema.String, Schema.Unknown),
|
||||
})
|
||||
|
||||
const UnknownEndpoint = Schema.Struct({
|
||||
type: Schema.Literal("unknown"),
|
||||
})
|
||||
export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Api = typeof Api.Type
|
||||
|
||||
export const Endpoint = Schema.Union([
|
||||
UnknownEndpoint,
|
||||
OpenAIResponses,
|
||||
OpenAICompletions,
|
||||
AnthropicMessages,
|
||||
AISDK,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Endpoint = typeof Endpoint.Type
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
export const Request = Schema.Struct({
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
body: Schema.Record(Schema.String, Schema.Any),
|
||||
aisdk: Schema.Struct({
|
||||
provider: Schema.Record(Schema.String, Schema.Any),
|
||||
request: Schema.Record(Schema.String, Schema.Any),
|
||||
}),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
export type Request = typeof Request.Type
|
||||
|
||||
export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
||||
id: ID,
|
||||
|
|
@ -98,26 +63,23 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
|
|||
}),
|
||||
]),
|
||||
env: Schema.String.pipe(Schema.Array),
|
||||
endpoint: Endpoint,
|
||||
options: Options,
|
||||
api: Api,
|
||||
request: Request,
|
||||
}) {
|
||||
static empty(providerID: ID): Info {
|
||||
return {
|
||||
return new Info({
|
||||
id: providerID,
|
||||
name: providerID,
|
||||
enabled: false,
|
||||
env: [],
|
||||
endpoint: {
|
||||
type: "unknown",
|
||||
api: {
|
||||
type: "native",
|
||||
settings: {},
|
||||
},
|
||||
options: {
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
aisdk: {
|
||||
provider: {},
|
||||
request: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
312
packages/core/src/pty.ts
Normal file
312
packages/core/src/pty.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
export * as Pty from "./pty"
|
||||
|
||||
import type { Disp, Proc } from "#pty"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { EventV2 } from "./event"
|
||||
import { Location } from "./location"
|
||||
import { NonNegativeInt, PositiveInt } from "./schema"
|
||||
import { PtyID } from "./pty/schema"
|
||||
import { lazy } from "./util/lazy"
|
||||
import * as Log from "./util/log"
|
||||
|
||||
const log = Log.create({ service: "pty" })
|
||||
const BUFFER_LIMIT = 1024 * 1024 * 2
|
||||
const BUFFER_CHUNK = 64 * 1024
|
||||
const encoder = new TextEncoder()
|
||||
const pty = lazy(() => import("#pty"))
|
||||
|
||||
type Socket = {
|
||||
readyState: number
|
||||
data?: unknown
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void
|
||||
close: (code?: number, reason?: string) => void
|
||||
}
|
||||
|
||||
type Active = {
|
||||
info: Info
|
||||
process: Proc
|
||||
buffer: string
|
||||
bufferCursor: number
|
||||
cursor: number
|
||||
subscribers: Map<unknown, Socket>
|
||||
listeners: Disp[]
|
||||
}
|
||||
|
||||
const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
|
||||
|
||||
// WebSocket control frame: 0x00 + UTF-8 JSON.
|
||||
const meta = (cursor: number) => {
|
||||
const json = JSON.stringify({ cursor })
|
||||
const bytes = encoder.encode(json)
|
||||
const out = new Uint8Array(bytes.length + 1)
|
||||
out[0] = 0
|
||||
out.set(bytes, 1)
|
||||
return out
|
||||
}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
id: PtyID,
|
||||
title: Schema.String,
|
||||
command: Schema.String,
|
||||
args: Schema.Array(Schema.String),
|
||||
cwd: Schema.String,
|
||||
status: Schema.Literals(["running", "exited"]),
|
||||
// Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time.
|
||||
pid: NonNegativeInt,
|
||||
}).annotate({ identifier: "Pty" })
|
||||
|
||||
export type Info = Types.DeepMutable<typeof Info.Type>
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
command: Schema.optional(Schema.String),
|
||||
args: Schema.optional(Schema.Array(Schema.String)),
|
||||
cwd: Schema.optional(Schema.String),
|
||||
title: Schema.optional(Schema.String),
|
||||
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
|
||||
export type CreateInput = Types.DeepMutable<typeof CreateInput.Type>
|
||||
|
||||
export type PreparedCreate = {
|
||||
readonly command: string
|
||||
readonly args: string[]
|
||||
readonly cwd: string
|
||||
readonly title?: string
|
||||
readonly env: Record<string, string>
|
||||
}
|
||||
|
||||
export const UpdateInput = Schema.Struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
size: Schema.optional(
|
||||
Schema.Struct({
|
||||
rows: PositiveInt,
|
||||
cols: PositiveInt,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export type UpdateInput = Types.DeepMutable<typeof UpdateInput.Type>
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty.NotFoundError", {
|
||||
ptyID: PtyID,
|
||||
}) {}
|
||||
|
||||
export const Event = {
|
||||
Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),
|
||||
Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }),
|
||||
Exited: EventV2.define({ type: "pty.exited", schema: { id: PtyID, exitCode: NonNegativeInt } }),
|
||||
Deleted: EventV2.define({ type: "pty.deleted", schema: { id: PtyID } }),
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
|
||||
readonly create: (input: PreparedCreate) => Effect.Effect<Info>
|
||||
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
|
||||
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
|
||||
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void, NotFoundError>
|
||||
readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
|
||||
readonly connect: (
|
||||
id: PtyID,
|
||||
ws: Socket,
|
||||
cursor?: number,
|
||||
) => Effect.Effect<
|
||||
{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined,
|
||||
NotFoundError
|
||||
>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Pty") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const location = yield* Location.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<PtyID, Active>()
|
||||
|
||||
function teardown(session: Active) {
|
||||
for (const listener of session.listeners) listener.dispose()
|
||||
session.listeners.length = 0
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [sub, ws] of session.subscribers.entries()) {
|
||||
try {
|
||||
if (sock(ws) === sub) ws.close()
|
||||
} catch {}
|
||||
}
|
||||
session.subscribers.clear()
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const session of sessions.values()) teardown(session)
|
||||
sessions.clear()
|
||||
}),
|
||||
)
|
||||
|
||||
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return yield* new NotFoundError({ ptyID: id })
|
||||
return session
|
||||
})
|
||||
|
||||
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
|
||||
const session = sessions.get(id)
|
||||
if (!session) return false
|
||||
sessions.delete(id)
|
||||
log.info("removing session", { id })
|
||||
teardown(session)
|
||||
yield* events.publish(Event.Deleted, { id: session.info.id })
|
||||
return true
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
||||
yield* requireSession(id)
|
||||
yield* removeSession(id)
|
||||
})
|
||||
|
||||
const list = Effect.fn("Pty.list")(function* () {
|
||||
return Array.from(sessions.values()).map((session) => session.info)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
|
||||
return (yield* requireSession(id)).info
|
||||
})
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) {
|
||||
const id = PtyID.ascending()
|
||||
log.info("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
|
||||
const { spawn } = yield* Effect.promise(() => pty())
|
||||
const proc = yield* Effect.sync(() =>
|
||||
spawn(input.command, input.args, {
|
||||
name: "xterm-256color",
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
}),
|
||||
)
|
||||
const info = {
|
||||
id,
|
||||
title: input.title || `Terminal ${id.slice(-4)}`,
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd,
|
||||
status: "running",
|
||||
pid: proc.pid,
|
||||
} as const
|
||||
const session: Active = {
|
||||
info,
|
||||
process: proc,
|
||||
buffer: "",
|
||||
bufferCursor: 0,
|
||||
cursor: 0,
|
||||
subscribers: new Map(),
|
||||
listeners: [],
|
||||
}
|
||||
sessions.set(id, session)
|
||||
session.listeners.push(
|
||||
proc.onData((chunk) => {
|
||||
session.cursor += chunk.length
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
if (ws.readyState !== 1 || sock(ws) !== key) {
|
||||
session.subscribers.delete(key)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
ws.send(chunk)
|
||||
} catch {
|
||||
session.subscribers.delete(key)
|
||||
}
|
||||
}
|
||||
session.buffer += chunk
|
||||
if (session.buffer.length <= BUFFER_LIMIT) return
|
||||
const excess = session.buffer.length - BUFFER_LIMIT
|
||||
session.buffer = session.buffer.slice(excess)
|
||||
session.bufferCursor += excess
|
||||
}),
|
||||
proc.onExit(({ exitCode }) => {
|
||||
if (session.info.status === "exited") return
|
||||
runFork(
|
||||
Effect.gen(function* () {
|
||||
log.info("session exited", { id, exitCode })
|
||||
session.info.status = "exited"
|
||||
yield* events.publish(Event.Exited, { id, exitCode })
|
||||
yield* removeSession(id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* events.publish(Event.Created, { info })
|
||||
return info
|
||||
})
|
||||
|
||||
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
|
||||
const session = yield* requireSession(id)
|
||||
if (input.title) session.info.title = input.title
|
||||
if (input.size) session.process.resize(input.size.cols, input.size.rows)
|
||||
yield* events.publish(Event.Updated, { info: session.info })
|
||||
return session.info
|
||||
})
|
||||
|
||||
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status === "running") session.process.resize(cols, rows)
|
||||
})
|
||||
|
||||
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
|
||||
const session = yield* requireSession(id)
|
||||
if (session.info.status === "running") session.process.write(data)
|
||||
})
|
||||
|
||||
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
|
||||
const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close())))
|
||||
log.info("client connected to session", { id, directory: location.directory })
|
||||
const sub = sock(ws)
|
||||
session.subscribers.delete(sub)
|
||||
session.subscribers.set(sub, ws)
|
||||
const cleanup = () => session.subscribers.delete(sub)
|
||||
const start = session.bufferCursor
|
||||
const end = session.cursor
|
||||
const from =
|
||||
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
|
||||
const data = (() => {
|
||||
if (!session.buffer || from >= end) return ""
|
||||
const offset = Math.max(0, from - start)
|
||||
if (offset >= session.buffer.length) return ""
|
||||
return session.buffer.slice(offset)
|
||||
})()
|
||||
if (data) {
|
||||
try {
|
||||
for (let i = 0; i < data.length; i += BUFFER_CHUNK) ws.send(data.slice(i, i + BUFFER_CHUNK))
|
||||
} catch {
|
||||
cleanup()
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
ws.send(meta(end))
|
||||
} catch {
|
||||
cleanup()
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
return {
|
||||
onMessage: (message: string | ArrayBuffer) => {
|
||||
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
|
||||
},
|
||||
onClose: () => {
|
||||
log.info("client disconnected from session", { id })
|
||||
cleanup()
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ list, get, create, update, remove, resize, write, connect })
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
24
packages/core/src/pty/input.ts
Normal file
24
packages/core/src/pty/input.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Effect } from "effect"
|
||||
|
||||
const inputDecoder = new TextDecoder("utf-8", { fatal: true })
|
||||
|
||||
export function handlePtyInput(
|
||||
handler: { onMessage: (message: string | ArrayBuffer) => void },
|
||||
message: string | Uint8Array,
|
||||
) {
|
||||
if (typeof message === "string") {
|
||||
handler.onMessage(message)
|
||||
return Effect.void
|
||||
}
|
||||
return Effect.try({
|
||||
try: () => inputDecoder.decode(message),
|
||||
catch: () => new Error("invalid PTY websocket input"),
|
||||
}).pipe(
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.flatMap((decoded) => {
|
||||
if (decoded === undefined) return Effect.void
|
||||
handler.onMessage(decoded)
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
26
packages/core/src/pty/pty.bun.ts
Normal file
26
packages/core/src/pty/pty.bun.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { spawn as create } from "bun-pty"
|
||||
import type { Opts, Proc } from "./pty"
|
||||
|
||||
export type { Disp, Exit, Opts, Proc } from "./pty"
|
||||
|
||||
export function spawn(file: string, args: string[], opts: Opts): Proc {
|
||||
const pty = create(file, args, opts)
|
||||
return {
|
||||
pid: pty.pid,
|
||||
onData(listener) {
|
||||
return pty.onData(listener)
|
||||
},
|
||||
onExit(listener) {
|
||||
return pty.onExit(listener)
|
||||
},
|
||||
write(data) {
|
||||
pty.write(data)
|
||||
},
|
||||
resize(cols, rows) {
|
||||
pty.resize(cols, rows)
|
||||
},
|
||||
kill(signal) {
|
||||
pty.kill(signal)
|
||||
},
|
||||
}
|
||||
}
|
||||
26
packages/core/src/pty/pty.node.ts
Normal file
26
packages/core/src/pty/pty.node.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import * as pty from "@lydell/node-pty"
|
||||
import type { Opts, Proc } from "./pty"
|
||||
|
||||
export type { Disp, Exit, Opts, Proc } from "./pty"
|
||||
|
||||
export function spawn(file: string, args: string[], opts: Opts): Proc {
|
||||
const proc = pty.spawn(file, args, opts)
|
||||
return {
|
||||
pid: proc.pid,
|
||||
onData(listener) {
|
||||
return proc.onData(listener)
|
||||
},
|
||||
onExit(listener) {
|
||||
return proc.onExit(listener)
|
||||
},
|
||||
write(data) {
|
||||
proc.write(data)
|
||||
},
|
||||
resize(cols, rows) {
|
||||
proc.resize(cols, rows)
|
||||
},
|
||||
kill(signal) {
|
||||
proc.kill(signal)
|
||||
},
|
||||
}
|
||||
}
|
||||
25
packages/core/src/pty/pty.ts
Normal file
25
packages/core/src/pty/pty.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export type Disp = {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export type Exit = {
|
||||
exitCode: number
|
||||
signal?: number | string
|
||||
}
|
||||
|
||||
export type Opts = {
|
||||
name: string
|
||||
cols?: number
|
||||
rows?: number
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export type Proc = {
|
||||
pid: number
|
||||
onData(listener: (data: string) => void): Disp
|
||||
onExit(listener: (event: Exit) => void): Disp
|
||||
write(data: string): void
|
||||
resize(cols: number, rows: number): void
|
||||
kill(signal?: string): void
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue