@zhin.js/runtime 1.1.7 → 1.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import Ajv2020 from 'ajv/dist/2020.js';
2
2
  import hostConfigSchema from './host-config-schema.json' with { type: 'json' };
3
3
  import { readPluginConfigSchema } from './plugin-config-schema.js';
4
+ import { readApplicationPluginDeclaration } from './application-plugin-declaration.js';
4
5
  function deepFreeze(value) {
5
6
  if (value && typeof value === 'object' && !Object.isFrozen(value)) {
6
7
  for (const nested of Object.values(value))
@@ -12,6 +13,7 @@ function deepFreeze(value) {
12
13
  export const HOST_CONFIG_SCHEMA = deepFreeze(hostConfigSchema);
13
14
  /** Host configuration keys consumed by Runtime composition and Host installers. */
14
15
  export const HOST_CONFIG_KEYS = Object.freeze(Object.keys(HOST_CONFIG_SCHEMA.properties));
16
+ const ROOT_FEATURE_CONFIG_KEYS = ['menuKeyword', 'menuStyle'];
15
17
  export class ConfigSchemaCollisionError extends Error {
16
18
  plugin;
17
19
  instanceKey;
@@ -37,22 +39,36 @@ export class ConfigValidationError extends Error {
37
39
  export class ConfigComposer {
38
40
  inactivePluginInstanceKeys;
39
41
  modules;
40
- constructor(inactivePluginInstanceKeys = [], modules) {
42
+ validatedSchemas;
43
+ constructor(inactivePluginInstanceKeys = [], modules,
44
+ /** Previously validated generation declarations; never import retired owners just to compare inputs. */
45
+ validatedSchemas) {
41
46
  this.inactivePluginInstanceKeys = inactivePluginInstanceKeys;
42
47
  this.modules = modules;
48
+ this.validatedSchemas = validatedSchemas;
43
49
  }
44
50
  async compose(graph, input = {},
45
51
  /** Source config file name, annotated on ConfigValidationError. */
46
52
  source) {
53
+ const declaration = readApplicationPluginDeclaration(input, source);
54
+ if (this.inactivePluginInstanceKeys.length > 0) {
55
+ throw new TypeError('Legacy disabled Plugin state must move to disable_plugins; see docs/migrations/plugin-instance-declarations.md');
56
+ }
57
+ const enabledKeys = new Set(declaration.instances.filter(instance => instance.enabled).map(instance => instance.instanceKey));
58
+ if (enabledKeys.size !== graph.root.children.length
59
+ || graph.root.children.some(child => !enabledKeys.has(child.instanceKey))) {
60
+ throw new TypeError('Compose configuration against its candidate application graph before loading Plugin schemas');
61
+ }
47
62
  // Effective schemas include child namespaces for whole-tree validation;
48
63
  // ownSchemas retain each package's private configuration contract.
49
64
  const ownSchemas = new Map();
50
- const rootOwn = await readOwnSchema(graph.root, this.modules);
65
+ const rootOwn = await readOwnSchema(graph.root, this.modules, this.validatedSchemas);
51
66
  ownSchemas.set(graph.root.id, rootOwn);
52
67
  const childSchemas = await Promise.all(graph.root.children.map(async (child) => [
53
68
  child.instanceKey,
54
- await composeNode(child, ownSchemas, this.modules),
69
+ await composeNode(child, ownSchemas, this.modules, this.validatedSchemas),
55
70
  ]));
71
+ const childSchemaByKey = new Map(childSchemas);
56
72
  // Host-level keys (`http`, `database`, `ai`, `mcp`, `a2a`, `speech`,
57
73
  // `htmlRenderer`, `assistant`, `log_level`) are consumed by CLI Root installers /
58
74
  // start-command, not Plugin ConfigViews.
@@ -63,15 +79,15 @@ export class ConfigComposer {
63
79
  properties: {
64
80
  ...HOST_CONFIG_SCHEMA.properties,
65
81
  plugin: withDefault(rootOwn),
66
- plugins: {
67
- type: 'object',
68
- additionalProperties: false,
69
- default: {},
70
- properties: Object.fromEntries([
71
- ...this.inactivePluginInstanceKeys.map((key) => [key, {}]),
72
- ...childSchemas.map(([key, schema]) => [key, withDefault(schema)]),
73
- ]),
74
- },
82
+ configVersion: { const: 2 },
83
+ plugins: { type: 'object', additionalProperties: { type: 'string' }, default: {} },
84
+ disable_plugins: { type: 'array', items: { type: 'string' }, default: [] },
85
+ ...Object.fromEntries(declaration.instances.map(instance => {
86
+ const schema = childSchemaByKey.get(instance.instanceKey);
87
+ if (instance.enabled && !schema)
88
+ throw new TypeError(`Candidate graph is missing enabled Plugin ${instance.instanceKey}`);
89
+ return [instance.instanceKey, instance.enabled && schema ? withDefault(schema) : { type: 'object' }];
90
+ })),
75
91
  },
76
92
  });
77
93
  const document = structuredClone(input);
@@ -83,8 +99,17 @@ export class ConfigComposer {
83
99
  const views = new Map();
84
100
  // A Plugin never receives its effective node object because that object
85
101
  // also contains descendants. Re-pick fields from the owner's own schema.
86
- views.set(graph.root.id, pickOwnFields(document.plugin, requireOwnSchema(ownSchemas, graph.root.id)));
87
- collectChildViews(graph.root, document.plugins, ownSchemas, views);
102
+ views.set(graph.root.id,
103
+ // Built-in menu settings belong to the application, but Command Feature
104
+ // consumes the root's generation view. Project only these reserved fields;
105
+ // Host credentials and other instances never enter a Plugin ConfigView.
106
+ Object.freeze({
107
+ ...pickOwnFields(document.plugin, requireOwnSchema(ownSchemas, graph.root.id)),
108
+ ...Object.fromEntries(ROOT_FEATURE_CONFIG_KEYS
109
+ .filter(key => Object.hasOwn(document, key))
110
+ .map(key => [key, deepFreeze(structuredClone(document[key]))])),
111
+ }));
112
+ collectChildViews(graph.root, document, ownSchemas, views);
88
113
  return Object.freeze({
89
114
  effectiveSchema,
90
115
  document: Object.freeze(document),
@@ -145,8 +170,8 @@ function injectFrameworkRoleSchema(properties) {
145
170
  }),
146
171
  });
147
172
  }
148
- async function composeNode(node, ownSchemas, modules) {
149
- const own = await readOwnSchema(node, modules);
173
+ async function composeNode(node, ownSchemas, modules, validatedSchemas) {
174
+ const own = await readOwnSchema(node, modules, validatedSchemas);
150
175
  ownSchemas.set(node.id, own);
151
176
  const properties = { ...schemaProperties(own) };
152
177
  // Adapter plugins (schemas declaring array-typed `endpoints`) get
@@ -159,11 +184,17 @@ async function composeNode(node, ownSchemas, modules) {
159
184
  if (Object.hasOwn(properties, child.instanceKey)) {
160
185
  throw new ConfigSchemaCollisionError(node.id, child.instanceKey);
161
186
  }
162
- properties[child.instanceKey] = withDefault(await composeNode(child, ownSchemas, modules));
187
+ properties[child.instanceKey] = withDefault(await composeNode(child, ownSchemas, modules, validatedSchemas));
163
188
  }
164
189
  return Object.freeze({ ...own, properties });
165
190
  }
166
- async function readOwnSchema(node, modules) {
191
+ async function readOwnSchema(node, modules, validatedSchemas) {
192
+ if (validatedSchemas) {
193
+ const schema = validatedSchemas.get(node.id);
194
+ if (!schema)
195
+ throw new TypeError(`Missing committed configuration schema for ${node.id}`);
196
+ return schema;
197
+ }
167
198
  const file = `${node.package.name} config`;
168
199
  const value = await readPluginConfigSchema(node.package, modules)
169
200
  ?? { type: 'object', additionalProperties: false, properties: {} };
@@ -183,8 +214,9 @@ async function readOwnSchema(node, modules) {
183
214
  throw new TypeError(`${file} root schema must declare properties; composition keywords (anyOf/oneOf/allOf/$ref) are not supported`);
184
215
  }
185
216
  const properties = schemaProperties(schema);
186
- if (Object.hasOwn(properties, 'commandNamespace')) {
187
- throw new ConfigSchemaCollisionError(node.id, 'commandNamespace');
217
+ for (const key of ['commandNamespace', ...(!node.parent ? ROOT_FEATURE_CONFIG_KEYS : [])]) {
218
+ if (Object.hasOwn(properties, key))
219
+ throw new ConfigSchemaCollisionError(node.id, key);
188
220
  }
189
221
  return deepFreeze({
190
222
  $id: `urn:zhin:plugin-config:${encodeURIComponent(String(node.id))}`,
@@ -9,6 +9,7 @@ export interface ConfigPatchPlan extends ComposedConfig {
9
9
  /** Validates a candidate document before deriving its minimal replacement forest. */
10
10
  export declare class ConfigPatchPlanner {
11
11
  private readonly composer;
12
- constructor(composer?: ConfigComposer);
13
- plan(graph: ProjectGraph, current: RuntimeConfigDocument, patches: readonly ConfigPatch[]): Promise<ConfigPatchPlan>;
12
+ private readonly previousComposer;
13
+ constructor(composer?: ConfigComposer, previousComposer?: ConfigComposer);
14
+ plan(graph: ProjectGraph, current: RuntimeConfigDocument, patches: readonly ConfigPatch[], candidateGraph?: ProjectGraph, previousDocument?: RuntimeConfigDocument): Promise<ConfigPatchPlan>;
14
15
  }
@@ -4,14 +4,16 @@ import { ConfigComposer } from './config-composer.js';
4
4
  /** Validates a candidate document before deriving its minimal replacement forest. */
5
5
  export class ConfigPatchPlanner {
6
6
  composer;
7
- constructor(composer = new ConfigComposer()) {
7
+ previousComposer;
8
+ constructor(composer = new ConfigComposer(), previousComposer = composer) {
8
9
  this.composer = composer;
10
+ this.previousComposer = previousComposer;
9
11
  }
10
- async plan(graph, current, patches) {
11
- const previous = await this.composer.compose(graph, current);
12
+ async plan(graph, current, patches, candidateGraph = graph, previousDocument = current) {
13
+ const previous = await this.previousComposer.compose(graph, previousDocument);
12
14
  const candidate = applyConfigPatches(current, patches);
13
- const next = await this.composer.compose(graph, candidate);
14
- const changed = indexGraph(graph)
15
+ const next = await this.composer.compose(candidateGraph, candidate);
16
+ const changed = indexGraph(candidateGraph)
15
17
  .filter((node) => !isDeepStrictEqual(previous.views.get(node.id), next.views.get(node.id)))
16
18
  .map((node) => node.id);
17
19
  return Object.freeze({