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
14 changes: 14 additions & 0 deletions .changeset/adr-0033-blueprint-app-building.md
Original file line number Diff line number Diff line change
@@ -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`.
7 changes: 7 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions packages/objectql/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 12 additions & 2 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>).id;
const whereId = (options.where as Record<string, unknown>).id;
const t = typeof whereId;
if (whereId !== null && (t === 'string' || t === 'number' || t === 'bigint')) {
id = whereId;
}
}

const opCtx: OperationContext = {
Expand Down
13 changes: 10 additions & 3 deletions packages/objectql/src/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
37 changes: 26 additions & 11 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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<string, any> = {};
Expand All @@ -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).',
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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' },
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 46 additions & 1 deletion packages/services/service-ai/src/tools/blueprint-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
},
Expand All @@ -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) {
Expand All @@ -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 ?? [],
Expand Down Expand Up @@ -204,6 +209,42 @@ function dashboardBody(d: NonNullable<SolutionBlueprint['dashboards']>[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<SolutionBlueprint['app']>,
blueprint: SolutionBlueprint,
): Record<string, unknown> {
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;
Expand Down Expand Up @@ -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,
Expand Down
Loading