mirror of
https://github.com/Heretek-AI/opencode.git
synced 2026-11-09 17:16:05 -10:00
253 lines
8.7 KiB
TypeScript
253 lines
8.7 KiB
TypeScript
export * as State from "./state.js"
|
|
|
|
import { Cause, Context, Effect, Exit, Fiber, Scope } from "effect"
|
|
|
|
/**
|
|
* A synchronous, replayable edit to the current domain state.
|
|
*
|
|
* Domain editors expose readable and writable state while preserving concise
|
|
* plugin/config code. Transforms synchronously rebuild derived state.
|
|
*/
|
|
type TransformCallback<Editor> = (editor: Editor) => void
|
|
export type MakeEditor<State, Editor> = (state: State) => Editor
|
|
|
|
export interface Registration {
|
|
readonly dispose: Effect.Effect<void>
|
|
}
|
|
|
|
/**
|
|
* Registers a scoped transform. Reads rebuild by applying every registered transform in order.
|
|
* Closing the owning Scope removes the transform and invalidates the current value.
|
|
*/
|
|
export type Transform<Editor> = (
|
|
transform: TransformCallback<Editor>,
|
|
) => Effect.Effect<Registration, never, Scope.Scope>
|
|
|
|
/** Invalidates the current value after captured inputs change and notifies like a registration would. */
|
|
export type Reload = () => Effect.Effect<void>
|
|
|
|
export interface Transformable<Editor> {
|
|
readonly transform: Transform<Editor>
|
|
readonly reload: Reload
|
|
}
|
|
|
|
export interface Failure {
|
|
readonly state: string
|
|
readonly cause: unknown
|
|
}
|
|
|
|
type GroupedRegistration = {
|
|
readonly remove: () => boolean
|
|
readonly notify: Effect.Effect<void>
|
|
}
|
|
|
|
type RegistrationGroup = {
|
|
failed: boolean
|
|
readonly registrations: Set<GroupedRegistration>
|
|
readonly report: (failure: Failure, refresh: Effect.Effect<void>) => void
|
|
}
|
|
|
|
const CurrentGroup = Context.Reference<RegistrationGroup | undefined>("@opencode/State/CurrentGroup", {
|
|
defaultValue: () => undefined,
|
|
})
|
|
|
|
/**
|
|
* Groups registrations without coupling State to plugin identity or asynchronous cleanup.
|
|
* A failed group is detached synchronously; its supervisor must run refresh and close its scope.
|
|
*/
|
|
export function group(report: RegistrationGroup["report"]) {
|
|
const group: RegistrationGroup = { failed: false, registrations: new Set(), report }
|
|
return <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.provideService(effect, CurrentGroup, group)
|
|
}
|
|
|
|
function disable(group: RegistrationGroup, failure: Failure) {
|
|
if (group.failed) return
|
|
group.failed = true
|
|
const notifications = new Set<Effect.Effect<void>>()
|
|
for (const registration of group.registrations) {
|
|
registration.remove()
|
|
notifications.add(registration.notify)
|
|
}
|
|
group.report(
|
|
failure,
|
|
Effect.forEach(notifications, (notify) => notify, { discard: true }),
|
|
)
|
|
}
|
|
|
|
type Batch = {
|
|
active: boolean
|
|
readonly shutdown: boolean
|
|
readonly notifications: Set<Effect.Effect<void>>
|
|
}
|
|
|
|
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
|
|
defaultValue: () => undefined,
|
|
})
|
|
/** Coalesces notifications until the effect completes. Reads inside stay fresh; nothing is rolled back. */
|
|
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
|
return run(effect, false)
|
|
}
|
|
|
|
/** Runs the effect as shutdown: States changed inside it close permanently and never notify again. */
|
|
export function shutdown<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
|
return run(effect, true)
|
|
}
|
|
|
|
function run<A, E, R>(effect: Effect.Effect<A, E, R>, shutdown: boolean) {
|
|
return Effect.uninterruptibleMask((restore) =>
|
|
Effect.gen(function* () {
|
|
const current = yield* CurrentBatch
|
|
if (current?.active && !shutdown) return yield* restore(effect)
|
|
const batch: Batch = { active: true, shutdown, notifications: new Set() }
|
|
const exit = yield* restore(effect.pipe(Effect.provideService(CurrentBatch, batch))).pipe(Effect.exit)
|
|
batch.active = false
|
|
// A shutdown batch never collects notifications: changed() closes the State instead.
|
|
const notifications = yield* Effect.forEach(batch.notifications, (notify) => restore(notify).pipe(Effect.exit))
|
|
// Aggregate ordinary failures across domains, while allowing cancellation to stop observer work.
|
|
yield* Exit.asVoidAll([exit, ...notifications])
|
|
return yield* exit
|
|
}),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* A `notify` body that runs resource reconciliation in the owning layer's FiberSet and awaits it, so work
|
|
* queued behind the layer's locks is interrupted with the layer. That interruption is not a failure.
|
|
*/
|
|
export function reconcile(
|
|
root: Scope.Scope,
|
|
fork: (effect: Effect.Effect<void>) => Fiber.Fiber<void>,
|
|
work: () => Effect.Effect<void>,
|
|
): Effect.Effect<void> {
|
|
return Effect.gen(function* () {
|
|
const exit = yield* Fiber.await(fork(work()))
|
|
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
|
|
yield* exit
|
|
})
|
|
}
|
|
|
|
export const inherit = Effect.fnUntraced(function* () {
|
|
const batch = yield* CurrentBatch
|
|
return <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.provideService(effect, CurrentBatch, batch)
|
|
})
|
|
|
|
export interface Options<State, Editor> {
|
|
readonly name?: string
|
|
/** Creates the empty base value for every rebuild. */
|
|
readonly initial: () => State
|
|
/** Wraps mutable state in a domain-specific editor API. */
|
|
readonly editor: MakeEditor<State, Editor>
|
|
/**
|
|
* Observes the freshly rebuilt value outside the read path. Every registration, disposal, or
|
|
* reload notifies once it is applied; a batch coalesces them into one notification at its end.
|
|
* Resource reconciliation owns its execution scope and coordination.
|
|
*/
|
|
readonly notify?: (state: State) => Effect.Effect<void>
|
|
}
|
|
|
|
export interface Interface<State, Editor> extends Transformable<Editor> {
|
|
/**
|
|
* Rebuilds synchronously when transforms changed since the last read. Each rebuild produces a new
|
|
* value and never touches earlier ones, so callers may retain what they read.
|
|
*/
|
|
readonly get: () => State
|
|
/** Marks a changed read-time dependency without scheduling another notification. */
|
|
readonly invalidate: () => void
|
|
/** Changes synchronously, including batched edits and grouped-registration removal. */
|
|
readonly revision: () => number
|
|
}
|
|
|
|
export function create<State, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
|
|
let state = options.initial()
|
|
const transforms = new Set<{ run: TransformCallback<Editor>; group: RegistrationGroup | undefined }>()
|
|
let dirty = false
|
|
let closed = false
|
|
let version = 0
|
|
|
|
const invalidate = () => {
|
|
dirty = true
|
|
version++
|
|
}
|
|
|
|
const get = () => {
|
|
if (closed || !dirty) return state
|
|
while (true) {
|
|
const started = version
|
|
const next = options.initial()
|
|
const editor = options.editor(next)
|
|
for (const transform of transforms) {
|
|
try {
|
|
transform.run(editor)
|
|
} catch (cause) {
|
|
if (!transform.group) throw cause
|
|
disable(transform.group, { state: options.name ?? "anonymous", cause })
|
|
}
|
|
// A nested read can disable a group that already contributed to this candidate.
|
|
if (version !== started) break
|
|
}
|
|
if (version !== started) continue
|
|
// Ungrouped failures still propagate; grouped failures restart from a fresh candidate.
|
|
state = next
|
|
dirty = false
|
|
return state
|
|
}
|
|
}
|
|
|
|
// One stable value per State, so a batch's notification Set holds it at most once.
|
|
const notify: Effect.Effect<void> = Effect.gen(function* () {
|
|
if (closed) return
|
|
const value = get()
|
|
if (options.notify) yield* options.notify(value)
|
|
}).pipe(Effect.withSpan("State.notify"))
|
|
|
|
const changed = Effect.uninterruptibleMask((restore) =>
|
|
Effect.gen(function* () {
|
|
if (closed) return
|
|
invalidate()
|
|
const batch = yield* CurrentBatch
|
|
if (batch?.active) {
|
|
if (batch.shutdown) {
|
|
closed = true
|
|
return
|
|
}
|
|
batch.notifications.add(notify)
|
|
return
|
|
}
|
|
yield* restore(notify)
|
|
}),
|
|
)
|
|
|
|
return {
|
|
get,
|
|
invalidate,
|
|
revision: () => version,
|
|
transform: Effect.fn("State.transform")(function* (update) {
|
|
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
|
|
const scope = yield* Scope.Scope
|
|
const group = yield* CurrentGroup
|
|
if (group?.failed) return { dispose: Effect.void }
|
|
return yield* Effect.uninterruptible(
|
|
Effect.gen(function* () {
|
|
const transform = { run: update, group }
|
|
const registration: GroupedRegistration = {
|
|
remove: () => {
|
|
if (!transforms.delete(transform)) return false
|
|
group?.registrations.delete(registration)
|
|
invalidate()
|
|
return true
|
|
},
|
|
notify: changed,
|
|
}
|
|
const dispose = Effect.uninterruptible(Effect.suspend(() => (registration.remove() ? changed : Effect.void)))
|
|
transforms.add(transform)
|
|
group?.registrations.add(registration)
|
|
yield* Scope.addFinalizer(scope, dispose)
|
|
yield* changed
|
|
return { dispose }
|
|
}),
|
|
)
|
|
}),
|
|
reload: () => changed,
|
|
}
|
|
}
|