fix(typert): harden remote reflection boundaries
This commit is contained in:
@@ -3097,7 +3097,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'TypertContribution',
|
||||
declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n readonly invocations?: readonly InvocationDescriptor[];\n}',
|
||||
declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n readonly invocations: readonly InvocationDescriptor[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'TypeRTDisposer',
|
||||
|
||||
@@ -284,6 +284,7 @@ class ScopedRemoteNamespace extends Service {
|
||||
|
||||
install(descriptor: InvocationDescriptor, projection: ScopedProjection, token: MountToken): void {
|
||||
this.assertMethodAvailable(descriptor.method)
|
||||
if (this.methods.size === 0) this.ownerCtx.set(this.name, this)
|
||||
const method = descriptor.method
|
||||
Object.defineProperty(this, method, {
|
||||
configurable: true,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
type InvocationParameterDescriptor,
|
||||
type TypeRTCodec,
|
||||
type TypeRTGatewayBinding,
|
||||
type TypeRTLookupProvider,
|
||||
} from '@deepseek-ai/dsh-type-meta'
|
||||
import type {
|
||||
InvokeRemoteRequest,
|
||||
@@ -149,6 +148,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
payload: unknown,
|
||||
_signal: AbortSignal,
|
||||
): Promise<ConnectionRpcResult> {
|
||||
// Remote methods have no cancellation parameter yet, so disconnects do not cancel business work.
|
||||
return this.invokeRpc(endpoint, payload)
|
||||
}
|
||||
|
||||
@@ -229,10 +229,8 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
const parameters: InvocationParameterDescriptor[] = []
|
||||
const wires = new Set<string>()
|
||||
for (const name of names) {
|
||||
const matches = this.ctx.typert.lookups.keys()
|
||||
.map(key => ({ key, provider: this.ctx.typert.lookups.get(key) }))
|
||||
.filter((entry): entry is { key: string; provider: TypeRTLookupProvider } =>
|
||||
entry.provider?.parameter === name)
|
||||
const matches = this.ctx.typert.lookups.definitions()
|
||||
.filter(definition => definition.parameter === name)
|
||||
if (matches.length > 1) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
@@ -246,7 +244,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
? { name, wire: name, source: 'json', codec: { mode: 'src-json' } }
|
||||
: {
|
||||
name,
|
||||
wire: match.provider.wire,
|
||||
wire: match.wire,
|
||||
source: 'lookup',
|
||||
lookup: match.key,
|
||||
codec: { mode: 'src-json' },
|
||||
@@ -540,7 +538,7 @@ function decode(
|
||||
field: string,
|
||||
): unknown {
|
||||
try {
|
||||
if (codec.mode === 'strict') return codec.schema.parse(value)
|
||||
if (codec.mode === 'strict') value = codec.schema.parse(value)
|
||||
assertJsonValue(value, new Set())
|
||||
return value
|
||||
} catch (cause) {
|
||||
|
||||
@@ -204,7 +204,13 @@ describe('Client TypeRT API', () => {
|
||||
})
|
||||
|
||||
it('rejects duplicate, live, scoped-service, and Context namespace collisions', async () => {
|
||||
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
|
||||
const call = vi.fn<ConnectionHandle['rpc']['call']>()
|
||||
.mockResolvedValue({ ok: true, value: { renamed: true } })
|
||||
const ctx = await bench(call)
|
||||
const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext
|
||||
ctx.typert.contexts.registerClient('fixture', {
|
||||
identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId,
|
||||
})
|
||||
const direct = directDescriptor()
|
||||
const context = contextDescriptor()
|
||||
|
||||
@@ -242,6 +248,13 @@ describe('Client TypeRT API', () => {
|
||||
package: '@fixture/multiple-scoped',
|
||||
descriptors: [directDescriptor(), contextDescriptor()],
|
||||
})
|
||||
await expect(agentCtx.goals.rename({ objective: 'remounted' })).resolves.toEqual({ renamed: true })
|
||||
expect(call).toHaveBeenLastCalledWith(
|
||||
'/api',
|
||||
'goals/rename',
|
||||
{ args: { agentId: 'agent-remounted', request: { objective: 'remounted' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
await disposeMultipleScoped()
|
||||
})
|
||||
|
||||
|
||||
@@ -370,6 +370,19 @@ describe('TypertGatewayService', () => {
|
||||
})).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' })
|
||||
})
|
||||
|
||||
it('does not downgrade an observed SRC lookup after its provider unloads', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
const dispose = registerAgentLookup(ctx, { id: 'agent-1' })
|
||||
await dispose()
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
}), 'lookup-unavailable')
|
||||
expect(service.calls).toEqual([])
|
||||
})
|
||||
|
||||
it('derives SRC Remote Context identity and preserves the scoped Proxy receiver', async () => {
|
||||
const { ctx } = await setup()
|
||||
const scoped = ctx.extend({ fixtureScope: 'agent-src' })
|
||||
@@ -657,6 +670,22 @@ describe('TypertGatewayService', () => {
|
||||
}), 'result-invalid')
|
||||
})
|
||||
|
||||
it('rejects non-JSON values after strict codec validation', async () => {
|
||||
const { ctx, service } = await setup()
|
||||
const descriptor = strictOnlyDescriptor()
|
||||
registerStrict(ctx, [{
|
||||
...descriptor,
|
||||
result: strictCodec('@fixture/gateway#UnknownResult', z.unknown()),
|
||||
}])
|
||||
service.nextResult = 1n
|
||||
|
||||
await expectCode(ctx.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'strictOnly',
|
||||
args: { request: { title: 'ship' } },
|
||||
}), 'result-invalid')
|
||||
})
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
Number.NaN,
|
||||
|
||||
@@ -1318,6 +1318,8 @@ class FaceAnalyzer {
|
||||
* type evaluator.
|
||||
*/
|
||||
private resolvedRemoteCodecType(authoredType: ts.TypeNode): TypeNodeId {
|
||||
const resolvedType = this.checker.getTypeFromTypeNode(authoredType)
|
||||
this.assertRemoteJsonType(resolvedType, authoredType, new Set(), false)
|
||||
const completed = new Map<ts.Type, TypeNodeId>()
|
||||
const active = new Map<ts.Type, TypeNodeId>()
|
||||
const recursiveDeclarations = new Map<ts.Type, SymbolId>()
|
||||
@@ -1474,7 +1476,107 @@ class FaceAnalyzer {
|
||||
active.delete(type)
|
||||
}
|
||||
}
|
||||
return convert(this.checker.getTypeFromTypeNode(authoredType))
|
||||
return convert(resolvedType)
|
||||
}
|
||||
|
||||
private assertRemoteJsonType(
|
||||
type: ts.Type,
|
||||
site: ts.TypeNode,
|
||||
active: Set<ts.Type>,
|
||||
allowUndefined: boolean,
|
||||
): void {
|
||||
const flags = type.flags
|
||||
if ((flags & ts.TypeFlags.Undefined) !== 0 && allowUndefined) return
|
||||
if ((flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) {
|
||||
this.fail(site, `Remote boundary contains unconstrained ${this.checker.typeToString(type)} data`)
|
||||
}
|
||||
if ((flags & (ts.TypeFlags.BigIntLike | ts.TypeFlags.ESSymbolLike | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0) {
|
||||
this.fail(site, `Remote boundary contains non-JSON type ${this.checker.typeToString(type)}`)
|
||||
}
|
||||
if ((flags & (ts.TypeFlags.StringLike
|
||||
| ts.TypeFlags.NumberLike
|
||||
| ts.TypeFlags.BooleanLike
|
||||
| ts.TypeFlags.Null
|
||||
| ts.TypeFlags.Never)) !== 0) return
|
||||
if (type.isUnion()) {
|
||||
for (const member of type.types) this.assertRemoteJsonType(member, site, active, allowUndefined)
|
||||
return
|
||||
}
|
||||
if (type.isIntersection()) {
|
||||
const material = type.types.filter(member => !this.isRemotePhantomConstraint(member))
|
||||
if (material.length === 0) this.fail(site, 'Remote boundary contains a symbol-only object')
|
||||
for (const member of material) this.assertRemoteJsonType(member, site, active, false)
|
||||
return
|
||||
}
|
||||
if ((flags & ts.TypeFlags.TypeParameter) !== 0) {
|
||||
this.fail(site, 'Remote boundary contains an unresolved type parameter')
|
||||
}
|
||||
if ((flags & ts.TypeFlags.Object) === 0) {
|
||||
this.fail(site, `Remote boundary contains non-JSON type ${this.checker.typeToString(type)}`)
|
||||
}
|
||||
const symbol = type.getSymbol()
|
||||
const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0]
|
||||
if (declaration !== undefined && (ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration))) {
|
||||
this.fail(site, `Remote boundary contains class instance ${symbol?.name ?? this.checker.typeToString(type)}`)
|
||||
}
|
||||
if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) {
|
||||
this.fail(site, 'Remote boundary contains callable or constructable data')
|
||||
}
|
||||
if (active.has(type)) return
|
||||
active.add(type)
|
||||
try {
|
||||
if (this.checker.isTupleType(type)) {
|
||||
const reference = type as ts.TypeReference
|
||||
const target = reference.target as ts.TupleType
|
||||
const arguments_ = this.checker.getTypeArguments(reference)
|
||||
arguments_.forEach((argument, index) => {
|
||||
const elementFlags = target.elementFlags[index] ?? ts.ElementFlags.Required
|
||||
this.assertRemoteJsonType(
|
||||
argument,
|
||||
site,
|
||||
active,
|
||||
(elementFlags & ts.ElementFlags.Optional) !== 0,
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (this.checker.isArrayType(type) || this.checker.isArrayLikeType(type)) {
|
||||
const element = this.checker.getIndexTypeOfType(type, ts.IndexKind.Number)
|
||||
if (element === undefined) this.fail(site, 'Remote boundary array has no element type')
|
||||
this.assertRemoteJsonType(element, site, active, false)
|
||||
return
|
||||
}
|
||||
const properties = this.checker.getPropertiesOfType(type)
|
||||
if (properties.some(property => property.getName().startsWith('__@'))) {
|
||||
this.fail(site, 'Remote boundary contains a symbol-keyed property')
|
||||
}
|
||||
for (const property of properties) {
|
||||
const propertyDeclaration = property.valueDeclaration ?? property.declarations?.[0]
|
||||
const propertyType = this.checker.getTypeOfSymbolAtLocation(property, propertyDeclaration ?? site)
|
||||
this.assertRemoteJsonType(
|
||||
propertyType,
|
||||
site,
|
||||
active,
|
||||
(property.flags & ts.SymbolFlags.Optional) !== 0,
|
||||
)
|
||||
}
|
||||
for (const info of this.checker.getIndexInfosOfType(type)) {
|
||||
if ((info.keyType.flags & ts.TypeFlags.ESSymbolLike) !== 0) {
|
||||
this.fail(site, 'Remote boundary contains a symbol index signature')
|
||||
}
|
||||
this.assertRemoteJsonType(info.type, site, active, false)
|
||||
}
|
||||
} finally {
|
||||
active.delete(type)
|
||||
}
|
||||
}
|
||||
|
||||
private isRemotePhantomConstraint(type: ts.Type): boolean {
|
||||
if ((type.flags & ts.TypeFlags.Unknown) !== 0) return true
|
||||
if ((type.flags & ts.TypeFlags.Any) !== 0 || (type.flags & ts.TypeFlags.Object) === 0) return false
|
||||
if (type.getCallSignatures().length > 0 || type.getConstructSignatures().length > 0) return false
|
||||
if (this.checker.getIndexInfosOfType(type).length > 0) return false
|
||||
return this.checker.getPropertiesOfType(type).every(property => property.getName().startsWith('__@'))
|
||||
}
|
||||
|
||||
private resolvedCycleReference(
|
||||
|
||||
@@ -284,6 +284,32 @@ export type GenericResult = {
|
||||
expect(() => analyzeRemote(root, false)).toThrow(/non-JSON class parameter Agent requires a TypeRTLookupMap entry/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['bigint', 'bigint'],
|
||||
['symbol', 'symbol'],
|
||||
['undefined', 'undefined'],
|
||||
['any', 'unconstrained any'],
|
||||
['unknown', 'unconstrained unknown'],
|
||||
])('rejects non-JSON Remote boundary type %s', (type, message) => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/types.ts', source => source.replace(
|
||||
' readonly title: string\n}',
|
||||
` readonly title: string\n readonly invalid: ${type}\n}`,
|
||||
))
|
||||
|
||||
expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message))
|
||||
})
|
||||
|
||||
it('keeps optional JSON object fields valid', () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/types.ts', source => source.replace(
|
||||
' readonly title: string\n}',
|
||||
' readonly title: string\n readonly note?: string\n}',
|
||||
))
|
||||
|
||||
expect(() => analyzeRemote(root)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a Remote Context without a static Context declaration', () => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/index.ts', source => source.replace("@RemoteContext('agent')", "@RemoteContext('missing')"))
|
||||
|
||||
@@ -135,10 +135,8 @@ export function validateTypertManifest(pkgName: string, exported: unknown): Type
|
||||
requireMembers(pkgName, object.members, `object "${object.name as string}"`)
|
||||
requireTypes(pkgName, object.types, `object "${object.name as string}"`)
|
||||
}
|
||||
if (manifest.invocations !== undefined) {
|
||||
for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) {
|
||||
requireInvocation(pkgName, value)
|
||||
}
|
||||
for (const value of requireArray(pkgName, manifest.invocations, 'TYPERT.invocations')) {
|
||||
requireInvocation(pkgName, value)
|
||||
}
|
||||
return manifest as unknown as TypertContribution
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ function typertSource(pkgName: string, entryName: string): string {
|
||||
' face: \'host\',',
|
||||
` schemas: [{ name: '${entryName}', schema: ${entryName} }],`,
|
||||
' model: { services: [], events: [], objects: [] },',
|
||||
' invocations: [],',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
@@ -262,6 +263,7 @@ describe('typert loader', () => {
|
||||
' face: \'host\',',
|
||||
' schemas: [{ name: \'Pending\', schema: Pending }],',
|
||||
' model: { services: [], events: [], objects: [] },',
|
||||
' invocations: [],',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'),
|
||||
@@ -295,7 +297,7 @@ describe('typert loader', () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-typert-loader-'))
|
||||
await linkZod(root)
|
||||
await writePackage(root, '@fixture/broken', {
|
||||
typertSource: 'export const TYPERT = { package: \'@fixture/broken\', face: \'host\', schemas: [{ name: \'\', schema: {} }], model: { services: [], events: [], objects: [] } }\n',
|
||||
typertSource: 'export const TYPERT = { package: \'@fixture/broken\', face: \'host\', schemas: [{ name: \'\', schema: {} }], model: { services: [], events: [], objects: [] }, invocations: [] }\n',
|
||||
})
|
||||
const ctx = await boot()
|
||||
await ctx.loader.create({ name: '@fixture/broken' })
|
||||
@@ -410,6 +412,7 @@ describe('validateTypertManifest', () => {
|
||||
face: 'host',
|
||||
schemas: [{ name: 'A', schema: zodish }],
|
||||
model: { services: [], events: [], objects: [] },
|
||||
invocations: [],
|
||||
}).schemas).toHaveLength(1)
|
||||
|
||||
expect(() => validateTypertManifest('pkg', undefined)).toThrow('no TYPERT manifest object')
|
||||
@@ -490,12 +493,14 @@ describe('validateTypertManifest', () => {
|
||||
})).toThrow('object has a missing or empty exportName')
|
||||
})
|
||||
|
||||
it('validates strict invocation descriptors and accepts legacy manifests without them', () => {
|
||||
const legacy = completeManifest(zodish)
|
||||
expect(validateTypertManifest('pkg', legacy)).toBe(legacy)
|
||||
it('requires and validates strict invocation descriptors', () => {
|
||||
const base = completeManifest(zodish)
|
||||
const { invocations: _invocations, ...missingInvocations } = base
|
||||
expect(() => validateTypertManifest('pkg', missingInvocations))
|
||||
.toThrow('TYPERT.invocations must be an array')
|
||||
|
||||
const descriptor = strictInvocation()
|
||||
const manifest = { ...legacy, invocations: [descriptor] }
|
||||
const manifest = { ...base, invocations: [descriptor] }
|
||||
expect(validateTypertManifest('pkg', manifest)).toBe(manifest)
|
||||
const scoped = {
|
||||
...descriptor,
|
||||
@@ -508,53 +513,53 @@ describe('validateTypertManifest', () => {
|
||||
codec: strictCodec('pkg#AgentId'),
|
||||
}, ...descriptor.parameters],
|
||||
}
|
||||
expect(validateTypertManifest('pkg', { ...legacy, invocations: [scoped] }).invocations)
|
||||
expect(validateTypertManifest('pkg', { ...base, invocations: [scoped] }).invocations)
|
||||
.toEqual([scoped])
|
||||
|
||||
expect(() => validateTypertManifest('pkg', { ...legacy, invocations: {} }))
|
||||
expect(() => validateTypertManifest('pkg', { ...base, invocations: {} }))
|
||||
.toThrow('TYPERT.invocations must be an array')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{ ...descriptor, invocation: { kind: 'future' } }],
|
||||
})).toThrow('receiver kind must be "direct" or "context"')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{ ...descriptor, result: { mode: 'src-json' } }],
|
||||
})).toThrow('result codec must use a strict codec')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }],
|
||||
})).toThrow('result codec is not backed by a zod v4 schema')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{
|
||||
...descriptor,
|
||||
parameters: [{ ...descriptor.parameters[0], source: 'future' }],
|
||||
}],
|
||||
})).toThrow('parameter source must be "json" or "lookup"')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{
|
||||
...descriptor,
|
||||
parameters: [{ ...descriptor.parameters[0], source: 'lookup' }],
|
||||
}],
|
||||
})).toThrow('lookup parameter has a missing or empty lookup')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{
|
||||
...descriptor,
|
||||
parameters: [{ ...descriptor.parameters[0], lookup: 'agent' }],
|
||||
}],
|
||||
})).toThrow('JSON parameter declares a lookup')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{
|
||||
...descriptor,
|
||||
parameters: [descriptor.parameters[0], { ...descriptor.parameters[0], name: 'again' }],
|
||||
}],
|
||||
})).toThrow('repeats wire field "request"')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{
|
||||
...descriptor,
|
||||
invocation: {
|
||||
@@ -566,19 +571,19 @@ describe('validateTypertManifest', () => {
|
||||
}],
|
||||
})).toThrow('repeats Context wire field "request"')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{ ...scoped, scope: null }],
|
||||
})).toThrow('scope must be an object')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{ ...scoped, scope: { wire: 'agentId' } }],
|
||||
})).toThrow('scope has a missing or empty context')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{ ...scoped, scope: { context: 'agent' } }],
|
||||
})).toThrow('scope has a missing or empty wire')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{
|
||||
...scoped,
|
||||
invocation: {
|
||||
@@ -590,11 +595,11 @@ describe('validateTypertManifest', () => {
|
||||
}],
|
||||
})).toThrow('Context receiver cannot declare a direct scope projection')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{ ...scoped, scope: { context: 'agent', wire: 'missingId' } }],
|
||||
})).toThrow('must select its only lookup parameter')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{
|
||||
...scoped,
|
||||
parameters: [...scoped.parameters, {
|
||||
@@ -607,11 +612,11 @@ describe('validateTypertManifest', () => {
|
||||
}],
|
||||
})).toThrow('must select its only lookup parameter')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{ ...scoped, scope: { context: 'other', wire: 'agentId' } }],
|
||||
})).toThrow('must select its only lookup parameter')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...legacy,
|
||||
...base,
|
||||
invocations: [{ ...descriptor, sourceLocation: { file: 'src/index.ts', line: 0, column: 1 } }],
|
||||
})).toThrow('sourceLocation.line must be a positive integer')
|
||||
})
|
||||
@@ -646,6 +651,7 @@ function completeManifest(zodish: object) {
|
||||
package: 'pkg',
|
||||
face: 'host',
|
||||
schemas: [{ name: 'Schema', schema: zodish }],
|
||||
invocations: [],
|
||||
model: {
|
||||
services: [{
|
||||
key: 'service',
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
TypeRTHostContextProvider,
|
||||
TypeRTLocalRegistry,
|
||||
TypeRTLookupHost,
|
||||
TypeRTLookupDefinition,
|
||||
TypeRTLookupMap,
|
||||
TypeRTLookupProvider,
|
||||
TypeRTLookupRegistry,
|
||||
@@ -212,6 +213,7 @@ class RemoteStore {
|
||||
|
||||
class LookupStore {
|
||||
private readonly providers = new Map<string, ProviderEntry<TypeRTLookupProvider>>()
|
||||
private readonly definitions = new Map<string, TypeRTLookupDefinition>()
|
||||
private readonly changes: ChangeSource
|
||||
|
||||
constructor(report: ReportObserverError) {
|
||||
@@ -228,6 +230,7 @@ class LookupStore {
|
||||
>,
|
||||
) => this.register(ctx, key, provider),
|
||||
get: key => this.providers.get(key)?.provider,
|
||||
definitions: () => [...this.definitions.values()],
|
||||
keys: () => [...this.providers.keys()],
|
||||
subscribe: listener => this.changes.subscribe(ctx, listener),
|
||||
}
|
||||
@@ -240,10 +243,22 @@ class LookupStore {
|
||||
validateNonempty('lookup Host type symbol', provider.hostTypeSymbol)
|
||||
validateNonempty('lookup wire type symbol', provider.wireTypeSymbol)
|
||||
if (this.providers.has(key)) throw new Error(`typert: lookup "${key}" is already registered`)
|
||||
const definition: TypeRTLookupDefinition = {
|
||||
key,
|
||||
parameter: provider.parameter,
|
||||
wire: provider.wire,
|
||||
hostTypeSymbol: provider.hostTypeSymbol,
|
||||
wireTypeSymbol: provider.wireTypeSymbol,
|
||||
}
|
||||
const known = this.definitions.get(key)
|
||||
if (known !== undefined && !lookupDefinitionEquals(known, definition)) {
|
||||
throw new Error(`typert: lookup "${key}" changed its wire declaration during this registry lifetime`)
|
||||
}
|
||||
const owner = {}
|
||||
const entry: ProviderEntry<TypeRTLookupProvider> = { provider, owner }
|
||||
const { providers, changes } = this
|
||||
const { definitions, providers, changes } = this
|
||||
return ctx.effect(function* () {
|
||||
definitions.set(key, definition)
|
||||
providers.set(key, entry)
|
||||
changes.emit({ kind: 'lookup', key })
|
||||
yield () => {
|
||||
@@ -256,6 +271,13 @@ class LookupStore {
|
||||
}
|
||||
}
|
||||
|
||||
function lookupDefinitionEquals(left: TypeRTLookupDefinition, right: TypeRTLookupDefinition): boolean {
|
||||
return left.parameter === right.parameter
|
||||
&& left.wire === right.wire
|
||||
&& left.hostTypeSymbol === right.hostTypeSymbol
|
||||
&& left.wireTypeSymbol === right.wireTypeSymbol
|
||||
}
|
||||
|
||||
class ContextStore {
|
||||
private readonly hosts = new Map<string, ProviderEntry<TypeRTHostContextProvider>>()
|
||||
private readonly clients = new Map<string, ProviderEntry<TypeRTClientContextBinder>>()
|
||||
@@ -377,7 +399,7 @@ export class TypertRegistry extends Service implements TypeRTService {
|
||||
register(contribution: TypertContribution): TypeRTDisposer {
|
||||
const packageRecord = this.validatePackage(contribution)
|
||||
const schemaRecords = this.validateSchemas(contribution)
|
||||
const invocations = contribution.invocations ?? []
|
||||
const invocations = contribution.invocations
|
||||
this.localStore.validate(invocations)
|
||||
const owner = {}
|
||||
const { schemas, packages, localStore } = this
|
||||
|
||||
@@ -83,12 +83,7 @@ export interface TypertContribution {
|
||||
readonly face: TypertFace
|
||||
readonly schemas: readonly TypertSchema[]
|
||||
readonly model: TypertPackageModel
|
||||
/** Host invocation definitions; absent on artifacts generated before Remote support. */
|
||||
readonly invocations?: readonly InvocationDescriptor[]
|
||||
}
|
||||
|
||||
/** Generated Host contribution with strict Remote invocation definitions. */
|
||||
export interface TypertLocalContribution extends TypertContribution {
|
||||
/** Host invocation definitions, empty when the package exports no Remote methods. */
|
||||
readonly invocations: readonly InvocationDescriptor[]
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ function toolsContribution(schema: z.ZodType = z.object({ name: z.string() })):
|
||||
package: '@deepseek-ai/dsh-tools',
|
||||
face: 'host',
|
||||
schemas: [{ name: 'ToolInput', schema }],
|
||||
invocations: [],
|
||||
model: {
|
||||
services: [{
|
||||
key: 'tools',
|
||||
@@ -329,11 +330,19 @@ describe('TypertRegistry', () => {
|
||||
})
|
||||
|
||||
expect(ctx.typert.lookups.get('fixture')?.resolve('agent-1')).toBe(object)
|
||||
expect(ctx.typert.lookups.definitions()).toEqual([{
|
||||
key: 'fixture',
|
||||
parameter: 'agent',
|
||||
wire: 'agentId',
|
||||
hostTypeSymbol: '@fixture/agent#Agent',
|
||||
wireTypeSymbol: '@fixture/session#SessionId',
|
||||
}])
|
||||
expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped)
|
||||
expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1')
|
||||
|
||||
await Promise.all([disposeClient(), disposeHost(), disposeLookup()])
|
||||
expect(ctx.typert.lookups.keys()).toEqual([])
|
||||
expect(ctx.typert.lookups.definitions()).toHaveLength(1)
|
||||
expect(ctx.typert.contexts.getHost('registryFixture')).toBeUndefined()
|
||||
expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined()
|
||||
})
|
||||
@@ -378,6 +387,15 @@ describe('TypertRegistry', () => {
|
||||
])
|
||||
|
||||
await Promise.all([disposeLookupSubscription(), disposeContextSubscription()])
|
||||
for (const changed of [
|
||||
{ ...lookup, parameter: 'session' },
|
||||
{ ...lookup, wire: 'sessionId' },
|
||||
{ ...lookup, hostTypeSymbol: '@fixture#Session' },
|
||||
{ ...lookup, wireTypeSymbol: '@fixture#SessionId' },
|
||||
]) {
|
||||
expect(() => ctx.typert.lookups.register('fixture', changed))
|
||||
.toThrow('changed its wire declaration during this registry lifetime')
|
||||
}
|
||||
ctx.typert.lookups.register('fixture', lookup)
|
||||
expect(changes).toHaveLength(6)
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ export type {
|
||||
TypeRTHostContextProvider,
|
||||
TypeRTLocalRegistry,
|
||||
TypeRTLookup,
|
||||
TypeRTLookupDefinition,
|
||||
TypeRTLookupHost,
|
||||
TypeRTLookupMap,
|
||||
TypeRTLookupProvider,
|
||||
|
||||
@@ -189,6 +189,20 @@ export interface TypeRTLookupProvider<Host = unknown, Wire = unknown> {
|
||||
resolve(id: Wire): Host | undefined
|
||||
}
|
||||
|
||||
/** Stable wire declaration retained after a lookup provider unloads. */
|
||||
export interface TypeRTLookupDefinition {
|
||||
/** Merge-declared lookup key. */
|
||||
readonly key: string
|
||||
/** Source parameter name recognized by the SRC weak parser. */
|
||||
readonly parameter: string
|
||||
/** Wire field replacing the Host object parameter. */
|
||||
readonly wire: string
|
||||
/** Canonical Host type symbol used by strict generation. */
|
||||
readonly hostTypeSymbol: string
|
||||
/** Canonical wire type symbol used by strict generation. */
|
||||
readonly wireTypeSymbol: string
|
||||
}
|
||||
|
||||
/** Host resolver for one scoped Remote Context kind. */
|
||||
export interface TypeRTHostContextProvider<Wire = unknown> {
|
||||
/** Wire field carrying the Context identity. */
|
||||
@@ -291,6 +305,8 @@ export interface TypeRTLookupRegistry {
|
||||
* @returns the live provider, or `undefined` when absent.
|
||||
*/
|
||||
get(key: string): TypeRTLookupProvider | undefined
|
||||
/** @returns lookup declarations observed during this TypeRT Service lifetime. */
|
||||
definitions(): readonly TypeRTLookupDefinition[]
|
||||
/** @returns a snapshot of registered provider keys. */
|
||||
keys(): readonly string[]
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user