Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/adr-0033-auto-app-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@objectstack/service-ai": minor
---

feat(ai): zero-package app building — auto-home a blueprint's app in a writable package

When the AI blueprint flow builds an **app**, it now silently gives that app a writable "home" package (one app ⇒ one `app.<name>` package) and binds every drafted artifact (objects, views, dashboards, the app) to it — so a business user never has to create a "package" to start building (the mainstream AI-builder UX: Power Apps' default solution, Salesforce orgs). Packaging/versioning stays an opt-in, later concern.

- `apply_blueprint` ensures the app package up front (idempotent: reuse if it exists, else create via the runtime `package` service) and threads its `packageId` through every `stageDraft` → `sys_metadata.package_id`. The result envelope gains `package: { id, name, created }`.
- The `package` service is resolved **lazily** (per call, not at plugin-init time) so it works regardless of service-init order and picks up the opt-in `marketplace` capability when present.
- **Best-effort, non-fatal:** if no `package` service is wired, drafting proceeds package-less exactly as before — the build never fails on packaging.

Scope/caveats: this stamps the *legacy* `sys_metadata.package_id` (a real grouping + the foundation for later version/export/promote), not the sealed `sys_package_version` model — full cross-environment promotion and Studio package-selector visibility depend on finishing the runtime package subsystem (ADR-0027), tracked separately. (The showcase example enables the `marketplace` capability to exercise this.)
6 changes: 5 additions & 1 deletion examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ export default defineStack({
// autolaunched / schedule flows below actually auto-fire.
// • job — JobServicePlugin, the timing backend the schedule trigger
// delegates to (interval / cron jobs).
requires: ['ui', 'automation', 'approvals', 'messaging', 'triggers', 'job'],
// • marketplace — PackageServicePlugin (sys_packages store). Enables the AI
// blueprint flow to auto-create a writable "app package" home
// (ADR-0033 zero-package app building) and the Studio package
// selector to list DB packages.
requires: ['ui', 'automation', 'approvals', 'messaging', 'triggers', 'job', 'marketplace'],

// Concrete connectors for the `connector_action` node. The baseline engine
// ships the dispatch node + an empty registry; these plugins populate it.
Expand Down
109 changes: 109 additions & 0 deletions packages/services/service-ai/src/__tests__/blueprint-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,3 +353,112 @@ describe('blueprint ⨯ OpenAI strict structured outputs', () => {
expect(project.fields.name).toEqual({ type: 'text' });
});
});

// ═══════════════════════════════════════════════════════════════════
// Zero-package app building — apply_blueprint auto-homes the app's artifacts
// in a writable app package (one app ⇒ one package), best-effort.
// ═══════════════════════════════════════════════════════════════════

function createMockPackageService(existing: string[] = []) {
const published: any[] = [];
const get = vi.fn(async (id: string) =>
existing.includes(id) ? { manifest: { id, name: id } } : null,
);
const publish = vi.fn(async (data: any) => {
published.push(data);
return { success: true };
});
return { svc: { get, publish }, get, publish, published };
}

const APP_BLUEPRINT: SolutionBlueprint = {
summary: 'pm',
assumptions: [],
objects: [{ name: 'project', label: 'Project', fields: [{ name: 'name', type: 'text' }] }],
views: [{ object: 'project', name: 'all_projects', type: 'list', columns: ['name'] }],
app: { name: 'project_management', label: '项目管理', nav: [{ type: 'object', target: 'project' }] },
};

describe('apply_blueprint — auto app package', () => {
it('creates app.<name> once and binds every artifact to it', async () => {
const registry = new ToolRegistry();
const proto = createMockProtocol();
const pkg = createMockPackageService();
registerBlueprintTools(registry, {
ai: createMockAi().ai, protocol: proto.protocol,
metadataService: createMockMetadataService(), packageService: pkg.svc as any,
});

const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: APP_BLUEPRINT })));

// looked up, found absent, published exactly one app package
expect(pkg.get).toHaveBeenCalledWith('app.project_management');
expect(pkg.publish).toHaveBeenCalledOnce();
expect(pkg.published[0].manifest).toMatchObject({
id: 'app.project_management', type: 'application', namespace: 'project_management', scope: 'environment',
});
// every staged artifact carries the package id
expect(proto.saveMetaItem.mock.calls.length).toBe(3); // object + view + app
for (const c of proto.saveMetaItem.mock.calls) {
expect(c[0].packageId).toBe('app.project_management');
expect(c[0].mode).toBe('draft');
}
expect(parsed.package).toEqual({ id: 'app.project_management', name: '项目管理', created: true });
});

it('reuses an existing app package (no second publish) and still binds', async () => {
const registry = new ToolRegistry();
const proto = createMockProtocol();
const pkg = createMockPackageService(['app.project_management']);
registerBlueprintTools(registry, {
ai: createMockAi().ai, protocol: proto.protocol,
metadataService: createMockMetadataService(), packageService: pkg.svc as any,
});

const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: APP_BLUEPRINT })));
expect(pkg.publish).not.toHaveBeenCalled();
expect(parsed.package).toEqual({ id: 'app.project_management', name: 'app.project_management', created: false });
for (const c of proto.saveMetaItem.mock.calls) expect(c[0].packageId).toBe('app.project_management');
});

