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
65 changes: 65 additions & 0 deletions .changeset/serve-boots-without-artifact.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
---
'@objectstack/cli': patch
'@objectstack/core': patch
'@objectstack/metadata': patch
'@objectstack/runtime': patch
---

fix(cli,core,metadata,runtime): `os serve` boots with no compiled artifact — the platform does not need an application to start (#4085)

The artifact (`dist/objectstack.json`) defines an **application**. ObjectStack is
a development platform, so it has to start without one — but `os serve
objectstack.config.ts` died during boot whenever the artifact was absent:

```
Loading objectstack.config.ts...
[StandaloneStack] artifact read FAILED: path='…/dist/objectstack.json' error=ENOENT…

✗ Service 'manifest' is async - use await
```

Exit 1 — on a **known-good app** (`examples/app-todo` fails the same way with
only its `dist/objectstack.json` moved aside), and on every freshly authored
project between `os init` and its first `os compile`. The message named neither
the missing artifact nor a fix, so it read as an internal kernel fault.

Three separate faults, each of which alone was enough to refuse the boot:

- **`serve` registered the config-derived `AppPlugin` before the stack's own
`plugins[]`.** Registration order *is* the kernel's init/start order, and that
slot sits ahead of `ObjectQLPlugin` (which registers `manifest`/`objectql`) and
`DefaultDatasourcePlugin` (which connects the database the app seeds through).
The wrap is now **appended** to `plugins[]`, the same slot
`createStandaloneStack` gives its artifact-derived `AppPlugin` — so config-boot
and artifact-boot share one plugin order. The artifact path never hit this,
which is exactly what made a plugin-**order** bug look artifact-related.

- **`ctx.getService()` reported a never-registered service as "is async".**
`PluginLoader.getService` is an `async` method, so its return value is *always*
a Promise and its internal "not found" rejection can never surface
synchronously — the kernel read the answer off that Promise and told every
caller to `await` a service that did not exist, while the `not found` branch
below it was unreachable. It now decides from the registry: absent ⇒
`[Kernel] Service 'x' not found`, registered-but-uninstantiated ⇒ the unchanged
`Service 'x' is async - use await`. The same crash now reads
`[Kernel] Service 'manifest' not found`, which points at the layer that is
actually wrong.

- **`MetadataPlugin` treated an absent `local-file` artifact as fatal.**
`createStandaloneStack` always points it at `dist/objectstack.json`, so a stack
with no app at all could not boot. A **missing** local artifact is now "nothing
compiled yet": it logs, starts empty, and leaves the artifact watcher armed, so
a later `os compile` hydrates the running server. The tolerance is
ENOENT-only — a malformed or unreadable artifact stays fatal — and
`bootstrap: 'artifact-only'` (sealed runtime, where the artifact *is* the
deployment) keeps failing loudly rather than silently serving an empty runtime.

`[StandaloneStack] artifact read FAILED … ENOENT` is likewise no longer shouted
at callers for whom "no artifact" is a healthy state; a present-but-unusable
artifact keeps the loud warning.

Pinned by an e2e pair that drives the real `os serve` with **no `os compile`
anywhere**: an app defined only by `objectstack.config.ts` (asserting its object
is in the started plugin set, not merely that boot survived) and a bare
`export default {}` platform. The #4012 fixture drops the `os compile` this bug
had forced on it.
30 changes: 27 additions & 3 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1020,13 +1020,37 @@ export default class Serve extends Command {
const configHasMetadata = !!(
config.objects || config.manifest || config.apps || config.flows || config.apis
);
// ORDERING (#4085): the wrap is APPENDED to `plugins` rather than
// registered here, because plugin registration order IS the kernel's
// init/start order (`resolveDependencies` preserves insertion order for
// plugins that declare no `dependencies`, and AppPlugin declares none).
// AppPlugin.init() registers its manifest through the `manifest` service
// and AppPlugin.start() seeds through the default datasource — both owned
// by plugins that live in `plugins[]` (ObjectQLPlugin /
// DefaultDatasourcePlugin, contributed by `createStandaloneStack`) and
// registered by the loop far below. Registering the wrap HERE put it
// ahead of them, so config-boot died in Phase 1 with
// "Service 'manifest' is async - use await" whenever no compiled
// `dist/objectstack.json` existed — the artifact path never hit it only
// because `createStandaloneStack` appends ITS AppPlugin after the engine
// (which also made the crash look artifact-related rather than
// order-related). Appending puts the config-derived app in exactly that
// same slot, so both boot paths share one plugin order.
if (!hasAppPluginAlready && configHasMetadata) {
try {
const { AppPlugin } = await import('@objectstack/runtime');
await kernel.use(new AppPlugin(config));
trackPlugin('App');
plugins = [...plugins, new AppPlugin(config)];
} catch (e: any) {
// silent
// Non-fatal — the platform still boots, just without this app's
// metadata. But it must SAY so: this catch was silent, and the two
// things it swallows (a malformed envelope AppPlugin rejects by
// construction, an unresolvable @objectstack/runtime) both leave a
// server answering with zero objects and no stated reason — the
// same class of invisible boot failure as #4085 itself.
console.warn(chalk.yellow(
` ⚠ Skipped registering the app defined in this config: ${e?.message ?? e}\n`
+ ' Its objects/flows will NOT be served. Fix the config (or pin an AppPlugin in `plugins`).',
));
}
}

Expand Down
97 changes: 97 additions & 0 deletions packages/cli/test/helpers/serve-process.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Shared harness for e2e tests that need the REAL `os serve` process.
*
* Some serve defects only exist above the kernel — the boot-quiet stdout window
* (#4012), the plugin registration ORDER the command assembles (#4085) — so
* they survive every in-process test and only a test that spawns the actual
* command can catch them. This module owns that spawn so each e2e file asserts
* rather than re-implements it.
*/

import { spawn } from 'node:child_process';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = resolve(fileURLToPath(import.meta.url), '..');

/** `bin/run-dev.js` — the CLI entrypoint that runs from TS source via tsx. */
export const CLI = resolve(HERE, '../../bin/run-dev.js');
export const TSX = resolve(HERE, '../../../../node_modules/.bin/tsx');

/** A random high port, so a run never contends with a dev server on this host. */
export function randomPort(): string {
return String(40000 + Math.floor(Math.random() * 20000));
}

export interface ServeRun {
stdout: string;
stderr: string;
}

/**
* Boot `os serve` in `cwd`, collect its output until `waitFor` matches (or the
* process exits), then stop it. Never leaves the child running.
*
* A boot that DIES still has to have said why, so an early exit resolves rather
* than rejects — the caller's assertions read what it printed on the way down.
*/
export function runServe(
cwd: string,
args: string[],
opts: { waitFor: RegExp; timeoutMs?: number; config?: string; env?: Record<string, string> },
): Promise<ServeRun> {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(TSX, [CLI, 'serve', opts.config ?? 'objectstack.config.ts', ...args], {
cwd,
env: {
...process.env,
NO_COLOR: '1',
// Keep the fixture self-contained: no file written, no port conflict
// with another agent's dev server, no inherited log level.
OS_DATABASE_URL: ':memory:',
OS_LOG_LEVEL: '',
OS_DISABLE_CONSOLE: '1',
...(opts.env ?? {}),
},
});

let stdout = '';
let stderr = '';
let settled = false;

const finish = (err?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
child.kill('SIGTERM');
} catch {
/* already gone */
}
if (err) rejectRun(err);
else resolveRun({ stdout, stderr });
};

const timer = setTimeout(
() =>
finish(
new Error(
`serve did not reach ${opts.waitFor} in time.\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
),
),
opts.timeoutMs ?? 180_000,
);

child.stdout.on('data', (d) => {
stdout += String(d);
if (opts.waitFor.test(stdout)) finish();
});
child.stderr.on('data', (d) => {
stderr += String(d);
});
child.on('error', (err) => finish(err));
child.on('exit', () => finish());
});
}
103 changes: 10 additions & 93 deletions packages/cli/test/serve-boot-diagnostics.e2e.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,18 +22,10 @@
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFile, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const execFileP = promisify(execFile);

const HERE = resolve(fileURLToPath(import.meta.url), '..');
const CLI = resolve(HERE, '../bin/run-dev.js');
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
import { join } from 'node:path';
import { runServe, randomPort } from './helpers/serve-process.js';

/**
* A stack whose only interesting property is that booting it MUST log a
Expand DownExpand Up@@ -67,91 +59,16 @@ export default {
};
`;

interface ServeRun {
stdout: string;
stderr: string;
}

/**
* Boot `os serve` in `cwd`, collect its output until the banner prints (or
* `waitFor` matches), then stop it. Never leaves the child running.
*/
function runServe(
cwd: string,
args: string[],
opts: { waitFor: RegExp; timeoutMs?: number },
): Promise<ServeRun> {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(TSX, [CLI, 'serve', 'objectstack.config.ts', ...args], {
cwd,
env: {
...process.env,
NO_COLOR: '1',
// Keep the fixture self-contained: no file written, no port conflict
// with another agent's dev server, no inherited log level.
OS_DATABASE_URL: ':memory:',
OS_LOG_LEVEL: '',
OS_DISABLE_CONSOLE: '1',
},
});

let stdout = '';
let stderr = '';
let settled = false;

const finish = (err?: Error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try {
child.kill('SIGTERM');
} catch {
/* already gone */
}
if (err) rejectRun(err);
else resolveRun({ stdout, stderr });
};

const timer = setTimeout(
() =>
finish(
new Error(
`serve did not reach ${opts.waitFor} in time.\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
),
),
opts.timeoutMs ?? 180_000,
);

child.stdout.on('data', (d) => {
stdout += String(d);
if (opts.waitFor.test(stdout)) finish();
});
child.stderr.on('data', (d) => {
stderr += String(d);
});
child.on('error', (err) => finish(err));
// A boot that dies still has to have said why — resolve rather than reject
// so the assertions can read what it printed on the way down.
child.on('exit', () => finish());
});
}

let dir: string;

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

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