diff --git a/.changeset/ai-slot-remedy-tells-the-truth.md b/.changeset/ai-slot-remedy-tells-the-truth.md new file mode 100644 index 0000000000..4990020749 --- /dev/null +++ b/.changeset/ai-slot-remedy-tells-the-truth.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': patch +'@objectstack/runtime': patch +--- + +Discovery stops telling Cloud/Enterprise deployments that nothing implements `ai` (#4093 follow-up). + +`CORE_SERVICE_PROVIDER` recorded `null` for `ai` because no **workspace** package provides it, and `serviceUnavailableMessage('ai')` therefore produced *"No implementation ships for the 'ai' slot"*. That is false: `@objectstack/service-ai` registers the slot in `objectstack-ai/cloud`. The table conflated "not in this repository" with "does not exist" — the same class of wrong answer the table was introduced to end, one step further out. + +Verified against the cloud repository rather than inferred: `packages/service-ai/src/plugin.ts` calls `ctx.registerService('ai', …)`, and the package is `private: true`, so there is genuinely nothing to install — which is why `null` stays right and an `Install X` sentence would still be wrong. `search`, `workflow` and `graphql` were checked the same way and nothing registers them in either repository, so their `null` and their "nothing ships" sentence are accurate. + +`ai` now carries a `REMEDY_DETAIL` sentence — *"Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework"* — the mechanism `ui` already used. Both discovery (`services.ai.message`) and the `/ai` 501 body report it. + +This **removes** code: `/ai`'s domain had a local message override, added because the shared sentence was wrong there. Correcting the table fixed the domain *and* discovery, which the override could never reach, so the override and `capabilityUnavailable`'s `message?` parameter are both gone. A slot whose sentence is wrong needs the table corrected, not a local exception. + +Also corrected: three places still describing `/ai`'s absent-service answer as a 404 (`docs/api/client-sdk.mdx`, `docs/releases/v17.mdx`, and a comment in `packages/client`'s URL-conformance test) — stale since that answer became 501. + +FROM → TO: `services.ai.message` and the `/ai/*` 501 body change text. Nothing branches on either — `status` and `enabled` are the contract, the message is prose for humans and agents. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 45f7e4b197..bb473d6494 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -315,8 +315,9 @@ await client.notifications.markAllRead(); // through sys_inbox_message and tracks read-state in sys_notification_receipt. // These helpers will be repointed during the objectui bell cut-over. -// AI — served by `service-ai` (Cloud/EE); 404s "AI service is not configured" -// when the plugin is absent, so check `discovery.services` first. +// AI — served by `service-ai` (Cloud/EE). Without it the route is mounted but +// unimplemented, so it answers 501 and discovery reports the slot unavailable +// with the same sentence; check `discovery.services` first. const answer = await client.ai.chat({ messages: [{ role: 'user', content: 'How many open orders this quarter?' }], conversationId, // omit to have one created and echoed back diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 291cbc8567..c1be47021a 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -55,7 +55,7 @@ GET /api/v1/discovery HTTP/1.1 "metadata": { "enabled": true, "status": "available", "route": "/api/v1/meta", "provider": "objectql" }, "auth": { "enabled": true, "status": "available", "route": "/api/v1/auth", "provider": "@objectstack/plugin-auth" }, "workflow": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'workflow' slot — register a service under it to enable" }, - "ai": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'ai' slot — register a service under it to enable" } + "ai": { "enabled": false, "status": "unavailable", "message": "Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework" } }, "locale": { "default": "en-US", diff --git a/content/docs/releases/v17.mdx b/content/docs/releases/v17.mdx index 69e490a1a1..99bc1c7d53 100644 --- a/content/docs/releases/v17.mdx +++ b/content/docs/releases/v17.mdx @@ -727,9 +727,9 @@ query dialect only the dispatcher understood. async generator, so the request is issued — and an HTTP error thrown — when you call it, not when you first iterate. -`service-ai` is a Cloud/EE package: this repo proxies `/api/v1/ai/**` and 404s -`AI service is not configured` without it, so check `discovery.services` before -calling. For a React chat UI, `useChat()` (`@ai-sdk/react`) remains the better +`service-ai` is a Cloud/EE package: this repo proxies `/api/v1/ai/**` and, without +it, answers 501 — the route is mounted, the implementation is not — so check +`discovery.services` before calling. For a React chat UI, `useChat()` (`@ai-sdk/react`) remains the better client — these methods are for callers that are not components. The spec's dead AI declarations retire with the namespace: diff --git a/packages/client/src/client-url-conformance.test.ts b/packages/client/src/client-url-conformance.test.ts index 44e508db8a..358b919aec 100644 --- a/packages/client/src/client-url-conformance.test.ts +++ b/packages/client/src/client-url-conformance.test.ts @@ -154,7 +154,7 @@ const CONTROL_PLANE_NAMESPACE = 'projects.'; /** * The AI plane. `/api/v1/ai/*` is served by `service-ai`, a Cloud/EE package in * the sibling `cloud` repo; this repo's dispatcher only PROXIES the prefix to - * whatever `buildAIRoutes()` mounted (or 404s "AI service is not configured"). + * whatever `buildAIRoutes()` mounted (or 501s when that package is absent). * No in-repo ledger can enumerate that table, so — exactly like the control * plane above — the prefix is exempt HERE and guarded THERE. * diff --git a/packages/runtime/src/domains/ai.ts b/packages/runtime/src/domains/ai.ts index 1d2f6b15e3..fb46467acd 100644 --- a/packages/runtime/src/domains/ai.ts +++ b/packages/runtime/src/domains/ai.ts @@ -69,13 +69,12 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string, // [#3842] Was a hand-rolled envelope with the status in `code`. It has // no header or shape of its own, so it is simply the shared exit now. // 501, not 404: `/ai/*` IS mounted, so the request reached a handler - // with nothing behind it — see ./unavailable.ts. The message stays - // local rather than using the shared sentence: the real provider - // (`@objectstack/service-ai`) ships outside this workspace as a - // Cloud/EE package, so CORE_SERVICE_PROVIDER — verified against - // workspace packages by check:service-providers — records `null` for - // this slot and would describe it as "nothing ships", which is wrong. - return capabilityUnavailable(deps, 'ai', 'AI service is not configured'); + // with nothing behind it — see ./unavailable.ts. This used to pass a + // local message because the shared sentence said "nothing ships", + // which is false for a Cloud/Enterprise deployment. The shared table + // says the accurate thing now (REMEDY_DETAIL), so the override is gone + // and this answer matches what discovery reports for the slot. + return capabilityUnavailable(deps, 'ai'); } // The AI service exposes route definitions via buildAIRoutes. diff --git a/packages/runtime/src/domains/unavailable.ts b/packages/runtime/src/domains/unavailable.ts index 33dbeb4f00..c40c2149a6 100644 --- a/packages/runtime/src/domains/unavailable.ts +++ b/packages/runtime/src/domains/unavailable.ts @@ -46,20 +46,17 @@ import type { DomainHandlerDeps } from '../domain-handler-registry.js'; * entry cannot drift into naming different remedies — and a caller who hits * the wall gets the fix without a second round trip. * + * There is deliberately no per-call message override. One briefly existed for + * `ai`, whose provider ships outside this workspace, and it hid the real bug: + * the shared table said "nothing ships" for a slot that Cloud/Enterprise does + * provide. Teaching the table to say the accurate thing fixed the domain AND + * discovery, which the override could only ever have fixed here. A slot whose + * sentence is wrong needs the table corrected, not a local exception. + * * @param slot the `CoreServiceName` key, NOT the route segment — `/notifications` * is served by the `notification` slot, and the remedy is looked up * by slot. */ -export function capabilityUnavailable( - deps: DomainHandlerDeps, - slot: string, - /** - * Overrides the shared sentence. Only for slots whose provider cannot be - * named by `CORE_SERVICE_PROVIDER` — it is verified against workspace - * packages, so a real provider that ships outside this repo (`ai`) has no - * entry there and would otherwise be described as "nothing ships". - */ - message?: string, -): HttpDispatcherResult { - return { handled: true, response: deps.error(message ?? serviceUnavailableMessage(slot), 501) }; +export function capabilityUnavailable(deps: DomainHandlerDeps, slot: string): HttpDispatcherResult { + return { handled: true, response: deps.error(serviceUnavailableMessage(slot), 501) }; } diff --git a/packages/spec/src/system/core-service-provider.test.ts b/packages/spec/src/system/core-service-provider.test.ts index 813ccc83c2..fc555db15e 100644 --- a/packages/spec/src/system/core-service-provider.test.ts +++ b/packages/spec/src/system/core-service-provider.test.ts @@ -22,9 +22,28 @@ describe('CORE_SERVICE_PROVIDER', () => { expect(CORE_SERVICE_PROVIDER['notification']).toBe('@objectstack/service-messaging'); }); - it('uses null — not a plausible name — for slots nothing implements', () => { + it('uses null — not a plausible name — where no package can be installed', () => { for (const slot of ['ai', 'search', 'workflow', 'graphql']) { - expect(CORE_SERVICE_PROVIDER[slot], `${slot} must have no provider`).toBeNull(); + expect(CORE_SERVICE_PROVIDER[slot], `${slot} must name no installable package`).toBeNull(); + } + }); + + // `null` covers two different situations, and only one of them means + // "nothing exists". Verified against objectstack-ai/cloud: nothing there + // registers search/workflow/graphql, but `@objectstack/service-ai` does + // register `ai` — it is simply `private: true`, so there is no package to + // install and no name that belongs in an "Install X" sentence. + it('still says something ships for a slot whose provider is real but uninstallable', () => { + const ai = serviceUnavailableMessage('ai'); + expect(ai).not.toMatch(/No implementation ships/); + expect(ai).toContain('@objectstack/service-ai'); + expect(ai).toMatch(/Cloud\/Enterprise/); + // Still not an instruction a reader could act on and fail at. + expect(ai).not.toMatch(/^Install /); + + // The genuinely-empty slots keep the plain sentence. + for (const slot of ['search', 'workflow', 'graphql']) { + expect(serviceUnavailableMessage(slot), slot).toMatch(/No implementation ships/); } }); diff --git a/packages/spec/src/system/core-services.zod.ts b/packages/spec/src/system/core-services.zod.ts index a2644477d2..4a0e2e8e91 100644 --- a/packages/spec/src/system/core-services.zod.ts +++ b/packages/spec/src/system/core-services.zod.ts @@ -90,8 +90,21 @@ export const CORE_SERVICE_PROVIDER: Readonly> = { // `/ui` is served by the `protocol` service, which MetadataPlugin registers; // the `ui` slot itself has no implementation anywhere (#4093 / #4146). 'ui': '@objectstack/metadata-protocol', - // Nothing ships for these. `search` and `workflow` have no consumer either - // (ADR-0115 Evidence 5); `graphql` and `ai` have surfaces but no provider. + // `null` means "no name belongs in an `Install X` sentence", which covers two + // different situations — see REMEDY_DETAIL, which is how the second one still + // gets an accurate message. + // + // Nothing provides the slot at all: `search`, `workflow` (no consumer + // either — ADR-0115 Evidence 5) and `graphql` (a surface with no provider). + // Verified across BOTH repositories: nothing in `objectstack-ai/cloud` + // registers them. + // + // A provider exists but cannot be installed: `ai`. `@objectstack/service-ai` + // registers this slot in `objectstack-ai/cloud` and is `private: true`, so + // naming it would send a reader after a package they cannot obtain — the + // exact failure this table was written to end. It carries a REMEDY_DETAIL + // sentence instead; a bare `null` here would tell a Cloud/Enterprise + // deployment that nothing ships, which is false. 'ai': null, 'search': null, 'workflow': null, @@ -110,6 +123,12 @@ export const CORE_SERVICE_PROVIDER: Readonly> = { */ const REMEDY_DETAIL: Readonly> = { 'ui': 'Served by the protocol service — register MetadataPlugin (@objectstack/metadata-protocol) to enable', + // A provider EXISTS — `@objectstack/service-ai` registers this slot — but it + // lives in the `objectstack-ai/cloud` repository and is `private: true`, so + // there is nothing to install and no name that belongs in an "Install X" + // sentence. Without this entry the `null` above reads as "nothing ships", + // which is what a Cloud/Enterprise deployment is NOT looking at. + 'ai': 'Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework', }; /** diff --git a/scripts/check-service-providers.mjs b/scripts/check-service-providers.mjs index e7612794bf..60474b18a0 100644 --- a/scripts/check-service-providers.mjs +++ b/scripts/check-service-providers.mjs @@ -22,8 +22,14 @@ // 2. A slot in `CoreServiceName` with no entry at all — which would fall // back to `undefined` and print a remedy naming nothing. // -// `null` is a legitimate entry: it means nothing ships for that slot yet, and -// the message says so instead of naming a plausible package. +// `null` is a legitimate entry, and it means "no name belongs in an `Install X` +// sentence" — which covers TWO situations. Nothing provides the slot at all +// (`search`, `workflow`, `graphql`), or a provider exists but cannot be +// installed: `@objectstack/service-ai` registers `ai` in objectstack-ai/cloud +// and is `private: true`. This script can only see workspace packages, so it +// cannot tell those apart — `REMEDY_DETAIL` in the same file is where the +// second kind gets an accurate sentence, and the reason a bare `null` must not +// be read here (or reported) as "nothing exists". import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; @@ -86,7 +92,9 @@ for (const [slot, pkg] of table) { problems.push( ` ${slot} → ${pkg}\n` + ' Not a workspace package. Point it at the package that actually registers\n' - + ` the '${slot}' slot, or use \`null\` if nothing ships for it yet.`, + + ` the '${slot}' slot, or use \`null\` if no installable package provides it\n` + + ' (adding a REMEDY_DETAIL sentence if something ships that simply cannot\n' + + ' be installed, e.g. a private package in another repository).', ); } } @@ -115,7 +123,12 @@ if (problems.length > 0) { } const named = [...table.values()].filter(Boolean).length; +// Deliberately NOT "nothing ships yet" — `null` means "no name belongs in an +// `Install X` sentence", which also covers a provider that exists but cannot be +// installed (`ai`, whose `@objectstack/service-ai` is private to the cloud +// repo). Saying "nothing ships" here would repeat, in this script's own output, +// the conflation the table was corrected to stop making. console.log( - `✓ check:service-providers — ${table.size} slot(s): ${named} name a real workspace ` - + `package, ${table.size - named} correctly report that nothing ships yet.`, + `✓ check:service-providers — ${table.size} slot(s): ${named} name an installable workspace ` + + `package, ${table.size - named} name none (see REMEDY_DETAIL for those that still ship something).`, );