mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(plugins): wire bundled-scenario pipeline fallback through apply (spec §23.3.3)
Plan O1 / spec §23.3.3.
When a plugin omits `od.pipeline`, applyPlugin() now consults the
bundled scenario plugins registered by the daemon's bundled boot
walker (apps/daemon/src/plugins/bundled.ts) and copies the matching
scenario's pipeline verbatim into the snapshot. Match key:
`od.taskKind` (defaulting to 'new-generation' when absent).
Pure-side (packages/plugin-runtime):
- new resolveAppliedPipeline({ manifest, scenarios }) →
{ pipeline, source: 'declared' | 'scenario' | 'none', scenarioId? }
- RegistryView gains optional `scenarios?: ScenarioRegistryEntry[]`
so the daemon registry view propagates the lookup table without
coupling the pure resolver to SQLite.
Daemon-side (apps/daemon):
- applyPlugin() calls resolveAppliedPipeline and stores the result on
BOTH `ApplyResult.pipeline` and `AppliedPluginSnapshot.pipeline`
so a replay reproduces the same stages without re-consulting the
scenario registry.
- server.loadPluginRegistryView() now collects bundled scenarios
from `installed_plugins` (source_kind='bundled', od.kind='scenario',
non-empty pipeline). Third-party scenarios are intentionally NOT
eligible — only rows seeded by the bundled boot walker.
Invariants preserved:
- Apply stays pure: scenarios are read once at the registry-view
construction site and passed in; apply never opens a DB itself.
- Scenario plugins themselves never fall back to themselves
(resolveAppliedPipeline returns 'none' when od.kind='scenario').
- manifestSourceDigest is unaffected by the fallback (digest is over
manifest + inputs + resolvedContextRefs); replay invariance held.
Tests: +15 (plugin-runtime: 7 cases on resolveAppliedPipeline;
daemon: 8 cases on plugins-scenario-fallback covering declared
pipeline win, scenario fallback by taskKind, default to
new-generation, scenario plugins don't recurse, no scenarios →
pipeline=undefined, manifestSourceDigest stability across applies,
DB collector round-trip).
Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
parent
1d142b4214
commit
694289295a
7 changed files with 444 additions and 2 deletions
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
import {
|
||||
manifestSourceDigest,
|
||||
resolveAppliedPipeline,
|
||||
resolveContext,
|
||||
type RegistryView,
|
||||
} from '@open-design/plugin-runtime';
|
||||
|
|
@ -118,6 +119,18 @@ export function applyPlugin(input: ApplyInput): ApplyComputed {
|
|||
|
||||
const taskKind = (manifest.od?.taskKind ?? 'new-generation') as AppliedPluginSnapshot['taskKind'];
|
||||
|
||||
// Spec §23.3.3: when the plugin omits `od.pipeline`, fall back to
|
||||
// the bundled scenario whose taskKind matches. The registry view
|
||||
// carries the lookup; daemon callers populate it from the
|
||||
// `installed_plugins` table filtered to source_kind='bundled' AND
|
||||
// od.kind='scenario'. Tests + non-daemon callers can pass an empty
|
||||
// list, in which case the pipeline stays undefined.
|
||||
const pipelineResolution = resolveAppliedPipeline({
|
||||
manifest,
|
||||
scenarios: input.registry.scenarios,
|
||||
});
|
||||
const appliedPipeline = pipelineResolution.pipeline;
|
||||
|
||||
const projectMetadata: PluginProjectMetadataPatch = {
|
||||
name: manifest.title ?? manifest.name,
|
||||
taskKind,
|
||||
|
|
@ -152,7 +165,7 @@ export function applyPlugin(input: ApplyInput): ApplyComputed {
|
|||
connectorsRequired,
|
||||
connectorsResolved,
|
||||
mcpServers,
|
||||
pipeline: manifest.od?.pipeline,
|
||||
pipeline: appliedPipeline,
|
||||
genuiSurfaces,
|
||||
pluginTitle: manifest.title ?? manifest.name,
|
||||
pluginDescription: manifest.description,
|
||||
|
|
@ -166,7 +179,7 @@ export function applyPlugin(input: ApplyInput): ApplyComputed {
|
|||
inputs: manifest.od?.inputs ?? [],
|
||||
assets,
|
||||
mcpServers,
|
||||
pipeline: manifest.od?.pipeline,
|
||||
pipeline: appliedPipeline,
|
||||
genuiSurfaces,
|
||||
projectMetadata,
|
||||
trust,
|
||||
|
|
|
|||
|
|
@ -3399,14 +3399,53 @@ export async function startServer({
|
|||
listSkills(SKILLS_DIR),
|
||||
listDesignSystems(DESIGN_SYSTEMS_DIR),
|
||||
]);
|
||||
// Spec §23.3.3: surface the bundled scenario plugins so apply()
|
||||
// can fall back to the matching scenario's pipeline when the
|
||||
// consumer plugin omits od.pipeline. Each scenario carries a
|
||||
// `taskKind` that picks the match.
|
||||
const scenarios = collectBundledScenarios();
|
||||
return {
|
||||
skills: skills.map((s) => ({ id: s.id, title: s.name, description: s.description })),
|
||||
designSystems: designSystems.map((d) => ({ id: d.id, title: d.title })),
|
||||
craft: [],
|
||||
atoms: FIRST_PARTY_ATOMS.map((a) => ({ id: a.id, label: a.label })),
|
||||
scenarios,
|
||||
};
|
||||
}
|
||||
|
||||
// Pure read off `installed_plugins`: rows whose source_kind='bundled'
|
||||
// AND od.kind='scenario' AND od.pipeline is non-empty become entries
|
||||
// the apply path can fall back to. Scenario plugins from third-party
|
||||
// sources are intentionally NOT trusted as defaults — the bundled
|
||||
// boot walker (apps/daemon/src/plugins/bundled.ts) is the only writer
|
||||
// of source_kind='bundled', so this function never grants the
|
||||
// privilege to user-installed scenarios.
|
||||
function collectBundledScenarios() {
|
||||
const out: Array<{
|
||||
id: string;
|
||||
taskKind: 'new-generation' | 'figma-migration' | 'code-migration' | 'tune-collab';
|
||||
pipeline: NonNullable<NonNullable<import('@open-design/contracts').PluginManifest['od']>['pipeline']>;
|
||||
}> = [];
|
||||
try {
|
||||
const all = listInstalledPlugins(db);
|
||||
for (const row of all) {
|
||||
if (row.sourceKind !== 'bundled') continue;
|
||||
const od = row.manifest.od;
|
||||
if (!od || od.kind !== 'scenario') continue;
|
||||
if (!od.pipeline || !Array.isArray(od.pipeline.stages) || od.pipeline.stages.length === 0) continue;
|
||||
const taskKind = (od.taskKind ?? 'new-generation') as 'new-generation' | 'figma-migration' | 'code-migration' | 'tune-collab';
|
||||
if (taskKind !== 'new-generation' && taskKind !== 'figma-migration' &&
|
||||
taskKind !== 'code-migration' && taskKind !== 'tune-collab') continue;
|
||||
out.push({ id: row.id, taskKind, pipeline: od.pipeline });
|
||||
}
|
||||
} catch {
|
||||
// On a fresh install the table may not exist yet; surface no
|
||||
// scenarios rather than crash the apply path.
|
||||
return [];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
app.post('/api/plugins/:id/apply', async (req, res) => {
|
||||
try {
|
||||
const plugin = getInstalledPlugin(db, req.params.id);
|
||||
|
|
|
|||
232
apps/daemon/tests/plugins-scenario-fallback.test.ts
Normal file
232
apps/daemon/tests/plugins-scenario-fallback.test.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
// Spec §23.3.3 / Plan §3.O1 — bundled-scenario pipeline fallback,
|
||||
// driven through the live applyPlugin path with the daemon
|
||||
// registry view.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
InstalledPluginRecord,
|
||||
PluginManifest,
|
||||
} from '@open-design/contracts';
|
||||
import { applyPlugin } from '../src/plugins/apply.js';
|
||||
import { openDatabase } from '../src/db.js';
|
||||
import { upsertInstalledPlugin } from '../src/plugins/registry.js';
|
||||
import { resolveAppliedPipeline, type ScenarioRegistryEntry } from '@open-design/plugin-runtime';
|
||||
|
||||
let tmpRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await mkdtemp(path.join(os.tmpdir(), 'od-scenario-fallback-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codeMigrationScenarioPipeline = {
|
||||
stages: [
|
||||
{ id: 'import', atoms: ['code-import'] },
|
||||
{ id: 'tokens', atoms: ['design-extract', 'token-map'] },
|
||||
{ id: 'plan', atoms: ['rewrite-plan'] },
|
||||
{ id: 'verify',
|
||||
atoms: ['patch-edit', 'build-test'],
|
||||
repeat: true,
|
||||
until: '(build.passing && tests.passing) || iterations>=8' },
|
||||
{ id: 'review', atoms: ['diff-review'] },
|
||||
{ id: 'handoff', atoms: ['handoff'] },
|
||||
],
|
||||
};
|
||||
|
||||
const newGenerationScenarioPipeline = {
|
||||
stages: [
|
||||
{ id: 'discovery', atoms: ['discovery-question-form'] },
|
||||
{ id: 'plan', atoms: ['direction-picker', 'todo-write'] },
|
||||
{ id: 'generate', atoms: ['file-write', 'live-artifact'] },
|
||||
{ id: 'critique', atoms: ['critique-theater'],
|
||||
repeat: true,
|
||||
until: 'critique.score>=4 || iterations>=3' },
|
||||
],
|
||||
};
|
||||
|
||||
const baseRegistry = (
|
||||
scenarios: ScenarioRegistryEntry[] = [],
|
||||
) => ({
|
||||
skills: [],
|
||||
designSystems: [],
|
||||
craft: [],
|
||||
atoms: [],
|
||||
scenarios,
|
||||
});
|
||||
|
||||
const consumerPlugin = (od: NonNullable<PluginManifest['od']>): InstalledPluginRecord => {
|
||||
const manifest: PluginManifest = {
|
||||
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
|
||||
name: 'fixture-consumer',
|
||||
title: 'Fixture consumer',
|
||||
version: '0.1.0',
|
||||
od,
|
||||
} as PluginManifest;
|
||||
return {
|
||||
id: 'fixture-consumer',
|
||||
title: 'Fixture consumer',
|
||||
version: '0.1.0',
|
||||
sourceKind: 'local',
|
||||
source: '/tmp/fixture',
|
||||
fsPath: '/tmp/fixture',
|
||||
trust: 'trusted',
|
||||
capabilitiesGranted: ['prompt:inject'],
|
||||
installedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
manifest,
|
||||
};
|
||||
};
|
||||
|
||||
describe('apply: bundled-scenario pipeline fallback (spec §23.3.3)', () => {
|
||||
it("uses the consumer's declared pipeline when present", () => {
|
||||
const plugin = consumerPlugin({
|
||||
taskKind: 'code-migration',
|
||||
pipeline: { stages: [{ id: 'custom', atoms: ['todo-write'] }] },
|
||||
});
|
||||
const out = applyPlugin({
|
||||
plugin,
|
||||
inputs: {},
|
||||
registry: baseRegistry([
|
||||
{ id: 'od-code-migration', taskKind: 'code-migration', pipeline: codeMigrationScenarioPipeline },
|
||||
]),
|
||||
});
|
||||
expect(out.result.pipeline?.stages?.[0]?.id).toBe('custom');
|
||||
expect(out.result.appliedPlugin.pipeline?.stages?.[0]?.id).toBe('custom');
|
||||
});
|
||||
|
||||
it('falls back to the matching scenario pipeline when the consumer omits it', () => {
|
||||
const plugin = consumerPlugin({ taskKind: 'code-migration' });
|
||||
const out = applyPlugin({
|
||||
plugin,
|
||||
inputs: {},
|
||||
registry: baseRegistry([
|
||||
{ id: 'od-new-generation', taskKind: 'new-generation', pipeline: newGenerationScenarioPipeline },
|
||||
{ id: 'od-code-migration', taskKind: 'code-migration', pipeline: codeMigrationScenarioPipeline },
|
||||
]),
|
||||
});
|
||||
expect(out.result.pipeline?.stages?.map((s) => s.id)).toEqual([
|
||||
'import', 'tokens', 'plan', 'verify', 'review', 'handoff',
|
||||
]);
|
||||
// Snapshot must carry the same fallback-resolved pipeline so a
|
||||
// replay reproduces the same stages without consulting the
|
||||
// scenario registry again.
|
||||
expect(out.result.appliedPlugin.pipeline).toEqual(out.result.pipeline);
|
||||
});
|
||||
|
||||
it("defaults to 'new-generation' when the consumer omits both pipeline and taskKind", () => {
|
||||
const plugin = consumerPlugin({});
|
||||
const out = applyPlugin({
|
||||
plugin,
|
||||
inputs: {},
|
||||
registry: baseRegistry([
|
||||
{ id: 'od-new-generation', taskKind: 'new-generation', pipeline: newGenerationScenarioPipeline },
|
||||
]),
|
||||
});
|
||||
expect(out.result.pipeline?.stages?.[0]?.id).toBe('discovery');
|
||||
});
|
||||
|
||||
it('keeps pipeline undefined when no scenario matches the taskKind', () => {
|
||||
const plugin = consumerPlugin({ taskKind: 'code-migration' });
|
||||
const out = applyPlugin({ plugin, inputs: {}, registry: baseRegistry([]) });
|
||||
expect(out.result.pipeline).toBeUndefined();
|
||||
expect(out.result.appliedPlugin.pipeline).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not fall back when the consumer is itself a scenario plugin", () => {
|
||||
const plugin = consumerPlugin({
|
||||
kind: 'scenario',
|
||||
taskKind: 'new-generation',
|
||||
});
|
||||
const out = applyPlugin({
|
||||
plugin,
|
||||
inputs: {},
|
||||
registry: baseRegistry([
|
||||
{ id: 'od-new-generation', taskKind: 'new-generation', pipeline: newGenerationScenarioPipeline },
|
||||
]),
|
||||
});
|
||||
expect(out.result.pipeline).toBeUndefined();
|
||||
});
|
||||
|
||||
it('produces stable manifestSourceDigest across two applies of the consumer + same registry', () => {
|
||||
const plugin = consumerPlugin({ taskKind: 'code-migration' });
|
||||
const reg = baseRegistry([
|
||||
{ id: 'od-code-migration', taskKind: 'code-migration', pipeline: codeMigrationScenarioPipeline },
|
||||
]);
|
||||
const a = applyPlugin({ plugin, inputs: {}, registry: reg });
|
||||
const b = applyPlugin({ plugin, inputs: {}, registry: reg });
|
||||
// The fallback does not alter the manifestSourceDigest input
|
||||
// (digest covers the manifest + inputs + resolvedContext refs;
|
||||
// pipeline source is downstream). e2e-2 invariant holds.
|
||||
expect(a.manifestSourceDigest).toBe(b.manifestSourceDigest);
|
||||
});
|
||||
});
|
||||
|
||||
describe('daemon scenarios collector (registry view source)', () => {
|
||||
it('handles a fresh DB without scenarios installed', async () => {
|
||||
const dataDir = path.join(tmpRoot, 'fresh');
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
const db = openDatabase(tmpRoot, { dataDir });
|
||||
expect(
|
||||
db.prepare("SELECT count(*) as c FROM installed_plugins").get(),
|
||||
).toEqual({ c: 0 });
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('reads bundled-scenario rows from installed_plugins', async () => {
|
||||
const dataDir = path.join(tmpRoot, 'scenario');
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
const db = openDatabase(tmpRoot, { dataDir });
|
||||
const folder = path.join(tmpRoot, 'od-code-migration');
|
||||
await mkdir(folder, { recursive: true });
|
||||
await writeFile(path.join(folder, 'open-design.json'), JSON.stringify({
|
||||
name: 'od-code-migration',
|
||||
version: '0.0.1',
|
||||
od: {
|
||||
kind: 'scenario',
|
||||
taskKind: 'code-migration',
|
||||
pipeline: codeMigrationScenarioPipeline,
|
||||
},
|
||||
}));
|
||||
upsertInstalledPlugin(db, {
|
||||
id: 'od-code-migration',
|
||||
title: 'Code migration scenario',
|
||||
version: '0.0.1',
|
||||
sourceKind: 'bundled',
|
||||
source: folder,
|
||||
fsPath: folder,
|
||||
trust: 'bundled',
|
||||
capabilitiesGranted: [],
|
||||
installedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
manifest: {
|
||||
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
|
||||
name: 'od-code-migration',
|
||||
version: '0.0.1',
|
||||
od: {
|
||||
kind: 'scenario',
|
||||
taskKind: 'code-migration',
|
||||
pipeline: codeMigrationScenarioPipeline,
|
||||
},
|
||||
} as PluginManifest,
|
||||
});
|
||||
// Direct read mirrors what loadPluginRegistryView does, sanity-
|
||||
// checking the round trip.
|
||||
const rows = db.prepare(
|
||||
"SELECT id, source_kind, manifest_json FROM installed_plugins WHERE source_kind='bundled'"
|
||||
).all() as Array<{ id: string; source_kind: string; manifest_json: string }>;
|
||||
expect(rows.length).toBe(1);
|
||||
const manifest = JSON.parse(rows[0]!.manifest_json) as PluginManifest;
|
||||
expect(manifest.od?.kind).toBe('scenario');
|
||||
expect(resolveAppliedPipeline({
|
||||
manifest: { $schema: '', name: 'consumer', version: '0.1.0', od: { taskKind: 'code-migration' } } as PluginManifest,
|
||||
scenarios: [{ id: 'od-code-migration', taskKind: 'code-migration', pipeline: manifest.od!.pipeline! }],
|
||||
}).source).toBe('scenario');
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -7,3 +7,4 @@ export * from './merge.js';
|
|||
export * from './digest.js';
|
||||
export * from './validate.js';
|
||||
export * from './resolve.js';
|
||||
export * from './pipeline-fallback.js';
|
||||
|
|
|
|||
51
packages/plugin-runtime/src/pipeline-fallback.ts
Normal file
51
packages/plugin-runtime/src/pipeline-fallback.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// Spec §23.3.3 — bundled-scenario pipeline fallback.
|
||||
//
|
||||
// When a consumer plugin omits `od.pipeline`, the runtime consults the
|
||||
// caller's bundled scenario list (passed via RegistryView.scenarios)
|
||||
// and returns the matching scenario's pipeline. The match is by
|
||||
// `taskKind`. Scenario plugins themselves never fall back (a scenario
|
||||
// without a pipeline is the identity case and not allowed by the
|
||||
// SKILL.md substrate landed in §3.N4).
|
||||
//
|
||||
// This module is pure — no fs, no SQLite, no network — so the daemon's
|
||||
// apply path stays pure even when the fallback fires.
|
||||
|
||||
import type { PluginManifest, PluginPipeline } from '@open-design/contracts';
|
||||
import type { ScenarioRegistryEntry } from './resolve.js';
|
||||
|
||||
export interface ResolvePipelineInput {
|
||||
manifest: PluginManifest;
|
||||
scenarios?: ReadonlyArray<ScenarioRegistryEntry> | undefined;
|
||||
}
|
||||
|
||||
export interface ResolvePipelineResult {
|
||||
pipeline: PluginPipeline | undefined;
|
||||
// 'declared' = the manifest carried `od.pipeline` itself.
|
||||
// 'scenario' = the manifest omitted it; we fell back to a scenario.
|
||||
// 'none' = no declared pipeline AND no matching scenario.
|
||||
source: 'declared' | 'scenario' | 'none';
|
||||
// When source='scenario', the scenario plugin id used for the
|
||||
// fallback. Useful for the run service's audit log so a reviewer
|
||||
// can attribute "why did this run start with stage X" to the
|
||||
// scenario plugin instead of the consumer.
|
||||
scenarioId?: string;
|
||||
}
|
||||
|
||||
export function resolveAppliedPipeline(input: ResolvePipelineInput): ResolvePipelineResult {
|
||||
const declared = input.manifest.od?.pipeline;
|
||||
if (declared && Array.isArray(declared.stages) && declared.stages.length > 0) {
|
||||
return { pipeline: declared, source: 'declared' };
|
||||
}
|
||||
// Scenario plugins never fall back to themselves.
|
||||
const kind = input.manifest.od?.kind;
|
||||
if (kind === 'scenario') return { pipeline: undefined, source: 'none' };
|
||||
const taskKind = (input.manifest.od?.taskKind ?? 'new-generation') as ScenarioRegistryEntry['taskKind'];
|
||||
const scenarios = input.scenarios ?? [];
|
||||
for (const candidate of scenarios) {
|
||||
if (candidate.taskKind !== taskKind) continue;
|
||||
if (!candidate.pipeline || !Array.isArray(candidate.pipeline.stages)) continue;
|
||||
if (candidate.pipeline.stages.length === 0) continue;
|
||||
return { pipeline: candidate.pipeline, source: 'scenario', scenarioId: candidate.id };
|
||||
}
|
||||
return { pipeline: undefined, source: 'none' };
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type {
|
||||
ContextItem,
|
||||
PluginManifest,
|
||||
PluginPipeline,
|
||||
ResolvedContext,
|
||||
} from '@open-design/contracts';
|
||||
|
||||
|
|
@ -20,6 +21,26 @@ export interface RegistryView {
|
|||
// `od.design_system.requires: true` without a concrete ref. Daemon
|
||||
// supplies the active project's design system here.
|
||||
activeProjectDesignSystem?: { id: string; title?: string } | undefined;
|
||||
// Spec §23.3.3: bundled scenario plugins. When a non-scenario plugin
|
||||
// omits `od.pipeline`, apply consults this list and uses the
|
||||
// matching scenario's pipeline (chosen by `taskKind`). The first
|
||||
// entry that matches wins; later entries are ignored. Daemons that
|
||||
// don't bundle scenarios pass an empty list — apply then leaves the
|
||||
// pipeline undefined and the agent falls back to its default loop.
|
||||
scenarios?: ReadonlyArray<ScenarioRegistryEntry> | undefined;
|
||||
}
|
||||
|
||||
export interface ScenarioRegistryEntry {
|
||||
// The scenario plugin's id (e.g. 'od-code-migration'). Used by tests
|
||||
// and audits to attribute the fallback choice.
|
||||
id: string;
|
||||
// The taskKind enum value this scenario claims to default for. Apply
|
||||
// matches against `manifest.od.taskKind` (or 'new-generation' when
|
||||
// absent).
|
||||
taskKind: 'new-generation' | 'figma-migration' | 'code-migration' | 'tune-collab';
|
||||
// The scenario plugin's `od.pipeline`. Copied verbatim into the
|
||||
// applied snapshot when the consumer plugin lacks one of its own.
|
||||
pipeline: PluginPipeline;
|
||||
}
|
||||
|
||||
export interface ResolveOptions {
|
||||
|
|
|
|||
85
packages/plugin-runtime/tests/pipeline-fallback.test.ts
Normal file
85
packages/plugin-runtime/tests/pipeline-fallback.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// Spec §23.3.3 — bundled-scenario pipeline fallback.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PluginManifest } from '@open-design/contracts';
|
||||
import { resolveAppliedPipeline, type ScenarioRegistryEntry } from '../src/index.js';
|
||||
|
||||
const baseManifest = (od: NonNullable<PluginManifest['od']> | undefined): PluginManifest => ({
|
||||
$schema: 'https://open-design.ai/schemas/plugin.v1.json',
|
||||
name: 'fixture',
|
||||
version: '0.0.1',
|
||||
...(od ? { od } : {}),
|
||||
}) as PluginManifest;
|
||||
|
||||
const scenarios: ScenarioRegistryEntry[] = [
|
||||
{
|
||||
id: 'od-new-generation',
|
||||
taskKind: 'new-generation',
|
||||
pipeline: { stages: [{ id: 'discovery', atoms: ['discovery-question-form'] }] },
|
||||
},
|
||||
{
|
||||
id: 'od-code-migration',
|
||||
taskKind: 'code-migration',
|
||||
pipeline: { stages: [{ id: 'import', atoms: ['code-import'] }] },
|
||||
},
|
||||
];
|
||||
|
||||
describe('resolveAppliedPipeline', () => {
|
||||
it('returns the declared pipeline when the manifest carries one', () => {
|
||||
const manifest = baseManifest({
|
||||
taskKind: 'new-generation',
|
||||
pipeline: { stages: [{ id: 'custom', atoms: ['todo-write'] }] },
|
||||
});
|
||||
const out = resolveAppliedPipeline({ manifest, scenarios });
|
||||
expect(out.source).toBe('declared');
|
||||
expect(out.pipeline?.stages?.[0]?.id).toBe('custom');
|
||||
expect(out.scenarioId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to the bundled scenario matching taskKind', () => {
|
||||
const manifest = baseManifest({ taskKind: 'code-migration' });
|
||||
const out = resolveAppliedPipeline({ manifest, scenarios });
|
||||
expect(out.source).toBe('scenario');
|
||||
expect(out.scenarioId).toBe('od-code-migration');
|
||||
expect(out.pipeline?.stages?.[0]?.id).toBe('import');
|
||||
});
|
||||
|
||||
it("defaults taskKind to 'new-generation' when the manifest omits it", () => {
|
||||
const manifest = baseManifest({});
|
||||
const out = resolveAppliedPipeline({ manifest, scenarios });
|
||||
expect(out.source).toBe('scenario');
|
||||
expect(out.scenarioId).toBe('od-new-generation');
|
||||
});
|
||||
|
||||
it("returns source='none' when the manifest is itself a scenario", () => {
|
||||
const manifest = baseManifest({
|
||||
kind: 'scenario',
|
||||
taskKind: 'new-generation',
|
||||
});
|
||||
const out = resolveAppliedPipeline({ manifest, scenarios });
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.pipeline).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns source='none' when no scenario list is supplied", () => {
|
||||
const manifest = baseManifest({ taskKind: 'code-migration' });
|
||||
const out = resolveAppliedPipeline({ manifest });
|
||||
expect(out.source).toBe('none');
|
||||
});
|
||||
|
||||
it("returns source='none' when no scenario matches the taskKind", () => {
|
||||
const manifest = baseManifest({ taskKind: 'tune-collab' });
|
||||
const out = resolveAppliedPipeline({ manifest, scenarios });
|
||||
expect(out.source).toBe('none');
|
||||
});
|
||||
|
||||
it('treats an empty stages[] declared pipeline as missing and falls back', () => {
|
||||
const manifest = baseManifest({
|
||||
taskKind: 'new-generation',
|
||||
pipeline: { stages: [] },
|
||||
});
|
||||
const out = resolveAppliedPipeline({ manifest, scenarios });
|
||||
expect(out.source).toBe('scenario');
|
||||
expect(out.scenarioId).toBe('od-new-generation');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue