Skip to content

fix: unify i18n service discovery, handler resolution, and plugin auto-loading - #903

Merged
hotlong merged 4 commits into
mainfrom
copilot/fix-i18n-service-consistency
Mar 11, 2026
Merged

fix: unify i18n service discovery, handler resolution, and plugin auto-loading#903
hotlong merged 4 commits into
mainfrom
copilot/fix-i18n-service-consistency

Conversation

CopilotAI commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

getDiscoveryInfo() used synchronous getServicesMap() to check service availability, while handleI18n() (and all other handlers) used the async resolveService() fallback chain (getServiceAsync → getService → context.getService → services Map). A service registered via an async factory would be reported as "unavailable" in discovery but fully functional via the handler. The locale field was also hardcoded rather than sourced from the actual i18n service. Additionally, AppPlugin did not coordinate i18n translation loading from app bundles, leaving server/dev/mock environments with inconsistent i18n state.

Core fix — http-dispatcher.ts

  • getDiscoveryInfo() is now async and resolves all 15 services via Promise.all(resolveService(...)) — same chain handlers use
  • locale is populated from i18nSvc.getDefaultLocale() / i18nSvc.getLocales() when available, with sensible defaults when not
// Before: sync Map lookup — misses async-factory servicesconsthasI18n=!!services[CoreServiceName.enum.i18n];// After: same resolution chain as handleI18n()const[authSvc, ...,i18nSvc, ...]=awaitPromise.all([this.resolveService(CoreServiceName.enum.auth),
...
this.resolveService(CoreServiceName.enum.i18n),
...
]);

AppPlugin i18n auto-loading — app-plugin.ts

  • AppPlugin.start() now auto-loads translation bundles from app configs (translations array) into the kernel's i18n service
  • Sets default locale from i18n.defaultLocale config via i18nService.setDefaultLocale()
  • Gracefully skips when no i18n service is registered (no errors, debug log only)
  • Per-locale error handling: a failure loading one locale does not block others

Callers updated to await

  • Adapters: Hono, Express, Fastify, Next.js, NestJS, Nuxt, SvelteKit
  • Plugins: dispatcher-plugin.ts, plugin-msw
  • Internal: dispatch() root discovery path

Documentation

  • Added comprehensive i18n section to content/docs/guides/kernel-services.mdx covering service registration patterns across production/dev/mock environments, discovery consistency, AppPlugin auto-loading behavior, and REST endpoint reference

Tests

  • 17 new tests covering: async-factory detection in discovery, locale populated from i18n service, discovery↔handler agreement, fallback defaults, dev stub behavior, MSW catch-all dispatch, context-based resolution, 501 consistency, AppPlugin translation auto-loading (6 tests), and error handling
  • NestJS mock updated from mockReturnValuemockResolvedValue; test made async
  • All 138 runtime + 190 adapter + 6668 spec tests pass
Original prompt

This section details on the original issue you should resolve

<issue_title>i18n 服务注册与状态不一致问题评估</issue_title>
<issue_description>### 背景
发现国际化服务(i18n)在 ObjectStack spec 项目中存在如下核心问题:

  1. Discovery API 检测 i18n 状态和实际 REST 路由处理使用了不同的查找逻辑,导致有时发现状态为 "unavailable",但接口却可用。
  2. handleI18n 路由查找 i18n 服务时采用异步多层 fallback,而 Discovery 只查同步 Map,导致状态和功能不一致。
  3. 插件注册阶段没有统一 i18n 的加载规范,导致不同环境下中断或状态混淆(如未注册则直接 501/404)。
  4. server/dev/mock 三种运行模式下 i18n 的数据加载策略和注册机制并未融合。

建议

  • 统一 i18n 服务的注册与状态检测逻辑,Discovery 和路由应使用同等查找链。
  • 明确插件注册规范,在 AppPlugin/ConsolePlugin 注册时自动协调 i18n 数据加载。
  • 增强 i18n 相关测试覆盖 server/MSW/mock 环境。
  • 补充文档和 API 文档,明确 i18n 接口行为的差异与期望。

影响

对 spec 项目的服务发现、客户端适配、以及多环境部署有直接影响,影响国际化的可靠性和一致性。


(本 issue 由 Copilot 自动生成,建议由维护者补充具体场景和采集现状。)
</issue_description>