it('falls back to package-less drafting when no package service is wired', async () => {
const registry = new ToolRegistry();
const proto = createMockProtocol();
registerBlueprintTools(registry, {
ai: createMockAi().ai, protocol: proto.protocol, metadataService: createMockMetadataService(),
});
const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: APP_BLUEPRINT })));
expect(parsed.status).toBe('drafted');
expect(parsed.package).toBeUndefined();
for (const c of proto.saveMetaItem.mock.calls) expect(c[0].packageId).toBeUndefined();
});

it('does nothing package-wise when the blueprint has no app', async () => {
const registry = new ToolRegistry();
const proto = createMockProtocol();
const pkg = createMockPackageService();
registerBlueprintTools(registry, {
ai: createMockAi().ai, protocol: proto.protocol,
metadataService: createMockMetadataService(), packageService: pkg.svc as any,
});
const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: SAMPLE_BLUEPRINT })));
expect(pkg.get).not.toHaveBeenCalled();
expect(pkg.publish).not.toHaveBeenCalled();
expect(parsed.package).toBeUndefined();
});

it('still drafts when publish fails (degrades to package-less, never blocks)', async () => {
const registry = new ToolRegistry();
const proto = createMockProtocol();
const pkg = createMockPackageService();
pkg.publish.mockResolvedValueOnce({ success: false, error: 'boom' } as any);
registerBlueprintTools(registry, {
ai: createMockAi().ai, protocol: proto.protocol,
metadataService: createMockMetadataService(), packageService: pkg.svc as any,
});
const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: APP_BLUEPRINT })));
expect(parsed.status).toBe('drafted');
expect(parsed.package).toBeUndefined();
for (const c of proto.saveMetaItem.mock.calls) expect(c[0].packageId).toBeUndefined();
});
});
28 changes: 28 additions & 0 deletions packages/services/service-ai/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,33 @@ export class AIServicePlugin implements Plugin {
protocolService = undefined;
}

// Lazy `package` service accessor so the blueprint tools can give an app a
// writable "home" package automatically — zero-package app building. Resolved
// PER CALL (at apply_blueprint time, long after startup) so service init
// order doesn't matter and a later-loaded `package` capability (the
// opt-in `marketplace` tier) is picked up. Throws when absent → ensureAppPackage
// catches it and degrades to package-less drafting.
const resolvePackage = (): any | undefined => {
try {
const pk = ctx.getService<any>('package');
return pk && typeof pk.get === 'function' && typeof pk.publish === 'function' ? pk : undefined;
} catch {
return undefined;
}
};
const packageService = {
get: async (id: string) => {
const pk = resolvePackage();
if (!pk) throw new Error('package service unavailable');
return pk.get(id);
},
publish: async (p: unknown) => {
const pk = resolvePackage();
if (!pk) throw new Error('package service unavailable');
return pk.publish(p);
},
};

