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
53 changes: 53 additions & 0 deletions .changeset/app-runtime-hooks-artifact-boot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
'@objectstack/cli': patch
---

**A config-booted app no longer loses its `onEnable` — every `script` action's
handler reaches the engine again instead of 404'ing at dispatch (#4095).**

`os serve <config>` calls `createStandaloneStack()`, which reads
`dist/objectstack.json` and returns a ready-made `AppPlugin` for the app. That
satisfied serve's "does the host already wrap itself with an AppPlugin?" guard,
so the `new AppPlugin(config)` built from the LOADED MODULE — the only one
carrying the module's `onEnable` — was skipped. A JSON artifact cannot hold a
function, so the app booted with all of its metadata and none of its code.

On `examples/app-todo` that meant eight declared `script` actions, zero
registered handlers, and every button answering
`404 Action 'complete_task' on object 'todo_task' not found`. The example is
correctly authored: it declares `target: 'completeTask'`, registers
`todo_task:completeTask`, and exports `onEnable`. serve carried that hook intact
all the way to the branch that discarded it.

Serve now grafts the module's executable members onto the app bundle already
registered, rather than dropping them with the wrap:

- Only members `AppPlugin` actually executes travel — `onEnable` and the
`functions` map that string-named hook/job handlers resolve against. (`onDisable`
is deliberately excluded: it is declared in `packages/spec` but no kernel,
runtime or service ever calls it, so grafting it would wire a hook nothing
runs.)
- The artifact stays the metadata source of truth. Neither side is a superset —
the artifact carries compile-time enrichment the config never has (ADR-0046
packaged docs, which serve already grafts the other way) — so this moves code
only, and never metadata.
- Targeting is by `manifest.id`, so a host composing several `AppPlugin`s can
never have one app's handlers attached to another. With no id to match, it
falls back to the single app bundle present and refuses when there are several.
- A bundle's own value always wins, so a host that wrapped itself on purpose is
untouched.
- Code that finds no bundle to land on is now reported with a boot warning naming
the consequence ("they 404 at dispatch") instead of vanishing. That silent drop
is what hid this.

Verified end to end on `examples/app-todo`: `POST /api/v1/actions/todo_task/complete_task`
went from `404 RESOURCE_NOT_FOUND` to `{"success":true}`, `export_csv` now returns
real CSV, and the `[action-governance]` boot warning naming all eight actions is
gone. 14 unit cases pin the graft and — as importantly — the cases where it must
refuse; one end-to-end case boots a real stack through `bin/run-dev.js` and fails
against the pre-fix command.

Note that `os serve <config>` still cannot boot at all when `dist/objectstack.json`
is absent (#4085, `Service 'manifest' is async - use await`). That was verified to
be a **separate** defect on the other side of the same fork, not this one: the
failure reproduces unchanged with this fix applied.
29 changes: 26 additions & 3 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { missingProviderMessage } from '../utils/capability-preflight.js';
import { resolveObjectStackHome } from '@objectstack/runtime';
import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js';
import { BootLogCapture, isVerboseBootLevel } from '../utils/boot-log-capture.js';
import { graftAuthoredRuntimeMembers, isAppPluginLike } from '../utils/graft-runtime-hooks.js';
import { redactConnectionUrl, describeDriverConnection } from '../utils/connection-display.js';
import {
printHeader,
Expand Down Expand Up @@ -1014,9 +1015,7 @@ export default class Serve extends Command {
// To avoid double-registration when the host already wraps itself with
// an AppPlugin (e.g. apps/objectos's dev-workspace stack), we skip if
// any plugin in `plugins[]` is already an AppPlugin instance.
const hasAppPluginAlready = plugins.some(
(p: any) => p && (p.type === 'app' || p.constructor?.name === 'AppPlugin' || (p.name && typeof p.name === 'string' && p.name.startsWith('plugin.app.')))
);
const hasAppPluginAlready = plugins.some(isAppPluginLike);
const configHasMetadata = !!(
config.objects || config.manifest || config.apps || config.flows || config.apis
);
Expand Down Expand Up @@ -1052,6 +1051,30 @@ export default class Serve extends Command {
+ ' Its objects/flows will NOT be served. Fix the config (or pin an AppPlugin in `plugins`).',
));
}
} else if (hasAppPluginAlready) {
// #4095 — skipping the wrap above also discards the authored module's
// CODE. On the config-boot path the bundle already in `plugins[]` came
// from `createStandaloneStack()` reading `dist/objectstack.json`, and a
// JSON artifact cannot carry a function: the app booted with every
// `script` action DECLARED and no handler registered, so each one 404'd
// at dispatch. Move the executable members onto that bundle (the
// bundle's own value always wins, so a host that wrapped itself on
// purpose is untouched) and say so out loud when they have nowhere to go
// — that silent drop is what hid this.
// Success is silent: on the config-boot path this is now the normal
// route by which handlers reach the engine, and the observable proof is
// that the actions dispatch.
const graft = graftAuthoredRuntimeMembers(plugins, config);
if (graft.orphaned.length > 0) {
console.warn(chalk.yellow(
` ⚠ ${relativeConfig} exports ${graft.orphaned.join(' / ')} but no app bundle claimed `
+ `${graft.orphaned.length === 1 ? 'it' : 'them'}`
+ `${graft.reason === 'ambiguous-app-plugin'
? ' — several apps are registered and the config declares no manifest.id to match'
: ' — no registered app bundle has a matching manifest.id'}`
+ '. Action handlers registered there will NOT be reachable (they 404 at dispatch).',
));
}
}