Comments on the Issue (you are @copilot in this section)


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

@vercel

vercelBot commented Mar 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
objectstack-playReadyReadyPreview, CommentMar 11, 2026 4:13am
specReadyReadyPreview, CommentMar 11, 2026 4:13am

Request Review

- Make getDiscoveryInfo() async using resolveService() fallback chain
- Populate locale from actual i18n service instead of hardcoded values
- Update all framework adapters and plugins to await getDiscoveryInfo()
- Add discovery-handler consistency tests
- Update CHANGELOG.md
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
CopilotAI changed the title [WIP] Fix i18n service registration and status inconsistencyfix: unify i18n service discovery and handler resolution logicMar 11, 2026
@hotlong

Copy link
Copy Markdown
Contributor

@copilot 仔细评估是否已经完成了任务中要求的所有开发

- AppPlugin.start() auto-loads translation bundles into i18n service
- Added 10 new tests: AppPlugin i18n loading + environment consistency
- Added i18n service registration guide to kernel-services.mdx
- Updated CHANGELOG.md with Added section
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>

CopilotAI commented Mar 11, 2026

Copy link
Copy Markdown
ContributorAuthor

@copilot 仔细评估是否已经完成了任务中要求的所有开发

经过全面评估,原始 issue 中的 4 项要求现已全部完成:

1. 统一 Discovery 与路由查找链getDiscoveryInfo() 改为 async,使用与 handleI18n() 相同的 resolveService() 四层回退链(getServiceAsync → getService → context.getService → services Map),7 个适配器 + 2 个插件已同步更新。(58df19f)

2. AppPlugin/ConsolePlugin 自动协调 i18n 数据加载AppPlugin.start() 新增 loadTranslations() 方法,自动从 app config 的 translations 数组和 i18n 配置加载翻译数据到 kernel i18n 服务,包含错误处理和无 i18n 服务时的静默降级。(979414a)

3. 增强 i18n 测试覆盖 ✅ 新增 17 个测试:discovery-handler 一致性(6)、dev stub 场景(1)、MSW catch-all 路由(1)、context 解析(1)、501 一致性(1)、AppPlugin i18n 加载(6)、错误处理(1)。总测试 138 通过。

4. 补充文档 ✅ 在 content/docs/guides/kernel-services.mdx 的 i18n 章节添加了服务注册模式、Discovery 一致性说明、AppPlugin 自动加载行为、REST 端点列表等文档。

CopilotAI changed the title fix: unify i18n service discovery and handler resolution logicfix: unify i18n service discovery, handler resolution, and plugin auto-loadingMar 11, 2026
@hotlong
hotlong marked this pull request as ready for review March 11, 2026 04:18
CopilotAI review requested due to automatic review settings March 11, 2026 04:18
@hotlong
hotlong merged commit 473e77d into mainMar 11, 2026
5 checks passed

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes i18n service discovery consistent with runtime handler resolution by switching discovery to the same async service-resolution chain, derives discovery locale metadata from the actual i18n service, and adds AppPlugin auto-loading for translation bundles so server/dev/mock environments converge on the same i18n state.

Changes:

  • Made HttpDispatcher.getDiscoveryInfo() async and aligned it with resolveService() resolution semantics; discovery now populates locale from the i18n service when available.
  • Updated all discovery callers (dispatcher plugin + framework adapters + MSW plugin) to await getDiscoveryInfo().
  • Added AppPlugin translation auto-loading and expanded docs/tests/changelog for i18n behavior.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
