@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,9 +1,10 @@
1
1
  import { normalizeSourcePath } from './source-path.js';
2
2
  import { resolve } from 'node:path';
3
3
  import { isDeepStrictEqual } from 'node:util';
4
- import { DisposeStack, GenerationHandoffStack, RootController, ConfigDocumentDivergenceError, createSnapshotView, rootPluginId, } from '@zhin.js/plugin-runtime';
4
+ import { DisposeStack, GenerationHandoffStack, RootController, applyConfigPatches, ConfigDocumentDivergenceError, createSnapshotView, rootPluginId, } from '@zhin.js/plugin-runtime';
5
5
  import { FeatureCatalog, FeatureDiscovery, } from '@zhin.js/feature-kit';
6
- import { ConfigComposer } from './config-composer.js';
6
+ import { ConfigComposer, HOST_CONFIG_KEYS } from './config-composer.js';
7
+ import { createPrimaryConfig, primaryConfigToken } from './primary-config.js';
7
8
  import { immutableConfigSchema } from './plugin-config-schema.js';
8
9
  import { ConfigPatchPlanner } from './config-patch-planner.js';
9
10
  import { defineRuntimeEnvironment } from './environment.js';
@@ -25,6 +26,7 @@ import { SubtreeGenerationPreparer, SubtreeTopologyChangedError, } from './subtr
25
26
  import { TopologyGenerationPreparer } from './topology-generation-preparer.js';
26
27
  import { RestartBoundaryPlanner } from './restart-boundary.js';
27
28
  import { RuntimeInputPlanner } from './runtime-input-planner.js';
