fix(typert): close remote gateway review gaps
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md
|
||||||
2026-08-02-typert-remote-method-calls.md: 4268539ecf0d40a9e8080e0571992cc2c5d724af
|
2026-08-02-typert-remote-method-calls.md: 552e910b403312c7c7a1cec3a14c0dc1f9cc4380
|
||||||
2026-08-02-typert-remote-method-calls.zh.md: f9f426f2fb80c74cb9ebaef15e801ccfcf67e027
|
2026-08-02-typert-remote-method-calls.zh.md: 18b8c1687d2c01aa23bb7cb9402fccf85fec333d
|
||||||
@@ -452,7 +452,7 @@ The Gateway registers only its ownership matcher and RPC handler with Connection
|
|||||||
|
|
||||||
The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. `@RemoteContext('agent')` remains the distinct scoped-receiver mode.
|
The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.api.goals.create(agentId, request)` and `agentCtx.goals.create(request)`. `@RemoteContext('agent')` remains the distinct scoped-receiver mode.
|
||||||
|
|
||||||
Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, cancellation, retries, idempotency, and cross-version protocol compatibility remain outside this decision.
|
Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision.
|
||||||
|
|
||||||
## Alternatives considered
|
## Alternatives considered
|
||||||
|
|
||||||
|
|||||||
@@ -452,7 +452,7 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H
|
|||||||
|
|
||||||
已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。
|
已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client API → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.api.goals.create(agentId, request)` 与 `agentCtx.goals.create(request)`。`@RemoteContext('agent')` 仍是独立的 scoped receiver 模式。
|
||||||
|
|
||||||
Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、取消、重试、幂等及跨版本协议兼容均不属于本决策。
|
Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。
|
||||||
|
|
||||||
## Alternatives considered
|
## Alternatives considered
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,22 @@ describe('GoalService creation and replay', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('GoalService mutations', () => {
|
describe('GoalService mutations', () => {
|
||||||
|
it('exposes the supported mutation sequence through Remote wrappers', async () => {
|
||||||
|
const { ctx, agent } = await harness()
|
||||||
|
const created = ctx.goals.remoteExportCreate(agent, { objective: 'remote lifecycle' })
|
||||||
|
const edited = ctx.goals.remoteExportEdit(agent, created.ref, { objective: 'edited remotely' })
|
||||||
|
const paused = ctx.goals.remoteExportPause(agent, edited)
|
||||||
|
const resumed = ctx.goals.remoteExportResume(agent, paused)
|
||||||
|
const completed = ctx.goals.remoteExportComplete(agent, resumed)
|
||||||
|
const cleared = ctx.goals.remoteExportClear(agent, completed)
|
||||||
|
|
||||||
|
expect(edited).toMatchObject({ objective: 'edited remotely', revision: 2 })
|
||||||
|
expect(paused).toMatchObject({ phase: 'paused', revision: 3 })
|
||||||
|
expect(resumed).toMatchObject({ phase: 'active', revision: 4 })
|
||||||
|
expect(completed).toMatchObject({ phase: 'complete', revision: 5 })
|
||||||
|
expect(cleared).toEqual({ id: created.ref.id, revision: 6 })
|
||||||
|
})
|
||||||
|
|
||||||
it('edits with compare-and-set revisions and rejects empty edits', async () => {
|
it('edits with compare-and-set revisions and rejects empty edits', async () => {
|
||||||
const { ctx, agent } = await harness()
|
const { ctx, agent } = await harness()
|
||||||
const created = ctx.goals.create(agent, { objective: 'old', maxGoalRounds: 4 })
|
const created = ctx.goals.create(agent, { objective: 'old', maxGoalRounds: 4 })
|
||||||
|
|||||||
@@ -998,14 +998,16 @@ describe('TypertGatewayService', () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
expect(invalid.status).toBe(200)
|
expect(invalid.status).toBe(200)
|
||||||
await expect(invalid.json()).resolves.toMatchObject({
|
const invalidBody = await invalid.json() as unknown
|
||||||
|
expect(invalidBody).toMatchObject({
|
||||||
type: 'server-response',
|
type: 'server-response',
|
||||||
rpcId: 'rpc-invalid',
|
rpcId: 'rpc-invalid',
|
||||||
result: {
|
result: {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { code: 'internal', message: expect.stringContaining('plain-object args field') },
|
error: { code: 'internal' },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
expect(JSON.stringify(invalidBody)).toContain('plain-object args field')
|
||||||
|
|
||||||
await removeStrict()
|
await removeStrict()
|
||||||
strictActive = false
|
strictActive = false
|
||||||
@@ -1020,14 +1022,16 @@ describe('TypertGatewayService', () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
expect(withdrawn.status).toBe(200)
|
expect(withdrawn.status).toBe(200)
|
||||||
await expect(withdrawn.json()).resolves.toMatchObject({
|
const withdrawnBody = await withdrawn.json() as unknown
|
||||||
|
expect(withdrawnBody).toMatchObject({
|
||||||
type: 'server-response',
|
type: 'server-response',
|
||||||
rpcId: 'rpc-withdrawn',
|
rpcId: 'rpc-withdrawn',
|
||||||
result: {
|
result: {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { code: 'internal', message: expect.stringContaining('strict definition was withdrawn') },
|
error: { code: 'internal' },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
expect(JSON.stringify(withdrawnBody)).toContain('strict definition was withdrawn')
|
||||||
|
|
||||||
const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { method: 'POST' })
|
const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { method: 'POST' })
|
||||||
expect(unclaimed.status).toBe(404)
|
expect(unclaimed.status).toBe(404)
|
||||||
|
|||||||
@@ -562,7 +562,8 @@ function validateInvocation(descriptor: InvocationDescriptor): void {
|
|||||||
}
|
}
|
||||||
validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`)
|
validateCodec(parameter.codec, `${descriptor.id} parameter ${parameter.name}`)
|
||||||
}
|
}
|
||||||
if (descriptor.cancellation !== undefined && descriptor.cancellation.parameter !== 'signal') {
|
const cancellation = descriptor.cancellation as { readonly parameter: string } | undefined
|
||||||
|
if (cancellation !== undefined && cancellation.parameter !== 'signal') {
|
||||||
throw new Error(`typert: invocation "${descriptor.id}" cancellation parameter must be "signal"`)
|
throw new Error(`typert: invocation "${descriptor.id}" cancellation parameter must be "signal"`)
|
||||||
}
|
}
|
||||||
if (descriptor.scope !== undefined) {
|
if (descriptor.scope !== undefined) {
|
||||||
|
|||||||
@@ -601,6 +601,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
|
|||||||
'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts',
|
'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts',
|
||||||
'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
|
'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
|
||||||
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
|
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
|
||||||
|
'packages/client/remotes/tests/built-lib.e2e.ts',
|
||||||
// The worker-entry packages' built bundles: the only automated proof
|
// The worker-entry packages' built bundles: the only automated proof
|
||||||
// that lib/index.js resolves its sibling lib/worker.cjs under plain node
|
// that lib/index.js resolves its sibling lib/worker.cjs under plain node
|
||||||
// (the e2e lane runs unbuilt, so these files self-skip there).
|
// (the e2e lane runs unbuilt, so these files self-skip there).
|
||||||
|
|||||||
+5
-25
@@ -3,8 +3,7 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||||
import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts'
|
import { resolvePwshPath } from './packages/bash/pwsh-local/src/resolve.ts'
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
import ts from 'typescript'
|
import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts'
|
||||||
import { vitestExecArgv } from './vitest.shared.ts'
|
|
||||||
import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts'
|
import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts'
|
||||||
|
|
||||||
// Prints exact `path:line:col` records for every uncovered statement, branch
|
// Prints exact `path:line:col` records for every uncovered statement, branch
|
||||||
@@ -18,29 +17,6 @@ const uncoveredLocationsReporter = fileURLToPath(new URL('./scripts/coverage-unc
|
|||||||
// map applies to every test file. paths must win over package exports so built
|
// map applies to every test file. paths must win over package exports so built
|
||||||
// lib/ never loads a second module-singleton copy.
|
// lib/ never loads a second module-singleton copy.
|
||||||
const pathsPlugin = (): ReturnType<typeof tsconfigPaths> => tsconfigPaths({ projects: ['./tsconfig.base.json'] })
|
const pathsPlugin = (): ReturnType<typeof tsconfigPaths> => tsconfigPaths({ projects: ['./tsconfig.base.json'] })
|
||||||
const decoratorSyntax = /^\s*@[A-Za-z_$][\w$]*/m
|
|
||||||
|
|
||||||
const standardDecoratorPlugin = () => ({
|
|
||||||
name: 'dsh-standard-decorators',
|
|
||||||
enforce: 'pre' as const,
|
|
||||||
transform(code: string, id: string) {
|
|
||||||
const file = id.split('?', 1)[0]!
|
|
||||||
if (!/\.[cm]?tsx?$/.test(file) || !decoratorSyntax.test(code)) return
|
|
||||||
const result = ts.transpileModule(code, {
|
|
||||||
fileName: file,
|
|
||||||
compilerOptions: {
|
|
||||||
target: ts.ScriptTarget.ES2024,
|
|
||||||
module: ts.ModuleKind.ESNext,
|
|
||||||
jsx: file.endsWith('x') ? ts.JsxEmit.ReactJSX : undefined,
|
|
||||||
sourceMap: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
code: result.outputText.replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'),
|
|
||||||
map: result.sourceMapText,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const windowsUnsupportedPackages = process.platform === 'win32'
|
const windowsUnsupportedPackages = process.platform === 'win32'
|
||||||
? [
|
? [
|
||||||
@@ -203,6 +179,10 @@ export default defineConfig({
|
|||||||
'packages/client/hmr/src/invariant.ts',
|
'packages/client/hmr/src/invariant.ts',
|
||||||
'packages/client/connection/src/index.ts',
|
'packages/client/connection/src/index.ts',
|
||||||
'packages/client/connection/src/http-bridge.ts',
|
'packages/client/connection/src/http-bridge.ts',
|
||||||
|
// This assembly imports generated Host-for-Client code that exists
|
||||||
|
// only in lib; the post-build built-bin smoke executes both entries.
|
||||||
|
'packages/client/remotes/src/index.ts',
|
||||||
|
'packages/client/remotes/src/client/index.ts',
|
||||||
// Slash/command/input round: per-file gaps deferred with the same
|
// Slash/command/input round: per-file gaps deferred with the same
|
||||||
// client-lane debt. TODO(gui): cover and remove with the lane above.
|
// client-lane debt. TODO(gui): cover and remove with the lane above.
|
||||||
'packages/client/connection/src/client/fixture.ts',
|
'packages/client/connection/src/client/fixture.ts',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
import { vitestExecArgv } from './vitest.shared.ts'
|
import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts'
|
||||||
|
|
||||||
// Real-API suite, separate because it spends tokens. Each test self-skips without
|
// Real-API suite, separate because it spends tokens. Each test self-skips without
|
||||||
// its provider credential for keyless CI; credentialed workflows preflight the
|
// its provider credential for keyless CI; credentialed workflows preflight the
|
||||||
@@ -36,7 +36,7 @@ export default defineConfig({
|
|||||||
// Built-artifact e2e suites are unaffected: their built-ness lives in
|
// Built-artifact e2e suites are unaffected: their built-ness lives in
|
||||||
// subprocesses and createRequire lookups, which bypass vite resolution
|
// subprocesses and createRequire lookups, which bypass vite resolution
|
||||||
// entirely.
|
// entirely.
|
||||||
plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })],
|
plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] }), standardDecoratorPlugin()],
|
||||||
test: {
|
test: {
|
||||||
execArgv: vitestExecArgv,
|
execArgv: vitestExecArgv,
|
||||||
setupFiles: ['./scripts/test-invariants.ts'],
|
setupFiles: ['./scripts/test-invariants.ts'],
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
const decoratorSyntax = /^\s*@[A-Za-z_$][\w$]*/m
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Worker arguments that keep process-wide Web Storage from shadowing jsdom storage.
|
* Worker arguments that keep process-wide Web Storage from shadowing jsdom storage.
|
||||||
* Node lists the positive spelling in `allowedNodeEnvironmentFlags` for this negatable flag.
|
* Node lists the positive spelling in `allowedNodeEnvironmentFlags` for this negatable flag.
|
||||||
*/
|
*/
|
||||||
export const vitestExecArgv = process.allowedNodeEnvironmentFlags.has('--webstorage') ? ['--no-webstorage'] : []
|
export const vitestExecArgv = process.allowedNodeEnvironmentFlags.has('--webstorage') ? ['--no-webstorage'] : []
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform standard TypeScript decorators before Vite's default parser sees source files.
|
||||||
|
* @returns a pre-transform Vite plugin shared by source-mode test configurations.
|
||||||
|
*/
|
||||||
|
export function standardDecoratorPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'dsh-standard-decorators',
|
||||||
|
enforce: 'pre' as const,
|
||||||
|
transform(code: string, id: string) {
|
||||||
|
const file = id.split('?', 1)[0]!
|
||||||
|
if (!/\.[cm]?tsx?$/.test(file) || !decoratorSyntax.test(code)) return
|
||||||
|
const result = ts.transpileModule(code, {
|
||||||
|
fileName: file,
|
||||||
|
compilerOptions: {
|
||||||
|
target: ts.ScriptTarget.ES2024,
|
||||||
|
module: ts.ModuleKind.ESNext,
|
||||||
|
jsx: file.endsWith('x') ? ts.JsxEmit.ReactJSX : undefined,
|
||||||
|
sourceMap: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
code: result.outputText
|
||||||
|
.replace(
|
||||||
|
/^(\s*)(__esDecorate\()/gmu,
|
||||||
|
'$1/* v8 ignore next -- compiler-synthetic decorator accessors have no source behavior */ $2',
|
||||||
|
)
|
||||||
|
.replace(/\n?\/\/# sourceMappingURL=.*$/u, '\n'),
|
||||||
|
map: result.sourceMapText,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { availableParallelism } from 'node:os'
|
import { availableParallelism } from 'node:os'
|
||||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
import { vitestExecArgv } from './vitest.shared.ts'
|
import { standardDecoratorPlugin, vitestExecArgv } from './vitest.shared.ts'
|
||||||
|
|
||||||
const DEFAULT_SNAPSHOT_MAX_CONCURRENCY = 5
|
const DEFAULT_SNAPSHOT_MAX_CONCURRENCY = 5
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export default defineConfig({
|
|||||||
// Same resolution note as vitest.config.ts: bare workspace names resolve
|
// Same resolution note as vitest.config.ts: bare workspace names resolve
|
||||||
// through the tsconfig.base.json paths facade; the native option cannot do
|
// through the tsconfig.base.json paths facade; the native option cannot do
|
||||||
// this (the root tsconfig is a solution file with no paths).
|
// this (the root tsconfig is a solution file with no paths).
|
||||||
plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })],
|
plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] }), standardDecoratorPlugin()],
|
||||||
test: {
|
test: {
|
||||||
execArgv: vitestExecArgv,
|
execArgv: vitestExecArgv,
|
||||||
setupFiles: ['./scripts/test-invariants.ts'],
|
setupFiles: ['./scripts/test-invariants.ts'],
|
||||||
|
|||||||
Reference in New Issue
Block a user