Skip to content

Commit 3c38025

Browse files
feat(cli)!: os migrate refuses a config whose AI plugin only os serve would mount, reading serve's own auto-inject rule (#31446)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X2kM3DQTy5XByDHKN47ZrVA)_ domain:server#5 · os-project-manager · session_01FEkQ5mbAyTfp7gbjowRA48 Fixes #31411 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no file under `skills/**` teaches that `os serve` auto-injects the AI plugin or that `os migrate` composes it: `skills/objectstack-platform` names `AIServicePlugin` as cloud / EE only and mounted by the cloud host, which is the explicit mount the refusal asks for; `content/docs/` does not describe the migrate composition's AI handling either. ## 维护者速读 - **改了什么**: `os serve` 的 AI 自动注入规则搬进一个导出的谓词 (`resolveAiServiceLoad` + `mountsAiServicePlugin`)。`serve` 调它决定加载什么 (对每种输入结果不变);`os migrate plan` / `apply` / 归属仪式的 `--apply` 调同一个谓词,在“serve 会自动注入 AI 而配置没挂 AI 插件”时于规划前拒绝,并给出补救:在配置里显式挂 AI 插件。 - **为什么改**: migrate 不组合 serve 自动注入的 AI 插件,`requires: ['ai']` 的配置被规划时缺少 serve 声明的 `ai_*` 表,`apply` 照样执行 (#31411,裁定 B,「以上都同意」)。 - **风险与回滚**: 收窄:依赖自动注入又跑 migrate 的应用会被拒绝 (退出 1,附原因与补救)。规则只有一处,serve 的决定有 40 组输入的对照钉。回滚即 revert 本 PR。 - **先看**: `packages/cli/src/utils/ai-service-auto-inject.ts` 与 `schema-migration-plugins.ts` 中 `composeServedPlatform` 的拒绝块。 - **两分钟验证**: `cd packages/cli && npx vitest run --project unit src/commands/migrate/plan.ai-auto-inject-refusal.test.ts src/utils/ai-service-auto-inject.test.ts test/serve-optional-plugin-intent.test.ts` - **需要你做的**: 无,席位落地。 ## What changes - **One reading of the rule.** New `packages/cli/src/utils/ai-service-auto-inject.ts` exports `resolveAiServiceLoad({ declaredRequires, aiTierEnabled, hostDeclaresDependency })` (`required` when `requires` names `ai` or `ai-studio`, `auto` when the app's `package.json` declares `@objectstack/service-ai` or `-studio`, `off` when the `ai` tier is off), `mountsAiServicePlugin(plugins)` (by registered name or class name, as serve read it), and the refusal text. `Serve.resolveOptionalPluginLoad` delegates to the same module. - **serve calls it.** `serve.ts` drops its inline `wantsAiService` / `aiRequired` / `hasAIPlugin` reading and calls the two functions. Its decision is unchanged for every input (pinned below). The Studio half still reads `hostDeclaresDependency`, and Studio still loads only once the base did. - **The migrate composition refuses with it.** `composeServedPlatform` resolves the boot's tiers as serve does and asks the predicate. When the answer is `required` or `auto` and none of the base, host or composed plugins is the AI plugin, it throws before planning: "`os serve` auto-injects the AI plugin (@objectstack/service-ai) for this config — {reason} and the config mounts no AI plugin — and `os migrate` does not compose it … Remedy: mount the AI plugin explicitly in the config (`plugins: [new AIServicePlugin()]`, imported from @objectstack/service-ai), then re-run." plan / apply answer it as `{ "error": … }` with exit 1, as their other boot refusals do; the ownership ceremony answers it as `reason: "boot-failed"` with the text as `detail`. - **stdout is handed back on a refused composition.** `bootSchemaStack` reserved stdout for the JSON document before composing, and a composition that threw left the reservation in place, so the next writer in the same process lost its document. It now releases stdout before rethrowing. This applies to every composition throw, not only this one. - Changeset `31411-migrate-refuses-serve-only-ai-plugin.md`: `@objectstack/cli` patch (the level follows the metadata protocol, and this PR declares `Metadata-protocol: none`), with a BREAKING section (Why / Before / Now / Who moves / Unchanged) and the ADR-0087 disposition `not-required (no-migration-prescription)`. ## Tests - `packages/cli/src/commands/migrate/plan.ai-auto-inject-refusal.test.ts` (unit tier, runs the real commands against a temp project and SQLite file): - ⭐ `requires: ['ai']`, no AI plugin: `plan` and `apply` exit 1, naming `requires`, `@objectstack/service-ai` and the remedy. - ⭐ `package.json` declares `@objectstack/service-ai`, no AI plugin: `plan` exits 1, naming `package.json` and the remedy. - ⭐ the ownership ceremony's `--apply` with `requires: ['ai-studio']`: exit 1, `reason: "boot-failed"`, detail carries the remedy. - Controls: an explicit AI plugin (with `requires: ['ai']` and the dependency) plans as today; the `ai` tier off (`tiers` without `ai`, dependency declared) plans as today; a config with no AI intent plans as today. - `packages/cli/src/utils/ai-service-auto-inject.test.ts`: - ⭐ the predicate answers what serve's old inline reading answered for all 40 combinations of `requires`, tier and declared dependency (the oracle is that reading, kept in the test). - the mounted-plugin reading, and the refusal text names the reason and the remedy. - ⭐ no second copy: `serve.ts` and `schema-migration-plugins.ts` call the predicate, and neither restates `has('ai')` / `has('ai-studio')` or the plugin names. - The existing `test/serve-optional-plugin-intent.test.ts` passes unchanged. ## Acceptance notes - Red on main: the three ⭐ refusal pins fail on `origin/main` (3 failed / 3 passed; the controls pass). - Ablation: with the refusal's condition replaced by `false` (`scripts/ablation-replace.mjs`, anchor hit ×1 → ×0, blob `814b130b8ddc` → `d0210f7486c9`), the same three pins go red (3 failed / 3 passed); restored to HEAD, `git diff HEAD` empty. - The `auto` case (dependency declared, nothing in `requires`) is refused too, because serve injects the plugin for it as well. Only the `ai` tier being off, or the plugin mounted, lets it through. - `os migrate unmapped-columns` shares the same composition, so it inherits the refusal. - `requires: ['ai']` always opens the `ai` tier (capability → tier mapping), so the tier-off control uses a declared dependency, which is the only intent the tier can deny. ## Verification - `vitest run --project unit` on the three files above: 3 files, 21 tests passed. - `tsc --noEmit` (cli) and `check:test-typecheck`: clean. `eslint` on the six touched files: clean. - Head `6c9a7c5d` (the commits after code head `ea6bebb6` touch only the changeset: its Skills-docs line, then its release level set to patch); origin/main merged at `0647dabb` before opening. - Gates from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 72 derived, 72 run, all exit 0 (`--ran` reconcile: 0 NOT-MEASURED, 0 UNRUN). Two were red on the first pass and fixed in this branch: the ADR-0087 disposition was missing from the changeset, and `check:test-source-alias` asked for the commands' dynamic imports at module top in the new test. - Full cli unit tier: 403 files, 5697 tests passed. - Full dogfood battery (owed: `!` title, `(narrowing)` arm), on code head `ea6bebb6`, closure built with `pnpm --filter '@objectstack/dogfood^...' run build`, then `pnpm --filter @objectstack/dogfood test` under `os-verify-lock`: `VERDICT command-exit 0`, Test Files 438 passed | 3 skipped (441), Tests 3622 passed | 41 skipped (3663), no failures. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint on the six touched `.ts` files: exit 0) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. (`.changeset/31411-migrate-refuses-serve-only-ai-plugin.md`, `@objectstack/cli` patch, BREAKING section) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (the 维护者速读 block is the lane's required Chinese summary) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5mbAyTfp7gbjowRA48 --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mbAyTfp7gbjowRA48)_ <!-- signed: domain:server#5 · os-project-manager · session_01FEkQ5mbAyTfp7gbjowRA48 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f67f3b3 commit 3c38025

