mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbdc9075b5 | |||
| ccba1c0df9 | |||
| 934935963d | |||
| f3912a2a8a | |||
| 45d58717a4 | |||
| 74e3155ef0 | |||
| 0af6c82563 | |||
| 686127f809 | |||
| 5256655c4d | |||
| d10b652637 | |||
| b03ca0d4e2 | |||
| 25aaea3d31 | |||
| cae7a139bc | |||
| 5ea62ab05f | |||
| faadc05c88 | |||
| 3e253c589e | |||
| 0a0fc09533 | |||
| 5aa0413fea | |||
| 6f4c199629 | |||
| ed8e1f4654 | |||
| 3b0195e045 |
@@ -248,11 +248,6 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
|
|||||||
return IS_MAC ? parts.join("") : parts.join("+")
|
return IS_MAC ? parts.join("") : parts.join("+")
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeybindV2 takes an array instead of a string
|
|
||||||
export function formatKeybindKeys(config: string, t?: (key: KeyLabel) => string): string[] {
|
|
||||||
return formatKeybindParts(config, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEditableTarget(target: EventTarget | null) {
|
function isEditableTarget(target: EventTarget | null) {
|
||||||
if (!(target instanceof HTMLElement)) return false
|
if (!(target instanceof HTMLElement)) return false
|
||||||
if (target.isContentEditable) return true
|
if (target.isContentEditable) return true
|
||||||
|
|||||||
@@ -286,13 +286,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
children: tree.children,
|
children: tree.children,
|
||||||
expand: tree.expandDir,
|
expand: tree.expandDir,
|
||||||
collapse: tree.collapseDir,
|
collapse: tree.collapseDir,
|
||||||
toggle(input: string) {
|
|
||||||
if (tree.dirState(input)?.expanded) {
|
|
||||||
tree.collapseDir(input)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tree.expandDir(input)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
get,
|
get,
|
||||||
load,
|
load,
|
||||||
|
|||||||
@@ -153,18 +153,6 @@ export function normalizeProviderList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeProject(project: Project) {
|
|
||||||
if (!project.icon?.url && !project.icon?.override) return project
|
|
||||||
return {
|
|
||||||
...project,
|
|
||||||
icon: {
|
|
||||||
...project.icon,
|
|
||||||
url: undefined,
|
|
||||||
override: undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
|
|||||||
@@ -753,9 +753,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
},
|
},
|
||||||
mobileSidebar: {
|
mobileSidebar: {
|
||||||
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
|
opened: createMemo(() => store.mobileSidebar?.opened ?? false),
|
||||||
show() {
|
|
||||||
setStore("mobileSidebar", "opened", true)
|
|
||||||
},
|
|
||||||
hide() {
|
hide() {
|
||||||
setStore("mobileSidebar", "opened", false)
|
setStore("mobileSidebar", "opened", false)
|
||||||
},
|
},
|
||||||
@@ -961,33 +958,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
if (current.reviewOpen.includes(path)) return
|
if (current.reviewOpen.includes(path)) return
|
||||||
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
|
setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
|
||||||
},
|
},
|
||||||
closePath(path: string) {
|
|
||||||
const session = key()
|
|
||||||
const current = store.sessionView[session]?.reviewOpen
|
|
||||||
if (!current) return
|
|
||||||
|
|
||||||
const index = current.indexOf(path)
|
|
||||||
if (index === -1) return
|
|
||||||
setStore(
|
|
||||||
"sessionView",
|
|
||||||
session,
|
|
||||||
"reviewOpen",
|
|
||||||
produce((draft) => {
|
|
||||||
if (!draft) return
|
|
||||||
draft.splice(index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
togglePath(path: string) {
|
|
||||||
const session = key()
|
|
||||||
const current = store.sessionView[session]?.reviewOpen
|
|
||||||
if (!current || !current.includes(path)) {
|
|
||||||
this.openPath(path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.closePath(path)
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ type TabsInput = {
|
|||||||
fileBrowser?: Accessor<boolean>
|
fileBrowser?: Accessor<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
|
|
||||||
|
|
||||||
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
|
export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) {
|
||||||
return input.opened && input.visible
|
return input.opened && input.visible
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -420,6 +420,7 @@ export type Endpoint5_26Output =
|
|||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly delta: { readonly [x: string]: (string & Brand.Brand<"Instruction.Hash">) | "removed" }
|
readonly delta: { readonly [x: string]: (string & Brand.Brand<"Instruction.Hash">) | "removed" }
|
||||||
|
readonly text?: string | undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -676,7 +676,7 @@ export type SessionInstructionsUpdated = {
|
|||||||
type: "session.instructions.updated"
|
type: "session.instructions.updated"
|
||||||
durable: { aggregateID: string; seq: number; version: 2 }
|
durable: { aggregateID: string; seq: number; version: 2 }
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: { sessionID: string; delta: { [x: string]: string | "removed" } }
|
data: { sessionID: string; delta: { [x: string]: string | "removed" }; text?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionSynthetic = {
|
export type SessionSynthetic = {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export * as Bus from "./bus"
|
|||||||
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import { Event } from "@opencode-ai/schema/event"
|
||||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||||
import { and, asc, eq, gt, inArray, lte, sql } from "drizzle-orm"
|
import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
|
||||||
import { Database } from "./database/database"
|
import { Database } from "./database/database"
|
||||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
@@ -134,8 +134,6 @@ export interface Interface {
|
|||||||
readonly after?: number
|
readonly after?: number
|
||||||
readonly follow?: boolean
|
readonly follow?: boolean
|
||||||
}) => Stream.Stream<LogItem>
|
}) => Stream.Stream<LogItem>
|
||||||
/** Latest committed seq per aggregate. Aggregates without events are absent. */
|
|
||||||
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Event.Seq>>
|
|
||||||
/** @deprecated Use `subscribe()` and consume the returned stream. */
|
/** @deprecated Use `subscribe()` and consume the returned stream. */
|
||||||
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
||||||
readonly project: <D extends Event.Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
readonly project: <D extends Event.Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
||||||
@@ -657,19 +655,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Event.Seq>> => {
|
|
||||||
if (aggregateIDs.length === 0) return Effect.succeed(new Map())
|
|
||||||
return db
|
|
||||||
.select({ aggregateID: EventSequenceTable.aggregate_id, seq: EventSequenceTable.seq })
|
|
||||||
.from(EventSequenceTable)
|
|
||||||
.where(inArray(EventSequenceTable.aggregate_id, Array.from(aggregateIDs)))
|
|
||||||
.all()
|
|
||||||
.pipe(
|
|
||||||
Effect.orDie,
|
|
||||||
Effect.map((rows) => new Map(rows.map((row) => [row.aggregateID, Event.Seq.make(row.seq)]))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
|
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
listeners.push(listener)
|
listeners.push(listener)
|
||||||
@@ -691,7 +676,6 @@ export const layerWith = (options?: LayerOptions) =>
|
|||||||
publish,
|
publish,
|
||||||
subscribe,
|
subscribe,
|
||||||
log,
|
log,
|
||||||
sequences,
|
|
||||||
listen,
|
listen,
|
||||||
project,
|
project,
|
||||||
replay,
|
replay,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
export * as FileMutation from "./file-mutation"
|
export * as FileMutation from "./file-mutation"
|
||||||
|
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { dirname } from "path"
|
|
||||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
@@ -22,22 +21,6 @@ export interface TextWriteInput {
|
|||||||
readonly content: string
|
readonly content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConditionalWriteInput extends WriteInput {
|
|
||||||
readonly expected: Uint8Array
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RemoveInput {
|
|
||||||
readonly target: Target
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
export interface WriteResult {
|
||||||
readonly operation: "write"
|
readonly operation: "write"
|
||||||
readonly target: string
|
readonly target: string
|
||||||
@@ -45,24 +28,10 @@ export interface WriteResult {
|
|||||||
readonly existed: boolean
|
readonly existed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoveResult {
|
|
||||||
readonly operation: "remove"
|
|
||||||
readonly target: string
|
|
||||||
readonly resource: string
|
|
||||||
readonly existed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
/** Create without replacing an existing target. */
|
|
||||||
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | FSUtil.Error>
|
|
||||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||||
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
||||||
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||||
/** Commit only if an existing target still has the expected bytes. */
|
|
||||||
readonly writeIfUnchanged: (
|
|
||||||
input: ConditionalWriteInput,
|
|
||||||
) => Effect.Effect<WriteResult, StaleContentError | FSUtil.Error>
|
|
||||||
readonly remove: (input: RemoveInput) => Effect.Effect<RemoveResult, FSUtil.Error>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
||||||
@@ -89,13 +58,6 @@ const layer = Layer.effect(
|
|||||||
existed,
|
existed,
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeResult = (target: Target, existed: boolean): RemoveResult => ({
|
|
||||||
operation: "remove",
|
|
||||||
target: target.canonical,
|
|
||||||
resource: target.resource,
|
|
||||||
existed,
|
|
||||||
})
|
|
||||||
|
|
||||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||||
withTargetLock(input.target)(
|
withTargetLock(input.target)(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -122,62 +84,10 @@ const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const create = Effect.fn("FileMutation.create")((input: WriteInput) =>
|
return Service.of({ write, writeTextPreservingBom })
|
||||||
withTargetLock(input.target)(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const write =
|
|
||||||
typeof input.content === "string"
|
|
||||||
? fs.writeFileString(input.target.canonical, input.content, { flag: "wx" })
|
|
||||||
: fs.writeFile(input.target.canonical, input.content, { flag: "wx" })
|
|
||||||
yield* write.pipe(
|
|
||||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
|
||||||
fs.ensureDir(dirname(input.target.canonical)).pipe(Effect.andThen(write)),
|
|
||||||
),
|
|
||||||
Effect.catchReason("PlatformError", "AlreadyExists", () =>
|
|
||||||
Effect.fail(new TargetExistsError({ path: input.target.canonical })),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return writeResult(input.target, false)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const writeIfUnchanged = Effect.fn("FileMutation.writeIfUnchanged")((input: ConditionalWriteInput) =>
|
|
||||||
withTargetLock(input.target)(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const current = yield* fs.readFile(input.target.canonical)
|
|
||||||
if (!sameBytes(current, input.expected)) {
|
|
||||||
return yield* new StaleContentError({ path: input.target.canonical })
|
|
||||||
}
|
|
||||||
yield* typeof input.content === "string"
|
|
||||||
? fs.writeFileString(input.target.canonical, input.content)
|
|
||||||
: fs.writeFile(input.target.canonical, input.content)
|
|
||||||
return writeResult(input.target, true)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const remove = Effect.fn("FileMutation.remove")((input: RemoveInput) =>
|
|
||||||
withTargetLock(input.target)(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const existed = yield* fs.remove(input.target.canonical).pipe(
|
|
||||||
Effect.as(true),
|
|
||||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)),
|
|
||||||
)
|
|
||||||
return removeResult(input.target, existed)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({ create, write, writeTextPreservingBom, writeIfUnchanged, remove })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
function sameBytes(left: Uint8Array, right: Uint8Array) {
|
|
||||||
if (left.length !== right.length) return false
|
|
||||||
return left.every((byte, index) => byte === right[index])
|
|
||||||
}
|
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
|||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { PositiveInt, RelativePath } from "./schema"
|
import { PositiveInt, RelativePath } from "./schema"
|
||||||
import { FileSystemSearch } from "./filesystem/search"
|
import { FileSystemSearch } from "./filesystem/search"
|
||||||
import { Entry, FileSystem, FindInput, Match } from "@opencode-ai/schema/filesystem"
|
import { Entry, FileSystem, FindInput } from "@opencode-ai/schema/filesystem"
|
||||||
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
|
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
|
||||||
|
|
||||||
export const ReadInput = Schema.Struct({
|
export const ReadInput = Schema.Struct({
|
||||||
@@ -53,8 +53,6 @@ export interface Interface {
|
|||||||
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
||||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||||
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
|
|
||||||
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
|
||||||
@@ -76,8 +74,6 @@ const baseLayer = Layer.effect(
|
|||||||
})
|
})
|
||||||
return Service.of({
|
return Service.of({
|
||||||
find: search.find,
|
find: search.find,
|
||||||
glob: search.glob,
|
|
||||||
grep: search.grep,
|
|
||||||
read: Effect.fn("FileSystem.read")(function* (input) {
|
read: Effect.fn("FileSystem.read")(function* (input) {
|
||||||
const target = yield* resolve(input.path)
|
const target = yield* resolve(input.path)
|
||||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ import {
|
|||||||
type DirItem,
|
type DirItem,
|
||||||
type DirSearchResult,
|
type DirSearchResult,
|
||||||
type FileItem,
|
type FileItem,
|
||||||
type GrepCursor,
|
|
||||||
type GrepMatch,
|
|
||||||
type GrepResult,
|
|
||||||
type InitOptions,
|
type InitOptions,
|
||||||
type MixedItem,
|
type MixedItem,
|
||||||
type MixedSearchResult,
|
type MixedSearchResult,
|
||||||
@@ -45,19 +42,6 @@ export interface MixedSearch {
|
|||||||
export type File = FileItem
|
export type File = FileItem
|
||||||
export type Directory = DirItem
|
export type Directory = DirItem
|
||||||
export type Mixed = MixedItem
|
export type Mixed = MixedItem
|
||||||
export type Cursor = GrepCursor | null
|
|
||||||
export type Hit = GrepMatch
|
|
||||||
|
|
||||||
export interface Grep {
|
|
||||||
items: GrepResult["items"]
|
|
||||||
totalMatched: number
|
|
||||||
totalFilesSearched: number
|
|
||||||
totalFiles: number
|
|
||||||
filteredFileCount: number
|
|
||||||
nextCursor: Cursor
|
|
||||||
regexFallbackError?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Picker {
|
export interface Picker {
|
||||||
destroy(): void
|
destroy(): void
|
||||||
isScanning(): boolean
|
isScanning(): boolean
|
||||||
@@ -71,14 +55,6 @@ export interface Picker {
|
|||||||
pageSize?: number
|
pageSize?: number
|
||||||
},
|
},
|
||||||
): Result<Search>
|
): Result<Search>
|
||||||
glob(
|
|
||||||
pattern: string,
|
|
||||||
opts?: {
|
|
||||||
currentFile?: string
|
|
||||||
pageIndex?: number
|
|
||||||
pageSize?: number
|
|
||||||
},
|
|
||||||
): Result<Search>
|
|
||||||
directorySearch(
|
directorySearch(
|
||||||
query: string,
|
query: string,
|
||||||
opts?: {
|
opts?: {
|
||||||
@@ -95,18 +71,6 @@ export interface Picker {
|
|||||||
pageSize?: number
|
pageSize?: number
|
||||||
},
|
},
|
||||||
): Result<MixedSearch>
|
): Result<MixedSearch>
|
||||||
grep(
|
|
||||||
query: string,
|
|
||||||
opts?: {
|
|
||||||
mode?: "plain" | "regex" | "fuzzy"
|
|
||||||
maxMatchesPerFile?: number
|
|
||||||
timeBudgetMs?: number
|
|
||||||
beforeContext?: number
|
|
||||||
afterContext?: number
|
|
||||||
cursor?: Cursor
|
|
||||||
pageSize?: number
|
|
||||||
},
|
|
||||||
): Result<Grep>
|
|
||||||
trackQuery(query: string, file: string): Result<boolean>
|
trackQuery(query: string, file: string): Result<boolean>
|
||||||
getHistoricalQuery(offset: number): Result<string | null>
|
getHistoricalQuery(offset: number): Result<string | null>
|
||||||
}
|
}
|
||||||
@@ -127,10 +91,8 @@ export function create(opts: Init): Result<Picker> {
|
|||||||
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
||||||
refreshGitStatus: () => pick.refreshGitStatus(),
|
refreshGitStatus: () => pick.refreshGitStatus(),
|
||||||
fileSearch: (query, next) => pick.fileSearch(query, next),
|
fileSearch: (query, next) => pick.fileSearch(query, next),
|
||||||
glob: (pattern, next) => pick.glob(pattern, next),
|
|
||||||
directorySearch: (query, next) => pick.directorySearch(query, next),
|
directorySearch: (query, next) => pick.directorySearch(query, next),
|
||||||
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
||||||
grep: (query, next) => pick.grep(query, next),
|
|
||||||
trackQuery: (query, file) => pick.trackQuery(query, file),
|
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||||
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import type {
|
|||||||
DirItem,
|
DirItem,
|
||||||
DirSearchResult,
|
DirSearchResult,
|
||||||
FileItem,
|
FileItem,
|
||||||
GrepCursor,
|
|
||||||
GrepMatch,
|
|
||||||
GrepResult,
|
|
||||||
InitOptions,
|
InitOptions,
|
||||||
MixedItem,
|
MixedItem,
|
||||||
MixedSearchResult,
|
MixedSearchResult,
|
||||||
@@ -42,19 +39,6 @@ export interface MixedSearch {
|
|||||||
export type File = FileItem
|
export type File = FileItem
|
||||||
export type Directory = DirItem
|
export type Directory = DirItem
|
||||||
export type Mixed = MixedItem
|
export type Mixed = MixedItem
|
||||||
export type Cursor = GrepCursor | null
|
|
||||||
export type Hit = GrepMatch
|
|
||||||
|
|
||||||
export interface Grep {
|
|
||||||
items: GrepResult["items"]
|
|
||||||
totalMatched: number
|
|
||||||
totalFilesSearched: number
|
|
||||||
totalFiles: number
|
|
||||||
filteredFileCount: number
|
|
||||||
nextCursor: Cursor
|
|
||||||
regexFallbackError?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Picker {
|
export interface Picker {
|
||||||
destroy(): void
|
destroy(): void
|
||||||
isScanning(): boolean
|
isScanning(): boolean
|
||||||
@@ -68,14 +52,6 @@ export interface Picker {
|
|||||||
pageSize?: number
|
pageSize?: number
|
||||||
},
|
},
|
||||||
): Result<Search>
|
): Result<Search>
|
||||||
glob(
|
|
||||||
pattern: string,
|
|
||||||
opts?: {
|
|
||||||
currentFile?: string
|
|
||||||
pageIndex?: number
|
|
||||||
pageSize?: number
|
|
||||||
},
|
|
||||||
): Result<Search>
|
|
||||||
directorySearch(
|
directorySearch(
|
||||||
query: string,
|
query: string,
|
||||||
opts?: {
|
opts?: {
|
||||||
@@ -92,18 +68,6 @@ export interface Picker {
|
|||||||
pageSize?: number
|
pageSize?: number
|
||||||
},
|
},
|
||||||
): Result<MixedSearch>
|
): Result<MixedSearch>
|
||||||
grep(
|
|
||||||
query: string,
|
|
||||||
opts?: {
|
|
||||||
mode?: "plain" | "regex" | "fuzzy"
|
|
||||||
maxMatchesPerFile?: number
|
|
||||||
timeBudgetMs?: number
|
|
||||||
beforeContext?: number
|
|
||||||
afterContext?: number
|
|
||||||
cursor?: Cursor
|
|
||||||
pageSize?: number
|
|
||||||
},
|
|
||||||
): Result<Grep>
|
|
||||||
trackQuery(query: string, file: string): Result<boolean>
|
trackQuery(query: string, file: string): Result<boolean>
|
||||||
getHistoricalQuery(offset: number): Result<string | null>
|
getHistoricalQuery(offset: number): Result<string | null>
|
||||||
}
|
}
|
||||||
@@ -125,10 +89,8 @@ export function create(opts: Init): Result<Picker> {
|
|||||||
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
|
||||||
refreshGitStatus: () => pick.refreshGitStatus(),
|
refreshGitStatus: () => pick.refreshGitStatus(),
|
||||||
fileSearch: (query, next) => pick.fileSearch(query, next),
|
fileSearch: (query, next) => pick.fileSearch(query, next),
|
||||||
glob: (pattern, next) => pick.glob(pattern, next),
|
|
||||||
directorySearch: (query, next) => pick.directorySearch(query, next),
|
directorySearch: (query, next) => pick.directorySearch(query, next),
|
||||||
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
mixedSearch: (query, next) => pick.mixedSearch(query, next),
|
||||||
grep: (query, next) => pick.grep(query, next),
|
|
||||||
trackQuery: (query, file) => pick.trackQuery(query, file),
|
trackQuery: (query, file) => pick.trackQuery(query, file),
|
||||||
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export * as LocationWatcher from "./location-watcher"
|
|||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Context, Effect, Layer, Stream } from "effect"
|
import { Context, Effect, Layer, Stream } from "effect"
|
||||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||||
import os from "os"
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Config } from "../config"
|
import { Config } from "../config"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
@@ -44,7 +43,7 @@ const layer = Layer.effect(
|
|||||||
const config = (yield* configService.entries())
|
const config = (yield* configService.entries())
|
||||||
.filter((entry): entry is Config.Document => entry.type === "document")
|
.filter((entry): entry is Config.Document => entry.type === "document")
|
||||||
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
.flatMap((item) => item.info.watcher?.ignore ?? [])
|
||||||
const home = path.resolve(location.directory) === path.resolve(os.homedir())
|
const home = Protected.isHome(location.directory)
|
||||||
|
|
||||||
if (!home && location.vcs) {
|
if (!home && location.vcs) {
|
||||||
const updates = yield* watcher.subscribe({
|
const updates = yield* watcher.subscribe({
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import path from "path"
|
|||||||
|
|
||||||
const home = os.homedir()
|
const home = os.homedir()
|
||||||
|
|
||||||
|
export function isHome(directory: string) {
|
||||||
|
return path.resolve(directory) === path.resolve(home)
|
||||||
|
}
|
||||||
|
|
||||||
const DARWIN_HOME = [
|
const DARWIN_HOME = [
|
||||||
"Music",
|
"Music",
|
||||||
"Pictures",
|
"Pictures",
|
||||||
|
|||||||
@@ -6,15 +6,13 @@ import { Context, Effect, Layer, Schema, Scope } from "effect"
|
|||||||
import { Fff } from "#fff"
|
import { Fff } from "#fff"
|
||||||
import fuzzysort from "fuzzysort"
|
import fuzzysort from "fuzzysort"
|
||||||
import { FileSystem } from "../filesystem"
|
import { FileSystem } from "../filesystem"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
||||||
import { Location } from "../location"
|
import { Location } from "../location"
|
||||||
import { Ripgrep } from "../ripgrep"
|
import { Ripgrep } from "../ripgrep"
|
||||||
import { RelativePath } from "../schema"
|
import { RelativePath } from "../schema"
|
||||||
|
import { Protected } from "./protected"
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
|
readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
|
||||||
readonly glob: (input: FileSystem.GlobInput) => Effect.Effect<readonly FileSystem.Entry[]>
|
|
||||||
readonly grep: (input: FileSystem.GrepInput) => Effect.Effect<readonly FileSystem.Match[]>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Options = Schema.Struct({
|
export const Options = Schema.Struct({
|
||||||
@@ -27,89 +25,35 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Fi
|
|||||||
export const ripgrepLayer = Layer.effect(
|
export const ripgrepLayer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const ripgrep = yield* Ripgrep.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const state = {
|
const files: string[] = []
|
||||||
files: [] as string[],
|
|
||||||
directories: [] as string[],
|
|
||||||
}
|
|
||||||
const directories = new Set<string>()
|
const directories = new Set<string>()
|
||||||
|
const home = Protected.isHome(location.directory)
|
||||||
yield* ripgrep
|
yield* ripgrep
|
||||||
.find({
|
.find({
|
||||||
cwd: location.directory,
|
cwd: location.directory,
|
||||||
pattern: "*",
|
pattern: "*",
|
||||||
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
|
limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
|
||||||
|
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
|
||||||
onEntry: (entry) =>
|
onEntry: (entry) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
state.files.push(entry.path)
|
files.push(entry.path)
|
||||||
const parts = entry.path.split("/")
|
const parts = entry.path.split("/")
|
||||||
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
|
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
|
||||||
state.directories = Array.from(directories)
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
|
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
|
||||||
return Service.of({
|
return Service.of({
|
||||||
glob: (input) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const target = path.resolve(location.directory, input.path ?? ".")
|
|
||||||
const info = yield* fs.stat(target).pipe(Effect.orDie)
|
|
||||||
const cwd = info.type === "File" ? path.dirname(target) : target
|
|
||||||
return yield* ripgrep
|
|
||||||
.glob({
|
|
||||||
cwd,
|
|
||||||
pattern: input.pattern,
|
|
||||||
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
Effect.map((result) =>
|
|
||||||
result.map((entry) =>
|
|
||||||
FileSystem.Entry.make({
|
|
||||||
...entry,
|
|
||||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Effect.orDie,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
grep: (input) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const target = path.resolve(location.directory, input.path ?? ".")
|
|
||||||
const info = yield* fs.stat(target).pipe(Effect.orDie)
|
|
||||||
const cwd = info.type === "File" ? path.dirname(target) : target
|
|
||||||
return yield* ripgrep
|
|
||||||
.grep({
|
|
||||||
cwd,
|
|
||||||
pattern: input.pattern,
|
|
||||||
file: info.type === "File" ? path.basename(target) : undefined,
|
|
||||||
include: input.include,
|
|
||||||
limit: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
Effect.map((result) =>
|
|
||||||
result.map((match) =>
|
|
||||||
FileSystem.Match.make({
|
|
||||||
...match,
|
|
||||||
entry: FileSystem.Entry.make({
|
|
||||||
...match.entry,
|
|
||||||
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Effect.orDie,
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
find: (input) =>
|
find: (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const items =
|
const items =
|
||||||
input.type === "file"
|
input.type === "file"
|
||||||
? state.files
|
? files
|
||||||
: input.type === "directory"
|
: input.type === "directory"
|
||||||
? state.directories
|
? Array.from(directories)
|
||||||
: [...state.files, ...state.directories]
|
: [...files, ...directories]
|
||||||
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
||||||
const relative = item.target
|
const relative = item.target
|
||||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||||
@@ -143,55 +87,10 @@ export const fffLayer = Layer.effect(
|
|||||||
if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error })
|
if (result) yield* Effect.logWarning("failed to initialize fff", { error: result.error })
|
||||||
return Service.of({
|
return Service.of({
|
||||||
find: () => Effect.succeed([]),
|
find: () => Effect.succeed([]),
|
||||||
glob: () => Effect.succeed([]),
|
|
||||||
grep: () => Effect.succeed([]),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
|
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
|
||||||
return Service.of({
|
return Service.of({
|
||||||
glob: (input) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
|
|
||||||
const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT,
|
|
||||||
})
|
|
||||||
if (!found.ok) throw found.error
|
|
||||||
return found.value.items.map((item) =>
|
|
||||||
FileSystem.Entry.make({
|
|
||||||
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
|
|
||||||
type: "file",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
grep: (input) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
|
|
||||||
const found = result.value.grep(
|
|
||||||
[prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
|
|
||||||
.filter((value) => value !== undefined)
|
|
||||||
.join(" "),
|
|
||||||
{ mode: "regex", pageSize: input.limit ?? FileSystem.DEFAULT_SEARCH_LIMIT, timeBudgetMs: 1_500 },
|
|
||||||
)
|
|
||||||
if (!found.ok) throw found.error
|
|
||||||
return found.value.items.map((match) => {
|
|
||||||
const bytes = Buffer.from(match.lineContent)
|
|
||||||
return FileSystem.Match.make({
|
|
||||||
entry: FileSystem.Entry.make({
|
|
||||||
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
|
|
||||||
type: "file",
|
|
||||||
}),
|
|
||||||
line: match.lineNumber,
|
|
||||||
offset: match.byteOffset,
|
|
||||||
text: match.lineContent.length > 2_000 ? match.lineContent.slice(0, 2_000) + "..." : match.lineContent,
|
|
||||||
submatches: match.matchRanges.map(([start, end]) => ({
|
|
||||||
text: bytes.subarray(start, end).toString("utf8"),
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
find: (input) =>
|
find: (input) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
|
const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
|
||||||
@@ -236,18 +135,19 @@ export const fffLayer = Layer.effect(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const layer = (options?: Options) => Layer.unwrap(
|
export const layer = (options?: Options) =>
|
||||||
Effect.gen(function* () {
|
Layer.unwrap(
|
||||||
if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available())
|
Effect.gen(function* () {
|
||||||
return ripgrepLayer
|
if (options?.fff === false || (options?.fff === undefined && process.platform === "win32") || !Fff.available())
|
||||||
const location = yield* Location.Service
|
return ripgrepLayer
|
||||||
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
|
const location = yield* Location.Service
|
||||||
return location.vcs ? fffLayer : ripgrepLayer
|
// Non-VCS locations can contain many repositories, so avoid eagerly content-indexing the entire aggregate tree.
|
||||||
}),
|
return location.vcs && !Protected.isHome(location.directory) ? fffLayer : ripgrepLayer
|
||||||
)
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export function configured(options?: Options) {
|
export function configured(options?: Options) {
|
||||||
return makeLocationNode({ service: Service, layer: layer(options), deps: [FSUtil.node, Location.node, Ripgrep.node] })
|
return makeLocationNode({ service: Service, layer: layer(options), deps: [Location.node, Ripgrep.node] })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const node = configured()
|
export const node = configured()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * as Formatter from "./formatter"
|
export * as Formatter from "./formatter"
|
||||||
|
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
@@ -11,16 +11,7 @@ import { Config } from "./config"
|
|||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
import { make, type Info } from "./formatter/builtins"
|
import { make, type Info } from "./formatter/builtins"
|
||||||
|
|
||||||
export const Status = Schema.Struct({
|
|
||||||
name: Schema.String,
|
|
||||||
extensions: Schema.Array(Schema.String),
|
|
||||||
enabled: Schema.Boolean,
|
|
||||||
}).annotate({ identifier: "FormatterStatus" })
|
|
||||||
export type Status = typeof Status.Type
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly init: () => Effect.Effect<void>
|
|
||||||
readonly status: () => Effect.Effect<Status[]>
|
|
||||||
readonly file: (filepath: string) => Effect.Effect<boolean>
|
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,23 +75,6 @@ const layer = Layer.effect(
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
const init = Effect.fn("Formatter.init")(function* () {
|
|
||||||
yield* load
|
|
||||||
})
|
|
||||||
|
|
||||||
const status = Effect.fn("Formatter.status")(function* () {
|
|
||||||
yield* load
|
|
||||||
return yield* Effect.forEach(formatters, (formatter) =>
|
|
||||||
command(formatter).pipe(
|
|
||||||
Effect.map((enabled) => ({
|
|
||||||
name: formatter.name,
|
|
||||||
extensions: [...formatter.extensions],
|
|
||||||
enabled: enabled !== false,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||||
yield* load
|
yield* load
|
||||||
const matching = formatters.filter((formatter) =>
|
const matching = formatters.filter((formatter) =>
|
||||||
@@ -143,7 +117,7 @@ const layer = Layer.effect(
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ init, status, file })
|
return Service.of({ file })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
-224
@@ -1,8 +1,7 @@
|
|||||||
export * as Git from "./git"
|
export * as Git from "./git"
|
||||||
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { randomUUID } from "crypto"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { Context, Effect, Layer, Schema, Stream } from "effect"
|
|
||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { AbsolutePath, RelativePath } from "./schema"
|
import { AbsolutePath, RelativePath } from "./schema"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
@@ -36,9 +35,6 @@ const snapshotConfig = `[core]
|
|||||||
threads = true
|
threads = true
|
||||||
`
|
`
|
||||||
|
|
||||||
export const ChangeSet = Schema.String.pipe(Schema.brand("Git.ChangeSet"))
|
|
||||||
export type ChangeSet = typeof ChangeSet.Type
|
|
||||||
|
|
||||||
export const TreeID = Schema.String.pipe(Schema.brand("Git.TreeID"))
|
export const TreeID = Schema.String.pipe(Schema.brand("Git.TreeID"))
|
||||||
export type TreeID = typeof TreeID.Type
|
export type TreeID = typeof TreeID.Type
|
||||||
|
|
||||||
@@ -73,13 +69,6 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
|
|||||||
cause: Schema.optional(Schema.Defect()),
|
cause: Schema.optional(Schema.Defect()),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.PatchError", {
|
|
||||||
operation: Schema.Literals(["capture", "apply", "reset"]),
|
|
||||||
directory: AbsolutePath,
|
|
||||||
message: Schema.String,
|
|
||||||
cause: Schema.optional(Schema.Defect()),
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly repo: {
|
readonly repo: {
|
||||||
readonly discover: (input: AbsolutePath) => Effect.Effect<Repository | undefined>
|
readonly discover: (input: AbsolutePath) => Effect.Effect<Repository | undefined>
|
||||||
@@ -116,20 +105,6 @@ export interface Interface {
|
|||||||
) => Effect.Effect<void, OperationError>
|
) => Effect.Effect<void, OperationError>
|
||||||
readonly resetHard: (repository: Repository, revision: string) => Effect.Effect<void, OperationError>
|
readonly resetHard: (repository: Repository, revision: string) => Effect.Effect<void, OperationError>
|
||||||
}
|
}
|
||||||
readonly change: {
|
|
||||||
readonly capture: (input: { repository: Repository; path: AbsolutePath }) => Effect.Effect<ChangeSet, PatchError>
|
|
||||||
readonly apply: (input: {
|
|
||||||
repository: Repository
|
|
||||||
path: AbsolutePath
|
|
||||||
changes: ChangeSet
|
|
||||||
}) => Effect.Effect<void, PatchError>
|
|
||||||
readonly discard: (input: {
|
|
||||||
repository: Repository
|
|
||||||
path: AbsolutePath
|
|
||||||
index: "preserve" | "reset"
|
|
||||||
untracked: "preserve" | "remove"
|
|
||||||
}) => Effect.Effect<void, PatchError>
|
|
||||||
}
|
|
||||||
readonly worktree: {
|
readonly worktree: {
|
||||||
readonly create: (input: {
|
readonly create: (input: {
|
||||||
repository: Repository
|
repository: Repository
|
||||||
@@ -175,17 +150,10 @@ export interface Interface {
|
|||||||
context?: number
|
context?: number
|
||||||
paths?: readonly RelativePath[]
|
paths?: readonly RelativePath[]
|
||||||
}) => Effect.Effect<readonly File.Diff[], OperationError>
|
}) => Effect.Effect<readonly File.Diff[], OperationError>
|
||||||
readonly preview: (input: {
|
|
||||||
repository: Repository
|
|
||||||
current: TreeID
|
|
||||||
files: ReadonlyMap<RelativePath, TreeID>
|
|
||||||
context?: number
|
|
||||||
}) => Effect.Effect<readonly File.Diff[], OperationError>
|
|
||||||
readonly restore: (input: {
|
readonly restore: (input: {
|
||||||
repository: Repository
|
repository: Repository
|
||||||
files: ReadonlyMap<RelativePath, TreeID>
|
files: ReadonlyMap<RelativePath, TreeID>
|
||||||
}) => Effect.Effect<void, OperationError>
|
}) => Effect.Effect<void, OperationError>
|
||||||
readonly checkout: (input: { repository: Repository; tree: TreeID }) => Effect.Effect<void, OperationError>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,58 +625,6 @@ const layer = Layer.effect(
|
|||||||
return { mode: match[1], object: match[2] }
|
return { mode: match[1], object: match[2] }
|
||||||
})
|
})
|
||||||
|
|
||||||
const preview = Effect.fn("Git.tree.preview")(
|
|
||||||
(input: {
|
|
||||||
repository: Repository
|
|
||||||
current: TreeID
|
|
||||||
files: ReadonlyMap<RelativePath, TreeID>
|
|
||||||
context?: number
|
|
||||||
}) =>
|
|
||||||
locked(
|
|
||||||
input.repository,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const index = path.join(input.repository.gitDirectory, `preview-${randomUUID()}.index`)
|
|
||||||
const env = { GIT_INDEX_FILE: index }
|
|
||||||
return yield* Effect.gen(function* () {
|
|
||||||
yield* repositoryOperation("diff", input.repository, ["read-tree", input.current], { env })
|
|
||||||
yield* Effect.forEach(
|
|
||||||
input.files,
|
|
||||||
([file, tree]) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const source = yield* entry(input.repository, tree, file)
|
|
||||||
if (!source) {
|
|
||||||
yield* repositoryOperation(
|
|
||||||
"diff",
|
|
||||||
input.repository,
|
|
||||||
["update-index", "--force-remove", "--", file],
|
|
||||||
{ env },
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
yield* repositoryOperation(
|
|
||||||
"diff",
|
|
||||||
input.repository,
|
|
||||||
["update-index", "--add", "--cacheinfo", source.mode, source.object, file],
|
|
||||||
{ env },
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
{ discard: true },
|
|
||||||
)
|
|
||||||
const target = TreeID.make(
|
|
||||||
(yield* repositoryOperation("diff", input.repository, ["write-tree"], { env })).text.trim(),
|
|
||||||
)
|
|
||||||
return yield* treeDiff({
|
|
||||||
repository: input.repository,
|
|
||||||
from: input.current,
|
|
||||||
to: target,
|
|
||||||
context: input.context,
|
|
||||||
paths: Array.from(input.files.keys()),
|
|
||||||
})
|
|
||||||
}).pipe(Effect.ensuring(fs.remove(index).pipe(Effect.catch(() => Effect.void))))
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const restore = Effect.fn("Git.tree.restore")(
|
const restore = Effect.fn("Git.tree.restore")(
|
||||||
(input: { repository: Repository; files: ReadonlyMap<RelativePath, TreeID> }) =>
|
(input: { repository: Repository; files: ReadonlyMap<RelativePath, TreeID> }) =>
|
||||||
locked(
|
locked(
|
||||||
@@ -738,142 +654,6 @@ const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const checkoutTree = Effect.fn("Git.tree.checkout")((input: { repository: Repository; tree: TreeID }) =>
|
|
||||||
locked(
|
|
||||||
input.repository,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* repositoryOperation("restore", input.repository, ["read-tree", input.tree])
|
|
||||||
yield* repositoryOperation("restore", input.repository, ["checkout-index", "--all", "--force"])
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const capture = Effect.fn("Git.change.capture")(function* (input: { repository: Repository; path: AbsolutePath }) {
|
|
||||||
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
|
|
||||||
const tracked = yield* execute(
|
|
||||||
input.repository.worktree,
|
|
||||||
proc,
|
|
||||||
)(["diff", "--binary", "HEAD", "--", scope]).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (tracked.exitCode !== 0) {
|
|
||||||
return yield* new PatchError({
|
|
||||||
operation: "capture",
|
|
||||||
directory: input.path,
|
|
||||||
message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const untracked = yield* execute(
|
|
||||||
input.repository.worktree,
|
|
||||||
proc,
|
|
||||||
)(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (untracked.exitCode !== 0) {
|
|
||||||
return yield* new PatchError({
|
|
||||||
operation: "capture",
|
|
||||||
directory: input.path,
|
|
||||||
message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) =>
|
|
||||||
execute(
|
|
||||||
input.repository.worktree,
|
|
||||||
proc,
|
|
||||||
)(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
|
||||||
),
|
|
||||||
Effect.flatMap((result) =>
|
|
||||||
// git diff --no-index returns 1 when differences were found.
|
|
||||||
result.exitCode === 0 || result.exitCode === 1
|
|
||||||
? Effect.succeed(result.text)
|
|
||||||
: Effect.fail(
|
|
||||||
new PatchError({
|
|
||||||
operation: "capture",
|
|
||||||
directory: input.path,
|
|
||||||
message:
|
|
||||||
result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return ChangeSet.make([tracked.text, ...created].filter(Boolean).join("\n"))
|
|
||||||
})
|
|
||||||
|
|
||||||
const apply = Effect.fn("Git.change.apply")(function* (input: {
|
|
||||||
repository: Repository
|
|
||||||
path: AbsolutePath
|
|
||||||
changes: ChangeSet
|
|
||||||
}) {
|
|
||||||
const result = yield* proc
|
|
||||||
.run(
|
|
||||||
ChildProcess.make("git", ["apply", "-"], {
|
|
||||||
cwd: input.path,
|
|
||||||
extendEnv: true,
|
|
||||||
stdin: Stream.make(new TextEncoder().encode(input.changes)),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(cause) => new PatchError({ operation: "apply", directory: input.path, message: cause.message, cause }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (result.exitCode === 0) return
|
|
||||||
return yield* new PatchError({
|
|
||||||
operation: "apply",
|
|
||||||
directory: input.path,
|
|
||||||
message:
|
|
||||||
result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const discard = Effect.fn("Git.change.discard")(function* (input: {
|
|
||||||
repository: Repository
|
|
||||||
path: AbsolutePath
|
|
||||||
index: "preserve" | "reset"
|
|
||||||
untracked: "preserve" | "remove"
|
|
||||||
}) {
|
|
||||||
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
|
|
||||||
const restore = yield* execute(
|
|
||||||
input.repository.worktree,
|
|
||||||
proc,
|
|
||||||
)(input.index === "reset" ? ["checkout", "HEAD", "--", scope] : ["checkout", "--", scope]).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (restore.exitCode !== 0) {
|
|
||||||
return yield* new PatchError({
|
|
||||||
operation: "reset",
|
|
||||||
directory: input.path,
|
|
||||||
message: restore.stderr.trim() || restore.text.trim() || "Failed to restore tracked changes",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (input.untracked === "preserve") return
|
|
||||||
const clean = yield* execute(
|
|
||||||
input.repository.worktree,
|
|
||||||
proc,
|
|
||||||
)(["clean", "-fd", "--", scope]).pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (clean.exitCode === 0) return
|
|
||||||
return yield* new PatchError({
|
|
||||||
operation: "reset",
|
|
||||||
directory: input.path,
|
|
||||||
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const worktreeRun = Effect.fnUntraced(function* (
|
const worktreeRun = Effect.fnUntraced(function* (
|
||||||
operation: "create" | "remove" | "list",
|
operation: "create" | "remove" | "list",
|
||||||
repository: Repository,
|
repository: Repository,
|
||||||
@@ -949,7 +729,6 @@ const layer = Layer.effect(
|
|||||||
remote: { get: remote },
|
remote: { get: remote },
|
||||||
history: { head, branch, defaultRemoteBranch: remoteHead, rootCommits: roots },
|
history: { head, branch, defaultRemoteBranch: remoteHead, rootCommits: roots },
|
||||||
sync: { fetchRemotes: fetch, fetchBranch, checkoutRemoteBranch: checkout, resetHard: reset },
|
sync: { fetchRemotes: fetch, fetchBranch, checkoutRemoteBranch: checkout, resetHard: reset },
|
||||||
change: { capture, apply, discard },
|
|
||||||
worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList },
|
worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList },
|
||||||
index: { refresh, ignored },
|
index: { refresh, ignored },
|
||||||
tree: {
|
tree: {
|
||||||
@@ -957,9 +736,7 @@ const layer = Layer.effect(
|
|||||||
write: writeTree,
|
write: writeTree,
|
||||||
files: treeFiles,
|
files: treeFiles,
|
||||||
diff: treeDiff,
|
diff: treeDiff,
|
||||||
preview,
|
|
||||||
restore,
|
restore,
|
||||||
checkout: checkoutTree,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export * as PluginPromise from "./promise"
|
|||||||
|
|
||||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||||
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
import type { Context, Plugin } from "@opencode-ai/plugin/promise/plugin"
|
||||||
import type { SessionHooks, SessionHttp, SessionHttpMiddleware } from "@opencode-ai/plugin/promise/session"
|
|
||||||
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
import type { Info } from "@opencode-ai/plugin/promise/tool"
|
||||||
import { Agent } from "@opencode-ai/schema/agent"
|
import { Agent } from "@opencode-ai/schema/agent"
|
||||||
import { Integration } from "@opencode-ai/schema/integration"
|
import { Integration } from "@opencode-ai/schema/integration"
|
||||||
@@ -58,62 +57,6 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
function sessionHook<Name extends keyof SessionHooks>(
|
|
||||||
name: Name,
|
|
||||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
|
||||||
): Promise<Registration>
|
|
||||||
function sessionHook(
|
|
||||||
...registration: {
|
|
||||||
[Name in keyof SessionHooks]: [
|
|
||||||
name: Name,
|
|
||||||
callback: (event: SessionHooks[Name]) => Promise<void> | void,
|
|
||||||
]
|
|
||||||
}[keyof SessionHooks]
|
|
||||||
) {
|
|
||||||
if (registration[0] !== "http")
|
|
||||||
return register(
|
|
||||||
host.session.hook(registration[0], (event) =>
|
|
||||||
Effect.promise(() => Promise.resolve(registration[1](event))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return register(
|
|
||||||
host.session.hook("http", (event) => {
|
|
||||||
const middlewares: SessionHttpMiddleware[] = []
|
|
||||||
const output: SessionHttp = {
|
|
||||||
...event,
|
|
||||||
use: (item) => {
|
|
||||||
middlewares.push(item)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return Effect.promise(() => Promise.resolve(registration[1](output))).pipe(
|
|
||||||
Effect.flatMap(() =>
|
|
||||||
Effect.forEach(
|
|
||||||
middlewares,
|
|
||||||
(item) =>
|
|
||||||
event.use((input, next) =>
|
|
||||||
Effect.tryPromise({
|
|
||||||
try: (signal) => {
|
|
||||||
const inputSignal = AbortSignal.any([signal, input.signal])
|
|
||||||
return Promise.resolve(
|
|
||||||
item(new Request(input, { signal: inputSignal }), (request) => {
|
|
||||||
const requestSignal = AbortSignal.any([signal, request.signal])
|
|
||||||
return Effect.runPromiseWith(
|
|
||||||
context,
|
|
||||||
)(next(new Request(request, { signal: requestSignal })), { signal: requestSignal })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
{ discard: true },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const context2: Context = {
|
const context2: Context = {
|
||||||
app: host.app,
|
app: host.app,
|
||||||
options: host.options,
|
options: host.options,
|
||||||
@@ -322,7 +265,8 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
hook: sessionHook,
|
hook: (name, callback) =>
|
||||||
|
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
run(
|
run(
|
||||||
host.session.create(
|
host.session.create(
|
||||||
|
|||||||
@@ -225,14 +225,14 @@ export const OpenAIPlugin = define({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
yield* ctx.session.hook("http", (evt) =>
|
yield* ctx.session.hook("http.request", (evt) =>
|
||||||
evt.use((request, next) => {
|
Effect.sync(() => {
|
||||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request)
|
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||||
const url = new URL(request.url)
|
const url = new URL(evt.request.url)
|
||||||
request.headers.set("originator", "opencode")
|
evt.request.headers.set("originator", "opencode")
|
||||||
request.headers.set("session-id", evt.sessionID)
|
evt.request.headers.set("session-id", evt.sessionID)
|
||||||
if (url.origin !== "https://api.openai.com") return next(request)
|
if (url.origin !== "https://api.openai.com") return
|
||||||
return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request))
|
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -59,16 +59,6 @@ export interface Interface {
|
|||||||
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
||||||
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
|
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
|
||||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
|
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
|
||||||
/**
|
|
||||||
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
|
|
||||||
*
|
|
||||||
* This exists while the old opencode project service and this core project
|
|
||||||
* service work together: core resolves the ID, while the old service still owns
|
|
||||||
* database migration and persistence. The old service should call this after it
|
|
||||||
* finishes migrating from `resolve().previous` to `resolve().id`; once project
|
|
||||||
* persistence moves into core, this separate bridge method can go away.
|
|
||||||
*/
|
|
||||||
readonly commit: (input: { store: AbsolutePath; id: ID }) => Effect.Effect<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
|
||||||
@@ -268,11 +258,7 @@ const layer = Layer.effect(
|
|||||||
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
|
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
|
||||||
})
|
})
|
||||||
|
|
||||||
const commit = Effect.fn("Project.commit")(function* (input: { store: AbsolutePath; id: ID }) {
|
return Service.of({ list, directories, resolve })
|
||||||
yield* fs.writeFileString(path.join(input.store, "opencode"), input.id).pipe(Effect.ignore)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ list, directories, resolve, commit })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -31,14 +31,6 @@ export type EnsureInput = {
|
|||||||
readonly branch?: string
|
readonly branch?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InvalidRepositoryError extends Schema.TaggedErrorClass<InvalidRepositoryError>()(
|
|
||||||
"RepositoryCacheInvalidRepositoryError",
|
|
||||||
{
|
|
||||||
repository: Schema.String,
|
|
||||||
message: Schema.String,
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()(
|
export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchError>()(
|
||||||
"RepositoryCacheInvalidBranchError",
|
"RepositoryCacheInvalidBranchError",
|
||||||
{
|
{
|
||||||
@@ -86,7 +78,6 @@ export class CacheOperationError extends Schema.TaggedErrorClass<CacheOperationE
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
export type Error =
|
export type Error =
|
||||||
| InvalidRepositoryError
|
|
||||||
| InvalidBranchError
|
| InvalidBranchError
|
||||||
| CloneFailedError
|
| CloneFailedError
|
||||||
| FetchFailedError
|
| FetchFailedError
|
||||||
@@ -103,7 +94,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Re
|
|||||||
|
|
||||||
export function isError(error: unknown): error is Error {
|
export function isError(error: unknown): error is Error {
|
||||||
return (
|
return (
|
||||||
error instanceof InvalidRepositoryError ||
|
|
||||||
error instanceof InvalidBranchError ||
|
error instanceof InvalidBranchError ||
|
||||||
error instanceof CloneFailedError ||
|
error instanceof CloneFailedError ||
|
||||||
error instanceof FetchFailedError ||
|
error instanceof FetchFailedError ||
|
||||||
@@ -114,13 +104,6 @@ export function isError(error: unknown): error is Error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const parseRemote = Effect.fn("RepositoryCache.parseRemote")(function* (repository: string) {
|
|
||||||
return yield* Effect.try({
|
|
||||||
try: () => Repository.parseRemote(repository),
|
|
||||||
catch: (error) => new InvalidRepositoryError({ repository, message: errorMessage(error) }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
|
export const validateBranch = Effect.fn("RepositoryCache.validateBranch")(function* (branch: string) {
|
||||||
return yield* Effect.try({
|
return yield* Effect.try({
|
||||||
try: () => Repository.validateBranch(branch),
|
try: () => Repository.validateBranch(branch),
|
||||||
|
|||||||
@@ -44,16 +44,6 @@ export class InvalidBranchError extends Schema.TaggedErrorClass<InvalidBranchErr
|
|||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export type Error = InvalidReferenceError | UnsupportedLocalRepositoryError | InvalidBranchError
|
|
||||||
|
|
||||||
export function isError(error: unknown): error is Error {
|
|
||||||
return (
|
|
||||||
error instanceof InvalidReferenceError ||
|
|
||||||
error instanceof UnsupportedLocalRepositoryError ||
|
|
||||||
error instanceof InvalidBranchError
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parse(input: string): Reference | undefined {
|
export function parse(input: string): Reference | undefined {
|
||||||
const cleaned = normalizeInput(input)
|
const cleaned = normalizeInput(input)
|
||||||
if (!cleaned) return
|
if (!cleaned) return
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface FindInput {
|
|||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
readonly pattern: string
|
readonly pattern: string
|
||||||
readonly limit: number
|
readonly limit: number
|
||||||
|
readonly exclude?: readonly string[]
|
||||||
readonly hidden?: boolean
|
readonly hidden?: boolean
|
||||||
readonly follow?: boolean
|
readonly follow?: boolean
|
||||||
readonly signal?: AbortSignal
|
readonly signal?: AbortSignal
|
||||||
@@ -195,6 +196,7 @@ const layer = Layer.effect(
|
|||||||
...(input.hidden ? ["--hidden"] : []),
|
...(input.hidden ? ["--hidden"] : []),
|
||||||
...(input.follow ? ["--follow"] : []),
|
...(input.follow ? ["--follow"] : []),
|
||||||
...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]),
|
...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]),
|
||||||
|
...(input.exclude ?? []).map((pattern) => `--glob=!${pattern}`),
|
||||||
"--glob=!**/.git/**",
|
"--glob=!**/.git/**",
|
||||||
".",
|
".",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ const layer = Layer.effect(
|
|||||||
fork: Effect.fn("Session.fork")(function* (input) {
|
fork: Effect.fn("Session.fork")(function* (input) {
|
||||||
const parent = yield* result.get(input.sessionID)
|
const parent = yield* result.get(input.sessionID)
|
||||||
const boundary = yield* db
|
const boundary = yield* db
|
||||||
.select({ id: SessionMessageTable.id, seq: SessionMessageTable.seq })
|
.select({ id: SessionMessageTable.id })
|
||||||
.from(SessionMessageTable)
|
.from(SessionMessageTable)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -429,13 +429,14 @@ const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
|
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
|
||||||
const sessionID = SessionSchema.ID.create()
|
const sessionID = SessionSchema.ID.create()
|
||||||
const instructionThrough =
|
// The fork adopts the parent's newest instruction values rather than the
|
||||||
input.boundary.type === "before" ? boundary.seq - 1 : yield* Bus.latestSequence(db, parent.id)
|
// values in effect at the boundary; copied history may contain frozen
|
||||||
|
// instruction-update text the initial baseline already reflects.
|
||||||
yield* bus.publish(SessionEvent.Forked, {
|
yield* bus.publish(SessionEvent.Forked, {
|
||||||
sessionID,
|
sessionID,
|
||||||
parentID: parent.id,
|
parentID: parent.id,
|
||||||
boundary: { ...input.boundary, messageID: boundary.id },
|
boundary: { ...input.boundary, messageID: boundary.id },
|
||||||
instructions: yield* InstructionState.valuesAt(db, parent.id, instructionThrough),
|
instructions: yield* InstructionState.current(db, parent.id),
|
||||||
})
|
})
|
||||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -80,10 +80,9 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun
|
|||||||
.transaction(() =>
|
.transaction(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const messages = yield* messageEntries(db, sessionID)
|
const messages = yield* messageEntries(db, sessionID)
|
||||||
const assembled = yield* InstructionState.assemble(db, sessionID, instructions)
|
|
||||||
return {
|
return {
|
||||||
initial: assembled.initial,
|
initial: yield* InstructionState.initial(db, sessionID, instructions),
|
||||||
entries: [...messages, ...assembled.updates].toSorted((a, b) => a.seq - b.seq),
|
entries: messages,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -106,10 +105,9 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
|
|||||||
)
|
)
|
||||||
const settled = unsettled === -1 ? messages : messages.slice(0, unsettled)
|
const settled = unsettled === -1 ? messages : messages.slice(0, unsettled)
|
||||||
const assembled = yield* InstructionState.preview(db, sessionID, instructions, observed)
|
const assembled = yield* InstructionState.preview(db, sessionID, instructions, observed)
|
||||||
const entries = [...settled, ...assembled.updates].toSorted((a, b) => a.seq - b.seq)
|
|
||||||
return {
|
return {
|
||||||
initial: assembled.initial,
|
initial: assembled.initial,
|
||||||
messages: entries.map((entry) => entry.message),
|
messages: settled.map((entry) => entry.message),
|
||||||
instructionUpdate: assembled.update,
|
instructionUpdate: assembled.update,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
export * as InstructionState from "./instruction-state"
|
export * as InstructionState from "./instruction-state"
|
||||||
|
|
||||||
import { and, asc, desc, eq, gt, inArray, lte, sql } from "drizzle-orm"
|
import { eq, inArray, sql } from "drizzle-orm"
|
||||||
import { DateTime, Effect, Option, Schema } from "effect"
|
import { Effect, Option, Schema } from "effect"
|
||||||
import type { Database } from "../database/database"
|
import type { Database } from "../database/database"
|
||||||
import { Bus } from "../bus"
|
import type { Bus } from "../bus"
|
||||||
import { EventTable } from "../event/sql"
|
|
||||||
import { Instructions } from "../instructions/index"
|
import { Instructions } from "../instructions/index"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
import { SessionMessage } from "./message"
|
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { InstructionBlobTable, InstructionStateTable } from "./sql"
|
import { InstructionBlobTable, InstructionStateTable } from "./sql"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
|
|
||||||
const decodeInstructionsUpdated = Schema.decodeUnknownSync(SessionEvent.InstructionsUpdated.data)
|
|
||||||
const decodeForked = Schema.decodeUnknownSync(SessionEvent.Forked.data)
|
|
||||||
|
|
||||||
export interface Observation extends Instructions.Admission {
|
export interface Observation extends Instructions.Admission {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly initial: boolean
|
readonly initial: boolean
|
||||||
|
readonly previous: Instructions.Values
|
||||||
readonly current: Instructions.Values
|
readonly current: Instructions.Values
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,13 +23,14 @@ export const observe = Effect.fn("InstructionState.observe")(function* (
|
|||||||
instructions: Instructions.Instructions,
|
instructions: Instructions.Instructions,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
): Effect.fn.Return<Observation, Instructions.InitializationBlocked> {
|
): Effect.fn.Return<Observation, Instructions.InitializationBlocked> {
|
||||||
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], {
|
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), find(db, sessionID)], {
|
||||||
concurrency: "unbounded",
|
concurrency: "unbounded",
|
||||||
})
|
})
|
||||||
const result = yield* observeAgainst(observed, stored?.current_values)
|
const result = yield* observeAgainst(observed, stored?.current_values)
|
||||||
return {
|
return {
|
||||||
sessionID,
|
sessionID,
|
||||||
initial: !stored,
|
initial: !stored,
|
||||||
|
previous: stored?.current_values ?? {},
|
||||||
...result,
|
...result,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -42,12 +38,20 @@ export const observe = Effect.fn("InstructionState.observe")(function* (
|
|||||||
export const commit = Effect.fn("InstructionState.commit")(function* (
|
export const commit = Effect.fn("InstructionState.commit")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
bus: Bus.Interface,
|
bus: Bus.Interface,
|
||||||
|
instructions: Instructions.Instructions,
|
||||||
observation: Observation,
|
observation: Observation,
|
||||||
) {
|
) {
|
||||||
if (!observation.initial && Object.keys(observation.delta).length === 0) return
|
if (!observation.initial && Object.keys(observation.delta).length === 0) return
|
||||||
|
// The rendered text is frozen into the durable event: replaying it later would
|
||||||
|
// require the Location-scoped registry that produced it.
|
||||||
|
const text = observation.initial ? "" : yield* renderUpdateText(db, instructions, observation)
|
||||||
yield* bus.publish(
|
yield* bus.publish(
|
||||||
SessionEvent.InstructionsUpdated,
|
SessionEvent.InstructionsUpdated,
|
||||||
{ sessionID: observation.sessionID, delta: observation.delta },
|
{
|
||||||
|
sessionID: observation.sessionID,
|
||||||
|
delta: observation.delta,
|
||||||
|
...(text.length > 0 ? { text } : {}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Initial sync establishes the baseline; unlike later deltas it is not chronological history.
|
// Initial sync establishes the baseline; unlike later deltas it is not chronological history.
|
||||||
...(observation.initial ? { metadata: { instructions: { initial: true } } } : {}),
|
...(observation.initial ? { metadata: { instructions: { initial: true } } } : {}),
|
||||||
@@ -56,13 +60,27 @@ export const commit = Effect.fn("InstructionState.commit")(function* (
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const renderUpdateText = Effect.fnUntraced(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
instructions: Instructions.Instructions,
|
||||||
|
observation: Observation,
|
||||||
|
) {
|
||||||
|
const replaced = Object.entries(observation.previous).filter(([key]) => Object.hasOwn(observation.delta, key))
|
||||||
|
const blobs = yield* loadBlobs(db, replaced.map(([, hash]) => hash))
|
||||||
|
const previous = Object.fromEntries(replaced.map(([key, hash]) => [key, requireBlob(blobs, hash)]))
|
||||||
|
const admitted = new Map(
|
||||||
|
Object.entries(observation.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]),
|
||||||
|
)
|
||||||
|
return Instructions.renderUpdate(instructions, previous, dereferenceDelta(observation.delta, admitted))
|
||||||
|
})
|
||||||
|
|
||||||
export const prepare = Effect.fn("InstructionState.prepare")(function* (
|
export const prepare = Effect.fn("InstructionState.prepare")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
bus: Bus.Interface,
|
bus: Bus.Interface,
|
||||||
instructions: Instructions.Instructions,
|
instructions: Instructions.Instructions,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
yield* commit(db, bus, yield* observe(db, instructions, sessionID))
|
yield* commit(db, bus, instructions, yield* observe(db, instructions, sessionID))
|
||||||
})
|
})
|
||||||
|
|
||||||
export const apply = Effect.fn("InstructionState.apply")(function* (
|
export const apply = Effect.fn("InstructionState.apply")(function* (
|
||||||
@@ -140,79 +158,24 @@ export const reset = Effect.fn("InstructionState.reset")(function* (db: Database
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const rebuild = Effect.fn("InstructionState.rebuild")(function* (
|
/** Renders the epoch baseline shown at the start of every model request. */
|
||||||
db: DatabaseService,
|
export const initial = Effect.fn("InstructionState.initial")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
) {
|
|
||||||
const state = yield* stateFromEvents(db, sessionID)
|
|
||||||
if (!state) {
|
|
||||||
yield* reset(db, sessionID)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
yield* db
|
|
||||||
.insert(InstructionStateTable)
|
|
||||||
.values(state)
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: InstructionStateTable.session_id,
|
|
||||||
set: {
|
|
||||||
epoch_start: state.epoch_start,
|
|
||||||
through_seq: state.through_seq,
|
|
||||||
initial_values: state.initial_values,
|
|
||||||
current_values: state.current_values,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
return state
|
|
||||||
})
|
|
||||||
|
|
||||||
const assembleState = Effect.fnUntraced(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
instructions: Instructions.Instructions,
|
|
||||||
state: typeof InstructionStateTable.$inferSelect,
|
|
||||||
) {
|
|
||||||
const rows = yield* instructionUpdatesAfter(db, sessionID, state.epoch_start)
|
|
||||||
const updates = rows.map((row) => ({
|
|
||||||
row,
|
|
||||||
delta: decodeInstructionsUpdated(row.data).delta,
|
|
||||||
}))
|
|
||||||
const blobs = yield* loadBlobs(db, [
|
|
||||||
...Object.values(state.initial_values),
|
|
||||||
...updates.flatMap((update) =>
|
|
||||||
Object.values(update.delta).filter((hash): hash is Instructions.Hash => hash !== "removed"),
|
|
||||||
),
|
|
||||||
])
|
|
||||||
const valuesAtStart = dereference(state.initial_values, blobs)
|
|
||||||
let values = valuesAtStart
|
|
||||||
const result: Array<{ readonly seq: number; readonly message: SessionMessage.System }> = []
|
|
||||||
for (const update of updates) {
|
|
||||||
const delta = dereferenceDelta(update.delta, blobs)
|
|
||||||
const text = Instructions.renderUpdate(instructions, values, delta)
|
|
||||||
if (text.length > 0)
|
|
||||||
result.push({
|
|
||||||
seq: update.row.seq,
|
|
||||||
message: SessionMessage.System.make({
|
|
||||||
id: SessionMessage.ID.fromEvent(Event.ID.make(update.row.id)),
|
|
||||||
type: "system",
|
|
||||||
text,
|
|
||||||
time: { created: DateTime.makeUnsafe(update.row.created) },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
values = Instructions.applyDelta(values, delta)
|
|
||||||
}
|
|
||||||
return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result, current: values }
|
|
||||||
})
|
|
||||||
|
|
||||||
export const assemble = Effect.fn("InstructionState.assemble")(function* (
|
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
instructions: Instructions.Instructions,
|
instructions: Instructions.Instructions,
|
||||||
) {
|
) {
|
||||||
const state = yield* find(db, sessionID)
|
const state = yield* find(db, sessionID)
|
||||||
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
|
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
|
||||||
const assembled = yield* assembleState(db, sessionID, instructions, state)
|
const blobs = yield* loadBlobs(db, Object.values(state.initial_values))
|
||||||
return { initial: assembled.initial, updates: assembled.updates }
|
return Instructions.renderInitial(instructions, dereference(state.initial_values, blobs))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The current instruction values, used to seed a fork's baseline. */
|
||||||
|
export const current = Effect.fn("InstructionState.current")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
) {
|
||||||
|
return (yield* find(db, sessionID))?.current_values
|
||||||
})
|
})
|
||||||
|
|
||||||
export const preview = Effect.fn("InstructionState.preview")(function* (
|
export const preview = Effect.fn("InstructionState.preview")(function* (
|
||||||
@@ -221,20 +184,26 @@ export const preview = Effect.fn("InstructionState.preview")(function* (
|
|||||||
instructions: Instructions.Instructions,
|
instructions: Instructions.Instructions,
|
||||||
observed: Instructions.ReadResult,
|
observed: Instructions.ReadResult,
|
||||||
) {
|
) {
|
||||||
const state = yield* readState(db, sessionID)
|
const state = yield* find(db, sessionID)
|
||||||
const result = yield* observeAgainst(observed, state?.current_values)
|
const result = yield* observeAgainst(observed, state?.current_values)
|
||||||
const blobs = new Map<Instructions.Hash, Schema.Json>(
|
const observedBlobs = new Map<Instructions.Hash, Schema.Json>(
|
||||||
Object.entries(result.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]),
|
Object.entries(result.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]),
|
||||||
)
|
)
|
||||||
if (!state) {
|
if (!state) {
|
||||||
const values = dereference(result.current, blobs)
|
const values = dereference(result.current, observedBlobs)
|
||||||
return { initial: Instructions.renderInitial(instructions, values), updates: [], update: "" }
|
return { initial: Instructions.renderInitial(instructions, values), update: "" }
|
||||||
}
|
}
|
||||||
const assembled = yield* assembleState(db, sessionID, instructions, state)
|
const stored = yield* loadBlobs(db, [
|
||||||
|
...Object.values(state.initial_values),
|
||||||
|
...Object.values(state.current_values),
|
||||||
|
])
|
||||||
return {
|
return {
|
||||||
initial: assembled.initial,
|
initial: Instructions.renderInitial(instructions, dereference(state.initial_values, stored)),
|
||||||
updates: assembled.updates,
|
update: Instructions.renderUpdate(
|
||||||
update: Instructions.renderUpdate(instructions, assembled.current, dereferenceDelta(result.delta, blobs)),
|
instructions,
|
||||||
|
dereference(state.current_values, stored),
|
||||||
|
dereferenceDelta(result.delta, new Map([...stored, ...observedBlobs])),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -255,46 +224,6 @@ const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sessio
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
})
|
})
|
||||||
|
|
||||||
const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
|
||||||
const stored = yield* find(db, sessionID)
|
|
||||||
if (!stored) return yield* rebuild(db, sessionID)
|
|
||||||
const latest = yield* latestRelevantSequence(db, sessionID)
|
|
||||||
if (!latest || latest.seq <= stored.through_seq) return stored
|
|
||||||
return yield* rebuild(db, sessionID)
|
|
||||||
})
|
|
||||||
|
|
||||||
const readState = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
|
||||||
const stored = yield* find(db, sessionID)
|
|
||||||
if (!stored) return yield* stateFromEvents(db, sessionID)
|
|
||||||
const latest = yield* latestRelevantSequence(db, sessionID)
|
|
||||||
if (!latest || latest.seq <= stored.through_seq) return stored
|
|
||||||
return yield* stateFromEvents(db, sessionID)
|
|
||||||
})
|
|
||||||
|
|
||||||
const stateFromEvents = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
|
||||||
const folded = fold(yield* instructionEvents(db, sessionID))
|
|
||||||
return folded ? foldedState(sessionID, folded) : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
export const valuesAt = Effect.fn("InstructionState.valuesAt")(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
through: number,
|
|
||||||
) {
|
|
||||||
return fold(yield* instructionEvents(db, sessionID, through))?.current
|
|
||||||
})
|
|
||||||
|
|
||||||
const latestRelevantSequence = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
|
||||||
return yield* db
|
|
||||||
.select({ seq: EventTable.seq })
|
|
||||||
.from(EventTable)
|
|
||||||
.where(and(eq(EventTable.aggregate_id, sessionID), inArray(EventTable.type, relevantEventTypes)))
|
|
||||||
.orderBy(desc(EventTable.seq))
|
|
||||||
.limit(1)
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
})
|
|
||||||
|
|
||||||
const insertBlobs = Effect.fnUntraced(function* (db: DatabaseService, blobs: Readonly<Record<string, Schema.Json>>) {
|
const insertBlobs = Effect.fnUntraced(function* (db: DatabaseService, blobs: Readonly<Record<string, Schema.Json>>) {
|
||||||
const rows = Object.entries(blobs).map(([hash, value]) => ({ hash: Instructions.Hash.make(hash), value }))
|
const rows = Object.entries(blobs).map(([hash, value]) => ({ hash: Instructions.Hash.make(hash), value }))
|
||||||
if (rows.length === 0) return
|
if (rows.length === 0) return
|
||||||
@@ -339,106 +268,3 @@ function requireBlob(blobs: ReadonlyMap<Instructions.Hash, Schema.Json>, hash: I
|
|||||||
if (value === undefined) throw new Error(`Instruction blob not found: ${hash}`)
|
if (value === undefined) throw new Error(`Instruction blob not found: ${hash}`)
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
const instructionEventType = Bus.versionedType(
|
|
||||||
SessionEvent.InstructionsUpdated.type,
|
|
||||||
SessionEvent.InstructionsUpdated.durable.version,
|
|
||||||
)
|
|
||||||
const compactionEventType = Bus.versionedType(
|
|
||||||
SessionEvent.Compaction.Ended.type,
|
|
||||||
SessionEvent.Compaction.Ended.durable.version,
|
|
||||||
)
|
|
||||||
const movedEventType = Bus.versionedType(SessionEvent.Moved.type, SessionEvent.Moved.durable.version)
|
|
||||||
const revertedEventType = Bus.versionedType(
|
|
||||||
SessionEvent.RevertEvent.Committed.type,
|
|
||||||
SessionEvent.RevertEvent.Committed.durable.version,
|
|
||||||
)
|
|
||||||
const forkedEventType = Bus.versionedType(SessionEvent.Forked.type, SessionEvent.Forked.durable.version)
|
|
||||||
const relevantEventTypes = [
|
|
||||||
forkedEventType,
|
|
||||||
instructionEventType,
|
|
||||||
compactionEventType,
|
|
||||||
movedEventType,
|
|
||||||
revertedEventType,
|
|
||||||
]
|
|
||||||
|
|
||||||
type InstructionEventRow = typeof EventTable.$inferSelect
|
|
||||||
|
|
||||||
const instructionEvents = Effect.fnUntraced(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
through?: number,
|
|
||||||
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
|
|
||||||
return yield* eventRows(db, sessionID, relevantEventTypes, undefined, through)
|
|
||||||
})
|
|
||||||
|
|
||||||
const instructionUpdatesAfter = Effect.fnUntraced(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
after: number,
|
|
||||||
) {
|
|
||||||
return yield* eventRows(db, sessionID, [instructionEventType], after)
|
|
||||||
})
|
|
||||||
|
|
||||||
const eventRows = Effect.fnUntraced(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
types: ReadonlyArray<string>,
|
|
||||||
after?: number,
|
|
||||||
through?: number,
|
|
||||||
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
|
|
||||||
return yield* db
|
|
||||||
.select()
|
|
||||||
.from(EventTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(EventTable.aggregate_id, sessionID),
|
|
||||||
inArray(EventTable.type, types),
|
|
||||||
after === undefined ? undefined : gt(EventTable.seq, after),
|
|
||||||
through === undefined ? undefined : lte(EventTable.seq, through),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(asc(EventTable.seq))
|
|
||||||
.all()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
})
|
|
||||||
|
|
||||||
function fold(rows: ReadonlyArray<InstructionEventRow>) {
|
|
||||||
return rows.reduce<
|
|
||||||
| {
|
|
||||||
readonly epochStart: number
|
|
||||||
readonly throughSeq: number
|
|
||||||
readonly initial: Instructions.Values
|
|
||||||
readonly current: Instructions.Values
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
>((state, row) => {
|
|
||||||
if (row.type === forkedEventType) {
|
|
||||||
const instructions = decodeForked(row.data).instructions
|
|
||||||
return instructions
|
|
||||||
? { epochStart: row.seq, throughSeq: row.seq, initial: instructions, current: instructions }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
if (row.type === movedEventType || row.type === revertedEventType) return undefined
|
|
||||||
if (row.type === compactionEventType)
|
|
||||||
return state
|
|
||||||
? { epochStart: row.seq, throughSeq: row.seq, initial: state.current, current: state.current }
|
|
||||||
: undefined
|
|
||||||
if (row.type !== instructionEventType) return state
|
|
||||||
const delta = decodeInstructionsUpdated(row.data).delta
|
|
||||||
const current = Instructions.applyHashDelta(state?.current ?? {}, delta)
|
|
||||||
return state
|
|
||||||
? { ...state, throughSeq: row.seq, current }
|
|
||||||
: { epochStart: row.seq, throughSeq: row.seq, initial: current, current }
|
|
||||||
}, undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
function foldedState(sessionID: SessionSchema.ID, folded: NonNullable<ReturnType<typeof fold>>) {
|
|
||||||
return {
|
|
||||||
session_id: sessionID,
|
|
||||||
epoch_start: folded.epochStart,
|
|
||||||
through_seq: folded.throughSeq,
|
|
||||||
initial_values: folded.initial,
|
|
||||||
current_values: folded.current,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { castDraft, produce, type WritableDraft } from "immer"
|
import { castDraft, produce, type WritableDraft } from "immer"
|
||||||
import { DateTime, Effect } from "effect"
|
import { DateTime, Effect, Match, pipe } from "effect"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
|
|
||||||
export type MemoryState = {
|
|
||||||
messages: SessionMessage.Info[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Adapter {
|
export interface Adapter {
|
||||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||||
@@ -23,89 +19,7 @@ export interface Adapter {
|
|||||||
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
|
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function memory(state: MemoryState): Adapter {
|
export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||||
const assistantIndex = (messageID: SessionMessage.ID) =>
|
|
||||||
state.messages.findLastIndex((message) => message.id === messageID)
|
|
||||||
const shellIndex = (messageID: SessionMessage.ID) =>
|
|
||||||
state.messages.findLastIndex((message) => message.id === messageID)
|
|
||||||
const compactionIndex = () =>
|
|
||||||
state.messages.findLastIndex((message) => message.type === "compaction" && message.status === "running")
|
|
||||||
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
|
|
||||||
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
|
|
||||||
|
|
||||||
return {
|
|
||||||
getModel() {
|
|
||||||
return Effect.sync(
|
|
||||||
() =>
|
|
||||||
state.messages.findLast(
|
|
||||||
(message): message is SessionMessage.ModelSelected | SessionMessage.Assistant =>
|
|
||||||
message.type === "model-switched" || message.type === "assistant",
|
|
||||||
)?.model,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
getCurrentAssistant() {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
const index = latestAssistantIndex()
|
|
||||||
if (index < 0) return
|
|
||||||
const assistant = state.messages[index]
|
|
||||||
return assistant?.type === "assistant" && !assistant.time.completed ? assistant : undefined
|
|
||||||
})
|
|
||||||
},
|
|
||||||
getAssistant(messageID) {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
const index = assistantIndex(messageID)
|
|
||||||
if (index < 0) return
|
|
||||||
const assistant = state.messages[index]
|
|
||||||
return assistant?.type === "assistant" ? assistant : undefined
|
|
||||||
})
|
|
||||||
},
|
|
||||||
getShell(shellID) {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
return state.messages.find((message): message is SessionMessage.Shell => {
|
|
||||||
return message.type === "shell" && message.shellID === shellID
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
getCompaction() {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
const index = compactionIndex()
|
|
||||||
const message = state.messages[index]
|
|
||||||
return message?.type === "compaction" ? message : undefined
|
|
||||||
})
|
|
||||||
},
|
|
||||||
updateAssistant(assistant) {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
const index = assistantIndex(assistant.id)
|
|
||||||
if (index < 0) return
|
|
||||||
const current = state.messages[index]
|
|
||||||
if (current?.type !== "assistant") return
|
|
||||||
state.messages[index] = assistant
|
|
||||||
})
|
|
||||||
},
|
|
||||||
updateShell(shell) {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
const index = shellIndex(shell.id)
|
|
||||||
if (index < 0) return
|
|
||||||
const current = state.messages[index]
|
|
||||||
if (current?.type !== "shell") return
|
|
||||||
state.messages[index] = shell
|
|
||||||
})
|
|
||||||
},
|
|
||||||
updateCompaction(compaction) {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
const index = state.messages.findLastIndex((message) => message.id === compaction.id)
|
|
||||||
if (index >= 0) state.messages[index] = compaction
|
|
||||||
})
|
|
||||||
},
|
|
||||||
appendMessage(message) {
|
|
||||||
return Effect.sync(() => {
|
|
||||||
state.messages.push(message)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|
||||||
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
|
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
|
||||||
type DraftTool = WritableDraft<SessionMessage.AssistantTool>
|
type DraftTool = WritableDraft<SessionMessage.AssistantTool>
|
||||||
type DraftText = WritableDraft<SessionMessage.AssistantText>
|
type DraftText = WritableDraft<SessionMessage.AssistantText>
|
||||||
@@ -139,9 +53,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return Effect.gen(function* () {
|
const project = pipe(
|
||||||
yield* SessionEvent.All.match(event, {
|
Match.type<SessionEvent.DurableEvent>(),
|
||||||
"session.usage.updated": () => Effect.void,
|
Match.discriminatorsExhaustive("type")({
|
||||||
"session.usage.recorded": () => Effect.void,
|
"session.usage.recorded": () => Effect.void,
|
||||||
"session.agent.selected": (event) => {
|
"session.agent.selected": (event) => {
|
||||||
return adapter.appendMessage(
|
return adapter.appendMessage(
|
||||||
@@ -179,7 +93,18 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
"session.execution.succeeded": () => clearCurrentRetry,
|
"session.execution.succeeded": () => clearCurrentRetry,
|
||||||
"session.execution.failed": () => clearCurrentRetry,
|
"session.execution.failed": () => clearCurrentRetry,
|
||||||
"session.execution.interrupted": () => clearCurrentRetry,
|
"session.execution.interrupted": () => clearCurrentRetry,
|
||||||
"session.instructions.updated": () => Effect.void,
|
"session.instructions.updated": (event) => {
|
||||||
|
if (event.data.text === undefined) return Effect.void
|
||||||
|
return adapter.appendMessage(
|
||||||
|
SessionMessage.System.make({
|
||||||
|
id: SessionMessage.ID.fromEvent(event.id),
|
||||||
|
type: "system",
|
||||||
|
text: event.data.text,
|
||||||
|
metadata: event.metadata,
|
||||||
|
time: { created: event.created },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
"session.synthetic": (event) => {
|
"session.synthetic": (event) => {
|
||||||
return adapter.appendMessage(
|
return adapter.appendMessage(
|
||||||
SessionMessage.Synthetic.make({
|
SessionMessage.Synthetic.make({
|
||||||
@@ -310,12 +235,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
|
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.text.delta": (event) => {
|
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
|
||||||
const match = latestText(draft)
|
|
||||||
if (match) match.text += event.data.delta
|
|
||||||
})
|
|
||||||
},
|
|
||||||
"session.text.ended": (event) => {
|
"session.text.ended": (event) => {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestText(draft)
|
const match = latestText(draft)
|
||||||
@@ -340,7 +259,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.tool.input.delta": () => Effect.void,
|
|
||||||
"session.tool.input.ended": (event) => {
|
"session.tool.input.ended": (event) => {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestTool(draft, event.data.id)
|
const match = latestTool(draft, event.data.id)
|
||||||
@@ -364,14 +282,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.tool.progress": (event) => {
|
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
|
||||||
const match = latestTool(draft, event.data.id)
|
|
||||||
if (match && match.state.status === "running") {
|
|
||||||
match.state.metadata = event.data.metadata
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
// Terminal tool events are self-contained; projection is a direct copy and
|
// Terminal tool events are self-contained; projection is a direct copy and
|
||||||
// never reaches into ephemeral progress history.
|
// never reaches into ephemeral progress history.
|
||||||
"session.tool.success": (event) => {
|
"session.tool.success": (event) => {
|
||||||
@@ -425,12 +335,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
"session.reasoning.delta": (event) => {
|
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
|
||||||
const match = latestReasoning(draft)
|
|
||||||
if (match) match.text += event.data.delta
|
|
||||||
})
|
|
||||||
},
|
|
||||||
"session.reasoning.ended": (event) => {
|
"session.reasoning.ended": (event) => {
|
||||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||||
const match = latestReasoning(draft)
|
const match = latestReasoning(draft)
|
||||||
@@ -464,12 +368,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
"session.compaction.delta": (event) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const current = yield* adapter.getCompaction()
|
|
||||||
if (current?.status !== "running") return
|
|
||||||
yield* adapter.updateCompaction({ ...current, summary: current.summary + event.data.text })
|
|
||||||
}),
|
|
||||||
"session.compaction.ended": (event) => {
|
"session.compaction.ended": (event) => {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const current = yield* adapter.getCompaction()
|
const current = yield* adapter.getCompaction()
|
||||||
@@ -515,8 +413,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
"session.revert.staged": () => Effect.void,
|
"session.revert.staged": () => Effect.void,
|
||||||
"session.revert.cleared": () => Effect.void,
|
"session.revert.cleared": () => Effect.void,
|
||||||
"session.revert.committed": () => Effect.void,
|
"session.revert.committed": () => Effect.void,
|
||||||
})
|
}),
|
||||||
})
|
)
|
||||||
|
return project(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as SessionMessageUpdater from "./message-updater"
|
export * as SessionMessageUpdater from "./message-updater"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export * as SessionModelRequest from "./model-request"
|
|||||||
|
|
||||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||||
import type { SessionHttpHandler, SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
|
|
||||||
import type { Content } from "@opencode-ai/schema/tool"
|
import type { Content } from "@opencode-ai/schema/tool"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
||||||
@@ -230,44 +229,31 @@ export const layer = Layer.effect(
|
|||||||
const options: StreamOptions = {
|
const options: StreamOptions = {
|
||||||
http: (request, handler) =>
|
http: (request, handler) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
let latest = request
|
const before = yield* hooks.trigger("session", "http.request", {
|
||||||
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
|
|
||||||
const middlewares: SessionHttpMiddleware[] = []
|
|
||||||
const web = yield* HttpClientRequest.toWeb(request)
|
|
||||||
yield* hooks.trigger("session", "http", {
|
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
model: resolved.ref,
|
model: resolved.ref,
|
||||||
use: (item) =>
|
request: yield* HttpClientRequest.toWeb(request),
|
||||||
Effect.sync(() => {
|
|
||||||
middlewares.push(item)
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
const send = (input: Request) =>
|
let sent = HttpClientRequest.fromWeb(before.request)
|
||||||
Effect.gen(function* () {
|
if (before.request.body)
|
||||||
let sent = HttpClientRequest.fromWeb(input)
|
sent = HttpClientRequest.bodyUint8Array(
|
||||||
if (input.body)
|
sent,
|
||||||
sent = HttpClientRequest.bodyUint8Array(
|
new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())),
|
||||||
sent,
|
before.request.headers.get("content-type") ?? undefined,
|
||||||
new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())),
|
)
|
||||||
input.headers.get("content-type") ?? undefined,
|
const response = yield* handler(sent)
|
||||||
)
|
const after = yield* hooks.trigger("session", "http.response", {
|
||||||
latest = sent
|
sessionID: session.id,
|
||||||
const response = yield* handler(sent)
|
agent: agent.id,
|
||||||
const body = [204, 205, 304].includes(response.status)
|
model: resolved.ref,
|
||||||
? null
|
request: before.request,
|
||||||
: yield* Stream.toReadableStreamEffect(response.stream)
|
response: new Response(
|
||||||
const output = new Response(body, { status: response.status, headers: response.headers })
|
[204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream),
|
||||||
origins.set(output, sent)
|
{ status: response.status, headers: response.headers },
|
||||||
return output
|
),
|
||||||
})
|
})
|
||||||
const dispatch = middlewares.reduce<SessionHttpHandler>(
|
return HttpClientResponse.fromWeb(sent, after.response)
|
||||||
(next, item) => (input: Request) => item(input, next),
|
|
||||||
send,
|
|
||||||
)
|
|
||||||
const response = yield* dispatch(web)
|
|
||||||
const origin = origins.get(response) ?? latest
|
|
||||||
return HttpClientResponse.fromWeb(origin, response)
|
|
||||||
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
}).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||||
}
|
}
|
||||||
if (promptCacheSnapshots) {
|
if (promptCacheSnapshots) {
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ import {
|
|||||||
User,
|
User,
|
||||||
UserData,
|
UserData,
|
||||||
} from "@opencode-ai/schema/session-pending"
|
} from "@opencode-ai/schema/session-pending"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
|
||||||
import type { Database } from "../database/database"
|
import type { Database } from "../database/database"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { EventTable } from "../event/sql"
|
|
||||||
import { KeyedMutex } from "../effect/keyed-mutex"
|
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
@@ -37,11 +35,7 @@ const decodeUser = Schema.decodeUnknownSync(UserData)
|
|||||||
const encodeUser = Schema.encodeSync(UserData)
|
const encodeUser = Schema.encodeSync(UserData)
|
||||||
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
||||||
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
||||||
const decodeAdmittedEvent = Schema.decodeUnknownOption(SessionEvent.InputAdmitted.data)
|
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||||
const admittedEventType = Bus.versionedType(
|
|
||||||
SessionEvent.InputAdmitted.type,
|
|
||||||
SessionEvent.InputAdmitted.durable.version,
|
|
||||||
)
|
|
||||||
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||||
|
|
||||||
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
|
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
|
||||||
@@ -103,46 +97,35 @@ export const compaction = Effect.fn("SessionPending.compaction")(function* (
|
|||||||
return entry.type === "compaction" ? entry : undefined
|
return entry.type === "compaction" ? entry : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(function* (
|
||||||
* Reconstruct the admitted record for a pending row that was already consumed
|
|
||||||
* by promotion. The projected `session_message` row proves promotion happened;
|
|
||||||
* the durable `session.input.admitted` event retains the exact admitted
|
|
||||||
* message, including delivery.
|
|
||||||
*/
|
|
||||||
const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(function* (
|
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
id: SessionMessage.ID,
|
id: SessionMessage.ID,
|
||||||
|
delivery: Delivery,
|
||||||
) {
|
) {
|
||||||
const message = yield* db
|
const row = yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionMessageTable)
|
.from(SessionMessageTable)
|
||||||
.where(eq(SessionMessageTable.id, id))
|
.where(eq(SessionMessageTable.id, id))
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (message === undefined) return undefined
|
if (row === undefined) return undefined
|
||||||
if (message.session_id !== sessionID || (message.type !== "user" && message.type !== "synthetic"))
|
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
|
||||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||||
const rows = yield* db
|
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||||
.select()
|
const base = { id, sessionID, timeCreated: message.time.created, delivery }
|
||||||
.from(EventTable)
|
if (message.type === "user")
|
||||||
.where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, admittedEventType)))
|
return User.make({
|
||||||
.all()
|
...base,
|
||||||
.pipe(Effect.orDie)
|
type: "user",
|
||||||
for (const row of rows) {
|
data: decodeUser(message),
|
||||||
const decoded = decodeAdmittedEvent(row.data)
|
})
|
||||||
if (decoded._tag !== "Some" || decoded.value.inputID !== id) continue
|
if (message.type === "synthetic")
|
||||||
const base = {
|
return Synthetic.make({
|
||||||
id,
|
...base,
|
||||||
sessionID,
|
type: "synthetic",
|
||||||
timeCreated: DateTime.makeUnsafe(row.created),
|
data: decodeSynthetic(message),
|
||||||
}
|
})
|
||||||
return decoded.value.input.type === "user"
|
|
||||||
? User.make({ ...base, ...decoded.value.input })
|
|
||||||
: Synthetic.make({ ...base, ...decoded.value.input })
|
|
||||||
}
|
|
||||||
// A projected message without an admitted event in this aggregate (for
|
|
||||||
// example fork-copied history) is not a retryable admission.
|
|
||||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -160,7 +143,7 @@ export const admit = Effect.fn("SessionPending.admit")(function* (
|
|||||||
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
const promoted = yield* promotedFromHistory(db, request.sessionID, request.id)
|
const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.input.delivery)
|
||||||
if (promoted !== undefined) return promoted
|
if (promoted !== undefined) return promoted
|
||||||
return yield* bus
|
return yield* bus
|
||||||
.publish(SessionEvent.InputAdmitted, {
|
.publish(SessionEvent.InputAdmitted, {
|
||||||
@@ -426,7 +409,7 @@ const publish = Effect.fn("SessionPending.publish")(function* (
|
|||||||
.pipe(
|
.pipe(
|
||||||
Effect.catchDefect((defect) =>
|
Effect.catchDefect((defect) =>
|
||||||
defect instanceof LifecycleConflict
|
defect instanceof LifecycleConflict
|
||||||
? promotedFromHistory(db, sessionID, entry.id).pipe(
|
? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
|
||||||
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
|
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
|
||||||
)
|
)
|
||||||
: Effect.die(defect),
|
: Effect.die(defect),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * as SessionProjector from "./projector"
|
export * as SessionProjector from "./projector"
|
||||||
|
|
||||||
import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm"
|
import { and, asc, desc, eq, gt, gte, lt, lte, sql } from "drizzle-orm"
|
||||||
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
@@ -21,10 +21,7 @@ import { Money } from "@opencode-ai/schema/money"
|
|||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||||
type MessageEvent = Exclude<
|
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
|
||||||
CurrentDurableEvent,
|
|
||||||
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type | typeof SessionEvent.InstructionsUpdated.Type
|
|
||||||
>
|
|
||||||
|
|
||||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||||
@@ -255,66 +252,22 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (rows.length === 0) break
|
if (rows.length === 0) break
|
||||||
|
|
||||||
const idMap = new Map(rows.map((row) => [row.id, SessionMessage.ID.create()]))
|
|
||||||
yield* db
|
yield* db
|
||||||
.insert(SessionMessageTable)
|
.insert(SessionMessageTable)
|
||||||
.values(
|
.values(
|
||||||
rows.map((row) => {
|
rows.map((row) => ({
|
||||||
const id = idMap.get(row.id)
|
id: SessionMessage.ID.create(),
|
||||||
if (!id) throw new Error(`Fork message ID mapping missing: ${row.id}`)
|
session_id: event.data.sessionID,
|
||||||
return {
|
type: row.type,
|
||||||
id,
|
seq: row.seq,
|
||||||
session_id: event.data.sessionID,
|
time_created: row.time_created,
|
||||||
type: row.type,
|
time_updated: row.time_updated,
|
||||||
seq: row.seq,
|
data: row.data,
|
||||||
time_created: row.time_created,
|
})),
|
||||||
time_updated: row.time_updated,
|
|
||||||
data: row.data,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
const pendingRows = yield* db
|
|
||||||
.select()
|
|
||||||
.from(SessionPendingTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(SessionPendingTable.session_id, event.data.parentID),
|
|
||||||
inArray(
|
|
||||||
SessionPendingTable.id,
|
|
||||||
rows.map((row) => row.id),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (pendingRows.length > 0) {
|
|
||||||
yield* db
|
|
||||||
.insert(SessionPendingTable)
|
|
||||||
.values(
|
|
||||||
pendingRows.flatMap((row) => {
|
|
||||||
const id = idMap.get(row.id)
|
|
||||||
return id && row.type !== "compaction"
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
session_id: event.data.sessionID,
|
|
||||||
type: row.type,
|
|
||||||
data: row.data,
|
|
||||||
delivery: row.delivery,
|
|
||||||
admitted_seq: row.admitted_seq,
|
|
||||||
time_created: row.time_created,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
}
|
|
||||||
|
|
||||||
cursor = rows.at(-1)!.seq
|
cursor = rows.at(-1)!.seq
|
||||||
}
|
}
|
||||||
if (copiedSeq !== undefined) yield* Bus.reserveSequence(db, event.data.sessionID, copiedSeq)
|
if (copiedSeq !== undefined) yield* Bus.reserveSequence(db, event.data.sessionID, copiedSeq)
|
||||||
@@ -682,7 +635,10 @@ const layer = Layer.effectDiscard(
|
|||||||
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
||||||
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
||||||
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
|
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
|
||||||
InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta),
|
Effect.gen(function* () {
|
||||||
|
yield* run(db, event)
|
||||||
|
yield* InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
yield* bus.project(SessionEvent.Synthetic, (event) => run(db, event))
|
yield* bus.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||||
yield* bus.project(SessionEvent.Skill.Activated, (event) => run(db, event))
|
yield* bus.project(SessionEvent.Skill.Activated, (event) => run(db, event))
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
export * as ShellSelect from "./select"
|
export * as ShellSelect from "./select"
|
||||||
|
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { spawn, type ChildProcess } from "child_process"
|
|
||||||
import { readFile } from "fs/promises"
|
import { readFile } from "fs/promises"
|
||||||
import { statSync } from "fs"
|
import { statSync } from "fs"
|
||||||
import { setTimeout } from "node:timers/promises"
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { which } from "../util/which"
|
import { which } from "../util/which"
|
||||||
|
|
||||||
const SIGKILL_TIMEOUT_MS = 200
|
|
||||||
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
|
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
|
||||||
bash: { login: true, posix: true },
|
bash: { login: true, posix: true },
|
||||||
dash: { login: true, posix: true },
|
dash: { login: true, posix: true },
|
||||||
@@ -33,37 +30,6 @@ export const Options = Schema.Struct({
|
|||||||
})
|
})
|
||||||
export type Options = typeof Options.Type
|
export type Options = typeof Options.Type
|
||||||
|
|
||||||
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
|
|
||||||
const pid = proc.pid
|
|
||||||
if (!pid || opts?.exited?.()) return
|
|
||||||
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
|
|
||||||
stdio: "ignore",
|
|
||||||
windowsHide: true,
|
|
||||||
})
|
|
||||||
killer.once("exit", () => resolve())
|
|
||||||
killer.once("error", () => resolve())
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
process.kill(-pid, "SIGTERM")
|
|
||||||
await setTimeout(SIGKILL_TIMEOUT_MS)
|
|
||||||
if (!opts?.exited?.()) {
|
|
||||||
process.kill(-pid, "SIGKILL")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
proc.kill("SIGTERM")
|
|
||||||
await setTimeout(SIGKILL_TIMEOUT_MS)
|
|
||||||
if (!opts?.exited?.()) {
|
|
||||||
proc.kill("SIGKILL")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stat(file: string) {
|
function stat(file: string) {
|
||||||
return statSync(file, { throwIfNoEntry: false }) ?? undefined
|
return statSync(file, { throwIfNoEntry: false }) ?? undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Hash } from "@opencode-ai/util/hash"
|
|||||||
export { ID }
|
export { ID }
|
||||||
|
|
||||||
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
|
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
|
||||||
operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]),
|
operation: Schema.Literals(["capture", "files", "diff", "restore"]),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
cause: Schema.optional(Schema.Defect()),
|
cause: Schema.optional(Schema.Defect()),
|
||||||
}) {}
|
}) {}
|
||||||
@@ -36,10 +36,6 @@ export interface RestoreInput {
|
|||||||
readonly files: ReadonlyMap<RelativePath, ID>
|
readonly files: ReadonlyMap<RelativePath, ID>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PreviewInput extends RestoreInput {
|
|
||||||
readonly context?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
/**
|
/**
|
||||||
* Capture the current Location-scoped filesystem state as a content-addressed
|
* Capture the current Location-scoped filesystem state as a content-addressed
|
||||||
@@ -60,25 +56,11 @@ export interface Interface {
|
|||||||
*/
|
*/
|
||||||
readonly diff: (input: DiffInput) => Effect.Effect<readonly File.Diff[], Error>
|
readonly diff: (input: DiffInput) => Effect.Effect<readonly File.Diff[], Error>
|
||||||
|
|
||||||
/**
|
|
||||||
* Preview the filesystem result of a selective restore without modifying the
|
|
||||||
* worktree. Each project-relative path maps to the tree it would be restored
|
|
||||||
* from.
|
|
||||||
*/
|
|
||||||
readonly preview: (input: PreviewInput) => Effect.Effect<readonly File.Diff[], Error>
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore selected project-relative paths from their associated trees. A path
|
* Restore selected project-relative paths from their associated trees. A path
|
||||||
* absent from its selected tree is removed; paths outside the map are untouched.
|
* absent from its selected tree is removed; paths outside the map are untouched.
|
||||||
*/
|
*/
|
||||||
readonly restore: (input: RestoreInput) => Effect.Effect<void, Error>
|
readonly restore: (input: RestoreInput) => Effect.Effect<void, Error>
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the snapshot index with a captured tree and check out all its entries.
|
|
||||||
* Files absent from the tree remain untouched. Prefer selective `restore` when
|
|
||||||
* only known paths should change.
|
|
||||||
*/
|
|
||||||
readonly checkout: (snapshot: ID) => Effect.Effect<void, Error>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Snapshot") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Snapshot") {}
|
||||||
@@ -176,59 +158,26 @@ const layer = Layer.effect(
|
|||||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||||
})
|
})
|
||||||
|
|
||||||
const plan = Effect.fnUntraced(function* (
|
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
|
||||||
operation: "preview" | "restore",
|
|
||||||
worktree: AbsolutePath,
|
|
||||||
input: RestoreInput,
|
|
||||||
) {
|
|
||||||
const files = new Map<RelativePath, Git.TreeID>()
|
const files = new Map<RelativePath, Git.TreeID>()
|
||||||
for (const [file, snapshot] of input.files) {
|
for (const [file, snapshot] of input.files) {
|
||||||
const absolute = path.resolve(worktree, file)
|
const absolute = path.resolve(worktree, file)
|
||||||
if (!FSUtil.contains(worktree, absolute))
|
if (!FSUtil.contains(worktree, absolute))
|
||||||
return yield* new Error({ operation, message: `Path escapes the project: ${file}` })
|
return yield* new Error({ operation: "restore", message: `Path escapes the project: ${file}` })
|
||||||
files.set(file, Git.TreeID.make(snapshot))
|
files.set(file, Git.TreeID.make(snapshot))
|
||||||
}
|
}
|
||||||
return files
|
return files
|
||||||
})
|
})
|
||||||
|
|
||||||
const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) {
|
|
||||||
if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" })
|
|
||||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
|
||||||
const files = yield* plan("preview", repo.worktree, input)
|
|
||||||
const current = yield* git.tree
|
|
||||||
.capture({
|
|
||||||
repository: repo.snapshotRepository,
|
|
||||||
scopes: Array.from(files.keys()),
|
|
||||||
ignores: repo.source,
|
|
||||||
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
|
||||||
})
|
|
||||||
.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
|
||||||
return yield* git.tree
|
|
||||||
.preview({
|
|
||||||
repository: repo.snapshotRepository,
|
|
||||||
current,
|
|
||||||
files,
|
|
||||||
context: input.context,
|
|
||||||
})
|
|
||||||
.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
|
||||||
})
|
|
||||||
|
|
||||||
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
|
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
|
||||||
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
|
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
|
||||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||||
yield* git.tree
|
yield* git.tree
|
||||||
.restore({ repository: repo.snapshotRepository, files: yield* plan("restore", repo.worktree, input) })
|
.restore({ repository: repo.snapshotRepository, files: yield* plan(repo.worktree, input) })
|
||||||
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||||
})
|
})
|
||||||
|
|
||||||
const checkout = Effect.fn("Snapshot.checkout")(function* (snapshot: ID) {
|
return Service.of({ capture, files, diff, restore })
|
||||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
|
||||||
yield* git.tree
|
|
||||||
.checkout({ repository: repo.snapshotRepository, tree: Git.TreeID.make(snapshot) })
|
|
||||||
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ capture, files, diff, preview, restore, checkout })
|
|
||||||
}).pipe(Effect.withSpan("Snapshot.boot")),
|
}).pipe(Effect.withSpan("Snapshot.boot")),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -244,9 +193,7 @@ export const noopLayer = Layer.succeed(
|
|||||||
capture: () => Effect.succeed(undefined),
|
capture: () => Effect.succeed(undefined),
|
||||||
files: () => Effect.succeed([]),
|
files: () => Effect.succeed([]),
|
||||||
diff: () => Effect.succeed([]),
|
diff: () => Effect.succeed([]),
|
||||||
preview: () => Effect.succeed([]),
|
|
||||||
restore: () => Effect.void,
|
restore: () => Effect.void,
|
||||||
checkout: () => Effect.void,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1298,24 +1298,4 @@ describe("Bus", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("sequences returns the latest committed seq per aggregate and omits unknown aggregates", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const first = Session.ID.create()
|
|
||||||
const second = Session.ID.create()
|
|
||||||
yield* bus.publish(DurableMessage, durableData(first, "zero"))
|
|
||||||
yield* bus.publish(DurableMessage, durableData(first, "one"))
|
|
||||||
yield* bus.publish(DurableMessage, durableData(second, "zero"))
|
|
||||||
|
|
||||||
const sequences = yield* bus.sequences([first, second, Session.ID.create()])
|
|
||||||
|
|
||||||
expect(sequences).toEqual(
|
|
||||||
new Map([
|
|
||||||
[first, Event.Seq.make(1)],
|
|
||||||
[second, Event.Seq.make(0)],
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
expect(yield* bus.sequences([])).toEqual(new Map())
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ describe("node build", () => {
|
|||||||
list: () => Effect.succeed([]),
|
list: () => Effect.succeed([]),
|
||||||
directories: () => Effect.succeed([]),
|
directories: () => Effect.succeed([]),
|
||||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||||
commit: () => Effect.void,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -89,68 +89,6 @@ describe("FileMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("rejects create when a prospective target appears after resolution", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const targetPath = path.join(directory, "appeared.txt")
|
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "appeared.txt" })
|
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "winner"))
|
|
||||||
|
|
||||||
expect(
|
|
||||||
yield* (yield* FileMutation.Service).create({ target, content: "replacement" }).pipe(Effect.flip),
|
|
||||||
).toMatchObject({
|
|
||||||
_tag: "FileMutation.TargetExistsError",
|
|
||||||
})
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("winner")
|
|
||||||
}).pipe(provide(directory)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("creates when an existing target disappears after resolution", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const targetPath = path.join(directory, "removed.txt")
|
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
|
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "removed.txt" })
|
|
||||||
yield* Effect.promise(() => fs.rm(targetPath))
|
|
||||||
|
|
||||||
expect(yield* (yield* FileMutation.Service).create({ target, content: "after" })).toEqual({
|
|
||||||
operation: "write",
|
|
||||||
target: target.canonical,
|
|
||||||
resource: "removed.txt",
|
|
||||||
existed: false,
|
|
||||||
})
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
|
|
||||||
}).pipe(provide(directory)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("removes an existing internal file", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const targetPath = path.join(directory, "remove.txt")
|
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "remove"))
|
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "remove.txt" })
|
|
||||||
const result = yield* (yield* FileMutation.Service).remove({ target })
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
operation: "remove",
|
|
||||||
target: target.canonical,
|
|
||||||
resource: "remove.txt",
|
|
||||||
existed: true,
|
|
||||||
})
|
|
||||||
expect(
|
|
||||||
yield* Effect.promise(() =>
|
|
||||||
fs.stat(targetPath).then(
|
|
||||||
() => true,
|
|
||||||
() => false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).toBe(false)
|
|
||||||
}).pipe(provide(directory)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("writes an explicitly resolved external target", () =>
|
it.live("writes an explicitly resolved external target", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
withTmp((outside) =>
|
withTmp((outside) =>
|
||||||
@@ -171,49 +109,6 @@ describe("FileMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("removes an explicitly resolved external target", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
withTmp((outside) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const targetPath = path.join(outside, "external.txt")
|
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "external"))
|
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
|
||||||
const result = yield* (yield* FileMutation.Service).remove({ target })
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
operation: "remove",
|
|
||||||
target: target.canonical,
|
|
||||||
resource: target.resource,
|
|
||||||
existed: true,
|
|
||||||
})
|
|
||||||
expect(
|
|
||||||
yield* Effect.promise(() =>
|
|
||||||
fs.stat(targetPath).then(
|
|
||||||
() => true,
|
|
||||||
() => false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).toBe(false)
|
|
||||||
}).pipe(provide(directory)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("reports a missing target as not removed without checking existence first", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "missing.txt" })
|
|
||||||
|
|
||||||
expect(yield* (yield* FileMutation.Service).remove({ target })).toEqual({
|
|
||||||
operation: "remove",
|
|
||||||
target: target.canonical,
|
|
||||||
resource: "missing.txt",
|
|
||||||
existed: false,
|
|
||||||
})
|
|
||||||
}).pipe(provide(directory)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("serializes concurrent writes to the same canonical target", () =>
|
it.live("serializes concurrent writes to the same canonical target", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -257,63 +152,6 @@ describe("FileMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("allows only one concurrent conditional write based on the same bytes", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const targetPath = path.join(directory, "shared.txt")
|
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
|
|
||||||
const firstStarted = yield* Deferred.make<void>()
|
|
||||||
const releaseFirst = yield* Deferred.make<void>()
|
|
||||||
let writes = 0
|
|
||||||
const filesystem = instrumentWrites((write) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
writes++
|
|
||||||
if (writes === 1) {
|
|
||||||
yield* Deferred.succeed(firstStarted, undefined)
|
|
||||||
yield* Deferred.await(releaseFirst)
|
|
||||||
}
|
|
||||||
yield* write
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* Effect.gen(function* () {
|
|
||||||
const mutation = yield* LocationMutation.Service
|
|
||||||
const files = yield* FileMutation.Service
|
|
||||||
const target = yield* mutation.resolve({ path: "shared.txt" })
|
|
||||||
const expected = new TextEncoder().encode("initial")
|
|
||||||
const first = yield* files.writeIfUnchanged({ target, expected, content: "first" }).pipe(Effect.forkChild)
|
|
||||||
yield* Deferred.await(firstStarted)
|
|
||||||
const second = yield* files
|
|
||||||
.writeIfUnchanged({ target, expected, content: "second" })
|
|
||||||
.pipe(Effect.flip, Effect.forkChild)
|
|
||||||
|
|
||||||
yield* Deferred.succeed(releaseFirst, undefined)
|
|
||||||
yield* Fiber.join(first)
|
|
||||||
expect(yield* Fiber.join(second)).toMatchObject({ _tag: "FileMutation.StaleContentError" })
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("first")
|
|
||||||
expect(writes).toBe(1)
|
|
||||||
}).pipe(provide(directory, filesystem))
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("rejects a conditional write when target content is already stale", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const targetPath = path.join(directory, "stale.txt")
|
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "current"))
|
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "stale.txt" })
|
|
||||||
|
|
||||||
expect(
|
|
||||||
yield* (yield* FileMutation.Service)
|
|
||||||
.writeIfUnchanged({ target, expected: new TextEncoder().encode("older"), content: "replacement" })
|
|
||||||
.pipe(Effect.flip),
|
|
||||||
).toMatchObject({ _tag: "FileMutation.StaleContentError", path: target.canonical })
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("current")
|
|
||||||
}).pipe(provide(directory)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("allows distinct canonical targets to proceed independently", () =>
|
it.live("allows distinct canonical targets to proceed independently", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -1,44 +1,59 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import fs from "fs/promises"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
import { Protected } from "@opencode-ai/core/filesystem/protected"
|
||||||
|
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||||
import { tmpdir } from "../fixture/tmpdir"
|
import { location } from "../fixture/location"
|
||||||
import { testEffect } from "../lib/effect"
|
|
||||||
|
|
||||||
const it = testEffect(LayerNode.compile(Ripgrep.node))
|
describe("FileSystemSearch", () => {
|
||||||
|
test("bounds a home scan even when home is detected as a repository", async () => {
|
||||||
|
let observed: Ripgrep.FindInput | undefined
|
||||||
|
const home = AbsolutePath.make(os.homedir())
|
||||||
|
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||||
|
[
|
||||||
|
Location.node,
|
||||||
|
Layer.succeed(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of(
|
||||||
|
location({ directory: home }, { vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Ripgrep.node,
|
||||||
|
Layer.succeed(
|
||||||
|
Ripgrep.Service,
|
||||||
|
Ripgrep.Service.of({
|
||||||
|
find: (input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
observed = input
|
||||||
|
if (input.onEntry)
|
||||||
|
yield* input.onEntry(FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" }))
|
||||||
|
return []
|
||||||
|
}),
|
||||||
|
glob: () => Effect.succeed([]),
|
||||||
|
grep: () => Effect.succeed([]),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
const withTmp = <A, E, R>(f: (directory: AbsolutePath) => Effect.Effect<A, E, R>) =>
|
await Effect.runPromise(
|
||||||
Effect.acquireRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
).pipe(Effect.flatMap((tmp) => f(AbsolutePath.make(tmp.path))))
|
|
||||||
|
|
||||||
describe("Ripgrep", () => {
|
|
||||||
it.live("globs files as an array", () =>
|
|
||||||
withTmp((cwd) =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
|
const search = yield* FileSystemSearch.Service
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
|
yield* Effect.sleep("10 millis")
|
||||||
const result = yield* (yield* Ripgrep.Service).glob({ cwd, pattern: "**/*.ts", limit: 10 })
|
expect(observed?.limit).toBe(100_000)
|
||||||
expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")])
|
expect(observed?.exclude).toEqual([...Protected.names()].map((name) => `${name}/**`))
|
||||||
}),
|
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
|
||||||
),
|
RelativePath.make(`src${path.sep}`),
|
||||||
)
|
)
|
||||||
|
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||||
it.live("greps files with include filtering", () =>
|
)
|
||||||
withTmp((cwd) =>
|
})
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* Effect.promise(() => fs.mkdir(path.join(cwd, "src")))
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "match.ts"), "needle\n"))
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(cwd, "src", "skip.txt"), "needle\n"))
|
|
||||||
const result = yield* (yield* Ripgrep.Service).grep({ cwd, pattern: "needle", include: "*.ts", limit: 10 })
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts"))
|
|
||||||
expect(result[0]?.submatches[0]?.text).toBe("needle")
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -56,52 +56,22 @@ function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Formatter", () => {
|
describe("Formatter", () => {
|
||||||
it.live("status() returns empty list when no formatters are configured", () =>
|
it.live("does not run formatters marked as disabled in config", () =>
|
||||||
withTemp((directory) =>
|
withTemp((directory) =>
|
||||||
Formatter.Service.use((formatter) => formatter.status()).pipe(Effect.provide(formatterLayer(directory))),
|
Effect.gen(function* () {
|
||||||
),
|
const file = path.join(directory, "test.disabled")
|
||||||
)
|
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
|
||||||
|
}).pipe(
|
||||||
it.live("status() returns built-in formatters when formatter is true", () =>
|
Effect.provide(
|
||||||
withTemp((directory) =>
|
formatterLayer(directory, {
|
||||||
Formatter.Service.use((formatter) =>
|
disabled: {
|
||||||
Effect.gen(function* () {
|
disabled: true,
|
||||||
const statuses = yield* formatter.status()
|
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
extensions: [".disabled"],
|
||||||
expect(gofmt).toBeDefined()
|
},
|
||||||
expect(gofmt?.extensions).toContain(".go")
|
}),
|
||||||
}),
|
),
|
||||||
).pipe(Effect.provide(formatterLayer(directory, true))),
|
),
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("status() keeps built-in formatters when config object is provided", () =>
|
|
||||||
withTemp((directory) =>
|
|
||||||
Formatter.Service.use((formatter) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const statuses = yield* formatter.status()
|
|
||||||
expect(statuses.find((item) => item.name === "gofmt")?.extensions).toContain(".go")
|
|
||||||
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
|
|
||||||
}),
|
|
||||||
).pipe(Effect.provide(formatterLayer(directory, { gofmt: {} }))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("status() excludes formatters marked as disabled in config", () =>
|
|
||||||
withTemp((directory) =>
|
|
||||||
Formatter.Service.use((formatter) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const statuses = yield* formatter.status()
|
|
||||||
expect(statuses.find((item) => item.name === "gofmt")).toBeUndefined()
|
|
||||||
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
|
|
||||||
}),
|
|
||||||
).pipe(Effect.provide(formatterLayer(directory, { gofmt: { disabled: true } }))),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("service initializes without error", () =>
|
|
||||||
withTemp((directory) =>
|
|
||||||
Formatter.Service.use((formatter) => formatter.init()).pipe(Effect.provide(formatterLayer(directory))),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -115,22 +85,29 @@ describe("Formatter", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("status() initializes formatter state per directory", () =>
|
it.live("loads formatter state per directory", () =>
|
||||||
Effect.acquireUseRelease(
|
withTemp((off) =>
|
||||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
withTemp((on) =>
|
||||||
([off, on]) =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const disabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
|
const offFile = path.join(off, "test.isolated")
|
||||||
Effect.provide(formatterLayer(off.path, false)),
|
const onFile = path.join(on, "test.isolated")
|
||||||
|
const disabled = yield* Formatter.Service.use((formatter) => formatter.file(offFile)).pipe(
|
||||||
|
Effect.provide(formatterLayer(off, false)),
|
||||||
)
|
)
|
||||||
const enabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
|
const enabled = yield* Formatter.Service.use((formatter) => formatter.file(onFile)).pipe(
|
||||||
Effect.provide(formatterLayer(on.path, true)),
|
Effect.provide(
|
||||||
|
formatterLayer(on, {
|
||||||
|
isolated: {
|
||||||
|
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||||
|
extensions: [".isolated"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
expect(disabled).toEqual([])
|
expect(disabled).toBe(false)
|
||||||
expect(enabled.find((item) => item.name === "gofmt")).toBeDefined()
|
expect(enabled).toBe(true)
|
||||||
}),
|
}),
|
||||||
(directories) =>
|
),
|
||||||
Effect.promise(() => Promise.all(directories.map((tmp) => tmp[Symbol.asyncDispose]())).then(() => undefined)),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -185,9 +185,6 @@ describe("Git trees", () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||||
const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
|
|
||||||
expect(preview).toHaveLength(1)
|
|
||||||
expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
|
|
||||||
yield* git.tree.restore({ repository, files })
|
yield* git.tree.restore({ repository, files })
|
||||||
expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
|
expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
|
||||||
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
|
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
|||||||
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
|
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||||
import { InstructionBlobTable, InstructionStateTable, SessionTable } from "@opencode-ai/core/session/sql"
|
import { InstructionBlobTable, InstructionStateTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node])))
|
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node])))
|
||||||
@@ -105,6 +105,7 @@ describe("InstructionState", () => {
|
|||||||
expect(observation).toEqual({
|
expect(observation).toEqual({
|
||||||
sessionID,
|
sessionID,
|
||||||
initial: true,
|
initial: true,
|
||||||
|
previous: {},
|
||||||
current: {
|
current: {
|
||||||
"test/first": Instructions.hash("first"),
|
"test/first": Instructions.hash("first"),
|
||||||
"test/second": Instructions.hash("second"),
|
"test/second": Instructions.hash("second"),
|
||||||
@@ -156,7 +157,7 @@ describe("InstructionState", () => {
|
|||||||
|
|
||||||
const initial = yield* InstructionState.observe(db, instructions, sessionID)
|
const initial = yield* InstructionState.observe(db, instructions, sessionID)
|
||||||
expect(reads).toBe(2)
|
expect(reads).toBe(2)
|
||||||
yield* InstructionState.commit(db, events, initial)
|
yield* InstructionState.commit(db, events, instructions, initial)
|
||||||
expect(reads).toBe(2)
|
expect(reads).toBe(2)
|
||||||
|
|
||||||
current = "changed"
|
current = "changed"
|
||||||
@@ -166,6 +167,10 @@ describe("InstructionState", () => {
|
|||||||
expect(changed).toMatchObject({
|
expect(changed).toMatchObject({
|
||||||
sessionID,
|
sessionID,
|
||||||
initial: false,
|
initial: false,
|
||||||
|
previous: {
|
||||||
|
"test/current": Instructions.hash("initial"),
|
||||||
|
"test/retired": Instructions.hash("retired"),
|
||||||
|
},
|
||||||
current: { "test/current": Instructions.hash("changed") },
|
current: { "test/current": Instructions.hash("changed") },
|
||||||
delta: {
|
delta: {
|
||||||
"test/current": Instructions.hash("changed"),
|
"test/current": Instructions.hash("changed"),
|
||||||
@@ -173,7 +178,7 @@ describe("InstructionState", () => {
|
|||||||
},
|
},
|
||||||
blobs: { [Instructions.hash("changed")]: "changed" },
|
blobs: { [Instructions.hash("changed")]: "changed" },
|
||||||
})
|
})
|
||||||
yield* InstructionState.commit(db, events, changed)
|
yield* InstructionState.commit(db, events, instructions, changed)
|
||||||
expect(reads).toBe(4)
|
expect(reads).toBe(4)
|
||||||
yield* unsubscribe
|
yield* unsubscribe
|
||||||
|
|
||||||
@@ -190,6 +195,11 @@ describe("InstructionState", () => {
|
|||||||
"test/retired": "removed",
|
"test/retired": "removed",
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
// The chronological update text is frozen into the event; the baseline has none.
|
||||||
|
expect((yield* instructionEvents(db, sessionID)).map((event) => event.data.text)).toEqual([
|
||||||
|
undefined,
|
||||||
|
"changed\n\nRemoved retired",
|
||||||
|
])
|
||||||
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toMatchObject({
|
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toMatchObject({
|
||||||
initial_values: {
|
initial_values: {
|
||||||
"test/current": Instructions.hash("initial"),
|
"test/current": Instructions.hash("initial"),
|
||||||
@@ -222,18 +232,19 @@ describe("InstructionState", () => {
|
|||||||
expect(observation).toEqual({
|
expect(observation).toEqual({
|
||||||
sessionID,
|
sessionID,
|
||||||
initial: false,
|
initial: false,
|
||||||
|
previous: { "test/context": Instructions.hash("unchanged") },
|
||||||
current: { "test/context": Instructions.hash("unchanged") },
|
current: { "test/context": Instructions.hash("unchanged") },
|
||||||
delta: {},
|
delta: {},
|
||||||
blobs: {},
|
blobs: {},
|
||||||
})
|
})
|
||||||
yield* InstructionState.commit(db, events, observation)
|
yield* InstructionState.commit(db, events, instructions, observation)
|
||||||
|
|
||||||
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
||||||
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("assembles a fresh private update without repairing a missing cache", () =>
|
it.effect("treats a missing state row as a fresh baseline without repairing it", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const sessionID = SessionSchema.ID.make("ses_instruction_generate")
|
const sessionID = SessionSchema.ID.make("ses_instruction_generate")
|
||||||
const { db, events } = yield* setup(sessionID)
|
const { db, events } = yield* setup(sessionID)
|
||||||
@@ -254,7 +265,7 @@ describe("InstructionState", () => {
|
|||||||
|
|
||||||
const assembled = yield* preview(db, sessionID, instructions)
|
const assembled = yield* preview(db, sessionID, instructions)
|
||||||
|
|
||||||
expect(assembled).toEqual({ initial: "Initial context", updates: [], update: "Changed context" })
|
expect(assembled).toEqual({ initial: "Changed context", update: "" })
|
||||||
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
||||||
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
||||||
expect(
|
expect(
|
||||||
@@ -268,7 +279,7 @@ describe("InstructionState", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("reads through a stale cache without repairing it", () =>
|
it.effect("trusts the projected state without consulting durable events", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const sessionID = SessionSchema.ID.make("ses_instruction_generate_stale")
|
const sessionID = SessionSchema.ID.make("ses_instruction_generate_stale")
|
||||||
const { db, events } = yield* setup(sessionID)
|
const { db, events } = yield* setup(sessionID)
|
||||||
@@ -280,6 +291,7 @@ describe("InstructionState", () => {
|
|||||||
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||||
value = "Committed update"
|
value = "Committed update"
|
||||||
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||||
|
// Tamper with the projected state; the authoritative row wins over event history.
|
||||||
yield* db
|
yield* db
|
||||||
.update(InstructionStateTable)
|
.update(InstructionStateTable)
|
||||||
.set({ through_seq: 0, current_values: { "test/context": Instructions.hash("Initial context") } })
|
.set({ through_seq: 0, current_values: { "test/context": Instructions.hash("Initial context") } })
|
||||||
@@ -294,7 +306,6 @@ describe("InstructionState", () => {
|
|||||||
const assembled = yield* preview(db, sessionID, instructions)
|
const assembled = yield* preview(db, sessionID, instructions)
|
||||||
|
|
||||||
expect(assembled.initial).toBe("Initial context")
|
expect(assembled.initial).toBe("Initial context")
|
||||||
expect(assembled.updates.map((entry) => entry.message.text)).toEqual(["Committed update"])
|
|
||||||
expect(assembled.update).toBe("Private update")
|
expect(assembled.update).toBe("Private update")
|
||||||
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
||||||
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
||||||
@@ -302,6 +313,41 @@ describe("InstructionState", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("persists chronological updates as system messages", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sessionID = SessionSchema.ID.make("ses_instruction_messages")
|
||||||
|
const { db, events } = yield* setup(sessionID)
|
||||||
|
let value = "Initial context"
|
||||||
|
const instructions = source(
|
||||||
|
"test/context",
|
||||||
|
Effect.sync(() => value),
|
||||||
|
)
|
||||||
|
const messages = () =>
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(SessionMessageTable)
|
||||||
|
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "system")))
|
||||||
|
.orderBy(asc(SessionMessageTable.seq))
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
// The initial baseline is not chronological history and produces no message.
|
||||||
|
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||||
|
expect(yield* messages()).toEqual([])
|
||||||
|
|
||||||
|
value = "Changed context"
|
||||||
|
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||||
|
const rows = yield* messages()
|
||||||
|
expect(rows).toHaveLength(1)
|
||||||
|
expect(rows[0]?.data).toMatchObject({ text: "Changed context" })
|
||||||
|
expect(rows.map((row) => row.seq)).toEqual([(yield* instructionEvents(db, sessionID)).at(-1)!.seq])
|
||||||
|
|
||||||
|
// A no-op observation adds nothing.
|
||||||
|
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||||
|
expect(yield* messages()).toHaveLength(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("assembles initial instructions without persisting a baseline", () =>
|
it.effect("assembles initial instructions without persisting a baseline", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const sessionID = SessionSchema.ID.make("ses_instruction_generate_initial")
|
const sessionID = SessionSchema.ID.make("ses_instruction_generate_initial")
|
||||||
@@ -310,7 +356,6 @@ describe("InstructionState", () => {
|
|||||||
|
|
||||||
expect(yield* preview(db, sessionID, instructions)).toEqual({
|
expect(yield* preview(db, sessionID, instructions)).toEqual({
|
||||||
initial: "Initial context",
|
initial: "Initial context",
|
||||||
updates: [],
|
|
||||||
update: "",
|
update: "",
|
||||||
})
|
})
|
||||||
expect(yield* instructionEvents(db, sessionID)).toEqual([])
|
expect(yield* instructionEvents(db, sessionID)).toEqual([])
|
||||||
@@ -336,7 +381,6 @@ describe("InstructionState", () => {
|
|||||||
|
|
||||||
expect(yield* preview(db, sessionID, instructions)).toEqual({
|
expect(yield* preview(db, sessionID, instructions)).toEqual({
|
||||||
initial: "Committed context",
|
initial: "Committed context",
|
||||||
updates: [],
|
|
||||||
update: "",
|
update: "",
|
||||||
})
|
})
|
||||||
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
||||||
@@ -388,7 +432,7 @@ describe("InstructionState", () => {
|
|||||||
for (const next of ["initial", "changed", "changed", Instructions.removed] as const) {
|
for (const next of ["initial", "changed", "changed", Instructions.removed] as const) {
|
||||||
value = next
|
value = next
|
||||||
yield* InstructionState.observe(db, observedInstructions, observedSessionID).pipe(
|
yield* InstructionState.observe(db, observedInstructions, observedSessionID).pipe(
|
||||||
Effect.flatMap((observation) => InstructionState.commit(db, events, observation)),
|
Effect.flatMap((observation) => InstructionState.commit(db, events, observedInstructions, observation)),
|
||||||
)
|
)
|
||||||
yield* InstructionState.prepare(db, events, preparedInstructions, preparedSessionID)
|
yield* InstructionState.prepare(db, events, preparedInstructions, preparedSessionID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const projectLayer = Layer.succeed(
|
|||||||
canonical: AbsolutePath.make("/main/repo"),
|
canonical: AbsolutePath.make("/main/repo"),
|
||||||
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
|
vcs: { type: "git", store: AbsolutePath.make("/repo/.git") },
|
||||||
}),
|
}),
|
||||||
commit: () => Effect.void,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
|
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Message, SystemPart } from "@opencode-ai/ai"
|
import { Message, SystemPart } from "@opencode-ai/ai"
|
||||||
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
import { DateTime, Effect, Schema } from "effect"
|
||||||
import { Agent } from "@opencode-ai/core/agent"
|
import { Agent } from "@opencode-ai/core/agent"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Model } from "@opencode-ai/core/model"
|
import { Model } from "@opencode-ai/core/model"
|
||||||
@@ -15,7 +15,7 @@ import { SessionPending } from "@opencode-ai/core/session/pending"
|
|||||||
import { Tool } from "@opencode-ai/core/tool"
|
import { Tool } from "@opencode-ai/core/tool"
|
||||||
import { Provider } from "@opencode-ai/core/provider"
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||||
import type { SessionHooks, SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { PluginTestLayer } from "./fixture"
|
import { PluginTestLayer } from "./fixture"
|
||||||
import { host as testHost } from "./host"
|
import { host as testHost } from "./host"
|
||||||
@@ -223,102 +223,45 @@ describe("fromPromise", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("adapts promise session HTTP hooks", () =>
|
it.effect("adapts promise session HTTP request and response hooks", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const hooks = yield* PluginHooks.Service
|
const hooks = yield* PluginHooks.Service
|
||||||
const host = yield* PluginHost.make(plugin)
|
const host = yield* PluginHost.make(plugin)
|
||||||
const bodies: string[] = []
|
|
||||||
yield* PluginPromise.fromPromise(
|
yield* PluginPromise.fromPromise(
|
||||||
define({
|
define({
|
||||||
id: "promise-session-http",
|
id: "promise-session-http",
|
||||||
setup: async (ctx) => {
|
setup: async (ctx) => {
|
||||||
await ctx.session.hook("http", (event) => {
|
await ctx.session.hook("http.request", (event) => {
|
||||||
event.use(async (request, next) => {
|
event.request = new Request("https://provider.test/changed", event.request)
|
||||||
request.headers.set("x-hook", "promise")
|
event.request.headers.set("x-hook", "promise")
|
||||||
await next(request)
|
|
||||||
const response = await next(request)
|
|
||||||
return new Response(`${await response.text()}-response`)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
await ctx.session.hook("http", (event) => {
|
await ctx.session.hook("http.response", async (event) => {
|
||||||
event.use(async (request, next) => {
|
event.response = new Response(`${await event.response.text()}-response`, {
|
||||||
const response = await next(request)
|
status: event.response.status,
|
||||||
return new Response(`${await response.text()}-outer`)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).effect(host)
|
).effect(host)
|
||||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
const context = {
|
||||||
const event: PluginHooks.Domains["session"]["http"] = {
|
|
||||||
sessionID: Session.ID.make("ses_promise_session_http"),
|
sessionID: Session.ID.make("ses_promise_session_http"),
|
||||||
agent: Agent.ID.make("build"),
|
agent: Agent.ID.make("build"),
|
||||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||||
use: (item) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
middlewares.push(item)
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* hooks.trigger("session", "http", event)
|
const request = yield* hooks.trigger("session", "http.request", {
|
||||||
const request = middlewares.reduce<SessionHttpHandler>(
|
...context,
|
||||||
(next, item) => (input: Request) => item(input, next),
|
request: new Request("https://provider.test", { method: "POST", body: "payload" }),
|
||||||
(input: Request) =>
|
})
|
||||||
Effect.promise(() => input.text()).pipe(
|
const response = yield* hooks.trigger("session", "http.response", {
|
||||||
Effect.tap((body) => Effect.sync(() => bodies.push(body))),
|
...context,
|
||||||
Effect.as(new Response(input.headers.get("x-hook") ?? "missing")),
|
request: request.request,
|
||||||
),
|
response: new Response(request.request.headers.get("x-hook") ?? "missing"),
|
||||||
)
|
})
|
||||||
const response = yield* request(new Request("https://provider.test", { method: "POST", body: "payload" }))
|
|
||||||
|
|
||||||
expect(bodies).toEqual(["payload", "payload"])
|
expect(request.request.url).toBe("https://provider.test/changed")
|
||||||
expect(yield* Effect.promise(() => response.text())).toBe("promise-response-outer")
|
expect(yield* Effect.promise(() => response.response.text())).toBe("promise-response")
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const plugin = yield* Plugin.Service
|
|
||||||
const hooks = yield* PluginHooks.Service
|
|
||||||
const host = yield* PluginHost.make(plugin)
|
|
||||||
yield* PluginPromise.fromPromise(
|
|
||||||
define({
|
|
||||||
id: "promise-session-http-interrupt",
|
|
||||||
setup: async (ctx) => {
|
|
||||||
await ctx.session.hook("http", (event) => {
|
|
||||||
event.use((request, next) => next(request))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).effect(host)
|
|
||||||
const started = yield* Deferred.make<void>()
|
|
||||||
const interrupted = yield* Deferred.make<void>()
|
|
||||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
|
||||||
const event: PluginHooks.Domains["session"]["http"] = {
|
|
||||||
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
|
|
||||||
agent: Agent.ID.make("build"),
|
|
||||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
|
||||||
use: (item) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
middlewares.push(item)
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* hooks.trigger("session", "http", event)
|
|
||||||
const request = middlewares.reduce<SessionHttpHandler>(
|
|
||||||
(next, item) => (input: Request) => item(input, next),
|
|
||||||
() =>
|
|
||||||
Deferred.succeed(started, undefined).pipe(
|
|
||||||
Effect.andThen(Effect.never),
|
|
||||||
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const fiber = yield* request(new Request("https://provider.test")).pipe(Effect.forkChild)
|
|
||||||
yield* Deferred.await(started)
|
|
||||||
yield* Fiber.interrupt(fiber)
|
|
||||||
|
|
||||||
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
|
|||||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||||
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
|
||||||
import { Provider } from "@opencode-ai/core/provider"
|
import { Provider } from "@opencode-ai/core/provider"
|
||||||
import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session"
|
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { PluginTestLayer } from "./fixture"
|
import { PluginTestLayer } from "./fixture"
|
||||||
|
|
||||||
@@ -31,26 +30,13 @@ function required<T>(value: T | undefined): T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
||||||
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[0][] = []
|
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||||
yield* (yield* PluginHooks.Service).trigger("session", "http", {
|
|
||||||
sessionID: Session.ID.make("ses_test"),
|
sessionID: Session.ID.make("ses_test"),
|
||||||
agent: Agent.ID.make("build"),
|
agent: Agent.ID.make("build"),
|
||||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||||
use: (item) =>
|
request: new Request(url, { method: "POST", body: "{}" }),
|
||||||
Effect.sync(() => {
|
|
||||||
middlewares.push(item)
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
const request = middlewares.reduce<SessionHttpHandler>(
|
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
|
||||||
(next, item) => (input: Request) => item(input, next),
|
|
||||||
(input: Request) => {
|
|
||||||
const headers = new Headers(input.headers)
|
|
||||||
headers.set("x-seen-url", input.url)
|
|
||||||
return Effect.succeed(new Response(null, { headers }))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const response = yield* request(new Request(url, { method: "POST", body: "{}" }))
|
|
||||||
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("OpenAIPlugin", () => {
|
describe("OpenAIPlugin", () => {
|
||||||
|
|||||||
@@ -101,13 +101,10 @@ describe("RepositoryCache", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("returns typed validation and clone failures", () =>
|
it.live("returns typed branch validation and clone failures", () =>
|
||||||
withRemote((fixture) =>
|
withRemote((fixture) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const cache = yield* RepositoryCache.Service
|
const cache = yield* RepositoryCache.Service
|
||||||
const invalidRepository = yield* Effect.flip(RepositoryCache.parseRemote("not-a-repo"))
|
|
||||||
expect(invalidRepository).toBeInstanceOf(RepositoryCache.InvalidRepositoryError)
|
|
||||||
|
|
||||||
const invalidBranch = yield* Effect.flip(cache.ensure({ reference: fixture.reference, branch: "../unsafe" }))
|
const invalidBranch = yield* Effect.flip(cache.ensure({ reference: fixture.reference, branch: "../unsafe" }))
|
||||||
expect(invalidBranch).toBeInstanceOf(RepositoryCache.InvalidBranchError)
|
expect(invalidBranch).toBeInstanceOf(RepositoryCache.InvalidBranchError)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,44 @@ import { testEffect } from "./lib/effect"
|
|||||||
const it = testEffect(LayerNode.compile(Ripgrep.node))
|
const it = testEffect(LayerNode.compile(Ripgrep.node))
|
||||||
|
|
||||||
describe("Ripgrep", () => {
|
describe("Ripgrep", () => {
|
||||||
|
it.live("globs files as an array", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "src")))
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "match.ts"), "needle\n"))
|
||||||
|
|
||||||
|
const result = yield* (yield* Ripgrep.Service).glob({ cwd: tmp.path, pattern: "**/*.ts", limit: 10 })
|
||||||
|
expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")])
|
||||||
|
}),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("greps files with include filtering", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "src")))
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "match.ts"), "needle\n"))
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "src", "skip.txt"), "needle\n"))
|
||||||
|
|
||||||
|
const result = yield* (yield* Ripgrep.Service).grep({
|
||||||
|
cwd: tmp.path,
|
||||||
|
pattern: "needle",
|
||||||
|
include: "*.ts",
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]?.entry.path).toBe(RelativePath.make("src/match.ts"))
|
||||||
|
expect(result[0]?.submatches[0]?.text).toBe("needle")
|
||||||
|
}),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("keeps ignored files out of catch-all find results", () =>
|
it.live("keeps ignored files out of catch-all find results", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
@@ -63,6 +101,29 @@ describe("Ripgrep", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("excludes protected directory trees from catch-all find results", () =>
|
||||||
|
Effect.acquireUseRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "Pictures")))
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "Pictures", "private.jpg"), "private\n"))
|
||||||
|
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "visible.txt"), "visible\n"))
|
||||||
|
|
||||||
|
const files = yield* (yield* Ripgrep.Service).find({
|
||||||
|
cwd: tmp.path,
|
||||||
|
pattern: "*",
|
||||||
|
limit: 10,
|
||||||
|
exclude: ["Pictures/**"],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(files.map((item) => item.path)).toContain(RelativePath.make("visible.txt"))
|
||||||
|
expect(files.map((item) => item.path)).not.toContain(RelativePath.make("Pictures/private.jpg"))
|
||||||
|
}),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("returns a bounded preview for matches on oversized lines", () =>
|
it.live("returns a bounded preview for matches on oversized lines", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ const projects = Layer.succeed(
|
|||||||
list: () => Effect.succeed([]),
|
list: () => Effect.succeed([]),
|
||||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||||
directories: () => Effect.succeed([]),
|
directories: () => Effect.succeed([]),
|
||||||
commit: () => Effect.void,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
let requests: LLMRequest[] = []
|
let requests: LLMRequest[] = []
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ const projects = Layer.succeed(
|
|||||||
list: () => Effect.succeed([]),
|
list: () => Effect.succeed([]),
|
||||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||||
directories: () => Effect.succeed([]),
|
directories: () => Effect.succeed([]),
|
||||||
commit: () => Effect.void,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
@@ -284,13 +283,9 @@ describe("Session.create", () => {
|
|||||||
})
|
})
|
||||||
expect(yield* SessionPending.find(db, forkContext[0].id)).toBeUndefined()
|
expect(yield* SessionPending.find(db, forkContext[0].id)).toBeUndefined()
|
||||||
expect(yield* SessionPending.find(db, forkContext[1].id)).toBeUndefined()
|
expect(yield* SessionPending.find(db, forkContext[1].id)).toBeUndefined()
|
||||||
// Fork-copied messages have no admitted event in the fork aggregate, so
|
|
||||||
// reusing their IDs as prompt IDs is conflicting reuse, not a retry.
|
|
||||||
expect(
|
expect(
|
||||||
yield* session
|
yield* session.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false }),
|
||||||
.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false })
|
).toMatchObject({ id: forkContext[0].id, type: "user", data: { text: "First" } })
|
||||||
.pipe(Effect.flip),
|
|
||||||
).toMatchObject({ _tag: "Session.PromptConflictError", messageID: forkContext[0].id })
|
|
||||||
|
|
||||||
yield* session.prompt({
|
yield* session.prompt({
|
||||||
sessionID: parent.id,
|
sessionID: parent.id,
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ const projects = Layer.succeed(
|
|||||||
list: () => Effect.succeed([]),
|
list: () => Effect.succeed([]),
|
||||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||||
directories: () => Effect.succeed([]),
|
directories: () => Effect.succeed([]),
|
||||||
commit: () => Effect.void,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const permission = Layer.succeed(
|
const permission = Layer.succeed(
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const projects = Layer.succeed(
|
|||||||
list: () => Effect.succeed([]),
|
list: () => Effect.succeed([]),
|
||||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||||
directories: () => Effect.succeed([]),
|
directories: () => Effect.succeed([]),
|
||||||
commit: () => Effect.void,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
@@ -41,17 +40,15 @@ describe("Session.log", () => {
|
|||||||
it.effect("replays public session events and marks synced at the aggregate watermark", () =>
|
it.effect("replays public session events and marks synced at the aggregate watermark", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const created = yield* session.create({ location })
|
const created = yield* session.create({ location })
|
||||||
yield* session.rename({ sessionID: created.id, title: "session.renamed" })
|
yield* session.rename({ sessionID: created.id, title: "session.renamed" })
|
||||||
|
|
||||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
|
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
|
||||||
const watermark = (yield* bus.sequences([created.id])).get(created.id)
|
|
||||||
|
|
||||||
// Session creation commits a non-public durable event, so the marker's
|
// Session creation commits a non-public durable event, so the marker's
|
||||||
// seq covers more of the aggregate than the public events emitted.
|
// seq covers more of the aggregate than the public events emitted.
|
||||||
expect(items.map((item) => item.type)).toEqual(["session.renamed", "log.synced"])
|
expect(items.map((item) => item.type)).toEqual(["session.renamed", "log.synced"])
|
||||||
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark })
|
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { Session } from "@opencode-ai/core/session"
|
|||||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { Money } from "@opencode-ai/schema/money"
|
import { Money } from "@opencode-ai/schema/money"
|
||||||
import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater"
|
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||||
import { fromRow } from "@opencode-ai/core/session/info"
|
import { fromRow } from "@opencode-ai/core/session/info"
|
||||||
@@ -126,34 +125,6 @@ describe("SessionProjector", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("folds live compaction deltas into running memory state", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const state = {
|
|
||||||
messages: [
|
|
||||||
SessionMessage.CompactionRunning.make({
|
|
||||||
id: SessionMessage.ID.make("msg_compaction"),
|
|
||||||
type: "compaction",
|
|
||||||
status: "running",
|
|
||||||
reason: "manual",
|
|
||||||
summary: "partial ",
|
|
||||||
recent: "recent",
|
|
||||||
time: { created },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
yield* SessionMessageUpdater.update(
|
|
||||||
SessionMessageUpdater.memory(state),
|
|
||||||
SessionEvent.Compaction.Delta.make({
|
|
||||||
id: Event.ID.make("evt_delta"),
|
|
||||||
type: "session.compaction.delta",
|
|
||||||
created,
|
|
||||||
data: { sessionID, text: "summary" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
expect(state.messages[0]).toMatchObject({ status: "running", summary: "partial summary", recent: "recent" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("projects staged, cleared, and committed reverts", () =>
|
it.effect("projects staged, cleared, and committed reverts", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
@@ -550,31 +521,6 @@ describe("SessionProjector", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("does not revive a stale incomplete in-memory assistant projection", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const stale = SessionMessage.Assistant.make({
|
|
||||||
id: SessionMessage.ID.make("msg_assistant_stale"),
|
|
||||||
type: "assistant",
|
|
||||||
agent: build,
|
|
||||||
model,
|
|
||||||
content: [],
|
|
||||||
time: { created },
|
|
||||||
})
|
|
||||||
const completed = SessionMessage.Assistant.make({
|
|
||||||
id: SessionMessage.ID.make("msg_assistant_completed"),
|
|
||||||
type: "assistant",
|
|
||||||
agent: build,
|
|
||||||
model,
|
|
||||||
content: [],
|
|
||||||
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(
|
|
||||||
yield* SessionMessageUpdater.memory({ messages: [stale, completed] }).getCurrentAssistant(),
|
|
||||||
).toBeUndefined()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
|
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
|
|||||||
@@ -553,6 +553,47 @@ describe("Session.prompt", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("reconciles an exact retry from the promoted message without admission history", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* Session.Service
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
|
||||||
|
const first = yield* session.prompt(input)
|
||||||
|
yield* SessionPending.promote(db, bus, sessionID, "steer")
|
||||||
|
yield* db
|
||||||
|
.delete(EventTable)
|
||||||
|
.where(eq(EventTable.aggregate_id, sessionID))
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
const retried = yield* session.prompt(input)
|
||||||
|
|
||||||
|
expect(retried).toMatchObject({ id: first.id, type: "user", data: { text: first.data.text } })
|
||||||
|
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||||
|
{ id: messageID, type: "user", text: "Fix the failing tests" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("ignores delivery when retrying a promoted message", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* Session.Service
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
|
||||||
|
yield* session.prompt(input)
|
||||||
|
yield* SessionPending.promote(db, bus, sessionID, "steer")
|
||||||
|
|
||||||
|
const retried = yield* session.prompt({ ...input, delivery: "queue" })
|
||||||
|
|
||||||
|
expect(retried).toMatchObject({ id: messageID, type: "user", data: { text: input.text } })
|
||||||
|
expect(yield* admitted(messageID)).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("wakes execution when an exact prompt retry recovers a committed message", () =>
|
it.effect("wakes execution when an exact prompt retry recovers a committed message", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ const projects = Layer.succeed(
|
|||||||
list: () => Effect.succeed([]),
|
list: () => Effect.succeed([]),
|
||||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||||
directories: () => Effect.succeed([]),
|
directories: () => Effect.succeed([]),
|
||||||
commit: () => Effect.void,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ describe("SessionRunnerLLM recorded", () => {
|
|||||||
describe("SessionModelRequest HTTP bridge", () => {
|
describe("SessionModelRequest HTTP bridge", () => {
|
||||||
const bodies: Uint8Array[] = []
|
const bodies: Uint8Array[] = []
|
||||||
const methods: string[] = []
|
const methods: string[] = []
|
||||||
|
const headers: Array<string | undefined> = []
|
||||||
const response = [
|
const response = [
|
||||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
|
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello!"},"finish_reason":null}]}',
|
||||||
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
'data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":0,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
||||||
@@ -267,6 +268,7 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
if (request.body._tag !== "Uint8Array") throw new Error(`Unexpected request body: ${request.body._tag}`)
|
||||||
methods.push(request.method)
|
methods.push(request.method)
|
||||||
bodies.push(request.body.body.slice())
|
bodies.push(request.body.body.slice())
|
||||||
|
headers.push(request.headers["x-hook"])
|
||||||
return HttpClientResponse.fromWeb(
|
return HttpClientResponse.fromWeb(
|
||||||
request,
|
request,
|
||||||
new Response(response, { headers: { "content-type": "text/event-stream" } }),
|
new Response(response, { headers: { "content-type": "text/event-stream" } }),
|
||||||
@@ -274,14 +276,16 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const retryIt = testEffect(
|
const httpIt = testEffect(
|
||||||
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
|
testLayer(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))),
|
||||||
)
|
)
|
||||||
|
|
||||||
retryIt.effect("lets an Effect plugin send the same POST Request twice", () =>
|
httpIt.effect("runs Effect HTTP request and response hooks around one provider request", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
bodies.length = 0
|
bodies.length = 0
|
||||||
methods.length = 0
|
methods.length = 0
|
||||||
|
headers.length = 0
|
||||||
|
const seen: string[] = []
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
const hooks = yield* PluginHooks.Service
|
const hooks = yield* PluginHooks.Service
|
||||||
@@ -296,13 +300,20 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
catalog: catalogHost(catalog),
|
catalog: catalogHost(catalog),
|
||||||
session: { hook: (name, callback) => hooks.register("session", name, callback) },
|
session: { hook: (name, callback) => hooks.register("session", name, callback) },
|
||||||
})
|
})
|
||||||
yield* pluginHost.session.hook("http", (event) =>
|
yield* pluginHost.session.hook("http.request", (event) =>
|
||||||
event.use((request, next) =>
|
Effect.sync(() => {
|
||||||
Effect.gen(function* () {
|
seen.push("request")
|
||||||
yield* next(request).pipe(Effect.flatMap((response) => Effect.promise(() => response.text())))
|
event.request.headers.set("x-hook", "effect")
|
||||||
return yield* next(request)
|
}),
|
||||||
}),
|
)
|
||||||
),
|
yield* pluginHost.session.hook("http.response", (event) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
seen.push(`response:${event.response.status}:${event.request.headers.get("x-hook")}`)
|
||||||
|
event.response = new Response(
|
||||||
|
(yield* Effect.promise(() => event.response.text())).replace("Hello!", "Hooked!"),
|
||||||
|
event.response,
|
||||||
|
)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
|
yield* Effect.forEach(SystemPromptPlugin.Plugins, (plugin) => plugin.effect(pluginHost), { discard: true })
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
@@ -330,10 +341,15 @@ describe("SessionModelRequest HTTP bridge", () => {
|
|||||||
|
|
||||||
yield* session.resume(retrySessionID)
|
yield* session.resume(retrySessionID)
|
||||||
|
|
||||||
expect(methods).toEqual(["POST", "POST"])
|
expect(methods).toEqual(["POST"])
|
||||||
expect(bodies).toHaveLength(2)
|
expect(headers).toEqual(["effect"])
|
||||||
|
expect(seen).toEqual(["request", "response:200:effect"])
|
||||||
|
expect(bodies).toHaveLength(1)
|
||||||
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
|
expect(bodies[0]?.byteLength).toBeGreaterThan(0)
|
||||||
expect(bodies[1]).toEqual(bodies[0])
|
expect((yield* session.context(retrySessionID))[1]).toMatchObject({
|
||||||
|
type: "assistant",
|
||||||
|
content: [{ type: "text", text: "Hooked!" }],
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1180,7 +1180,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("forks instruction values at the selected message instead of the parent's latest state", () =>
|
it.effect("seeds a fork with the parent's newest instruction values", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
yield* runPrompt(session, "First")
|
yield* runPrompt(session, "First")
|
||||||
@@ -1197,14 +1197,16 @@ describe("SessionRunnerLLM", () => {
|
|||||||
.where(eq(InstructionStateTable.session_id, forked.id))
|
.where(eq(InstructionStateTable.session_id, forked.id))
|
||||||
.get(),
|
.get(),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
initial_values: { "test/context": Instructions.hash("Changed context") },
|
initial_values: { "test/context": Instructions.hash("Latest context") },
|
||||||
current_values: { "test/context": Instructions.hash("Changed context") },
|
current_values: { "test/context": Instructions.hash("Latest context") },
|
||||||
})
|
})
|
||||||
yield* session.prompt({ sessionID: forked.id, text: "Forked", resume: false })
|
yield* session.prompt({ sessionID: forked.id, text: "Forked", resume: false })
|
||||||
yield* session.resume(forked.id)
|
yield* session.resume(forked.id)
|
||||||
|
|
||||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"])
|
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Latest context"])
|
||||||
expect(systemTexts(requests.at(-1)!)).toContain("Latest context")
|
// Copied history keeps the frozen chronological update; no new update is emitted.
|
||||||
|
expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
|
||||||
|
expect(systemTexts(requests.at(-1)!)).not.toContain("Latest context")
|
||||||
|
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
@@ -1263,7 +1265,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rebuilds a missing instruction cache without admitting another delta", () =>
|
it.effect("re-establishes a fresh baseline when instruction state is missing", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
@@ -1277,13 +1279,15 @@ describe("SessionRunnerLLM", () => {
|
|||||||
expect(requests).toHaveLength(1)
|
expect(requests).toHaveLength(1)
|
||||||
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||||
expect(messageRoles(requests[0])).toEqual(["user", "user"])
|
expect(messageRoles(requests[0])).toEqual(["user", "user"])
|
||||||
|
// The projected row is authoritative: a missing row admits a fresh baseline
|
||||||
|
// instead of rebuilding from durable events.
|
||||||
expect(
|
expect(
|
||||||
yield* db
|
yield* db
|
||||||
.select({ id: EventTable.id })
|
.select({ data: EventTable.data })
|
||||||
.from(EventTable)
|
.from(EventTable)
|
||||||
.where(eq(EventTable.type, "session.instructions.updated.2"))
|
.where(eq(EventTable.type, "session.instructions.updated.2"))
|
||||||
.all(),
|
.all(),
|
||||||
).toHaveLength(1)
|
).toHaveLength(2)
|
||||||
expect(yield* db.select().from(InstructionStateTable).get()).toMatchObject({
|
expect(yield* db.select().from(InstructionStateTable).get()).toMatchObject({
|
||||||
initial_values: { "test/context": Instructions.hash("Initial context") },
|
initial_values: { "test/context": Instructions.hash("Initial context") },
|
||||||
current_values: { "test/context": Instructions.hash("Initial context") },
|
current_values: { "test/context": Instructions.hash("Initial context") },
|
||||||
@@ -1310,7 +1314,10 @@ describe("SessionRunnerLLM", () => {
|
|||||||
])
|
])
|
||||||
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
|
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
|
||||||
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
||||||
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
// The chronological update is a durable client-visible system message.
|
||||||
|
const messages = yield* session.messages({ sessionID })
|
||||||
|
expect(messages).toHaveLength(3)
|
||||||
|
expect(messages[1]).toMatchObject({ type: "system", text: "Changed context" })
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
const updates = yield* db
|
const updates = yield* db
|
||||||
.select({ data: EventTable.data })
|
.select({ data: EventTable.data })
|
||||||
@@ -1327,9 +1334,10 @@ describe("SessionRunnerLLM", () => {
|
|||||||
expect(updates[1]?.data).toEqual({
|
expect(updates[1]?.data).toEqual({
|
||||||
sessionID,
|
sessionID,
|
||||||
delta: { "test/context": Instructions.hash("Changed context") },
|
delta: { "test/context": Instructions.hash("Changed context") },
|
||||||
|
text: "Changed context",
|
||||||
})
|
})
|
||||||
yield* replaySessionProjection(sessionID)
|
yield* replaySessionProjection(sessionID)
|
||||||
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1596,7 +1604,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
||||||
{ type: "text", text: "System context source removed: test/context" },
|
{ type: "text", text: "System context source removed: test/context" },
|
||||||
])
|
])
|
||||||
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1708,12 +1716,14 @@ describe("SessionRunnerLLM", () => {
|
|||||||
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
|
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
|
||||||
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
||||||
"user",
|
"user",
|
||||||
|
"system",
|
||||||
"user",
|
"user",
|
||||||
"model-switched",
|
"model-switched",
|
||||||
|
"system",
|
||||||
"user",
|
"user",
|
||||||
])
|
])
|
||||||
yield* replaySessionProjection(sessionID)
|
yield* replaySessionProjection(sessionID)
|
||||||
expect(yield* session.messages({ sessionID })).toHaveLength(4)
|
expect(yield* session.messages({ sessionID })).toHaveLength(6)
|
||||||
yield* runPrompt(session, "Fourth")
|
yield* runPrompt(session, "Fourth")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -117,9 +117,6 @@ describe("Snapshot", () => {
|
|||||||
RelativePath.make("scope/tracked.txt"),
|
RelativePath.make("scope/tracked.txt"),
|
||||||
])
|
])
|
||||||
const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||||
const preview = yield* snapshot.preview({ files: plan, context: 1 })
|
|
||||||
expect(preview).toHaveLength(1)
|
|
||||||
expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
|
|
||||||
yield* snapshot.restore({ files: plan })
|
yield* snapshot.restore({ files: plan })
|
||||||
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
|
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
|
||||||
expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
|
expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
|
||||||
@@ -185,36 +182,6 @@ describe("Snapshot", () => {
|
|||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(tmp) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const project = path.join(tmp.path, "project")
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
await fs.mkdir(project)
|
|
||||||
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
|
|
||||||
await initGit(project)
|
|
||||||
})
|
|
||||||
|
|
||||||
yield* Effect.gen(function* () {
|
|
||||||
const snapshot = yield* Snapshot.Service
|
|
||||||
const before = yield* snapshot.capture()
|
|
||||||
expect(before).toBeDefined()
|
|
||||||
if (!before) return
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
await fs.writeFile(path.join(project, "tracked.txt"), "two\n")
|
|
||||||
await fs.writeFile(path.join(project, "unrelated.txt"), "keep\n")
|
|
||||||
})
|
|
||||||
yield* snapshot.checkout(before)
|
|
||||||
expect(yield* read(path.join(project, "tracked.txt"))).toBe("one\n")
|
|
||||||
expect(yield* read(path.join(project, "unrelated.txt"))).toBe("keep\n")
|
|
||||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
|
||||||
}),
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function snapshotLayer(data: string, directory: string) {
|
function snapshotLayer(data: string, directory: string) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Message, SystemPart } from "@opencode-ai/ai"
|
|||||||
import type { Agent } from "@opencode-ai/schema/agent"
|
import type { Agent } from "@opencode-ai/schema/agent"
|
||||||
import type { Model } from "@opencode-ai/schema/model"
|
import type { Model } from "@opencode-ai/schema/model"
|
||||||
import type { Session } from "@opencode-ai/schema/session"
|
import type { Session } from "@opencode-ai/schema/session"
|
||||||
import type { Effect, JsonSchema } from "effect"
|
import type { JsonSchema } from "effect"
|
||||||
import type { Hooks } from "./registration.js"
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
export interface SessionContext {
|
export interface SessionContext {
|
||||||
@@ -15,23 +15,25 @@ export interface SessionContext {
|
|||||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionHttp {
|
export interface SessionHttpRequest {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly agent: Agent.ID
|
readonly agent: Agent.ID
|
||||||
readonly model: Model.Ref
|
readonly model: Model.Ref
|
||||||
readonly use: (middleware: SessionHttpMiddleware) => Effect.Effect<void>
|
request: Request
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionHttpHandler = (request: Request) => Effect.Effect<Response, Error>
|
export interface SessionHttpResponse {
|
||||||
|
readonly sessionID: Session.ID
|
||||||
export type SessionHttpMiddleware = (
|
readonly agent: Agent.ID
|
||||||
request: Request,
|
readonly model: Model.Ref
|
||||||
next: SessionHttpHandler,
|
readonly request: Request
|
||||||
) => Effect.Effect<Response, Error>
|
response: Response
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionHooks {
|
export interface SessionHooks {
|
||||||
readonly context: SessionContext
|
readonly context: SessionContext
|
||||||
readonly http: SessionHttp
|
readonly "http.request": SessionHttpRequest
|
||||||
|
readonly "http.response": SessionHttpResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionDomain = Pick<
|
export type SessionDomain = Pick<
|
||||||
|
|||||||
@@ -15,23 +15,25 @@ export interface SessionContext {
|
|||||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionHttp {
|
export interface SessionHttpRequest {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly agent: Agent.ID
|
readonly agent: Agent.ID
|
||||||
readonly model: Model.Ref
|
readonly model: Model.Ref
|
||||||
readonly use: (middleware: SessionHttpMiddleware) => void
|
request: Request
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionHttpHandler = (request: Request) => Promise<Response>
|
export interface SessionHttpResponse {
|
||||||
|
readonly sessionID: Session.ID
|
||||||
export type SessionHttpMiddleware = (
|
readonly agent: Agent.ID
|
||||||
request: Request,
|
readonly model: Model.Ref
|
||||||
next: SessionHttpHandler,
|
readonly request: Request
|
||||||
) => Promise<Response> | Response
|
response: Response
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionHooks {
|
export interface SessionHooks {
|
||||||
readonly context: SessionContext
|
readonly context: SessionContext
|
||||||
readonly http: SessionHttp
|
readonly "http.request": SessionHttpRequest
|
||||||
|
readonly "http.response": SessionHttpResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionDomain = Pick<
|
export type SessionDomain = Pick<
|
||||||
|
|||||||
@@ -186,6 +186,11 @@ export const InstructionsUpdated = Event.durable({
|
|||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
delta: Instruction.Delta,
|
delta: Instruction.Delta,
|
||||||
|
/**
|
||||||
|
* The rendered chronological update shown to the model, frozen at emit time.
|
||||||
|
* Absent for the initial baseline observation and for deltas that render empty.
|
||||||
|
*/
|
||||||
|
text: Schema.String.pipe(optional),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type InstructionsUpdated = typeof InstructionsUpdated.Type
|
export type InstructionsUpdated = typeof InstructionsUpdated.Type
|
||||||
|
|||||||
@@ -2,10 +2,6 @@ export * as ServerAuth from "./auth"
|
|||||||
|
|
||||||
import { Context, Layer, Option, Redacted } from "effect"
|
import { Context, Layer, Option, Redacted } from "effect"
|
||||||
|
|
||||||
export type Credentials = {
|
|
||||||
password?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DecodedCredentials = {
|
export type DecodedCredentials = {
|
||||||
readonly username: string
|
readonly username: string
|
||||||
readonly password: Redacted.Redacted
|
readonly password: Redacted.Redacted
|
||||||
@@ -37,16 +33,3 @@ export function authorized(credentials: DecodedCredentials, config: Info) {
|
|||||||
Redacted.value(credentials.password) === config.password.value
|
Redacted.value(credentials.password) === config.password.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function header(credentials?: Credentials) {
|
|
||||||
const password = credentials?.password
|
|
||||||
if (!password) return undefined
|
|
||||||
|
|
||||||
return `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function headers(credentials?: Credentials) {
|
|
||||||
const authorization = header(credentials)
|
|
||||||
if (!authorization) return undefined
|
|
||||||
return { Authorization: authorization }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,7 +7,3 @@ test("accepts only the fixed opencode username", () => {
|
|||||||
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(true)
|
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(true)
|
||||||
expect(ServerAuth.authorized({ username: "custom", password: Redacted.make("secret") }, config)).toBe(false)
|
expect(ServerAuth.authorized({ username: "custom", password: Redacted.make("secret") }, config)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("encodes the fixed opencode username", () => {
|
|
||||||
expect(ServerAuth.header({ password: "secret" })).toBe(`Basic ${Buffer.from("opencode:secret").toString("base64")}`)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -36,10 +36,6 @@ export const lineCommentStyles = `
|
|||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-component="line-comment"][data-variant="add"] [data-slot="line-comment-button"] {
|
|
||||||
background: var(--syntax-diff-add);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-component="line-comment"] [data-component="icon"] {
|
[data-component="line-comment"] [data-component="icon"] {
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useI18n } from "@opencode-ai/ui/context/i18n"
|
|||||||
|
|
||||||
installLineCommentStyles()
|
installLineCommentStyles()
|
||||||
|
|
||||||
export type LineCommentVariant = "default" | "editor" | "add"
|
export type LineCommentVariant = "default" | "editor"
|
||||||
|
|
||||||
function InlineGlyph(props: { icon: "comment" | "plus" }) {
|
function InlineGlyph(props: { icon: "comment" | "plus" }) {
|
||||||
return (
|
return (
|
||||||
@@ -156,25 +156,6 @@ export const LineComment = (props: LineCommentProps) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LineCommentAddProps = Omit<LineCommentAnchorProps, "children" | "variant" | "open" | "icon"> & {
|
|
||||||
label?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LineCommentAdd = (props: LineCommentAddProps) => {
|
|
||||||
const [split, rest] = splitProps(props, ["label"])
|
|
||||||
const i18n = useI18n()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LineCommentAnchor
|
|
||||||
{...rest}
|
|
||||||
open={false}
|
|
||||||
variant="add"
|
|
||||||
icon="plus"
|
|
||||||
buttonLabel={split.label ?? i18n.t("ui.lineComment.submit")}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
|
export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "open" | "variant" | "onClick"> & {
|
||||||
value: string
|
value: string
|
||||||
selection: JSX.Element
|
selection: JSX.Element
|
||||||
|
|||||||
@@ -948,10 +948,6 @@ function ExaOutput(props: { output?: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerPartComponent(type: string, component: PartComponent) {
|
|
||||||
PART_MAPPING[type] = component
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Message(props: MessageProps) {
|
export function Message(props: MessageProps) {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
|
|||||||
@@ -147,12 +147,12 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
|||||||
const config = createTuiResolvedConfig()
|
const config = createTuiResolvedConfig()
|
||||||
const transport = createFetch((url) => {
|
const transport = createFetch((url) => {
|
||||||
if (url.pathname !== "/api/vcs/diff") return
|
if (url.pathname !== "/api/vcs/diff") return
|
||||||
if (fail) return json({ message: "boom" }, { status: 500 })
|
|
||||||
vcsDiffInput = {
|
vcsDiffInput = {
|
||||||
location: { directory: url.searchParams.get("location[directory]") },
|
location: { directory: url.searchParams.get("location[directory]") },
|
||||||
mode: url.searchParams.get("mode"),
|
mode: url.searchParams.get("mode"),
|
||||||
context: url.searchParams.get("context"),
|
context: url.searchParams.get("context"),
|
||||||
}
|
}
|
||||||
|
if (fail) return json({ message: "boom" }, { status: 500 })
|
||||||
return json({
|
return json({
|
||||||
location: { directory: "/repo/session", project: { id: "project-1", directory: "/repo/session" } },
|
location: { directory: "/repo/session", project: { id: "project-1", directory: "/repo/session" } },
|
||||||
data: vcsDiff,
|
data: vcsDiff,
|
||||||
@@ -238,6 +238,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
|||||||
|
|
||||||
const app = await testRender(() => <Harness />, { width: 80, height })
|
const app = await testRender(() => <Harness />, { width: 80, height })
|
||||||
await waitForCommand(app, commands, "diff.close")
|
await waitForCommand(app, commands, "diff.close")
|
||||||
|
await app.waitFor(() => vcsDiffInput !== undefined)
|
||||||
return {
|
return {
|
||||||
app,
|
app,
|
||||||
commands,
|
commands,
|
||||||
|
|||||||
+16
-8
@@ -246,19 +246,27 @@ Runtime hooks intercept live operations:
|
|||||||
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
||||||
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
||||||
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
||||||
| `ctx.session.hook("http", callback)` | `use`, registering request and response handling |
|
| `ctx.session.hook("http.request", callback)` | `request`, immediately before provider dispatch |
|
||||||
|
| `ctx.session.hook("http.response", callback)` | `response`, immediately after the provider responds |
|
||||||
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
||||||
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
|
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
|
||||||
|
|
||||||
HTTP hooks can modify requests, inspect responses, retry, or return a
|
HTTP hooks can modify requests and responses. They apply to native models; AI
|
||||||
response without calling the provider. It applies to native models; AI SDK
|
SDK models do not currently pass through these hooks. Request and response
|
||||||
models do not currently pass through this hook.
|
bodies are one-shot streams. Use `clone()` when you intentionally need a
|
||||||
|
separate reader, but be aware that its slower branch may buffer data. To inspect
|
||||||
|
or modify chunks while preserving streaming, replace the body with one piped
|
||||||
|
through a `TransformStream`.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
await ctx.session.hook("http", (event) => {
|
await ctx.session.hook("http.request", (event) => {
|
||||||
event.use((request, next) => {
|
event.request.headers.set("x-session-id", event.sessionID)
|
||||||
request.headers.set("x-session-id", event.sessionID)
|
})
|
||||||
return next(request)
|
|
||||||
|
await ctx.session.hook("http.response", (event) => {
|
||||||
|
event.response = new Response(event.response.body, {
|
||||||
|
status: event.response.status,
|
||||||
|
headers: { ...Object.fromEntries(event.response.headers), "x-plugin": "enabled" },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user