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:
Cursor Agent 2026-05-09 15:05:54 +00:00
parent 1d142b4214
commit 694289295a
No known key found for this signature in database
7 changed files with 444 additions and 2 deletions

View file

@ -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,

View file

@ -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);

View 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();
});
});

View file

@ -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';

View 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' };
}

View file

@ -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 {

View 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');
});
});