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
14 changes: 14 additions & 0 deletions .changeset/28713-stored-connector-plugin-name-at-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@objectstack/service-automation": patch
"@objectstack/core": patch
---

fix(service-automation): a stored connector under a host plugin connector's name is skipped at start, as it is while the platform runs

Clause-②: yes (widening) — `@objectstack/core` adds one read-only public member, `dispatchesBootPhaseHooks()`, on `ObjectKernel` and `ObjectKernelBase` (so `LiteKernel`); no accepted value changes.

Metadata-protocol: none

- **Now.** A provider-bound connector saved in the environment (through the metadata door or Studio) under the name of a connector that a plugin on the host provides is treated at start exactly as it is while the platform runs: the plugin's connector is served under that name, and the saved connector is not served, with the same log record naming it. Connectors saved in the environment are now materialized once every plugin has started, still before the server accepts requests.
- **Unchanged.** A connector the host's own stack declares, and an installed package's, keep their start behavior, including when their name collides with a plugin's connector.
- **`@objectstack/core`.** `ObjectKernel.dispatchesBootPhaseHooks()` answers whether `bootstrap()` dispatches `kernel:ready`, `kernel:bootstrapped` and `kernel:listening` once every plugin has started (`false` exactly when the `bootPhaseHooks` option is `false`); `LiteKernel` always answers `true`. A plugin that leaves part of its start to one of those hooks reads it during its own `start()`. On a kernel that does not dispatch them, or a host that does not say, saved connectors are materialized during the automation plugin's own start, as before.
16 changes: 16 additions & 0 deletions packages/core/src/kernel-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,22 @@ export abstract class ObjectKernelBase {
return this.state;
}

/**
* Whether `bootstrap()` dispatches the boot-phase hooks — `kernel:ready`,
* `kernel:bootstrapped` and `kernel:listening` — once every plugin's
* `start()` has run. A kernel on this base always does, so this answers
* `true`.
*
* Read-only. It is for a plugin that leaves part of its start to one of
* those hooks and must know, during its own `start()`, that the hook will
* come; where the answer is `false` (or a host does not say), it keeps
* that work in `start()`. `ObjectKernel` answers from its
* `bootPhaseHooks` option.
*/
dispatchesBootPhaseHooks(): boolean {
return true;
}

/**
* Get all registered plugins
*/
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/kernel.boot-phase-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,23 @@ describe('ObjectKernel bootPhaseHooks', () => {
});