7 files changed

Lines changed: 511 additions & 23 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli)!: `os migrate plan` / `apply` refuse a config whose AI plugin only `os serve` would mount, reading serve's own AI auto-inject rule
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
Skills-docs: none — no file under `skills/**` teaches that `os serve` auto-injects the AI plugin or that `os migrate` composes it: `skills/objectstack-platform` names `AIServicePlugin` as cloud / EE only and mounted by the cloud host, which is the explicit mount the refusal asks for; `content/docs/` does not describe the migrate composition's AI handling either.
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema or export is added, removed, renamed or re-shaped. What moves is the migrate commands' answer to a config that relies on serve's AI auto-inject: they refuse it before planning, and the remedy is one line in the config, not a rewrite of anything at rest. -->
12+
13+
`os serve` auto-injects the AI service plugin (`@objectstack/service-ai`) when the stack's `requires` names `ai` or `ai-studio`, or the app's own `package.json` declares `@objectstack/service-ai` or `@objectstack/service-ai-studio`, unless the `ai` tier is off. That rule now lives in one exported predicate (`resolveAiServiceLoad`, with `mountsAiServicePlugin` for "the config already mounts it"). `serve` decides what it loads with it, unchanged for every input, and the composition the migrate commands share (`os migrate plan`, `os migrate apply`, the organization-ownership ceremony's `--apply`, and `os migrate unmapped-columns`) refuses with it.
14+
15+
**BREAKING** the migrate commands refuse a config they used to plan without the AI tables.
16+
17+
- **Why.** The migrate commands compose what `serve` composes so they plan the same tables, but they did not compose the AI plugin `serve` injects. A config with `requires: ['ai']` and no AI plugin got a plan with none of the `ai_*` tables `serve` declares, and `apply` applied it.
18+
- **Before.** That config planned and applied with exit 0, and the AI tables were missing from the plan.
19+
- **Now.** The run stops before planning with exit 1. The JSON `error` (and the ceremony's `boot-failed` detail) says that `os serve` auto-injects the AI plugin for this config, why (the `requires` token or the `package.json` dependency), and the remedy: mount the AI plugin explicitly in the config (`plugins: [new AIServicePlugin()]`, from `@objectstack/service-ai`), then re-run.
20+
- **Who moves.** An app that relies on `serve`'s AI auto-inject and runs `os migrate`. Add the plugin to the config's `plugins`; `serve` then counts it as mounted and injects nothing, so it boots the same.
21+
- **Unchanged.** A config that mounts the AI plugin plans as today. A config with the `ai` tier off (`tiers` without `ai`, or a preset without it) plans as today, as does a config that neither requires nor declares AI. `os serve` loads the same plugins for every config.
22+
23+
A composition that refuses (this one or any other) now hands stdout back before it throws, so the reservation the migrate boot takes for its JSON document no longer outlives the command.
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
4+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5+
import { tmpdir } from 'node:os';
6+
import { dirname, join, resolve } from 'node:path';
7+
import { fileURLToPath } from 'node:url';
8+
import { isExitSignal } from '../../utils/format.js';
9+
import MigratePlan from './plan.js';
10+
import MigrateApply from './apply.js';
11+
import MigrateOrganizationOwnership from './organization-ownership.js';
12+
13+
// Pay the first transform of the dist-resolved workspace deps the commands
14+
// reach through dynamic `import()`s at MODULE LOAD, not inside a case.
15+
import '@objectstack/runtime';
16+
import '@objectstack/objectql';
17+
import '@objectstack/plugin-auth';
18+
import '@objectstack/plugin-security';
19+
import '@objectstack/metadata-protocol';
20+
import '@objectstack/platform-objects/plugin';
21+
import '@objectstack/platform-objects/system';
22+
import '@objectstack/spec/automation';
23+
import '@objectstack/rest';
24+
import '@objectstack/service-datasource';
25+
import '@objectstack/driver-sql';
26+
import '@objectstack/driver-turso';
27+
28+
/**
29+
* `os migrate plan` / `apply` and the organization-ownership ceremony compose
30+
* what `os serve` mounts around a stack (`composeServedPlatform`). `serve`
31+
* auto-injects `@objectstack/service-ai` when the config mounts no AI plugin
32+
* and declares `requires: ['ai']` (or `['ai-studio']`), or when the app's
33+
* `package.json` declares the package, unless the `ai` tier is off. The
34+
* migrate commands compose no AI plugin, so on such a host they plan without
35+
* the `ai_*` tables `serve` would declare.
36+
*
37+
* Ruled B (the maintainer's 「以上都同意」, recorded on the card): the
38+
* migrate commands refuse that config, naming the reason and the remedy —
39+
* mount the AI plugin explicitly in the config — through the one predicate
40+
* `serve` itself reads (`resolveAiServiceLoad`).
41+
*
42+
* Controls: a config that mounts the AI plugin explicitly, a config whose
43+
* `ai` tier is off, and a config that asks for no AI, all plan as before.
44+
*/
45+
46+
const HERE = dirname(fileURLToPath(import.meta.url));
47+
const CLI_ROOT = resolve(HERE, '..', '..', '..');
48+
const CASE_TIMEOUT_MS = 240_000;
49+
const REMEDY = /mount the AI plugin explicitly in the config/;
50+
51+
const ENV_KEYS = [
52+
'OS_DATABASE_URL', 'DATABASE_URL', 'TURSO_DATABASE_URL', 'OS_DATABASE_DRIVER', 'OS_HOME',
53+
'OS_TELEMETRY_DB', 'OS_AUTH_SECRET', 'AUTH_SECRET', 'BETTER_AUTH_SECRET', 'OS_ARTIFACT_PATH', 'NODE_ENV',
54+
] as const;
55+
56+
type Invoke = (argv: string[]) => Promise<unknown>;
57+
const invoke = (command: { run: (argv: string[], opts: { root: string }) => Promise<unknown> }): Invoke =>
58+
(argv) => command.run(argv, { root: CLI_ROOT });
59+
60+
/** Run a command with stdout captured, its exits trapped, and its JSON document parsed. */
61+
async function runJson(command: Invoke, argv: string[]): Promise<{ payload: any; exitCode: number }> {
62+
const savedExit = process.exitCode;
63+
const swallow = ((_chunk: unknown, ...rest: unknown[]) => {
64+
const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
65+
if (cb) cb();
66+
return true;
67+
}) as typeof process.stdout.write;
68+
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(swallow);
69+
vi.spyOn(process.stderr, 'write').mockImplementation(swallow);
70+
vi.spyOn(console, 'error').mockImplementation(() => {});
71+
vi.spyOn(console, 'log').mockImplementation(() => {});
72+
vi.spyOn(console, 'warn').mockImplementation(() => {});
73+
let processExit: number | undefined;
74+
vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
75+
processExit = code ?? 0;
76+
throw new Error(`__PROCESS_EXIT__:${processExit}`);
77+
}) as never);
78+
let thrownExit: number | undefined;
79+
try {
80+
try {
81+
await command(argv);
82+
} catch (error) {
83+
const trapped = error instanceof Error && error.message.startsWith('__PROCESS_EXIT__');
84+
if (!trapped && !isExitSignal(error)) throw error;
85+
if (!trapped) thrownExit = (error as { oclif?: { exit?: number } }).oclif?.exit;
86+
}
87+
const out = stdout.mock.calls.map((c) => String(c[0])).join('').trim();
88+
// The boot's log lines share stdout with the document on some paths: the
89+
// document is the last whole line that parses as a JSON object.
90+
let payload: any = { unparsed: out.slice(-400) };
91+
for (const line of out.split('\n').reverse()) {
92+
if (!line.startsWith('{')) continue;
93+
try {
94+
payload = JSON.parse(line);
95+
break;
96+
} catch { /* a log fragment */ }
97+
}
98+
if (payload.unparsed !== undefined) {
99+
try { payload = JSON.parse(out); } catch { /* left unparsed */ }
100+
}
101+
return { payload, exitCode: processExit ?? thrownExit ?? (process.exitCode as number | undefined) ?? 0 };
102+
} finally {
103+
process.exitCode = savedExit;
104+
vi.restoreAllMocks();
105+
}
106+
}
107+
108+
const dirs: string[] = [];
109+
const savedEnv: Record<string, string | undefined> = {};
110+
let savedCwd: string;
111+
112+
/**
113+
* A config-only project. `config` is the config's extra keys, as source text;
114+
* `dependencies` is the app's own `package.json` dependencies.
115+
*/
116+
function project(config: string[], dependencies: Record<string, string> = {}): string {
117+
const dir = mkdtempSync(join(tmpdir(), 'os-31411-ai-'));
118+
dirs.push(dir);
119+
mkdirSync(join(dir, 'dist'), { recursive: true });
120+
writeFileSync(
121+
join(dir, 'objectstack.config.ts'),
122+
[
123+
'export default {',
124+
" manifest: { id: 'com.example.os31411ai', name: 'ai', version: '0.0.0', type: 'app' },",
125+
" objects: [{ name: 'os31411_thing', fields: { title: { type: 'text' } } }],",
126+
...config.map((line) => ` ${line}`),
127+
'};',
128+
'',
129+
].join('\n'),
130+
);
131+
writeFileSync(
132+
join(dir, 'package.json'),
133+
`${JSON.stringify({ name: 'os31411-ai-host', version: '0.0.0', private: true, dependencies }, null, 2)}\n`,
134+
);
135+
return dir;
136+
}
137+
138+
/** Enter the fixture as the operator's shell would. */
139+
function enter(dir: string): void {
140+
process.env.NODE_ENV = 'production';
141+
process.env.OS_TELEMETRY_DB = '0';
142+
process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json');
143+
process.env.OS_DATABASE_URL = `file:${join(dir, 'app.db')}`;
144+
process.chdir(dir);
145+
}
146+
147+
/** The AI plugin, mounted explicitly: `serve` reads it by its registered name. */
148+
const EXPLICIT_AI_PLUGIN =
149+
"plugins: [{ name: 'com.objectstack.service-ai', version: '0.0.0', init: async () => {}, start: async () => {} }],";
150+
151+
beforeAll(() => {
152+
savedCwd = process.cwd();
153+
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
154+
});
155+
156+
beforeEach(() => {
157+
for (const key of ENV_KEYS) delete process.env[key];
158+
});
159+
160+
afterEach(() => {
161+
process.chdir(savedCwd);
162+
for (const key of ENV_KEYS) {
163+
const value = savedEnv[key];
164+
if (value === undefined) delete process.env[key];
165+
else process.env[key] = value;
166+
}
167+
});
168+
169+
afterAll(() => {
170+
for (const d of dirs) {
171+
try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ }
172+
}
173+
});
174+
175+
describe('os migrate refuses a config that relies on serve\'s AI auto-inject', () => {
176+
it('⭐ requires: [ai] and no AI plugin: plan and apply refuse, naming the reason and the remedy', async () => {
177+
enter(project(["requires: ['ai'],"]));
178+
179+
const plan = await runJson(invoke(MigratePlan), ['--json']);
180+
expect(plan.exitCode, JSON.stringify(plan.payload).slice(0, 400)).toBe(1);
181+
expect(plan.payload.error).toMatch(/requires/);
182+
expect(plan.payload.error).toMatch(/@objectstack\/service-ai/);
183+
expect(plan.payload.error).toMatch(REMEDY);
184+
185+
const apply = await runJson(invoke(MigrateApply), ['--json']);
186+
expect(apply.exitCode, JSON.stringify(apply.payload).slice(0, 400)).toBe(1);
187+
expect(apply.payload.error, JSON.stringify(apply.payload).slice(0, 600)).toMatch(REMEDY);
188+
}, CASE_TIMEOUT_MS);
189+
190+
it('⭐ the app\'s package.json declares @objectstack/service-ai and the config mounts no AI plugin: plan refuses', async () => {
191+
enter(project([], { '@objectstack/service-ai': '*' }));
192+
193+
const plan = await runJson(invoke(MigratePlan), ['--json']);
194+
expect(plan.exitCode, JSON.stringify(plan.payload).slice(0, 400)).toBe(1);
195+
expect(plan.payload.error, JSON.stringify(plan.payload).slice(0, 600)).toMatch(/package\.json/);
196+
expect(plan.payload.error).toMatch(REMEDY);
197+
}, CASE_TIMEOUT_MS);
198+
199+
it('⭐ the organization-ownership ceremony\'s --apply refuses the same config at its boot', async () => {
200+
const dir = project(["requires: ['ai-studio'],"]);
201+
enter(dir);
202+
// A well-formed plan file: the refusal comes at the boot, before any plan is compared.
203+
const planFile = join(dir, 'plan.json');
204+
writeFileSync(planFile, JSON.stringify({
205+
kind: 'organization-ownership-plan', ceremony: 'ADR-0131 D10', inventoryDigest: 'sha256:fixture',
206+
database: join(dir, 'app.db'), generatedAt: '2026-11-08T00:00:00.000Z', posture: 'single',
207+
}));
208+
209+
const ceremony = await runJson(
210+
invoke(MigrateOrganizationOwnership),
211+
['--apply', '--backup-taken', '--plan-file', planFile, '--out', join(dir, 'apply.json'), '--json'],
212+
);
213+
expect(ceremony.exitCode, JSON.stringify(ceremony.payload).slice(0, 400)).toBe(1);
214+
expect(ceremony.payload, JSON.stringify(ceremony.payload).slice(0, 600)).toMatchObject({ reason: 'boot-failed', detail: expect.stringMatching(REMEDY) });
215+
}, CASE_TIMEOUT_MS);
216+
217+
it('control: the AI plugin mounted explicitly plans as before', async () => {
218+
enter(project(["requires: ['ai'],", EXPLICIT_AI_PLUGIN], { '@objectstack/service-ai': '*' }));
219+
220+
const plan = await runJson(invoke(MigratePlan), ['--json']);
221+
expect(plan.exitCode, JSON.stringify(plan.payload).slice(0, 400)).toBe(0);
222+
expect(plan.payload.error).toBeUndefined();
223+
}, CASE_TIMEOUT_MS);
224+
225+
it('control: the ai tier off — serve injects nothing for a declared dependency — plans as before', async () => {
226+
enter(project(["tiers: ['core', 'i18n', 'ui', 'auth'],"], { '@objectstack/service-ai': '*' }));
227+
228+
const plan = await runJson(invoke(MigratePlan), ['--json']);
229+
expect(plan.exitCode, JSON.stringify(plan.payload).slice(0, 400)).toBe(0);
230+
expect(plan.payload.error).toBeUndefined();
231+
}, CASE_TIMEOUT_MS);
232+
233+
it('control: a config that asks for no AI plans as before', async () => {
234+
enter(project([]));
235+
236+
const plan = await runJson(invoke(MigratePlan), ['--json']);
237+
expect(plan.exitCode, JSON.stringify(plan.payload).slice(0, 400)).toBe(0);
238+
expect(plan.payload.error).toBeUndefined();
239+
}, CASE_TIMEOUT_MS);
240+
});

