mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Restore explicit .ts relative specifiers in source and enable rewriteRelativeImportExtensions so emitted JS uses .js while declarations keep explicit .ts specifiers. Add a NodeNext declaration-consumer gate to prevent extensionless declaration regressions.
505 lines
14 KiB
TypeScript
505 lines
14 KiB
TypeScript
import { Awaitable, defineProperty, Dict, isNullable } from 'cosmokit'
|
|
import { Context } from './context.ts'
|
|
import { Plugin } from './registry.ts'
|
|
import { buildOuterStack, composeError, DisposableList, getTraceable, isConstructor, isObject, symbols } from './utils.ts'
|
|
import { Impl } from './reflect.ts'
|
|
import { StandardSchemaV1 } from '@standard-schema/spec'
|
|
|
|
declare module './context.ts' {
|
|
export interface Context extends Pick<Fiber, 'effect'> {
|
|
fiber: Fiber
|
|
}
|
|
}
|
|
|
|
const kValidationError = Symbol.for('ValidationError')
|
|
|
|
/** Error raised when plugin configuration fails standard-schema validation. */
|
|
export class ValidationError extends TypeError {
|
|
name = 'ValidationError'
|
|
|
|
constructor(issues: readonly StandardSchemaV1.Issue[]) {
|
|
super(`invalid config:\n` + issues.map(issue => {
|
|
if (issue.path) {
|
|
return ` - ${issue.message} (at ${issue.path.join('.')})`
|
|
} else {
|
|
return ` - ${issue.message}`
|
|
}
|
|
}).join('\n'))
|
|
}
|
|
}
|
|
|
|
Object.defineProperty(ValidationError.prototype, kValidationError, {
|
|
value: true,
|
|
})
|
|
|
|
/** Validate and normalize config for a plugin runtime before it starts. */
|
|
export function resolveConfig(runtime: Plugin.Runtime, config: any) {
|
|
if (!runtime.Config) return config
|
|
// TODO: async validation
|
|
const result = runtime.Config['~standard'].validate(config)
|
|
if ('then' in result) {
|
|
throw new TypeError('Async config validation is not supported')
|
|
}
|
|
if (result.issues) {
|
|
throw new ValidationError(result.issues)
|
|
} else {
|
|
return result.value
|
|
}
|
|
}
|
|
|
|
interface AsyncDisposable<T extends Awaitable<void> = Awaitable<void>> extends PromiseLike<() => T> {
|
|
(): T
|
|
}
|
|
|
|
/** Function returned by an effect to release resources during disposal. */
|
|
export type Disposable<T = any> = () => T
|
|
|
|
/** Effect body result accepted by `ctx.effect()` and plugin startup. */
|
|
export type Effect<T = any> =
|
|
| SyncEffect<T>
|
|
| AsyncEffect<T>
|
|
|
|
type SyncEffect<T = any> =
|
|
| Disposable<T>
|
|
| Iterable<Disposable<T>, void, void>
|
|
|
|
type AsyncEffect<T = any> =
|
|
| Promise<Disposable<T>>
|
|
| AsyncIterable<Disposable<T>, void, void>
|
|
|
|
/** Tree node used to expose nested effect labels for diagnostics. */
|
|
export interface EffectMeta {
|
|
label: string
|
|
children: EffectMeta[]
|
|
}
|
|
|
|
interface EffectRunner<T> {
|
|
epoch: T
|
|
execute: () => any
|
|
collect: (dispose: Disposable) => void
|
|
getOuterStack: () => string[]
|
|
}
|
|
|
|
/** Lifecycle state for one plugin fiber. */
|
|
export const enum FiberState {
|
|
PENDING,
|
|
LOADING,
|
|
ACTIVE,
|
|
FAILED,
|
|
DISPOSED,
|
|
UNLOADING,
|
|
}
|
|
|
|
/** Framework error with a stable machine-readable code. */
|
|
export class CordisError extends Error {
|
|
constructor(public code: CordisError.Code, message?: string) {
|
|
super(message ?? CordisError.Code[code])
|
|
}
|
|
}
|
|
|
|
/** Cordis error code definitions. */
|
|
export namespace CordisError {
|
|
export type Code = keyof typeof Code
|
|
|
|
export const Code = {
|
|
INACTIVE_EFFECT: 'cannot create effect on inactive context',
|
|
} as const
|
|
}
|
|
|
|
const INACTIVE = '__INACTIVE__'
|
|
|
|
/**
|
|
* Runtime instance of one plugin application.
|
|
*
|
|
* A fiber tracks dependency state, validated config, lifecycle effects, and
|
|
* cleanup for the plugin context returned by `ctx.plugin()`.
|
|
*/
|
|
export class Fiber {
|
|
public uid: number | null
|
|
public readonly ctx: Context
|
|
public config: any
|
|
public state = FiberState.PENDING
|
|
public readonly dispose: () => Promise<void>
|
|
public store: Dict<Impl> | undefined
|
|
public inertia: Promise<void> | undefined
|
|
|
|
public readonly _hooks: Dict<DisposableList<Function>> = Object.create(null)
|
|
public readonly _disposables = new DisposableList<Disposable>()
|
|
|
|
// Same as `this.ctx`, but with a more specific type.
|
|
protected context: Context
|
|
|
|
private _error: any
|
|
private _runner: EffectRunner<string>
|
|
private _store: Dict<Impl> = Object.create(null)
|
|
|
|
constructor(
|
|
public parent: Context,
|
|
config: any,
|
|
public inject: Dict<any>,
|
|
public runtime: Plugin.Runtime | null,
|
|
getOuterStack: () => string[],
|
|
) {
|
|
const collect = (dispose: Disposable) => {
|
|
this._disposables.push(dispose)
|
|
}
|
|
|
|
if (runtime) {
|
|
this.uid = parent.registry.counter
|
|
this.ctx = this.context = parent.extend({ fiber: this })
|
|
|
|
const injectEntries = Object.entries(this.inject)
|
|
if (injectEntries.length) {
|
|
this.ctx[Context.intercept] = Object.create(parent[Context.intercept])
|
|
for (const [name, config] of injectEntries) {
|
|
if (isNullable(config)) continue
|
|
this.ctx[Context.intercept][name] = config
|
|
}
|
|
}
|
|
|
|
this._runner = {
|
|
epoch: INACTIVE,
|
|
getOuterStack,
|
|
execute: () => {
|
|
if (isConstructor(runtime.callback)) {
|
|
// eslint-disable-next-line new-cap
|
|
const instance = new runtime.callback(this.ctx, this.config)
|
|
for (const hook of instance?.[symbols.initHooks] ?? []) {
|
|
hook()
|
|
}
|
|
return instance?.[symbols.init]?.()
|
|
} else {
|
|
return runtime.callback(this.ctx, this.config)
|
|
}
|
|
},
|
|
collect,
|
|
}
|
|
|
|
this.context.emit('internal/plugin', this)
|
|
|
|
for (const name of Object.keys(this.inject)) {
|
|
this._checkImpl(name)
|
|
}
|
|
|
|
this.dispose = parent.fiber.effect(() => {
|
|
const remove = runtime.fibers.push(this)
|
|
try {
|
|
this.config = resolveConfig(runtime, config)
|
|
this._refresh()
|
|
} catch (error) {
|
|
this.ctx.logger.error(error)
|
|
this._error = error
|
|
}
|
|
return async () => {
|
|
this.uid = null
|
|
this.context.emit('internal/plugin', this)
|
|
if (this.ctx.registry.has(runtime.callback)) {
|
|
remove()
|
|
if (!runtime.fibers.length) {
|
|
this.ctx.registry.delete(runtime.callback)
|
|
}
|
|
}
|
|
this._setEpoch(INACTIVE)
|
|
// `this.inertia` itself should never reject — both `_reload` and
|
|
// `_unload` swallow their own work errors via `ctx.logger.error`.
|
|
// If it *does* reject, the only remaining cause is the logger
|
|
// itself failing, which we can't recover from in this exact spot
|
|
// (calling the logger again is what just failed). Let the
|
|
// rejection propagate; process-level crash is the honest outcome.
|
|
while (this.inertia) {
|
|
await this.inertia
|
|
}
|
|
}
|
|
}, 'ctx.plugin()')
|
|
} else {
|
|
this.uid = 0
|
|
this.ctx = this.context = parent
|
|
this.state = FiberState.ACTIVE
|
|
this.store = Object.create(null)
|
|
this._runner = {
|
|
epoch: '',
|
|
getOuterStack,
|
|
execute: () => {},
|
|
collect,
|
|
}
|
|
this.dispose = () => this.restart()
|
|
}
|
|
}
|
|
|
|
get name() {
|
|
let fiber: Fiber = this
|
|
do {
|
|
if (fiber.runtime?.name) return fiber.runtime.name
|
|
fiber = fiber.parent.fiber
|
|
} while (fiber !== fiber.parent.fiber)
|
|
return 'root'
|
|
}
|
|
|
|
/** Throw if the fiber has already been disposed. */
|
|
assertActive() {
|
|
if (this.uid !== null) return
|
|
throw new CordisError('INACTIVE_EFFECT')
|
|
}
|
|
|
|
private _execute<T>(runner: EffectRunner<T>) {
|
|
const oldEpoch = runner.epoch
|
|
return composeError((info) => {
|
|
const safeCollect = (dispose: void | Disposable) => {
|
|
if (typeof dispose === 'function') {
|
|
runner.collect(dispose)
|
|
} else if (!isNullable(dispose)) {
|
|
throw new TypeError('Invalid effect')
|
|
}
|
|
}
|
|
const effect: Effect = runner.execute()
|
|
if (typeof effect === 'function') {
|
|
return runner.collect(effect)
|
|
} else if (isNullable(effect)) {
|
|
// return
|
|
} else if (!isObject(effect)) {
|
|
throw new TypeError('Invalid effect')
|
|
} else if ('then' in effect) {
|
|
return effect.then(safeCollect)
|
|
} else if (Symbol.iterator in effect) {
|
|
info.error = new Error()
|
|
const iter = effect[Symbol.iterator]()
|
|
while (true) {
|
|
const result = iter.next()
|
|
safeCollect(result.value)
|
|
if (result.done) return
|
|
}
|
|
} else if (Symbol.asyncIterator in effect) {
|
|
const iter = effect[Symbol.asyncIterator]()
|
|
return (async () => {
|
|
// force async stack trace
|
|
await Promise.resolve()
|
|
info.error = new Error()
|
|
while (true) {
|
|
if (runner.epoch !== oldEpoch) return
|
|
const result = await iter.next()
|
|
safeCollect(result.value)
|
|
if (result.done) return
|
|
}
|
|
})()
|
|
} else {
|
|
throw new TypeError('Invalid effect')
|
|
}
|
|
}, runner.getOuterStack)
|
|
}
|
|
|
|
/** Register a cleanup-aware effect on this fiber. */
|
|
effect(execute: () => SyncEffect, label?: string): Disposable<Promise<void>>
|
|
effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
|
|
effect(execute: () => Effect, label = 'anonymous'): any {
|
|
this.assertActive()
|
|
|
|
const disposables: Disposable[] = []
|
|
const dispose = () => {
|
|
let task!: void | Promise<void>
|
|
for (const dispose of disposables.splice(0).reverse()) {
|
|
if (task) {
|
|
task = task.then(dispose)
|
|
} else {
|
|
const result = dispose()
|
|
if (isObject(result) && 'then' in result) {
|
|
task = result as any
|
|
}
|
|
}
|
|
}
|
|
return task
|
|
}
|
|
|
|
const meta: EffectMeta = { label, children: [] }
|
|
const runner: EffectRunner<boolean> = {
|
|
execute,
|
|
epoch: true,
|
|
collect: (dispose) => {
|
|
disposables.push(dispose)
|
|
this._disposables.delete(dispose)
|
|
if (dispose[symbols.effect]) {
|
|
meta.children.push(dispose[symbols.effect])
|
|
}
|
|
},
|
|
getOuterStack: buildOuterStack(),
|
|
}
|
|
|
|
let task: void | Promise<void>
|
|
try {
|
|
task = this._execute(runner)
|
|
} catch (reason) {
|
|
dispose()
|
|
throw reason
|
|
}
|
|
|
|
// prevent unhandled rejection — both from `task` itself and from the
|
|
// disposer chain if it fails to settle cleanly.
|
|
task?.catch(dispose).catch((error) => this.ctx.logger.error(error))
|
|
|
|
const wrapper = defineProperty(() => {
|
|
if (!runner.epoch) return
|
|
runner.epoch = false
|
|
return task ? task.then(dispose) : dispose()
|
|
}, symbols.effect, meta) as AsyncDisposable
|
|
|
|
const disposeAsync = () => {
|
|
if (!runner.epoch) return
|
|
runner.epoch = false
|
|
return dispose()
|
|
}
|
|
wrapper.then = async (onFulfilled, onRejected) => {
|
|
return Promise.resolve(task)
|
|
.then(() => disposeAsync)
|
|
.then(onFulfilled, onRejected)
|
|
}
|
|
disposables.push(this._disposables.push(wrapper))
|
|
return wrapper
|
|
}
|
|
|
|
/** Return metadata for currently registered effects. */
|
|
getEffects() {
|
|
return [...this._disposables]
|
|
.map<EffectMeta>(dispose => dispose[symbols.effect])
|
|
.filter(Boolean)
|
|
}
|
|
|
|
private _getState() {
|
|
if (this.uid === null) return FiberState.DISPOSED
|
|
if (this._error) return FiberState.FAILED
|
|
if (this._runner.epoch !== INACTIVE) return FiberState.ACTIVE
|
|
return FiberState.PENDING
|
|
}
|
|
|
|
private _updateState(callback: () => void | FiberState) {
|
|
const oldState = this.state
|
|
this.state = callback() ?? this._getState()
|
|
if (oldState === this.state) return
|
|
// FIXME internal/fiber-info
|
|
this.context.emit('internal/status', this, oldState)
|
|
|
|
// only notify changes between ACTIVE and NON-ACTIVE states
|
|
if (oldState !== FiberState.ACTIVE && this.state !== FiberState.ACTIVE) return
|
|
for (const key of Reflect.ownKeys(this.ctx.reflect.store)) {
|
|
const impl = this.ctx.reflect.store[key as symbol]
|
|
if (impl.fiber !== this) continue
|
|
this.ctx.reflect.notify([impl.name])
|
|
}
|
|
}
|
|
|
|
_checkImpl(name: string) {
|
|
const impl = this.ctx.reflect._getImpl(name, true)
|
|
if (!impl) return delete this._store[name]
|
|
try {
|
|
if (impl.check && !impl.check.call(getTraceable(this.ctx, impl.value))) {
|
|
return delete this._store[name]
|
|
}
|
|
} catch (error) {
|
|
impl.fiber.ctx.logger.error(error)
|
|
return delete this._store[name]
|
|
}
|
|
this._store[name] = impl
|
|
}
|
|
|
|
_refresh() {
|
|
let epoch: string | boolean = false
|
|
epoch = ''
|
|
for (const name of Object.keys(this.inject)) {
|
|
const impl = this._store[name]
|
|
if (!impl) {
|
|
epoch = INACTIVE
|
|
break
|
|
}
|
|
epoch += ':' + impl.fiber.uid
|
|
}
|
|
this._setEpoch(epoch)
|
|
}
|
|
|
|
private _setEpoch(epoch: string) {
|
|
const oldEpoch = this._runner.epoch
|
|
if (epoch === oldEpoch) return
|
|
this._runner.epoch = epoch
|
|
if (this.inertia) return
|
|
this._updateState(() => {
|
|
if (epoch !== INACTIVE && oldEpoch === INACTIVE) {
|
|
this.inertia = this._reload()
|
|
return FiberState.LOADING
|
|
} else {
|
|
this.inertia = this._unload()
|
|
return FiberState.UNLOADING
|
|
}
|
|
})
|
|
}
|
|
|
|
private async _reload() {
|
|
this.store = { ...this._store }
|
|
const oldEpoch = this._runner.epoch
|
|
try {
|
|
await Promise.resolve()
|
|
await this._execute(this._runner)
|
|
} catch (reason) {
|
|
// impl guarantees that the error is non-null (?)
|
|
this.ctx.logger.error(reason)
|
|
this._error = reason
|
|
this._runner.epoch = INACTIVE
|
|
}
|
|
this._updateState(() => {
|
|
if (this._runner.epoch === oldEpoch) {
|
|
this.inertia = undefined
|
|
} else {
|
|
this.inertia = this._unload()
|
|
return FiberState.UNLOADING
|
|
}
|
|
})
|
|
}
|
|
|
|
private async _unload() {
|
|
await Promise.all(this._disposables.clear().map(async (dispose) => {
|
|
try {
|
|
await composeError(async (info) => {
|
|
await Promise.resolve()
|
|
info.error = new Error()
|
|
await dispose()
|
|
}, this._runner.getOuterStack)
|
|
} catch (reason) {
|
|
this.ctx.logger.error(reason)
|
|
}
|
|
}))
|
|
this.store = undefined
|
|
this._updateState(() => {
|
|
if (this._runner.epoch === INACTIVE) {
|
|
this.inertia = undefined
|
|
} else {
|
|
this.inertia = this._reload()
|
|
return FiberState.LOADING
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Wait for current lifecycle work and rethrow startup errors. */
|
|
async await() {
|
|
while (this.inertia) {
|
|
await this.inertia
|
|
}
|
|
if (this._error) throw this._error
|
|
return this
|
|
}
|
|
|
|
/** Dispose and immediately reload this plugin with its current config. */
|
|
async restart() {
|
|
this.assertActive()
|
|
this._setEpoch(INACTIVE)
|
|
this._refresh()
|
|
await this.await()
|
|
}
|
|
|
|
/** Validate and apply new config, then restart the plugin. */
|
|
update(config: any, noSave = false) {
|
|
this.assertActive()
|
|
config = resolveConfig(this.runtime!, config)
|
|
this.context.waterfall(this, 'internal/update', config, noSave, () => {
|
|
this.config = config
|
|
this._error = undefined
|
|
return this.restart()
|
|
})
|
|
}
|
|
}
|