Skip to content

Commit 857a6cf

Browse files
authored
fix(cli,core,metadata,runtime): the platform boots with no application — os serve no longer needs a compiled artifact (#4085) (#4110)
The artifact defines an APPLICATION; a development platform has to start without one. Three faults each blocked that: - `serve` registered the config-derived AppPlugin ahead of the stack's own `plugins[]` — registration order IS the kernel's init/start order, and that slot precedes ObjectQLPlugin (`manifest`/`objectql`) and DefaultDatasourcePlugin. The wrap is appended now, the same slot `createStandaloneStack` gives its artifact-derived AppPlugin, so both boot paths share one plugin order. - `ctx.getService()` reported a never-registered service as "is async" — `PluginLoader.getService` is async, so its return is always a Promise and its "not found" rejection could never surface synchronously. Decided from the registry now: absent => `[Kernel] Service 'x' not found`. - MetadataPlugin treated an absent `local-file` artifact as fatal. Missing is "nothing compiled yet" now (watcher stays armed); ENOENT-only, and `bootstrap: 'artifact-only'` still fails loudly. Also: the silent catch around the AppPlugin wrap now names why an app was skipped instead of serving zero objects without a reason. Pinned by e2e over the real `os serve` with no `os compile` anywhere.
1 parent 4f30943 commit 857a6cf

10 files changed

Lines changed: 565 additions & 125 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/core': patch
4+
'@objectstack/metadata': patch
5+
'@objectstack/runtime': patch
6+
---
7+
8+
fix(cli,core,metadata,runtime): `os serve` boots with no compiled artifact — the platform does not need an application to start (#4085)
9+
10+
The artifact (`dist/objectstack.json`) defines an **application**. ObjectStack is
11+
a development platform, so it has to start without one — but `os serve
12+
objectstack.config.ts` died during boot whenever the artifact was absent:
13+
14+
```
15+
Loading objectstack.config.ts...
16+
[StandaloneStack] artifact read FAILED: path='…/dist/objectstack.json' error=ENOENT…
17+
18+
✗ Service 'manifest' is async - use await
19+
```
20+
21+
Exit 1 — on a **known-good app** (`examples/app-todo` fails the same way with
22+
only its `dist/objectstack.json` moved aside), and on every freshly authored
23+
project between `os init` and its first `os compile`. The message named neither
24+
the missing artifact nor a fix, so it read as an internal kernel fault.
25+
26+
Three separate faults, each of which alone was enough to refuse the boot:
27+
28+
- **`serve` registered the config-derived `AppPlugin` before the stack's own
29+
`plugins[]`.** Registration order *is* the kernel's init/start order, and that
30+
slot sits ahead of `ObjectQLPlugin` (which registers `manifest`/`objectql`) and
31+
`DefaultDatasourcePlugin` (which connects the database the app seeds through).
32+
The wrap is now **appended** to `plugins[]`, the same slot
33+
`createStandaloneStack` gives its artifact-derived `AppPlugin` — so config-boot
34+
and artifact-boot share one plugin order. The artifact path never hit this,
35+
which is exactly what made a plugin-**order** bug look artifact-related.
36+
37+
- **`ctx.getService()` reported a never-registered service as "is async".**
38+
`PluginLoader.getService` is an `async` method, so its return value is *always*
39+
a Promise and its internal "not found" rejection can never surface
40+
synchronously — the kernel read the answer off that Promise and told every
41+
caller to `await` a service that did not exist, while the `not found` branch
42+
below it was unreachable. It now decides from the registry: absent ⇒
43+
`[Kernel] Service 'x' not found`, registered-but-uninstantiated ⇒ the unchanged
44+
`Service 'x' is async - use await`. The same crash now reads
45+
`[Kernel] Service 'manifest' not found`, which points at the layer that is
46+
actually wrong.
47+
48+
- **`MetadataPlugin` treated an absent `local-file` artifact as fatal.**
49+
`createStandaloneStack` always points it at `dist/objectstack.json`, so a stack
50+
with no app at all could not boot. A **missing** local artifact is now "nothing
51+
compiled yet": it logs, starts empty, and leaves the artifact watcher armed, so
52+
a later `os compile` hydrates the running server. The tolerance is
53+
ENOENT-only — a malformed or unreadable artifact stays fatal — and
54+
`bootstrap: 'artifact-only'` (sealed runtime, where the artifact *is* the
55+
deployment) keeps failing loudly rather than silently serving an empty runtime.
56+
57+
`[StandaloneStack] artifact read FAILED … ENOENT` is likewise no longer shouted
58+
at callers for whom "no artifact" is a healthy state; a present-but-unusable
59+
artifact keeps the loud warning.
60+
61+
Pinned by an e2e pair that drives the real `os serve` with **no `os compile`
62+
anywhere**: an app defined only by `objectstack.config.ts` (asserting its object
63+
is in the started plugin set, not merely that boot survived) and a bare
64+
`export default {}` platform. The #4012 fixture drops the `os compile` this bug
65+
had forced on it.

packages/cli/src/commands/serve.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,13 +1020,37 @@ export default class Serve extends Command {
10201020
const configHasMetadata = !!(
10211021
config.objects || config.manifest || config.apps || config.flows || config.apis
10221022
);
1023+
// ORDERING (#4085): the wrap is APPENDED to `plugins` rather than
1024+
// registered here, because plugin registration order IS the kernel's
1025+
// init/start order (`resolveDependencies` preserves insertion order for
1026+
// plugins that declare no `dependencies`, and AppPlugin declares none).
1027+
// AppPlugin.init() registers its manifest through the `manifest` service
1028+
// and AppPlugin.start() seeds through the default datasource — both owned
1029+
// by plugins that live in `plugins[]` (ObjectQLPlugin /
1030+
// DefaultDatasourcePlugin, contributed by `createStandaloneStack`) and
1031+
// registered by the loop far below. Registering the wrap HERE put it
1032+
// ahead of them, so config-boot died in Phase 1 with
1033+
// "Service 'manifest' is async - use await" whenever no compiled
1034+
// `dist/objectstack.json` existed — the artifact path never hit it only
1035+
// because `createStandaloneStack` appends ITS AppPlugin after the engine
1036+
// (which also made the crash look artifact-related rather than
1037+
// order-related). Appending puts the config-derived app in exactly that
1038+
// same slot, so both boot paths share one plugin order.
10231039
if (!hasAppPluginAlready && configHasMetadata) {
10241040
try {
10251041
const { AppPlugin } = await import('@objectstack/runtime');
1026-
await kernel.use(new AppPlugin(config));
1027-
trackPlugin('App');
1042+
plugins = [...plugins, new AppPlugin(config)];
10281043
} catch (e: any) {
1029-
// silent
1044+
// Non-fatal — the platform still boots, just without this app's
1045+
// metadata. But it must SAY so: this catch was silent, and the two
1046+
// things it swallows (a malformed envelope AppPlugin rejects by
1047+
// construction, an unresolvable @objectstack/runtime) both leave a
1048+
// server answering with zero objects and no stated reason — the
1049+
// same class of invisible boot failure as #4085 itself.
1050+
console.warn(chalk.yellow(
1051+
` ⚠ Skipped registering the app defined in this config: ${e?.message ?? e}\n`
1052+
+ ' Its objects/flows will NOT be served. Fix the config (or pin an AppPlugin in `plugins`).',
1053+
));
10301054
}
10311055
}
10321056

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Shared harness for e2e tests that need the REAL `os serve` process.
5+
*
6+
* Some serve defects only exist above the kernel — the boot-quiet stdout window
7+
* (#4012), the plugin registration ORDER the command assembles (#4085) — so
8+
* they survive every in-process test and only a test that spawns the actual
9+
* command can catch them. This module owns that spawn so each e2e file asserts
10+
* rather than re-implements it.
11+
*/
12+
13+
import { spawn } from 'node:child_process';
14+
import { resolve } from 'node:path';
15+
import { fileURLToPath } from 'node:url';
16+
17+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
18+
19+
/** `bin/run-dev.js` — the CLI entrypoint that runs from TS source via tsx. */
20+
export const CLI = resolve(HERE, '../../bin/run-dev.js');
21+
export const TSX = resolve(HERE, '../../../../node_modules/.bin/tsx');
22+
23+
/** A random high port, so a run never contends with a dev server on this host. */
24+
export function randomPort(): string {
25+
return String(40000 + Math.floor(Math.random() * 20000));
26+
}
27+
28+
export interface ServeRun {
29+
stdout: string;
30+
stderr: string;
31+
}
32+
33+
/**
34+
* Boot `os serve` in `cwd`, collect its output until `waitFor` matches (or the
35+
* process exits), then stop it. Never leaves the child running.
36+
*
37+
* A boot that DIES still has to have said why, so an early exit resolves rather
38+
* than rejects — the caller's assertions read what it printed on the way down.
39+
*/
40+
export function runServe(
41+
cwd: string,
42+
args: string[],
43+
opts: { waitFor: RegExp; timeoutMs?: number; config?: string; env?: Record<string, string> },
44+
): Promise<ServeRun> {
45+
return new Promise((resolveRun, rejectRun) => {
46+
const child = spawn(TSX, [CLI, 'serve', opts.config ?? 'objectstack.config.ts', ...args], {
47+
cwd,
48+
env: {
49+
...process.env,
50+
NO_COLOR: '1',
51+
// Keep the fixture self-contained: no file written, no port conflict
52+
// with another agent's dev server, no inherited log level.
53+
OS_DATABASE_URL: ':memory:',
54+
OS_LOG_LEVEL: '',
55+
OS_DISABLE_CONSOLE: '1',
56+
...(opts.env ?? {}),
57+
},
58+
});
59+
60+
let stdout = '';
61+
let stderr = '';
62+
let settled = false;
63+
64+
const finish = (err?: Error) => {
65+
if (settled) return;
66+
settled = true;
67+
clearTimeout(timer);
68+
try {
69+
child.kill('SIGTERM');
70+
} catch {
71+
/* already gone */
72+
}
73+
if (err) rejectRun(err);
74+
else resolveRun({ stdout, stderr });
75+
};
76+
77+
const timer = setTimeout(
78+
() =>
79+
finish(
80+
new Error(
81+
`serve did not reach ${opts.waitFor} in time.\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
82+
),
83+
),
84+
opts.timeoutMs ?? 180_000,
85+
);
86+
87+
child.stdout.on('data', (d) => {
88+
stdout += String(d);
89+
if (opts.waitFor.test(stdout)) finish();
90+
});
91+
child.stderr.on('data', (d) => {
92+
stderr += String(d);
93+
});
94+
child.on('error', (err) => finish(err));
95+
child.on('exit', () => finish());
96+
});
97+
}

packages/cli/test/serve-boot-diagnostics.e2e.test.ts

Lines changed: 10 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,10 @@
2222
*/
2323

2424
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
25-
import { execFile, spawn } from 'node:child_process';
26-
import { promisify } from 'node:util';
2725
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2826
import { tmpdir } from 'node:os';
29-
import { join, resolve } from 'node:path';
30-
import { fileURLToPath } from 'node:url';
31-
32-
const execFileP = promisify(execFile);
33-
34-
const HERE = resolve(fileURLToPath(import.meta.url), '..');
35-
const CLI = resolve(HERE, '../bin/run-dev.js');
36-
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
27+
import { join } from 'node:path';
28+
import { runServe, randomPort } from './helpers/serve-process.js';
3729

3830
/**
3931
* A stack whose only interesting property is that booting it MUST log a
@@ -67,91 +59,16 @@ export default {
6759
};
6860
`;
6961

70-
interface ServeRun {
71-
stdout: string;
72-
stderr: string;
73-
}
74-
75-
/**
76-
* Boot `os serve` in `cwd`, collect its output until the banner prints (or
77-
* `waitFor` matches), then stop it. Never leaves the child running.
78-
*/
79-
function runServe(
80-
cwd: string,
81-
args: string[],
82-
opts: { waitFor: RegExp; timeoutMs?: number },
83-
): Promise<ServeRun> {
84-
return new Promise((resolveRun, rejectRun) => {
85-
const child = spawn(TSX, [CLI, 'serve', 'objectstack.config.ts', ...args], {
86-
cwd,
87-
env: {
88-
...process.env,
89-
NO_COLOR: '1',
90-
// Keep the fixture self-contained: no file written, no port conflict
91-
// with another agent's dev server, no inherited log level.
92-
OS_DATABASE_URL: ':memory:',
93-
OS_LOG_LEVEL: '',
94-
OS_DISABLE_CONSOLE: '1',
95-
},
96-
});
97-
98-
let stdout = '';
99-
let stderr = '';
100-
let settled = false;
101-
102-
const finish = (err?: Error) => {
103-
if (settled) return;
104-
settled = true;
105-
clearTimeout(timer);
106-
try {
107-
child.kill('SIGTERM');
108-
} catch {
109-
/* already gone */
110-
}
111-
if (err) rejectRun(err);
112-
else resolveRun({ stdout, stderr });
113-
};
114-
115-
const timer = setTimeout(
116-
() =>
117-
finish(
118-
new Error(
119-
`serve did not reach ${opts.waitFor} in time.\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
120-
),
121-
),
122-
opts.timeoutMs ?? 180_000,
123-
);
124-
125-
child.stdout.on('data', (d) => {
126-
stdout += String(d);
127-
if (opts.waitFor.test(stdout)) finish();
128-
});
129-
child.stderr.on('data', (d) => {
130-
stderr += String(d);
131-
});
132-
child.on('error', (err) => finish(err));
133-
// A boot that dies still has to have said why — resolve rather than reject
134-
// so the assertions can read what it printed on the way down.
135-
child.on('exit', () => finish());
136-
});
137-
}
138-
13962
let dir: string;
14063

141-
beforeAll(async () => {
64+
beforeAll(() => {
14265
dir = mkdtempSync(join(tmpdir(), 'os-boot-diagnostics-e2e-'));
14366
writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG, 'utf8');
144-
// `serve` needs the compiled artifact beside the config: booting from the
145-
// config alone currently dies in `AppPlugin` with "Service 'manifest' is
146-
// async - use await" (reproducible on `examples/app-todo` too, by moving its
147-
// `dist/objectstack.json` aside) — a separate, pre-existing defect, filed
148-
// rather than worked around here.
149-
await execFileP(TSX, [CLI, 'compile'], {
150-
cwd: dir,
151-
maxBuffer: 16 * 1024 * 1024,
152-
env: { ...process.env, NO_COLOR: '1' },
153-
});
154-
}, 240_000);
67+
// No `os compile` step. This fixture used to need the artifact beside the
68+
// config because config-boot itself died in `AppPlugin` with
69+
// "Service 'manifest' is async - use await" — filed as #4085 and fixed
70+
// there, so the config alone boots now.
71+
});
15572

15673
afterAll(() => {
15774
if (dir) rmSync(dir, { recursive: true, force: true });
@@ -163,7 +80,7 @@ describe('os serve — boot-phase logger output (#4012)', () => {
16380
async () => {
16481
// Random high port: never contend with a dev server this machine is
16582
// already running (AGENTS.md multi-agent discipline §8).
166-
const port = String(40000 + Math.floor(Math.random() * 20000));
83+
const port = randomPort();
16784
const { stdout, stderr } = await runServe(dir, ['--port', port], {
16885
waitFor: /Press Ctrl\+C to stop/,
16986
});
@@ -189,7 +106,7 @@ describe('os serve — boot-phase logger output (#4012)', () => {
189106
// none of them from boot — not even the kernel's own plain
190107
// `logger.debug('Triggering kernel:ready hook')`. At a verbose level the
191108
// quiet window no longer opens at all.
192-
const port = String(40000 + Math.floor(Math.random() * 20000));
109+
const port = randomPort();
193110
const { stdout, stderr } = await runServe(dir, ['--port', port, '--log-level', 'debug'], {
194111
waitFor: /Press Ctrl\+C to stop/,
195112
});

0 commit comments

Comments
 (0)