1
/** Runtime plugin trees shared by Agents selecting one preset revision. */2
import { Context, type Fiber } from '@deepseek-ai/cordis'3
import { EntryTree, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'4
import { prepareProfileEntries } from '@deepseek-ai/dsh-app-boot'5
import type { PresetDefinition } from './definition.ts'6
import { scopeOf, type ScopeKey } from '@deepseek-ai/dsh-scope'8
/** In-memory Loader tree; only the profile configuration editor persists definitions. */9
class PresetTree extends EntryTree {10
constructor(ctx: Context) {11
const owner = ctx.fiber.entry12
const subtree = owner?.subtree13
const subgroup = owner?.subgroup14
super(ctx)15
if (owner !== undefined) {16
if (subtree === undefined) delete owner.subtree17
else owner.subtree = subtree18
if (subgroup === undefined) delete owner.subgroup19
else owner.subgroup = subgroup20
}21
}23
override write(): void {}24
}26
/** One live revision shared by Agents and scoped readers. */27
export interface PresetMount {28
/** The preset the subtree was composed from. */29
readonly presetId: string30
/** The mounted subtree's fiber. */31
readonly fiber: Fiber32
/** Loader entry tree whose active rows form this standing composition. */33
readonly tree: EntryTree34
/** The standing scope key agents are parented to (undefined only in torn-down records). */35
readonly key: ScopeKey | undefined36
}38
/**39
* Whether `fiber` is `root` itself or is mounted anywhere inside its subtree.40
*41
* Membership is object identity. `uid` looks like a cheaper key but is a42
* per-registry counter, so fibers in two different roots collide on it and a43
* subtree in one runtime would be blamed for a service published in another.44
* @param fiber - the fiber to locate.45
* @param root - the subtree root to test membership against.46
* @returns true when `fiber` belongs to `root`'s subtree.47
*/48
function withinFiber(fiber: Fiber, root: Fiber): boolean {49
let current = fiber50
while (true) {51
if (current === root) return true52
const parent = current.parent.fiber53
if (parent === current) return false54
current = parent55
}56
}58
/**59
* Service names the mounted subtree published into the root realm.60
*61
* A provider without an `isolate` realm stores its implementation under the62
* root's symbol for that name, which is exactly the comparison below; a63
* provider inside an `isolate` realm stores under a realm-private symbol and64
* is correctly absent here.65
* @param ctx - any context of the runtime whose service store is inspected.66
* @param mount - the mounted subtree's fiber.67
* @returns the leaked service names in lexical order.68
*/69
export function leakedServices(ctx: Context, mount: Fiber): string[] {70
const store = ctx.reflect.store71
const rootIsolate = ctx.root[Context.isolate]72
const leaked: string[] = []73
for (const key of Object.getOwnPropertySymbols(store)) {74
const impl = store[key]75
/* v8 ignore next -- cordis deletes a store slot on disposal rather than76
clearing it, so an own symbol always resolves; the guard exists only77
because the store's index signature is optional. */78
if (impl === undefined) continue79
if (!withinFiber(impl.fiber, mount)) continue80
if (rootIsolate[impl.name] === key) leaked.push(impl.name)81
}82
return leaked.sort((left, right) => left.localeCompare(right))83
}85
/**86
* Read a service implementation owned by one retained preset, including isolated realms.87
*88
* Ownership is the same relation {@link leakedServices} reads, inverted: there89
* it names implementations a subtree published into the ROOT realm, here it90
* names the one this subtree published anywhere. Fiber membership is object91
* identity for the reason stated on {@link withinFiber}.92
*93
* @param ctx - any context of the runtime whose service store is inspected.94
* @param mount - the retained revision whose subtree owns the service.95
* @param name - the service name as the preset's rows resolve it.96
* @returns the implementation, or undefined when the mount provides none.97
*/98
export function serviceForMount<K extends string & keyof Context>(99
ctx: Context,100
mount: PresetMount,101
name: K,102
): Context[K] | undefined {103
const store = ctx.reflect.store104
for (const key of Object.getOwnPropertySymbols(store)) {105
const impl = store[key]106
/* v8 ignore next -- cordis deletes a store slot on disposal rather than clearing it */107
if (impl === undefined) continue108
if (impl.name !== name) continue109
if (withinFiber(impl.fiber, mount.fiber)) return impl.value as Context[K]110
}111
return undefined112
}114
/** Rows that did not reach a usable state, each rendered as one diagnostic line. */115
export interface RowAudit {116
/** Rows that never started or whose import or activation rejected. */117
readonly failed: string[]118
/**119
* Rows waiting for a service the composition does not supply. A Host120
* provider still activating completes such a row later; only a settled Host121
* tree tells that case from a genuinely missing service.122
*/123
readonly pending: string[]124
}126
/**127
* Audit the rows of a mounted subtree.128
*129
* Wait for the subtree, then report import failures, activation failures, and130
* rows waiting for services the composition does not supply.131
* @param tree - the mounted subtree.132
* @returns failed and pending rows, both empty when every enabled row is usable.133
*/134
export async function auditRows(tree: EntryTree): Promise<RowAudit> {135
await tree.await()136
const failed: string[] = []137
const pending: string[] = []138
for (const entry of tree.entries()) {139
if (entry.disabled) continue140
const fiber = entry.fiber141
if (fiber === undefined) {142
failed.push(`${entry.options.id} (${entry.options.name}): never started`)143
continue144
}145
try {146
await fiber.await()147
} catch (error) {148
const detail = mountDetail(error)149
failed.push(`${entry.options.id} (${entry.options.name}): ${detail}`)150
continue151
}152
const missing = Object.keys(fiber.inject).filter(name => fiber.ctx.get(name) === undefined)153
if (missing.length > 0) {154
pending.push(`${entry.options.id} (${entry.options.name}): waiting for ${missing.join(', ')}`)155
}156
}157
return { failed, pending }158
}160
/**161
* The causes of `error` whose detail its own message does not already carry.162
*163
* Aggregate errors carry separate member messages. A wrapper can preserve the164
* aggregate as its cause without including those messages in its own text.165
* @param error - the failure to read branches from.166
* @returns the branches to render beneath `error.message`, possibly empty.167
*/168
function detailBranches(error: Error): readonly unknown[] {169
if (error instanceof AggregateError) return error.errors170
return error.cause instanceof AggregateError ? error.cause.errors : []171
}173
/**174
* The reportable text of a mount failure.175
*176
* A plugin may reject with an aggregate or wrap one as its cause. Include its177
* member messages beneath the row diagnostic so each failure is visible.178
* @param error - the value the mount rejected with.179
* @returns a single-line-per-cause description.180
*/181
function mountDetail(error: unknown): string {182
if (!(error instanceof Error)) return String(error)183
const branches = detailBranches(error)184
if (branches.length === 0) return error.message185
return [186
error.message,187
...branches.map(branch => `- ${mountDetail(branch).replaceAll('\n', '\n ')}`),188
].join('\n')189
}191
/** Load and audit one revision under its registry-owned scope.192
*193
* Failed rows and root-realm service leaks reject the mount. Rows waiting for194
* a Host service stay mounted: they activate by themselves once the provider195
* finishes, and the registry re-audits them after the Host tree settles.196
* Inside a profile, compatibility policy decides admission first: a row whose197
* plugin the profile denies mounts disabled, so the audit reads it as198
* intentionally inactive instead of reporting a failed import.199
* @param ctx Scope context inheriting the declaring Loader's resolution base.200
* @param id Preset identity.201
* @param plugins Declared Cordis entry list.202
* @returns The live tree; scope disposal owns its teardown.203
*/204
export async function mountPreset(ctx: Context, id: string, plugins: PresetDefinition['plugins']): Promise<PresetMount> {205
if (scopeOf(ctx) === undefined) throw new Error('agent-preset: mounting requires a scope')206
await ctx.fiber.await()207
const tree = new PresetTree(ctx)208
ctx.effect(() => () =>{ tree.root.stop() }, 'agent-preset.tree')209
await tree.root.update(prepareProfileEntries(ctx, plugins as EntryOptions[], ctx.baseUrl))210
const audit = await auditRows(tree)211
const leaked = leakedServices(ctx, ctx.fiber)212
if (audit.failed.length > 0) throw new Error(audit.failed.join('\n'))213
if (leaked.length > 0) throw new Error(`Preset services require isolate realms: ${leaked.join(', ')}`)214
const mount = { presetId: id, fiber: ctx.fiber, tree, key: scopeOf(ctx) }215
return mount216
}