From beb6802d991732ac8bdc57d33f200b4b8e062fa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:12:37 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(cli,core,metadata,runtime):=20the=20pla?= =?UTF-8?q?tform=20boots=20with=20no=20application=20=E2=80=94=20`os=20ser?= =?UTF-8?q?ve`=20no=20longer=20needs=20a=20compiled=20artifact=20(#4085)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The artifact (`dist/objectstack.json`) defines an *application*. This is a development platform, so it must start without one — but config-boot died whenever the artifact was absent: Loading objectstack.config.ts... [StandaloneStack] artifact read FAILED: path='…/dist/objectstack.json' ENOENT ✗ Service 'manifest' is async - use await Exit 1 on a known-good app (`examples/app-todo` fails identically with only its artifact moved aside) and on every project between `os init` and its first `os compile`. Three faults, each alone enough to refuse the boot: - `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 (which registers `manifest`/`objectql`) and DefaultDatasourcePlugin (which connects the DB the app seeds through). The wrap is appended now, the same slot `createStandaloneStack` gives its artifact-derived AppPlugin, so both boot paths share one plugin order. The artifact path never hit this, which is what made a plugin-ORDER bug look artifact-related. - `ctx.getService()` reported a never-registered service as "is async". `PluginLoader.getService` is `async`, 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 callers to await a service that did not exist, while the `not found` branch below was unreachable. It decides from the registry now: absent ⇒ `[Kernel] Service 'x' not found`, registered-but-uninstantiated ⇒ the unchanged `is async - use await`. - MetadataPlugin treated its absent `local-file` artifact as fatal, so a stack with no app at all could not boot. A missing artifact is "nothing compiled yet" now: it logs, starts empty, and leaves the watcher armed so a later `os compile` hydrates the running server. ENOENT-only — malformed artifacts stay fatal — and `bootstrap: 'artifact-only'` (sealed runtime) still fails loudly rather than serving an empty runtime. `[StandaloneStack] artifact read FAILED … ENOENT` is likewise no longer shouted at callers for whom "no artifact" is healthy; unusable artifacts keep the warn. Pinned by an e2e pair over the real `os serve` with no `os compile` anywhere: an app defined only by `objectstack.config.ts` (asserting the app 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, and its spawn harness moves to a shared test helper. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ve9HidCGRGtS2UjNPUGHSV --- .changeset/serve-boots-without-artifact.md | 65 +++++++++ packages/cli/src/commands/serve.ts | 23 ++- packages/cli/test/helpers/serve-process.ts | 97 +++++++++++++ .../test/serve-boot-diagnostics.e2e.test.ts | 103 ++----------- .../cli/test/serve-no-artifact.e2e.test.ts | 135 ++++++++++++++++++ packages/core/src/kernel.test.ts | 43 ++++++ packages/core/src/kernel.ts | 48 +++---- packages/metadata/src/metadata.test.ts | 76 ++++++++++ packages/metadata/src/plugin.ts | 35 ++++- packages/runtime/src/load-artifact-bundle.ts | 16 +++ 10 files changed, 516 insertions(+), 125 deletions(-) create mode 100644 .changeset/serve-boots-without-artifact.md create mode 100644 packages/cli/test/helpers/serve-process.ts create mode 100644 packages/cli/test/serve-no-artifact.e2e.test.ts diff --git a/.changeset/serve-boots-without-artifact.md b/.changeset/serve-boots-without-artifact.md new file mode 100644 index 0000000000..f341f77028 --- /dev/null +++ b/.changeset/serve-boots-without-artifact.md @@ -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. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 3a9ec08b81..bbfabb625c 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1020,13 +1020,30 @@ 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 + // @objectstack/runtime unavailable — no wrap to append, so + // top-level metadata stays out of the registry (unchanged + // behaviour; a standalone boot cannot get this far without it). } } diff --git a/packages/cli/test/helpers/serve-process.ts b/packages/cli/test/helpers/serve-process.ts new file mode 100644 index 0000000000..2db9b1f29e --- /dev/null +++ b/packages/cli/test/helpers/serve-process.ts @@ -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 }, +): Promise { + 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()); + }); +} diff --git a/packages/cli/test/serve-boot-diagnostics.e2e.test.ts b/packages/cli/test/serve-boot-diagnostics.e2e.test.ts index 3e15871ae7..47d4160576 100644 --- a/packages/cli/test/serve-boot-diagnostics.e2e.test.ts +++ b/packages/cli/test/serve-boot-diagnostics.e2e.test.ts @@ -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 @@ -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 { - 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 }); @@ -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/, }); @@ -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/, }); diff --git a/packages/cli/test/serve-no-artifact.e2e.test.ts b/packages/cli/test/serve-no-artifact.e2e.test.ts new file mode 100644 index 0000000000..8e54cdb438 --- /dev/null +++ b/packages/cli/test/serve-no-artifact.e2e.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * framework#4085 — `os serve` boots WITHOUT a compiled `dist/objectstack.json`. + * + * ObjectStack is a development platform: the artifact defines an *application*, + * and the platform must start with no application at all. Config-boot is a + * first-class documented path (`serve` prints `Loading objectstack.config.ts…`), + * and a freshly authored project has no `dist/` until its first `os compile`. + * + * It nevertheless died in Phase 1 with `Service 'manifest' is async - use await` + * whenever the artifact was absent — two faults, both invisible: + * + * 1. `serve` registered the config-derived `AppPlugin` BEFORE the stack's own + * `plugins[]` (ObjectQLPlugin, which registers `manifest`/`objectql`; + * DefaultDatasourcePlugin, which connects the DB the app seeds through). + * Registration order IS the kernel's init/start order. The artifact path + * never hit it because `createStandaloneStack` appends ITS AppPlugin after + * the engine — which is what made a plugin-ORDER bug look + * artifact-related. + * 2. `MetadataPlugin` treated its absent `local-file` artifact as fatal, so + * even a stack with no app at all could not boot. + * + * Both live above the kernel — in what the command assembles and in what boot + * treats as fatal — so only a test driving the real `os serve` process pins + * them. These two boots are that pin: no `os compile` anywhere in this file. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runServe, randomPort } from './helpers/serve-process.js'; + +/** An ordinary app: manifest + an object, exactly what `os init` scaffolds. */ +const APP_CONFIG = ` +export default { + manifest: { + id: 'com.example.noartifact', + version: '1.0.0', + type: 'app', + name: 'No Artifact Fixture', + }, + objects: [{ + name: 'noart_task', + label: 'Task', + sharingModel: 'private', + fields: { + title: { type: 'text', label: 'Title' }, + }, + }], +}; +`; + +/** + * The platform with NO application: no manifest, no objects, nothing to + * compile. This is what "the platform does not need an app to start" means at + * its limit, and it is the shape `os init` leaves before any metadata is + * authored. + */ +const BARE_CONFIG = ` +export default {}; +`; + +let appDir: string; +let bareDir: string; + +beforeAll(() => { + appDir = mkdtempSync(join(tmpdir(), 'os-no-artifact-app-')); + writeFileSync(join(appDir, 'objectstack.config.ts'), APP_CONFIG, 'utf8'); + + bareDir = mkdtempSync(join(tmpdir(), 'os-no-artifact-bare-')); + writeFileSync(join(bareDir, 'objectstack.config.ts'), BARE_CONFIG, 'utf8'); +}); + +afterAll(() => { + for (const dir of [appDir, bareDir]) { + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('os serve — boots without a compiled artifact (#4085)', () => { + it( + 'serves an app defined only by objectstack.config.ts', + async () => { + const { stdout, stderr } = await runServe(appDir, ['--port', randomPort()], { + waitFor: /Press Ctrl\+C to stop/, + timeoutMs: 240_000, + }); + const seen = `\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`; + + // No artifact was built — the whole point. + expect(existsSync(join(appDir, 'dist/objectstack.json'))).toBe(false); + + expect(stdout, `serve never reported ready${seen}`).toContain('Server is ready'); + // The exact Phase-1 death this issue filed, in either of its spellings + // (the misleading "is async" one and the truthful "not found" the kernel + // now reports). + expect(stdout + stderr).not.toMatch(/Service 'manifest' (is async|not found)/); + expect(stdout + stderr).not.toMatch(/Service 'objectql' (is async|not found)/); + expect(stdout + stderr).not.toContain('rollback complete'); + + // Not merely "did not crash": the config-derived app is IN the started + // plugin set, so its init registered the manifest and its start seeded + // through a connected datasource. A failure in either aborts boot before + // the banner, so reaching this line with the app listed is the real + // guarantee. + expect(stdout, `app plugin missing from the boot banner${seen}`).toMatch( + /Plugins:[\s\S]*noartifact/, + ); + + // A missing artifact is a normal state, so it must not be reported as a + // failure — it reads as a build problem and sends readers hunting. + expect(stdout + stderr).not.toContain('artifact read FAILED'); + }, + 240_000, + ); + + it( + 'serves a config with no application at all', + async () => { + const { stdout, stderr } = await runServe(bareDir, ['--port', randomPort()], { + waitFor: /Press Ctrl\+C to stop/, + timeoutMs: 240_000, + }); + const seen = `\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`; + + expect(stdout, `bare platform never reported ready${seen}`).toContain('Server is ready'); + expect(stdout + stderr).not.toContain('rollback complete'); + // MetadataPlugin's own fatal on the absent `dist/objectstack.json`. + expect(stdout + stderr).not.toContain('Cannot read artifact file'); + }, + 240_000, + ); +}); diff --git a/packages/core/src/kernel.test.ts b/packages/core/src/kernel.test.ts index 2939201228..4e0960983f 100644 --- a/packages/core/src/kernel.test.ts +++ b/packages/core/src/kernel.test.ts @@ -130,6 +130,49 @@ describe('ObjectKernel', () => { }); }); + // #4085 — `ctx.getService()` has to name the fault it actually hit. Both + // cases below land in the same branch (neither sync map has the name), and + // reading the answer off `pluginLoader.getService()` — an `async` method, + // so ALWAYS a Promise — reported both as "is async - use await". That sent + // every ordering bug ("this plugin ran before the one that registers the + // service") looking for an await that does not exist: the crash that kept + // `os serve` from booting without a compiled artifact read as a kernel + // fault, and the real cause (plugin order) was invisible. + describe('Service resolution diagnostics', () => { + it('reports a never-registered service as not found', async () => { + expect(() => kernel.getService('nothing-registered-this-name')) + .toThrow("[Kernel] Service 'nothing-registered-this-name' not found"); + }); + + it('reports a registered-but-uninstantiated factory as async', async () => { + kernel.registerServiceFactory( + 'lazy-thing', + () => ({ ok: true }), + ServiceLifecycle.SINGLETON, + ); + + // Sync accessor cannot run an async factory — the caller needs + // `getServiceAsync`, and the message has to say so. + expect(() => kernel.getService('lazy-thing')) + .toThrow("Service 'lazy-thing' is async - use await"); + + // …and the async accessor resolves it, after which the sync + // accessor finds the cached instance. + await expect(kernel.getServiceAsync('lazy-thing')).resolves.toEqual({ ok: true }); + expect(kernel.getService('lazy-thing')).toEqual({ ok: true }); + }); + + it('does not mask a factory that throws as a missing service', async () => { + kernel.registerServiceFactory( + 'exploding', + () => { throw new Error('driver connect failed'); }, + ServiceLifecycle.SINGLETON, + ); + + await expect(kernel.getServiceAsync('exploding')).rejects.toThrow('driver connect failed'); + }); + }); + describe('Plugin Lifecycle with Timeout', () => { it('should timeout plugin init if it takes too long', async () => { const plugin: PluginMetadata = { diff --git a/packages/core/src/kernel.ts b/packages/core/src/kernel.ts index 7d6f7c080c..cf41c3b9fc 100644 --- a/packages/core/src/kernel.ts +++ b/packages/core/src/kernel.ts @@ -95,33 +95,31 @@ export class ObjectKernel { return loaderService; } - // 3. Try to get from plugin loader (support async factories) - try { - const service = this.pluginLoader.getService(name); - if (service instanceof Promise) { - // If we found it in the loader but not in the sync map, it's likely a factory-based service or still loading - // We must silence any potential rejection from this promise since we are about to throw our own error - // and abandon the promise. Without this, Node.js will crash with "Unhandled Promise Rejection". - service.catch(() => {}); - throw new Error(`Service '${name}' is async - use await`); - } - return service as T; - } catch (error: any) { - if (error.message?.includes('is async')) { - throw error; - } - - // Re-throw critical factory errors instead of masking them as "not found" - // If the error came from the factory execution (e.g. database connection failed), we must see it. - // "Service '${name}' not found" comes from PluginLoader.getService fallback. - const isNotFoundError = error.message === `Service '${name}' not found`; - - if (!isNotFoundError) { - throw error; - } - + // 3. Neither sync map has it. Two very different faults share + // this branch and MUST NOT share one message (#4085): + // (a) nothing ever registered `name` — a composition / + // ordering fault at the CALLER (e.g. a plugin reaching + // for `manifest` in init() before the engine plugin + // registered it); + // (b) `name` IS registered, as a factory that has not been + // instantiated yet — the caller merely used the wrong + // accessor and needs `getServiceAsync`. + // `pluginLoader.getService` is an `async` method, so its + // return value is ALWAYS a Promise and its internal + // "not found" rejection can never surface synchronously. + // Reading (a) off that Promise therefore reported every + // missing service as "is async - use await" — the wrong fix, + // pointing at the wrong layer. Decide from the registry + // instead, which is synchronous and authoritative. + if (!this.pluginLoader.hasService(name)) { throw new Error(`[Kernel] Service '${name}' not found`); } + + // Registered but not instantiated ⇒ factory-backed. Message + // kept verbatim: callers that tolerate an async-only service + // (console static assets, the HTTP dispatcher) match on + // `is async`. + throw new Error(`Service '${name}' is async - use await`); }, replaceService: (name: string, implementation: T): void => { const hasService = this.services.has(name) || this.pluginLoader.hasService(name); diff --git a/packages/metadata/src/metadata.test.ts b/packages/metadata/src/metadata.test.ts index 2c2db320c3..9a76c64fb9 100644 --- a/packages/metadata/src/metadata.test.ts +++ b/packages/metadata/src/metadata.test.ts @@ -1,6 +1,9 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { MetadataManager } from './metadata-manager'; import { MemoryLoader } from './loaders/memory-loader'; import type { MetadataLoader } from './loaders/loader-interface'; @@ -763,6 +766,79 @@ describe('MetadataPlugin', () => { expect(manager.loadMany).not.toHaveBeenCalled(); }); + // #4085 — an ABSENT local artifact is "no app compiled yet", not a fault. + // `createStandaloneStack` always points MetadataPlugin at + // `dist/objectstack.json`, so an unconditional throw here made + // `os serve objectstack.config.ts` impossible before a first `os compile`: + // the development platform refused to start without an app. + it('eager bootstrap boots without a compiled artifact instead of failing', async () => { + const { MetadataPlugin } = await import('./plugin.js'); + const missing = join(tmpdir(), `os-absent-artifact-${process.pid}`, 'dist/objectstack.json'); + const plugin = new MetadataPlugin({ + rootDir: '/tmp/test', + watch: false, + artifactSource: { mode: 'local-file', path: missing }, + }); + + const manager = (plugin as any).manager; + manager.loadMany = vi.fn().mockResolvedValue([]); + + const ctx = createMockPluginContext(); + await plugin.init(ctx); + await expect(plugin.start(ctx)).resolves.not.toThrow(); + // …and it says so, naming the path, rather than booting silently. + expect(ctx.logger.info).toHaveBeenCalledWith( + expect.stringContaining('no compiled artifact yet'), + expect.objectContaining({ path: missing }), + ); + }); + + // The tolerance is ENOENT-only: a present-but-broken artifact is a real + // fault and must not degrade into a silent empty boot. + it('eager bootstrap still fails loudly on a malformed artifact file', async () => { + const { MetadataPlugin } = await import('./plugin.js'); + const dir = mkdtempSync(join(tmpdir(), 'os-bad-artifact-')); + const bad = join(dir, 'objectstack.json'); + writeFileSync(bad, '{ this is not json', 'utf8'); + const plugin = new MetadataPlugin({ + rootDir: '/tmp/test', + watch: false, + artifactSource: { mode: 'local-file', path: bad }, + }); + + const manager = (plugin as any).manager; + manager.loadMany = vi.fn().mockResolvedValue([]); + + const ctx = createMockPluginContext(); + await plugin.init(ctx); + try { + await expect(plugin.start(ctx)).rejects.toThrow(/Cannot read artifact file/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + // …and the sealed runtime keeps its hard guarantee: `artifact-only` exists + // precisely so a deployment cannot serve metadata it did not ship. + it('artifact-only bootstrap still fails when the local artifact is missing', async () => { + const { MetadataPlugin } = await import('./plugin.js'); + const missing = join(tmpdir(), `os-absent-sealed-${process.pid}`, 'dist/objectstack.json'); + const plugin = new MetadataPlugin({ + rootDir: '/tmp/test', + watch: false, + config: { bootstrap: 'artifact-only' }, + artifactSource: { mode: 'local-file', path: missing }, + }); + + const manager = (plugin as any).manager; + manager.loadMany = vi.fn().mockResolvedValue([]); + + const ctx = createMockPluginContext(); + await plugin.init(ctx); + await expect(plugin.start(ctx)).rejects.toThrow(/Cannot read artifact file/); + expect(manager.loadMany).not.toHaveBeenCalled(); + }); + it('artifact-only bootstrap rejects the not-yet-implemented artifact-api source', async () => { const { MetadataPlugin } = await import('./plugin.js'); const plugin = new MetadataPlugin({ diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index b873c0047e..0f7ca41e19 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -295,7 +295,7 @@ export class MetadataPlugin implements Plugin { // An artifact source, if present, is still honored so projects can // pin a known set of metadata at boot without paying the FS scan. if (src?.mode === 'local-file') { - await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs); + await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs, { optional: true }); } else if (src?.mode === 'artifact-api') { await this._loadFromArtifactApi(ctx, src); } else { @@ -304,7 +304,7 @@ export class MetadataPlugin implements Plugin { } else { // 'eager' (default): preserve historical behavior. if (src?.mode === 'local-file') { - await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs); + await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs, { optional: true }); } else if (src?.mode === 'artifact-api') { await this._loadFromArtifactApi(ctx, src); } else { @@ -650,7 +650,10 @@ export class MetadataPlugin implements Plugin { src: { path: string; fetchTimeoutMs?: number }, changed: string[], ): Promise { - await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs); + // Optional for the same reason boot is: an artifact deleted or moved + // aside mid-run (a `dist/` clean between recompiles) must not take the + // running server down — the watcher reloads it when it comes back. + await this._loadFromLocalFile(ctx, src.path, src.fetchTimeoutMs, { optional: true }); try { // `metadata` carries the freshly parsed artifact collections so // subscribers can consume the ones that never reach the @@ -662,7 +665,24 @@ export class MetadataPlugin implements Plugin { } } - private async _loadFromLocalFile(ctx: PluginContext, filePath: string, fetchTimeoutMs?: number): Promise { + /** + * @param opts.optional When true, a LOCAL artifact file that does not exist + * is "nothing compiled yet" rather than a fault: log and return, leaving + * the manager empty and the artifact watcher armed so the first + * `os compile` hydrates the running server (#4085). Callers pass it for + * the `eager` / `lazy` bootstrap modes — the development-platform paths, + * where an app is optional. `artifact-only` (sealed runtime) does NOT: + * there the artifact IS the deployment, so its absence must fail loudly + * instead of silently serving an empty runtime. Only ENOENT is tolerated; + * a present-but-unreadable artifact (malformed JSON, bad permissions) and + * every remote-URL failure stay fatal. + */ + private async _loadFromLocalFile( + ctx: PluginContext, + filePath: string, + fetchTimeoutMs?: number, + opts: { optional?: boolean } = {}, + ): Promise { const isUrl = /^https?:\/\//i.test(filePath); ctx.logger.info( `[MetadataPlugin] Loading metadata from ${isUrl ? 'remote URL' : 'local artifact file'}`, @@ -678,6 +698,13 @@ export class MetadataPlugin implements Plugin { raw = JSON.parse(content); } } catch (e: any) { + if (opts.optional && !isUrl && e?.code === 'ENOENT') { + ctx.logger.info( + '[MetadataPlugin] no compiled artifact yet — starting with no artifact metadata', + { path: filePath }, + ); + return; + } throw new Error(`[MetadataPlugin] Cannot read artifact ${isUrl ? 'URL' : 'file'} at "${filePath}": ${e.message}`); } diff --git a/packages/runtime/src/load-artifact-bundle.ts b/packages/runtime/src/load-artifact-bundle.ts index b6d4b23782..c5ff425ef1 100644 --- a/packages/runtime/src/load-artifact-bundle.ts +++ b/packages/runtime/src/load-artifact-bundle.ts @@ -87,6 +87,22 @@ export async function loadArtifactBundle( ? parsed.metadata : parsed; } catch (err: any) { + // An ABSENT artifact is not a failure (#4085). The platform is a + // development platform first: `os serve objectstack.config.ts` boots + // from the config, `os migrate` reads metadata, and a freshly authored + // project has no `dist/` at all until its first `os compile`. Shouting + // "read FAILED" at those callers described a healthy state as a fault + // and sent readers hunting for a build problem. A PRESENT-but-unusable + // artifact (malformed JSON, bad permissions, HTTP error) is a real + // fault and keeps the loud warning. + if (err?.code === 'ENOENT') { + // eslint-disable-next-line no-console + console.log( + `${tag} no compiled artifact at '${absArtifactPath}' — booting without one ` + + `(run 'os compile' to build it)`, + ); + return null; + } // eslint-disable-next-line no-console console.warn(`${tag} artifact read FAILED: path='${absArtifactPath}' error=${err?.message ?? err}`); return null; From afd844fdd20ccecc7a8414c58e0903d91426c1f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 10:24:21 +0000 Subject: [PATCH 2/2] fix(cli): a config whose app cannot be registered says so instead of serving zero objects silently (#4085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `catch` around the config-derived AppPlugin wrap — the same block the boot order fix rewrites — was silent. The two things it swallows both end with a server answering on its port with NO objects and no stated reason: a malformed envelope that `AppPlugin` rejects by construction (app payload but no `manifest.id`/`name`), and an unresolvable `@objectstack/runtime`. That is the same invisible-boot-failure class as #4085 itself, so leaving it silent in the block that fixes #4085 would ship the lesson unlearned. Boot still continues — a bad app is not a bad platform — but it now names the cause and says the app's objects/flows are NOT being served. Pinned by a third e2e case: an app payload with no manifest boots the platform (`Server is ready`) AND prints the skip with its cause. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ve9HidCGRGtS2UjNPUGHSV --- packages/cli/src/commands/serve.ts | 13 ++++-- .../cli/test/serve-no-artifact.e2e.test.ts | 44 ++++++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index bbfabb625c..6a79a5d576 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1041,9 +1041,16 @@ export default class Serve extends Command { const { AppPlugin } = await import('@objectstack/runtime'); plugins = [...plugins, new AppPlugin(config)]; } catch (e: any) { - // @objectstack/runtime unavailable — no wrap to append, so - // top-level metadata stays out of the registry (unchanged - // behaviour; a standalone boot cannot get this far without it). + // 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`).', + )); } } diff --git a/packages/cli/test/serve-no-artifact.e2e.test.ts b/packages/cli/test/serve-no-artifact.e2e.test.ts index 8e54cdb438..75c934caa8 100644 --- a/packages/cli/test/serve-no-artifact.e2e.test.ts +++ b/packages/cli/test/serve-no-artifact.e2e.test.ts @@ -62,8 +62,27 @@ const BARE_CONFIG = ` export default {}; `; +/** + * An app payload with no `manifest.id`/`name` — the one envelope `AppPlugin` + * rejects by construction. The platform must still serve (a bad app is not a + * bad platform), but it has to SAY the app was skipped: the CLI used to swallow + * this into a silent `catch`, leaving a server answering with zero objects and + * no stated reason — the same invisible-boot-failure class as #4085 itself. + */ +const UNREGISTERABLE_CONFIG = ` +export default { + objects: [{ + name: 'orphan_task', + label: 'Task', + sharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' } }, + }], +}; +`; + let appDir: string; let bareDir: string; +let orphanDir: string; beforeAll(() => { appDir = mkdtempSync(join(tmpdir(), 'os-no-artifact-app-')); @@ -71,10 +90,13 @@ beforeAll(() => { bareDir = mkdtempSync(join(tmpdir(), 'os-no-artifact-bare-')); writeFileSync(join(bareDir, 'objectstack.config.ts'), BARE_CONFIG, 'utf8'); + + orphanDir = mkdtempSync(join(tmpdir(), 'os-no-artifact-orphan-')); + writeFileSync(join(orphanDir, 'objectstack.config.ts'), UNREGISTERABLE_CONFIG, 'utf8'); }); afterAll(() => { - for (const dir of [appDir, bareDir]) { + for (const dir of [appDir, bareDir, orphanDir]) { if (dir) rmSync(dir, { recursive: true, force: true }); } }); @@ -132,4 +154,24 @@ describe('os serve — boots without a compiled artifact (#4085)', () => { }, 240_000, ); + + it( + 'serves on, and says why, when the config carries an app it cannot register', + async () => { + const { stdout, stderr } = await runServe(orphanDir, ['--port', randomPort()], { + waitFor: /Press Ctrl\+C to stop/, + timeoutMs: 240_000, + }); + const seen = `\n--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`; + const out = stdout + stderr; + + // A rejected app must not take the platform down… + expect(stdout, `platform died on an unregisterable app${seen}`).toContain('Server is ready'); + // …and must not be swallowed either: the operator has to learn that their + // objects are NOT being served, and why. + expect(out, `no warning about the skipped app${seen}`).toContain('Skipped registering the app'); + expect(out, `warning does not name the cause${seen}`).toContain('no manifest.id'); + }, + 240_000, + ); });