diff --git a/.changeset/adr-0033-phase-a-draft-gating.md b/.changeset/adr-0033-phase-a-draft-gating.md new file mode 100644 index 000000000..2fb18d7e8 --- /dev/null +++ b/.changeset/adr-0033-phase-a-draft-gating.md @@ -0,0 +1,9 @@ +--- +"@objectstack/service-ai": minor +--- + +feat(service-ai): ADR-0033 Phase A — draft-gate AI metadata authoring + +AI metadata mutations no longer publish straight to the live schema. Every write now routes through the ADR-0027 draft workspace via `protocol.saveMetaItem({ mode:'draft' })` — nothing an agent authors goes live until a human reviews the diff and publishes. The draft is the approval gate (the never-enforced `requiresConfirmation` flag is retired). + +Adds a type-agnostic apply surface — `create_metadata` / `update_metadata` / `describe_metadata` / `list_metadata` — that works for any metadata type (view, dashboard, flow, …), validated against each type's Zod schema with errors fed back to the agent for self-correction. The existing object/field tools become thin draft-writing wrappers. Tool results return `{ status:'drafted', type, name, summary, changedKeys }`. diff --git a/packages/services/service-ai/src/__tests__/metadata-tools.test.ts b/packages/services/service-ai/src/__tests__/metadata-tools.test.ts index 441f2a8e3..59e155579 100644 --- a/packages/services/service-ai/src/__tests__/metadata-tools.test.ts +++ b/packages/services/service-ai/src/__tests__/metadata-tools.test.ts @@ -19,6 +19,10 @@ import { modifyFieldTool } from '../tools/modify-field.tool.js'; import { deleteFieldTool } from '../tools/delete-field.tool.js'; import { listObjectsTool } from '../tools/list-objects.tool.js'; import { describeObjectTool } from '../tools/describe-object.tool.js'; +import { createMetadataTool } from '../tools/create-metadata.tool.js'; +import { updateMetadataTool } from '../tools/update-metadata.tool.js'; +import { describeMetadataTool } from '../tools/describe-metadata.tool.js'; +import { listMetadataTool } from '../tools/list-metadata.tool.js'; // ── Helpers ──────────────────────────────────────────────────────── @@ -47,18 +51,83 @@ function createMockMetadataService( }; } +/** + * Build a mock protocol that mimics ObjectStackProtocolImplementation's + * draft-aware behaviour: `saveMetaItem({ mode:'draft' })` stages a draft; + * `getMetaItem({ state:'draft' })` returns it or throws `no_draft` (404); + * the published value is served when `state` is omitted. This is what + * `applyDraft` writes through (ADR-0033) — nothing reaches a live store. + */ +function createMockProtocol(seedActive: Record = {}) { + const active = new Map(Object.entries(seedActive)); + const drafts = new Map(); + + const saveMetaItem = vi.fn(async (req: any) => { + const key = `${req.type}:${req.name}`; + if (req.mode === 'draft') drafts.set(key, req.item); + else active.set(key, req.item); + return { success: true }; + }); + const getMetaItem = vi.fn(async (req: any) => { + const key = `${req.type}:${req.name}`; + if (req.state === 'draft') { + if (!drafts.has(key)) { + const e: any = new Error(`[no_draft] No pending draft for ${key}.`); + e.code = 'no_draft'; + e.status = 404; + throw e; + } + return { type: req.type, name: req.name, item: drafts.get(key) }; + } + return { type: req.type, name: req.name, item: active.get(key) }; + }); + // Returns the bare array form (the metadata-tools handlers normalize both + // `unknown[]` and `{ items }`, but the declared protocol contract is + // `Promise`). + const getMetaItems = vi.fn(async (req: any) => { + return [...active.entries()] + .filter(([k]) => k.startsWith(`${req.type}:`)) + .map(([, v]) => v); + }); + + const protocol: NonNullable = { + getMetaItems, + getMetaItem, + saveMetaItem, + }; + return { protocol, active, drafts, saveMetaItem, getMetaItem, getMetaItems }; +} + +/** Parse a tool-call result envelope into an object. */ +function parse(result: any): any { + return JSON.parse((result.output as any).value); +} + +const call = (toolName: string, input: Record, id = 't') => ({ + type: 'tool-call' as const, + toolCallId: id, + toolName, + input, +}); + // ═══════════════════════════════════════════════════════════════════ // Metadata Tool Definitions // ═══════════════════════════════════════════════════════════════════ describe('Metadata Tool Definitions', () => { - it('should define exactly 7 tools', () => { - expect(METADATA_TOOL_DEFINITIONS).toHaveLength(7); + it('should define exactly 11 tools', () => { + expect(METADATA_TOOL_DEFINITIONS).toHaveLength(11); }); it('should include all expected tool names', () => { const names = METADATA_TOOL_DEFINITIONS.map(t => t.name); expect(names).toEqual([ + // ADR-0033 type-agnostic apply surface first + 'create_metadata', + 'update_metadata', + 'describe_metadata', + 'list_metadata', + // object/field convenience tools 'create_object', 'add_field', 'modify_field', @@ -83,6 +152,10 @@ describe('Metadata Tool Definitions', () => { describe('Individual Tool Metadata (.tool.ts)', () => { const tools = [ + { tool: createMetadataTool, expectedName: 'create_metadata', expectedLabel: 'Create Metadata' }, + { tool: updateMetadataTool, expectedName: 'update_metadata', expectedLabel: 'Update Metadata' }, + { tool: describeMetadataTool, expectedName: 'describe_metadata', expectedLabel: 'Describe Metadata' }, + { tool: listMetadataTool, expectedName: 'list_metadata', expectedLabel: 'List Metadata' }, { tool: createObjectTool, expectedName: 'create_object', expectedLabel: 'Create Object' }, { tool: addFieldTool, expectedName: 'add_field', expectedLabel: 'Add Field' }, { tool: modifyFieldTool, expectedName: 'modify_field', expectedLabel: 'Modify Field' }, @@ -124,48 +197,48 @@ describe('Individual Tool Metadata (.tool.ts)', () => { }); } - it('should not set requiresConfirmation on create_object (server-side enforcement not yet implemented)', () => { + // ADR-0033: the draft workspace is the approval gate, so no tool relies on + // the (never-enforced) requiresConfirmation flag. + it('should leave requiresConfirmation false on write tools (draft is the gate)', () => { expect(createObjectTool.requiresConfirmation).toBe(false); - }); - - it('should not set requiresConfirmation on delete_field (server-side enforcement not yet implemented)', () => { expect(deleteFieldTool.requiresConfirmation).toBe(false); + expect(addFieldTool.requiresConfirmation).toBe(false); + expect(modifyFieldTool.requiresConfirmation).toBe(false); + expect(createMetadataTool.requiresConfirmation).toBe(false); + expect(updateMetadataTool.requiresConfirmation).toBe(false); }); - it('should not mark read-only tools as requiresConfirmation', () => { + it('should leave requiresConfirmation false on read tools', () => { expect(listObjectsTool.requiresConfirmation).toBe(false); expect(describeObjectTool.requiresConfirmation).toBe(false); - }); - - it('should not mark add_field and modify_field as requiresConfirmation', () => { - expect(addFieldTool.requiresConfirmation).toBe(false); - expect(modifyFieldTool.requiresConfirmation).toBe(false); + expect(listMetadataTool.requiresConfirmation).toBe(false); + expect(describeMetadataTool.requiresConfirmation).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════════ -// registerMetadataTools + Handlers +// registerMetadataTools // ═══════════════════════════════════════════════════════════════════ describe('registerMetadataTools', () => { let registry: ToolRegistry; - let metadataService: IMetadataService; beforeEach(() => { registry = new ToolRegistry(); - metadataService = createMockMetadataService(); - registerMetadataTools(registry, { metadataService }); - }); - - it('should register all 7 tools', () => { - expect(registry.size).toBe(7); - expect(registry.has('create_object')).toBe(true); - expect(registry.has('add_field')).toBe(true); - expect(registry.has('modify_field')).toBe(true); - expect(registry.has('delete_field')).toBe(true); - expect(registry.has('list_objects')).toBe(true); - expect(registry.has('describe_object')).toBe(true); - expect(registry.has('validate_expression')).toBe(true); + const metadataService = createMockMetadataService(); + const { protocol } = createMockProtocol(); + registerMetadataTools(registry, { metadataService, protocol }); + }); + + it('should register all 11 tools', () => { + expect(registry.size).toBe(11); + for (const name of [ + 'create_metadata', 'update_metadata', 'describe_metadata', 'list_metadata', + 'create_object', 'add_field', 'modify_field', 'delete_field', + 'list_objects', 'describe_object', 'validate_expression', + ]) { + expect(registry.has(name)).toBe(true); + } }); }); @@ -174,9 +247,10 @@ describe('registerMetadataTools', () => { // ═══════════════════════════════════════════════════════════════════ describe('registerDataTools + registerMetadataTools — unified list/describe', () => { - it('should register both tool sets on the same registry with shared list_objects and describe_object', () => { + it('should register both tool sets on the same registry', () => { const registry = new ToolRegistry(); const metadataService = createMockMetadataService(); + const { protocol } = createMockProtocol(); const dataEngine = { find: vi.fn(), findOne: vi.fn(), @@ -186,29 +260,103 @@ describe('registerDataTools + registerMetadataTools — unified list/describe', registerDataTools(registry, { dataEngine }); const sizeAfterData = registry.size; - registerMetadataTools(registry, { metadataService }); + registerMetadataTools(registry, { metadataService, protocol }); const sizeAfterBoth = registry.size; // Data tools define: query_records, get_record, aggregate_data (3) - // Metadata tools define: create_object, add_field, modify_field, delete_field, list_objects, describe_object, validate_expression (7) - // Total should be 3 + 7 = 10 + // Metadata tools define 11. expect(sizeAfterData).toBe(3); - expect(sizeAfterBoth).toBe(sizeAfterData + 7); + expect(sizeAfterBoth).toBe(sizeAfterData + 11); - // Unified list/describe should be present (from metadata tools) expect(registry.has('list_objects')).toBe(true); expect(registry.has('describe_object')).toBe(true); - - // Data-only tools should be present expect(registry.has('query_records')).toBe(true); - expect(registry.has('get_record')).toBe(true); - expect(registry.has('aggregate_data')).toBe(true); - - // Metadata-only tools should be present expect(registry.has('create_object')).toBe(true); - expect(registry.has('add_field')).toBe(true); - expect(registry.has('modify_field')).toBe(true); - expect(registry.has('delete_field')).toBe(true); + expect(registry.has('create_metadata')).toBe(true); + }); +}); + +// ═══════════════════════════════════════════════════════════════════ +// Draft gating — the core ADR-0033 invariant +// ═══════════════════════════════════════════════════════════════════ + +describe('ADR-0033 draft gating', () => { + it('write tools NEVER publish (metadataService.register is never called) and stage mode:draft', async () => { + const registry = new ToolRegistry(); + const metadataService = createMockMetadataService(); + const { protocol, saveMetaItem, drafts } = createMockProtocol(); + registerMetadataTools(registry, { metadataService, protocol }); + + const result = await registry.execute(call('create_object', { name: 'project', label: 'Project' }, 'c1')); + const parsed = parse(result); + + expect(parsed.status).toBe('drafted'); + expect(parsed.type).toBe('object'); + expect(parsed.name).toBe('project'); + expect(parsed.summary).toContain('project'); + expect(Array.isArray(parsed.changedKeys)).toBe(true); + + // The live-publish path is dead. + expect(metadataService.register).not.toHaveBeenCalled(); + // The change is staged as a draft. + expect(saveMetaItem).toHaveBeenCalledWith(expect.objectContaining({ + type: 'object', + name: 'project', + mode: 'draft', + })); + expect(drafts.get('object:project')).toEqual(expect.objectContaining({ name: 'project', label: 'Project' })); + }); + + it('refuses to write when no draft-capable protocol is wired (safe by default)', async () => { + const registry = new ToolRegistry(); + const metadataService = createMockMetadataService(); + // No protocol — applyDraft must refuse rather than fall back to publish. + registerMetadataTools(registry, { metadataService }); + + const result = await registry.execute(call('create_object', { name: 'project', label: 'Project' }, 'c1')); + const parsed = parse(result); + + expect(parsed.status).toBeUndefined(); + expect(parsed.error).toMatch(/draft persistence is unavailable/i); + expect(metadataService.register).not.toHaveBeenCalled(); + }); + + it('feeds per-type validation errors back to the model (does not throw)', async () => { + const registry = new ToolRegistry(); + const metadataService = createMockMetadataService(); + const { protocol, saveMetaItem } = createMockProtocol(); + // saveMetaItem rejects with the structured invalid_metadata shape. + (saveMetaItem as any).mockImplementation(async () => { + const e: any = new Error('[invalid_metadata] object/project failed spec validation: label: Required'); + e.code = 'invalid_metadata'; + e.status = 422; + e.issues = [{ path: 'label', message: 'Required', code: 'invalid_type' }]; + throw e; + }); + registerMetadataTools(registry, { metadataService, protocol }); + + const result = await registry.execute(call('create_object', { name: 'project', label: 'Project' }, 'c1')); + const parsed = parse(result); + + expect(parsed.error).toContain('invalid_metadata'); + expect(parsed.code).toBe('invalid_metadata'); + expect(parsed.issues).toEqual([{ path: 'label', message: 'Required', code: 'invalid_type' }]); + // It returned a string error, not a thrown exception — the loop continues. + expect(result.isError).toBeFalsy(); + }); + + it('stacks repeated field ops into a SINGLE object draft (no fork)', async () => { + const registry = new ToolRegistry(); + const metadataService = createMockMetadataService(); + const { protocol, drafts } = createMockProtocol(); + registerMetadataTools(registry, { metadataService, protocol }); + + await registry.execute(call('create_object', { name: 'invoice', label: 'Invoice' }, 's1')); + await registry.execute(call('add_field', { objectName: 'invoice', name: 'amount', type: 'number' }, 's2')); + await registry.execute(call('add_field', { objectName: 'invoice', name: 'status', type: 'text' }, 's3')); + + const draft = drafts.get('object:invoice') as any; + expect(Object.keys(draft.fields)).toEqual(['amount', 'status']); }); }); @@ -219,183 +367,93 @@ describe('registerDataTools + registerMetadataTools — unified list/describe', describe('create_object handler', () => { let registry: ToolRegistry; let metadataService: IMetadataService; + let drafts: Map; beforeEach(() => { registry = new ToolRegistry(); metadataService = createMockMetadataService(); - registerMetadataTools(registry, { metadataService }); + const mock = createMockProtocol(); + drafts = mock.drafts; + registerMetadataTools(registry, { metadataService, protocol: mock.protocol }); }); - it('should create object with name and label', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c1', - toolName: 'create_object', - input: { name: 'project', label: 'Project' }, - }); - - const parsed = JSON.parse((result.output as any).value); + it('should draft an object with name and label', async () => { + const parsed = parse(await registry.execute(call('create_object', { name: 'project', label: 'Project' }))); + expect(parsed.status).toBe('drafted'); expect(parsed.name).toBe('project'); - expect(parsed.label).toBe('Project'); - expect(parsed.fieldCount).toBe(0); - expect(metadataService.register).toHaveBeenCalledWith( - 'object', - 'project', - expect.objectContaining({ name: 'project', label: 'Project' }), - ); - }); - - it('should create object with initial fields', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c2', - toolName: 'create_object', - input: { - name: 'task', - label: 'Task', - fields: [ - { name: 'title', type: 'text', label: 'Title', required: true }, - { name: 'status', type: 'select' }, - ], + expect(drafts.get('object:project')).toEqual(expect.objectContaining({ name: 'project', label: 'Project' })); + }); + + it('should draft an object with initial fields', async () => { + await registry.execute(call('create_object', { + name: 'task', + label: 'Task', + fields: [ + { name: 'title', type: 'text', label: 'Title', required: true }, + { name: 'status', type: 'select' }, + ], + })); + expect(drafts.get('object:task')).toEqual(expect.objectContaining({ + fields: { + title: { type: 'text', label: 'Title', required: true }, + status: { type: 'select' }, }, - }); - - const parsed = JSON.parse((result.output as any).value); - expect(parsed.name).toBe('task'); - expect(parsed.fieldCount).toBe(2); - expect(metadataService.register).toHaveBeenCalledWith( - 'object', - 'task', - expect.objectContaining({ - fields: { - title: { type: 'text', label: 'Title', required: true }, - status: { type: 'select' }, - }, - }), - ); - }); - - it('should create object with enableFeatures', async () => { - await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c3', - toolName: 'create_object', - input: { - name: 'account', - label: 'Account', - enableFeatures: { trackHistory: true, apiEnabled: true }, - }, - }); + })); + }); - expect(metadataService.register).toHaveBeenCalledWith( - 'object', - 'account', - expect.objectContaining({ - enable: { trackHistory: true, apiEnabled: true }, - }), - ); + it('should draft an object with enableFeatures', async () => { + await registry.execute(call('create_object', { + name: 'account', + label: 'Account', + enableFeatures: { trackHistory: true, apiEnabled: true }, + })); + expect(drafts.get('object:account')).toEqual(expect.objectContaining({ + enable: { trackHistory: true, apiEnabled: true }, + })); }); it('should reject invalid snake_case name', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c4', - toolName: 'create_object', - input: { name: 'MyProject', label: 'My Project' }, - }); - - const parsed = JSON.parse((result.output as any).value); + const parsed = parse(await registry.execute(call('create_object', { name: 'MyProject', label: 'My Project' }))); expect(parsed.error).toContain('snake_case'); - expect(metadataService.register).not.toHaveBeenCalled(); + expect(drafts.size).toBe(0); }); - it('should reject duplicate object names', async () => { - // Pre-populate the store - metadataService = createMockMetadataService({ - project: { name: 'project', label: 'Project' }, - }); + it('should reject duplicate object names (published)', async () => { + metadataService = createMockMetadataService({ project: { name: 'project', label: 'Project' } }); registry = new ToolRegistry(); - registerMetadataTools(registry, { metadataService }); + const mock = createMockProtocol(); + registerMetadataTools(registry, { metadataService, protocol: mock.protocol }); - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c5', - toolName: 'create_object', - input: { name: 'project', label: 'Project v2' }, - }); + const parsed = parse(await registry.execute(call('create_object', { name: 'project', label: 'Project v2' }))); + expect(parsed.error).toContain('already exists'); + }); - const parsed = JSON.parse((result.output as any).value); + it('should reject duplicate object names (already drafted)', async () => { + await registry.execute(call('create_object', { name: 'project', label: 'Project' })); + const parsed = parse(await registry.execute(call('create_object', { name: 'project', label: 'Project v2' }))); expect(parsed.error).toContain('already exists'); }); it('should return error when name or label is missing', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c6', - toolName: 'create_object', - input: { name: 'project' }, - }); - - const parsed = JSON.parse((result.output as any).value); + const parsed = parse(await registry.execute(call('create_object', { name: 'project' }))); expect(parsed.error).toContain('required'); }); it('should reject fields with invalid snake_case names', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c7', - toolName: 'create_object', - input: { - name: 'project', - label: 'Project', - fields: [ - { name: 'ValidField', type: 'text' }, - ], - }, - }); - - const parsed = JSON.parse((result.output as any).value); + const parsed = parse(await registry.execute(call('create_object', { + name: 'project', label: 'Project', fields: [{ name: 'ValidField', type: 'text' }], + }))); expect(parsed.error).toContain('snake_case'); - expect(metadataService.register).not.toHaveBeenCalled(); + expect(drafts.size).toBe(0); }); it('should reject fields with duplicate names', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c8', - toolName: 'create_object', - input: { - name: 'project', - label: 'Project', - fields: [ - { name: 'status', type: 'text' }, - { name: 'status', type: 'select' }, - ], - }, - }); - - const parsed = JSON.parse((result.output as any).value); + const parsed = parse(await registry.execute(call('create_object', { + name: 'project', label: 'Project', + fields: [{ name: 'status', type: 'text' }, { name: 'status', type: 'select' }], + }))); expect(parsed.error).toContain('Duplicate'); - expect(metadataService.register).not.toHaveBeenCalled(); - }); - - it('should reject fields with missing name', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c9', - toolName: 'create_object', - input: { - name: 'project', - label: 'Project', - fields: [ - { type: 'text' }, - ], - }, - }); - - const parsed = JSON.parse((result.output as any).value); - expect(parsed.error).toBeTruthy(); - expect(metadataService.register).not.toHaveBeenCalled(); + expect(drafts.size).toBe(0); }); }); @@ -406,174 +464,78 @@ describe('create_object handler', () => { describe('add_field handler', () => { let registry: ToolRegistry; let metadataService: IMetadataService; + let drafts: Map; beforeEach(() => { metadataService = createMockMetadataService({ project: { name: 'project', label: 'Project', fields: {} }, }); registry = new ToolRegistry(); - registerMetadataTools(registry, { metadataService }); - }); - - it('should add a field to an existing object', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c1', - toolName: 'add_field', - input: { objectName: 'project', name: 'due_date', type: 'date', label: 'Due Date' }, - }); - - const parsed = JSON.parse((result.output as any).value); - expect(parsed.objectName).toBe('project'); - expect(parsed.fieldName).toBe('due_date'); - expect(parsed.fieldType).toBe('date'); - expect(metadataService.register).toHaveBeenCalledWith( - 'object', - 'project', - expect.objectContaining({ - fields: expect.objectContaining({ - due_date: expect.objectContaining({ type: 'date', label: 'Due Date' }), - }), + const mock = createMockProtocol(); + drafts = mock.drafts; + registerMetadataTools(registry, { metadataService, protocol: mock.protocol }); + }); + + it('should draft a new field onto an existing (published) object', async () => { + const parsed = parse(await registry.execute(call('add_field', + { objectName: 'project', name: 'due_date', type: 'date', label: 'Due Date' }))); + expect(parsed.status).toBe('drafted'); + expect(parsed.changedKeys).toEqual(['fields.due_date']); + expect(drafts.get('object:project')).toEqual(expect.objectContaining({ + fields: expect.objectContaining({ + due_date: expect.objectContaining({ type: 'date', label: 'Due Date' }), }), - ); - }); - - it('should add a field with options (select type)', async () => { - await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c2', - toolName: 'add_field', - input: { - objectName: 'project', - name: 'priority', - type: 'select', - options: [ - { label: 'Low', value: 'low' }, - { label: 'High', value: 'high' }, - ], - }, - }); - - expect(metadataService.register).toHaveBeenCalledWith( - 'object', - 'project', - expect.objectContaining({ - fields: expect.objectContaining({ - priority: expect.objectContaining({ - type: 'select', - options: [{ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }], - }), + })); + }); + + it('should draft a select field with options', async () => { + await registry.execute(call('add_field', { + objectName: 'project', name: 'priority', type: 'select', + options: [{ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }], + })); + expect(drafts.get('object:project')).toEqual(expect.objectContaining({ + fields: expect.objectContaining({ + priority: expect.objectContaining({ + type: 'select', + options: [{ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }], }), }), - ); + })); }); it('should reject adding field to non-existent object', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c3', - toolName: 'add_field', - input: { objectName: 'nonexistent', name: 'field_a', type: 'text' }, - }); - - const parsed = JSON.parse((result.output as any).value); + const parsed = parse(await registry.execute(call('add_field', + { objectName: 'nonexistent', name: 'field_a', type: 'text' }))); expect(parsed.error).toContain('not found'); }); - it('should reject duplicate field name', async () => { - // Add the field first - await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c4a', - toolName: 'add_field', - input: { objectName: 'project', name: 'status', type: 'text' }, - }); - - // Try to add the same field again - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c4b', - toolName: 'add_field', - input: { objectName: 'project', name: 'status', type: 'select' }, - }); - - const parsed = JSON.parse((result.output as any).value); + it('should reject duplicate field name (against the pending draft)', async () => { + await registry.execute(call('add_field', { objectName: 'project', name: 'status', type: 'text' }, 'a')); + const parsed = parse(await registry.execute(call('add_field', + { objectName: 'project', name: 'status', type: 'select' }, 'b'))); expect(parsed.error).toContain('already exists'); }); it('should reject invalid field name', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c5', - toolName: 'add_field', - input: { objectName: 'project', name: 'MyField', type: 'text' }, - }); - - const parsed = JSON.parse((result.output as any).value); + const parsed = parse(await registry.execute(call('add_field', + { objectName: 'project', name: 'MyField', type: 'text' }))); expect(parsed.error).toContain('snake_case'); }); - it('should reject invalid objectName (not snake_case)', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c6', - toolName: 'add_field', - input: { objectName: 'MyProject', name: 'status', type: 'text' }, - }); - - const parsed = JSON.parse((result.output as any).value); - expect(parsed.error).toContain('snake_case'); - }); - - it('should accept reference as a string (not object)', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c7', - toolName: 'add_field', - input: { objectName: 'project', name: 'account_id', type: 'lookup', reference: 'account' }, - }); - - const parsed = JSON.parse((result.output as any).value); - expect(parsed.fieldName).toBe('account_id'); - expect(metadataService.register).toHaveBeenCalledWith( - 'object', - 'project', - expect.objectContaining({ - fields: expect.objectContaining({ - account_id: expect.objectContaining({ type: 'lookup', reference: 'account' }), - }), + it('should accept reference as a string', async () => { + const parsed = parse(await registry.execute(call('add_field', + { objectName: 'project', name: 'account_id', type: 'lookup', reference: 'account' }))); + expect(parsed.status).toBe('drafted'); + expect(drafts.get('object:project')).toEqual(expect.objectContaining({ + fields: expect.objectContaining({ + account_id: expect.objectContaining({ type: 'lookup', reference: 'account' }), }), - ); + })); }); it('should reject invalid reference (not snake_case)', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c8', - toolName: 'add_field', - input: { objectName: 'project', name: 'account_id', type: 'lookup', reference: 'MyAccount' }, - }); - - const parsed = JSON.parse((result.output as any).value); - expect(parsed.error).toContain('snake_case'); - }); - - it('should reject invalid select option values (not snake_case)', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c9', - toolName: 'add_field', - input: { - objectName: 'project', - name: 'priority', - type: 'select', - options: [ - { label: 'High Priority', value: 'HighPriority' }, - ], - }, - }); - - const parsed = JSON.parse((result.output as any).value); + const parsed = parse(await registry.execute(call('add_field', + { objectName: 'project', name: 'account_id', type: 'lookup', reference: 'MyAccount' }))); expect(parsed.error).toContain('snake_case'); }); }); @@ -584,13 +546,12 @@ describe('add_field handler', () => { describe('modify_field handler', () => { let registry: ToolRegistry; - let metadataService: IMetadataService; + let drafts: Map; beforeEach(() => { - metadataService = createMockMetadataService({ + const metadataService = createMockMetadataService({ project: { - name: 'project', - label: 'Project', + name: 'project', label: 'Project', fields: { status: { type: 'text', label: 'Status', required: false }, budget: { type: 'number', label: 'Budget' }, @@ -598,72 +559,35 @@ describe('modify_field handler', () => { }, }); registry = new ToolRegistry(); - registerMetadataTools(registry, { metadataService }); + const mock = createMockProtocol(); + drafts = mock.drafts; + registerMetadataTools(registry, { metadataService, protocol: mock.protocol }); }); - it('should modify field label', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c1', - toolName: 'modify_field', - input: { - objectName: 'project', - fieldName: 'status', - changes: { label: 'Project Status' }, - }, - }); - - const parsed = JSON.parse((result.output as any).value); - expect(parsed.objectName).toBe('project'); - expect(parsed.fieldName).toBe('status'); - expect(parsed.updatedProperties).toEqual(['label']); - }); - - it('should modify multiple field properties', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c2', - toolName: 'modify_field', - input: { - objectName: 'project', - fieldName: 'status', - changes: { label: 'Project Status', required: true }, - }, - }); + it('should draft a field-label change', async () => { + const parsed = parse(await registry.execute(call('modify_field', + { objectName: 'project', fieldName: 'status', changes: { label: 'Project Status' } }))); + expect(parsed.status).toBe('drafted'); + expect(parsed.changedKeys).toEqual(['fields.status.label']); + expect((drafts.get('object:project') as any).fields.status.label).toBe('Project Status'); + }); - const parsed = JSON.parse((result.output as any).value); - expect(parsed.updatedProperties).toEqual(expect.arrayContaining(['label', 'required'])); + it('should draft multiple property changes', async () => { + const parsed = parse(await registry.execute(call('modify_field', + { objectName: 'project', fieldName: 'status', changes: { label: 'Project Status', required: true } }))); + expect(parsed.changedKeys).toEqual(expect.arrayContaining(['fields.status.label', 'fields.status.required'])); + expect((drafts.get('object:project') as any).fields.status.required).toBe(true); }); it('should return error for non-existent object', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c3', - toolName: 'modify_field', - input: { - objectName: 'nonexistent', - fieldName: 'status', - changes: { label: 'New' }, - }, - }); - - const parsed = JSON.parse((result.output as any).value); + const parsed = parse(await registry.execute(call('modify_field', + { objectName: 'nonexistent', fieldName: 'status', changes: { label: 'New' } }))); expect(parsed.error).toContain('not found'); }); it('should return error for non-existent field', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c4', - toolName: 'modify_field', - input: { - objectName: 'project', - fieldName: 'nonexistent_field', - changes: { label: 'New' }, - }, - }); - - const parsed = JSON.parse((result.output as any).value); + const parsed = parse(await registry.execute(call('modify_field', + { objectName: 'project', fieldName: 'nonexistent_field', changes: { label: 'New' } }))); expect(parsed.error).toContain('not found'); }); }); @@ -674,13 +598,12 @@ describe('modify_field handler', () => { describe('delete_field handler', () => { let registry: ToolRegistry; - let metadataService: IMetadataService; + let drafts: Map; beforeEach(() => { - metadataService = createMockMetadataService({ + const metadataService = createMockMetadataService({ project: { - name: 'project', - label: 'Project', + name: 'project', label: 'Project', fields: { status: { type: 'text', label: 'Status' }, budget: { type: 'number', label: 'Budget' }, @@ -688,285 +611,195 @@ describe('delete_field handler', () => { }, }); registry = new ToolRegistry(); - registerMetadataTools(registry, { metadataService }); + const mock = createMockProtocol(); + drafts = mock.drafts; + registerMetadataTools(registry, { metadataService, protocol: mock.protocol }); }); - it('should delete a field from an object', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c1', - toolName: 'delete_field', - input: { objectName: 'project', fieldName: 'budget' }, - }); - - const parsed = JSON.parse((result.output as any).value); - expect(parsed.objectName).toBe('project'); - expect(parsed.fieldName).toBe('budget'); - expect(parsed.success).toBe(true); - - // Verify the field was removed from the re-registered object - expect(metadataService.register).toHaveBeenCalledWith( - 'object', - 'project', - expect.objectContaining({ - fields: expect.not.objectContaining({ budget: expect.anything() }), - }), - ); + it('should draft the removal of a field', async () => { + const parsed = parse(await registry.execute(call('delete_field', + { objectName: 'project', fieldName: 'budget' }))); + expect(parsed.status).toBe('drafted'); + expect(parsed.changedKeys).toEqual(['fields.budget']); + const draft = drafts.get('object:project') as any; + expect(draft.fields.budget).toBeUndefined(); + expect(draft.fields.status).toBeDefined(); }); it('should return error for non-existent object', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c2', - toolName: 'delete_field', - input: { objectName: 'nonexistent', fieldName: 'status' }, - }); - - const parsed = JSON.parse((result.output as any).value); + const parsed = parse(await registry.execute(call('delete_field', + { objectName: 'nonexistent', fieldName: 'status' }))); expect(parsed.error).toContain('not found'); }); it('should return error for non-existent field', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c3', - toolName: 'delete_field', - input: { objectName: 'project', fieldName: 'nonexistent_field' }, - }); - - const parsed = JSON.parse((result.output as any).value); + const parsed = parse(await registry.execute(call('delete_field', + { objectName: 'project', fieldName: 'nonexistent_field' }))); expect(parsed.error).toContain('not found'); }); }); // ═══════════════════════════════════════════════════════════════════ -// list_metadata_objects handler +// Generic type-agnostic tools (ADR-0033) // ═══════════════════════════════════════════════════════════════════ -describe('list_metadata_objects handler', () => { +describe('create_metadata / update_metadata / describe_metadata / list_metadata', () => { let registry: ToolRegistry; - let metadataService: IMetadataService; + let drafts: Map; + let active: Map; + let saveMetaItem: ReturnType; beforeEach(() => { - metadataService = createMockMetadataService({ - account: { name: 'account', label: 'Account', fields: { name: { type: 'text' } } }, - contact: { name: 'contact', label: 'Contact', fields: { email: { type: 'text' }, phone: { type: 'text' } } }, - }); + const metadataService = createMockMetadataService(); registry = new ToolRegistry(); - registerMetadataTools(registry, { metadataService }); + const mock = createMockProtocol({ + 'view:account_list': { name: 'account_list', label: 'Accounts', object: 'account' }, + 'dashboard:sales': { name: 'sales', label: 'Sales' }, + }); + drafts = mock.drafts; + active = mock.active; + saveMetaItem = mock.saveMetaItem; + registerMetadataTools(registry, { metadataService, protocol: mock.protocol }); }); - it('should list all objects with name, label, and field count', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c1', - toolName: 'list_objects', - input: {}, - }); + it('create_metadata drafts a new view with the name folded in', async () => { + const parsed = parse(await registry.execute(call('create_metadata', + { type: 'view', name: 'contact_list', definition: { label: 'Contacts', object: 'contact' } }))); + expect(parsed.status).toBe('drafted'); + expect(parsed.type).toBe('view'); + expect(saveMetaItem).toHaveBeenCalledWith(expect.objectContaining({ type: 'view', mode: 'draft' })); + expect(drafts.get('view:contact_list')).toEqual({ name: 'contact_list', label: 'Contacts', object: 'contact' }); + }); - const parsed = JSON.parse((result.output as any).value); - expect(parsed.totalCount).toBe(2); - expect(parsed.objects).toHaveLength(2); - expect(parsed.objects[0]).toEqual(expect.objectContaining({ name: 'account', label: 'Account', fieldCount: 1 })); - expect(parsed.objects[1]).toEqual(expect.objectContaining({ name: 'contact', label: 'Contact', fieldCount: 2 })); + it('create_metadata rejects an item that already exists', async () => { + const parsed = parse(await registry.execute(call('create_metadata', + { type: 'view', name: 'account_list', definition: { label: 'X' } }))); + expect(parsed.error).toContain('already exists'); }); - it('should filter objects by name/label substring', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c2', - toolName: 'list_objects', - input: { filter: 'account' }, - }); + it('create_metadata rejects an invalid snake_case name', async () => { + const parsed = parse(await registry.execute(call('create_metadata', + { type: 'view', name: 'BadName', definition: {} }))); + expect(parsed.error).toContain('snake_case'); + }); - const parsed = JSON.parse((result.output as any).value); - expect(parsed.totalCount).toBe(1); - expect(parsed.objects[0].name).toBe('account'); + it('update_metadata merges a patch into the published item and drafts it', async () => { + const parsed = parse(await registry.execute(call('update_metadata', + { type: 'view', name: 'account_list', patch: { label: 'All Accounts' } }))); + expect(parsed.status).toBe('drafted'); + expect(parsed.changedKeys).toEqual(['label']); + expect(drafts.get('view:account_list')).toEqual(expect.objectContaining({ + name: 'account_list', label: 'All Accounts', object: 'account', + })); + // Published value untouched. + expect((active.get('view:account_list') as any).label).toBe('Accounts'); }); - it('should include field summaries when includeFields is true', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c3', - toolName: 'list_objects', - input: { includeFields: true }, - }); + it('update_metadata deletes a key when the patch value is null (RFC 7386)', async () => { + await registry.execute(call('update_metadata', + { type: 'view', name: 'account_list', patch: { object: null } })); + const draft = drafts.get('view:account_list') as any; + expect(draft.object).toBeUndefined(); + expect(draft.label).toBe('Accounts'); + }); - const parsed = JSON.parse((result.output as any).value); - expect(parsed.objects[0].fields).toBeDefined(); - expect(parsed.objects[0].fields).toHaveLength(1); + it('update_metadata returns not-found for an unknown item', async () => { + const parsed = parse(await registry.execute(call('update_metadata', + { type: 'view', name: 'ghost', patch: { label: 'X' } }))); + expect(parsed.error).toContain('not found'); }); - it('should return empty list when no objects exist', async () => { - metadataService = createMockMetadataService({}); - registry = new ToolRegistry(); - registerMetadataTools(registry, { metadataService }); + it('describe_metadata returns the draft body when one exists (draft-first)', async () => { + await registry.execute(call('update_metadata', { type: 'view', name: 'account_list', patch: { label: 'Edited' } })); + const parsed = parse(await registry.execute(call('describe_metadata', { type: 'view', name: 'account_list' }))); + expect(parsed.item.label).toBe('Edited'); + }); - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c4', - toolName: 'list_objects', - input: {}, - }); + it('describe_metadata falls back to the published body when no draft', async () => { + const parsed = parse(await registry.execute(call('describe_metadata', { type: 'dashboard', name: 'sales' }))); + expect(parsed.item.label).toBe('Sales'); + }); + + it('list_metadata enumerates items of a type with an optional filter', async () => { + const all = parse(await registry.execute(call('list_metadata', { type: 'view' }))); + expect(all.totalCount).toBe(1); + expect(all.items[0]).toEqual({ name: 'account_list', label: 'Accounts' }); - const parsed = JSON.parse((result.output as any).value); - expect(parsed.totalCount).toBe(0); - expect(parsed.objects).toHaveLength(0); + const filtered = parse(await registry.execute(call('list_metadata', { type: 'view', filter: 'zzz' }))); + expect(filtered.totalCount).toBe(0); }); }); // ═══════════════════════════════════════════════════════════════════ -// describe_metadata_object handler +// describe_object / list_objects (read side, unchanged behaviour) // ═══════════════════════════════════════════════════════════════════ -describe('describe_metadata_object handler', () => { +describe('list_objects + describe_object handlers', () => { let registry: ToolRegistry; - let metadataService: IMetadataService; beforeEach(() => { - metadataService = createMockMetadataService({ - account: { - name: 'account', - label: 'Account', - fields: { - name: { type: 'text', label: 'Account Name', required: true }, - revenue: { type: 'number', label: 'Revenue' }, - industry: { type: 'select', label: 'Industry', options: ['Tech', 'Finance'] }, - }, - enable: { trackHistory: true, apiEnabled: true }, - }, + const metadataService = createMockMetadataService({ + account: { name: 'account', label: 'Account', fields: { name: { type: 'text' } } }, + contact: { name: 'contact', label: 'Contact', fields: { email: { type: 'text' }, phone: { type: 'text' } } }, }); registry = new ToolRegistry(); + // No protocol getMetaItems seeded for objects → falls back to metadataService.listObjects. registerMetadataTools(registry, { metadataService }); }); - it('should return full schema details with field array', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c1', - toolName: 'describe_object', - input: { objectName: 'account' }, - }); + it('list_objects lists objects with field counts', async () => { + const parsed = parse(await registry.execute(call('list_objects', {}))); + expect(parsed.totalCount).toBe(2); + expect(parsed.objects[0]).toEqual(expect.objectContaining({ name: 'account', fieldCount: 1 })); + }); - const parsed = JSON.parse((result.output as any).value); + it('describe_object returns full schema', async () => { + const parsed = parse(await registry.execute(call('describe_object', { objectName: 'account' }))); expect(parsed.name).toBe('account'); - expect(parsed.label).toBe('Account'); - expect(parsed.fields).toHaveLength(3); - expect(parsed.enableFeatures).toEqual({ trackHistory: true, apiEnabled: true }); - - const nameField = parsed.fields.find((f: any) => f.name === 'name'); - expect(nameField.type).toBe('text'); - expect(nameField.required).toBe(true); - - const industryField = parsed.fields.find((f: any) => f.name === 'industry'); - expect(industryField.options).toEqual(['Tech', 'Finance']); + expect(parsed.fields).toHaveLength(1); }); - it('should return error for unknown object', async () => { - const result = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 'c2', - toolName: 'describe_object', - input: { objectName: 'nonexistent' }, - }); - - const parsed = JSON.parse((result.output as any).value); + it('describe_object errors for an unknown object', async () => { + const parsed = parse(await registry.execute(call('describe_object', { objectName: 'nope' }))); expect(parsed.error).toContain('not found'); }); }); // ═══════════════════════════════════════════════════════════════════ -// End-to-End: full lifecycle +// End-to-End: full draft lifecycle through the generic + object tools // ═══════════════════════════════════════════════════════════════════ -describe('Metadata Tools — full lifecycle', () => { +describe('Metadata Tools — full draft lifecycle', () => { let registry: ToolRegistry; - let metadataService: IMetadataService; + let drafts: Map; beforeEach(() => { - metadataService = createMockMetadataService(); + const metadataService = createMockMetadataService(); registry = new ToolRegistry(); - registerMetadataTools(registry, { metadataService }); + const mock = createMockProtocol(); + drafts = mock.drafts; + registerMetadataTools(registry, { metadataService, protocol: mock.protocol }); }); - it('should support create → add_field → describe → modify → delete lifecycle', async () => { - // 1. Create object - await registry.execute({ - type: 'tool-call' as const, - toolCallId: 's1', - toolName: 'create_object', - input: { name: 'invoice', label: 'Invoice' }, - }); + it('create → add_field → describe_metadata → modify → delete all stage one draft', async () => { + await registry.execute(call('create_object', { name: 'invoice', label: 'Invoice' }, 's1')); + await registry.execute(call('add_field', { objectName: 'invoice', name: 'amount', type: 'number', label: 'Amount' }, 's2')); + await registry.execute(call('add_field', { objectName: 'invoice', name: 'status', type: 'text', label: 'Status' }, 's3')); - // 2. Add fields - await registry.execute({ - type: 'tool-call' as const, - toolCallId: 's2', - toolName: 'add_field', - input: { objectName: 'invoice', name: 'amount', type: 'number', label: 'Amount' }, - }); + // describe_metadata is draft-aware and sees both fields. + const desc = parse(await registry.execute(call('describe_metadata', { type: 'object', name: 'invoice' }, 's4'))); + expect(Object.keys(desc.item.fields)).toEqual(['amount', 'status']); - await registry.execute({ - type: 'tool-call' as const, - toolCallId: 's3', - toolName: 'add_field', - input: { objectName: 'invoice', name: 'status', type: 'text', label: 'Status' }, - }); + await registry.execute(call('modify_field', { + objectName: 'invoice', fieldName: 'status', changes: { type: 'select', label: 'Invoice Status' }, + }, 's5')); - // 3. Describe — should show both fields - const descResult = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 's4', - toolName: 'describe_object', - input: { objectName: 'invoice' }, - }); - const desc = JSON.parse((descResult.output as any).value); - expect(desc.fields).toHaveLength(2); - - // 4. Modify field - await registry.execute({ - type: 'tool-call' as const, - toolCallId: 's5', - toolName: 'modify_field', - input: { - objectName: 'invoice', - fieldName: 'status', - changes: { type: 'select', label: 'Invoice Status' }, - }, - }); + const del = parse(await registry.execute(call('delete_field', { objectName: 'invoice', fieldName: 'amount' }, 's6'))); + expect(del.status).toBe('drafted'); - // 5. Delete field - const delResult = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 's6', - toolName: 'delete_field', - input: { objectName: 'invoice', fieldName: 'amount' }, - }); - const del = JSON.parse((delResult.output as any).value); - expect(del.success).toBe(true); - - // 6. Describe again — should show only 1 field (status, modified) - const descResult2 = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 's7', - toolName: 'describe_object', - input: { objectName: 'invoice' }, - }); - const desc2 = JSON.parse((descResult2.output as any).value); - expect(desc2.fields).toHaveLength(1); - expect(desc2.fields[0].name).toBe('status'); - expect(desc2.fields[0].type).toBe('select'); - expect(desc2.fields[0].label).toBe('Invoice Status'); - - // 7. List objects — should show the invoice - const listResult = await registry.execute({ - type: 'tool-call' as const, - toolCallId: 's8', - toolName: 'list_objects', - input: {}, - }); - const list = JSON.parse((listResult.output as any).value); - expect(list.totalCount).toBe(1); - expect(list.objects[0].name).toBe('invoice'); + const draft = drafts.get('object:invoice') as any; + expect(Object.keys(draft.fields)).toEqual(['status']); + expect(draft.fields.status.type).toBe('select'); + expect(draft.fields.status.label).toBe('Invoice Status'); }); }); diff --git a/packages/services/service-ai/src/agents/metadata-assistant-agent.ts b/packages/services/service-ai/src/agents/metadata-assistant-agent.ts index 18286a9a9..a25cf5ab7 100644 --- a/packages/services/service-ai/src/agents/metadata-assistant-agent.ts +++ b/packages/services/service-ai/src/agents/metadata-assistant-agent.ts @@ -29,6 +29,8 @@ export const METADATA_ASSISTANT_AGENT: Agent = { role: 'Schema Architect', instructions: `You are an expert metadata architect that helps users design and manage their data models through natural language. +You author metadata as DRAFTS: every change you make is staged for the human to review as a diff and publish. You never publish, and you must never claim a change is live or applied — describe it as "drafted for your review". The human's publish is the only path to production. + Always answer in the same language the user is using. If the user's request is ambiguous, ask clarifying questions before proceeding. Detailed tool-usage guidance is supplied by the skills attached to this agent.`, model: { diff --git a/packages/services/service-ai/src/skills/metadata-authoring-skill.ts b/packages/services/service-ai/src/skills/metadata-authoring-skill.ts index 6b21b68ff..2b7d01ed7 100644 --- a/packages/services/service-ai/src/skills/metadata-authoring-skill.ts +++ b/packages/services/service-ai/src/skills/metadata-authoring-skill.ts @@ -21,26 +21,30 @@ export const METADATA_AUTHORING_SKILL: Skill = { description: 'Create and modify ObjectStack metadata — objects, fields, schema changes through natural language.', instructions: `You are an expert metadata architect. When the user asks you to design or change a data model, use these tools. +IMPORTANT — you propose drafts; you never publish. Every change you make with these tools lands in a DRAFT workspace, not the live schema. The human reviews your draft as a diff and publishes it themselves. Never tell the user a change is "live", "applied", or "saved to production" — say it is "drafted for your review". You have no publish tool, and that is by design (the draft is the approval gate). + Capabilities: -- Create new data objects (tables) with fields -- Add fields (columns) to existing objects -- Modify field properties (label, type, required, default value) -- Delete fields from objects -- List all registered metadata objects and their schemas -- Describe the full schema of a specific object +- Create or update any metadata type (object, view, dashboard, flow, report, app) via create_metadata / update_metadata — prefer these for non-object types. +- Create new data objects (tables) with fields, and add / modify / delete fields on objects (object-specific convenience tools). +- Inspect what exists: list_metadata / describe_metadata (any type), list_objects / describe_object (objects). Guidelines: -1. Before creating a new object, use list_objects to check if a similar one already exists. -2. Before modifying or deleting fields, use describe_object to understand the current schema. -3. Always use snake_case for object names and field names (e.g. project_task, due_date). +1. Before creating, use list_objects / list_metadata to check if a similar item already exists. +2. Before updating, modifying, or deleting, use describe_object / describe_metadata to understand the current shape. +3. Always use snake_case for type names and field names (e.g. project_task, due_date). 4. Suggest meaningful field types based on the user's description (e.g. "deadline" → date, "active" → boolean). 5. When creating objects, propose a reasonable set of initial fields based on the entity type. 6. Explain what changes you are about to make before executing them. -7. After making changes, confirm the result by describing the updated schema. -8. For destructive operations (deleting fields), always warn the user about potential data loss. -9. Always answer in the same language the user is using. -10. If the user's request is ambiguous, ask clarifying questions before proceeding.`, +7. After drafting changes, tell the user the change is drafted and ask them to review and publish; summarize what you staged (the tools return a { status: 'drafted', summary, changedKeys } envelope). +8. For destructive operations (deleting fields), warn the user about potential data loss on publish. +9. If a tool returns an error with validation issues, fix your input and try again — do not surface the raw error to the user as a failure if you can self-correct. +10. Always answer in the same language the user is using. +11. If the user's request is ambiguous, ask clarifying questions before proceeding.`, tools: [ + 'create_metadata', + 'update_metadata', + 'describe_metadata', + 'list_metadata', 'create_object', 'add_field', 'modify_field', diff --git a/packages/services/service-ai/src/tools/create-metadata.tool.ts b/packages/services/service-ai/src/tools/create-metadata.tool.ts new file mode 100644 index 000000000..33d58fbb5 --- /dev/null +++ b/packages/services/service-ai/src/tools/create-metadata.tool.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineTool } from '@objectstack/spec/ai'; + +/** + * create_metadata — AI Tool Metadata (ADR-0033) + * + * Type-agnostic creation of ANY metadata item (object, view, dashboard, flow, + * …). The new item is staged as a **draft** — it never goes live until a human + * reviews and publishes. The payload is validated against the type's canonical + * Zod schema (ADR-0005); invalid output is rejected with a fixable error. + * + * For new data objects + their fields the dedicated `create_object` / + * `add_field` tools offer a friendlier shape, but they ultimately stage the + * same way. + */ +export const createMetadataTool = defineTool({ + name: 'create_metadata', + label: 'Create Metadata', + description: + 'Create a new metadata item of ANY type (view, dashboard, flow, report, app, object, …) and stage it as a draft for human review. ' + + 'Use for non-object types, or any type, when no dedicated tool fits. The change is NOT published — a human must publish it.', + category: 'data', + builtIn: true, + parameters: { + type: 'object', + properties: { + type: { + type: 'string', + description: 'Metadata type (singular), e.g. "object", "view", "dashboard", "flow", "report", "app".', + }, + name: { + type: 'string', + description: 'Machine name for the item (snake_case, e.g. account_kanban).', + }, + definition: { + type: 'object', + description: + 'The full metadata definition body for this type, conforming to the type\'s schema. The "name" field is set automatically from the name argument.', + }, + packageId: { + type: 'string', + description: 'Package ID that will own this item. If omitted, uses the active package from conversation context.', + }, + }, + required: ['type', 'name', 'definition'], + additionalProperties: false, + }, +}); diff --git a/packages/services/service-ai/src/tools/create-object.tool.ts b/packages/services/service-ai/src/tools/create-object.tool.ts index 00c4e601c..959bd8fa5 100644 --- a/packages/services/service-ai/src/tools/create-object.tool.ts +++ b/packages/services/service-ai/src/tools/create-object.tool.ts @@ -7,21 +7,20 @@ import { defineTool } from '@objectstack/spec/ai'; * * Creates a new data object (table) with schema validation. * Validates snake_case naming for object and initial fields, - * checks for duplicates, and registers the object definition. + * checks for duplicates, and stages the object as a draft. + * + * ADR-0033: this never publishes — the object lands in the draft workspace + * for a human to review and publish. The draft IS the approval gate, which is + * why no `requiresConfirmation` flag is needed (it was never enforced anyway). */ export const createObjectTool = defineTool({ name: 'create_object', label: 'Create Object', description: - 'Creates a new data object (table) with the specified name, label, and optional field definitions. ' + - 'Use this when the user wants to create a new entity, table, or data model.', + 'Creates a new data object (table) with the specified name, label, and optional field definitions, staged as a draft for human review. ' + + 'Use this when the user wants to create a new entity, table, or data model. The change is NOT published.', category: 'data', builtIn: true, - // NOTE: requiresConfirmation is intentionally false (default) because the - // server-side tool-call loop in AIService.chatWithTools/streamChatWithTools - // executes tool calls immediately without checking this flag. The flag - // should only be set once server-side approval gating is implemented to - // avoid giving users a false sense of safety. parameters: { type: 'object', properties: { diff --git a/packages/services/service-ai/src/tools/delete-field.tool.ts b/packages/services/service-ai/src/tools/delete-field.tool.ts index 8ca0f511a..fb4f73c3a 100644 --- a/packages/services/service-ai/src/tools/delete-field.tool.ts +++ b/packages/services/service-ai/src/tools/delete-field.tool.ts @@ -5,21 +5,19 @@ import { defineTool } from '@objectstack/spec/ai'; /** * delete_field — AI Tool Metadata * - * Removes a field (column) from an existing data object. - * This is a destructive operation. + * Removes a field (column) from an existing data object. This is a destructive + * operation, but ADR-0033 stages it as a draft — the field is only actually + * dropped when a human reviews and publishes (which re-runs the destructive + * data-loss check). The draft IS the approval gate. */ export const deleteFieldTool = defineTool({ name: 'delete_field', label: 'Delete Field', description: - 'Removes a field (column) from an existing data object. This is a destructive operation. ' + + 'Removes a field (column) from an existing data object, staged as a draft for human review. This is a destructive operation; it is NOT published until a human publishes the draft. ' + 'Use this when the user explicitly wants to remove an attribute or column from a table.', category: 'data', builtIn: true, - // NOTE: requiresConfirmation is intentionally false (default) because the - // server-side tool-call loop in AIService.chatWithTools/streamChatWithTools - // executes tool calls immediately without checking this flag. The flag - // should only be set once server-side approval gating is implemented. parameters: { type: 'object', properties: { diff --git a/packages/services/service-ai/src/tools/describe-metadata.tool.ts b/packages/services/service-ai/src/tools/describe-metadata.tool.ts new file mode 100644 index 000000000..c7aa84a39 --- /dev/null +++ b/packages/services/service-ai/src/tools/describe-metadata.tool.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineTool } from '@objectstack/spec/ai'; + +/** + * describe_metadata — AI Tool Metadata (ADR-0033) + * + * Type-agnostic read of a single metadata item's full body. Returns the pending + * draft if one exists, else the published value — so the agent edits against + * what it (or the user) most recently staged. Use before `update_metadata` to + * see the current shape. + */ +export const describeMetadataTool = defineTool({ + name: 'describe_metadata', + label: 'Describe Metadata', + description: + 'Return the full definition of a metadata item of ANY type (draft-first: shows the pending draft if one exists, else the published value). ' + + 'Use to inspect an item before updating it.', + category: 'data', + builtIn: true, + parameters: { + type: 'object', + properties: { + type: { + type: 'string', + description: 'Metadata type (singular), e.g. "object", "view", "dashboard", "flow".', + }, + name: { + type: 'string', + description: 'Machine name of the item (snake_case).', + }, + }, + required: ['type', 'name'], + additionalProperties: false, + }, +}); diff --git a/packages/services/service-ai/src/tools/index.ts b/packages/services/service-ai/src/tools/index.ts index 00e6d2318..2fcef0e1f 100644 --- a/packages/services/service-ai/src/tools/index.ts +++ b/packages/services/service-ai/src/tools/index.ts @@ -20,3 +20,7 @@ export { deleteFieldTool } from './delete-field.tool.js'; export { listObjectsTool } from './list-objects.tool.js'; export { describeObjectTool } from './describe-object.tool.js'; export { validateExpressionTool } from './validate-expression.tool.js'; +export { createMetadataTool } from './create-metadata.tool.js'; +export { updateMetadataTool } from './update-metadata.tool.js'; +export { describeMetadataTool } from './describe-metadata.tool.js'; +export { listMetadataTool } from './list-metadata.tool.js'; diff --git a/packages/services/service-ai/src/tools/list-metadata.tool.ts b/packages/services/service-ai/src/tools/list-metadata.tool.ts new file mode 100644 index 000000000..a430366f9 --- /dev/null +++ b/packages/services/service-ai/src/tools/list-metadata.tool.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineTool } from '@objectstack/spec/ai'; + +/** + * list_metadata — AI Tool Metadata (ADR-0033) + * + * Type-agnostic enumeration of all items of a metadata type (name + label), + * backed by the same source as `GET /api/v1/meta/:type`. Use to discover what + * exists before creating or to find an item to update. + */ +export const listMetadataTool = defineTool({ + name: 'list_metadata', + label: 'List Metadata', + description: + 'List all metadata items of a given type (name and label), with an optional name/label substring filter. ' + + 'Use to discover existing views, dashboards, flows, etc. before creating or updating one.', + category: 'data', + builtIn: true, + parameters: { + type: 'object', + properties: { + type: { + type: 'string', + description: 'Metadata type (singular), e.g. "object", "view", "dashboard", "flow", "report", "app".', + }, + filter: { + type: 'string', + description: 'Optional case-insensitive substring to filter items by name or label.', + }, + }, + required: ['type'], + additionalProperties: false, + }, +}); diff --git a/packages/services/service-ai/src/tools/metadata-tools.ts b/packages/services/service-ai/src/tools/metadata-tools.ts index 1739f5b7b..3874bf836 100644 --- a/packages/services/service-ai/src/tools/metadata-tools.ts +++ b/packages/services/service-ai/src/tools/metadata-tools.ts @@ -16,6 +16,10 @@ export { deleteFieldTool } from './delete-field.tool.js'; export { listObjectsTool } from './list-objects.tool.js'; export { describeObjectTool } from './describe-object.tool.js'; export { validateExpressionTool } from './validate-expression.tool.js'; +export { createMetadataTool } from './create-metadata.tool.js'; +export { updateMetadataTool } from './update-metadata.tool.js'; +export { describeMetadataTool } from './describe-metadata.tool.js'; +export { listMetadataTool } from './list-metadata.tool.js'; import { createObjectTool } from './create-object.tool.js'; import { addFieldTool } from './add-field.tool.js'; @@ -24,10 +28,20 @@ import { deleteFieldTool } from './delete-field.tool.js'; import { listObjectsTool } from './list-objects.tool.js'; import { describeObjectTool } from './describe-object.tool.js'; import { validateExpressionTool } from './validate-expression.tool.js'; +import { createMetadataTool } from './create-metadata.tool.js'; +import { updateMetadataTool } from './update-metadata.tool.js'; +import { describeMetadataTool } from './describe-metadata.tool.js'; +import { listMetadataTool } from './list-metadata.tool.js'; import { validateExpression, introspectScope, type FieldRole } from '@objectstack/formula'; /** All built-in metadata management tool definitions (Tool metadata). */ export const METADATA_TOOL_DEFINITIONS: Tool[] = [ + // ADR-0033 type-agnostic apply surface (preferred for any metadata type) + createMetadataTool, + updateMetadataTool, + describeMetadataTool, + listMetadataTool, + // Object/field convenience tools (now draft-gated thin wrappers) createObjectTool, addFieldTool, modifyFieldTool, @@ -187,9 +201,183 @@ export interface MetadataToolContext { */ protocol?: { getMetaItems(request: { type: string; packageId?: string; organizationId?: string }): Promise; + /** + * Read a single metadata item. With `state:'draft'` returns the pending + * draft row and throws `no_draft` (404) when none exists — it does NOT + * fall through to the published value, so callers must catch and fall + * back. The runtime object backing `ctx.protocol` is the full + * ObjectStackProtocolImplementation, which provides this. + */ + getMetaItem?(request: { + type: string; + name: string; + packageId?: string; + organizationId?: string; + state?: 'active' | 'draft'; + }): Promise; + /** + * Save a metadata item. ADR-0033: AI writes ALWAYS pass `mode:'draft'` so + * nothing the agent authors goes live until a human publishes. Validates + * against the per-type Zod schema (ADR-0005) and throws `invalid_metadata` + * / `destructive_change` with structured `issues` on rejection. + */ + saveMetaItem?(request: { + type: string; + name: string; + item?: unknown; + organizationId?: string; + parentVersion?: string | null; + actor?: string; + force?: boolean; + mode?: 'draft' | 'publish'; + packageId?: string | null; + }): Promise; }; } +// --------------------------------------------------------------------------- +// ADR-0033 — draft-gated write core +// +// Every metadata mutation an AI makes routes through `applyDraft`, which +// writes `mode:'draft'` via the protocol's `saveMetaItem`. The draft IS the +// approval gate: nothing is live until a human publishes. We never call +// `metadataService.register(...)` from a tool handler — that path publishes +// straight to the live schema (the exact hazard ADR-0033 closes). +// --------------------------------------------------------------------------- + +interface ApplyDraftInput { + /** Metadata type (singular, e.g. 'object', 'view'). */ + type: string; + /** Item name (snake_case). */ + name: string; + /** The full item body to stage as a draft. */ + item: unknown; + /** Acting user id (from the tool execution context) for provenance/audit. */ + actor?: string; + /** Owning package id, when resolved. */ + packageId?: string | null; + /** + * Bypass the destructive-data 409. Defaults to `true` for draft writes: a + * draft never applies DDL or drops data — the human's *publish* is the + * moment data is touched, and it re-runs its own checks. Blocking staging on + * the publish-time guard would prevent the agent from proposing schema + * changes for review, which is the whole point. + */ + force?: boolean; + /** Human-readable one-line summary for the result envelope. */ + summary: string; + /** Paths that changed, for the review/diff surface. */ + changedKeys: string[]; +} + +/** + * Stage `item` as a draft and return the ADR-0033 result envelope + * `{ status:'drafted', type, name, summary, changedKeys }`. On validation / + * destructive-change rejection, returns the structured error as a string so + * the tool-call loop feeds it back to the model for self-correction (same + * validate-by-default spine as ADR-0032 / the `validate_expression` tool) — + * it never throws. + */ +async function applyDraft(ctx: MetadataToolContext, input: ApplyDraftInput): Promise { + if (!ctx.protocol?.saveMetaItem) { + // Safe by default: with no draft-capable protocol wired we refuse rather + // than fall back to an immediate-publish path. (In a real runtime the + // ObjectQL protocol service is always present.) + return JSON.stringify({ + error: + 'Draft persistence is unavailable: no protocol service is wired, so metadata changes cannot be staged for review.', + }); + } + try { + await ctx.protocol.saveMetaItem({ + type: input.type, + name: input.name, + item: input.item, + mode: 'draft', + force: input.force ?? true, + ...(input.actor ? { actor: input.actor } : {}), + ...(input.packageId !== undefined && input.packageId !== null + ? { packageId: input.packageId } + : {}), + }); + return JSON.stringify({ + status: 'drafted', + type: input.type, + name: input.name, + summary: input.summary, + changedKeys: input.changedKeys, + }); + } catch (err) { + const e = err as { message?: string; code?: string; issues?: unknown }; + return JSON.stringify({ + error: e.message ?? String(err), + ...(e.code ? { code: e.code } : {}), + ...(e.issues ? { issues: e.issues } : {}), + }); + } +} + +/** + * Read the current body of a metadata item, **draft-first**: returns the + * pending draft if one exists, else the live/published value, else undefined. + * This is what lets successive field ops (`add_field`, `modify_field`, …) + * stack into the *same* single draft rather than each starting from the last + * published version (ADR-0033 §3: "read-modify-write the single object draft, + * they do not fork drafts"). + */ +async function readDraftFirst( + ctx: MetadataToolContext, + type: string, + name: string, +): Promise { + if (ctx.protocol?.getMetaItem) { + // Draft row first. `getMetaItem({state:'draft'})` throws `no_draft` (404) + // when none exists — catch and fall through to the published value. + try { + const draft = await ctx.protocol.getMetaItem({ type, name, state: 'draft' }); + const draftItem = (draft as { item?: unknown } | undefined)?.item; + if (draftItem) return draftItem; + } catch { + /* no draft — fall through */ + } + try { + const active = await ctx.protocol.getMetaItem({ type, name }); + const activeItem = (active as { item?: unknown } | undefined)?.item; + if (activeItem) return activeItem; + } catch { + /* not found via protocol — fall through to the metadata service */ + } + } + if (type === 'object') { + return ctx.metadataService.getObject(name); + } + return ctx.metadataService.get(type, name); +} + +/** + * RFC 7386 JSON Merge Patch: recursively merge `patch` into `target`; a `null` + * value deletes that key; a non-object `patch` replaces wholesale. Used by + * `update_metadata` so the agent can express a partial change without + * restating the whole item. + */ +function mergePatch(target: unknown, patch: unknown): unknown { + if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) { + return patch; + } + const base: Record = + target && typeof target === 'object' && !Array.isArray(target) + ? { ...(target as Record) } + : {}; + for (const [key, value] of Object.entries(patch as Record)) { + if (value === null) { + delete base[key]; + } else { + base[key] = mergePatch(base[key], value); + } + } + return base; +} + // --------------------------------------------------------------------------- // Handler Factories // --------------------------------------------------------------------------- @@ -231,7 +419,7 @@ function createValidateExpressionHandler(ctx: MetadataToolContext): ToolHandler } function createCreateObjectHandler(ctx: MetadataToolContext): ToolHandler { - return async (args) => { + return async (args, exec) => { const { name, label, packageId: explicitPackageId, fields, enableFeatures } = args as { name: string; label: string; @@ -256,8 +444,9 @@ function createCreateObjectHandler(ctx: MetadataToolContext): ToolHandler { return JSON.stringify({ error: `Invalid object name "${name}". Must be snake_case.` }); } - // Check if the object already exists - const existing = await ctx.metadataService.getObject(name); + // Check if the object already exists (draft-first — an AI-drafted object + // not yet published still counts as existing). + const existing = await readDraftFirst(ctx, 'object', name); if (existing) { return JSON.stringify({ error: `Object "${name}" already exists` }); } @@ -293,19 +482,22 @@ function createCreateObjectHandler(ctx: MetadataToolContext): ToolHandler { ...(enableFeatures ? { enable: enableFeatures } : {}), }; - await ctx.metadataService.register('object', name, objectDef); - - return JSON.stringify({ + return applyDraft(ctx, { + type: 'object', name, - label, - ...(packageId ? { packageId } : {}), - fieldCount: Object.keys(fieldMap).length, + item: objectDef, + actor: exec?.actor?.id, + packageId, + summary: `Drafted new object "${name}" (${label})${ + Object.keys(fieldMap).length ? ` with ${Object.keys(fieldMap).length} field(s)` : '' + }`, + changedKeys: Object.keys(objectDef), }); }; } function createAddFieldHandler(ctx: MetadataToolContext): ToolHandler { - return async (args) => { + return async (args, exec) => { const { objectName, name, label, type, required, defaultValue, options, reference, packageId: explicitPackageId } = args as { objectName: string; name: string; @@ -350,8 +542,9 @@ function createAddFieldHandler(ctx: MetadataToolContext): ToolHandler { } } - // Verify the target object exists - const objectDef = await ctx.metadataService.getObject(objectName); + // Verify the target object exists (draft-first so repeated field ops stack + // into the same single draft rather than forking from the published copy). + const objectDef = await readDraftFirst(ctx, 'object', objectName); if (!objectDef) { return JSON.stringify({ error: `Object "${objectName}" not found` }); } @@ -372,24 +565,22 @@ function createAddFieldHandler(ctx: MetadataToolContext): ToolHandler { ...(reference ? { reference } : {}), }; - // Merge the new field into the existing object definition and re-register + // Merge the new field into the existing object definition and stage it. const updatedFields = { ...(def.fields ?? {}), [name]: fieldDef }; - await ctx.metadataService.register('object', objectName, { - ...def, - fields: updatedFields, - }); - - return JSON.stringify({ - objectName, - fieldName: name, - fieldType: type, + return applyDraft(ctx, { + type: 'object', + name: objectName, + item: { ...def, fields: updatedFields }, + actor: exec?.actor?.id, packageId: resolved.packageId, + summary: `Drafted field "${name}" (${type}) on object "${objectName}"`, + changedKeys: [`fields.${name}`], }); }; } function createModifyFieldHandler(ctx: MetadataToolContext): ToolHandler { - return async (args) => { + return async (args, exec) => { const { objectName, fieldName, changes, packageId: explicitPackageId } = args as { objectName: string; fieldName: string; @@ -415,8 +606,8 @@ function createModifyFieldHandler(ctx: MetadataToolContext): ToolHandler { return JSON.stringify({ error: `Invalid field name "${fieldName}". Must be snake_case.` }); } - // Verify the target object exists - const objectDef = await ctx.metadataService.getObject(objectName); + // Verify the target object exists (draft-first — see add_field). + const objectDef = await readDraftFirst(ctx, 'object', objectName); if (!objectDef) { return JSON.stringify({ error: `Object "${objectName}" not found` }); } @@ -431,22 +622,20 @@ function createModifyFieldHandler(ctx: MetadataToolContext): ToolHandler { const updatedField = { ...existingField, ...changes }; const updatedFields = { ...def.fields, [fieldName]: updatedField }; - await ctx.metadataService.register('object', objectName, { - ...def, - fields: updatedFields, - }); - - return JSON.stringify({ - objectName, - fieldName, - updatedProperties: Object.keys(changes), + return applyDraft(ctx, { + type: 'object', + name: objectName, + item: { ...def, fields: updatedFields }, + actor: exec?.actor?.id, packageId: resolved.packageId, + summary: `Drafted change to field "${fieldName}" on object "${objectName}" (${Object.keys(changes).join(', ')})`, + changedKeys: Object.keys(changes).map((k) => `fields.${fieldName}.${k}`), }); }; } function createDeleteFieldHandler(ctx: MetadataToolContext): ToolHandler { - return async (args) => { + return async (args, exec) => { const { objectName, fieldName, packageId: explicitPackageId } = args as { objectName: string; fieldName: string; @@ -471,8 +660,8 @@ function createDeleteFieldHandler(ctx: MetadataToolContext): ToolHandler { return JSON.stringify({ error: `Invalid field name "${fieldName}". Must be snake_case.` }); } - // Verify the target object exists - const objectDef = await ctx.metadataService.getObject(objectName); + // Verify the target object exists (draft-first — see add_field). + const objectDef = await readDraftFirst(ctx, 'object', objectName); if (!objectDef) { return JSON.stringify({ error: `Object "${objectName}" not found` }); } @@ -482,18 +671,18 @@ function createDeleteFieldHandler(ctx: MetadataToolContext): ToolHandler { return JSON.stringify({ error: `Field "${fieldName}" not found on object "${objectName}"` }); } - // Remove the field and re-register + // Remove the field and stage the change. Dropping a field is destructive, + // but it only lands in the draft here — the human's publish is the gate + // that actually touches data (and re-runs the destructive check). const { [fieldName]: _removed, ...remainingFields } = def.fields; - await ctx.metadataService.register('object', objectName, { - ...def, - fields: remainingFields, - }); - - return JSON.stringify({ - objectName, - fieldName, - success: true, + return applyDraft(ctx, { + type: 'object', + name: objectName, + item: { ...def, fields: remainingFields }, + actor: exec?.actor?.id, packageId: resolved.packageId, + summary: `Drafted removal of field "${fieldName}" from object "${objectName}"`, + changedKeys: [`fields.${fieldName}`], }); }; } @@ -618,6 +807,160 @@ function createDescribeObjectHandler(ctx: MetadataToolContext): ToolHandler { }; } +// --------------------------------------------------------------------------- +// ADR-0033 — type-agnostic apply surface +// +// A small generic surface (`create_metadata` / `update_metadata` / +// `describe_metadata` / `list_metadata`) that works for ANY metadata type — +// view, dashboard, flow, … — not just objects. Coverage of new types grows by +// teaching the agent these tools, not by adding bespoke per-type write tools. +// Every write goes through `applyDraft` (draft-gated, per-type Zod validated). +// --------------------------------------------------------------------------- + +function createCreateMetadataHandler(ctx: MetadataToolContext): ToolHandler { + return async (args, exec) => { + const { type, name, definition, packageId: explicitPackageId } = args as { + type: string; + name: string; + definition: unknown; + packageId?: string; + }; + + if (!type || !name || definition === undefined || definition === null) { + return JSON.stringify({ error: '"type", "name", and "definition" are required' }); + } + if (!isSnakeCase(name)) { + return JSON.stringify({ error: `Invalid name "${name}". Must be snake_case.` }); + } + + // Reject re-creating an item that already exists (draft or published). + const existing = await readDraftFirst(ctx, type, name); + if (existing) { + return JSON.stringify({ + error: `${type} "${name}" already exists — use update_metadata to change it.`, + }); + } + + // Ensure the canonical `name` is present on the body (most type schemas + // require it); the explicit `name` arg is authoritative. + const item = + definition && typeof definition === 'object' && !Array.isArray(definition) + ? { name, ...(definition as Record) } + : definition; + const changedKeys = + item && typeof item === 'object' && !Array.isArray(item) + ? Object.keys(item as Record) + : []; + + return applyDraft(ctx, { + type, + name, + item, + actor: exec?.actor?.id, + packageId: explicitPackageId ?? null, + summary: `Drafted new ${type} "${name}"`, + changedKeys, + }); + }; +} + +function createUpdateMetadataHandler(ctx: MetadataToolContext): ToolHandler { + return async (args, exec) => { + const { type, name, patch, packageId: explicitPackageId } = args as { + type: string; + name: string; + patch: unknown; + packageId?: string; + }; + + if (!type || !name || patch === undefined) { + return JSON.stringify({ error: '"type", "name", and "patch" are required' }); + } + + // Read-modify-write the SINGLE draft (never fork): start from the pending + // draft if any, else the published value. + const current = await readDraftFirst(ctx, type, name); + if (!current) { + return JSON.stringify({ + error: `${type} "${name}" not found — use create_metadata to create it first.`, + }); + } + + const merged = mergePatch(current, patch); + const changedKeys = + patch && typeof patch === 'object' && !Array.isArray(patch) + ? Object.keys(patch as Record) + : []; + + return applyDraft(ctx, { + type, + name, + item: merged, + actor: exec?.actor?.id, + packageId: explicitPackageId ?? null, + summary: `Drafted update to ${type} "${name}"`, + changedKeys, + }); + }; +} + +function createDescribeMetadataHandler(ctx: MetadataToolContext): ToolHandler { + return async (args) => { + const { type, name } = args as { type: string; name: string }; + if (!type || !name) { + return JSON.stringify({ error: '"type" and "name" are required' }); + } + const item = await readDraftFirst(ctx, type, name); + if (!item) { + return JSON.stringify({ error: `${type} "${name}" not found` }); + } + return JSON.stringify({ type, name, item }); + }; +} + +function createListMetadataHandler(ctx: MetadataToolContext): ToolHandler { + return async (args) => { + const { type, filter } = args as { type: string; filter?: string }; + if (!type) { + return JSON.stringify({ error: '"type" is required' }); + } + + // Prefer the protocol enumerator (same source as GET /api/v1/meta/:type); + // fall back to the metadata service registry. + let items: unknown[] = []; + if (ctx.protocol?.getMetaItems) { + try { + const res = await ctx.protocol.getMetaItems({ type }); + items = Array.isArray(res) + ? res + : res && typeof res === 'object' && Array.isArray((res as { items?: unknown[] }).items) + ? (res as { items: unknown[] }).items + : []; + } catch { + items = await ctx.metadataService.list(type); + } + } else { + items = await ctx.metadataService.list(type); + } + if (!Array.isArray(items)) items = []; + + let summaries = (items as Array>).map((it) => ({ + name: it?.name, + label: it?.label ?? it?.name, + })); + if (filter) { + const lower = filter.toLowerCase(); + summaries = summaries.filter( + (s) => + String(s.name ?? '').toLowerCase().includes(lower) || + String(s.label ?? '').toLowerCase().includes(lower), + ); + } + + return JSON.stringify({ type, items: summaries, totalCount: summaries.length }); + }; +} + // --------------------------------------------------------------------------- // Public Registration Helper // --------------------------------------------------------------------------- @@ -639,6 +982,12 @@ export function registerMetadataTools( registry: ToolRegistry, context: MetadataToolContext, ): void { + // ADR-0033 type-agnostic apply surface. + registry.register(createMetadataTool, createCreateMetadataHandler(context)); + registry.register(updateMetadataTool, createUpdateMetadataHandler(context)); + registry.register(describeMetadataTool, createDescribeMetadataHandler(context)); + registry.register(listMetadataTool, createListMetadataHandler(context)); + // Object/field convenience tools (draft-gated thin wrappers). registry.register(createObjectTool, createCreateObjectHandler(context)); registry.register(addFieldTool, createAddFieldHandler(context)); registry.register(modifyFieldTool, createModifyFieldHandler(context)); diff --git a/packages/services/service-ai/src/tools/update-metadata.tool.ts b/packages/services/service-ai/src/tools/update-metadata.tool.ts new file mode 100644 index 000000000..53f13c0a6 --- /dev/null +++ b/packages/services/service-ai/src/tools/update-metadata.tool.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineTool } from '@objectstack/spec/ai'; + +/** + * update_metadata — AI Tool Metadata (ADR-0033) + * + * Type-agnostic update of ANY existing metadata item. Applies an RFC 7386 JSON + * Merge Patch to the item's current draft (or, if none, the published value) + * and re-stages the result as a **draft** — read-modify-write of the single + * draft, never a fork. A `null` value in the patch deletes that key. The merged + * body is validated against the type's Zod schema before it enters the draft. + * The change is NOT published — a human reviews the diff and publishes. + */ +export const updateMetadataTool = defineTool({ + name: 'update_metadata', + label: 'Update Metadata', + description: + 'Apply a partial change (JSON merge patch) to an existing metadata item of ANY type and stage it as a draft for human review. ' + + 'Set a key to null to remove it. The change is NOT published — a human must publish it. Use describe_metadata first to see the current body.', + category: 'data', + builtIn: true, + parameters: { + type: 'object', + properties: { + type: { + type: 'string', + description: 'Metadata type (singular), e.g. "object", "view", "dashboard", "flow".', + }, + name: { + type: 'string', + description: 'Machine name of the existing item (snake_case).', + }, + patch: { + type: 'object', + description: + 'Partial change to merge into the item. Only the keys you include are changed; nested objects merge recursively; a null value deletes that key. Example: { "label": "New Label", "fields": { "old_field": null } }.', + }, + packageId: { + type: 'string', + description: 'Package ID owning this item. If omitted, uses the active package from conversation context.', + }, + }, + required: ['type', 'name', 'patch'], + additionalProperties: false, + }, +});