Skip to content

Commit 4771bd9

Browse files
claude[bot]os-project-managerclaude
authored
fix(cli,core): the ready line reports the degraded boot the kernel already concluded (#16869)
* wip(cli,core): carry the kernel's degraded-boot conclusion into the ready banner Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 * fix(cli,core): the ready line reports the degraded boot the kernel already concluded Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 * chore(scripts): admit the degraded-boot e2e to the built-entrypoint census Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --------- Co-authored-by: objectstack-agent <pm@objectstack.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 4cfc93b commit 4771bd9

8 files changed

Lines changed: 691 additions & 2 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/core": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
The `Server is ready` line now reports the degraded boot it is standing on, instead of printing a green `` over it.
7+
8+
`✓ 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.
9+
10+
- **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.
11+
- **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.
12+
- **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.

packages/cli/src/commands/serve.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ import {
111111
type AutomationReadySummary,
112112
type SeedSourceSummary,
113113
} from '../utils/format.js';
114+
// [#16630] The reader half of the degraded-boot data path — the kernel's own
115+
// missing-core-service list, fetched, never re-derived. See its header.
116+
import { readMissingCoreServices } from '../utils/degraded-capabilities.js';
114117
import { redirectStdoutToStderr } from '../utils/json-stdout.js';
115118
import {
116119
CONSOLE_PATH,
@@ -4735,6 +4738,18 @@ export default class Serve extends Command {
47354738
if (Array.isArray(s) && s.length > 0) seedSummary = s;
47364739
} catch { /* no seeds ran — nothing to show */ }
47374740

4741+
// ── Degraded-capabilities readout (#16630) ─────────────────────
4742+
// The banner and the kernel's `System started with degraded
4743+
// capabilities. Missing core services: …` warning used to be two
4744+
// statements about one boot with NO data path between them — printed
4745+
// from two packages, and the louder one (`✓ Server is ready`) was the
4746+
// wrong one. This read IS that path: the same kernel handle the two
4747+
// reads above use, the same `getService` accessor, and the kernel's own
4748+
// list handed on untouched. ⛔ Nothing here decides which services count
4749+
// as core — that judgement stays in one place, `ServiceRequirementDef`.
4750+
// Absent on every healthy boot, where the banner is unchanged.
4751+
const missingCoreServices = readMissingCoreServices(kernel);
4752+
47384753
// ── Multi-node licence reading → telemetry (#12667) ────────────
47394754
// The advisory the gate produced at boot, published where a deployment's
47404755
// metrics pipeline already looks. Emitted HERE, after every plugin has
@@ -4825,6 +4840,10 @@ export default class Serve extends Command {
48254840
seededAdmin,
48264841
automation: automationSummary,
48274842
seeds: seedSummary,
4843+
// #16630 — what the kernel already knows about this boot, so the ready
4844+
// line can say what state it is ready in. `undefined` on a healthy
4845+
// boot, where the ready block prints exactly what it always has.
4846+
missingCoreServices,
48284847
// #4012 — every boot-phase `logger.warn` the quiet window intercepted,
48294848
// replayed here. Without this the window is a drain: the ADR-0110 D5
48304849
// `[action-governance]` inventory, degraded-boot notices and flow
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The CLI half of the degraded-boot data path (#16630).
5+
*
6+
* ## The defect this closes
7+
*
8+
* `✓ Server is ready` and `System started with degraded capabilities. Missing
9+
* core services: …` came from two different packages — `printServerReady` here
10+
* in `@objectstack/cli`, `ObjectKernel.validateSystemRequirements()` in
11+
* `@objectstack/core` — with **no data path between them**. So the banner could
12+
* not report the degradation: it never learned of it. It printed a green tick
13+
* over a boot the kernel had just called degraded, on objectui CI run
14+
* `34056438855` and again on this repo's own published-artifact canary, run
15+
* `34084559243`, where the tick printed directly ABOVE the four boot warnings
16+
* that said otherwise. That canary is the only pipeline validating the
17+
* published on-ramp from outside, and the ready signal carried no weight in its
18+
* verdict at all — it was present, green, wrong, and believed by nobody.
19+
*
20+
* ## What this module is, and what it deliberately is NOT
21+
*
22+
* It is a READER. The kernel decides which services are `core`
23+
* (`ServiceRequirementDef`, `@objectstack/spec/system`) and which of them are
24+
* missing; this function fetches that answer and hands it on unchanged.
25+
*
26+
* ⛔ It must never re-derive that judgement. A second implementation of "which
27+
* services count as core" on this side is free to disagree with the kernel's,
28+
* and then the banner is wrong in a new way instead of the old one — with two
29+
* plausible answers on one screen and nothing to say which is authoritative.
30+
* That is why nothing here reads `ServiceRequirementDef`, counts services, or
31+
* inspects plugins: the ONLY input is the list the kernel published.
32+
*
33+
* ## Absent means healthy, and why a throw is the healthy path
34+
*
35+
* The kernel publishes the readout only when a boot IS degraded, so on a
36+
* healthy boot `getService` throws `Service '…' not found` — the same shape
37+
* `serve` already handles for the `auth` and `seed-summary` reads next to this
38+
* one. The throw is caught and reported as `undefined`, which the banner
39+
* renders as today's unchanged output, byte for byte.
40+
*/
41+
42+
/**
43+
* The kernel service the degraded conclusion is published on.
44+
*
45+
* ⚠️ The producer's copy of this string is `DEGRADED_CAPABILITIES_SERVICE` in
46+
* `packages/core/src/kernel.ts`. They are two literals for one name, which is
47+
* how every kernel-service name crossing this boundary is already spelled
48+
* (`auth`, `seed-summary`, `driver.*`, `app.*` are all bare literals on both
49+
* sides). What holds them equal is not a shared constant but a test that drives
50+
* a REAL degraded kernel boot through this reader —
51+
* `format.server-ready-degraded-boot.test.ts` — so a rename on either side
52+
* reddens instead of silently restoring the green tick.
53+
*/
54+
const DEGRADED_CAPABILITIES_SERVICE = 'kernel.degraded-capabilities';
55+
56+
/** The shape `ObjectKernel` publishes. Structural — nothing is imported for it. */
57+
interface DegradedCapabilitiesReadout {
58+
missingCoreServices?: unknown;
59+
}
60+
61+
/** Just enough of the kernel to ask it a question. */
62+
interface ServiceReader {
63+
getService?: (name: string) => unknown;
64+
}
65+
66+
/**
67+
* The core services the kernel reported missing on this boot, or `undefined`
68+
* when it reported none — i.e. when the boot was not degraded.
69+
*
70+
* Returns a fresh array: the stored value is frozen on purpose (it is the
71+
* kernel's own record), and the banner's options are ordinary mutable data.
72+
*
73+
* Never throws. A readout that cannot be read is reported as "nothing to say",
74+
* exactly as an absent one is — this is a diagnostic, and a diagnostic that can
75+
* abort a boot the kernel already approved would be a worse defect than the one
76+
* it exists to fix.
77+
*/
78+
export function readMissingCoreServices(kernel: unknown): string[] | undefined {
79+
try {
80+
const readout = (kernel as ServiceReader | null | undefined)?.getService?.(
81+
DEGRADED_CAPABILITIES_SERVICE,
82+
) as DegradedCapabilitiesReadout | undefined;
83+
const list = readout?.missingCoreServices;
84+
if (!Array.isArray(list)) return undefined;
85+
const names = list.filter((n): n is string => typeof n === 'string' && n.length > 0);
86+
return names.length > 0 ? [...names] : undefined;
87+
} catch {
88+
// Healthy boot: the kernel published nothing and `getService` threw.
89+
return undefined;
90+
}
91+
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { ObjectKernel, type Plugin } from '@objectstack/core';
5+
import { printServerReady, type ServerReadyOptions } from './format.js';
6+
import { readMissingCoreServices } from './degraded-capabilities.js';
7+
8+
/**
9+
* #16630 — the ready signal must report the degraded boot it is standing on.
10+
*
11+
* ## Why this file boots a REAL kernel instead of unit-testing the formatter
12+
*
13+
* The defect was never a wording problem. `✓ Server is ready` is printed by
14+
* `printServerReady` in `@objectstack/cli`; `System started with degraded
15+
* capabilities. Missing core services: …` is concluded by
16+
* `ObjectKernel.validateSystemRequirements()` in `@objectstack/core`; and there
17+
* was **no data path between them**, so the banner could not report the
18+
* degradation — it never learned of it. Two statements about one boot, produced
19+
* independently, and the louder one was the wrong one.
20+
*
21+
* ⇒ A test that hands a formatter a hand-written list asserts nothing about
22+
* that. It exercises exactly one of the two packages that each spoke alone,
23+
* which is the very perspective that produced the defect. So every degraded
24+
* assertion below starts from a REAL `ObjectKernel` bootstrap whose `auth`
25+
* service is genuinely absent, reads the conclusion the way `serve` reads it,
26+
* and prints the REAL banner from it.
27+
*
28+
* ## One output, both sides
29+
*
30+
* The two statements do not even share a stream: `ObjectLogger` writes `warn`
31+
* to `process.stdout`, the banner writes to `process.stderr` via
32+
* `console.error`. A terminal — and a CI log — interleaves them into ONE
33+
* transcript, which is where a reader met the contradiction: on this repo's own
34+
* registry canary (run `34084559243`, job `101626009369`, the published
35+
* `npx create-objectstack@latest` on-ramp) `✓ Server is ready` printed directly
36+
* ABOVE the four boot warnings that said the opposite. `beforeEach` below
37+
* reassembles that single transcript on purpose, so the assertions can hold
38+
* both sentences against ONE output rather than two.
39+
*
40+
* ## The three properties, and the one that is easy to lose
41+
*
42+
* 1. degraded ⇒ the ready line names what the kernel found missing, and the
43+
* unconditional `✓` is gone;
44+
* 2. healthy ⇒ the ready block is byte-for-byte what it has always been —
45+
* the property an "always append a status line" implementation quietly
46+
* spends to buy (1);
47+
* 3. `Server is ready` still appears on a degraded boot. Readiness is NOT
48+
* made strict (a machine deliberately booted without auth still boots and
49+
* still exits 0); the line only says what state it is ready in.
50+
*/
51+
52+
/** A plugin that registers exactly the named kernel services and nothing else. */
53+
function servicesPlugin(names: string[]): Plugin {
54+
return {
55+
name: 'com.objectstack.test.degraded-boot-fixture',
56+
version: '1.0.0',
57+
init: async (ctx) => {
58+
for (const name of names) ctx.registerService(name, { fixture: name });
59+
},
60+
};
61+
}
62+
63+
/**
64+
* Boot a real kernel providing exactly `names`, and hand back the kernel.
65+
*
66+
* `data` is `required` (the kernel throws without it) and `auth`/`job` are the
67+
* two `core` services with no in-memory fallback — see `CORE_FALLBACK_FACTORIES`
68+
* — so they are the only ones that can ever reach the degraded list. Omitting
69+
* `auth` reproduces both filed incidents; the rest are pre-injected.
70+
*/
71+
async function bootKernel(names: string[]): Promise<ObjectKernel> {
72+
const kernel = new ObjectKernel({
73+
logger: { level: 'warn' },
74+
// ⛔ Not `skipSystemValidation` — that is the branch under test.
75+
gracefulShutdown: false,
76+
});
77+
await kernel.use(servicesPlugin(names));
78+
await kernel.bootstrap();
79+
return kernel;
80+
}
81+
82+
/** Banner options held fixed across the legs, so only the boot differs. */
83+
const BASE: ServerReadyOptions = {
84+
externalBaseOrigin: 'http://localhost:3000',
85+
isDev: true,
86+
pluginCount: 3,
87+
};
88+
89+
/**
90+
* The healthy ready block, verbatim, as `printServerReady` has always emitted
91+
* it for {@link BASE} under NO_COLOR.
92+
*
93+
* ⛔ This literal is the point of the byte-identity leg — do not regenerate it
94+
* from the implementation. It is transcribed from the block the same options
95+
* produced before #16630 touched this function, and its ready line is
96+
* character-identical to the one in `bannerFor()` in
97+
* `test/serve-port-readback.e2e.test.ts`, which was transcribed independently
98+
* from a real boot.
99+
*/
100+
const HEALTHY_READY_BLOCK = [
101+
'',
102+
' ✓ Server is ready',
103+
'',
104+
' ➜ API: http://localhost:3000/',
105+
'',
106+
' Mode: development',
107+
' Plugins: 3 loaded',
108+
'',
109+
' Press Ctrl+C to stop',
110+
'',
111+
];
112+
113+
let transcript: string[];
114+
let errSpy: ReturnType<typeof vi.spyOn>;
115+
let outSpy: ReturnType<typeof vi.spyOn>;
116+
117+
/** Strip SGR so assertions hold whether or not chalk colors this run. */
118+
const plain = (s: string) => s.replace(/\u001b\[[0-9;]*m/g, '');
119+
120+
beforeEach(() => {
121+
transcript = [];
122+
// The banner (stderr, #7915) …
123+
errSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
124+
transcript.push(plain(args.join(' ')));
125+
});
126+
// … and the kernel's own `warn` (stdout — see `ObjectLogger.emit`), into the
127+
// SAME buffer and in emission order, which is what a terminal shows.
128+
outSpy = vi
129+
.spyOn(process.stdout, 'write')
130+
.mockImplementation(((chunk: unknown) => {
131+
for (const line of plain(String(chunk)).split('\n')) {
132+
if (line !== '') transcript.push(line);
133+
}
134+
return true;
135+
}) as never);
136+
});
137+
138+
afterEach(() => {
139+
errSpy.mockRestore();
140+
outSpy.mockRestore();
141+
});
142+
143+
describe('the ready signal on a degraded boot (#16630)', () => {
144+
it("carries the kernel's own missing-core-service list from core to the banner", async () => {
145+
const kernel = await bootKernel(['data', 'job']); // auth deliberately absent
146+
147+
// ── the data path, read exactly as `serve` reads it ──────────────
148+
const missingCoreServices = readMissingCoreServices(kernel);
149+
expect(missingCoreServices, 'the kernel published no degraded readout').toEqual(['auth']);
150+
151+
printServerReady({ ...BASE, missingCoreServices });
152+
const output = transcript.join('\n');
153+
154+
// ── BOTH sides, in ONE output ────────────────────────────────────
155+
// The producer: the kernel's own sentence, unchanged.
156+
expect(output).toContain('System started with degraded capabilities. Missing core services: auth');
157+
// The consumer: the ready line, now reporting the same fact.
158+
expect(output).toContain('Server is ready');
159+
expect(output).toContain('⚠ Server is ready — DEGRADED: missing core services: auth');
160+
161+
// ⭐ The defect itself: an unconditional green tick over a boot the kernel
162+
// had just called degraded. This is the assertion that was impossible to
163+
// write before the data path existed.
164+
expect(output).not.toContain('✓ Server is ready');
165+
166+
await kernel.shutdown();
167+
});
168+
169+
it('names the SAME list the kernel printed, never a separately computed one', async () => {
170+
// Both fallback-less core services absent at once: the banner must render
171+
// the kernel's list as the kernel ordered it, not a set of its own.
172+
const kernel = await bootKernel(['data']);
173+
174+
const missingCoreServices = readMissingCoreServices(kernel);
175+
printServerReady({ ...BASE, missingCoreServices });
176+
177+
const kernelLine = transcript.find((l) => l.includes('Missing core services:')) ?? '';
178+
const bannerLine = transcript.find((l) => l.includes('Server is ready')) ?? '';
179+
const namesFrom = (line: string) => line.slice(line.lastIndexOf(':') + 1).trim();
180+
181+
expect(kernelLine, 'the kernel said nothing about degraded capabilities').not.toBe('');
182+
expect(namesFrom(bannerLine)).toBe(namesFrom(kernelLine));
183+
expect(namesFrom(bannerLine)).toBe('auth, job');
184+
185+
await kernel.shutdown();
186+
});
187+
188+
it('leaves a healthy boot byte-identical — no readout, no extra line', async () => {
189+
const kernel = await bootKernel(['data', 'auth', 'job']);
190+
191+
// Nothing published ⇒ nothing to say. `getService` throws on a healthy
192+
// boot and the reader reports that as "no degradation", not as unknown.
193+
expect(readMissingCoreServices(kernel)).toBeUndefined();
194+
expect(transcript, 'a healthy boot logged a degraded-capabilities warning').toEqual([]);
195+
196+
printServerReady({ ...BASE, missingCoreServices: readMissingCoreServices(kernel) });
197+
198+
expect(transcript).toEqual(HEALTHY_READY_BLOCK);
199+
200+
await kernel.shutdown();
201+
});
202+
203+
it('treats an empty list as healthy, so no caller can append an empty warning', () => {
204+
printServerReady({ ...BASE, missingCoreServices: [] });
205+
expect(transcript).toEqual(HEALTHY_READY_BLOCK);
206+
});
207+
208+
it('reports nothing rather than throwing when there is no kernel to ask', () => {
209+
// The readout is a diagnostic. It must never be able to fail a boot the
210+
// kernel has already decided is good enough to run.
211+
expect(readMissingCoreServices(undefined)).toBeUndefined();
212+
expect(readMissingCoreServices({})).toBeUndefined();
213+
expect(readMissingCoreServices({ getService: () => { throw new Error('nope'); } })).toBeUndefined();
214+
expect(readMissingCoreServices({ getService: () => ({ missingCoreServices: 'auth' }) })).toBeUndefined();
215+
});
216+
});

0 commit comments

Comments
 (0)