Skip to content

Commit 2856f6b

Browse files
hotlongCopilot
andcommitted
feat(runtime): tier-driven capability loader for ObjectOS per-project kernels
Add capability-loader.ts mirroring the CLI's CAPABILITY_PROVIDERS table so artifact bundles' `requires: [...]` array drives plugin installation on per-project kernels built by ArtifactKernelFactory. Previously only 7 plugins loaded per project on objectos (driver, objectql, metadata, auth, security, i18n, app). Flows/AI/Analytics declared in the artifact were silently dropped because the host had no AutomationServicePlugin, AIServicePlugin or AnalyticsServicePlugin registered. Tokens supported (matches `defineStack({ requires })`): automation (+ crud/logic/http/screen node packs), ai, analytics, audit, cache, storage, queue, job, realtime, feed Tier-gated capabilities (auth, ui, i18n) remain wired explicitly because they need bespoke configuration (per-project HKDF secret, UI dist paths). Lazy & silent on missing deps — a host that doesn't bundle service-queue just logs a warning and continues. apps/objectos already declares service-automation/ai/analytics/feed in its package.json so dynamic imports resolve at runtime. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4220018 commit 2856f6b

2 files changed

Lines changed: 217 additions & 0 deletions

File tree

packages/runtime/src/cloud/artifact-kernel-factory.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { AppPlugin } from '../app-plugin.js';
3434
import type { ProjectKernelFactory } from './kernel-manager.js';
3535
import type { EnvironmentDriverRegistry } from './environment-registry.js';
3636
import type { ArtifactApiClient } from './artifact-api-client.js';
37+
import { loadCapabilities } from './capability-loader.js';
3738

3839
type IDataDriver = Contracts.IDataDriver;
3940

@@ -218,6 +219,33 @@ export class ArtifactKernelFactory implements ProjectKernelFactory {
218219
});
219220
}
220221