FileDescription
packages/runtime/src/http-dispatcher.tsMakes discovery async, resolves services via resolveService(), and derives locale from i18n service.
packages/runtime/src/http-dispatcher.test.tsAdds tests for discovery↔handler consistency and locale population across environments.
packages/runtime/src/dispatcher-plugin.tsAwaits async discovery info in discovery routes.
packages/runtime/src/app-plugin.tsAdds translation bundle auto-loading into the kernel i18n service during start().
packages/runtime/src/app-plugin.test.tsAdds tests for AppPlugin i18n auto-loading behavior.
packages/plugins/plugin-msw/src/msw-plugin.tsAwaits discovery info in MSW discovery handler.
packages/adapters/hono/src/index.tsAwaits dispatcher discovery in Hono adapter.
packages/adapters/express/src/index.tsAwaits dispatcher discovery in Express adapter.
packages/adapters/fastify/src/index.tsAwaits dispatcher discovery in Fastify adapter.
packages/adapters/nextjs/src/index.tsAwaits dispatcher discovery in Next.js adapter.
packages/adapters/nestjs/src/index.tsMakes NestJS controller discovery handler async and awaits discovery info.
packages/adapters/nestjs/src/nestjs.test.tsUpdates NestJS discovery test to async.
packages/adapters/nestjs/src/mocks/runtime.tsUpdates mock dispatcher discovery to return a promise.
packages/adapters/nuxt/src/index.tsAwaits dispatcher discovery in Nuxt adapter.
packages/adapters/sveltekit/src/index.tsAwaits dispatcher discovery in SvelteKit adapter.
content/docs/guides/kernel-services.mdxDocuments i18n registration, discovery consistency, and AppPlugin auto-loading.
CHANGELOG.mdRecords the fix/additions under Unreleased.