describe('bootPhaseHooks: false', () => {
it('dispatchesBootPhaseHooks() answers false, read by a plugin during its own start()', async () => {
const { plugins } = composition();
const read: unknown[] = [];
const reader: Plugin = {
name: 'test.reader',
version: '1.0.0',
init: async () => {},
start: async (ctx) => { read.push(ctx.getKernel().dispatchesBootPhaseHooks()); },
};
const kernel = track(newKernel({ bootPhaseHooks: false }));

await boot(kernel, [...plugins, reader]);

expect(read).toEqual([false]);
expect(kernel.dispatchesBootPhaseHooks()).toBe(false);
});

it('keeps every definition registered in init() and start(), and the kernel runs', async () => {
const { plugins } = composition();
const kernel = track(newKernel({ bootPhaseHooks: false }));
Expand Down Expand Up @@ -221,6 +238,22 @@ describe('ObjectKernel bootPhaseHooks', () => {
['true', { bootPhaseHooks: true }],
['undefined', { bootPhaseHooks: undefined }],
] as Array<[string, ObjectKernelConfig]>)('bootPhaseHooks %s (control)', (_label, extra) => {
it('dispatchesBootPhaseHooks() answers true, read by a plugin during its own start()', async () => {
const { plugins } = composition();
const read: unknown[] = [];
const reader: Plugin = {
name: 'test.reader',
version: '1.0.0',
init: async () => {},
start: async (ctx) => { read.push(ctx.getKernel().dispatchesBootPhaseHooks()); },
};
const kernel = track(newKernel(extra));

await boot(kernel, [...plugins, reader]);

expect(read).toEqual([true]);
});

it('fires all three hooks in order and keeps every definition, the kernel:ready one included', async () => {
const { plugins, spies, order } = composition();
const kernel = track(newKernel(extra));
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,21 @@ export class ObjectKernel {
return this.state;
}

/**
* Whether `bootstrap()` dispatches the boot-phase hooks — `kernel:ready`,
* `kernel:bootstrapped` and `kernel:listening` — once every plugin's
* `start()` has run: `false` exactly when {@link ObjectKernelConfig.bootPhaseHooks}
* is `false`, `true` otherwise.
*
* Read-only; the option is fixed at construction. It is for a plugin that
* leaves part of its start to one of those hooks and must know, during its
* own `start()`, that the hook will come; where the answer is `false`, it
* keeps that work in `start()`.
*/
dispatchesBootPhaseHooks(): boolean {
return this.config.bootPhaseHooks !== false;
}

// Private methods

private async initPluginWithTimeout(plugin: PluginMetadata): Promise<void> {
Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/lite-kernel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,22 @@ describe('LiteKernel with Configurable Logger', () => {
expect(order).toEqual(['kernel:ready', 'kernel:bootstrapped', 'kernel:listening']);
});

it('dispatchesBootPhaseHooks() answers true, read by a plugin during its own start()', async () => {
const read: unknown[] = [];
const plugin: Plugin = {
name: 'boot-phase-reader-plugin',
init: async () => {},
start: async (ctx) => { read.push(ctx.getKernel().dispatchesBootPhaseHooks()); },
};

kernel.use(plugin);
await kernel.bootstrap();
await kernel.shutdown();

expect(read).toEqual([true]);
expect(kernel.dispatchesBootPhaseHooks()).toBe(true);
});

// #5170 — the LiteKernel half of the cross-kernel pin. This is the
// behaviour the issue changed: the throw used to be caught inside
// `triggerHook`, logged as `Hook handler failed: kernel:ready`, and the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,22 @@
// environment-wide stdio `mcp` entry, and a control carrying its document
// inline. [#28698] It also saves an environment-wide entry naming a provider
// no installed plugin offers (accepted at save, skipped after boot: the shape
// a row takes when the plugin that offered its provider is removed). The
// second boot:
// a row takes when the plugin that offered its provider is removed).
// [#28713] And an environment-wide entry under the name of a connector a host
// plugin registers (`slack`, ConnectorSlackPlugin): accepted at save, not
// served beside the plugin's connector while the platform runs. The second
// boot:
//
// 1. starts (no throw);
// 2. does not list any refused entry, and its boot record names each entry
// and its ref (or, for the unknown provider, the provider);
// 3. lists the stored inline control `ready` with its operation (the rows
// were hydrated, so the refusal above was read at boot, not skipped);
// 4. lists the showcase's own file-ref and stdio entries `ready` (control).
// 4. lists the showcase's own file-ref and stdio entries `ready` (control);
// 5. [#28713] lists the plugin's `slack` connector `ready` with the plugin's
// actions, not the stored entry's, and its boot record names the stored
// entry; the stored inline control is already `ready` on the first read
// after the start (it is served before the server answers).
//
// Boots fixture stacks of its own (a database file), so it stays out of
// `SHARED_SHOWCASE`.
Expand All @@ -53,6 +60,8 @@ const AUTHORED_PACKAGE_ID = 'com.dogfood.storedrestart';
const INLINE = 'srr_inline';
/** [#28698] A stored entry whose provider no installed plugin offers. */
const UNKNOWN_PROVIDER = 'srr_unknown_provider';
/** [#28713] The name of the connector ConnectorSlackPlugin registers on the showcase. */
const PLUGIN_NAME = 'slack';
const HOST_FILE_ENTRY = 'showcase_status_openapi';
const HOST_STDIO_ENTRY = 'showcase_mcp_tools';

Expand Down Expand Up @@ -126,6 +135,7 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe
let before: Answer;
let bootError: unknown;
let after: Answer;
let firstRead: Answer;
let bootLines: string[] = [];
let second: VerifyStack | undefined;

Expand Down Expand Up @@ -154,6 +164,7 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe
...openapi(UNKNOWN_PROVIDER, DOCUMENT),
provider: 'no_such_provider',
}));
saves[PLUGIN_NAME] = await json(await first.apiAs(token, 'PUT', `/meta/connector/${PLUGIN_NAME}`, openapi(PLUGIN_NAME, DOCUMENT)));
saves[INLINE] = await json(await first.apiAs(token, 'PUT', `/meta/connector/${INLINE}`, openapi(INLINE, DOCUMENT)));
before = await settle(first, token, [INLINE]);
} finally {
Expand All @@ -168,6 +179,7 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe
return undefined;
}
const token = await second.signIn();
firstRead = await json(await second.apiAs(token, 'GET', '/automation/connectors'));
return settle(second, token, [INLINE, HOST_FILE_ENTRY, HOST_STDIO_ENTRY]);
});
bootLines = captured.lines;
Expand All @@ -186,6 +198,8 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe
expect(entryOf(before, INLINE)?.state, shown(before, INLINE)).toBe('ready');
expect(entryOf(before, ENV_FILE), shown(before, ENV_FILE)).toBeUndefined();
expect(entryOf(before, UNKNOWN_PROVIDER), shown(before, UNKNOWN_PROVIDER)).toBeUndefined();
// [#28713] While the platform runs, the plugin's connector keeps the name.
expect(entryOf(before, PLUGIN_NAME)?.actions?.map((a: any) => a.key), shown(before, PLUGIN_NAME)).not.toContain('GetThings');
});

it('the second boot over the same database starts', () => {
Expand All @@ -210,6 +224,19 @@ describe('dogfood: a stored connector entry refused its package-relative ref doe
expect(record).toContain('no provider factory is registered');
});

it('[#28713] a stored entry under a host plugin connector\'s name: the plugin\'s connector lists ready after the restart, and the boot record names the stored entry', () => {
const plugin = entryOf(after, PLUGIN_NAME);
expect(plugin?.state, shown(after, PLUGIN_NAME)).toBe('ready');
expect(plugin.actions.map((a: any) => a.key)).not.toContain('GetThings');
expect(plugin.actions.length).toBeGreaterThan(0);
const record = bootLines.find((l) => l.includes(`declarative provider-bound instance '${PLUGIN_NAME}' collides with a plugin-registered connector`));
expect(record, `no boot record names '${PLUGIN_NAME}'`).toBeDefined();
});

it('[#28713] control: the stored inline entry is already ready on the first read after the restart', () => {
expect(entryOf(firstRead, INLINE)?.state, shown(firstRead, INLINE)).toBe('ready');
});

it('controls: the stored inline entry, and the showcase\'s own file-ref and stdio entries, list ready after the restart', () => {
expect(entryOf(after, INLINE)?.state, shown(after, INLINE)).toBe('ready');
expect(entryOf(after, INLINE).actions.map((a: any) => a.key)).toEqual(['GetThings']);
Expand Down
Loading
Loading