29
+ import { TopologyTransactionPlanner } from './topology-transaction.js';
28
30
  export class RootRuntime {
29
31
  #projectRoot;
30
32
  #modules;
@@ -38,7 +40,7 @@ export class RootRuntime {
38
40
  #configDocument;
39
41
  #installResources;
40
42
  #isolation;
41
- #configPatchTail = Promise.resolve();
43
+ #controlTail = Promise.resolve();
42
44
  #stopResult;
43
45
  #initialConfigSchemas = new Map();
44
46
  #resolveConfigurationReady;
@@ -56,8 +58,13 @@ export class RootRuntime {
56
58
  this.#environment = defineRuntimeEnvironment(options.environment);
57
59
  this.#environmentLayers = defineEnvironmentLayers(options.environmentVariables);
58
60
  this.#environmentSource = options.environmentSource;
59
- if (typeof options.config === 'function')
61
+ if (options.application !== undefined && typeof options.config !== 'function') {
62
+ throw new TypeError('application is only used with a custom config resolver; pass the v2 document as config otherwise');
63
+ }
64
+ if (typeof options.config === 'function') {
60
65
  this.#configResolver = options.config;
66
+ this.#configDocument = structuredClone(options.application ?? {});
67
+ }
61
68
  else if (isConfigDocumentPort(options.config))
62
69
  this.#configPort = options.config;
63
70
  else
@@ -94,7 +101,10 @@ export class RootRuntime {
94
101
  get sourceOwnership() {
95
102
  return this.#controller.committed.state.ownership;
96
103
  }
97
- async start() {
104
+ start() {
105
+ return this.#enqueueControl(() => this.#start());
106
+ }
107
+ async #start() {
98
108
  try {
99
109
  if (this.#environmentSource) {
100
110
  this.#environmentLayers = defineEnvironmentLayers(await this.#environmentSource.read());
@@ -115,15 +125,24 @@ export class RootRuntime {
115
125
  }
116
126
  }
117
127
  async reload(target = rootPluginId()) {
118
- const snapshot = await this.#controller.reload(target, async (current, signal) => {
119
- return (await this.#prepare(current, signal)).generation;
128
+ return this.#enqueueControl(async () => {
129
+ const previousInputs = this.#runtimeInputState();
130
+ return this.#controller.reload(target, async (current, signal) => {
131
+ return (await this.#prepare(current, signal)).generation;
132
+ }).catch(error => {
133
+ this.#restoreRuntimeInputs(previousInputs);
134
+ throw error;
135
+ });
120
136
  });
121
- return snapshot;
122
137
  }
123
138
  patchConfig(patches) {
124
139
  const operations = cloneConfigPatches(patches);
125
- const result = this.#configPatchTail.then(() => this.#applyConfigPatches(operations), () => this.#applyConfigPatches(operations));
126
- this.#configPatchTail = result.catch(() => undefined);
140
+ return this.#enqueueControl(() => this.#applyConfigPatches(operations));
141
+ }
142
+ /** Serializes source reads and input adoption around the Controller's generation transaction. */
143
+ #enqueueControl(operation) {
144
+ const result = this.#controlTail.then(operation, operation);
145
+ this.#controlTail = result.catch(() => undefined);
127
146
  return result;
128
147
  }
129
148
  createHmrCoordinator(options) {
@@ -159,10 +178,13 @@ export class RootRuntime {
159
178
  return result;
160
179
  }
161
180
  async #reloadPlan(plan) {
181
+ return this.#enqueueControl(() => this.#reloadPlanInQueue(plan));
182
+ }
183
+ async #reloadPlanInQueue(plan) {
162
184
  const inputs = await this.#planRuntimeInputs(plan.changed);
163
185
  if (inputs && 'kind' in inputs)
164
186
  return inputs;
165
- const previousInputs = inputs ? this.#runtimeInputState() : undefined;
187
+ const previousInputs = this.#runtimeInputState();
166
188
  const effectiveInputPlan = inputs
167
189
  ? Object.freeze({
168
190
  ...plan,
@@ -179,10 +201,12 @@ export class RootRuntime {
179
201
  && effective.manifestSources.length === 0
180
202
  && effective.subtrees.length === 0
181
203
  && effective.slots.length === 0
182
- && resolved.capabilities.size === 0)
204
+ && resolved.capabilities.size === 0
205
+ && !inputs.topologyChanged
206
+ && !inputs.primaryConfigChanged)
183
207
  return undefined;
184
- if (this.#model && effective.manifestSources.length > 0) {
185
- const inspected = await this.#inspectProject(inputs?.document, inputs?.environmentLayers);
208
+ if (this.#model && (effective.manifestSources.length > 0 || inputs?.topologyChanged)) {
209
+ const inspected = inputs?.inspected ?? await this.#inspectProject(inputs?.document, inputs?.environmentLayers);
186
210
  restart = new RestartBoundaryPlanner().plan(this.#model.graph, inspected.graph, effective.changed);
187
211
  if (restart)
188
212
  return undefined;
@@ -191,13 +215,18 @@ export class RootRuntime {
191
215
  : await new TopologyGenerationPreparer(this.#modules, this.#model, inspected.graph, inspected.configResolver, inspected.primaryConfigDocument, this.#environment, this.#installResources, inspected.environmentLayers, this.#isolation).prepare(current, signal, { ...effective, capabilities: resolved.capabilities });
192
216
  }
193
217
  else if (effective.subtrees.length === 0 && resolved.capabilities.size > 0 && this.#model) {
194
- prepared = await new SlotGenerationPreparer(this.#modules, this.#model)
218
+ prepared = await new SlotGenerationPreparer(this.#modules, this.#model, inputs?.primaryConfigChanged
219
+ ? createPrimaryConfig(inputs.inspected.primaryConfigDocument, createEnvStore(rootPluginId(), this.#environment, inputs.environmentLayers), inputs.inspected.configResolver.compositionMode)
220
+ : undefined)
195
221
  .prepare(current, resolved.capabilities, signal);
196
222
  }
197
223
  else if (this.#model && this.#canPrepareSubtrees(effective)) {
198
- const inspected = await this.#inspectProject(inputs?.document, inputs?.environmentLayers);
224
+ const inspected = inputs?.inspected ?? await this.#inspectProject(inputs?.document, inputs?.environmentLayers);
199
225
  prepared = await this.#prepareSubtrees(current, inspected, effective.subtrees, signal);
200
226
  }
227
+ else if (this.#model && inputs?.primaryConfigChanged && effective.subtrees.length === 0 && resolved.capabilities.size === 0) {
228
+ prepared = await this.#prepareSubtrees(current, inputs.inspected, [], signal);
229
+ }
201
230
  else {
202
231
  prepared = await this.#prepare(current, signal, inputs?.document, inputs?.environmentLayers);
203
232
  }
@@ -206,6 +235,9 @@ export class RootRuntime {
206
235
  return inputs && previousInputs
207
236
  ? withRuntimeInputHandoff(prepared.generation, () => this.#adoptRuntimeInputs(inputs), () => this.#restoreRuntimeInputs(previousInputs))
208
237
  : prepared.generation;
238
+ }).catch(error => {
239
+ this.#restoreRuntimeInputs(previousInputs);
240
+ throw error;
209
241
  });
210
242
  if (restart)
211
243
  return restart;
@@ -227,8 +259,10 @@ export class RootRuntime {
227
259
  const environmentLayers = environmentChanged && this.#environmentSource
228
260
  ? defineEnvironmentLayers(await this.#environmentSource.read())
229
261
  : this.#environmentLayers;
230
- const inputPlan = await new RuntimeInputPlanner(new ConfigComposer(this.#disabledPluginInstanceKeys, this.#modules)).plan({
262
+ const inspected = await this.#inspectProject(document, environmentLayers, configSnapshot?.pluginOrder ?? this.#configSnapshot?.pluginOrder);
263
+ const inputPlan = await new RuntimeInputPlanner(new ConfigComposer(this.#disabledPluginInstanceKeys, this.#modules), this.#committedConfigComposer()).plan({
231
264
  graph: this.#model.graph,
265
+ nextGraph: inspected.graph,
232
266
  environment: this.#environment,
233
267
  previousDocument: requireConfigDocument(this.#configDocument),
234
268
  nextDocument: document,
@@ -248,6 +282,9 @@ export class RootRuntime {
248
282
  environmentLayers,
249
283
  inputSources,
250
284
  roots: inputPlan.roots,
285
+ inspected,
286
+ topologyChanged: new TopologyTransactionPlanner().plan(this.#model.graph, inspected.graph).changed,
287
+ primaryConfigChanged: this.#primaryConfigChanged(inspected.primaryConfigDocument),
251
288
  });
252
289
  }
253
290
  #adoptRuntimeInputs(candidate) {
@@ -303,14 +340,14 @@ export class RootRuntime {
303
340
  signal.throwIfAborted();
304
341
  return this.#prepareInspected(current, inspected, signal);
305
342
  }
306
- async #inspectProject(document, environmentLayers = this.#environmentLayers) {
307
- const resolver = await NodePackageResolver.create(this.#projectRoot);
308
- const graph = await new ProjectGraphService(resolver, {
309
- disabledPluginInstanceKeys: this.#disabledPluginInstanceKeys,
310
- }).inspect(this.#projectRoot);
343
+ async #inspectProject(document, environmentLayers = this.#environmentLayers, pluginOrder) {
311
344
  if (document === undefined)
312
345
  await this.#refreshConfigDocument();
313
346
  const activeDocument = document ?? this.#configDocument;
347
+ const resolver = await NodePackageResolver.create(this.#projectRoot);
348
+ const graph = await new ProjectGraphService(resolver, {
349
+ disabledPluginInstanceKeys: this.#disabledPluginInstanceKeys,
350
+ }).inspect(this.#projectRoot, activeDocument, pluginOrder ?? this.#configSnapshot?.pluginOrder);
314
351
  if (this.#configResolver) {
315
352
  const customResolver = this.#configResolver;
316
353
  const schemas = new Map(graphNodes(graph.root).flatMap(node => {
@@ -319,6 +356,7 @@ export class RootRuntime {
319
356
  }));
320
357
  const configResolver = node => customResolver(node);
321
358
  configResolver.schema = node => schemas.get(node.id);
359
+ configResolver.compositionMode = 'custom';
322
360
  if (this.snapshot.generation === 0) {
323
361
  this.#initialConfigSchemas = new Map(graphNodes(graph.root).flatMap(node => {
324
362
  const schema = schemas.get(node.id);
@@ -329,7 +367,7 @@ export class RootRuntime {
329
367
  return {
330
368
  graph,
331
369
  configResolver,
332
- primaryConfigDocument: Object.freeze({}),
370
+ primaryConfigDocument: Object.freeze(structuredClone(activeDocument ?? {})),
333
371
  environmentLayers,
334
372
  };
335
373
  }
@@ -374,14 +412,26 @@ export class RootRuntime {
374
412
  .expandMissingAsEmpty(view);
375
413
  };
376
414
  resolver.schema = (node) => schemas?.get(node.id);
415
+ resolver.compositionMode = 'document';
377
416
  return resolver;
378
417
  }
418
+ #committedConfigComposer() {
419
+ return new ConfigComposer([], undefined, new Map([...this.snapshot.tree.values()].flatMap(node => node.configSchema ? [[node.id, node.configSchema]] : [])));
420
+ }
421
+ #committedPrimaryConfig() {
422
+ return this.snapshot.resources.get(rootPluginId())?.get(primaryConfigToken.id);
423
+ }
424
+ #primaryConfigChanged(document) {
425
+ return !isDeepStrictEqual(this.#committedPrimaryConfig()?.document, document);
426
+ }
379
427
  async #applyConfigPatches(patches) {
380
- if (!this.#configDocument) {
428
+ if (this.#configResolver || !this.#configDocument) {
381
429
  throw new Error('Config patches require a document-backed RootRuntime config');
382
430
  }
383
431
  // Adopt any external edit before planning so the port's revision check
384
432
  // cannot conflict and the patch applies on top of the on-disk document.
433
+ const previousInputs = this.#runtimeInputState();
434
+ const previousDocument = this.#configDocument;
385
435
  await this.#refreshConfigDocument();
386
436
  const currentDocument = requireConfigDocument(this.#configDocument);
387
437
  let plan;
@@ -390,31 +440,34 @@ export class RootRuntime {
390
440
  const snapshot = await this.#controller.reload(rootPluginId(), async (current, signal) => {
391
441
  let prepared;
392
442
  signal.throwIfAborted();
393
- const resolver = await NodePackageResolver.create(this.#projectRoot);
394
- const graph = await new ProjectGraphService(resolver, {
395
- disabledPluginInstanceKeys: this.#disabledPluginInstanceKeys,
396
- }).inspect(this.#projectRoot);
397
- const planned = await new ConfigPatchPlanner(new ConfigComposer(this.#disabledPluginInstanceKeys, this.#modules)).plan(graph, currentDocument, patches);
398
- plan = planned;
399
- if (!planned.documentChanged)
400
- return undefined;
401
443
  try {
402
- if (this.#configPort) {
444
+ const candidate = applyConfigPatches(currentDocument, patches);
445
+ const editsDeclarations = patches.some(patch => patch.path.length === 0 || patch.path[0] === 'plugins');
446
+ if (this.#configPort && (!isDeepStrictEqual(currentDocument, candidate) || editsDeclarations)) {
403
447
  const currentSnapshot = requireConfigDocumentSnapshot(this.#configSnapshot);
404
448
  // Port preparation must remain inert; validation and shadow setup can
405
449
  // still reject this candidate without touching the backing document.
406
450
  documentTransaction = await this.#configPort.prepare(currentSnapshot, patches);
407
- if (!isDeepStrictEqual(documentTransaction.document, planned.candidate)) {
451
+ if (!isDeepStrictEqual(documentTransaction.document, candidate)) {
408
452
  throw new ConfigDocumentDivergenceError();
409
453
  }
410
454
  }
411
- // Host-level sections (http/database/ai/...) never land in a Plugin
412
- // view, so their patches plan zero roots. With a Root Resource
413
- // installer the whole generation must still be rebuilt; only an
414
- // installer-less runtime may take the commit-only shortcut.
415
- if (!this.#installResources && planned.roots.length === 0) {
455
+ const resolver = await NodePackageResolver.create(this.#projectRoot);
456
+ const graphService = new ProjectGraphService(resolver, {
457
+ disabledPluginInstanceKeys: this.#disabledPluginInstanceKeys,
458
+ });
459
+ const previousGraph = this.#model?.graph ?? await graphService.inspect(this.#projectRoot, previousDocument);
460
+ const pluginOrder = documentTransaction?.pluginOrder
461
+ ?? patchedPluginOrder(this.#configSnapshot?.pluginOrder, candidate, patches);
462
+ const graph = await graphService.inspect(this.#projectRoot, candidate, pluginOrder);
463
+ const planned = await new ConfigPatchPlanner(new ConfigComposer(this.#disabledPluginInstanceKeys, this.#modules), this.#committedConfigComposer()).plan(previousGraph, currentDocument, patches, graph, previousDocument);
464
+ plan = planned;
465
+ const topologyChanged = this.#model
466
+ ? new TopologyTransactionPlanner().plan(this.#model.graph, graph).changed : false;
467
+ const orderChanged = !isDeepStrictEqual(this.#configSnapshot?.pluginOrder, pluginOrder);
468
+ if (!planned.documentChanged && !topologyChanged && !orderChanged && isDeepStrictEqual(previousDocument, candidate)) {
416
469
  if (documentTransaction)
417
- committedDocument = await documentTransaction.commit();
470
+ await documentTransaction.rollback();
418
471
  return undefined;
419
472
  }
420
473
  const inspected = {
@@ -423,15 +476,30 @@ export class RootRuntime {
423
476
  primaryConfigDocument: planned.document,
424
477
  environmentLayers: this.#environmentLayers,
425
478
  };
426
- // Root resources may consume any Primary Config section. Reinstall them
427
- // when present so a committed patch cannot leave Host services on the
428
- // previous generation's document.
429
- if (this.#model && !planned.roots.includes(rootPluginId()) && planned.roots.length > 0) {
479
+ if (!this.#primaryConfigChanged(planned.document) && planned.roots.length === 0 && !topologyChanged) {
480
+ if (documentTransaction)
481
+ committedDocument = await documentTransaction.commit();
482
+ return undefined;
483
+ }
484
+ const previousPrimary = this.#committedPrimaryConfig()?.document;
485
+ const hostChanged = HOST_CONFIG_KEYS.some(key => !isDeepStrictEqual(previousPrimary?.[key], planned.document[key]));
486
+ const retainRoot = !this.#installResources || !hostChanged;
487
+ // Host sections require resource reinstallation. Other edits publish a
488
+ // current PrimaryConfig projection while retaining Root lifetimes.
489
+ if (this.#model && retainRoot && topologyChanged && !planned.roots.includes(rootPluginId())) {
490
+ prepared = await new TopologyGenerationPreparer(this.#modules, this.#model, graph, inspected.configResolver, inspected.primaryConfigDocument, this.#environment, this.#installResources, this.#environmentLayers, this.#isolation).prepare(current, signal);
491
+ }
492
+ else if (this.#model && retainRoot && !planned.roots.includes(rootPluginId())) {
430
493
  prepared = await this.#prepareSubtrees(current, inspected, planned.roots, signal);
431
494
  }
432
495
  else {
433
496
  prepared = await this.#prepareInspected(current, inspected, signal);
434
497
  }
498
+ if (!prepared) {
499
+ if (documentTransaction)
500
+ committedDocument = await documentTransaction.commit();
501
+ return undefined;
502
+ }
435
503
  return documentTransaction
436
504
  ? withConfigDocumentHandoff(prepared.generation, documentTransaction, (committed) => { committedDocument = committed; })
437
505
  : prepared.generation;
@@ -444,6 +512,9 @@ export class RootRuntime {
444
512
  await documentTransaction.rollback().catch(() => undefined);
445
513
  throw error;
446
514
  }
515
+ }).catch(error => {
516
+ this.#restoreRuntimeInputs(previousInputs);
517
+ throw error;
447
518
  });
448
519
  const completed = requireConfigPatchPlan(plan);
449
520
  this.#configDocument = completed.candidate;
@@ -472,6 +543,29 @@ function collapseInvalidationSubtrees(values) {
472
543
  const sorted = [...new Set(values)].sort((left, right) => left.length - right.length);
473
544
  return Object.freeze(sorted.filter((candidate, index) => !sorted.slice(0, index).some((root) => isWithinInvalidationRoot(candidate, root))));
474
545
  }
546
+ /** Preserve known source ordering when a structural port omits optional write metadata. */
547
+ function patchedPluginOrder(previous, candidate, patches) {
548
+ if (!previous)
549
+ return undefined;
550
+ let order = [...previous];
551
+ for (const patch of patches) {
552
+ if (patch.path.length === 0 || (patch.path[0] === 'plugins' && patch.path.length === 1)) {
553
+ const value = patch.path.length === 0 && patch.op === 'set'
554
+ ? patch.value?.plugins : patch.op === 'set' ? patch.value : {};
555
+ order = value && typeof value === 'object' ? Object.keys(value) : [];
556
+ }
557
+ else if (patch.path[0] === 'plugins' && patch.path[1]) {
558
+ const key = patch.path[1];
559
+ if (patch.op === 'remove')
560
+ order = order.filter(alias => alias !== key);
561
+ else if (!order.includes(key))
562
+ order.push(key);
563
+ }
564
+ }
565
+ const plugins = candidate.plugins;
566
+ const keys = plugins && typeof plugins === 'object' ? Object.keys(plugins) : [];
567
+ return Object.freeze([...order.filter(key => keys.includes(key)), ...keys.filter(key => !order.includes(key))]);
568
+ }
475
569
  function isWithinInvalidationRoot(plugin, root) {
476
570
  return plugin === root || plugin.startsWith(`${root}/`);
477
571
  }
@@ -12,9 +12,11 @@ export interface RuntimeInputPlan {
12
12
  /** Classifies config/environment changes by their actual expanded runtime projection. */
13
13
  export declare class RuntimeInputPlanner {
14
14
  private readonly composer;
15
- constructor(composer?: ConfigComposer);
15
+ private readonly previousComposer;
16
+ constructor(composer?: ConfigComposer, previousComposer?: ConfigComposer);
16
17
  plan(options: {
17
18
  readonly graph: ProjectGraph;
19
+ readonly nextGraph?: ProjectGraph;
18
20
  readonly environment: RuntimeEnvironment;
19
21
  readonly previousDocument: RuntimeConfigDocument;
20
22
  readonly nextDocument: RuntimeConfigDocument;
@@ -5,15 +5,18 @@ import { createEnvStore, } from './environment-store.js';
5
5
  /** Classifies config/environment changes by their actual expanded runtime projection. */
6
6
  export class RuntimeInputPlanner {
7
7
  composer;
8
- constructor(composer = new ConfigComposer()) {
8
+ previousComposer;
9
+ constructor(composer = new ConfigComposer(), previousComposer = composer) {
9
10
  this.composer = composer;
11
+ this.previousComposer = previousComposer;
10
12
  }
11
13
  async plan(options) {
12
- const previous = await this.composer.compose(options.graph, options.previousDocument);
13
- const next = await this.composer.compose(options.graph, options.nextDocument);
14
+ const previous = await this.previousComposer.compose(options.graph, options.previousDocument);
15
+ const nextGraph = options.nextGraph ?? options.graph;
16
+ const next = await this.composer.compose(nextGraph, options.nextDocument);
14
17
  const environmentChanged = !isDeepStrictEqual(options.previousEnvironment, options.nextEnvironment);
15
18
  const hostKeys = HOST_CONFIG_KEYS.filter((key) => !isDeepStrictEqual(expandHostValue(previous.document[key], options.environment, options.previousEnvironment), expandHostValue(next.document[key], options.environment, options.nextEnvironment)));
16
- const changed = indexGraph(options.graph)
19
+ const changed = indexGraph(nextGraph)
17
20
  .filter((node) => !isDeepStrictEqual(expandPluginView(node.id, previous.views.get(node.id), options.environment, options.previousEnvironment), expandPluginView(node.id, next.views.get(node.id), options.environment, options.nextEnvironment)))
18
21
  .map((node) => node.id);
19
22
  // EnvStore is itself a public Plugin resource. A changed global layer can
@@ -1,10 +1,12 @@
1
1
  import { type CapabilityId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
2
  import type { ModuleRuntime } from './module-runtime.js';
3
3
  import { type PreparedRuntimeGeneration, type RuntimeGenerationModel } from './runtime-generation.js';
4
+ import { type PrimaryConfig } from './primary-config.js';
4
5
  import { type CapabilityDelta } from './convention-capability-delta.js';
5
6
  export declare class SlotGenerationPreparer {
6
7
  private readonly modules;
7
8
  private readonly model;
8
- constructor(modules: ModuleRuntime, model: RuntimeGenerationModel);
9
+ private readonly primaryConfig?;
10
+ constructor(modules: ModuleRuntime, model: RuntimeGenerationModel, primaryConfig?: PrimaryConfig | undefined);
9
11
  prepare(current: RuntimeSnapshot, selected: readonly CapabilityId[] | CapabilityDelta, signal: AbortSignal): Promise<PreparedRuntimeGeneration>;
10
12
  }
@@ -4,13 +4,16 @@ import { FeatureProjector, composeGenerationHandoffs } from './feature-projector
4
4
  import { NodeDiscoveryHost } from './node-discovery-host.js';
5
5
  import { prepareRuntimeGeneration, } from './runtime-generation.js';
6
6
  import { SourceOwnershipIndex } from './source-ownership.js';
7
+ import { projectPrimaryConfigResources } from './primary-config.js';
7
8
  import { capabilityDeltaFromSlots, } from './convention-capability-delta.js';
8
9
  export class SlotGenerationPreparer {
9
10
  modules;
10
11
  model;
11
- constructor(modules, model) {
12
+ primaryConfig;
13
+ constructor(modules, model, primaryConfig) {
12
14
  this.modules = modules;
13
15
  this.model = model;
16
+ this.primaryConfig = primaryConfig;
14
17
  }
15
18
  async prepare(current, selected, signal) {
16
19
  signal.throwIfAborted();
@@ -38,7 +41,9 @@ export class SlotGenerationPreparer {
38
41
  root: current.root,
39
42
  tree: current.tree,
40
43
  config: current.config,
41
- resources: current.resources,
44
+ resources: this.primaryConfig
45
+ ? projectPrimaryConfigResources(current.resources, this.primaryConfig)
46
+ : current.resources,
42
47
  capabilities,
43
48
  }, signal, current.projections);
44
49
  try {
@@ -78,13 +78,19 @@ export class TopologyGenerationPreparer {
78
78
  plugins.synchronizeTree(this.graph.root);
79
79
  const capabilities = await this.#prepareCapabilities(current, plan, setupRoots, featureTopology, selected);
80
80
  mergeSetupCapabilities(capabilities, plugins.setupCapabilities(), featureTopology.providers, featureTopology.rootsByFeature);
81
- const projected = await new FeatureProjector(featureTopology.providers.values()).project(current.generation + 1, {
81
+ const providers = [...featureTopology.providers.values()].filter(provider => provider.runtime.retain !== 'when-owner-unchanged'
82
+ || this.model.providers.get(provider.id) !== provider
83
+ || !current.projections.has(provider.id)
84
+ || !sameFeatureOwners(this.model.rootsByFeature.get(provider.id) ?? [], featureTopology.rootsByFeature.get(provider.id) ?? [], this.model.scopes, plugins.scopes)
85
+ || !sameFeatureSlots(provider.id, current.capabilities, capabilities));
86
+ const retained = new Map([...current.projections].filter(([id]) => featureTopology.providers.has(id)));
87
+ const projected = await new FeatureProjector(providers).project(current.generation + 1, {
82
88
  root: current.root,
83
89
  tree: plugins.tree,
84
90
  config: plugins.config,
85
91
  resources: plugins.resources,
86
92
  capabilities,
87
- }, signal);
93
+ }, signal, retained);
88
94
  for (const [feature, dispose] of projected.disposers) {
89
95
  projectionDisposers.set(feature, dispose);
90
96
  }
@@ -92,7 +98,10 @@ export class TopologyGenerationPreparer {
92
98
  plugins.bindCandidateSnapshot(snapshot);
93
99
  const ownership = SourceOwnershipIndex.fromGeneration(this.graph, snapshot, featureTopology.featureIdsByPackageRoot);
94
100
  const replacements = new Map(plugins.createdScopeDisposers());
95
- const assets = this.model.assets.replaceScopes(graphOrder(this.graph), replacements, new Set([...this.model.providers.keys(), ...featureTopology.providers.keys()]), projectionDisposers);
101
+ const assets = this.model.assets.replaceScopes(graphOrder(this.graph), replacements, new Set([
102
+ ...providers.map(provider => provider.id),
103
+ ...[...this.model.providers.keys()].filter(id => !featureTopology.providers.has(id)),
104
+ ]), projectionDisposers);
96
105
  return prepareRuntimeGeneration({
97
106
  snapshot: projected.state,
98
107
  dispose: () => assets.dispose(),
@@ -276,6 +285,19 @@ function selectedSlotsByFeature(current, selected, refreshed) {
276
285
  function sameValues(left, right) {
277
286
  return left.length === right.length && left.every((value, index) => value === right[index]);
278
287
  }
288
+ function sameFeatureSlots(feature, previous, next) {
289
+ const before = [...previous.values()].filter(slot => slot.feature === feature);
290
+ const after = [...next.values()].filter(slot => slot.feature === feature);
291
+ return before.length === after.length && before.every(slot => next.get(slot.id) === slot);
292
+ }
293
+ function sameFeatureOwners(previous, next, previousScopes, nextScopes) {
294
+ return previous.length === next.length && previous.every((root, index) => {
295
+ const candidate = next[index];
296
+ return candidate?.owner === root.owner
297
+ && candidate.packageRoot === root.packageRoot
298
+ && previousScopes.get(root.owner) === nextScopes.get(root.owner);
299
+ });
300
+ }
279
301
  async function rollback(scopeDisposers, projectionDisposers, prepareError) {
280
302
  const disposers = new DisposeStack();
281
303
  for (const dispose of scopeDisposers)
@@ -1,4 +1,5 @@
1
1
  import { resolve } from 'node:path';
2
+ import { isDeepStrictEqual } from 'node:util';
2
3
  /** Compiles two resolved graphs into the smallest safe topology transaction. */
3
4
  export class TopologyTransactionPlanner {
4
5
  plan(previous, next) {
@@ -90,7 +91,8 @@ function scopeContractChanged(previous, next) {
90
91
  || previous.package.name !== next.package.name
91
92
  || previousManifest.entry !== nextManifest.entry
92
93
  || previousManifest.runtime !== nextManifest.runtime
93
- || previousManifest.engine !== nextManifest.engine);
94
+ || previousManifest.engine !== nextManifest.engine
95
+ || !isDeepStrictEqual(previousManifest.metadata, nextManifest.metadata));
94
96
  }
95
97
  function childOrderChanged(previous, next) {
96
98
  for (const [id, node] of next) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/runtime",
3
- "version": "1.1.7",
3
+ "version": "1.1.9",
4
4
  "description": "Static Plugin graph, generation transaction and HMR Root runtime for Zhin.js",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -19,25 +19,25 @@
19
19
  "es-module-lexer": "2.3.1",
20
20
  "import-meta-resolve": "4.2.0",
21
21
  "semver": "7.8.5",
22
- "@zhin.js/feature-kit": "1.1.5",
23
- "@zhin.js/plugin-runtime": "1.1.14"
22
+ "@zhin.js/feature-kit": "1.1.6",
23
+ "@zhin.js/plugin-runtime": "1.1.15"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^26.1.2",
27
27
  "tsx": "^4.23.1",
28
28
  "typescript": "^6.0.3",
29
- "@zhin.js/agent-feature": "1.1.6",
30
- "@zhin.js/command": "1.1.7",
31
- "@zhin.js/adapter": "1.1.19",
32
- "@zhin.js/component": "1.1.6",
33
- "@zhin.js/handler": "1.1.5",
34
- "@zhin.js/layout": "1.1.6",
35
- "@zhin.js/mcp-feature": "1.1.6",
36
- "@zhin.js/middleware": "1.1.6",
37
- "@zhin.js/page": "1.1.6",
38
- "@zhin.js/prompt-section": "1.1.5",
39
- "@zhin.js/skill": "1.1.6",
40
- "@zhin.js/tool": "1.1.6"
29
+ "@zhin.js/adapter": "1.1.20",
30
+ "@zhin.js/command": "1.1.8",
31
+ "@zhin.js/component": "1.1.7",
32
+ "@zhin.js/handler": "1.1.6",
33
+ "@zhin.js/mcp-feature": "1.1.7",
34
+ "@zhin.js/agent-feature": "1.1.7",
35
+ "@zhin.js/page": "1.1.7",
36
+ "@zhin.js/prompt-section": "1.1.6",
37
+ "@zhin.js/skill": "1.1.7",
38
+ "@zhin.js/tool": "1.1.7",
39
+ "@zhin.js/middleware": "1.1.7",
40
+ "@zhin.js/layout": "1.1.7"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",