// Data tools require only the data engine. When metadata service is
// wired we also pass it (+ protocol) so the tools can validate
// field references at runtime and reject hallucinated field names
Expand Down Expand Up @@ -807,6 +834,7 @@ export class AIServicePlugin implements Plugin {
ai: this.service,
protocol: protocolService,
metadataService,
packageService: packageService as any,
});
ctx.logger.info('[AI] Plan-first blueprint tools registered');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ The flow is PLAN-FIRST and has two steps:
Hard rules:
- NEVER call apply_blueprint before the user has explicitly approved the blueprint. The blueprint-confirm step is the safety valve against mass-generating unreviewed artifacts.
- Everything apply_blueprint creates is a DRAFT — including the app (navigation shell), which the user will find in the App Launcher once published. Tell the user the artifacts are "drafted for your review" and that they must publish them in the designer to make them live. Never say they are live/created/applied.
- The app's artifacts are automatically grouped under an app package (the result includes a "package" field) — the user does NOT need to create a package. Do not ask them to; just mention everything is grouped under the app.
- If apply_blueprint reports per-item failures, explain which items failed and why, and offer to fix them (e.g. via update_metadata) — the successfully drafted items still stand.
- Seed data in a blueprint is a suggestion only; it is not auto-applied.
- Always answer in the same language the user is using.
Expand Down
72 changes: 71 additions & 1 deletion packages/services/service-ai/src/tools/blueprint-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,34 @@ export const BLUEPRINT_TOOL_DEFINITIONS = [proposeBlueprintTool, applyBlueprintT
// Context
// ---------------------------------------------------------------------------

/**
* The narrow slice of the runtime `package` service the blueprint tools use to
* give an app a home (see {@link ensureAppPackage}). A subset of the service
* registered at `ctx.registerService('package', …)` in `@objectstack/service-package`.
*/
export interface BlueprintPackageService {
/** Look up a package by id (latest version); null/undefined when absent. */
get(packageId: string): Promise<{ manifest?: { name?: string } } | null | undefined>;
/** Insert/publish a package record (writable, source:'database'). */
publish(data: { manifest: Record<string, unknown>; metadata?: Record<string, unknown> }):
Promise<{ success?: boolean; error?: string } | undefined>;
}

/**
* Services the plan-first blueprint tools need (ADR-0033 §4).
*
* - {@link IAIService} drives `generateObject` for the structured blueprint.
* - `protocol` is the draft-capable write path reused from the metadata tools
* ({@link stageDraft}) — every artifact is staged, never published.
* - {@link IMetadataService} is a fallback enumerator for existing objects.
* - `packageService` (optional) lets a blueprint's app auto-create a writable
* "app package" home so the user never has to make one (zero-package UX).
*/
export interface BlueprintToolContext {
ai: IAIService;
protocol?: DraftCapableProtocol;
metadataService: IMetadataService;
packageService?: BlueprintPackageService;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -271,6 +287,50 @@ function appBody(
};
}

/**
* Give a blueprint's app a writable "home" package so the user never has to
* create one (mainstream AI builders — Power Apps' default solution, Salesforce
* orgs — never make a business user make a package to start building). Idempotent:
* one app ⇒ one `app.<name>` package. Returns the package descriptor, or `null`
* to fall back to today's package-less drafting (no package service wired, or
* publish failed) — never throws, never blocks the build.
*
* NOTE: this stamps the *legacy* `sys_metadata.package_id` (a real grouping that
* shows in Studio's package selector and is the foundation for later
* version/export/promote). Full cross-environment promotion still needs the
* sealed `sys_package_version` model from ADR-0027, which is separate.
*/
async function ensureAppPackage(
pkgSvc: BlueprintPackageService | undefined,
app: { name: string; label?: string; icon?: string },
): Promise<{ id: string; name: string; created: boolean } | null> {
if (!pkgSvc?.get || !pkgSvc?.publish) return null;
const id = `app.${app.name}`;
const name = app.label ?? app.name;
try {
const existing = await pkgSvc.get(id);
if (existing) return { id, name: existing.manifest?.name ?? name, created: false };
const res = await pkgSvc.publish({
manifest: {
id,
name,
version: '1.0.0',
type: 'application',
namespace: app.name,
// Must NOT be 'system'/'cloud' — Studio's package selector filters those
// out (studio.app.ts optionsSource). 'environment' keeps it visible.
scope: 'environment',
...(app.icon ? { icon: app.icon } : {}),
},
metadata: { createdBy: 'ai', source: 'database' },
});
if (res && res.success === false) return null; // degrade to package-less
return { id, name, created: true };
} catch {
return null; // never block the build on packaging
}
}

function createApplyBlueprintHandler(ctx: BlueprintToolContext): ToolHandler {
return async (args, exec) => {
const raw = (args as { blueprint?: unknown }).blueprint;
Expand All @@ -293,11 +353,17 @@ function createApplyBlueprintHandler(ctx: BlueprintToolContext): ToolHandler {
const blueprint = parsed.data;
const actor = exec?.actor?.id;

// Zero-package UX: if the blueprint has an app, ensure a writable home
// package up front and bind every drafted artifact to it. Best-effort —
// `null` (no package service / publish failed) falls back to package-less.
const appPackage = blueprint.app ? await ensureAppPackage(ctx.packageService, blueprint.app) : null;
const packageId = appPackage?.id;

const drafted: Array<{ type: string; name: string }> = [];
const failed: Array<{ type: string; name: string; error: string; code?: string }> = [];

const record = async (type: string, name: string, item: unknown) => {
const res = await stageDraft(ctx.protocol, { type, name, item, actor });
const res = await stageDraft(ctx.protocol, { type, name, item, actor, packageId });
if (res.ok) drafted.push({ type, name });
else failed.push({ type, name, error: res.error ?? 'unknown error', ...(res.code ? { code: res.code } : {}) });
};
Expand Down Expand Up @@ -326,12 +392,16 @@ function createApplyBlueprintHandler(ctx: BlueprintToolContext): ToolHandler {

const summaryParts = [`drafted ${drafted.length} artifact(s)`];
if (failed.length) summaryParts.push(`${failed.length} failed`);
if (appPackage) summaryParts.push(`grouped under app package "${appPackage.name}"`);
if (seedDataProposed.length) summaryParts.push(`${seedDataProposed.length} seed set(s) proposed (not applied)`);

return JSON.stringify({
status: failed.length && !drafted.length ? 'failed' : 'drafted',
drafted,
failed,
// The app's artifacts were auto-homed in a writable package (zero user
// package steps); informational only — no action required.
...(appPackage ? { package: appPackage } : {}),
// Phase C does not auto-apply seed data — no runtime-draftable `dataset`
// type exists; surface it so a human can wire it deliberately.
seedDataProposed,
Expand Down