// 3b. Auto-register I18nServicePlugin if config contains translations/i18n
Expand Down
192 changes: 192 additions & 0 deletions packages/cli/src/utils/graft-runtime-hooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* framework#4095 — a config-booted app lost its `onEnable`, so every `script`
* action was declared and none was executable.
*
* `os serve <config>` calls `createStandaloneStack()`, which reads
* `dist/objectstack.json` and returns a ready-made `AppPlugin`. That tripped
* serve's "does the host already wrap itself?" guard, so the
* `new AppPlugin(config)` built from the loaded MODULE — the only one carrying
* the module's `onEnable` — never ran. A JSON artifact cannot hold a function,
* so `examples/app-todo` booted with eight action declarations and zero
* handlers and every button answered `404 Action 'complete_task' on object
* 'todo_task' not found`.
*
* These pin the graft that repairs it, and — just as important — the cases where
* it must REFUSE, since attaching one app's handlers to another app's bundle
* would be worse than the bug.
*/

import { describe, it, expect } from 'vitest';
import {
GRAFTABLE_RUNTIME_MEMBERS,
graftAuthoredRuntimeMembers,
isAppPluginLike,
} from './graft-runtime-hooks.js';

/** An AppPlugin as `createStandaloneStack()` builds it: artifact metadata, no code. */
const artifactApp = (id: string, extra: Record<string, unknown> = {}) => ({
name: `plugin.app.${id}`,
type: 'app',
bundle: {
manifest: { id },
objects: [{ name: 'x_task' }],
actions: [{ name: 'do_thing', type: 'script', target: 'doThing' }],
...extra,
},
});

/** The authored module as serve assembles it — metadata PLUS the code half. */
const authoredConfig = (id: string, extra: Record<string, unknown> = {}) => ({
manifest: { id },
objects: [{ name: 'x_task' }],
onEnable: async () => {},
...extra,
});

describe('isAppPluginLike', () => {
it('recognises every shape serve\'s own guard recognised', () => {
// Must stay in lockstep with the guard that decides to SKIP the wrap, or the
// skip and the graft that compensates for it disagree.
expect(isAppPluginLike({ type: 'app' })).toBe(true);
expect(isAppPluginLike({ name: 'plugin.app.com.example.todo' })).toBe(true);
class AppPlugin { }
expect(isAppPluginLike(new AppPlugin())).toBe(true);
});

it('is not fooled by ordinary plugins or non-objects', () => {
expect(isAppPluginLike({ name: 'com.objectstack.metadata' })).toBe(false);
expect(isAppPluginLike({ name: 'com.objectstack.engine.objectql' })).toBe(false);
expect(isAppPluginLike({ type: 'service' })).toBe(false);
expect(isAppPluginLike(null)).toBe(false);
expect(isAppPluginLike(undefined)).toBe(false);
expect(isAppPluginLike('plugin.app.x')).toBe(false);
});
});

