diff --git a/.changeset/ready-signal-reports-degraded-boot.md b/.changeset/ready-signal-reports-degraded-boot.md new file mode 100644 index 0000000000..cf977568ea --- /dev/null +++ b/.changeset/ready-signal-reports-degraded-boot.md @@ -0,0 +1,12 @@ +--- +"@objectstack/core": patch +"@objectstack/cli": patch +--- + +The `Server is ready` line now reports the degraded boot it is standing on, instead of printing a green `✓` over it. + +`✓ Server is ready` and the kernel's `System started with degraded capabilities. Missing core services: …` were two statements about one boot, produced by two packages — the banner in `@objectstack/cli`, the conclusion in `@objectstack/core` — with **no data path between them**. So the ready signal did not depend on the thing that broke, and therefore could not report it. Measured twice within a day, from unrelated causes: an objectui CI boot where the auth plugin failed and not one `sys_*` table existed, and this repo's own weekly registry canary on the published `npx create-objectstack@latest` on-ramp, where the tick printed directly **above** four boot warnings. In the second case the ready line carried no weight in the job's verdict at all — it was present, green, wrong, and believed by nobody. + +- **The data path.** `ObjectKernel.validateSystemRequirements()` now publishes the list it had already computed — the same array behind its own warning — on the kernel's service registry, which is the seam boot facts already cross to reach the banner (`serve` reads `auth` and `seed-summary` off it the same way). No member and no type is added to `@objectstack/core`'s public surface, and nothing re-derives which services count as `core`: that judgement stays in `ServiceRequirementDef` alone. +- **The line.** On a degraded boot the banner prints `⚠ Server is ready — DEGRADED: missing core services: `, naming exactly what the kernel found missing. On a healthy boot the ready block is byte-for-byte unchanged, so an ordinary boot's output does not move. +- **Readiness is NOT made strict.** Nothing about what boots, binds, or exits changes. A machine deliberately running without auth still starts, still prints ready, and still exits 0 — the line just says what state it is ready in. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index c53cbcb558..cd94d6480a 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -111,6 +111,9 @@ import { type AutomationReadySummary, type SeedSourceSummary, } from '../utils/format.js'; +// [#16630] The reader half of the degraded-boot data path — the kernel's own +// missing-core-service list, fetched, never re-derived. See its header. +import { readMissingCoreServices } from '../utils/degraded-capabilities.js'; import { redirectStdoutToStderr } from '../utils/json-stdout.js'; import { CONSOLE_PATH, @@ -4735,6 +4738,18 @@ export default class Serve extends Command { if (Array.isArray(s) && s.length > 0) seedSummary = s; } catch { /* no seeds ran — nothing to show */ } + // ── Degraded-capabilities readout (#16630) ───────────────────── + // The banner and the kernel's `System started with degraded + // capabilities. Missing core services: …` warning used to be two + // statements about one boot with NO data path between them — printed + // from two packages, and the louder one (`✓ Server is ready`) was the + // wrong one. This read IS that path: the same kernel handle the two + // reads above use, the same `getService` accessor, and the kernel's own + // list handed on untouched. ⛔ Nothing here decides which services count + // as core — that judgement stays in one place, `ServiceRequirementDef`. + // Absent on every healthy boot, where the banner is unchanged. + const missingCoreServices = readMissingCoreServices(kernel); + // ── Multi-node licence reading → telemetry (#12667) ──────────── // The advisory the gate produced at boot, published where a deployment's // metrics pipeline already looks. Emitted HERE, after every plugin has @@ -4825,6 +4840,10 @@ export default class Serve extends Command { seededAdmin, automation: automationSummary, seeds: seedSummary, + // #16630 — what the kernel already knows about this boot, so the ready + // line can say what state it is ready in. `undefined` on a healthy + // boot, where the ready block prints exactly what it always has. + missingCoreServices, // #4012 — every boot-phase `logger.warn` the quiet window intercepted, // replayed here. Without this the window is a drain: the ADR-0110 D5 // `[action-governance]` inventory, degraded-boot notices and flow diff --git a/packages/cli/src/utils/degraded-capabilities.ts b/packages/cli/src/utils/degraded-capabilities.ts new file mode 100644 index 0000000000..f966e0d9ad --- /dev/null +++ b/packages/cli/src/utils/degraded-capabilities.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The CLI half of the degraded-boot data path (#16630). + * + * ## The defect this closes + * + * `✓ Server is ready` and `System started with degraded capabilities. Missing + * core services: …` came from two different packages — `printServerReady` here + * in `@objectstack/cli`, `ObjectKernel.validateSystemRequirements()` in + * `@objectstack/core` — with **no data path between them**. So the banner could + * not report the degradation: it never learned of it. It printed a green tick + * over a boot the kernel had just called degraded, on objectui CI run + * `34056438855` and again on this repo's own published-artifact canary, run + * `34084559243`, where the tick printed directly ABOVE the four boot warnings + * that said otherwise. That canary is the only pipeline validating the + * published on-ramp from outside, and the ready signal carried no weight in its + * verdict at all — it was present, green, wrong, and believed by nobody. + * + * ## What this module is, and what it deliberately is NOT + * + * It is a READER. The kernel decides which services are `core` + * (`ServiceRequirementDef`, `@objectstack/spec/system`) and which of them are + * missing; this function fetches that answer and hands it on unchanged. + * + * ⛔ It must never re-derive that judgement. A second implementation of "which + * services count as core" on this side is free to disagree with the kernel's, + * and then the banner is wrong in a new way instead of the old one — with two + * plausible answers on one screen and nothing to say which is authoritative. + * That is why nothing here reads `ServiceRequirementDef`, counts services, or + * inspects plugins: the ONLY input is the list the kernel published. + * + * ## Absent means healthy, and why a throw is the healthy path + * + * The kernel publishes the readout only when a boot IS degraded, so on a + * healthy boot `getService` throws `Service '…' not found` — the same shape + * `serve` already handles for the `auth` and `seed-summary` reads next to this + * one. The throw is caught and reported as `undefined`, which the banner + * renders as today's unchanged output, byte for byte. + */ + +/** + * The kernel service the degraded conclusion is published on. + * + * ⚠️ The producer's copy of this string is `DEGRADED_CAPABILITIES_SERVICE` in + * `packages/core/src/kernel.ts`. They are two literals for one name, which is + * how every kernel-service name crossing this boundary is already spelled + * (`auth`, `seed-summary`, `driver.*`, `app.*` are all bare literals on both + * sides). What holds them equal is not a shared constant but a test that drives + * a REAL degraded kernel boot through this reader — + * `format.server-ready-degraded-boot.test.ts` — so a rename on either side + * reddens instead of silently restoring the green tick. + */ +const DEGRADED_CAPABILITIES_SERVICE = 'kernel.degraded-capabilities'; + +/** The shape `ObjectKernel` publishes. Structural — nothing is imported for it. */ +interface DegradedCapabilitiesReadout { + missingCoreServices?: unknown; +} + +/** Just enough of the kernel to ask it a question. */ +interface ServiceReader { + getService?: (name: string) => unknown; +} + +/** + * The core services the kernel reported missing on this boot, or `undefined` + * when it reported none — i.e. when the boot was not degraded. + * + * Returns a fresh array: the stored value is frozen on purpose (it is the + * kernel's own record), and the banner's options are ordinary mutable data. + * + * Never throws. A readout that cannot be read is reported as "nothing to say", + * exactly as an absent one is — this is a diagnostic, and a diagnostic that can + * abort a boot the kernel already approved would be a worse defect than the one + * it exists to fix. + */ +export function readMissingCoreServices(kernel: unknown): string[] | undefined { + try { + const readout = (kernel as ServiceReader | null | undefined)?.getService?.( + DEGRADED_CAPABILITIES_SERVICE, + ) as DegradedCapabilitiesReadout | undefined; + const list = readout?.missingCoreServices; + if (!Array.isArray(list)) return undefined; + const names = list.filter((n): n is string => typeof n === 'string' && n.length > 0); + return names.length > 0 ? [...names] : undefined; + } catch { + // Healthy boot: the kernel published nothing and `getService` threw. + return undefined; + } +} diff --git a/packages/cli/src/utils/format.server-ready-degraded-boot.test.ts b/packages/cli/src/utils/format.server-ready-degraded-boot.test.ts new file mode 100644 index 0000000000..52a85a6df8 --- /dev/null +++ b/packages/cli/src/utils/format.server-ready-degraded-boot.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ObjectKernel, type Plugin } from '@objectstack/core'; +import { printServerReady, type ServerReadyOptions } from './format.js'; +import { readMissingCoreServices } from './degraded-capabilities.js'; + +/** + * #16630 — the ready signal must report the degraded boot it is standing on. + * + * ## Why this file boots a REAL kernel instead of unit-testing the formatter + * + * The defect was never a wording problem. `✓ Server is ready` is printed by + * `printServerReady` in `@objectstack/cli`; `System started with degraded + * capabilities. Missing core services: …` is concluded by + * `ObjectKernel.validateSystemRequirements()` in `@objectstack/core`; and there + * was **no data path between them**, so the banner could not report the + * degradation — it never learned of it. Two statements about one boot, produced + * independently, and the louder one was the wrong one. + * + * ⇒ A test that hands a formatter a hand-written list asserts nothing about + * that. It exercises exactly one of the two packages that each spoke alone, + * which is the very perspective that produced the defect. So every degraded + * assertion below starts from a REAL `ObjectKernel` bootstrap whose `auth` + * service is genuinely absent, reads the conclusion the way `serve` reads it, + * and prints the REAL banner from it. + * + * ## One output, both sides + * + * The two statements do not even share a stream: `ObjectLogger` writes `warn` + * to `process.stdout`, the banner writes to `process.stderr` via + * `console.error`. A terminal — and a CI log — interleaves them into ONE + * transcript, which is where a reader met the contradiction: on this repo's own + * registry canary (run `34084559243`, job `101626009369`, the published + * `npx create-objectstack@latest` on-ramp) `✓ Server is ready` printed directly + * ABOVE the four boot warnings that said the opposite. `beforeEach` below + * reassembles that single transcript on purpose, so the assertions can hold + * both sentences against ONE output rather than two. + * + * ## The three properties, and the one that is easy to lose + * + * 1. degraded ⇒ the ready line names what the kernel found missing, and the + * unconditional `✓` is gone; + * 2. healthy ⇒ the ready block is byte-for-byte what it has always been — + * the property an "always append a status line" implementation quietly + * spends to buy (1); + * 3. `Server is ready` still appears on a degraded boot. Readiness is NOT + * made strict (a machine deliberately booted without auth still boots and + * still exits 0); the line only says what state it is ready in. + */ + +/** A plugin that registers exactly the named kernel services and nothing else. */ +function servicesPlugin(names: string[]): Plugin { + return { + name: 'com.objectstack.test.degraded-boot-fixture', + version: '1.0.0', + init: async (ctx) => { + for (const name of names) ctx.registerService(name, { fixture: name }); + }, + }; +} + +/** + * Boot a real kernel providing exactly `names`, and hand back the kernel. + * + * `data` is `required` (the kernel throws without it) and `auth`/`job` are the + * two `core` services with no in-memory fallback — see `CORE_FALLBACK_FACTORIES` + * — so they are the only ones that can ever reach the degraded list. Omitting + * `auth` reproduces both filed incidents; the rest are pre-injected. + */ +async function bootKernel(names: string[]): Promise { + const kernel = new ObjectKernel({ + logger: { level: 'warn' }, + // ⛔ Not `skipSystemValidation` — that is the branch under test. + gracefulShutdown: false, + }); + await kernel.use(servicesPlugin(names)); + await kernel.bootstrap(); + return kernel; +} + +/** Banner options held fixed across the legs, so only the boot differs. */ +const BASE: ServerReadyOptions = { + externalBaseOrigin: 'http://localhost:3000', + isDev: true, + pluginCount: 3, +}; + +/** + * The healthy ready block, verbatim, as `printServerReady` has always emitted + * it for {@link BASE} under NO_COLOR. + * + * ⛔ This literal is the point of the byte-identity leg — do not regenerate it + * from the implementation. It is transcribed from the block the same options + * produced before #16630 touched this function, and its ready line is + * character-identical to the one in `bannerFor()` in + * `test/serve-port-readback.e2e.test.ts`, which was transcribed independently + * from a real boot. + */ +const HEALTHY_READY_BLOCK = [ + '', + ' ✓ Server is ready', + '', + ' ➜ API: http://localhost:3000/', + '', + ' Mode: development', + ' Plugins: 3 loaded', + '', + ' Press Ctrl+C to stop', + '', +]; + +let transcript: string[]; +let errSpy: ReturnType; +let outSpy: ReturnType; + +/** Strip SGR so assertions hold whether or not chalk colors this run. */ +const plain = (s: string) => s.replace(/\u001b\[[0-9;]*m/g, ''); + +beforeEach(() => { + transcript = []; + // The banner (stderr, #7915) … + errSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + transcript.push(plain(args.join(' '))); + }); + // … and the kernel's own `warn` (stdout — see `ObjectLogger.emit`), into the + // SAME buffer and in emission order, which is what a terminal shows. + outSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(((chunk: unknown) => { + for (const line of plain(String(chunk)).split('\n')) { + if (line !== '') transcript.push(line); + } + return true; + }) as never); +}); + +afterEach(() => { + errSpy.mockRestore(); + outSpy.mockRestore(); +}); + +describe('the ready signal on a degraded boot (#16630)', () => { + it("carries the kernel's own missing-core-service list from core to the banner", async () => { + const kernel = await bootKernel(['data', 'job']); // auth deliberately absent + + // ── the data path, read exactly as `serve` reads it ────────────── + const missingCoreServices = readMissingCoreServices(kernel); + expect(missingCoreServices, 'the kernel published no degraded readout').toEqual(['auth']); + + printServerReady({ ...BASE, missingCoreServices }); + const output = transcript.join('\n'); + + // ── BOTH sides, in ONE output ──────────────────────────────────── + // The producer: the kernel's own sentence, unchanged. + expect(output).toContain('System started with degraded capabilities. Missing core services: auth'); + // The consumer: the ready line, now reporting the same fact. + expect(output).toContain('Server is ready'); + expect(output).toContain('⚠ Server is ready — DEGRADED: missing core services: auth'); + + // ⭐ The defect itself: an unconditional green tick over a boot the kernel + // had just called degraded. This is the assertion that was impossible to + // write before the data path existed. + expect(output).not.toContain('✓ Server is ready'); + + await kernel.shutdown(); + }); + + it('names the SAME list the kernel printed, never a separately computed one', async () => { + // Both fallback-less core services absent at once: the banner must render + // the kernel's list as the kernel ordered it, not a set of its own. + const kernel = await bootKernel(['data']); + + const missingCoreServices = readMissingCoreServices(kernel); + printServerReady({ ...BASE, missingCoreServices }); + + const kernelLine = transcript.find((l) => l.includes('Missing core services:')) ?? ''; + const bannerLine = transcript.find((l) => l.includes('Server is ready')) ?? ''; + const namesFrom = (line: string) => line.slice(line.lastIndexOf(':') + 1).trim(); + + expect(kernelLine, 'the kernel said nothing about degraded capabilities').not.toBe(''); + expect(namesFrom(bannerLine)).toBe(namesFrom(kernelLine)); + expect(namesFrom(bannerLine)).toBe('auth, job'); + + await kernel.shutdown(); + }); + + it('leaves a healthy boot byte-identical — no readout, no extra line', async () => { + const kernel = await bootKernel(['data', 'auth', 'job']); + + // Nothing published ⇒ nothing to say. `getService` throws on a healthy + // boot and the reader reports that as "no degradation", not as unknown. + expect(readMissingCoreServices(kernel)).toBeUndefined(); + expect(transcript, 'a healthy boot logged a degraded-capabilities warning').toEqual([]); + + printServerReady({ ...BASE, missingCoreServices: readMissingCoreServices(kernel) }); + + expect(transcript).toEqual(HEALTHY_READY_BLOCK); + + await kernel.shutdown(); + }); + + it('treats an empty list as healthy, so no caller can append an empty warning', () => { + printServerReady({ ...BASE, missingCoreServices: [] }); + expect(transcript).toEqual(HEALTHY_READY_BLOCK); + }); + + it('reports nothing rather than throwing when there is no kernel to ask', () => { + // The readout is a diagnostic. It must never be able to fail a boot the + // kernel has already decided is good enough to run. + expect(readMissingCoreServices(undefined)).toBeUndefined(); + expect(readMissingCoreServices({})).toBeUndefined(); + expect(readMissingCoreServices({ getService: () => { throw new Error('nope'); } })).toBeUndefined(); + expect(readMissingCoreServices({ getService: () => ({ missingCoreServices: 'auth' }) })).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index 5bb41e8bcb..b4364b553c 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -694,6 +694,41 @@ export interface ServerReadyOptions { * them here, so they land under the banner instead of nowhere. */ bootDiagnostics?: BootDiagnostics; + /** + * The core services the KERNEL concluded were missing on this boot (#16630). + * + * This is `ObjectKernel.validateSystemRequirements()`'s OWN list — the very + * array behind its `System started with degraded capabilities. Missing core + * services: …` warning — carried here over the kernel's service registry and + * read by `readMissingCoreServices` in `./degraded-capabilities.ts`. + * + * ## Why the banner takes the list instead of working it out + * + * Until this field there was no data path at all between the two statements: + * `✓ Server is ready` is printed here, in `@objectstack/cli`; the degraded + * conclusion is reached in `@objectstack/core`. So the ready line could not + * report the failure — it never learned of it — and printed a green tick over + * a boot the kernel had just called degraded. Measured on objectui CI run + * `34056438855`, and again on this repo's own published-artifact canary (run + * `34084559243`), where the tick printed directly ABOVE four boot warnings + * saying the opposite. + * + * ⛔ Never re-derive it here. Which services count as `core` is + * `ServiceRequirementDef`'s judgement (`@objectstack/spec/system`), and a + * second implementation of it on this side would be free to disagree with the + * kernel's — replacing one wrong line with two contradictory ones. + * + * ## What it changes, and what it must not + * + * Non-empty ⇒ the ready line says what state the server is ready IN, and the + * unconditional `✓` is not printed. Absent or empty ⇒ the ready block is + * byte-for-byte what it has always been. That asymmetry is the design, not an + * optimisation: readiness itself is UNCHANGED (a machine deliberately booted + * without auth still boots, still prints ready, still exits 0), and an + * implementation that always appended a status line would have reported the + * degradation while rewriting every healthy boot's output as well. + */ + missingCoreServices?: string[]; /** * Whether the MCP server surface (`/api/v1/mcp`) is on (#3167). Default-on * core capability, but nothing in the dev loop surfaces it — an AI client @@ -806,7 +841,20 @@ export function printServerReady(opts: ServerReadyOptions) { const base = opts.externalBaseOrigin; const link = (path: string) => (base === null ? path : base + path); console.error(''); - console.error(chalk.bold.green(' ✓ Server is ready')); + // [#16630] The ready line carries the degradation the kernel already + // concluded. ⛔ Not a readiness gate: nothing about what boots, binds or + // exits changes here — the line only stops claiming an unqualified `✓` for a + // state the system has already recorded as degraded. On a healthy boot the + // `else` arm is the original statement, unchanged, so normal output does not + // move. See {@link ServerReadyOptions.missingCoreServices}. + const missingCore = opts.missingCoreServices ?? []; + if (missingCore.length > 0) { + console.error( + chalk.bold.yellow(` ⚠ Server is ready — DEGRADED: missing core services: ${missingCore.join(', ')}`), + ); + } else { + console.error(chalk.bold.green(' ✓ Server is ready')); + } console.error(''); console.error(chalk.cyan(' ➜') + chalk.bold(' API: ') + chalk.cyan(link('/'))); if (opts.uiEnabled && opts.consolePath) { diff --git a/packages/cli/test/serve-ready-degraded-boot.e2e.test.ts b/packages/cli/test/serve-ready-degraded-boot.e2e.test.ts new file mode 100644 index 0000000000..76e199ab74 --- /dev/null +++ b/packages/cli/test/serve-ready-degraded-boot.e2e.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { spawn, type ChildProcessByStdio } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import type { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import { + childEnv, + E2E_SECRET_KEY, + portContentionError, + requireBuiltCli, + reservePort, + RUN_JS_RESOLVES_FROM_DIST, +} from './helpers/serve-process.js'; + +/** + * #16630 — a REAL `os serve` boot, degraded, read end to end. + * + * ## Why a spawned process and not only the unit legs + * + * The entire defect is that two packages each spoke alone: `✓ Server is ready` + * came out of `printServerReady` (`@objectstack/cli`) and `System started with + * degraded capabilities. Missing core services: …` out of + * `ObjectKernel.validateSystemRequirements()` (`@objectstack/core`), with no + * data path between them — so the banner could not report the degradation, and + * printed a green tick over it instead. Measured on objectui CI run + * `34056438855`, and again on this repo's own registry canary (run + * `34084559243`, job `101626009369`) where the published + * `npx create-objectstack@latest` on-ramp printed `✓ Server is ready` directly + * ABOVE four boot warnings. That job did fail — by later probes, ⛔ never by + * the ready line, which carried no decisional weight at all. + * + * ⇒ Testing one package's output is the perspective that produced the defect. + * `src/utils/format.server-ready-degraded-boot.test.ts` closes the seam over a + * real `ObjectKernel`; this file closes it over the real COMMAND — the whole + * assembly of plugins, tiers and banner a user actually runs. + * + * ## Why `bin/run.js` with `NODE_ENV` unset, and not `runServe()` + * + * The degradation has to be REACHED, not injected, and the only reachable + * spelling is production posture. `serve` auto-registers `AuthPlugin` when a + * secret resolves; outside `--dev` there is no fallback secret, so a production + * boot with no `OS_AUTH_SECRET` skips auth by the command's own documented rule + * (`⚠ AuthPlugin skipped — set OS_AUTH_SECRET …`) and `auth` — a `core` service + * with no in-memory fallback — is genuinely absent. That is the end state both + * incidents reached by other routes: a plugin that did not load, and a core + * service that is not there. + * + * ⛔ `runServe()` cannot reach it. That helper spawns `bin/run-dev.js`, which + * sets `NODE_ENV = 'development'` before argv is parsed, so `isDev` is true, + * the dev fallback secret applies, auth ALWAYS loads and the boot is never + * degraded. Measured: switching this file to `runServe()` makes both legs + * healthy and the degraded assertions unreachable — a green that measures + * nothing. `bin/run.js` with `NODE_ENV` genuinely unset is the only shape that + * reaches the gate, which is also why {@link requireBuiltCli} guards the file. + * + * ⚠️ The degraded leg is ALSO the negative control for the acceptance rule that + * start and exit behaviour must not change: it is a machine deliberately + * running without auth, and it still reaches the complete banner and is still a + * live server afterwards. Readiness is not made strict here; the ready line + * only says what state it is ready in. + */ + +/** The complete banner — keyed on its LAST line, so the whole block is on the stream. */ +const READY_BANNER_TAIL = /Press Ctrl\+C to stop/; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +/** `bin/run.js` — the SHIPPED entrypoint. See the header for why this one. */ +const CLI = resolve(HERE, '../bin/run.js'); + +const BARE_CONFIG = 'export default {};\n'; + +/** What `spawn(…, { stdio: ['ignore', 'pipe', 'pipe'] })` returns — no `stdin`. */ +type BootChild = ChildProcessByStdio; + +const children: BootChild[] = []; +let fixtureDir: string; + +interface Boot { + out: string; + err: string; + child: BootChild; +} + +/** + * Spawn `os serve` in production posture and resolve once the COMPLETE banner + * is on the stream. Extra `env` entries override the base; `undefined` unsets. + */ +async function boot(env: Record = {}): Promise { + const port = reservePort(); + const child = spawn(process.execPath, [CLI, 'serve', 'objectstack.config.ts', '--port', String(port)], { + cwd: fixtureDir, + stdio: ['ignore', 'pipe', 'pipe'], + // `childEnv`, never a bare `...process.env` — see its header (#11267). + env: childEnv({ + NO_COLOR: '1', + OS_DATABASE_URL: ':memory:', + OS_LOG_LEVEL: '', + OS_DISABLE_CONSOLE: '1', + // Production posture refuses to mint a crypto key; supplying a stable one + // keeps the refusal out of the way of the property under test. + OS_SECRET_KEY: E2E_SECRET_KEY, + // ⭐ Truly unset — the value that leaves the boot in production posture + // (and leaves oclif resolving the command from `dist/`). Node omits an + // `undefined` entry rather than inheriting the vitest worker's own. + NODE_ENV: undefined, + ...env, + }), + }) as BootChild; + children.push(child); + + let out = ''; + let err = ''; + + await new Promise((ready, fail) => { + const timer = setTimeout(() => { + fail(new Error( + 'serve never printed its COMPLETE ready banner (last line: "Press Ctrl+C to stop")' + + `\n--- stdout ---\n${out}\n--- stderr ---\n${err}`, + )); + }, 150_000); + const onData = () => { + if (READY_BANNER_TAIL.test(out + err)) { + clearTimeout(timer); + ready(); + } + }; + child.stdout.on('data', (d) => { out += String(d); onData(); }); + child.stderr.on('data', (d) => { err += String(d); onData(); }); + child.on('exit', (code) => { + clearTimeout(timer); + // A lost port race gets its own failure before the generic one (#12441): + // `serve exited 1 before the banner` is what it produces otherwise, and + // that reads as a verdict about this file's subject when it is not. + fail( + portContentionError(out + err, 'os serve (bin/run.js, NODE_ENV unset ⇒ production)', port) + ?? new Error(`serve exited ${code} before the ready banner\n--- stdout ---\n${out}\n--- stderr ---\n${err}`), + ); + }); + }); + + return { out, err, child }; +} + +beforeAll(() => { + requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST); + fixtureDir = mkdtempSync(join(tmpdir(), 'os-ready-degraded-')); + writeFileSync(join(fixtureDir, 'objectstack.config.ts'), BARE_CONFIG, 'utf8'); +}); + +afterAll(() => { + for (const child of children) { + try { child.kill('SIGTERM'); } catch { /* already gone */ } + } + if (fixtureDir) rmSync(fixtureDir, { recursive: true, force: true }); +}); + +describe('os serve — the ready line reports a degraded boot (#16630)', () => { + it( + 'prints ready AND the missing core service in the same output, with no unconditional tick', + async () => { + const { out, err, child } = await boot(); + const seen = `\n--- stdout ---\n${out}\n--- stderr ---\n${err}`; + const output = out + err; + + // The premise: this boot really is degraded, and the kernel really said + // so. Asserted FIRST — without it every assertion below is vacuous. + expect(output, `this boot was not degraded${seen}`).toContain( + 'System started with degraded capabilities. Missing core services: auth', + ); + + // ⭐ BOTH sides, in ONE output — the property the two packages could not + // hold between them before the data path existed. + expect(err, `serve never reported ready${seen}`).toContain('Server is ready'); + expect(err, `the ready line withheld the degradation${seen}`).toContain( + '⚠ Server is ready — DEGRADED: missing core services: auth', + ); + + // ⭐ The defect itself, gone: the green tick may not appear on a boot the + // kernel has recorded as degraded. + expect(err, `the unconditional green tick survived${seen}`).not.toContain('✓ Server is ready'); + + // ⛔ Start behaviour unchanged. Reaching here already means the COMPLETE + // banner printed; the process being alive is the other half. + expect(output).not.toContain('rollback complete'); + expect(child.exitCode, `serve exited during a boot it called ready${seen}`).toBeNull(); + }, + 240_000, + ); + + it( + 'leaves a healthy boot printing exactly the tick it always printed', + async () => { + // Same fixture, same entrypoint, same posture — the ONLY difference is + // that auth can now register, so nothing is missing. An "always append a + // status line" implementation passes the degraded leg and fails here. + const { out, err } = await boot({ + OS_AUTH_SECRET: 'os-16630-healthy-leg-secret-not-a-real-credential', + }); + const seen = `\n--- stdout ---\n${out}\n--- stderr ---\n${err}`; + const output = out + err; + + // The premise for THIS leg: nothing was missing. + expect(output, `the healthy leg booted degraded${seen}`).not.toContain( + 'System started with degraded capabilities', + ); + + expect(err, `healthy boot lost its ready tick${seen}`).toContain('✓ Server is ready'); + expect(err, `healthy boot grew a degraded notice${seen}`).not.toContain('DEGRADED'); + }, + 240_000, + ); +}); diff --git a/packages/core/src/kernel.ts b/packages/core/src/kernel.ts index d541278af1..55b3458831 100644 --- a/packages/core/src/kernel.ts +++ b/packages/core/src/kernel.ts @@ -17,6 +17,49 @@ import { dispatchHookIsolating, dispatchHookPropagating } from './hook-dispatch. import { registerPluginByName } from './plugin-registration.js'; import { raceWithTimeout } from './timeout-guard.js'; +/** + * The kernel service a DEGRADED boot's conclusion is published on (#16630). + * + * ## Why this exists at all + * + * `validateSystemRequirements()` below decides which `core` services are + * missing and says so with `logger.warn`. That sentence was, until this + * constant, the ONLY form the conclusion took — and a log line is not a datum: + * the CLI's ready banner (`printServerReady`, `@objectstack/cli`) is emitted + * from a different package with no data path back to here, so it printed a + * green `✓ Server is ready` over a boot this method had just declared + * degraded. Two statements about one boot, produced independently, and the + * louder one was the wrong one. Measured twice within a day: objectui CI run + * `34056438855` (auth plugin failed, no `sys_*` table, ready printed anyway) + * and this repo's own published-artifact canary, run `34084559243`, where + * `✓ Server is ready` printed ABOVE four boot warnings including this one. + * + * ## Why a service entry and not a new export + * + * The kernel's service registry is ALREADY the seam every boot fact crosses on + * its way to that banner: `serve` reads `auth` (for the seeded dev admin) and + * `seed-summary` (the per-source seed outcomes `@objectstack/runtime` stashes + * the same way) off exactly this map, through the `getService` accessor this + * class already publishes. Publishing here therefore adds no member and no + * type to `@objectstack/core`'s public surface — the fact rides a boundary + * that is already crossed. + * + * ## Reading it + * + * Present ⇔ this boot was degraded. `getService` THROWS when it is absent, and + * absent is the healthy case, so every reader catches and treats the throw as + * "nothing to report" — the same shape `serve` already uses for the two reads + * above. The value is `{ missingCoreServices: string[] }`, frozen, holding the + * very array the warning above rendered: a reader must ⛔ never re-derive which + * services count as `core`, because that judgement is `ServiceRequirementDef`'s + * and a second implementation of it is free to disagree with this one. + * + * Dotted, kernel-owned name on purpose: every capability service is a bare noun + * (`auth`, `metadata`, `job`), and the two prefixes anything scans for are + * `driver.` and `app.`, so this collides with neither. + */ +const DEGRADED_CAPABILITIES_SERVICE = 'kernel.degraded-capabilities'; + /** * Enhanced Kernel Configuration */ @@ -325,11 +368,50 @@ export class ObjectKernel { if (missingCoreServices.length > 0) { this.logger.warn(`System started with degraded capabilities. Missing core services: ${missingCoreServices.join(', ')}`); + // [#16630] Say it as DATA as well as prose, so a reader outside this + // package can report the same conclusion instead of contradicting + // it. Same array, same moment — see DEGRADED_CAPABILITIES_SERVICE. + this.publishDegradedCapabilities(missingCoreServices); } this.logger.info('System requirement check passed'); } + /** + * Publish this boot's degraded-capabilities conclusion on + * {@link DEGRADED_CAPABILITIES_SERVICE} — the data half of the warning + * `validateSystemRequirements()` just logged (#16630). + * + * ⛔ Best-effort, and silent on failure BY DESIGN: this is a diagnostic + * readout, and a readout must never be able to fail a boot that the kernel + * has just decided is good enough to run. The one way `registerService` + * can throw here is a name collision, which the guard above already + * forecloses; the `catch` is there so that stays true if either ever + * changes. (`recordSeedOutcome` in `@objectstack/runtime` states the same + * rule for the same reason.) + * + * The value is FROZEN and holds a COPY. `getService` hands out the stored + * reference, so an unfrozen live array would let any reader edit the + * kernel's own record of what was missing — and this record exists + * precisely so that two packages cannot disagree about it. + */ + private publishDegradedCapabilities(missingCoreServices: string[]): void { + try { + if ( + this.services.has(DEGRADED_CAPABILITIES_SERVICE) + || this.pluginLoader.hasService(DEGRADED_CAPABILITIES_SERVICE) + ) { + return; + } + this.registerService( + DEGRADED_CAPABILITIES_SERVICE, + Object.freeze({ missingCoreServices: Object.freeze([...missingCoreServices]) }), + ); + } catch { + /* diagnostic only — never let the readout break a boot */ + } + } + /** * Bootstrap the kernel with enhanced features */ diff --git a/scripts/check-cli-test-child-env.mjs b/scripts/check-cli-test-child-env.mjs index 22b37d1511..a019ed2dbc 100644 --- a/scripts/check-cli-test-child-env.mjs +++ b/scripts/check-cli-test-child-env.mjs @@ -2601,11 +2601,16 @@ export function selfTest() { .filter((abs) => builtEntrypointSpawns(abs, readFileSync(abs, 'utf8')).spawns > 0) .map((abs) => relative(REPO_ROOT, abs).split(sep).join('/')) .sort(); - t('the built-entrypoint population is exactly the four files that spawn bin/run.js', + t('the built-entrypoint population is exactly the five files that spawn bin/run.js', JSON.stringify(builtFiles) === JSON.stringify([ 'packages/cli/test/serve-mcp-capability-collision.e2e.test.ts', 'packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts', 'packages/cli/test/serve-node-env-production-default.e2e.test.ts', + // [#16630] Joined the population deliberately: the degraded-boot the + // ready line has to report is only reachable in PRODUCTION posture, and + // `bin/run-dev.js` sets `NODE_ENV=development` before argv is parsed — + // so the tsx shim every `runServe()` caller uses cannot reach it at all. + 'packages/cli/test/serve-ready-degraded-boot.e2e.test.ts', 'packages/cli/test/serve-stdio-stdout-purity.e2e.test.ts', ]), JSON.stringify(builtFiles));