Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/ready-signal-reports-degraded-boot.md
Original file line number Diff line number Diff line change
@@ -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: <names>`, 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.
19 changes: 19 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions packages/cli/src/utils/degraded-capabilities.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
216 changes: 216 additions & 0 deletions packages/cli/src/utils/format.server-ready-degraded-boot.test.ts
Original file line number Diff line number Diff line change
@@ -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<ObjectKernel> {
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<typeof vi.spyOn>;
let outSpy: ReturnType<typeof vi.spyOn>;

/** 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();
});
});
Loading
Loading