From 83ba83313e7980196b0cb3c2dab9ab7f2c469c1a Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:46:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20blueprint=20app-building=20?= =?UTF-8?q?=E2=80=94=20draft=20the=20navigation=20app,=20not=20just=20the?= =?UTF-8?q?=20data=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan-first blueprint now also designs the `app` (navigation shell), so "build me a project-management application" yields an openable app. - SolutionBlueprintSchema gains optional `app: { name, label?, icon?, nav? }`; nav entries target a created object/dashboard. Omit `nav` to auto-surface every object then dashboard. - apply_blueprint expands it into an AppSchema body (single-level navigation) and drafts it last via the same stageDraft path; never sets isDefault. - propose_blueprint asks the agent to include the app; reports counts.app. Still draft-gated — nothing live until the human publishes. Co-Authored-By: Claude Opus 4.8 --- .changeset/adr-0033-blueprint-app-building.md | 14 ++++++ .claude/launch.json | 7 +++ packages/objectql/src/engine.test.ts | 39 +++++++++++++++ packages/objectql/src/engine.ts | 14 +++++- packages/objectql/src/registry.test.ts | 13 +++-- packages/objectql/src/registry.ts | 37 +++++++++----- .../src/__tests__/blueprint-tools.test.ts | 49 ++++++++++++++++++- .../src/skills/solution-design-skill.ts | 4 +- .../service-ai/src/tools/blueprint-tools.ts | 47 +++++++++++++++++- .../spec/src/ai/solution-blueprint.test.ts | 46 +++++++++++++++++ .../spec/src/ai/solution-blueprint.zod.ts | 29 +++++++++++ 11 files changed, 279 insertions(+), 20 deletions(-) create mode 100644 .changeset/adr-0033-blueprint-app-building.md diff --git a/.changeset/adr-0033-blueprint-app-building.md b/.changeset/adr-0033-blueprint-app-building.md new file mode 100644 index 000000000..eca61d7c4 --- /dev/null +++ b/.changeset/adr-0033-blueprint-app-building.md @@ -0,0 +1,14 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-ai": minor +--- + +feat(ai): blueprint app-building — propose/draft the navigation app, not just the data model + +The plan-first blueprint (ADR-0033 §4) now also designs the **app** (the navigation shell end users open in the App Launcher), so "build me a project-management application" yields an openable app — not just its objects, views, and dashboards. + +- `SolutionBlueprintSchema` (`@objectstack/spec/ai`) gains an optional `app: { name, label?, icon?, nav? }`, where each nav entry targets a created object or dashboard. `nav` may be omitted to auto-surface every object (then dashboard). +- `apply_blueprint` expands the app into an `AppSchema` body (single-level `navigation` of object/dashboard items) and drafts it last — through the same draft-gated, per-type-validated `stageDraft` path as everything else. It never sets `isDefault`. +- `propose_blueprint` now asks the agent to include the app and reports `counts.app`. + +Still draft-gated: nothing is live until the human publishes. Scope is basic app-building (one app, flat nav); areas/groups/mobile-nav remain author-it-later via `update_metadata`. diff --git a/.claude/launch.json b/.claude/launch.json index c0474b678..900804cdf 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -27,6 +27,13 @@ "runtimeArgs": ["--filter", "@objectstack/docs", "dev", "--", "-p", "3002"], "port": 3002 }, + { + "name": "showcase-debug (3055)", + "runtimeExecutable": "bash", + "runtimeArgs": ["-lc", "cd /Users/zhuangjianguo/Documents/GitHub/framework/examples/app-showcase && OS_LOG_LEVEL=debug OS_PORT=3055 PORT=3055 pnpm dev 2>&1 | tee /tmp/sc-debug.log"], + "port": 3055, + "autoPort": false + }, { "name": "showcase-console", "runtimeExecutable": "bash", diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index e2db2e7bf..60ea954b8 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -275,6 +275,45 @@ describe('ObjectQL Engine', () => { }); }); + describe('Update routing — where.id operator objects', () => { + beforeEach(async () => { + engine.registerDriver(mockDriver, true); + await engine.init(); + vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: {} } as any); + (mockDriver as any).updateMany = vi.fn().mockResolvedValue(2); + }); + + it('routes where:{id:{$in:[...]}} + multi to updateMany, not single update', async () => { + // Regression: a multi-row predicate on `id` ({ $in: [...] }) was + // mis-extracted as a scalar id and bound literally by the driver + // (`WHERE id = {"$in":[...]}`), which SQLite rejects. It must route + // to updateMany so the operator is compiled to `WHERE id IN (...)`. + await engine.update( + 'task', + { status: 'in_flight' }, + { where: { id: { $in: ['a', 'b'] }, status: 'pending' }, multi: true } as any, + ); + + expect((mockDriver as any).updateMany).toHaveBeenCalledTimes(1); + const [obj, ast] = (mockDriver as any).updateMany.mock.calls[0]; + expect(obj).toBe('task'); + expect(ast.where).toEqual({ id: { $in: ['a', 'b'] }, status: 'pending' }); + // The single-row update path must NOT have been taken. + expect(mockDriver.update).not.toHaveBeenCalled(); + }); + + it('still treats a scalar where.id as a single-row update', async () => { + vi.mocked(mockDriver.update).mockResolvedValue({ id: 't1' } as any); + await engine.update('task', { status: 'done' }, { where: { id: 't1' } } as any); + expect(mockDriver.update).toHaveBeenCalledTimes(1); + const [obj, id, data] = vi.mocked(mockDriver.update).mock.calls[0]; + expect(obj).toBe('task'); + expect(id).toBe('t1'); + expect(data).toEqual({ status: 'done' }); + expect((mockDriver as any).updateMany).not.toHaveBeenCalled(); + }); + }); + describe('Expand Related Records', () => { beforeEach(async () => { engine.registerDriver(mockDriver, true); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 0293c1a1c..a2d3f1163 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1835,10 +1835,20 @@ export class ObjectQL implements IDataEngine { this.assertWriteAllowed(object, 'update'); const driver = this.getDriver(object); - // 1. Extract ID from data or where if it's a single update by ID + // 1. Extract ID from data or where if it's a single update by ID. + // Only a SCALAR `where.id` means "update one row by primary key". An + // operator object ({ $in: [...] }, { $ne: ... }, …) is a multi-row + // predicate — treating it as an id would bind the object literally + // (e.g. `WHERE id = {"$in":[...]}`, which SQLite rejects). Leave `id` + // undefined in that case so the call routes to updateMany (requires + // options.multi=true), where applyFilters compiles the operator. let id = data.id; if (!id && options?.where && typeof options.where === 'object' && 'id' in options.where) { - id = (options.where as Record).id; + const whereId = (options.where as Record).id; + const t = typeof whereId; + if (whereId !== null && (t === 'string' || t === 'number' || t === 'bigint')) { + id = whereId; + } } const opCtx: OperationContext = { diff --git a/packages/objectql/src/registry.test.ts b/packages/objectql/src/registry.test.ts index 0f7f6002f..26946cf2f 100644 --- a/packages/objectql/src/registry.test.ts +++ b/packages/objectql/src/registry.test.ts @@ -471,18 +471,25 @@ describe('SchemaRegistry', () => { describe('applySystemFields', () => { const baseLead: any = { name: 'lead', label: 'Lead', fields: { first_name: { type: 'text' } } }; - it('injects organization_id when multiTenant is true and field is missing', () => { + it('injects an indexed organization_id when multiTenant is true and field is missing', () => { const out = applySystemFields(baseLead, { multiTenant: true }); expect(out.fields.organization_id).toBeDefined(); expect(out.fields.organization_id.type).toBe('lookup'); expect(out.fields.organization_id.reference).toBe('sys_organization'); + // Multi-tenant stacks index the column (per-tenant filtering). + expect(out.fields.organization_id.indexed).toBe(true); // author-declared field still present expect(out.fields.first_name).toBeDefined(); }); - it('skips organization_id injection when multiTenant is false (but still injects audit fields)', () => { + it('still injects organization_id when multiTenant is false, but unindexed', () => { + // The COLUMN is always provisioned (decoupled from the tenant flag) so + // sudo writers can always stamp it; only the index is gated, since a + // single-tenant DB never filters by organization. const out = applySystemFields(baseLead, { multiTenant: false }); - expect(out.fields.organization_id).toBeUndefined(); + expect(out.fields.organization_id).toBeDefined(); + expect(out.fields.organization_id.type).toBe('lookup'); + expect(out.fields.organization_id.indexed).toBe(false); // audit fields are tenant-independent — still injected expect(out.fields.created_at).toBeDefined(); expect(out.fields.updated_at).toBeDefined(); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index ab85435c7..bc9b6b57e 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -125,11 +125,13 @@ export type RegistryLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent'; */ export interface SchemaRegistryOptions { /** - * Whether the host kernel runs in multi-tenant mode. When `true`, the - * registry auto-injects `organization_id` (lookup → sys_organization) - * into every registered user object that doesn't already declare it and - * isn't `managedBy` an external subsystem or explicitly opted-out via - * `systemFields: false`. + * Whether the host kernel runs in multi-tenant mode. The `organization_id` + * column itself is auto-injected regardless of this flag (lookup → + * sys_organization, on every registered object that doesn't already declare + * it, isn't `managedBy` an external subsystem, and hasn't opted out via + * `systemFields`/`tenancy.enabled:false`). When `true` the injected column + * is additionally INDEXED — single-tenant stacks skip the index since + * nothing ever filters by organization. * * Sourced from the `OS_MULTI_TENANT` env var when not explicitly set — * matches how the SecurityPlugin and CLI startup banner pick the mode. @@ -152,9 +154,12 @@ export interface SchemaRegistryOptions { * via the natural `{ ...sys, ...authored }` merge. * * Currently injects: - * - `organization_id` — multi-tenant deployments. Required-false (the - * SecurityPlugin populates it on insert; nullable rows are still - * filtered out by the `tenant_isolation` RLS USING clause). + * - `organization_id` — always provisioned (unless the object opts out via + * `systemFields`/`tenancy.enabled:false` or is `better-auth` managed) so + * the column never depends on the global multi-tenant flag. Required-false; + * org-scoping populates it on insert in multi-tenant mode, and it stays + * NULL on single-tenant stacks. Only the column's INDEX is gated on + * `multiTenant` (no per-tenant filtering exists single-tenant). * - `created_at` / `created_by` / `updated_at` / `updated_by` — audit * fields. Marked `system: true, readonly: true` so detail views can * surface them in a dedicated "System Information" section while @@ -196,7 +201,16 @@ export function applySystemFields( // SecurityPlugin's RLS layer would filter every cross-org read down // to 0 rows even though the schema explicitly disabled multi-tenancy. const tenancyDisabled = (schema as any).tenancy?.enabled === false; - const wantTenant = opts.multiTenant && sf?.tenant !== false && !tenancyDisabled; + // The `organization_id` COLUMN is provisioned unconditionally (subject only + // to the explicit opt-outs above) — its existence no longer depends on the + // global multi-tenant flag. Decoupling "does the column exist" from "is + // tenancy enabled" is what stops sudo writers (audit / messaging / inbox / + // outbox …) from failing with "no column named organization_id" on + // single-tenant stacks: they can always stamp the column, it just stays NULL + // when no tenant context exists. The multi-tenant flag now governs only + // whether the column is INDEXED — on a single-tenant DB nothing ever filters + // by organization, so the index would be dead weight. + const wantTenant = sf?.tenant !== false && !tenancyDisabled; const wantAudit = sf?.audit !== false; const additions: Record = {}; @@ -207,11 +221,12 @@ export function applySystemFields( reference: 'sys_organization', label: 'Organization', required: false, - indexed: true, + indexed: opts.multiTenant, hidden: true, readonly: true, system: true, - description: 'Tenant scope (auto-populated by SecurityPlugin on insert).', + description: + 'Tenant scope (auto-populated by org-scoping on insert; NULL on single-tenant stacks).', }; } 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 0685c51e1..bd113ebe5 100644 --- a/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts +++ b/packages/services/service-ai/src/__tests__/blueprint-tools.test.ts @@ -117,7 +117,7 @@ describe('propose_blueprint handler', () => { const parsed = parse(await registry.execute(call('propose_blueprint', { goal: 'build a project tracker' }))); expect(parsed.status).toBe('blueprint_proposed'); expect(parsed.blueprint.objects).toHaveLength(2); - expect(parsed.counts).toEqual({ objects: 2, views: 1, dashboards: 0, seedData: 1 }); + expect(parsed.counts).toEqual({ objects: 2, views: 1, dashboards: 0, app: 0, seedData: 1 }); // Crucially: proposing creates no drafts. expect(saveMetaItem).not.toHaveBeenCalled(); expect(generateObject).toHaveBeenCalledOnce(); @@ -239,4 +239,51 @@ describe('apply_blueprint handler', () => { const view = drafts.get('view:all_leads') as any; expect(view.list.columns).toEqual(['name', 'email']); }); + + it('drafts the app (navigation shell) with explicit nav referencing the objects', async () => { + const bp: SolutionBlueprint = { + ...SAMPLE_BLUEPRINT, + app: { + name: 'project_mgmt', + label: 'Project Management', + icon: 'kanban', + nav: [ + { type: 'object', target: 'project', label: 'Projects' }, + { type: 'object', target: 'task' }, + ], + }, + }; + const parsed = parse(await registry.execute(call('apply_blueprint', { blueprint: bp }))); + expect(parsed.drafted).toContainEqual({ type: 'app', name: 'project_mgmt' }); + expect(saveMetaItem).toHaveBeenCalledWith(expect.objectContaining({ type: 'app', mode: 'draft' })); + + const app = drafts.get('app:project_mgmt') as any; + expect(app.label).toBe('Project Management'); + expect(app.icon).toBe('kanban'); + expect(app.isDefault).toBeUndefined(); // never hijack the default app + expect(app.navigation).toEqual([ + { id: 'nav_project', label: 'Projects', order: 0, type: 'object', objectName: 'project' }, + { id: 'nav_task', label: 'task', order: 1, type: 'object', objectName: 'task' }, + ]); + }); + + it('auto-surfaces every object then dashboard when app.nav is omitted', async () => { + const bp: SolutionBlueprint = { + summary: 'crm', + assumptions: [], + objects: [ + { name: 'account', label: 'Account', fields: [{ name: 'name', type: 'text' }] }, + { name: 'contact', label: 'Contact', fields: [{ name: 'name', type: 'text' }] }, + ], + dashboards: [{ name: 'sales', label: 'Sales', widgets: [] }], + app: { name: 'crm', label: 'CRM' }, + }; + await registry.execute(call('apply_blueprint', { blueprint: bp })); + const app = drafts.get('app:crm') as any; + expect(app.navigation).toEqual([ + { id: 'nav_account', label: 'Account', order: 0, type: 'object', objectName: 'account' }, + { id: 'nav_contact', label: 'Contact', order: 1, type: 'object', objectName: 'contact' }, + { id: 'nav_sales', label: 'Sales', order: 2, type: 'dashboard', dashboardName: 'sales' }, + ]); + }); }); 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 722b07e12..fecd5b66a 100644 --- a/packages/services/service-ai/src/skills/solution-design-skill.ts +++ b/packages/services/service-ai/src/skills/solution-design-skill.ts @@ -19,12 +19,12 @@ export const SOLUTION_DESIGN_SKILL: Skill = { instructions: `Use this skill when the user asks you to build a whole SYSTEM, APP, or MODULE ("build me a CRM", "I need an applicant tracking system"), not a single object or field. The flow is PLAN-FIRST and has two steps: -1. propose_blueprint — design a structured blueprint (objects, fields, relationships, views, dashboards) from the goal. This creates NOTHING. Present it to the user: summarize the objects/views, state your assumptions, and ask any (at most 1-2) structure-deciding questions the tool returned. +1. propose_blueprint — design a structured blueprint (objects, fields, relationships, views, dashboards, and an app that surfaces them in the navigation) from the goal. This creates NOTHING. Present it to the user: summarize the objects/views and the app, state your assumptions, and ask any (at most 1-2) structure-deciding questions the tool returned. 2. apply_blueprint — ONLY after the user approves (or edits) the blueprint, call this to batch-draft every artifact. Pass the approved/edited blueprint object. 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. 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. +- 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. - 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 2a05dd97e..9debbfcc0 100644 --- a/packages/services/service-ai/src/tools/blueprint-tools.ts +++ b/packages/services/service-ai/src/tools/blueprint-tools.ts @@ -97,6 +97,10 @@ function createProposeBlueprintHandler(ctx: BlueprintToolContext): ToolHandler { '- If (and only if) a genuinely structure-deciding choice is unclear, put at most 1-2 ' + 'short `questions`; otherwise pick the most likely interpretation and proceed.\n' + '- Do NOT invent field types — use the allowed enum values.\n' + + '- Include an `app` (navigation shell) that surfaces the created objects (and any ' + + 'dashboards) so the user can actually open the solution: give it a snake_case `name`, a ' + + 'friendly `label`, and a Lucide `icon`. Keep it to a single app with a flat list of nav ' + + 'entries (you may omit `nav` to auto-surface every object and dashboard).\n' + `- ${existingNote}\n` + 'This is a PROPOSAL. Nothing is built from it until the human approves.', }, @@ -111,7 +115,7 @@ function createProposeBlueprintHandler(ctx: BlueprintToolContext): ToolHandler { const generated = await ctx.ai.generateObject(messages, SolutionBlueprintSchema, { schemaName: 'SolutionBlueprint', schemaDescription: - 'A proposed solution: objects + fields + relationships + views + dashboards + seed data, with stated assumptions.', + 'A proposed solution: objects + fields + relationships + views + dashboards + an app (navigation shell) + seed data, with stated assumptions.', }); blueprint = generated.object; } catch (err) { @@ -128,6 +132,7 @@ function createProposeBlueprintHandler(ctx: BlueprintToolContext): ToolHandler { objects: blueprint.objects?.length ?? 0, views: blueprint.views?.length ?? 0, dashboards: blueprint.dashboards?.length ?? 0, + app: blueprint.app ? 1 : 0, seedData: blueprint.seedData?.length ?? 0, }, questions: blueprint.questions ?? [], @@ -204,6 +209,42 @@ function dashboardBody(d: NonNullable[number]): }; } +/** + * Convert the blueprint's app into an `app` metadata body — the navigation + * shell end users open in the App Launcher. When the blueprint gives no + * explicit `nav`, auto-surface every created object (then every dashboard) as a + * top-level nav entry. Never sets `isDefault` (don't hijack the default app). + */ +function appBody( + app: NonNullable, + blueprint: SolutionBlueprint, +): Record { + const navSource: Array<{ type: 'object' | 'dashboard'; target: string; label?: string; icon?: string }> = + app.nav && app.nav.length > 0 + ? app.nav + : [ + ...(blueprint.objects ?? []).map((o) => ({ type: 'object' as const, target: o.name, label: o.label })), + ...(blueprint.dashboards ?? []).map((d) => ({ type: 'dashboard' as const, target: d.name, label: d.label })), + ]; + const navigation = navSource.map((n, i) => { + const base = { + id: `nav_${n.target}`, + label: n.label ?? n.target, + ...(n.icon ? { icon: n.icon } : {}), + order: i, + }; + return n.type === 'dashboard' + ? { ...base, type: 'dashboard', dashboardName: n.target } + : { ...base, type: 'object', objectName: n.target }; + }); + return { + name: app.name, + label: app.label ?? app.name, + ...(app.icon ? { icon: app.icon } : {}), + navigation, + }; +} + function createApplyBlueprintHandler(ctx: BlueprintToolContext): ToolHandler { return async (args, exec) => { const raw = (args as { blueprint?: unknown }).blueprint; @@ -244,6 +285,10 @@ function createApplyBlueprintHandler(ctx: BlueprintToolContext): ToolHandler { for (const d of blueprint.dashboards ?? []) { await record('dashboard', d.name, dashboardBody(d)); } + // The app (navigation shell) is drafted last — it references everything above. + if (blueprint.app) { + await record('app', blueprint.app.name, appBody(blueprint.app, blueprint)); + } const seedDataProposed = (blueprint.seedData ?? []).map((s) => ({ object: s.object, diff --git a/packages/spec/src/ai/solution-blueprint.test.ts b/packages/spec/src/ai/solution-blueprint.test.ts index 717f872ad..409d36348 100644 --- a/packages/spec/src/ai/solution-blueprint.test.ts +++ b/packages/spec/src/ai/solution-blueprint.test.ts @@ -89,4 +89,50 @@ describe('SolutionBlueprintSchema', () => { const bp = defineSolutionBlueprint(validBlueprint); expect(bp.summary).toBe('A simple project tracker'); }); + + it('accepts an optional app with explicit nav', () => { + const parsed = SolutionBlueprintSchema.parse({ + ...validBlueprint, + app: { + name: 'project_mgmt', + label: 'Project Management', + icon: 'kanban', + nav: [ + { type: 'object', target: 'project', label: 'Projects' }, + { type: 'object', target: 'task' }, + { type: 'dashboard', target: 'overview' }, + ], + }, + }); + expect(parsed.app?.name).toBe('project_mgmt'); + expect(parsed.app?.nav).toHaveLength(3); + expect(parsed.app?.nav?.[1].type).toBe('object'); // default applied + }); + + it('allows an app with no nav (auto-surfaced at apply time)', () => { + const parsed = SolutionBlueprintSchema.parse({ + ...validBlueprint, + app: { name: 'pm', label: 'PM' }, + }); + expect(parsed.app?.nav).toBeUndefined(); + }); + + it('app is optional', () => { + expect(SolutionBlueprintSchema.parse(validBlueprint).app).toBeUndefined(); + }); + + it('rejects a non-snake_case app name', () => { + expect(() => + SolutionBlueprintSchema.parse({ ...validBlueprint, app: { name: 'MyApp' } }), + ).toThrow(); + }); + + it('rejects an invalid nav item type', () => { + expect(() => + SolutionBlueprintSchema.parse({ + ...validBlueprint, + app: { name: 'pm', nav: [{ type: 'flow', target: 'project' }] }, + }), + ).toThrow(); + }); }); diff --git a/packages/spec/src/ai/solution-blueprint.zod.ts b/packages/spec/src/ai/solution-blueprint.zod.ts index 07daa7ee6..118214c22 100644 --- a/packages/spec/src/ai/solution-blueprint.zod.ts +++ b/packages/spec/src/ai/solution-blueprint.zod.ts @@ -74,6 +74,33 @@ export const BlueprintDashboardSchema = lazySchema(() => z.object({ })); export type BlueprintDashboard = z.infer; +/** + * A proposed navigation item in the blueprint app — points at one of the + * created objects or dashboards. `apply_blueprint` expands it into the full + * `AppSchema` nav item (object → list view, dashboard → dashboard view). + */ +export const BlueprintNavItemSchema = lazySchema(() => z.object({ + type: z.enum(['object', 'dashboard']).default('object').describe('What this nav entry opens'), + target: z.string().regex(SNAKE_CASE).describe('Object or dashboard machine name to surface (snake_case)'), + label: z.string().optional().describe('Nav entry label (defaults to the target label/name)'), + icon: z.string().optional().describe('Lucide icon name for the nav entry'), +})); +export type BlueprintNavItem = z.infer; + +/** + * The navigation shell (the thing end users open in the App Launcher) that + * surfaces the solution. When `nav` is omitted, `apply_blueprint` auto-builds + * one nav entry per created object (then per dashboard). + */ +export const BlueprintAppSchema = lazySchema(() => z.object({ + name: z.string().regex(SNAKE_CASE).describe('App machine name (snake_case)'), + label: z.string().optional().describe('App display label'), + icon: z.string().optional().describe('Lucide icon for the App Launcher'), + nav: z.array(BlueprintNavItemSchema).optional() + .describe('Navigation entries; omit to auto-surface every created object and dashboard'), +})); +export type BlueprintApp = z.infer; + /** * Seed data the agent suggests. Mirrors {@link DatasetSchema.records}. NOTE: * Phase C does NOT auto-apply seed data — there is no runtime-draftable @@ -100,6 +127,8 @@ export const SolutionBlueprintSchema = lazySchema(() => z.object({ objects: z.array(BlueprintObjectSchema).describe('Objects (tables) to create'), views: z.array(BlueprintViewSchema).optional().describe('Views to create'), dashboards: z.array(BlueprintDashboardSchema).optional().describe('Dashboards to create'), + app: BlueprintAppSchema.optional() + .describe('The navigation shell (app) that surfaces the created objects/dashboards to end users'), seedData: z.array(BlueprintSeedSchema).optional() .describe('Suggested seed data (reported, not auto-applied in Phase C)'), }));