feat(core): add embedded v2 session runtime and tool foundation (#30632)
This commit is contained in:
parent
c35267776a
commit
76ee87ead8
215 changed files with 31398 additions and 3332 deletions
182
packages/core/src/ripgrep.ts
Normal file
182
packages/core/src/ripgrep.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
export * as Ripgrep from "./ripgrep"
|
||||
|
||||
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Ripgrep as FileSystemRipgrep } from "./filesystem/ripgrep"
|
||||
import { AppProcess, collectStream, waitForAbort } from "./process"
|
||||
import { NonNegativeInt, PositiveInt } from "./schema"
|
||||
|
||||
/**
|
||||
* Small core-owned ripgrep execution adapter. It deliberately exposes raw
|
||||
* process-oriented rows, not model text or permission behavior. LocationSearch
|
||||
* supplies read authority and bounded substrate results; future leaf tools own
|
||||
* presentation and permission prompts.
|
||||
*/
|
||||
|
||||
const ERROR_BYTES = 8 * 1024
|
||||
export const MAX_RECORD_BYTES = 64 * 1024
|
||||
export const MAX_SUBMATCHES = 100
|
||||
|
||||
const RawMatch = Schema.Struct({
|
||||
type: Schema.Literal("match"),
|
||||
data: Schema.Struct({
|
||||
path: Schema.Struct({ text: Schema.String }),
|
||||
lines: Schema.Struct({ text: Schema.String }),
|
||||
line_number: PositiveInt,
|
||||
absolute_offset: NonNegativeInt,
|
||||
submatches: Schema.Array(
|
||||
Schema.Struct({
|
||||
match: Schema.Struct({ text: Schema.String }),
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
export type Match = typeof RawMatch.Type["data"]
|
||||
|
||||
export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export class InvalidPatternError extends Schema.TaggedErrorClass<InvalidPatternError>()("Ripgrep.InvalidPatternError", {
|
||||
pattern: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Result<A> {
|
||||
readonly items: A[]
|
||||
readonly truncated: boolean
|
||||
readonly partial: boolean
|
||||
}
|
||||
|
||||
export interface FilesInput {
|
||||
readonly cwd: string
|
||||
readonly pattern: string
|
||||
readonly limit: number
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface GrepInput {
|
||||
readonly cwd: string
|
||||
readonly pattern: string
|
||||
readonly file?: string
|
||||
readonly include?: string
|
||||
readonly limit: number
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly files: (input: FilesInput) => Effect.Effect<Result<string>, Error>
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<Result<Match>, Error | InvalidPatternError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Ripgrep") {}
|
||||
|
||||
const failure = (message: string, cause?: unknown) => new Error({ message, cause })
|
||||
|
||||
const isInvalidPattern = (stderr: string) => stderr.includes("regex parse error") || stderr.includes("error parsing regex")
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const process = yield* AppProcess.Service
|
||||
const binary = yield* FileSystemRipgrep.Service
|
||||
|
||||
const run = <A>(input: {
|
||||
readonly cwd: string
|
||||
readonly args: string[]
|
||||
readonly limit: number
|
||||
readonly signal?: AbortSignal
|
||||
readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
|
||||
readonly pattern?: string
|
||||
}) => {
|
||||
const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* process.spawn(
|
||||
ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }),
|
||||
)
|
||||
const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe(
|
||||
Effect.map((output) => output.buffer.toString("utf8")),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const rows = yield* Stream.decodeText(handle.stdout).pipe(
|
||||
Stream.splitLines,
|
||||
Stream.filter((line) => line.length > 0),
|
||||
Stream.mapEffect(input.parse),
|
||||
Stream.filter((row): row is A => row !== undefined),
|
||||
Stream.take(input.limit + 1),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
)
|
||||
const truncated = rows.length > input.limit
|
||||
if (truncated) return { items: rows.slice(0, input.limit), truncated, partial: false }
|
||||
|
||||
const code = yield* handle.exitCode
|
||||
const stderr = yield* Fiber.join(stderrFiber)
|
||||
if (input.pattern && code === 2 && isInvalidPattern(stderr)) {
|
||||
return yield* new InvalidPatternError({ pattern: input.pattern, message: stderr.trim() })
|
||||
}
|
||||
if (code !== 0 && code !== 1 && code !== 2) {
|
||||
return yield* failure(stderr.trim() || `ripgrep failed with code ${code}`)
|
||||
}
|
||||
return { items: code === 1 ? [] : rows, truncated: false, partial: code === 2 }
|
||||
}),
|
||||
)
|
||||
const abortable = input.signal ? program.pipe(Effect.raceFirst(waitForAbort(input.signal))) : program
|
||||
return abortable.pipe(Effect.mapError((cause) => cause instanceof Error || cause instanceof InvalidPatternError ? cause : failure("ripgrep execution failed", cause)))
|
||||
}
|
||||
|
||||
return Service.of({
|
||||
files: (input) =>
|
||||
run<string>({
|
||||
...input,
|
||||
args: [
|
||||
"--no-config",
|
||||
"--files",
|
||||
"--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure.
|
||||
`--glob=${input.pattern}`,
|
||||
"--glob=!.*",
|
||||
"--glob=!**/.*",
|
||||
".",
|
||||
],
|
||||
parse: (line) => Effect.succeed(line.replace(/^\.\//, "")),
|
||||
}).pipe(
|
||||
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
|
||||
),
|
||||
grep: (input) =>
|
||||
run<Match>({
|
||||
...input,
|
||||
args: [
|
||||
"--no-config",
|
||||
"--json",
|
||||
"--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure.
|
||||
"--no-messages",
|
||||
...(input.include ? [`--glob=${input.include}`] : []),
|
||||
"--glob=!.*",
|
||||
"--glob=!**/.*",
|
||||
"--",
|
||||
input.pattern,
|
||||
input.file ?? ".",
|
||||
],
|
||||
parse: (line) =>
|
||||
(Buffer.byteLength(line, "utf8") > MAX_RECORD_BYTES
|
||||
? Effect.fail(failure(`Ripgrep JSON record exceeded ${MAX_RECORD_BYTES} bytes`))
|
||||
: Effect.try({
|
||||
try: () => JSON.parse(line) as unknown,
|
||||
catch: (cause) => failure("Invalid ripgrep JSON output", cause),
|
||||
})).pipe(
|
||||
Effect.flatMap((json) => {
|
||||
if (!json || typeof json !== "object" || !("type" in json) || json.type !== "match") return Effect.succeed(undefined)
|
||||
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
|
||||
Effect.map((match) => ({ ...match.data, submatches: match.data.submatches.slice(0, MAX_SUBMATCHES) })),
|
||||
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(FileSystemRipgrep.defaultLayer))
|
||||
Loading…
Add table
Add a link
Reference in a new issue