diff --git a/.changeset/adr-0033-auto-app-package.md b/.changeset/adr-0033-auto-app-package.md new file mode 100644 index 000000000..88fa092bb --- /dev/null +++ b/.changeset/adr-0033-auto-app-package.md @@ -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.` 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.) diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index e02be9d37..2123b48dc 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -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. diff --git a/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts b/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts index 9a71c5375..4ba35377a 100644 --- a/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts +++ b/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts @@ -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. 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(); + }); +}); diff --git a/packages/services/service-ai/src/plugin.ts b/packages/services/service-ai/src/plugin.ts index 19dd3abc0..576d2574f 100644 --- a/packages/services/service-ai/src/plugin.ts +++ b/packages/services/service-ai/src/plugin.ts @@ -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('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 @@ -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'); diff --git a/packages/services/service-ai/src/skills/solution-design-skill.ts b/packages/services/service-ai/src/skills/solution-design-skill.ts index fecd5b66a..151380b63 100644 --- a/packages/services/service-ai/src/skills/solution-design-skill.ts +++ b/packages/services/service-ai/src/skills/solution-design-skill.ts @@ -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. diff --git a/packages/services/service-ai/src/tools/blueprint-tools.ts b/packages/services/service-ai/src/tools/blueprint-tools.ts index 2e13cd26d..9c7eb2e3f 100644 --- a/packages/services/service-ai/src/tools/blueprint-tools.ts +++ b/packages/services/service-ai/src/tools/blueprint-tools.ts @@ -39,6 +39,19 @@ 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; metadata?: Record }): + Promise<{ success?: boolean; error?: string } | undefined>; +} + /** * Services the plan-first blueprint tools need (ADR-0033 §4). * @@ -46,11 +59,14 @@ export const BLUEPRINT_TOOL_DEFINITIONS = [proposeBlueprintTool, applyBlueprintT * - `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; } // --------------------------------------------------------------------------- @@ -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.` 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; @@ -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 } : {}) }); }; @@ -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,