Comment on lines +203 to +208
private loadTranslations(ctx: PluginContext, appId: string): void {
const i18nService = ctx.getService('i18n') as II18nService | undefined;
if (!i18nService) {
ctx.logger.debug('[i18n] No i18n service registered; skipping translation loading', { appId });
return;
}

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadTranslations() calls ctx.getService('i18n'), but PluginContext.getService() is documented/implemented to throw when a service is missing (and can also throw when the service is async). This means AppPlugin can crash during start() in environments where i18n is intentionally not registered, contradicting the “gracefully skips” behavior described here. Wrap service lookup in a try/catch (treat not-found as undefined) and/or use await ctx.getKernel().getServiceAsync('i18n') with error handling so missing i18n truly becomes a no-op.

Copilot uses AI. Check for mistakes.
Comment on lines +76 to +99
async getDiscoveryInfo(prefix: string) {
// Resolve all services through the same async fallback chain
// that request handlers (handleI18n, handleAuth, …) use.
const [
authSvc, graphqlSvc, searchSvc, realtimeSvc, filesSvc,
analyticsSvc, workflowSvc, aiSvc, notificationSvc, i18nSvc,
uiSvc, automationSvc, cacheSvc, queueSvc, jobSvc,
] = await Promise.all([
this.resolveService(CoreServiceName.enum.auth),
this.resolveService(CoreServiceName.enum.graphql),
this.resolveService(CoreServiceName.enum.search),
this.resolveService(CoreServiceName.enum.realtime),
this.resolveService(CoreServiceName.enum['file-storage']),
this.resolveService(CoreServiceName.enum.analytics),
this.resolveService(CoreServiceName.enum.workflow),
this.resolveService(CoreServiceName.enum.ai),
this.resolveService(CoreServiceName.enum.notification),
this.resolveService(CoreServiceName.enum.i18n),
this.resolveService(CoreServiceName.enum.ui),
this.resolveService(CoreServiceName.enum.automation),
this.resolveService(CoreServiceName.enum.cache),
this.resolveService(CoreServiceName.enum.queue),
this.resolveService(CoreServiceName.enum.job),
]);

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getDiscoveryInfo() now resolves all services via resolveService(), which prefers kernel.getServiceAsync(). In ObjectKernel, getServiceAsync() executes service factories and can instantiate services (including transient/scoped) just to build discovery output. That’s a behavior change from the prior “check the services map” approach and can introduce side effects / extra work on every discovery request. Consider adding a non-instantiating availability check (e.g., a kernel hasService() API or a resolveServiceRegistration() path) and using that in discovery, while keeping resolveService() for request handlers.

Copilot uses AI. Check for mistakes.
Comment on lines +156 to +173
it('should skip translation loading when i18n service is not registered', async () => {
vi.mocked(mockContext.getService).mockImplementation((name: string) => {
if (name === 'objectql') return mockQL;
return undefined; // No i18n service
});

const bundle = {
id: 'com.test.noi18n',
translations: [{ en: { messages: { hello: 'Hello' } } }],
};
const plugin = new AppPlugin(bundle);
await plugin.start!(mockContext);

// Should log debug but not throw
expect(mockContext.logger.debug).toHaveBeenCalledWith(
expect.stringContaining('No i18n service registered'),
expect.any(Object)
);

CopilotAIMar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test simulates “no i18n service” by returning undefined from mockContext.getService, but real PluginContext.getService() throws when a service is missing. To ensure the intended graceful-skip behavior is covered, update the mock to throw (e.g., throw new Error("[Kernel] Service 'i18n' not found")) and assert AppPlugin.start() does not reject.

Copilot uses AI. Check for mistakes.
os-zhuang added a commit that referenced this pull request Jul 28, 2026
…outes the SDK could not reach (#3718) (#3888)
* feat(client,spec): `ai.agents.*` and `ai.pendingActions.*` — the AI routes the SDK could not reach (#3718)
#3718 deleted three `client.ai.*` methods whose URLs no route had ever mounted,
then expressed the surface that does exist. It expressed ONE builder's worth of
it. `service-ai` mounts seven; widening its ledger (objectstack-ai/cloud#903)
counted ten routes the SDK cannot reach, nine of which had never been counted.
This closes the six with the strongest evidence: objectui already ships product
on them, over URLs it hand-builds because there is nothing to call.
ai.agents — `/ai/chat` talks to the default agent; these talk to a named one.
list() — agents this CALLER may chat with; the route
filters by permission (ADR-0049), so empty is a
legitimate answer, not an error to retry
chat(name, req) — forces `stream: false`, same reason `ai.chat`
does: the route streams by default
chatStream(name, req) — same route, streaming mode. One route, two
methods, mirroring chat/chatStream rather than
inventing a third shape
ai.pendingActions — the HITL approval queue an embedding app must render.
list(options?) — status/conversationId/limit ONLY. The service
also accepts objectName; the route never
forwards it, so typing it would offer a filter
that silently does nothing
get(id)
approve(id) — approves AND executes. `{status:'failed'}` comes
back on HTTP 200: the approval succeeded, the
execution did not. Reading only `res.ok` reports
a failed write as a success
reject(id, reason?) — executes nothing
Typed from what the routes RETURN, not from what a client might like them to —
the failure #3718 exists to punish. Pending actions are the persisted row,
snake_case on the wire because that is what it is; agent rows require
`capabilities` because that object is what tells a UI what to render.
The capstone's `/api/v1/ai/` prefix exemption says the evidence lives across the
repo boundary. It does, and it now reaches these: cloud's ledger drives every
`ai.*` method against the tables its builders really return, and since #903 that
means all seven. Comment updated there — it still described the one-builder
version, under which `buildAgentRoutes()` and `buildPendingActionRoutes()` were
invisible.
Verification: client 184/184, spec 6836/6836 (24 in protocol.test.ts, extended
with the new shapes incl. the negative cases — an agent row without
capabilities, a non-enum status filter, `approve` yielding "rejected").
Mutation-checked: pointing `ai.pendingActions.get` off the AI prefix fails the
capstone by name, proving the new methods are really in its sweep rather than
silently absent. Generated artifacts regenerated: api-surface.json (+20 exports,
0 breaking) and json-schema.manifest.json.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WJX6GnuNix7HisBc92THMN
* docs(spec,client): regenerate the protocol reference and document the new ai methods
`check:docs` regenerates `content/docs/references` from the schemas and fails
when the committed copy drifts. The seven new AI schemas produce new reference
entries, so that check went red on the first push — the artifact half of the
same commit, not a separate defect.
Also updates the hand-written SDK doc, which enumerates the whole `client.ai.*`
surface and carried the #3718 history note. Leaving it at ten methods while
shipping seventeen would be the exact drift this line of work keeps closing —
and the docs-drift check flagged `content/docs/api/client-sdk.mdx` as affected,
which on inspection it genuinely was (the other 107 files it lists are
package-level fan-out, unrelated to this diff).
The added block documents the two things a caller gets wrong by default: an
access-filtered agent catalog is legitimately EMPTY for a seat-less user, and
`pendingActions.approve` returns `{ status: 'failed' }` on HTTP 200 when the
tool fails after approval — reading only `res.ok` calls that a success.
check:docs / check:api-surface / check:spec-changes / check:upgrade-guide all
pass; 250 generated files in sync.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WJX6GnuNix7HisBc92THMN
---------
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n 服务注册与状态不一致问题评估

3 participants

@hotlong