describe('graftAuthoredRuntimeMembers', () => {
it('moves onEnable onto the artifact-derived bundle — the #4095 repair', () => {
const app = artifactApp('com.example.todo');
const config = authoredConfig('com.example.todo');
const result = graftAuthoredRuntimeMembers([{ name: 'com.objectstack.metadata' }, app], config);

expect(result.grafted).toEqual(['onEnable']);
expect(result.orphaned).toEqual([]);
expect(result.appId).toBe('com.example.todo');
// The load-bearing assertion: AppPlugin reads this at start().
expect(typeof (app.bundle as any).onEnable).toBe('function');
expect((app.bundle as any).onEnable).toBe(config.onEnable);
});

it('moves the `functions` map too — string-named hook/job handlers are code as well', () => {
const app = artifactApp('com.example.todo');
const config = authoredConfig('com.example.todo', { functions: { doThing: () => 1 } });
const result = graftAuthoredRuntimeMembers([app], config);

expect(result.grafted).toEqual(['onEnable', 'functions']);
expect(typeof (app.bundle as any).functions.doThing).toBe('function');
});

it('grafts nothing the artifact never lost', () => {
// Only executable members travel. Metadata is the artifact's job, and the
// artifact is the richer source (it carries compile-time docs the config
// never has), so nothing else may be copied over it.
const app = artifactApp('com.example.todo', { docs: [{ name: 'readme' }] });
const config = authoredConfig('com.example.todo', { objects: [{ name: 'DIFFERENT' }] });
graftAuthoredRuntimeMembers([app], config);

expect((app.bundle as any).objects).toEqual([{ name: 'x_task' }]);
expect((app.bundle as any).docs).toEqual([{ name: 'readme' }]);
});

it('never overwrites a hook the bundle already has', () => {
// A host that wrapped itself on purpose already decided what runs.
const own = async () => {};
const app = artifactApp('com.example.todo', { onEnable: own });
const result = graftAuthoredRuntimeMembers([app], authoredConfig('com.example.todo'));

expect(result.grafted).toEqual([]);
expect(result.reason).toBe('already-present');
expect((app.bundle as any).onEnable).toBe(own);
});

it('matches by manifest.id, so one app never gets another app\'s handlers', () => {
const todo = artifactApp('com.example.todo');
const crm = artifactApp('com.example.crm');
const result = graftAuthoredRuntimeMembers([todo, crm], authoredConfig('com.example.crm'));

expect(result.grafted).toEqual(['onEnable']);
expect(result.appId).toBe('com.example.crm');
expect((crm.bundle as any).onEnable).toBeTypeOf('function');
expect((todo.bundle as any).onEnable).toBeUndefined();
});

it('refuses rather than guess when the authored id matches nothing', () => {
// Grafting onto "the only candidate" here would wire the wrong app.
const other = artifactApp('com.example.other');
const result = graftAuthoredRuntimeMembers([other], authoredConfig('com.example.todo'));

expect(result.grafted).toEqual([]);
expect(result.orphaned).toEqual(['onEnable']);
expect(result.reason).toBe('no-app-plugin');
expect((other.bundle as any).onEnable).toBeUndefined();
});

it('falls back to the single app bundle when the config declares no manifest id', () => {
const app = artifactApp('com.example.todo');
const result = graftAuthoredRuntimeMembers([app], { objects: [], onEnable: async () => {} });
expect(result.grafted).toEqual(['onEnable']);
});

it('refuses when there is no id AND several candidates', () => {
const result = graftAuthoredRuntimeMembers(
[artifactApp('com.example.todo'), artifactApp('com.example.crm')],
{ objects: [], onEnable: async () => {} },
);
expect(result.grafted).toEqual([]);
expect(result.orphaned).toEqual(['onEnable']);
expect(result.reason).toBe('ambiguous-app-plugin');
});

it('reports orphaned code when no app bundle is registered at all', () => {
// The silent-drop case that hid #4095 — the caller has to be able to shout.
const result = graftAuthoredRuntimeMembers(
[{ name: 'com.objectstack.metadata' }],
authoredConfig('com.example.todo'),
);
expect(result.orphaned).toEqual(['onEnable']);
expect(result.reason).toBe('no-app-plugin');
});

it('stays quiet for a config that carries no code', () => {
const app = artifactApp('com.example.todo');
const result = graftAuthoredRuntimeMembers([app], { manifest: { id: 'com.example.todo' } });
expect(result).toEqual({ grafted: [], orphaned: [], reason: 'no-authored-members' });
});

it('tolerates the degenerate inputs a boot can hand it', () => {
for (const plugins of [undefined, [], [null], [undefined]]) {
expect(() => graftAuthoredRuntimeMembers(plugins as any, authoredConfig('x'))).not.toThrow();
}
for (const authored of [undefined, null, 'nope', 42, {}]) {
expect(graftAuthoredRuntimeMembers([artifactApp('x')], authored).grafted).toEqual([]);
}
// An app plugin with no bundle at all must not throw.
expect(
graftAuthoredRuntimeMembers([{ type: 'app' }], authoredConfig('x')).reason,
).toBe('no-app-plugin');
});

it('grafts only members AppPlugin actually executes', () => {
// `onDisable` is declared in packages/spec but called by no kernel, runtime
// or service — grafting it would wire a hook nothing runs.
expect([...GRAFTABLE_RUNTIME_MEMBERS]).toEqual(['onEnable', 'functions']);

const app = artifactApp('com.example.todo');
graftAuthoredRuntimeMembers([app], authoredConfig('com.example.todo', {
onDisable: async () => {},
}));
expect((app.bundle as any).onDisable).toBeUndefined();
});
});
Loading
Loading