mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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>
232 lines
8.3 KiB
TypeScript
232 lines
8.3 KiB
TypeScript
// 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();
|
|
});
|
|
});
|