diff --git a/hypaware-core/smoke/flows/cli_bundled_plugins_activated.js b/hypaware-core/smoke/flows/cli_bundled_plugins_activated.js index 43f8329..a187985 100644 --- a/hypaware-core/smoke/flows/cli_bundled_plugins_activated.js +++ b/hypaware-core/smoke/flows/cli_bundled_plugins_activated.js @@ -21,10 +21,11 @@ import { defaultConfigPath } from '../../../src/core/config/schema.js' * 3. `hyp attach --client codex --dry-run` reaches the Codex adapter * (same shape). * 4. `hyp status --json` emits a stable JSON document listing the - * configured sources, sinks, clients, and active plugins, *without* - * mentioning `@hypaware/central` or `@hypaware/gascity` (both - * remain loadable for developers but are excluded from the V1 - * default surface). + * configured sources, sinks, clients, and active plugins. Because + * neither `@hypaware/central` nor `@hypaware/gascity` is in this + * config, they must not appear — they are excluded from default + * activation but remain discoverable through the plugin catalog and + * activatable via explicit config or init presets. * * Telemetry contract (per bead): * - One `kernel.boot` root span per dispatch boot. @@ -48,9 +49,9 @@ export async function run({ harness, expect }) { // Stage a v2 config that selects six of the nine V1-bundled // plugins. `@hypaware/format-jsonl`, `@hypaware/s3`, and // `@hypaware/format-iceberg` are intentionally omitted so the smoke - // can assert the "skipped" log surface, and we exclude - // `@hypaware/central` / `@hypaware/gascity` because they are not on - // the V1 default surface in the first place. + // can assert the "skipped" log surface. `@hypaware/central` and + // `@hypaware/gascity` are not in this config — they are excluded from + // default activation but activatable via explicit config. const configPath = defaultConfigPath(harness.hypHome) await fs.mkdir(path.dirname(configPath), { recursive: true }) await fs.writeFile(configPath, JSON.stringify({ @@ -131,7 +132,7 @@ export async function run({ harness, expect }) { (rows) => Array.isArray(rows) && rows.every((/** @type {any} */ r) => r.source === 'bundled') ) expect.that( - 'plugins: no excluded-from-default plugin (central/gascity) appears', + 'plugins: unconfigured plugins (central/gascity) absent from active list', (listed.plugins ?? []).map((/** @type {any} */ p) => p.name), (v) => Array.isArray(v) && @@ -219,12 +220,12 @@ export async function run({ harness, expect }) { typeof v.state === 'string' ) expect.that( - 'status: JSON does not reference @hypaware/central', + 'status: unconfigured @hypaware/central absent from status JSON', statusStdout.text(), (v) => typeof v === 'string' && !v.includes('@hypaware/central') ) expect.that( - 'status: JSON does not reference @hypaware/gascity', + 'status: unconfigured @hypaware/gascity absent from status JSON', statusStdout.text(), (v) => typeof v === 'string' && !v.includes('@hypaware/gascity') ) diff --git a/hypaware-core/smoke/flows/gascity_attach_writes_partition.js b/hypaware-core/smoke/flows/gascity_attach_writes_partition.js index 51de455..540e598 100644 --- a/hypaware-core/smoke/flows/gascity_attach_writes_partition.js +++ b/hypaware-core/smoke/flows/gascity_attach_writes_partition.js @@ -16,19 +16,17 @@ import { activatePlugins } from '../../../src/core/runtime/loader.js' import { loadManifests } from '../../../src/core/manifest.js' /** - * Phase 8.6 smoke. Boots `@hypaware/gascity` from the in-repo - * workspace, attaches an in-process fixture supervisor through - * `hyp gascity attach`, drives a few SSE-shaped frames, and asserts - * the §Phase 8.6 contract from the implementation plan: + * Gascity plugin-surface acceptance smoke. Boots `@hypaware/gascity` + * from the in-repo workspace and exercises the full plugin lifecycle + * through plugin-owned contributions: * - * - traces: a `source.start` span tagged `hyp_plugin=@hypaware/gascity` - * appears on the first `gascity attach` - * - traces: a `source.reload` span tagged `hyp_plugin=@hypaware/gascity` - * appears on the second `gascity attach` - * - query: `select count(*) from gascity_messages` returns the number - * of frames the fixture pushed - * - cache: at least one `cache.append` span landed for - * `hyp_dataset=gascity_messages` + * - `gascity attach` starts/reloads the source through plugin code + * - `gascity list` shows attached city state + * - `select count(*) from gascity_messages` returns captured rows + * - `gascity detach` removes a city and reloads cleanly + * - traces: `source.start` tagged `hyp_plugin=@hypaware/gascity` + * - traces: `source.reload` on subsequent attach/detach + * - cache: `cache.append` spans for `hyp_dataset=gascity_messages` * * The fixture supervisor lives entirely in this file and is wired to * the plugin via `globalThis[Symbol.for('hypaware-gascity:transport')]` @@ -171,6 +169,39 @@ export async function run({ harness, expect }) { (v) => v === true ) + // Detach hypburb and verify the source reloads cleanly. + const detachOut = await dispatchCommand( + ['gascity', 'detach', 'hypburb'], + { kernel, registry, harness } + ) + expect.that( + "stdout: detach prints confirmation for 'hypburb'", + detachOut.includes('hypburb'), + (v) => v === true + ) + + // After detach, list should still show hyptown but not hypburb. + const list2Stdout = makeBuf() + const list2Stderr = makeBuf() + await dispatch(['gascity', 'list'], { + stdout: list2Stdout, + stderr: list2Stderr, + kernel, + registry, + env: smokeEnv(harness), + }) + const list2Text = list2Stdout.text() + expect.that( + 'stdout: gascity list after detach still includes hyptown', + list2Text.includes('hyptown'), + (v) => v === true + ) + expect.that( + 'stdout: gascity list after detach no longer includes hypburb', + list2Text.includes('hypburb'), + (v) => v === false + ) + await obs.shutdown() fixture.uninstall() @@ -199,9 +230,9 @@ export async function run({ harness, expect }) { (/** @type {any} */ t) => t.name === 'source.reload' ) expect.that( - 'traces: at least one source.reload span emitted (from the second attach)', + 'traces: at least 2 source.reload spans emitted (attach + detach)', reloadSpans, - (rows) => rows.length >= 1 + (rows) => rows.length >= 2 ) expect.that( 'traces: source.reload tagged hyp_plugin=@hypaware/gascity', diff --git a/src/core/plugin_catalog.js b/src/core/plugin_catalog.js index c16000c..a5f8e16 100644 --- a/src/core/plugin_catalog.js +++ b/src/core/plugin_catalog.js @@ -33,8 +33,14 @@ /** * Build a plugin catalog from loaded manifests. The catalog derives - * capability metadata, known datasets, and contribution summaries - * from the manifest files themselves rather than a hardcoded table. + * capability metadata, known datasets, client descriptors, and + * contribution summaries from the manifest files themselves rather + * than a hardcoded table. + * + * Callers should pass both `bundled.loaded` and `bundled.excluded` + * manifests so excluded plugins (like `@hypaware/gascity`) remain + * visible for config validation and descriptor resolution even though + * they are not activated by default. * * Duplicate plugin names are resolved by first-writer-wins: the first * manifest array is treated as authoritative (bundled plugins), so diff --git a/src/core/runtime/boot.js b/src/core/runtime/boot.js index 254cdb3..e23d580 100644 --- a/src/core/runtime/boot.js +++ b/src/core/runtime/boot.js @@ -128,12 +128,10 @@ export async function bootKernel(opts = {}) { const loadedConfig = configPath ? await loadConfigFile(configPath) : null const config = loadedConfig?.ok ? loadedConfig.config : null - // The full plugin pool the kernel knows about: V1 allowlist plus - // the excluded-from-default set (so developers can still activate - // `@hypaware/central` or `@hypaware/gascity` by naming them in - // config), plus every plugin in `plugin-lock.json` whose manifest - // loaded. `all-bundled` boots intentionally skip the excluded and - // installed sets so the picker only sees the V1 default surface. + // Full plugin pool: V1 allowlist + excluded-from-default set + + // installed plugins. Excluded plugins are in the pool so they + // activate when named in config or an init preset — the allowlist + // only governs default activation, not discoverability. const installedNames = new Set(installed.loaded.map((m) => m.manifest.name)) const excludedAvailable = discovered.excluded.filter( (m) => !installedNames.has(/** @type {PluginName} */ (m.manifest.name)) diff --git a/src/core/runtime/bundled.js b/src/core/runtime/bundled.js index c8fbf62..dae822d 100644 --- a/src/core/runtime/bundled.js +++ b/src/core/runtime/bundled.js @@ -12,12 +12,14 @@ import { loadManifests } from '../manifest.js' */ /** - * V1 bundled plugin allowlist (finish-v1.md §Phase 2). A plugin must - * appear here to be discoverable through the default boot path. The - * allowlist exists so the V1 default install does not pull - * `@hypaware/central` or `@hypaware/gascity` into the picker, the - * default config, or the V1 smokes — both remain on disk for - * developers but are excluded from V1 acceptance gates. + * V1 bundled plugin allowlist. A plugin must appear here to be + * activated by the default boot profiles (`all-bundled`, + * `all-available`). Excluded plugins (`@hypaware/central`, + * `@hypaware/gascity`) are still discoverable through the plugin + * catalog — their manifest contributions (datasets, client + * descriptors, capability metadata) are visible to config validation + * and the walkthrough. They are activatable via explicit config or + * init presets; the allowlist only governs default activation. * * @type {ReadonlySet} */ @@ -34,10 +36,12 @@ export const V1_BUNDLED_PLUGIN_ALLOWLIST = new Set(/** @type {PluginName[]} */ ( ])) /** - * Bundled plugins present in the repo workspace but excluded from the - * V1 default surface. They remain loadable for developers (via - * explicit manifest discovery) but never appear in the V1 picker, - * default configs, V1 docs, or V1 smokes. + * Bundled plugins excluded from default activation. Their manifests + * are still loaded by `discoverBundledPlugins` (in the `excluded` + * bucket) so the plugin catalog can derive datasets, client + * descriptors, and capability metadata for config validation. + * Activation requires explicit config (`{ name: '@hypaware/gascity' }`) + * or an init preset — the picker and default boot profiles skip them. * * @type {ReadonlySet} */ @@ -67,8 +71,10 @@ export function defaultBundledWorkspaceDir() { * - `loaded` — manifests whose `name` is in the V1 allowlist. * - `excluded` — manifests whose `name` is in the V1 exclude set * (`@hypaware/central`, `@hypaware/gascity`). These - * remain available for developers but are not surfaced - * by the default boot path. + * are excluded from default activation but their + * manifests feed the plugin catalog so datasets, + * client descriptors, and capability metadata remain + * visible to config validation and the walkthrough. * - `unknownDirs` — directories that hold a parseable manifest under * a name the kernel doesn't recognise as bundled. * diff --git a/test/core/config.test.js b/test/core/config.test.js index 9481576..c3c8354 100644 --- a/test/core/config.test.js +++ b/test/core/config.test.js @@ -233,6 +233,36 @@ test('buildPluginCatalog collects known datasets from manifest contributions', a assert.ok(catalog.knownDatasets.has('logs')) assert.ok(catalog.knownDatasets.has('traces')) assert.ok(catalog.knownDatasets.has('metrics')) + assert.ok( + catalog.knownDatasets.has('gascity_messages'), + 'catalog includes gascity_messages from excluded plugin manifest' + ) +}) + +test('buildPluginCatalog includes excluded gascity plugin as a catalog entry', async () => { + const bundled = await discoverBundledPlugins() + const catalog = buildPluginCatalog([...bundled.loaded, ...bundled.excluded]) + + const entry = catalog.plugins.get('@hypaware/gascity') + assert.ok(entry, 'gascity must be in catalog when excluded manifests are included') + assert.equal(entry.name, '@hypaware/gascity') + assert.ok(entry.contributes, 'gascity catalog entry must carry its contributions') + assert.ok( + entry.contributes.sources?.some((s) => s.name === 'gascity'), + 'gascity contributes a "gascity" source' + ) + assert.ok( + entry.contributes.commands?.some((c) => c.name === 'gascity attach'), + 'gascity contributes a "gascity attach" command' + ) + assert.ok( + entry.contributes.init_presets?.some((p) => p.name === 'gascity'), + 'gascity contributes a "gascity" init preset' + ) + assert.ok( + entry.contributes.skills?.some((s) => s.name === 'hypaware-gascity'), + 'gascity contributes the hypaware-gascity skill' + ) }) test('validateConfig uses catalog-derived metadata for sink validation', async () => {