‎packages/cli/src/commands/serve.ts‎

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { loadConfig, bundleConfigModule } from '../utils/config.js';
1010
import { mergeBootConfig } from '../utils/merge-boot-config.js';
1111
import { provisionTelemetryDatasource, standaloneTelemetryPrimary } from '../utils/telemetry-datasource.js';
1212
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
13+
import { mountsAiServicePlugin, resolveAiServiceLoad, resolveOptionalPluginLoad } from '../utils/ai-service-auto-inject.js';
1314
import { registerInstalledPlugins } from '../utils/installed-plugins-boot.js';
1415
import { readInternalArtifactPath, readInternalConfigOutputPath } from '../utils/internal-artifact-channel.js';
1516
import { readAuthSecretFileChannel } from '../utils/internal-auth-secret-channel.js';
@@ -1650,10 +1651,8 @@ export default class Serve extends Command {
16501651
required: boolean;
16511652
declared: boolean;
16521653
}): 'required' | 'auto' | 'off' {
1653-
if (!opts.tierAllowed) return 'off';
1654-
if (opts.required) return 'required';
1655-
if (opts.declared) return 'auto';
1656-
return 'off';
1654+
// [#31411] The rule's one home, shared with `os migrate`.
1655+
return resolveOptionalPluginLoad(opts);
16571656
}
16581657

