| 1 | /** |
| 2 | * Owned registrations: the host half of `registry/register` for one kind. |
| 3 | * |
| 4 | * Every shim that contributes something to the core (tools, commands) follows |
| 5 | * the same life cycle: propose it with `registry/register`, keep the handle |
| 6 | * the core admitted, undo exactly that handle when the plugin's effect is |
| 7 | * disposed, and report a refusal so activation fails with the core's reason. |
| 8 | * The Rust side is the authority (`OwnerRegistry`); this only keeps the |
| 9 | * host's own index by handle so a later `tool/call` or `command/run` can find |
| 10 | * the definition. |
| 11 | */ |
| 12 | import type { OwnerRef, EntryRef } from '../protocol.ts' |
| 13 | import type { RpcPeer } from '../rpc.ts' |
| 14 | |
| 15 | /** What an owner record must carry for registrations to be tracked on it. */ |
| 16 | export interface OwnerBase { |
| 17 | ref: OwnerRef |
| 18 | scope?: EntryRef |
| 19 | state: 'activating' | 'active' | 'failed' | 'disposed' |
| 20 | /** In-flight `registry/register` requests, awaited before activation acks. */ |
| 21 | pendingRegistrations: Set<Promise<void>> |
| 22 | refusals: string[] |
| 23 | } |
| 24 | |
| 25 | /** One registration a plugin made, from this host's side. */ |
| 26 | export interface OwnedEntry<O extends OwnerBase> { |
| 27 | owner: O |
| 28 | name: string |
| 29 | /** Set when the core admits it. */ |
| 30 | handle?: number |
| 31 | disposed: boolean |
| 32 | } |
| 33 | |
| 34 | export type RegisterSpec = Record<string, unknown> |
| 35 | |
| 36 | function describeError(error: unknown): string { |
| 37 | return error instanceof Error ? `${error.name}: ${error.message}` : String(error) |
| 38 | } |
| 39 | |
| 40 | export class OwnedRegistrations<O extends OwnerBase, T extends OwnedEntry<O>> { |
| 41 | /** Admitted entries by core handle. */ |
| 42 | readonly byHandle = new Map<number, T>() |
| 43 | |
| 44 | private readonly rpc: RpcPeer |
| 45 | private readonly kind: 'tool' | 'command' | 'hook' | 'prompt_section' | 'prompt_template' | 'skill_root' | 'shell_hook' | 'mcp_server' |
| 46 | /** The owner's own index of this kind, for the leak report at deactivation. */ |
| 47 | private readonly ownedBy: (owner: O) => Map<number, T> |
| 48 | private readonly warn: (message: string, owner: O) => void |
| 49 | |
| 50 | constructor( |
| 51 | rpc: RpcPeer, |
| 52 | kind: 'tool' | 'command' | 'hook' | 'prompt_section' | 'prompt_template' | 'skill_root' | 'shell_hook' | 'mcp_server', |
| 53 | ownedBy: (owner: O) => Map<number, T>, |
| 54 | warn: (message: string, owner: O) => void, |
| 55 | ) { |
| 56 | this.rpc = rpc |
| 57 | this.kind = kind |
| 58 | this.ownedBy = ownedBy |
| 59 | this.warn = warn |
| 60 | } |
| 61 | |
| 62 | /** Called inside the owner's effect; returns the effect's cleanup. */ |
| 63 | add(entry: T, spec: RegisterSpec): () => void { |
| 64 | const owner = entry.owner |
| 65 | if (owner.state!=='activating' && owner.state!=='active') throw new Error('registration owner was disposed') |
| 66 | const registration: Promise<void> = this.rpc |
| 67 | .request('registry/register', { owner: owner.ref, ...(owner.scope ? {scope:owner.scope} : {}), kind: this.kind, spec }) |
| 68 | .then( |
| 69 | (result: any) => { |
| 70 | if (typeof result?.handle === 'number') { |
| 71 | entry.handle = result.handle |
| 72 | if (entry.disposed || (owner.state!=='activating' && owner.state!=='active')) { |
| 73 | return this.unregister(entry) |
| 74 | } |
| 75 | this.ownedBy(owner).set(result.handle, entry) |
| 76 | this.byHandle.set(result.handle, entry) |
| 77 | } else { |
| 78 | const reason = typeof result?.refused === 'string' ? result.refused : 'refused without a reason' |
| 79 | owner.refusals.push(`${this.kind} \`${entry.name}\` refused: ${reason}`) |
| 80 | if (owner.state === 'active') this.warn(`${this.kind} \`${entry.name}\` refused: ${reason}`, owner) |
| 81 | } |
| 82 | }, |
| 83 | (error: unknown) => { |
| 84 | owner.refusals.push(`${this.kind} \`${entry.name}\` registration failed: ${describeError(error)}`) |
| 85 | }, |
| 86 | ) |
| 87 | .finally(() => owner.pendingRegistrations.delete(registration)) |
| 88 | owner.pendingRegistrations.add(registration) |
| 89 | return () => { |
| 90 | if (entry.disposed) return |
| 91 | entry.disposed = true |
| 92 | if (entry.handle !== undefined) void this.unregister(entry) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | /** Undo exactly this entry. The core revokes first, so a failure here is expected after revocation. */ |
| 97 | private unregister(entry: T): Promise<void> { |
| 98 | const handle = entry.handle! |
| 99 | const owner = entry.owner |
| 100 | this.ownedBy(owner).delete(handle) |
| 101 | this.byHandle.delete(handle) |
| 102 | // Rollback is part of owner quiescence, including an admission response |
| 103 | // arriving after its fiber failed. No activation/disposal ack precedes it. |
| 104 | const cleanup: Promise<void> = this.rpc |
| 105 | .request('registry/unregister', { owner: owner.ref, handle }) |
| 106 | .then(() => undefined, () => undefined) // Core may have revoked first. |
| 107 | .finally(() => owner.pendingRegistrations.delete(cleanup)) |
| 108 | owner.pendingRegistrations.add(cleanup) |
| 109 | return cleanup |
| 110 | } |
| 111 | |
| 112 | /** Drop the host's index of everything `owner` registered (after its teardown). */ |
| 113 | forget(owner: O) { |
| 114 | for (const entry of this.ownedBy(owner).values()) this.byHandle.delete(entry.handle!) |
| 115 | } |
| 116 | } |
| 117 |