feat(typert): propagate Remote cancellation
This commit is contained in:
@@ -2097,7 +2097,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'InvocationDescriptor',
|
||||
declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}',
|
||||
declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly cancellation?: {\n readonly parameter: \'signal\';\n };\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}',
|
||||
},
|
||||
{
|
||||
name: 'InvocationParameterDescriptor',
|
||||
@@ -2109,7 +2109,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'InvokeRemoteRequest',
|
||||
declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly<Record<string, unknown>>;\n}',
|
||||
declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly<Record<string, unknown>>;\n readonly signal?: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'JsonSchemaNode',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/api-gateway/README.md
|
||||
README.md: cc80bb19fec15414aa0857154a8a36fb4f642672
|
||||
README.zh.md: 6febb1cfe4fc7fa4c5a17e1e4f6a21e2ee03e295
|
||||
README.md: 9cb6e7e1c0a23789ab4ab2c999b5a6c2d4cd32f9
|
||||
README.zh.md: 609580ceb77649ba8df6103093a72092c9ccc8a1
|
||||
|
||||
@@ -12,11 +12,13 @@ Strict mode reads generated invocation descriptors from `ctx.typert.local`. Look
|
||||
|
||||
The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs.
|
||||
|
||||
A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type.
|
||||
|
||||
## Client service: `ClientApi` (ctx key: `api`)
|
||||
|
||||
`ctx.api.mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable.
|
||||
|
||||
Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject.
|
||||
Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject.
|
||||
|
||||
Generated declaration merges provide the TypeScript API. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy.
|
||||
|
||||
|
||||
@@ -12,11 +12,13 @@
|
||||
|
||||
Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。
|
||||
|
||||
支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
|
||||
|
||||
## Client 服务:`ClientApi`(ctx key:`api`)
|
||||
|
||||
`ctx.api.mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。
|
||||
|
||||
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
|
||||
每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。
|
||||
|
||||
生成的声明合并提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。
|
||||
|
||||
|
||||
@@ -223,9 +223,13 @@ class ClientApiService extends Service implements ClientApi {
|
||||
const endpoint = endpointOf(descriptor)
|
||||
if (!token.active) throw new Error(`client api: Remote method ${endpoint} is no longer mounted`)
|
||||
const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1)
|
||||
if (values.length !== expected) {
|
||||
const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1
|
||||
if (values.length !== expected && !hasCallerSignal) {
|
||||
const contract = descriptor.cancellation === undefined
|
||||
? `${String(expected)} argument(s)`
|
||||
: `${String(expected)} business argument(s) plus an optional AbortSignal`
|
||||
throw new Error(
|
||||
`client api: ${endpoint} expected ${String(expected)} argument(s), got ${String(values.length)}`,
|
||||
`client api: ${endpoint} expected ${contract}, got ${String(values.length)}`,
|
||||
)
|
||||
}
|
||||
const args: Record<string, unknown> = {}
|
||||
@@ -248,7 +252,11 @@ class ClientApiService extends Service implements ClientApi {
|
||||
})
|
||||
const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined
|
||||
if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`)
|
||||
const result = await connection.rpc.call('/api', endpoint, { args }, token.abort.signal)
|
||||
const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined
|
||||
const signal = callerSignal === undefined
|
||||
? token.abort.signal
|
||||
: AbortSignal.any([token.abort.signal, callerSignal])
|
||||
const result = await connection.rpc.call('/api', endpoint, { args }, signal)
|
||||
if (!mountActive(token)) throw new Error(`client api: Remote method ${endpoint} was withdrawn during invocation`)
|
||||
if (!result.ok) throw remoteFailure(endpoint, result.error)
|
||||
return parse(descriptor.result, result.value, endpoint, 'result')
|
||||
|
||||
@@ -36,6 +36,7 @@ interface ResolvedBinding {
|
||||
}
|
||||
|
||||
type ConnectionRpcResult = Awaited<ReturnType<ConnectionRpcHandler>>
|
||||
const NEVER_ABORTED_SIGNAL = new AbortController().signal
|
||||
|
||||
/** Dispatch failure produced outside the invoked business method. */
|
||||
export class TypertGatewayError extends Error {
|
||||
@@ -129,6 +130,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
}
|
||||
validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint)
|
||||
const args = descriptor.parameters.map(parameter => this.resolveParameter(parameter, request.args, endpoint))
|
||||
if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)
|
||||
const implementation = descriptor.implementation ?? descriptor.method
|
||||
const method = Reflect.get(receiver, implementation) as unknown
|
||||
if (typeof method !== 'function') {
|
||||
@@ -146,13 +148,12 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
private async dispatchRpc(
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
_signal: AbortSignal,
|
||||
signal: AbortSignal,
|
||||
): Promise<ConnectionRpcResult> {
|
||||
// Remote methods have no cancellation parameter yet, so disconnects do not cancel business work.
|
||||
return this.invokeRpc(endpoint, payload)
|
||||
return this.invokeRpc(endpoint, payload, signal)
|
||||
}
|
||||
|
||||
private async invokeRpc(endpoint: string, payload: unknown): Promise<ConnectionRpcResult> {
|
||||
private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise<ConnectionRpcResult> {
|
||||
try {
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.length !== 2 || segments[0] === '' || segments[1] === '') {
|
||||
@@ -171,6 +172,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
namespace,
|
||||
method,
|
||||
args: payload.args,
|
||||
signal,
|
||||
})
|
||||
return { ok: true, value }
|
||||
} catch (error) {
|
||||
@@ -226,9 +228,22 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
endpoint: string,
|
||||
): InvocationDescriptor {
|
||||
const names = methodParameterNames(binding.service, marker.method, endpoint)
|
||||
const signalIndex = names.indexOf('signal')
|
||||
if (signalIndex >= 0 && signalIndex !== names.length - 1) {
|
||||
throw new TypertGatewayError(
|
||||
'signature-invalid',
|
||||
endpoint,
|
||||
'SRC cancellation parameter signal must be the final parameter',
|
||||
{ field: 'signal' },
|
||||
)
|
||||
}
|
||||
const cancellation = signalIndex >= 0
|
||||
? { parameter: 'signal' as const }
|
||||
: undefined
|
||||
const businessNames = cancellation === undefined ? names : names.slice(0, -1)
|
||||
const parameters: InvocationParameterDescriptor[] = []
|
||||
const wires = new Set<string>()
|
||||
for (const name of names) {
|
||||
for (const name of businessNames) {
|
||||
const matches = this.ctx.typert.lookups.definitions()
|
||||
.filter(definition => definition.parameter === name)
|
||||
if (matches.length > 1) {
|
||||
@@ -295,6 +310,7 @@ export class TypertGatewayService extends Service implements TypertGateway {
|
||||
...(marker.method === method ? {} : { implementation: marker.method }),
|
||||
invocation: receiver,
|
||||
parameters,
|
||||
...(cancellation === undefined ? {} : { cancellation }),
|
||||
result: { mode: 'src-json' },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface InvokeRemoteRequest {
|
||||
readonly method: string
|
||||
/** Named wire values; fields must exactly match the descriptor. */
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
/** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Stable infrastructure and boundary failures emitted before or after business execution. */
|
||||
|
||||
@@ -17,11 +17,18 @@ declare module '@deepseek-ai/dsh-type-meta' {
|
||||
}
|
||||
|
||||
interface TypeRTRemoteMap {
|
||||
'goals/create': (agentId: string, request: { readonly objective: string }) => Promise<{ readonly ref: string }>
|
||||
'goals/create': (
|
||||
agentId: string,
|
||||
request: { readonly objective: string },
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ readonly ref: string }>
|
||||
}
|
||||
|
||||
interface TypeRTRemoteContextMap {
|
||||
'fixture:goals/create': (request: { readonly objective: string }) => Promise<{ readonly ref: string }>
|
||||
'fixture:goals/create': (
|
||||
request: { readonly objective: string },
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ readonly ref: string }>
|
||||
'fixture:goals/rename': (request: { readonly objective: string }) => Promise<{ readonly renamed: boolean }>
|
||||
}
|
||||
|
||||
@@ -58,6 +65,7 @@ function directDescriptor(): InvocationDescriptor {
|
||||
source: 'json',
|
||||
codec: { mode: 'strict', typeSymbol: '@fixture#CreateRequest', schema: requestSchema },
|
||||
}],
|
||||
cancellation: { parameter: 'signal' },
|
||||
result: { mode: 'strict', typeSymbol: '@fixture#CreateResult', schema: createResultSchema },
|
||||
}
|
||||
}
|
||||
@@ -114,6 +122,19 @@ describe('Client TypeRT API', () => {
|
||||
{ args: { agentId: 'agent-1', request: { objective: 'ship' } } },
|
||||
expect.any(AbortSignal),
|
||||
)
|
||||
const callerAbort = new AbortController()
|
||||
await expect(ctx.api.goals.create(
|
||||
'agent-1',
|
||||
{ objective: 'cancel me' },
|
||||
callerAbort.signal,
|
||||
)).resolves.toEqual({ ref: 'goal-1' })
|
||||
const combinedSignal = call.mock.calls.at(-1)?.[3]
|
||||
expect(combinedSignal).toBeInstanceOf(AbortSignal)
|
||||
expect(combinedSignal).not.toBe(callerAbort.signal)
|
||||
const cancellation = new Error('caller cancelled')
|
||||
callerAbort.abort(cancellation)
|
||||
expect(combinedSignal?.aborted).toBe(true)
|
||||
expect(combinedSignal?.reason).toBe(cancellation)
|
||||
await expect(ctx.api.goals.create('', { objective: 'ship' })).rejects.toThrow('rejected "agentId"')
|
||||
|
||||
call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } })
|
||||
@@ -299,10 +320,18 @@ describe('Client TypeRT API', () => {
|
||||
.mockResolvedValue({ ok: true, value: { ref: 'goal-1' } })
|
||||
const ctx = await bench(call)
|
||||
const descriptor = directDescriptor()
|
||||
const dispose = ctx.api.mount({ package: '@fixture/goals', descriptors: [descriptor] })
|
||||
const dispose = ctx.api.mount({
|
||||
package: '@fixture/goals',
|
||||
descriptors: [descriptor, contextDescriptor()],
|
||||
})
|
||||
const create = ctx.api.goals.create as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
const goals = (ctx as FixtureContext).goals
|
||||
const rename = goals.rename as unknown as (...args: unknown[]) => Promise<unknown>
|
||||
|
||||
await expect(create('agent-1')).rejects.toThrow('expected 2 argument(s), got 1')
|
||||
await expect(create('agent-1')).rejects.toThrow('expected 2 business argument(s) plus an optional AbortSignal, got 1')
|
||||
await expect(create('agent-1', { objective: 'ship' }, undefined, 'extra'))
|
||||
.rejects.toThrow('got 4')
|
||||
await expect(rename.call(goals)).rejects.toThrow('expected 1 argument(s), got 0')
|
||||
await expect((ctx as FixtureContext).goals.create({ objective: 'ship' }))
|
||||
.rejects.toThrow('no Client Context binder')
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ const emptyModel: TypertContribution['model'] = {
|
||||
class GoalService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'goals')
|
||||
readonly calls: string[] = []
|
||||
lastSignal: AbortSignal | undefined
|
||||
nextResult: unknown = undefined
|
||||
businessError: Error | undefined
|
||||
|
||||
@@ -53,8 +54,9 @@ class GoalService extends Service {
|
||||
}
|
||||
|
||||
@Remote
|
||||
create(agent: FixtureAgent, request: { readonly title: string }): unknown {
|
||||
create(agent: FixtureAgent, request: { readonly title: string }, signal: AbortSignal): unknown {
|
||||
this.calls.push('create')
|
||||
this.lastSignal = signal
|
||||
return {
|
||||
agentId: agent.id,
|
||||
title: request.title,
|
||||
@@ -224,6 +226,19 @@ class RestParameterService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
class NonFinalSignalService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'nonFinalSignal', { namespace: 'invalid-signal' })
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'nonFinalSignal')
|
||||
}
|
||||
|
||||
@Remote
|
||||
run(signal: AbortSignal, value: string): string {
|
||||
return signal.aborted ? '' : value
|
||||
}
|
||||
}
|
||||
|
||||
class WrongBindingService extends Service {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'notWrongBinding', { namespace: 'wrong-binding' })
|
||||
|
||||
@@ -334,13 +349,24 @@ describe('TypertGatewayService', () => {
|
||||
registerAgentLookup(ctx, agent)
|
||||
registerStrict(ctx, [createDescriptor()])
|
||||
const caller = ctx.extend({ fixtureScope: 'direct-caller' })
|
||||
const abort = new AbortController()
|
||||
|
||||
await expect(caller.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: ' ship ' } },
|
||||
signal: abort.signal,
|
||||
})).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-caller' })
|
||||
expect(service.calls).toEqual(['create'])
|
||||
expect(service.lastSignal).toBe(abort.signal)
|
||||
|
||||
await expect(caller.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'again' } },
|
||||
})).resolves.toEqual({ agentId: 'agent-1', title: 'again', scope: 'direct-caller' })
|
||||
expect(service.lastSignal).toBeInstanceOf(AbortSignal)
|
||||
expect(service.lastSignal?.aborted).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves strict Remote Context identity without adding a business argument', async () => {
|
||||
@@ -358,16 +384,19 @@ describe('TypertGatewayService', () => {
|
||||
})
|
||||
|
||||
it('derives SRC direct lookup and JSON parameters from marker and parameter names', async () => {
|
||||
const { ctx } = await setup()
|
||||
const { ctx, service } = await setup()
|
||||
const agent = { id: 'agent-1' }
|
||||
registerAgentLookup(ctx, agent)
|
||||
const caller = ctx.extend({ fixtureScope: 'direct-src' })
|
||||
const abort = new AbortController()
|
||||
|
||||
await expect(caller.typertGateway.invoke({
|
||||
namespace: 'goals',
|
||||
method: 'create',
|
||||
args: { agentId: 'agent-1', request: { title: 'ship' } },
|
||||
signal: abort.signal,
|
||||
})).resolves.toEqual({ agentId: 'agent-1', title: 'ship', scope: 'direct-src' })
|
||||
expect(service.lastSignal).toBe(abort.signal)
|
||||
})
|
||||
|
||||
it('does not downgrade an observed SRC lookup after its provider unloads', async () => {
|
||||
@@ -605,6 +634,7 @@ describe('TypertGatewayService', () => {
|
||||
{ plugin: DefaultParameterService, namespace: 'invalid-default', args: { value: 'x' } },
|
||||
{ plugin: DestructuredParameterService, namespace: 'invalid-destructure', args: { value: { value: 'x' } } },
|
||||
{ plugin: RestParameterService, namespace: 'invalid-rest', args: { values: ['x'] } },
|
||||
{ plugin: NonFinalSignalService, namespace: 'invalid-signal', args: { value: 'x' } },
|
||||
] as const
|
||||
for (const testCase of cases) {
|
||||
const ctx = await setupGateway()
|
||||
@@ -874,7 +904,8 @@ describe('TypertGatewayService', () => {
|
||||
expect(connection.matches?.('goals')).toBe(false)
|
||||
expect(connection.matches?.('goals/missing')).toBe(false)
|
||||
expect(connection.matches?.('legacy/list')).toBe(false)
|
||||
const signal = new AbortController().signal
|
||||
const abort = new AbortController()
|
||||
const signal = abort.signal
|
||||
const handler = connection.handler
|
||||
if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor')
|
||||
await expect(handler('goals/create', {
|
||||
@@ -883,6 +914,10 @@ describe('TypertGatewayService', () => {
|
||||
ok: true,
|
||||
value: { agentId: 'agent-1', title: 'ship', scope: 'rpc-caller' },
|
||||
})
|
||||
const service = rawGoalService(ctx)
|
||||
expect(service.lastSignal).toBe(signal)
|
||||
abort.abort(new Error('client disconnected'))
|
||||
expect(service.lastSignal?.aborted).toBe(true)
|
||||
const invalid = await handler('goals/create', { invalid: true }, signal)
|
||||
expect(invalid).toMatchObject({
|
||||
ok: false,
|
||||
@@ -904,7 +939,6 @@ describe('TypertGatewayService', () => {
|
||||
expect(result.error.message).toContain('plain-object args field')
|
||||
}
|
||||
|
||||
const service = rawGoalService(ctx)
|
||||
service.businessError = 'non-error failure' as unknown as Error
|
||||
await expect(handler('goals/fail', { args: { request: null } }, signal)).resolves.toEqual({
|
||||
ok: false,
|
||||
@@ -1099,6 +1133,7 @@ function createDescriptor(): InvocationDescriptor {
|
||||
})),
|
||||
},
|
||||
],
|
||||
cancellation: { parameter: 'signal' },
|
||||
result: strictCodec('@fixture/gateway#CreateResult', z.object({
|
||||
agentId: z.string(),
|
||||
title: z.string(),
|
||||
|
||||
@@ -963,8 +963,9 @@ class FaceAnalyzer {
|
||||
const lookups = this.lookupDeclarations()
|
||||
const lookupByHost = new Map(lookups.map(lookup => [lookup.hostSymbol, lookup]))
|
||||
const parameters: InvocationParameterModel[] = []
|
||||
let cancellation: InvocationModel['cancellation']
|
||||
const wires = new Set<string>()
|
||||
for (const parameter of method.parameters) {
|
||||
for (const [parameterIndex, parameter] of method.parameters.entries()) {
|
||||
if (!ts.isIdentifier(parameter.name)) {
|
||||
this.fail(parameter, 'Remote parameters must use identifier bindings')
|
||||
}
|
||||
@@ -973,6 +974,18 @@ class FaceAnalyzer {
|
||||
if (parameter.questionToken !== undefined) this.fail(parameter, 'Remote parameters cannot be optional')
|
||||
if (parameter.name.text === 'this') this.fail(parameter, 'Remote methods cannot declare an explicit this parameter')
|
||||
const authoredType = this.requiredType(parameter, parameter.type, 'parameter')
|
||||
const cancellationName = parameter.name.text === 'signal'
|
||||
const cancellationType = this.isGlobalAbortSignal(authoredType)
|
||||
if (cancellationName || cancellationType) {
|
||||
if (!cancellationName || !cancellationType) {
|
||||
this.fail(parameter, 'Remote cancellation must use a parameter named signal with the global AbortSignal type')
|
||||
}
|
||||
if (parameterIndex !== method.parameters.length - 1) {
|
||||
this.fail(parameter, 'Remote cancellation signal must be the final parameter')
|
||||
}
|
||||
cancellation = { parameter: 'signal' }
|
||||
continue
|
||||
}
|
||||
const hostSymbol = this.symbolAtType(authoredType)
|
||||
const lookup = hostSymbol === undefined ? undefined : lookupByHost.get(this.symbolId(hostSymbol))
|
||||
let modeled: InvocationParameterModel
|
||||
@@ -1065,6 +1078,7 @@ class FaceAnalyzer {
|
||||
invocation: receiver,
|
||||
...(scope === undefined ? {} : { scope }),
|
||||
parameters,
|
||||
...(cancellation === undefined ? {} : { cancellation }),
|
||||
result: this.remoteBoundary(
|
||||
resultType,
|
||||
`${registration.name}#${binding.namespace}/${exportedMethod}:result`,
|
||||
@@ -1181,6 +1195,13 @@ class FaceAnalyzer {
|
||||
return resultType
|
||||
}
|
||||
|
||||
private isGlobalAbortSignal(type: ts.TypeNode): boolean {
|
||||
const symbol = this.symbolAtType(type)
|
||||
if (symbol?.name !== 'AbortSignal') return false
|
||||
return symbol.declarations?.some(declaration =>
|
||||
isStandardLibraryFile(declaration.getSourceFile().fileName)) === true
|
||||
}
|
||||
|
||||
private lookupDeclarations(): readonly StaticLookupDeclaration[] {
|
||||
if (this.staticLookups !== undefined) return this.staticLookups
|
||||
const byKey = new Map<string, StaticLookupDeclaration>()
|
||||
|
||||
@@ -315,6 +315,9 @@ export class FaceModelEmitter {
|
||||
lines.push(' },')
|
||||
})
|
||||
lines.push(' ],')
|
||||
if (invocation.cancellation !== undefined) {
|
||||
lines.push(" cancellation: { parameter: 'signal' },")
|
||||
}
|
||||
lines.push(` result: ${indent(strictCodec(
|
||||
invocation.result,
|
||||
schemas.boundary(resultBoundaryKey(invocation)),
|
||||
@@ -459,6 +462,7 @@ export class FaceModelEmitter {
|
||||
const parameters = invocation.parameters.filter(parameter =>
|
||||
!scoped || invocation.invocation.kind === 'context' || parameter.wire !== invocation.scope?.wire).map(parameter =>
|
||||
`${safeIdentifier(parameter.wire)}: ${this.renderer.renderType(parameter.boundary.type, referenceNames)}`)
|
||||
if (invocation.cancellation !== undefined) parameters.push('signal?: AbortSignal')
|
||||
const result = this.renderer.renderType(invocation.result.type, referenceNames)
|
||||
return `(${parameters.join(', ')}) => Promise<${result}>`
|
||||
}
|
||||
|
||||
@@ -140,6 +140,9 @@ export interface InvocationModel {
|
||||
readonly wire: string
|
||||
}
|
||||
readonly parameters: readonly InvocationParameterModel[]
|
||||
readonly cancellation?: {
|
||||
readonly parameter: 'signal'
|
||||
}
|
||||
readonly result: RemoteBoundaryModel
|
||||
readonly location: SourceLocation
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ export class GoalService {
|
||||
readonly typertGateway = bindTypeRTGateway(this, 'goals')
|
||||
|
||||
@Remote
|
||||
async create(agent: Agent, request: CreateGoalRequest): Promise<CreateGoalResult> {
|
||||
async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise<CreateGoalResult> {
|
||||
signal.throwIfAborted()
|
||||
return { ref: `${agent.id}:${request.title}` }
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ interface RuntimeSchema {
|
||||
|
||||
interface RuntimeDescriptor {
|
||||
readonly id: string
|
||||
readonly cancellation?: { readonly parameter: 'signal' }
|
||||
readonly parameters: readonly {
|
||||
readonly wire: string
|
||||
readonly codec: { readonly schema: RuntimeSchema }
|
||||
@@ -83,6 +84,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
|
||||
boundary: { typeSymbol: '@fixture/remote/types#CreateGoalRequest' },
|
||||
},
|
||||
],
|
||||
cancellation: { parameter: 'signal' },
|
||||
result: { typeSymbol: '@fixture/remote/types#CreateGoalResult' },
|
||||
})
|
||||
expect(model.invocations[1]).toMatchObject({
|
||||
@@ -107,12 +109,12 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
|
||||
|
||||
expect(artifact?.js).toContain('invocations: [')
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'goals/create': (agentId: AgentId, request: CreateGoalRequest) => Promise<CreateGoalResult>",
|
||||
"'goals/create': (agentId: AgentId, request: CreateGoalRequest, signal?: AbortSignal) => Promise<CreateGoalResult>",
|
||||
)
|
||||
expect(artifact?.remote?.dts).toContain('interface TypeRTRemoteNamespace$676f616c73 {\n create:')
|
||||
expect(artifact?.remote?.dts).toContain("'goals': TypeRTRemoteNamespace$676f616c73")
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'agent:goals/create': (request: CreateGoalRequest) => Promise<CreateGoalResult>",
|
||||
"'agent:goals/create': (request: CreateGoalRequest, signal?: AbortSignal) => Promise<CreateGoalResult>",
|
||||
)
|
||||
expect(artifact?.remote?.dts).toContain(
|
||||
"'agent:goals/rename': (request: RenameGoalRequest) => Promise<RenameGoalResult>",
|
||||
@@ -124,6 +126,7 @@ describe('Remote model generation', { timeout: 60_000 }, () => {
|
||||
const generated = await import(`data:text/javascript,${encodeURIComponent(executable)}`) as RuntimeRemoteModule
|
||||
expect(generated.TYPERT_REMOTE.package).toBe('@fixture/remote')
|
||||
const create = generated.TYPERT_REMOTE.descriptors[0]
|
||||
expect(create?.cancellation).toEqual({ parameter: 'signal' })
|
||||
expect(create?.parameters[1]?.codec.schema.safeParse({ title: 'ship' }).success).toBe(true)
|
||||
expect(create?.parameters[1]?.codec.schema.safeParse({ title: 1 }).success).toBe(false)
|
||||
expect(create?.result.schema.safeParse({ ref: 'goal-1' }).success).toBe(true)
|
||||
@@ -234,8 +237,8 @@ export type GenericResult = {
|
||||
edit: (source: string) => source
|
||||
.replace('export class GoalService', 'export abstract class GoalService')
|
||||
.replace(
|
||||
' async create(agent: Agent, request: CreateGoalRequest): Promise<CreateGoalResult> {\n return { ref: `${agent.id}:${request.title}` }\n }',
|
||||
' abstract create(agent: Agent, request: CreateGoalRequest): Promise<CreateGoalResult>',
|
||||
' async create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise<CreateGoalResult> {\n signal.throwIfAborted()\n return { ref: `${agent.id}:${request.title}` }\n }',
|
||||
' abstract create(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): Promise<CreateGoalResult>',
|
||||
),
|
||||
message: 'Remote methods must have a concrete implementation',
|
||||
},
|
||||
@@ -267,6 +270,24 @@ export type GenericResult = {
|
||||
edit: (source: string) => source.replace('request: CreateGoalRequest', 'request?: CreateGoalRequest'),
|
||||
message: 'Remote parameters cannot be optional',
|
||||
},
|
||||
{
|
||||
name: 'wrong cancellation type',
|
||||
edit: (source: string) => source.replace('signal: AbortSignal', 'signal: string'),
|
||||
message: 'cancellation must use a parameter named signal with the global AbortSignal type',
|
||||
},
|
||||
{
|
||||
name: 'wrong cancellation name',
|
||||
edit: (source: string) => source.replace('signal: AbortSignal', 'abort: AbortSignal'),
|
||||
message: 'cancellation must use a parameter named signal with the global AbortSignal type',
|
||||
},
|
||||
{
|
||||
name: 'non-final cancellation',
|
||||
edit: (source: string) => source.replace(
|
||||
'agent: Agent, request: CreateGoalRequest, signal: AbortSignal',
|
||||
'agent: Agent, signal: AbortSignal, request: CreateGoalRequest',
|
||||
),
|
||||
message: 'cancellation signal must be the final parameter',
|
||||
},
|
||||
])('rejects $name', ({ edit, message }) => {
|
||||
const root = copyFixture()
|
||||
editFile(root, 'packages/remote/src/index.ts', edit)
|
||||
@@ -399,12 +420,14 @@ declare const create: TypeRTRemoteMap['goals/create']
|
||||
declare const createScoped: TypeRTRemoteContextMap['agent:goals/create']
|
||||
declare const rename: TypeRTRemoteContextMap['agent:goals/rename']
|
||||
const created: Promise<CreateGoalResult> = create('agent-1', { title: 'ship' })
|
||||
const cancellable: Promise<CreateGoalResult> = create('agent-1', { title: 'ship' }, new AbortController().signal)
|
||||
const createdScoped: Promise<CreateGoalResult> = createScoped({ title: 'ship' })
|
||||
const renamed: Promise<RenameGoalResult> = rename({ ref: 'goal-1', title: 'land' })
|
||||
declare const ctx: { api: TypeRTRemoteNamespaceMap }
|
||||
const navigated: Promise<CreateGoalResult> = ctx.api.goals.create('agent-1', { title: 'navigate' })
|
||||
void contribution
|
||||
void created
|
||||
void cancellable
|
||||
void createdScoped
|
||||
void renamed
|
||||
void navigated
|
||||
|
||||
@@ -226,6 +226,12 @@ function requireInvocation(pkgName: string, value: unknown): void {
|
||||
parameters.set(wire, parameter)
|
||||
requireStrictCodec(pkgName, parameter.codec, `invocation "${id}" parameter codec`)
|
||||
}
|
||||
if (invocation.cancellation !== undefined) {
|
||||
const cancellation = requireObject(pkgName, invocation.cancellation, `invocation "${id}" cancellation`)
|
||||
if (cancellation.parameter !== 'signal') {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" cancellation parameter must be "signal"`)
|
||||
}
|
||||
}
|
||||
if (invocation.scope !== undefined) {
|
||||
if (receiver.kind !== 'direct') {
|
||||
throw new Error(`typert-loader: ${pkgName} invocation "${id}" Context receiver cannot declare a direct scope projection`)
|
||||
|
||||
@@ -83,6 +83,7 @@ function invocationTypertSource(pkgName: string): string {
|
||||
' name: \'request\', wire: \'request\', source: \'json\',',
|
||||
` codec: { mode: 'strict', typeSymbol: '${pkgName}/types#Request', schema: Text },`,
|
||||
' }],',
|
||||
" cancellation: { parameter: 'signal' },",
|
||||
` result: { mode: 'strict', typeSymbol: '${pkgName}/types#Result', schema: Text },`,
|
||||
' sourceLocation: { file: \'src/index.ts\', line: 8, column: 3 },',
|
||||
' }],',
|
||||
@@ -157,6 +158,7 @@ describe('typert loader', () => {
|
||||
id: '@fixture/invocation#goals/create',
|
||||
invocation: { kind: 'direct' },
|
||||
parameters: [{ wire: 'request', source: 'json' }],
|
||||
cancellation: { parameter: 'signal' },
|
||||
sourceLocation: { file: 'src/index.ts', line: 8, column: 3 },
|
||||
})
|
||||
expect(descriptor?.parameters[0]?.codec.mode).toBe('strict')
|
||||
@@ -502,6 +504,9 @@ describe('validateTypertManifest', () => {
|
||||
const descriptor = strictInvocation()
|
||||
const manifest = { ...base, invocations: [descriptor] }
|
||||
expect(validateTypertManifest('pkg', manifest)).toBe(manifest)
|
||||
const cancellable = { ...descriptor, cancellation: { parameter: 'signal' } }
|
||||
expect(validateTypertManifest('pkg', { ...base, invocations: [cancellable] }).invocations)
|
||||
.toEqual([cancellable])
|
||||
const scoped = {
|
||||
...descriptor,
|
||||
scope: { context: 'agent', wire: 'agentId' },
|
||||
@@ -526,6 +531,14 @@ describe('validateTypertManifest', () => {
|
||||
...base,
|
||||
invocations: [{ ...descriptor, result: { mode: 'src-json' } }],
|
||||
})).toThrow('result codec must use a strict codec')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...base,
|
||||
invocations: [{ ...descriptor, cancellation: null }],
|
||||
})).toThrow('cancellation must be an object')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...base,
|
||||
invocations: [{ ...descriptor, cancellation: { parameter: 'abort' } }],
|
||||
})).toThrow('cancellation parameter must be "signal"')
|
||||
expect(() => validateTypertManifest('pkg', {
|
||||
...base,
|
||||
invocations: [{ ...descriptor, result: { mode: 'strict', typeSymbol: 'pkg#Result', schema: zodish } }],
|
||||
|
||||
@@ -562,6 +562,9 @@ function validateInvocation(descriptor: InvocationDescriptor): void {
|
||||
}
|
||||
validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`)
|
||||
}
|
||||
if (descriptor.cancellation !== undefined && descriptor.cancellation.parameter !== 'signal') {
|
||||
throw new Error(`typert: invocation "${descriptor.id}" cancellation parameter must be "signal"`)
|
||||
}
|
||||
if (descriptor.scope !== undefined) {
|
||||
if (descriptor.invocation.kind !== 'direct') {
|
||||
throw new Error(`typert: invocation "${descriptor.id}" Context receiver cannot declare a direct scope projection`)
|
||||
|
||||
@@ -411,6 +411,7 @@ describe('TypertRegistry', () => {
|
||||
...invocation('@fixture/remote#strict'),
|
||||
implementation: 'remoteExportCreate',
|
||||
parameters: [{ name: 'request', wire: 'request', source: 'json', codec: strict }],
|
||||
cancellation: { parameter: 'signal' },
|
||||
result: strict,
|
||||
}
|
||||
const dispose = ctx.typert.remotes.register({ package: '@fixture/strict', descriptors: [strictInvocation] })
|
||||
@@ -420,6 +421,10 @@ describe('TypertRegistry', () => {
|
||||
[{ ...invocation(), id: '' }, 'invocation id'],
|
||||
[{ ...invocation(), namespace: 'bad/name' }, 'namespace'],
|
||||
[{ ...invocation(), implementation: 'bad/name' }, 'implementation method'],
|
||||
[{
|
||||
...invocation(),
|
||||
cancellation: { parameter: 'abort' } as unknown as { readonly parameter: 'signal' },
|
||||
}, 'cancellation parameter'],
|
||||
[{
|
||||
...invocation(),
|
||||
parameters: [
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/typert/type-meta/README.md
|
||||
README.md: 9dd8dadd07b219c7471c8851262958d4d9e96a43
|
||||
README.zh.md: 5716f56d988c6d2dd9cd237346c3b02ec9ae7c4e
|
||||
README.md: 95716446c01c7fd510cdf55a82509b5b8af6f3ae
|
||||
README.zh.md: 0d30b3122265d9bb3caa289345f843fe67377be3
|
||||
|
||||
@@ -11,6 +11,8 @@ Compiler-independent declarations shared by business packages, generated TypeRT
|
||||
- `bindTypeRTGateway(this, serviceKey, options?)` creates the visible, frozen binding between a Service instance, its exact Cordis key, and its wire namespace.
|
||||
- `remoteMethods(service)` returns a detached declaration-order snapshot used by the Gateway's SRC fallback.
|
||||
|
||||
A Host method opts into cooperative cancellation by declaring `signal: AbortSignal` as its final parameter. `InvocationDescriptor.cancellation` records that reserved injection point; the signal never becomes a JSON parameter or lookup field. SRC recognizes the final parameter name, while strict generation also verifies the global `AbortSignal` type.
|
||||
|
||||
Decorator initializers retain markers in a module-private `WeakMap` keyed by the Service prototype. They do not add constructor symbols, prototype properties, parameter metadata, or runtime reflection fields. The Service opts in explicitly through its `typertGateway` binding field.
|
||||
|
||||
## TypeRT protocol
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
- `bindTypeRTGateway(this, serviceKey, options?)` 在服务实例、其准确的 Cordis key 与协议命名空间之间创建可见且冻结的绑定。
|
||||
- `remoteMethods(service)` 返回按声明顺序排列、与内部状态分离的快照,供 Gateway 的 SRC 回退路径使用。
|
||||
|
||||
Host 方法通过将 `signal: AbortSignal` 声明为最后一个参数来启用协作式取消。`InvocationDescriptor.cancellation` 记录这个保留的注入点;signal 绝不会成为 JSON 参数或 lookup 字段。SRC 识别末位参数名,严格生成还会校验它是否具有全局 `AbortSignal` 类型。
|
||||
|
||||
装饰器初始化器将标记保存在以服务 prototype 为键的模块私有 `WeakMap` 中。它们不会在构造函数上添加 symbol,也不会添加 prototype 属性、参数元数据或运行时反射字段。服务通过自身的 `typertGateway` 绑定字段显式接入。
|
||||
|
||||
## TypeRT 协议
|
||||
|
||||
@@ -157,6 +157,11 @@ export interface InvocationDescriptor {
|
||||
}
|
||||
/** Ordered business parameters. */
|
||||
readonly parameters: readonly InvocationParameterDescriptor[]
|
||||
/** Transport cancellation injected after business parameters instead of entering wire args. */
|
||||
readonly cancellation?: {
|
||||
/** Reserved final Host method parameter. */
|
||||
readonly parameter: 'signal'
|
||||
}
|
||||
/** Codec for the resolved method result. */
|
||||
readonly result: TypeRTCodec
|
||||
/** Source declaration used only for diagnostics. */
|
||||
|
||||
Reference in New Issue
Block a user