222+
// Tier-driven capability loading: install service plugins listed
223+
// in the artifact's `requires` array (e.g. ['ai','automation',
224+
// 'analytics']). Must be registered BEFORE AppPlugin so that
225+
// AppPlugin's start phase can hand off flows/agents/cubes to
226+
// services that are already initialised.
227+
const requiresRaw =
228+
(Array.isArray(bundle?.requires) ? bundle.requires : null) ??
229+
(Array.isArray(sys?.requires) ? sys.requires : null) ??
230+
[];
231+
const requires: string[] = (requiresRaw as unknown[])
232+
.filter((x): x is string => typeof x === 'string' && x.length > 0);
233+
234+
if (requires.length > 0) {
235+
const installed = await loadCapabilities({
236+
kernel,
237+
requires,
238+
bundle: { ...(bundle ?? {}), ...(sys ?? {}) } as Record<string, unknown>,
239+
logger: this.logger,
240+
projectId,
241+
});
242+
this.logger.info?.('[ArtifactKernelFactory] capabilities loaded', {
243+
projectId,
244+
requires,
245+
installed,
246+
});
247+
}
248+
221249
await kernel.use(new AppPlugin(bundle, {
222250
projectId,
223251
organizationId: project.organization_id ?? '',
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Capability loader — `bundle.requires` driven dispatch.
5+
*
6+
* Mirrors the CAPABILITY_PROVIDERS table in
7+
* `@objectstack/cli/src/commands/serve.ts` so that per-project kernels
8+
* built by {@link ArtifactKernelFactory} pick up the same service plugins
9+
* a developer would get when running `objectstack serve` locally.
10+
*
11+
* Design goals:
12+
* - Single source of truth: artifact's `requires` array. No hardcoded
13+
* plugin list per host.
14+
* - Lazy: each provider is dynamically imported only when requested,
15+
* keeping cold-start small for artifacts that don't need that
16+
* capability.
17+
* - Silent on missing deps: a host that doesn't ship the optional
18+
* package (e.g. service-queue) just logs a warn and continues.
19+
*/
20+
21+
import type { ObjectKernel } from '@objectstack/core';
22+
23+
export interface CapabilitySpec {
24+
/** npm package name to import. */
25+
pkg: string;
26+
/** Named export — class constructor for the main plugin. */
27+
export: string;
28+
/**
29+
* Optional bundle key that, when present, is forwarded as constructor
30+
* argument (e.g. analytics needs `analyticsCubes`).
31+
*/
32+
configKey?: string;
33+
/** Auxiliary plugins loaded alongside the main one. */
34+
extras?: Array<{ pkg: string; export: string }>;
35+
}
36+
37+
/**
38+
* Registry of `requires` token → plugin provider.
39+
*
40+
* Keep keys in sync with the user-facing tokens accepted by
41+
* `defineStack({ requires: [...] })` and the CLI's CAPABILITY_PROVIDERS.
42+
*
43+
* Tier-gated capabilities (`auth`, `ui`, `i18n`) are intentionally NOT
44+
* listed here — they are wired explicitly by the kernel factory because
45+
* they need bespoke configuration (per-project HKDF secret, UI dist
46+
* paths, etc).
47+
*/
48+
export const CAPABILITY_PROVIDERS: Record<string, CapabilitySpec> = {
49+
automation: {
50+
pkg: '@objectstack/service-automation',
51+
export: 'AutomationServicePlugin',
52+
extras: [
53+
{ pkg: '@objectstack/service-automation', export: 'CrudNodesPlugin' },
54+
{ pkg: '@objectstack/service-automation', export: 'LogicNodesPlugin' },
55+
{ pkg: '@objectstack/service-automation', export: 'HttpConnectorPlugin' },
56+
{ pkg: '@objectstack/service-automation', export: 'ScreenNodesPlugin' },
57+
],
58+
},
59+
ai: {
60+
pkg: '@objectstack/service-ai',
61+
export: 'AIServicePlugin',
62+
},
63+
analytics: {
64+
pkg: '@objectstack/service-analytics',
65+
export: 'AnalyticsServicePlugin',
66+
configKey: 'analyticsCubes',
67+
},
68+
audit: {
69+
pkg: '@objectstack/plugin-audit',
70+
export: 'AuditPlugin',
71+
},
72+
cache: {
73+
pkg: '@objectstack/service-cache',
74+
export: 'CacheServicePlugin',
75+
},
76+
storage: {
77+
pkg: '@objectstack/service-storage',
78+
export: 'StorageServicePlugin',
79+
},
80+
queue: {
81+
pkg: '@objectstack/service-queue',
82+
export: 'QueueServicePlugin',
83+
},
84+
job: {
85+
pkg: '@objectstack/service-job',
86+
export: 'JobServicePlugin',
87+
},
88+
realtime: {
89+
pkg: '@objectstack/service-realtime',
90+
export: 'RealtimeServicePlugin',
91+
},
92+
feed: {
93+
pkg: '@objectstack/service-feed',
94+
export: 'FeedServicePlugin',
95+
},
96+
};
97+
98+
type Logger = { info?: (...a: any[]) => void; warn?: (...a: any[]) => void; error?: (...a: any[]) => void };
99+
100+
export interface LoadCapabilitiesOptions {
101+
kernel: ObjectKernel;
102+
/** Tokens from `bundle.requires` (e.g. `['ai','automation','analytics']`). */
103+
requires: readonly string[];
104+
/** Compiled artifact metadata. Used to pull `configKey`-driven args. */
105+
bundle: Record<string, unknown>;
106+
/** Optional logger. */
107+
logger?: Logger;
108+
/** projectId for log breadcrumbs. */
109+
projectId: string;
110+
}
111+
112+
/**
113+
* Walk `requires` and install each known capability on the kernel.
114+
*
115+
* Returns the list of plugin exports actually installed for diagnostics.
116+
*/
117+
export async function loadCapabilities(opts: LoadCapabilitiesOptions): Promise<string[]> {
118+
const { kernel, requires, bundle, projectId } = opts;
119+
const logger: Logger = opts.logger ?? console;
120+
const installed: string[] = [];
121+
122+
for (const cap of requires) {
123+
const spec = CAPABILITY_PROVIDERS[cap];
124+
if (!spec) {
125+
// Tier-gated capability (auth/ui/i18n) — wired elsewhere.
126+
continue;
127+
}
128+
129+
try {
130+
const mod: any = await import(/* webpackIgnore: true */ spec.pkg);
131+
const Ctor = mod[spec.export];
132+
if (!Ctor) {
133+
logger.warn?.(
134+
`[CapabilityLoader] '${cap}': package '${spec.pkg}' did not export '${spec.export}'`,
135+
{ projectId },
136+
);
137+
continue;
138+
}
139+
140+
let arg: unknown;
141+
if (spec.configKey) {
142+
const v = (bundle as Record<string, unknown>)[spec.configKey];
143+
if (spec.configKey === 'analyticsCubes') {
144+
arg = { cubes: Array.isArray(v) ? v : [] };
145+
} else if (v !== undefined) {
146+
arg = v;
147+
}
148+
}
149+
150+
await kernel.use(arg !== undefined ? new Ctor(arg) : new Ctor());
151+
installed.push(spec.export);
152+
153+
if (spec.extras) {
154+
for (const ex of spec.extras) {
155+
try {
156+
const exMod: any = await import(/* webpackIgnore: true */ ex.pkg);
157+
const ExCtor = exMod[ex.export];
158+
if (ExCtor) {
159+
await kernel.use(new ExCtor());
160+
installed.push(ex.export);
161+
}
162+
} catch {
163+
// Optional extra — silently skip.
164+
}
165+
}
166+
}
167+
168+
logger.info?.(
169+
`[CapabilityLoader] '${cap}' installed (${spec.export}${spec.extras ? ' + ' + spec.extras.length + ' extras' : ''})`,
170+
{ projectId },
171+
);
172+
} catch (err: any) {
173+
const msg = err?.message ?? String(err);
174+
if (msg.includes('Cannot find module') || msg.includes('ERR_MODULE_NOT_FOUND')) {
175+
logger.warn?.(
176+
`[CapabilityLoader] '${cap}' requested but '${spec.pkg}' not installed in host — skipped`,
177+
{ projectId },
178+
);
179+
} else {
180+
logger.error?.(
181+
`[CapabilityLoader] '${cap}' load failed: ${msg}`,
182+
{ projectId },
183+
);
184+
}
185+
}
186+
}
187+
188+
return installed;
189+
}

0 commit comments

Comments
 (0)