16591658
/**
@@ -4970,10 +4969,7 @@ export default class Serve extends Command {
49704969
// 4. Auto-register AIServicePlugin if not already loaded by config plugins.
49714970
// Registered AFTER Dispatcher so that the ai:routes hook listener is
49724971
// already in place when AIServicePlugin.start() fires the hook.
4973-
const hasAIPlugin = plugins.some(
4974-
(p: any) => p.name === 'com.objectstack.service-ai'
4975-
|| p.constructor?.name === 'AIServicePlugin'
4976-
);
4972+
const hasAIPlugin = mountsAiServicePlugin(plugins);
49774973
// `importFromHost` (module scope, hoisted over this whole file — it is no
49784974
// longer "declared above" anything, which is the point) resolves optional
49794975
// plugin packages from the HOST APP's context — the app being served
@@ -4996,16 +4992,14 @@ export default class Serve extends Command {
49964992
// now go through the one owner in `@objectstack/types/node`, so "declared"
49974993
// cannot mean two different things in one file (Prime Directive #12).
49984994
const hostDeclaresDependency = (pkg: string): boolean => isDeclaredByHost(pkg, hostRoot);
4999-
// `wantsAiService` is the AUTO (opt-in) signal: the host app listed the base
5000-
// AI service — or the Studio that builds on it — in its OWN package.json. This
5001-
// is a package.json READ (a deliberate authoring act), not a speculative
5002-
// import: gating on a *declared* dep, not mere resolvability, is reliable in a
4995+
// The AUTO (opt-in) signal is the host app listing the base AI service — or
4996+
// the Studio that builds on it — in its OWN package.json. This is a
4997+
// package.json READ (a deliberate authoring act), not a speculative import:
4998+
// gating on a *declared* dep, not mere resolvability, is reliable in a
50034999
// workspace/monorepo where a package stays hoist-resolvable when undeclared.
50045000
// Studio implies the base service (it attaches via the `ai:ready` hook the base
50055001
// fires; the base is a transitive dep of Studio, so it stays resolvable).
5006-
const wantsAiService =
5007-
hostDeclaresDependency('@objectstack/service-ai')
5008-
|| hostDeclaresDependency('@objectstack/service-ai-studio');
5002+
// `resolveAiServiceLoad` below reads it.
50095003

50105004
// Load an optional, separately-published service plugin by INTENT (#1597).
50115005
// `required` (from an explicit `requires: [...]` capability) makes a missing
@@ -5069,12 +5063,12 @@ export default class Serve extends Command {
50695063
// `tiers` omit `ai` never loads it, whatever the intent — so a CE app that
50705064
// omits AI gets no AI service, no agents, and no `services.ai` in discovery
50715065
// (the console hides its AI surface), while every other capability is unaffected.
5072-
const aiRequired =
5073-
declaredRequires.has('ai') || declaredRequires.has('ai-studio');
5074-
const aiDecision = Serve.resolveOptionalPluginLoad({
5075-
tierAllowed: tierEnabled('ai'),
5076-
required: aiRequired,
5077-
declared: wantsAiService,
5066+
// [#31411] The rule's one reading, shared with `os migrate`, which refuses a
5067+
// config whose AI plugin only this auto-inject would mount.
5068+
const aiDecision = resolveAiServiceLoad({
5069+
declaredRequires,
5070+
aiTierEnabled: tierEnabled('ai'),
5071+
hostDeclaresDependency,
50785072
});
50795073
if (!hasAIPlugin && aiDecision !== 'off') {
50805074
// AIServicePlugin auto-detects its LLM provider from environment

0 commit comments

Comments
 (0)